Reddit Manager

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();

})();