From 35fde503acfe371ccc42a879a95412e0fe75e566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jardin=20=F0=9F=8C=B8?= <84980243+jardinsys@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:32:28 -0500 Subject: [PATCH 1/6] Create gui.py --- scripts/python/gui.py | 379 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 scripts/python/gui.py diff --git a/scripts/python/gui.py b/scripts/python/gui.py new file mode 100644 index 0000000..a65f7a9 --- /dev/null +++ b/scripts/python/gui.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +"""PluralBridge GUI — self-contained tkinter exporter. + +Uses pluralbridge modules directly (no subprocess) so it works both as a +standalone script and when bundled into a single .exe via PyInstaller. +""" + +from __future__ import annotations + +import io +import json +import sys +import threading +import tkinter as tk +import urllib.request +from tkinter import filedialog, messagebox, scrolledtext +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError + +# ── Resolve pluralbridge package ───────────────────────────────── +# When running from source the package lives at ../../pluralbridge +# relative to this file. When frozen by PyInstaller it is in the +# temp extraction dir, so we fall back to a direct import. +_here = Path(__file__).resolve().parent +_src_pkg = _here / "pluralbridge" +if _src_pkg.is_dir() and str(_here) not in sys.path: + sys.path.insert(0, str(_here)) + +from pluralbridge.api import get_json # noqa: E402 +from pluralbridge.paths import ensure_dir, write_json # noqa: E402 + +DEFAULT_API_BASE = "https://api.apparyllis.com" +REQUEST_DELAY = 0.15 + + +# ── Log capture ────────────────────────────────────────────────── + +class LogCapture(io.StringIO): + """Tee print() output to the GUI log widget.""" + + def __init__(self, append_fn) -> None: + super().__init__() + self._append = append_fn + + def write(self, s: str) -> int: + super().write(s) + if s.strip(): + self._append(s) + return len(s) + + def flush(self) -> None: + pass + + +# ── Export logic ───────────────────────────────────────────────── + +def export_json(token: str, output_dir: Path, notes_dir: Path, + skip_notes: bool, append) -> None: + """Replicate export_json.py logic with GUI output.""" + import time + + def _fetch(endpoint: str, *, required: bool) -> tuple[bool, Any, str]: + append(f"Fetching {endpoint}\n") + try: + data = get_json(DEFAULT_API_BASE, endpoint, token) + return True, data, "" + except HTTPError as exc: + msg = f"HTTP {exc.code} from {endpoint}" + except URLError as exc: + msg = f"URL error from {endpoint}: {exc.reason}" + except Exception as exc: + msg = f"{type(exc).__name__} from {endpoint}: {exc}" + if required: + raise RuntimeError(msg) + append(f"Warning: skipped {endpoint}: {msg}\n") + return False, [], msg + + def _export(filename: str, endpoint: str, required: bool, + manifest: dict, data_dir: Path) -> Any: + ok, data, error = _fetch(endpoint, required=required) + write_json(data_dir / filename, data) + entry: dict[str, Any] = {"endpoint": endpoint, "filename": filename, + "required": required, "ok": ok} + if error: + entry["error"] = error + manifest["errors"].append(entry) + manifest["files"].append(entry) + append(f" saved {endpoint} -> {filename}\n") + time.sleep(REQUEST_DELAY) + return data + + output_dir = ensure_dir(output_dir) + notes_dir = ensure_dir(notes_dir) + + manifest: dict[str, Any] = {"base_url": DEFAULT_API_BASE, "files": [], "errors": []} + + me = _export("me.json", "/v1/me", True, manifest, output_dir) + uid = (me.get("id") or me.get("uid") or me.get("_id") + or (me.get("content", {}) or {}).get("id") + or (me.get("content", {}) or {}).get("uid")) + if not uid: + raise RuntimeError("Could not determine user ID from /v1/me.") + uid = str(uid) + + specs = [ + ("user.json", f"/v1/user/{uid}", False), + ("members.json", f"/v1/members/{uid}", True), + ("groups.json", f"/v1/groups/{uid}", False), + ("customFronts.json", f"/v1/customFronts/{uid}", False), + ("frontHistory.json", "/v1/frontHistory", False), + ("fronthistory_starttime_and_endtime.json", + f"/v1/frontHistory/{uid}?startTime=0&endTime=9999999999999", True), + ("timers__automated.json",f"/v1/timers/automated/{uid}", False), + ("timers__repeated.json", f"/v1/timers/repeated/{uid}", False), + ("polls.json", f"/v1/polls/{uid}", False), + ("customFields.json", f"/v1/customFields/{uid}", True), + ("privacyBuckets.json", "/v1/privacyBuckets", False), + ("filters.json", "/v1/filters", False), + ("categories.json", "/v1/chat/categories", False), + ("channels.json", "/v1/chat/channels", False), + ("friends.json", "/v1/friends/", False), + ] + + exported: dict[str, Any] = {} + for filename, endpoint, required in specs: + exported[filename] = _export(filename, endpoint, required, manifest, output_dir) + + if not skip_notes: + members_json = exported.get("members.json") + members_list: list[dict] = [] + if isinstance(members_json, list): + members_list = [m for m in members_json if isinstance(m, dict)] + elif isinstance(members_json, dict): + for key in ("members", "data", "results"): + val = members_json.get(key) + if isinstance(val, list): + members_list = [m for m in val if isinstance(m, dict)] + break + + for idx, member in enumerate(members_list, start=1): + mid = str(member.get("id") or member.get("uid") or member.get("_id") or "") + if not mid: + continue + endpoint = f"/v1/notes/{uid}/{mid}" + fname = f"{idx}.json" + ok, data, error = _fetch(endpoint, required=False) + write_json(notes_dir / fname, data) + entry = {"endpoint": endpoint, "filename": f"notes/{fname}", + "required": False, "ok": ok} + if error: + entry["error"] = error + manifest["errors"].append(entry) + manifest["files"].append(entry) + append(f" saved {endpoint} -> notes/{fname}\n") + time.sleep(REQUEST_DELAY) + + write_json(output_dir / "manifest.json", manifest) + append(f"\nJSON export complete: {output_dir}\n") + append(f"Notes directory: {notes_dir}\n") + + +def export_avatars(members_json_path: Path, output_dir: Path, append) -> None: + """Replicate export_avatars.py logic with GUI output.""" + output_dir = ensure_dir(output_dir) + + with members_json_path.open("r", encoding="utf-8") as f: + members = json.load(f) + + manifest_rows: list[str] = [] + count = 0 + for row in members: + mid = row.get("id", "") + if not mid: + continue + content = row.get("content", {}) or {} + uid = content.get("uid", "") + avatar_uuid = content.get("avatarUuid", "") + url = content.get("avatarUrl", "") + if not url and uid and avatar_uuid: + url = f"https://spaces.apparyllis.com/avatars/{uid}/{avatar_uuid}" + if not url: + continue + + append(f"Downloading avatar for {mid}\n") + req = urllib.request.Request(url, headers={"Accept": "image/*", + "User-Agent": "PluralBridge/0.1"}) + with urllib.request.urlopen(req) as resp: + data = resp.read() + ct = resp.headers.get("Content-Type", "").split(";")[0].strip().lower() + + ext = {".png": "image/png", ".jpg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp"}.get(ct, "") + if not ext: + for e, m in {".png": "png", ".jpg": "jpeg", ".gif": "gif", ".webp": "webp"}.items(): + if m in ct: + ext = e + break + else: + ext = ".img" + + local = output_dir / f"{mid}{ext}" + local.write_bytes(data) + manifest_rows.append(f"{mid}\t{uid}\t{avatar_uuid}\t{url}\t{local.name}\t{local.as_posix()}") + count += 1 + + manifest_path = output_dir.parent / "avatar_manifest.tsv" + with manifest_path.open("w", encoding="utf-8", newline="\n") as f: + f.write("member_id\tsystem_uid\tavatar_uuid\tsource_url\tlocal_filename\tlocal_path\n") + for r in manifest_rows: + f.write(r + "\n") + + append(f"\nAvatar export complete: {count} files -> {output_dir}\n") + append(f"Manifest: {manifest_path}\n") + + +# ── GUI ────────────────────────────────────────────────────────── + +class ExportGUI(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title("PluralBridge Exporter") + self.resizable(True, True) + self.minsize(580, 500) + self._running = False + + root = Path(__file__).resolve().parents[2] if not getattr(sys, 'frozen', False) else Path(sys.executable).parent + self._default_output = str(root / "exports") + + self._build_ui() + self._center(640, 540) + + def _build_ui(self) -> None: + pad = {"padx": 8, "pady": 4} + + # Token + tk.Label(self, text="Simply Plural API Token:").pack(anchor="w", **pad) + tf = tk.Frame(self) + tf.pack(fill="x", **pad) + self._token_var = tk.StringVar() + self._token_entry = tk.Entry(tf, textvariable=self._token_var, show="•", width=60) + self._token_entry.pack(side="left", fill="x", expand=True) + self._show_var = tk.BooleanVar() + tk.Checkbutton(tf, text="Show", variable=self._show_var, + command=self._toggle_vis).pack(side="left", padx=(4, 0)) + + # Output folder + tk.Label(self, text="Output Folder:").pack(anchor="w", **pad) + ff = tk.Frame(self) + ff.pack(fill="x", **pad) + self._folder_var = tk.StringVar(value=self._default_output) + tk.Entry(ff, textvariable=self._folder_var, width=50).pack(side="left", fill="x", expand=True) + tk.Button(ff, text="Browse…", command=self._browse).pack(side="left", padx=(4, 0)) + + # Options + of = tk.LabelFrame(self, text="Export Options", padx=8, pady=4) + of.pack(fill="x", **pad) + self._json_var = tk.BooleanVar(value=True) + self._avatar_var = tk.BooleanVar(value=True) + self._notes_var = tk.BooleanVar(value=True) + tk.Checkbutton(of, text="JSON data (members, groups, front history…)", + variable=self._json_var).pack(anchor="w") + tk.Checkbutton(of, text="Per-member notes", + variable=self._notes_var).pack(anchor="w") + tk.Checkbutton(of, text="Avatar images", + variable=self._avatar_var).pack(anchor="w") + + # Export button + bf = tk.Frame(self) + bf.pack(fill="x", **pad) + self._export_btn = tk.Button(bf, text="▶ Export", command=self._start, + bg="#4CAF50", fg="white", font=("Segoe UI", 11, "bold"), + padx=16, pady=4) + self._export_btn.pack(side="left") + self._status_var = tk.StringVar(value="Ready") + tk.Label(bf, textvariable=self._status_var, anchor="w").pack(side="left", padx=12) + + # Log + tk.Label(self, text="Log:").pack(anchor="w", **pad) + self._log = scrolledtext.ScrolledText(self, height=14, state="disabled", + font=("Consolas", 9), wrap="word") + self._log.pack(fill="both", expand=True, padx=8, pady=(0, 8)) + + def _center(self, w: int, h: int) -> None: + sw, sh = self.winfo_screenwidth(), self.winfo_screenheight() + self.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") + + def _toggle_vis(self) -> None: + self._token_entry.config(show="" if self._show_var.get() else "•") + + def _browse(self) -> None: + d = filedialog.askdirectory(initialdir=self._folder_var.get()) + if d: + self._folder_var.set(d) + + def _append_log(self, text: str) -> None: + self._log.config(state="normal") + self._log.insert("end", text) + self._log.see("end") + self._log.config(state="disabled") + + def _clear_log(self) -> None: + self._log.config(state="normal") + self._log.delete("1.0", "end") + self._log.config(state="disabled") + + def _set_status(self, msg: str) -> None: + self._status_var.set(msg) + + def _after(self, fn) -> None: + self.after(0, fn) + + def _start(self) -> None: + token = self._token_var.get().strip() + if not token: + messagebox.showwarning("Missing Token", "Please paste your Simply Plural API token.") + return + if self._running: + return + self._running = True + self._export_btn.config(state="disabled") + self._clear_log() + self._set_status("Exporting…") + threading.Thread(target=self._run, args=(token,), daemon=True).start() + + def _run(self, token: str) -> None: + output_dir = Path(self._folder_var.get().strip()) + notes_dir = output_dir / "notes" + avatar_dir = output_dir.parent / "member_images" + log = lambda t: self._after(lambda t=t: self._append_log(t)) + + try: + if self._json_var.get() or self._notes_var.get(): + log("Starting JSON/notes export…\n") + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = LogCapture(log) + sys.stderr = sys.stdout + try: + export_json(token, output_dir, notes_dir, + skip_notes=not self._notes_var.get(), append=log) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + + if self._avatar_var.get(): + log("\nStarting avatar export…\n") + members_json = output_dir / "members.json" + if not members_json.exists(): + log("Skipped: members.json not found (run JSON export first).\n") + else: + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = LogCapture(log) + sys.stderr = sys.stdout + try: + export_avatars(members_json, avatar_dir, append=log) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + + log("\n✓ Export complete!\n") + self._after(lambda: self._set_status("Done")) + self._after(lambda: messagebox.showinfo( + "Export Complete", + f"Files saved to:\n{output_dir}\n\nBack up this folder and keep it private.")) + + except Exception as exc: + log(f"\n✗ Error: {exc}\n") + self._after(lambda: self._set_status("Failed")) + finally: + self._running = False + self._after(lambda: self._export_btn.config(state="normal")) + + +def main() -> int: + app = ExportGUI() + app.mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a4166ecad3f33720141a08ff178eb9d9ebb2325b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jardin=20=F0=9F=8C=B8?= <84980243+jardinsys@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:47:55 -0500 Subject: [PATCH 2/6] Update gui.py --- scripts/python/gui.py | 80 +++++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/scripts/python/gui.py b/scripts/python/gui.py index a65f7a9..ee4abf1 100644 --- a/scripts/python/gui.py +++ b/scripts/python/gui.py @@ -60,7 +60,7 @@ def export_json(token: str, output_dir: Path, notes_dir: Path, """Replicate export_json.py logic with GUI output.""" import time - def _fetch(endpoint: str, *, required: bool) -> tuple[bool, Any, str]: + def _fetch(endpoint: str) -> tuple[bool, Any, str]: append(f"Fetching {endpoint}\n") try: data = get_json(DEFAULT_API_BASE, endpoint, token) @@ -71,17 +71,15 @@ def _fetch(endpoint: str, *, required: bool) -> tuple[bool, Any, str]: msg = f"URL error from {endpoint}: {exc.reason}" except Exception as exc: msg = f"{type(exc).__name__} from {endpoint}: {exc}" - if required: - raise RuntimeError(msg) - append(f"Warning: skipped {endpoint}: {msg}\n") + append(f" Skipped: {msg}\n") return False, [], msg - def _export(filename: str, endpoint: str, required: bool, + def _export(filename: str, endpoint: str, manifest: dict, data_dir: Path) -> Any: - ok, data, error = _fetch(endpoint, required=required) - write_json(data_dir / filename, data) - entry: dict[str, Any] = {"endpoint": endpoint, "filename": filename, - "required": required, "ok": ok} + ok, data, error = _fetch(endpoint) + if ok: + write_json(data_dir / filename, data) + entry: dict[str, Any] = {"endpoint": endpoint, "filename": filename, "ok": ok} if error: entry["error"] = error manifest["errors"].append(entry) @@ -95,7 +93,9 @@ def _export(filename: str, endpoint: str, required: bool, manifest: dict[str, Any] = {"base_url": DEFAULT_API_BASE, "files": [], "errors": []} - me = _export("me.json", "/v1/me", True, manifest, output_dir) + me = _export("me.json", "/v1/me", manifest, output_dir) + if not me: + raise RuntimeError("/v1/me returned no data — cannot continue without a user ID.") uid = (me.get("id") or me.get("uid") or me.get("_id") or (me.get("content", {}) or {}).get("id") or (me.get("content", {}) or {}).get("uid")) @@ -104,27 +104,27 @@ def _export(filename: str, endpoint: str, required: bool, uid = str(uid) specs = [ - ("user.json", f"/v1/user/{uid}", False), - ("members.json", f"/v1/members/{uid}", True), - ("groups.json", f"/v1/groups/{uid}", False), - ("customFronts.json", f"/v1/customFronts/{uid}", False), - ("frontHistory.json", "/v1/frontHistory", False), + ("user.json", f"/v1/user/{uid}"), + ("members.json", f"/v1/members/{uid}"), + ("groups.json", f"/v1/groups/{uid}"), + ("customFronts.json", f"/v1/customFronts/{uid}"), + ("frontHistory.json", "/v1/frontHistory"), ("fronthistory_starttime_and_endtime.json", - f"/v1/frontHistory/{uid}?startTime=0&endTime=9999999999999", True), - ("timers__automated.json",f"/v1/timers/automated/{uid}", False), - ("timers__repeated.json", f"/v1/timers/repeated/{uid}", False), - ("polls.json", f"/v1/polls/{uid}", False), - ("customFields.json", f"/v1/customFields/{uid}", True), - ("privacyBuckets.json", "/v1/privacyBuckets", False), - ("filters.json", "/v1/filters", False), - ("categories.json", "/v1/chat/categories", False), - ("channels.json", "/v1/chat/channels", False), - ("friends.json", "/v1/friends/", False), + f"/v1/frontHistory/{uid}?startTime=0&endTime=9999999999999"), + ("timers__automated.json",f"/v1/timers/automated/{uid}"), + ("timers__repeated.json", f"/v1/timers/repeated/{uid}"), + ("polls.json", f"/v1/polls/{uid}"), + ("customFields.json", f"/v1/customFields/{uid}"), + ("privacyBuckets.json", "/v1/privacyBuckets"), + ("filters.json", "/v1/filters"), + ("categories.json", "/v1/chat/categories"), + ("channels.json", "/v1/chat/channels"), + ("friends.json", "/v1/friends/"), ] exported: dict[str, Any] = {} - for filename, endpoint, required in specs: - exported[filename] = _export(filename, endpoint, required, manifest, output_dir) + for filename, endpoint in specs: + exported[filename] = _export(filename, endpoint, manifest, output_dir) if not skip_notes: members_json = exported.get("members.json") @@ -144,10 +144,10 @@ def _export(filename: str, endpoint: str, required: bool, continue endpoint = f"/v1/notes/{uid}/{mid}" fname = f"{idx}.json" - ok, data, error = _fetch(endpoint, required=False) - write_json(notes_dir / fname, data) - entry = {"endpoint": endpoint, "filename": f"notes/{fname}", - "required": False, "ok": ok} + ok, data, error = _fetch(endpoint) + if ok: + write_json(notes_dir / fname, data) + entry = {"endpoint": endpoint, "filename": f"notes/{fname}", "ok": ok} if error: entry["error"] = error manifest["errors"].append(entry) @@ -169,6 +169,7 @@ def export_avatars(members_json_path: Path, output_dir: Path, append) -> None: manifest_rows: list[str] = [] count = 0 + skipped = 0 for row in members: mid = row.get("id", "") if not mid: @@ -183,11 +184,16 @@ def export_avatars(members_json_path: Path, output_dir: Path, append) -> None: continue append(f"Downloading avatar for {mid}\n") - req = urllib.request.Request(url, headers={"Accept": "image/*", - "User-Agent": "PluralBridge/0.1"}) - with urllib.request.urlopen(req) as resp: - data = resp.read() - ct = resp.headers.get("Content-Type", "").split(";")[0].strip().lower() + try: + req = urllib.request.Request(url, headers={"Accept": "image/*", + "User-Agent": "PluralBridge/0.1"}) + with urllib.request.urlopen(req) as resp: + data = resp.read() + ct = resp.headers.get("Content-Type", "").split(";")[0].strip().lower() + except Exception as exc: + append(f" Skipped avatar {mid}: {exc}\n") + skipped += 1 + continue ext = {".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}.get(ct, "") @@ -210,7 +216,7 @@ def export_avatars(members_json_path: Path, output_dir: Path, append) -> None: for r in manifest_rows: f.write(r + "\n") - append(f"\nAvatar export complete: {count} files -> {output_dir}\n") + append(f"\nAvatar export complete: {count} saved, {skipped} skipped -> {output_dir}\n") append(f"Manifest: {manifest_path}\n") From 106a0febfa80dba00422a6b0f96dbaa911d1b815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jardin=20=F0=9F=8C=B8?= <84980243+jardinsys@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:49:56 -0500 Subject: [PATCH 3/6] Update README with GUI script location Added GUI information to the project website section. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c4b88e7..75e98c0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ [![GitGem](https://gitgem.org/api/badge/github/needsofmany/PluralBridge.svg)](https://gitgem.org/gem/github/needsofmany/PluralBridge) -Project website: https://thepluralbridge.org/ +Project website: https://thepluralbridge.org/ +(GUI [gui.py] available in scripts\python\gui.py) ## Regular users: start here From 60e492d66031520ae935aaa497528f9a13cfa582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jardin=20=F0=9F=8C=B8?= <84980243+jardinsys@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:50:30 -0500 Subject: [PATCH 4/6] Update README to include GUI script location Add GUI script reference to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 75e98c0..fe25724 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![GitGem](https://gitgem.org/api/badge/github/needsofmany/PluralBridge.svg)](https://gitgem.org/gem/github/needsofmany/PluralBridge) Project website: https://thepluralbridge.org/ + (GUI [gui.py] available in scripts\python\gui.py) ## Regular users: start here From bef1e68781561609adf574d27b926f583f78b959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jardin=20=F0=9F=8C=B8?= <84980243+jardinsys@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:02:36 -0500 Subject: [PATCH 5/6] Update gui.py --- scripts/python/gui.py | 63 ++++++++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/scripts/python/gui.py b/scripts/python/gui.py index ee4abf1..7983d3f 100644 --- a/scripts/python/gui.py +++ b/scripts/python/gui.py @@ -1,39 +1,57 @@ #!/usr/bin/env python3 -"""PluralBridge GUI — self-contained tkinter exporter. +"""PluralBridge GUI — standalone tkinter exporter. -Uses pluralbridge modules directly (no subprocess) so it works both as a -standalone script and when bundled into a single .exe via PyInstaller. +Fully self-contained. No external dependencies beyond Python stdlib. +Send this single file to anyone and they can run it directly. """ from __future__ import annotations import io import json +import os import sys import threading import tkinter as tk import urllib.request +from datetime import datetime from tkinter import filedialog, messagebox, scrolledtext from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError -# ── Resolve pluralbridge package ───────────────────────────────── -# When running from source the package lives at ../../pluralbridge -# relative to this file. When frozen by PyInstaller it is in the -# temp extraction dir, so we fall back to a direct import. -_here = Path(__file__).resolve().parent -_src_pkg = _here / "pluralbridge" -if _src_pkg.is_dir() and str(_here) not in sys.path: - sys.path.insert(0, str(_here)) - -from pluralbridge.api import get_json # noqa: E402 -from pluralbridge.paths import ensure_dir, write_json # noqa: E402 - DEFAULT_API_BASE = "https://api.apparyllis.com" REQUEST_DELAY = 0.15 +# ── Helpers (inlined so the file is standalone) ───────────────── + +def get_json(api_base: str, path: str, token: str) -> Any: + url = api_base.rstrip("/") + "/" + path.lstrip("/") + request = urllib.request.Request( + url, + headers={"Authorization": token, "Accept": "application/json", + "User-Agent": "PluralBridge/0.1"}, + method="GET", + ) + with urllib.request.urlopen(request) as response: + return json.loads(response.read().decode("utf-8")) + + +def ensure_dir(path: str | Path) -> Path: + target = Path(path) + target.mkdir(parents=True, exist_ok=True) + return target + + +def write_json(path: str | Path, data: Any) -> None: + target = Path(path) + ensure_dir(target.parent) + with target.open("w", encoding="utf-8", newline="\n") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + # ── Log capture ────────────────────────────────────────────────── class LogCapture(io.StringIO): @@ -210,7 +228,7 @@ def export_avatars(members_json_path: Path, output_dir: Path, append) -> None: manifest_rows.append(f"{mid}\t{uid}\t{avatar_uuid}\t{url}\t{local.name}\t{local.as_posix()}") count += 1 - manifest_path = output_dir.parent / "avatar_manifest.tsv" + manifest_path = output_dir / "avatar_manifest.tsv" with manifest_path.open("w", encoding="utf-8", newline="\n") as f: f.write("member_id\tsystem_uid\tavatar_uuid\tsource_url\tlocal_filename\tlocal_path\n") for r in manifest_rows: @@ -230,8 +248,8 @@ def __init__(self) -> None: self.minsize(580, 500) self._running = False - root = Path(__file__).resolve().parents[2] if not getattr(sys, 'frozen', False) else Path(sys.executable).parent - self._default_output = str(root / "exports") + root = Path(os.path.expanduser("~")) if not getattr(sys, 'frozen', False) else Path(sys.executable).parent + self._default_output = str(root / "Documents" / f"PluralBridge Export {datetime.now():%Y-%m-%d}") self._build_ui() self._center(640, 540) @@ -295,9 +313,10 @@ def _toggle_vis(self) -> None: self._token_entry.config(show="" if self._show_var.get() else "•") def _browse(self) -> None: - d = filedialog.askdirectory(initialdir=self._folder_var.get()) - if d: - self._folder_var.set(d) + parent = filedialog.askdirectory(initialdir=self._folder_var.get()) + if parent: + subfolder = f"PluralBridge Export {datetime.now():%Y-%m-%d %H%M}" + self._folder_var.set(str(Path(parent) / subfolder)) def _append_log(self, text: str) -> None: self._log.config(state="normal") @@ -332,7 +351,7 @@ def _start(self) -> None: def _run(self, token: str) -> None: output_dir = Path(self._folder_var.get().strip()) notes_dir = output_dir / "notes" - avatar_dir = output_dir.parent / "member_images" + avatar_dir = output_dir / "member_images" log = lambda t: self._after(lambda t=t: self._append_log(t)) try: From 19d8cf63dd84112adedf3dc83c04519aa0e813aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jardin=20=F0=9F=8C=B8?= <84980243+jardinsys@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:38:20 -0500 Subject: [PATCH 6/6] downloads 1st and 3rd party images --- scripts/python/gui.py | 67 ++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/scripts/python/gui.py b/scripts/python/gui.py index 7983d3f..dc7d273 100644 --- a/scripts/python/gui.py +++ b/scripts/python/gui.py @@ -195,38 +195,51 @@ def export_avatars(members_json_path: Path, output_dir: Path, append) -> None: content = row.get("content", {}) or {} uid = content.get("uid", "") avatar_uuid = content.get("avatarUuid", "") - url = content.get("avatarUrl", "") - if not url and uid and avatar_uuid: - url = f"https://spaces.apparyllis.com/avatars/{uid}/{avatar_uuid}" - if not url: + + # Build list of URLs to download (both if available) + urls: list[tuple[str, str]] = [] # (url, label) + primary = content.get("avatarUrl", "") + if primary: + urls.append((primary, "")) + if uid and avatar_uuid: + spaces_url = f"https://spaces.apparyllis.com/avatars/{uid}/{avatar_uuid}" + if spaces_url != primary: + urls.append((spaces_url, "_apparyllis")) + if not urls: continue append(f"Downloading avatar for {mid}\n") - try: - req = urllib.request.Request(url, headers={"Accept": "image/*", - "User-Agent": "PluralBridge/0.1"}) - with urllib.request.urlopen(req) as resp: - data = resp.read() - ct = resp.headers.get("Content-Type", "").split(";")[0].strip().lower() - except Exception as exc: - append(f" Skipped avatar {mid}: {exc}\n") - skipped += 1 - continue + downloaded = 0 + for url, label in urls: + try: + req = urllib.request.Request(url, headers={"Accept": "image/*", + "User-Agent": "PluralBridge/0.1"}) + with urllib.request.urlopen(req) as resp: + data = resp.read() + ct = resp.headers.get("Content-Type", "").split(";")[0].strip().lower() + except Exception: + continue - ext = {".png": "image/png", ".jpg": "image/jpeg", - ".gif": "image/gif", ".webp": "image/webp"}.get(ct, "") - if not ext: - for e, m in {".png": "png", ".jpg": "jpeg", ".gif": "gif", ".webp": "webp"}.items(): - if m in ct: - ext = e - break - else: - ext = ".img" + ext = {".png": "image/png", ".jpg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp"}.get(ct, "") + if not ext: + for e, m in {".png": "png", ".jpg": "jpeg", ".gif": "gif", ".webp": "webp"}.items(): + if m in ct: + ext = e + break + else: + ext = ".img" - local = output_dir / f"{mid}{ext}" - local.write_bytes(data) - manifest_rows.append(f"{mid}\t{uid}\t{avatar_uuid}\t{url}\t{local.name}\t{local.as_posix()}") - count += 1 + local = output_dir / f"{mid}{label}{ext}" + local.write_bytes(data) + manifest_rows.append(f"{mid}\t{uid}\t{avatar_uuid}\t{url}\t{local.name}\t{local.as_posix()}") + downloaded += 1 + + if downloaded == 0: + append(f" Skipped avatar {mid}: all URLs failed\n") + skipped += 1 + else: + count += 1 manifest_path = output_dir / "avatar_manifest.tsv" with manifest_path.open("w", encoding="utf-8", newline="\n") as f: