Reddit Manager

Export, import, and bulk-manage your Reddit subreddit subscriptions from a floating panel.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Reddit Manager
// @namespace    https://github.com/quantavil/userscript/reddit-manager
// @version      3.2.0
// @author       quantavil (https://github.com/quantavil)
// @description  Export, import, and bulk-manage your Reddit subreddit subscriptions from a floating panel.
// @license      MIT
// @homepage     https://github.com/quantavil/userscript
// @homepageURL  https://github.com/quantavil/userscript
// @match        https://www.reddit.com/*
// @match        https://old.reddit.com/*
// @match        https://sh.reddit.com/*
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const ORIGIN = location.origin;
  const MAX_PAGES = 60;
  const RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  async function fetchWithRetry(url, options = {}, maxRetries = 5, onRetry) {
    let delay = 2e3;
    let resp = null;
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        resp = await fetch(url, options);
        if (!RETRYABLE.has(resp.status)) return resp;
      } catch (err) {
        if (attempt === maxRetries) throw err;
      }
      if (attempt === maxRetries && resp) break;
      let wait = delay;
      if (resp) {
        const header = resp.headers.get("Retry-After") || resp.headers.get("x-ratelimit-reset");
        if (header) {
          const seconds = Number.parseFloat(header);
          if (Number.isFinite(seconds)) {
            wait = Math.max(seconds * 1e3, 2e3);
          } else {
            const parsedDate = Date.parse(header);
            if (Number.isFinite(parsedDate)) {
              wait = Math.max(parsedDate - Date.now(), 2e3);
            }
          }
        }
      }
      wait += Math.random() * 400 - 200;
      wait = Math.max(wait, 1e3);
      const status = resp?.status ?? 0;
      console.warn(
        `[Reddit Manager] Request on ${new URL(url, ORIGIN).pathname} failed/retryable (status ${status}). Retrying in ${(wait / 1e3).toFixed(1)}s (attempt ${attempt + 1}/${maxRetries})…`
      );
      onRetry?.(attempt + 1, maxRetries, wait, status);
      const step = 1e3;
      let remaining = wait;
      while (remaining > 0) {
        const sleepMs = Math.min(remaining, step);
        await sleep(sleepMs);
        remaining -= sleepMs;
        onRetry?.(attempt + 1, maxRetries, Math.max(0, remaining), status);
      }
      delay *= 2;
    }
    if (!resp) throw new Error("Network request failed after retries");
    return resp;
  }
  function normalizeName(input) {
    if (!input) return "";
    return input.trim().replace(/^https?:\/\/[^/]+/i, "").replace(/^\/?(?:r\/)?/i, "").replace(/\/.*$/, "").replace(/[^\w-]/g, "");
  }
  let cached = null;
  let inflight = null;
  function loadSubs(force = false, onRetry) {
    if (force) {
      cached = null;
      inflight = null;
    }
    if (cached) return Promise.resolve(cached);
    if (inflight) return inflight;
    inflight = (async () => {
      const subs = [];
      let after = null;
      for (let page = 0; page < MAX_PAGES; page++) {
        const url = new URL(`${ORIGIN}/subreddits/mine/subscriber.json`);
        url.searchParams.set("limit", "100");
        url.searchParams.set("raw_json", "1");
        url.searchParams.set("_", String(Date.now()));
        if (after) url.searchParams.set("after", after);
        const resp = await fetchWithRetry(
          url.toString(),
          { credentials: "include", headers: { Accept: "application/json" } },
          4,
          onRetry
        );
        if (resp.status === 401 || resp.status === 403)
          throw new Error("not logged in to Reddit");
        if (!resp.ok) throw new Error(`subreddits/mine returned ${resp.status}`);
        const json = await resp.json();
        for (const child of json?.data?.children ?? []) {
          const d = child?.data;
          if (!d?.display_name) continue;
          subs.push({
            name: d.display_name,
            id: d.name,
            title: d.title,
            subscribers: d.subscribers,
            over18: d.over18
          });
        }
        after = json?.data?.after ?? null;
        if (!after) break;
        if (page === MAX_PAGES - 1 && after) {
          console.warn(
            `[Reddit Manager] Subscriptions capped at ${MAX_PAGES * 100} items limit.`
          );
        }
      }
      cached = subs;
      return subs;
    })().finally(() => {
      inflight = null;
    });
    return inflight;
  }
  let meCached = null;
  let meInflight = null;
  async function meInfo(onRetry, force = false) {
    if (force) meCached = null;
    if (meCached) return meCached;
    if (meInflight) return meInflight;
    meInflight = (async () => {
      const resp = await fetchWithRetry(
        `${ORIGIN}/api/me.json`,
        { credentials: "include", headers: { Accept: "application/json" } },
        3,
        onRetry
      );
      if (resp.status === 401 || resp.status === 403) {
        throw new Error("not logged in to Reddit");
      }
      if (!resp.ok) {
        throw new Error(`/api/me.json returned ${resp.status}`);
      }
      const json = await resp.json();
      const data = {
        modhash: json?.data?.modhash ?? "",
        name: json?.data?.name ?? ""
      };
      if (!data.name) throw new Error("Reddit did not return a username");
      meCached = data;
      return data;
    })().finally(() => {
      meInflight = null;
    });
    return meInflight;
  }
  async function getModhash(onRetry) {
    const info = await meInfo(onRetry);
    return info.modhash;
  }
  async function setSubscribed(name, subscribe, onRetry) {
    cached = null;
    let currentModhash = await getModhash(onRetry);
    const params = new URLSearchParams({
      action: subscribe ? "sub" : "unsub",
      sr_name: name,
      api_type: "json"
    });
    for (let attempt = 0; attempt < 2; attempt++) {
      const headers = {
        "Content-Type": "application/x-www-form-urlencoded"
      };
      if (currentModhash) {
        headers["X-Modhash"] = currentModhash;
        params.set("uh", currentModhash);
      } else {
        params.delete("uh");
      }
      const resp = await fetchWithRetry(
        `${ORIGIN}/api/subscribe`,
        { method: "POST", credentials: "include", headers, body: params },
        5,
        onRetry
      );
      if (resp.status === 403) {
        if (attempt === 0) {
          currentModhash = (await meInfo(onRetry, true)).modhash;
          continue;
        }
        throw new Error(
          currentModhash ? "HTTP 403 — modhash rejected; log out and back in on Reddit" : "HTTP 403 — no modhash available from /api/me.json (are you logged in to Reddit?)"
        );
      }
      if (!resp.ok) {
        const body = (await resp.text().catch(() => "")).trim();
        const detail = body.startsWith("<") ? body.match(/<title>([^<]*)<\/title>/i)?.[1] ?? "HTML error page" : body.slice(0, 200);
        throw new Error(`HTTP ${resp.status}${detail ? ` — ${detail}` : ""}`);
      }
      const errors = (await resp.json().catch(() => null))?.json?.errors;
      if (Array.isArray(errors) && errors.length > 0)
        throw new Error(errors.map((e) => e[1] ?? e[0]).join("; "));
      return;
    }
  }
  async function loadUserItems(kind, onRetry) {
    const info = await meInfo(onRetry);
    if (!info.name) throw new Error("not logged in to Reddit");
    const items = [];
    let after = null;
    const endpoint = kind === "posts" ? "submitted" : "comments";
    const encodedName = encodeURIComponent(info.name);
    for (let page = 0; page < MAX_PAGES; page++) {
      const url = new URL(`${ORIGIN}/user/${encodedName}/${endpoint}.json`);
      url.searchParams.set("limit", "100");
      url.searchParams.set("raw_json", "1");
      url.searchParams.set("_", String(Date.now()));
      if (after) url.searchParams.set("after", after);
      const resp = await fetchWithRetry(
        url.toString(),
        { credentials: "include", headers: { Accept: "application/json" } },
        4,
        onRetry
      );
      if (resp.status === 401 || resp.status === 403)
        throw new Error("not logged in to Reddit");
      if (!resp.ok) throw new Error(`user/${endpoint} returned ${resp.status}`);
      const json = await resp.json();
      const children = json?.data?.children ?? [];
      if (children.length === 0) break;
      for (const child of children) {
        const d = child?.data;
        if (!d?.name) continue;
        items.push({
          fullname: d.name,
          title: d.title,
          body: d.body,
          subreddit: d.subreddit
        });
      }
      after = json?.data?.after ?? null;
      if (!after) break;
    }
    return items;
  }
  async function deleteUserItem(fullname, editFirst = true, onRetry) {
    for (let attempt = 0; attempt < 2; attempt++) {
      const info = await meInfo(onRetry);
      const headers = {
        "Content-Type": "application/x-www-form-urlencoded"
      };
      if (info.modhash) {
        headers["X-Modhash"] = info.modhash;
      }
      if (editFirst) {
        const editParams = new URLSearchParams({
          thing_id: fullname,
          text: ".",
          api_type: "json"
        });
        if (info.modhash) editParams.set("uh", info.modhash);
        const editResp = await fetchWithRetry(
          `${ORIGIN}/api/editusertext`,
          { method: "POST", credentials: "include", headers, body: editParams },
          2,
          onRetry
        );
        if (editResp.status === 401 || editResp.status === 403) {
          if (attempt === 0) {
            await meInfo(onRetry, true);
            continue;
          }
        }
        if (!editResp.ok) {
          console.warn(
            `[Reddit Manager] Overwrite failed with HTTP ${editResp.status} for ${fullname}; proceeding to deletion.`
          );
        } else {
          const editJson = await editResp.json().catch(() => null);
          const editErrors = editJson?.json?.errors;
          if (Array.isArray(editErrors) && editErrors.length > 0) {
            console.warn(
              `[Reddit Manager] Overwrite failed for ${fullname}: ${editErrors.map((e) => e[1] ?? e[0]).join("; ")}; proceeding to deletion.`
            );
          }
        }
      }
      const delParams = new URLSearchParams({
        id: fullname,
        api_type: "json"
      });
      if (info.modhash) delParams.set("uh", info.modhash);
      const resp = await fetchWithRetry(
        `${ORIGIN}/api/del`,
        { method: "POST", credentials: "include", headers, body: delParams },
        3,
        onRetry
      );
      if (resp.status === 401 || resp.status === 403) {
        if (attempt === 0) {
          await meInfo(onRetry, true);
          continue;
        }
      }
      if (!resp.ok) {
        throw new Error(`HTTP ${resp.status} deleting ${fullname}`);
      }
      return;
    }
  }
  const SUB_PAUSE_MS = 650;
  const DELETE_PAUSE_MS = 1300;
  const GIVE_UP_AFTER = 3;
  async function applyAll(identifiers, subscribe, onProgress, r) {
    const failed = [];
    let lastError = "";
    const onRetry = (attempt, maxRetries, waitMs, status) => {
      const msg = `Reddit returned ${status}. Retrying in ${(waitMs / 1e3).toFixed(1)}s (${attempt}/${maxRetries})…`;
      r?.status(msg, "bad");
      console.warn(`[Reddit Manager] ${msg}`);
    };
    for (let i = 0; i < identifiers.length; i++) {
      while (r?.isPaused?.() && !r?.stopped?.()) {
        await sleep(200);
      }
      if (r?.stopped?.())
        return { failed, error: lastError, stoppedAt: i };
      try {
        await setSubscribed(identifiers[i], subscribe, onRetry);
        r?.status("");
      } catch (err) {
        lastError = err.message;
        failed.push(identifiers[i]);
        console.warn(
          `[Reddit Manager] ${subscribe ? "join" : "leave"} ${identifiers[i]} failed: ${lastError}`
        );
        if (failed.length === i + 1 && failed.length >= GIVE_UP_AFTER)
          throw new Error(
            `${lastError} (first ${failed.length} all failed the same way — stopped, nothing was changed)`
          );
      }
      onProgress(i + 1, identifiers.length, failed.length);
      if (i + 1 < identifiers.length) await sleep(SUB_PAUSE_MS);
    }
    return { failed, error: lastError, stoppedAt: identifiers.length };
  }
  const HEADERS = /* @__PURE__ */ new Set([
    "subreddit",
    "subreddits",
    "display_name",
    "display_names",
    "url"
  ]);
  function parseImport(text) {
    const trimmed = text.trim();
    let raw;
    if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
      const json = JSON.parse(trimmed);
      const list = Array.isArray(json) ? json : json.subreddits ?? json.subs ?? json.data;
      if (!Array.isArray(list)) throw new Error("no subreddit list in that JSON");
      raw = list;
    } else {
      raw = trimmed.split(/[\s,;]+/);
      if (HEADERS.has(String(raw[0]).toLowerCase())) raw.shift();
    }
    const seen = /* @__PURE__ */ new Map();
    for (const item of raw) {
      const name = normalizeName(
        typeof item === "string" ? item : item?.name ?? item?.display_name
      );
      if (name && !seen.has(name.toLowerCase())) seen.set(name.toLowerCase(), name);
    }
    return [...seen.values()];
  }
  function downloadSubs(subs, tag = "") {
    const text = `${JSON.stringify(
    {
      exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
      count: subs.length,
      subreddits: [...subs].sort((a2, b) => a2.name.localeCompare(b.name))
    },
    null,
    2
  )}
`;
    const url = URL.createObjectURL(
      new Blob([text], { type: "application/json" })
    );
    const a = document.createElement("a");
    a.href = url;
    a.download = `reddit-subreddits${tag}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`;
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 1e3);
  }
  async function refresh(r, force = false, full = false) {
    r.status(force ? "Reading from Reddit…" : "");
    const onRetry = (attempt, maxRetries, waitMs, status) => r.status(
      `Rate limited (${status}). Retrying in ${(waitMs / 1e3).toFixed(1)}s (${attempt}/${maxRetries})…`,
      "bad"
    );
    try {
      const subsPromise = loadSubs(force, onRetry);
      const postsPromise = full ? loadUserItems("posts", onRetry) : Promise.reject("skipped");
      const commentsPromise = full ? loadUserItems("comments", onRetry) : Promise.reject("skipped");
      const results = await Promise.allSettled([
        subsPromise,
        postsPromise,
        commentsPromise
      ]);
      const subs = results[0].status === "fulfilled" ? results[0].value : null;
      const posts = results[1].status === "fulfilled" ? results[1].value : null;
      const comments = results[2].status === "fulfilled" ? results[2].value : null;
      r.count(subs?.length ?? null, {
        posts: posts?.length ?? null,
        comments: comments?.length ?? null
      });
      if (results[0].status === "rejected") {
        const err = results[0].reason?.message ?? "Failed to read subscriptions";
        r.status(err, "bad");
        throw new Error(err);
      }
      if (force) {
        const countsMsg = `${subs ? `${subs.length} subreddits` : "subscriptions unavailable"}${posts ? `, ${posts.length} posts` : ""}${comments ? `, ${comments.length} comments` : ""}`;
        r.status(`Read ${countsMsg}.`, "ok");
      }
      return subs?.length ?? 0;
    } catch (e) {
      r.status(e.message, "bad");
      throw e;
    }
  }
  async function exportSubs(r) {
    r.status("Reading your subscriptions…");
    const subs = await loadSubs();
    r.count(subs.length);
    if (subs.length === 0) {
      r.status("Nothing to export — you aren't subscribed to anything.", "bad");
      return;
    }
    downloadSubs(subs);
    r.status(`Exported ${subs.length} subreddits.`, "ok");
  }
  async function importSubs(r) {
    const text = await r.pickFile();
    if (text == null) return;
    let parsed;
    try {
      parsed = parseImport(text);
    } catch (e) {
      r.status(`Couldn't read that file: ${e.message}`, "bad");
      return;
    }
    if (parsed.length === 0) {
      r.status("No subreddit names in that file.", "bad");
      return;
    }
    r.status("Reading your subscriptions…");
    const subs = await loadSubs();
    r.count(subs.length);
    const have = new Set(subs.map((s) => s.name.toLowerCase()));
    const names = parsed.filter((n) => !have.has(n.toLowerCase()));
    const skipped = parsed.length - names.length;
    if (names.length === 0) {
      r.status(`Already in all ${parsed.length} of those subreddits.`, "ok");
      return;
    }
    const ok = await r.confirm({
      title: `Join ${names.length} subreddits`,
      body: `${names.slice(0, 6).join(", ")}${names.length > 6 ? `, and ${names.length - 6} more` : ""}.${skipped ? ` ${skipped} you're already in are left alone.` : ""}`,
      action: `Join ${names.length}`
    });
    if (!ok) return;
    await apply(r, names, true, subs.length);
  }
  async function leaveAll(r) {
    r.status("Reading your subscriptions…");
    const subs = await loadSubs(true);
    r.count(subs.length);
    if (subs.length === 0) {
      r.status("You aren't subscribed to anything.", "bad");
      return;
    }
    const ok = await r.confirm({
      title: `Leave all ${subs.length} subreddits`,
      body: "A backup file downloads first, so Import can put them back. One request per subreddit — whatever gets through stays done even if you close the tab.",
      action: "Leave all",
      danger: true,
      typed: String(subs.length)
    });
    if (!ok) return;
    downloadSubs(subs, "-backup");
    r.status("Backup downloaded.", "ok");
    await apply(
      r,
      subs.map((s) => s.name),
      false,
      subs.length
    );
  }
  async function apply(r, names, subscribe, startCount) {
    const [verb, past] = subscribe ? ["Joining", "Joined"] : ["Leaving", "Left"];
    const headline = (succeeded2) => subscribe ? startCount + succeeded2 : startCount - succeeded2;
    r.progress(verb, 0, names.length, startCount, "subs");
    const { failed, error: lastError, stoppedAt } = await applyAll(
      names,
      subscribe,
      (done, total, bad) => r.progress(verb, done, total, headline(done - bad), "subs"),
      r
    );
    const succeeded = stoppedAt - failed.length;
    const untouched = names.length - stoppedAt;
    const halted = untouched ? ` Stopped — ${untouched} left untouched.` : "";
    r.status("Confirming with Reddit…");
    await sleep(1e3);
    await refresh(r, true).catch(() => r.count(null));
    if (failed.length === 0) {
      r.status(`${past} ${succeeded} subreddits.${halted}`, halted ? "bad" : "ok");
      return;
    }
    r.status(
      `${past} ${succeeded}. Skipped ${failed.length} (${lastError}): ${failed.slice(0, 6).join(", ")}${failed.length > 6 ? "…" : ""}${halted}`,
      "bad"
    );
    console.warn("[Reddit Manager] failed:", failed, lastError);
  }
  async function applyItemDeletions(items, onProgress, r) {
    const failed = [];
    let lastError = "";
    let consecutiveFailures = 0;
    const onRetry = (attempt, maxRetries, waitMs, status) => {
      const msg = `Reddit returned ${status}. Retrying in ${(waitMs / 1e3).toFixed(1)}s (${attempt}/${maxRetries})…`;
      r?.status(msg, "bad");
    };
    for (let i = 0; i < items.length; i++) {
      while (r?.isPaused?.() && !r?.stopped?.()) {
        await sleep(200);
      }
      if (r?.stopped?.())
        return { failed, error: lastError, stoppedAt: i };
      try {
        await deleteUserItem(items[i].fullname, true, onRetry);
        consecutiveFailures = 0;
        r?.status("");
      } catch (err) {
        lastError = err.message;
        failed.push(items[i].fullname);
        consecutiveFailures++;
        if (consecutiveFailures >= GIVE_UP_AFTER) {
          return { failed, error: lastError, stoppedAt: i + 1 };
        }
      }
      onProgress(i + 1, items.length, failed.length);
      if (i + 1 < items.length) await sleep(DELETE_PAUSE_MS);
    }
    return { failed, error: lastError, stoppedAt: items.length };
  }
  async function deleteAllUserItems(r, kind, label) {
    r.status(`Reading your visible ${label} from Reddit…`);
    let items;
    try {
      items = await loadUserItems(kind);
    } catch (e) {
      r.status(e.message, "bad");
      return;
    }
    if (items.length === 0) {
      r.status(`No visible ${label} found to delete.`, "ok");
      return;
    }
    const ok = await r.confirm({
      title: `Delete ${items.length} visible ${label}`,
      body: `Deletes up to Reddit's 1,000 visible history limit. Each item will be overwritten with '.' prior to deletion.`,
      action: `Delete ${label}`,
      danger: true,
      typed: String(items.length)
    });
    if (!ok) return;
    const verb = `Deleting ${label}`;
    r.progress(verb, 0, items.length, items.length, kind);
    const { failed, error: lastError, stoppedAt } = await applyItemDeletions(
      items,
      (done, total, bad) => r.progress(
        verb,
        done,
        total,
        items.length - (done - bad),
        kind
      ),
      r
    );
    const succeeded = stoppedAt - failed.length;
    const untouched = items.length - stoppedAt;
    const halted = untouched ? ` Stopped — ${untouched} left untouched.` : "";
    r.status("Confirming with Reddit…");
    await sleep(500);
    await refresh(r, true, true).catch(() => r.count(null));
    r.status(
      `Deleted ${succeeded} ${label}.${failed.length ? ` (${failed.length} failed: ${lastError})` : ""}${halted}`,
      failed.length || halted ? "bad" : "ok"
    );
  }
  async function deleteAllPosts(r) {
    return deleteAllUserItems(r, "posts", "posts");
  }
  async function deleteAllComments(r) {
    return deleteAllUserItems(r, "comments", "comments");
  }
  async function deleteAccount(r) {
    const ok = await r.confirm({
      title: "Open Account Deletion Page",
      body: "Reddit requires password verification to delete an account. You will be redirected to Reddit's official account deletion page.",
      action: "Open deletion page",
      danger: true
    });
    if (!ok) return;
    r.status("Redirecting to account deletion page…", "ok");
    window.location.href = "https://old.reddit.com/prefs/delete/";
  }
  const svg = (d) => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
  const ACTIONS = [
    {
      key: "export",
      name: "Export",
      hint: "Download your subscriptions as JSON",
      icon: svg('<path d="M8 2v8M4.5 7L8 10.5 11.5 7M2.5 13.5h11"/>'),
      run: exportSubs
    },
    {
      key: "import",
      name: "Import",
      hint: "Join every subreddit in a file",
      icon: svg('<path d="M8 10.5v-8M4.5 6L8 2.5 11.5 6M2.5 13.5h11"/>'),
      run: importSubs
    },
    {
      key: "refresh",
      name: "Refresh",
      hint: "Re-read your list from Reddit",
      icon: svg(
        '<path d="M13.2 8a5.2 5.2 0 1 1-1.7-3.85M13.5 1.8v3.4h-3.4"/>'
      ),
      run: (r) => refresh(r, true, true).then(() => void 0)
    },
    {
      key: "leave",
      name: "Leave all",
      hint: "Unsubscribe from every subreddit",
      icon: svg('<path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/>'),
      run: leaveAll,
      danger: true
    },
    {
      key: "deletePosts",
      name: "Delete visible posts",
      hint: "Overwrite & delete up to 1,000 visible posts",
      icon: svg('<path d="M3 4h10M5 4v9a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V4M6 2h4"/>'),
      run: deleteAllPosts,
      danger: true
    },
    {
      key: "deleteComments",
      name: "Delete visible comments",
      hint: "Overwrite & delete up to 1,000 visible comments",
      icon: svg('<path d="M3 3h10v7H6l-3 3V3z"/>'),
      run: deleteAllComments,
      danger: true
    },
    {
      key: "deleteAccount",
      name: "Open account deletion page",
      hint: "Navigate to official account deletion",
      icon: svg('<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 1.5c-3 0-5.5 2-5.5 4.5v.5h11v-.5c0-2.5-2.5-4.5-5.5-4.5z"/>'),
      run: deleteAccount,
      danger: true
    }
  ];
  const CSS = `
:host {
  position: fixed;
  right: 24px;
  bottom: calc(24px + env(safe-area-inset-bottom, 0px));
  z-index: 2147483647;
  --ink: #0f1217;
  --plate: #1a1e24;
  --chalk: #eceef2;
  --muted: #8b929e;
  --amber: #f59e0b;
  --line: rgba(236, 238, 242, 0.12);
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  color: var(--chalk);
}
* { box-sizing: border-box; }
button { font: inherit; color: inherit; touch-action: manipulation; }
:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }

.wrap { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }

.fab {
  position: relative;
  width: 48px; height: 48px;
  display: grid; place-items: center;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--ink);
  color: var(--amber);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
  cursor: pointer;
  transition: transform .18s cubic-bezier(.2,.85,.3,1), border-color .18s ease, box-shadow .18s ease;
}
.fab:hover { transform: translateY(-2px); border-color: rgba(245, 158, 11, .6); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.65); }
.fab.is-busy { border-color: var(--amber); }

.fab-icon { display: flex; align-items: center; justify-content: center; }
.fab.is-busy .fab-icon { display: none; }

.fab-progress {
  position: absolute; inset: 0;
  display: none; align-items: center; justify-content: center;
}
.fab.is-busy .fab-progress { display: flex; }

.progress-ring { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.ring-fill { transition: stroke-dashoffset .25s ease; }

.fab-badge {
  font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
  color: var(--amber);
  z-index: 1;
}

.panel {
  position: relative;
  width: 292px;
  max-height: calc(100vh - 120px - env(safe-area-inset-bottom, 0px));
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--ink);
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: 0 18px 48px rgba(0, 0, 0, .55);
  transform-origin: bottom right;
  opacity: 0;
  transform: translateY(10px) scale(.96);
  pointer-events: none;
  visibility: hidden;
  transition: opacity .16s ease, transform .22s cubic-bezier(.2,.9,.3,1), visibility .16s ease;
}
.panel.is-open { opacity: 1; transform: none; pointer-events: auto; visibility: visible; }

.head { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px 0; }
.eyebrow { font-size: 10px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); }
.close { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 8px; margin: -8px -8px -8px 0; line-height: 0; border-radius: 4px; }
.close:hover { color: var(--chalk); }

.readout { padding: 12px 14px 14px; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; text-align: center; }
.stat-cell { display: flex; flex-direction: column; align-items: center; }
.figure {
  font: 700 20px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
  font-variant-numeric: tabular-nums;
  letter-spacing: -.02em;
  color: var(--amber);
}
.unit { margin-top: 4px; font-size: 9px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.progress-label { margin-top: 6px; font-size: 11px; font-weight: 600; color: var(--muted); min-height: 14px; text-align: center; }
.progress-label:empty { display: none; }
.rule { margin-top: 8px; height: 2px; background: var(--line); }
.rule > i { display: block; height: 100%; width: 0; background: var(--amber); transition: width .25s ease; }

.actions { border-top: 1px solid var(--line); }
.act {
  display: flex; gap: 11px; align-items: flex-start;
  width: 100%; padding: 11px 14px;
  border: 0; border-bottom: 1px solid var(--line);
  background: transparent;
  text-align: left; cursor: pointer;
}
.act:last-child { border-bottom: 0; }
.act:hover:not(:disabled) { background: var(--plate); }
.act:disabled { opacity: .35; cursor: default; }
.act > .ico { color: var(--amber); line-height: 0; padding-top: 1px; }
.act .name { display: block; font-size: 13px; font-weight: 600; letter-spacing: -.01em; }
.act .hint { display: block; margin-top: 2px; font-size: 11px; line-height: 1.35; color: var(--muted); }
.act.is-danger {
  background-image: repeating-linear-gradient(45deg, rgba(255,184,77,.22) 0 2px, transparent 2px 6px);
  background-size: 4px 100%;
  background-repeat: no-repeat;
}

.status { padding: 9px 14px; border-top: 1px solid var(--line); font-size: 11px; line-height: 1.35; color: var(--muted); }
.status:empty { display: none; }
.status.is-ok { color: var(--chalk); }
.status.is-bad { color: var(--amber); }

.sheet {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  padding: 14px;
  background: rgba(15, 18, 23, 0.88);
  backdrop-filter: blur(6px);
  z-index: 20;
  animation: fadeIn .16s ease;
}
.sheet__card {
  position: relative;
  width: 100%;
  max-width: 264px;
  background: #1c2026;
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 16px 14px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.65);
  display: flex;
  flex-direction: column;
  gap: 10px;
  animation: popIn .2s cubic-bezier(.2,.9,.3,1) both;
}
.sheet.is-danger .sheet__card {
  border-color: rgba(245, 158, 11, 0.35);
  background-image: repeating-linear-gradient(45deg, rgba(245, 158, 11, .06) 0 2px, transparent 2px 6px);
}
.sheet__badge {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; border-radius: 8px;
  background: rgba(245, 158, 11, 0.14); color: var(--amber);
  margin-bottom: 2px;
}
.sheet.is-danger .sheet__badge {
  background: rgba(245, 158, 11, 0.18); color: var(--amber);
}
.sheet__title { font-size: 14px; font-weight: 700; letter-spacing: -.01em; color: var(--chalk); }
.sheet__body { font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.sheet__input {
  width: 100%; padding: 9px 10px;
  font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace;
  color: var(--chalk); background: var(--plate);
  border: 1px solid var(--line); border-radius: 8px;
  transition: border-color .15s ease, box-shadow .15s ease;
}
.sheet__input:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); }
.sheet__row { display: flex; gap: 8px; margin-top: 4px; }
.btn { flex: 1; padding: 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .15s ease; }
.btn:hover { background: var(--plate); }
.btn--go { background: var(--amber); border-color: var(--amber); color: #17130a; }
.btn--go:hover:not(:disabled) { background: #ffc76e; }
.btn--go:disabled { background: transparent; border-color: var(--line); color: var(--muted); opacity: .5; cursor: default; }

@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes popIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }

.panel.is-open .act { animation: rise .26s cubic-bezier(.2,.9,.3,1) both; }
.panel.is-open .act:nth-child(1) { animation-delay: .04s; }
.panel.is-open .act:nth-child(2) { animation-delay: .07s; }
.panel.is-open .act:nth-child(3) { animation-delay: .10s; }
.panel.is-open .act:nth-child(4) { animation-delay: .13s; }
@keyframes rise { from { opacity: 0; transform: translateY(6px); } }

.controls-running {
  display: none;
  gap: 8px;
  padding: 8px 14px;
  border-top: 1px solid var(--line);
}
.panel.is-running .controls-running { display: flex; }
.btn-ctrl {
  flex: 1;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--plate);
  color: var(--chalk);
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  touch-action: manipulation;
}
.btn-ctrl:hover:not(:disabled) { background: rgba(236, 238, 242, 0.12); }
.btn-ctrl.cancel-btn { color: #f87171; border-color: rgba(248, 113, 113, 0.3); }
.btn-ctrl.cancel-btn:hover:not(:disabled) { background: rgba(248, 113, 113, 0.15); }
.btn-ctrl:disabled { opacity: .4; cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .fab, .panel, .rule > i, .act { transition: none; animation: none; }
}

@media (max-width: 480px) {
  :host { right: 12px; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); }
  .panel { width: min(320px, calc(100vw - 24px)); }
  .fab { width: 52px; height: 52px; }
  .act { padding: 13px 14px; }
  .btn { padding: 12px; }
  .sheet__input { padding: 10px; font-size: 16px; }
}
`;
  const RING = 2 * Math.PI * 20;
  class Panel {
    root;
    panel;
    fab;
    ringFill;
    badge;
    figSubs;
    figPosts;
    figComments;
    progressLabel;
    bar;
    statusEl;
    pauseBtn;
    cancelBtn;
    buttons = [];
    busy = false;
    open = false;
    paused = false;
    stopping = false;
    refreshGen = 0;
    activeResolve = null;
    constructor() {
      const host = document.createElement("div");
      this.root = host.attachShadow({ mode: "open" });
      this.root.innerHTML = `
      <style>${CSS}</style>
      <div class="wrap">
        <div class="panel" role="dialog" aria-label="Reddit Manager" aria-hidden="true">
          <div class="head">
            <span class="eyebrow">Dashboard</span>
            <button class="close" aria-label="Close">${svg('<path d="M4 4l8 8M12 4l-8 8"/>')}</button>
          </div>
          <div class="readout">
            <div class="stats-grid">
              <div class="stat-cell">
                <div class="figure fig-subs">—</div>
                <div class="unit">Subreddits</div>
              </div>
              <div class="stat-cell">
                <div class="figure fig-posts">—</div>
                <div class="unit">Posts</div>
              </div>
              <div class="stat-cell">
                <div class="figure fig-comments">—</div>
                <div class="unit">Comments</div>
              </div>
            </div>
            <div class="progress-label"></div>
            <div class="rule"><i></i></div>
          </div>
          <div class="actions">${ACTIONS.map(
      (a) => `
            <button class="act${"danger" in a ? " is-danger" : ""}" data-key="${a.key}">
              <span class="ico">${a.icon}</span>
              <span><span class="name">${a.name}</span><span class="hint">${a.hint}</span></span>
            </button>`
    ).join("")}</div>
          <div class="controls-running">
            <button class="btn-ctrl pause-btn">Pause</button>
            <button class="btn-ctrl cancel-btn">Cancel</button>
          </div>
          <div class="status"></div>
        </div>
        <button class="fab" aria-label="Subreddit Subscriptions" title="Subreddit Subscription Manager" aria-expanded="false">
          <span class="fab-icon">${svg('<path d="M2.5 4.5h11M2.5 8h8M2.5 11.5h5"/>')}</span>
          <div class="fab-progress" aria-hidden="true">
            <svg class="progress-ring" width="48" height="48" viewBox="0 0 48 48">
              <circle class="ring-bg" cx="24" cy="24" r="20" fill="none" stroke="rgba(236, 238, 242, 0.14)" stroke-width="3"/>
              <circle class="ring-fill" cx="24" cy="24" r="20" fill="none" stroke="var(--amber)" stroke-width="3" stroke-dasharray="${RING}" stroke-dashoffset="${RING}" stroke-linecap="round" transform="rotate(-90 24 24)"/>
            </svg>
            <span class="fab-badge">0%</span>
          </div>
        </button>
      </div>`;
      const $ = (sel) => this.root.querySelector(sel);
      this.panel = $(".panel");
      this.fab = $(".fab");
      this.ringFill = $(".ring-fill");
      this.badge = $(".fab-badge");
      this.figSubs = $(".fig-subs");
      this.figPosts = $(".fig-posts");
      this.figComments = $(".fig-comments");
      this.progressLabel = $(".progress-label");
      this.bar = $(".rule > i");
      this.statusEl = $(".status");
      this.pauseBtn = $(".pause-btn");
      this.cancelBtn = $(".cancel-btn");
      this.buttons = [...this.root.querySelectorAll(".act")];
      this.fab.addEventListener("click", () => this.toggle());
      $(".close").addEventListener("click", () => this.toggle(false));
      this.pauseBtn.addEventListener("click", () => {
        this.paused = !this.paused;
        this.pauseBtn.textContent = this.paused ? "Resume" : "Pause";
        if (this.paused) {
          this.status("Operation paused", "bad");
        } else {
          this.status("");
        }
      });
      this.cancelBtn.addEventListener("click", () => {
        this.stopping = true;
        this.paused = false;
        this.cancelBtn.disabled = true;
        this.cancelBtn.textContent = "Cancelling…";
        this.status("Cancelling after current item…", "bad");
      });
      for (const btn of this.buttons) {
        btn.addEventListener("click", () => this.run(btn.dataset.key));
      }
      document.addEventListener("keydown", (e) => {
        if (e.key === "Escape") this.toggle(false);
      });
      document.addEventListener("click", (e) => {
        if (this.open && !e.composedPath().includes(host)) this.toggle(false);
      });
      document.body.append(host);
    }
    toggle(next = !this.open) {
      this.open = next;
      this.panel.classList.toggle("is-open", next);
      this.panel.setAttribute("aria-hidden", String(!next));
      this.panel.style.visibility = next ? "visible" : "hidden";
      this.fab.setAttribute("aria-expanded", String(next));
      if (!next) {
        this.fab.focus();
        if (this.activeResolve) {
          const resolve = this.activeResolve;
          this.activeResolve = null;
          resolve(false);
        }
      }
      if (next && !this.busy) {
        const gen = ++this.refreshGen;
        refresh(this, false, false).then(() => {
          if (gen !== this.refreshGen) return;
        }).catch((e) => {
          if (gen === this.refreshGen) this.status(e.message, "bad");
        });
      }
    }
    async run(key) {
      const action = ACTIONS.find((a) => a.key === key);
      if (!action || this.busy) return;
      if (!this.open) this.toggle(true);
      this.setBusy(true);
      try {
        await action.run(this);
      } catch (e) {
        this.status(e.message, "bad");
      } finally {
        this.setBusy(false);
      }
    }
    onBeforeUnload = (e) => {
      if (this.busy) {
        e.preventDefault();
        e.returnValue = "Still working. What's already done stays done — leaving only cancels the rest.";
        return e.returnValue;
      }
    };
    stopped() {
      return this.stopping;
    }
    isPaused() {
      return this.paused;
    }
    setBusy(busy) {
      this.busy = busy;
      this.fab.classList.toggle("is-busy", busy);
      this.panel.classList.toggle("is-running", busy);
      for (const b of this.buttons) b.disabled = busy;
      if (busy) {
        this.stopping = false;
        this.paused = false;
        this.pauseBtn.disabled = false;
        this.pauseBtn.textContent = "Pause";
        this.cancelBtn.disabled = false;
        this.cancelBtn.textContent = "Cancel";
        window.addEventListener("beforeunload", this.onBeforeUnload);
      } else {
        window.removeEventListener("beforeunload", this.onBeforeUnload);
        this.fab.title = "Subreddit Subscription Manager";
        this.ringFill.style.strokeDashoffset = String(RING);
        this.badge.textContent = "0%";
        this.progressLabel.textContent = "";
      }
    }
    // ---------- Report ----------
    count(n, stats) {
      this.bar.style.width = "0%";
      this.progressLabel.textContent = "";
      this.figSubs.textContent = n === null ? "—" : String(n);
      this.figPosts.textContent = stats?.posts == null ? "—" : String(stats.posts);
      this.figComments.textContent = stats?.comments == null ? "—" : String(stats.comments);
    }
    progress(verb, done, total, headline, metric = "subs") {
      this.progressLabel.textContent = `${verb} — ${done} of ${total}`;
      if (headline != null) {
        if (metric === "posts") {
          this.figPosts.textContent = String(headline);
        } else if (metric === "comments") {
          this.figComments.textContent = String(headline);
        } else {
          this.figSubs.textContent = String(headline);
        }
      }
      const pct = total > 0 ? done / total : 0;
      this.bar.style.width = `${pct * 100}%`;
      this.ringFill.style.strokeDashoffset = String(RING * (1 - pct));
      this.badge.textContent = `${Math.round(pct * 100)}%`;
      this.fab.title = `${verb} — ${done} of ${total}`;
    }
    status(text, tone) {
      this.statusEl.textContent = text;
      this.statusEl.className = `status${tone ? ` is-${tone}` : ""}`;
    }
    confirm(spec) {
      return new Promise((resolve) => {
        this.activeResolve = resolve;
        const sheet = document.createElement("div");
        sheet.className = `sheet${spec.danger ? " is-danger" : ""}`;
        const card = document.createElement("div");
        card.className = "sheet__card";
        const badge = document.createElement("div");
        badge.className = "sheet__badge";
        badge.innerHTML = spec.danger ? svg('<path d="M8 2l6 11H2L8 2zM8 6v3M8 11h.01"/>') : svg('<path d="M8 2.5v11M2.5 8h11"/>');
        const titleEl = document.createElement("div");
        titleEl.className = "sheet__title";
        titleEl.textContent = spec.title;
        const bodyEl = document.createElement("div");
        bodyEl.className = "sheet__body";
        bodyEl.textContent = spec.body;
        card.append(badge, titleEl, bodyEl);
        let input = null;
        if (spec.typed) {
          input = document.createElement("input");
          input.className = "sheet__input";
          input.placeholder = `Type ${spec.typed} to confirm`;
          input.setAttribute("aria-label", `Type ${spec.typed} to confirm`);
          card.append(input);
        }
        const row = document.createElement("div");
        row.className = "sheet__row";
        const cancelBtn = document.createElement("button");
        cancelBtn.className = "btn btn--cancel";
        cancelBtn.textContent = "Cancel";
        const goBtn = document.createElement("button");
        goBtn.className = "btn btn--go";
        goBtn.textContent = spec.action;
        if (spec.typed) goBtn.disabled = true;
        row.append(cancelBtn, goBtn);
        card.append(row);
        sheet.append(card);
        const finish = (ok) => {
          this.activeResolve = null;
          sheet.remove();
          resolve(ok);
        };
        if (input) {
          input.addEventListener("input", () => {
            goBtn.disabled = input.value.trim() !== spec.typed;
          });
          input.addEventListener("keydown", (e) => {
            if (e.key === "Enter" && !goBtn.disabled) finish(true);
          });
        }
        goBtn.addEventListener("click", () => finish(true));
        cancelBtn.addEventListener("click", () => finish(false));
        this.panel.append(sheet);
        (input ?? goBtn).focus();
      });
    }
    pickFile() {
      return new Promise((resolve) => {
        const input = document.createElement("input");
        input.type = "file";
        input.accept = ".json,.txt,.csv,application/json,text/plain";
        const done = (v) => {
          window.removeEventListener("focus", onFocus);
          resolve(v);
        };
        const onFocus = () => setTimeout(() => {
          if (!input.files?.length) done(null);
        }, 400);
        input.onchange = () => done(input.files?.[0]?.text() ?? null);
        input.oncancel = () => done(null);
        window.addEventListener("focus", onFocus);
        input.click();
      });
    }
  }
  new Panel();

})();