// ==UserScript== // @name GithubStarManager // @namespace https://github.com/YsLtr // @version 4.19.1 // @author YsLtr // @description 将 GitHub Stars 页面的列表视图改为卡片网格视图,缩小左侧个人资料栏,最大化仓库展示空间(仅桌面端生效;窄视口 <768px 下完全惰性,不注入样式、不改动页面) // @license MIT // @icon https://github.com/favicon.ico // @match https://github.com/* // @grant GM_download // @grant GM_getValue // @grant GM_openInTab // @grant GM_registerMenuCommand // @grant GM_setValue // @run-at document-start // ==/UserScript== (function() { "use strict"; var persistent_default = "@media (min-width:768px){.stars-right-sidebar{display:none!important}.Layout.Layout--sidebarPosition-start{transition:grid-template-columns .35s}.Layout-sidebar{transition:width .35s,min-width .35s}.Layout-sidebar .avatar-user.width-full,.Layout-sidebar a[href*=avatars] img,.Layout-sidebar a[href*=avatars]{transition:width .35s,height .35s}.Layout-sidebar .user-status-circle-badge-container{transition:top .35s,left .35s}}"; var LS_PREFIX = "github-star-manager::"; var SENSITIVE_KEYS = new Set(["github_pat"]); function lsRead(key) { try { const raw = localStorage.getItem(LS_PREFIX + key); return raw === null ? void 0 : JSON.parse(raw); } catch { return; } } function lsWrite(key, value) { if (SENSITIVE_KEYS.has(key)) { try { localStorage.removeItem(LS_PREFIX + key); } catch {} return; } try { localStorage.setItem(LS_PREFIX + key, JSON.stringify(value)); } catch (e) { console.error("[github-star-manager] localStorage 写入失败", e); } } function gmGet(key, defaultValue) { if (typeof GM_getValue === "function") { const gmValue = GM_getValue(key, defaultValue); const lsValue = lsRead(key); if (lsValue !== void 0 && JSON.stringify(gmValue) === JSON.stringify(defaultValue)) { if (typeof GM_setValue === "function") try { GM_setValue(key, lsValue); if (SENSITIVE_KEYS.has(key)) try { localStorage.removeItem(LS_PREFIX + key); } catch {} } catch {} return lsValue; } return gmValue; } warnMissingGmApi("GM_getValue"); const lsValue = lsRead(key); return lsValue === void 0 ? defaultValue : lsValue; } function gmSet(key, value) { if (typeof GM_setValue === "function") try { GM_setValue(key, value); } catch (e) { console.error("[github-star-manager] GM_setValue 失败,仅写 localStorage", e); } lsWrite(key, value); } function gmAddStyle(css) { const style = document.createElement("style"); style.textContent = css; (document.head || document.documentElement).appendChild(style); return style; } function missingGmApiReason() { const loaderKeys = Object.keys(document).filter((k) => k.startsWith("__monkeyWindow-")); if (loaderKeys.length > 0) return `dev loader 与本地产物的 window key 不一致(页面是 ${loaderKeys[0]})——改过脚本头(如 @version)后必现,导致 mountGmApi 读不到 sandbox window。重新安装 http://127.0.0.1:5173/__vite-plugin-monkey.install.user.js 即可恢复。`; return "非 TM 环境,或 TM 未给该脚本该授权(@grant 缺项)"; } var gmApiWarned = false; function warnMissingGmApi(missing) { if (gmApiWarned) return; gmApiWarned = true; console.warn(`[github-star-manager] GM API 不可用(首个缺失的调用:${missing}):${missingGmApiReason()}\n· 后果:TM 菜单项消失;PAT 读不到(安全设计上 PAT 不写 localStorage 镜像);GM_openInTab / GM_download 退化为本地回退或失效。 · 页面渲染不受影响:缓存等非敏感数据仍经 localStorage 镜像可用。`); } function gmRegisterMenuCommand(name, fn, options) { if (typeof GM_registerMenuCommand === "function") try { return GM_registerMenuCommand(name, fn, options); } catch (e) { console.error("[github-star-manager] GM_registerMenuCommand 失败", e); } else warnMissingGmApi("GM_registerMenuCommand"); } function gmOpenInTab(url) { if (typeof GM_openInTab === "function") try { GM_openInTab(url, { active: true }); return; } catch (e) { console.error("[github-star-manager] GM_openInTab 失败,回退 window.open", e); } window.open(url, "_blank", "noopener"); } function gmDownloadFile(data, filename) { if (typeof GM_download !== "function") { warnMissingGmApi("GM_download"); return false; } try { GM_download({ url: data, name: filename }); return true; } catch (e) { console.error("[github-star-manager] GM_download 调用失败", e); return false; } } var GRACE_PERIOD = 864e5; var SORT_OPTIONS = [ { key: "created", label: "Recently starred" }, { key: "updated", label: "Recently active" }, { key: "stars", label: "Most stars" }, { key: "forks", label: "Most Forks" } ]; var TYPE_OPTIONS = [ { value: "public", label: "Public" }, { value: "private", label: "Private" }, { value: "source", label: "Sources" }, { value: "fork", label: "Forks" }, { value: "mirror", label: "Mirrors" }, { value: "template", label: "Templates" } ]; var STORAGE_KEYS = { repoCache: "stars_repo_cache", pendingDeletePrefix: "stars_pending_delete_", tagsPrefix: "stars_tags_", notesPrefix: "stars_notes_", githubPat: "github_pat", fullSyncMeta: "stars_full_sync_meta", langColors: "stars_lang_colors", hideLists: "stars_hide_lists", accountIdentity: "stars_account_identity", accountBannerDismissed: "stars_account_banner_dismissed" }; var GSM_HIDDEN_ATTR = "data-gsm-hidden"; var GSM_TOPICS_SRC_ATTR = "data-gsm-topics-src"; var STARS_LAYOUT_CLASS = "gsm-stars-layout"; var EXPORT_KIND = "github-star-manager-export"; function localStamp(d) { const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; } function buildExportFilename(userId) { return `github-star-manager-${userId}-${localStamp(new Date())}.json`; } var STAR_FILL_SVG = ""; var STAR_EMPTY_SVG = ""; var STAR_META_SVG = ""; var FORK_META_SVG = ""; var TRIANGLE_DOWN_SVG = ""; var CHECK_SVG = ""; var ARROW_DOWN_SVG = ""; var ARROW_UP_SVG = ""; var SYNC_SVG = ""; function escapeHtml(str) { const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } var DESKTOP_QUERY = `(min-width: 768px)`; var BREAKPOINT_DEBOUNCE_MS = 150; function isDesktop() { return window.matchMedia(DESKTOP_QUERY).matches; } function subscribeBreakpointChange(onChange) { const mql = window.matchMedia(DESKTOP_QUERY); let timer; const handler = () => { if (timer !== void 0) window.clearTimeout(timer); timer = window.setTimeout(() => { timer = void 0; onChange(mql.matches); }, BREAKPOINT_DEBOUNCE_MS); }; mql.addEventListener("change", handler); return () => { if (timer !== void 0) window.clearTimeout(timer); mql.removeEventListener("change", handler); }; } function formatRelative(iso) { const t = Date.parse(iso); if (!isFinite(t)) return ""; const diff = Date.now() - t; if (diff < 0) return "just now"; const hour = 36e5; const day = 864e5; if (diff < hour) return "just now"; const hours = Math.floor(diff / hour); if (hours < 24) return hours === 1 ? "1 hour ago" : hours + " hours ago"; const days = Math.floor(diff / day); if (days < 30) return days === 1 ? "1 day ago" : days + " days ago"; const months = Math.floor(days / 30); if (months < 12) return months === 1 ? "1 month ago" : months + " months ago"; const years = Math.floor(days / 365); return years <= 1 ? "1 year ago" : years + " years ago"; } function hideNativeNode(el) { el.style.setProperty("display", "none", "important"); el.setAttribute(GSM_HIDDEN_ATTR, "1"); } function showNativeNode(el) { el.style.removeProperty("display"); el.removeAttribute(GSM_HIDDEN_ATTR); } function findStarsLayout() { return (document.querySelector(".stars-grid-container") ?? document.getElementById("user-starred-repos"))?.closest(".Layout") ?? null; } function markStarsLayout() { const layout = findStarsLayout(); if (layout && !layout.classList.contains("gsm-stars-layout")) layout.classList.add(STARS_LAYOUT_CLASS); } function unmarkStarsLayout() { document.querySelectorAll(`.${STARS_LAYOUT_CLASS}`).forEach((el) => el.classList.remove(STARS_LAYOUT_CLASS)); } function moveTopicsToRightSidebar() { const colLg3 = document.querySelector("#user-starred-repos .col-lg-3"); if (!colLg3) return false; const layoutEl = findStarsLayout(); if (!layoutEl) return false; let rightSidebar = layoutEl.querySelector(".stars-right-sidebar"); if (!rightSidebar) { rightSidebar = document.createElement("div"); rightSidebar.className = "stars-right-sidebar"; layoutEl.appendChild(rightSidebar); } rightSidebar.innerHTML = ""; while (colLg3.firstChild) rightSidebar.appendChild(colLg3.firstChild); colLg3.setAttribute(GSM_TOPICS_SRC_ATTR, "1"); return true; } function restoreTopicsFromRightSidebar() { const rightSidebar = document.querySelector(".stars-right-sidebar"); if (!rightSidebar) return; const colLg3 = document.querySelector(`#user-starred-repos .col-lg-3[${GSM_TOPICS_SRC_ATTR}]`); if (colLg3 && colLg3.isConnected) { while (rightSidebar.firstChild) colLg3.appendChild(rightSidebar.firstChild); colLg3.removeAttribute(GSM_TOPICS_SRC_ATTR); } rightSidebar.remove(); } function getRepoIdMeta() { return document.querySelector("meta[name=\"octolytics-dimension-repository_id\"]"); } function getStarsMainColumn() { const frame = document.getElementById("user-starred-repos"); if (!frame) return null; return frame.querySelector(".col-lg-9"); } function isRepoItem(el) { return !el.classList.contains("stars-original-hidden") && !el.classList.contains("stars-grid-card") && !el.classList.contains("stars-grid-card-cached"); } function getRepoItems(container) { for (const sel of ["div:has(> div.f6.color-fg-muted)", "div[class*=\"col-12\"][class*=\"border-bottom\"]"]) { let found; try { found = Array.from(container.querySelectorAll(sel)); } catch { continue; } const items = found.filter(isRepoItem); if (items.length > 0) return items; } return []; } function getNativeFilterRow() { const langBtn = document.getElementById("stars-language-filter-menu-button"); const row = langBtn ? langBtn.closest("div.d-flex") : null; if (row instanceof HTMLElement) return row; return null; } function getNativeFilterBar(container) { const bar = container.querySelector(".TableObject.border-bottom:not(.stars-tag-info-bar)"); if (bar) return bar; return null; } var LISTS_HIDDEN_CLASS = "stars-lists-hidden"; function isHideListsEnabled() { return gmGet(STORAGE_KEYS.hideLists, true); } function applyHideListsGate() { document.documentElement.classList.toggle("gsm-hide-lists", isDesktop() && isHideListsEnabled()); } function clearListsHiddenMarks() { document.querySelectorAll(".stars-lists-hidden").forEach((el) => { el.classList.remove(LISTS_HIDDEN_CLASS); el.style.removeProperty("display"); }); } function hideListsSection() { if (!isDesktop()) { clearListsHiddenMarks(); return; } if (!isHideListsEnabled()) { clearListsHiddenMarks(); return; } const frame = document.getElementById("user-profile-frame"); const wrapper = frame ? frame.firstElementChild : null; if (!wrapper) return; Array.from(wrapper.children).forEach((child) => { if (!(child instanceof HTMLElement)) return; const heading = child.querySelector("h2.f3-light"); const isListsRow = !!heading && (heading.textContent || "").includes("Lists"); const isListsEmpty = child.classList.contains("blankslate"); if (!isListsRow && !isListsEmpty && child.id !== "profile-lists-container") return; child.classList.add(LISTS_HIDDEN_CLASS); child.style.setProperty("display", "none", "important"); }); } var persistentStyleEl = null; var layoutStyleEl = null; function ensureLayoutStyles() { if (!isDesktop()) return; if (!persistentStyleEl || !persistentStyleEl.isConnected) persistentStyleEl = gmAddStyle(persistent_default); if (!layoutStyleEl || !layoutStyleEl.isConnected) layoutStyleEl = gmAddStyle("@media (min-width:768px){.container-xl{max-width:1600px!important}.Layout.gsm-stars-layout{--Layout-sidebar-width:180px!important}.gsm-stars-layout .Layout-sidebar{width:180px!important;min-width:180px!important}.gsm-stars-layout .Layout-sidebar .avatar-user.width-full,.gsm-stars-layout .Layout-sidebar a[href*=avatars] img{width:120px!important;height:120px!important}.gsm-stars-layout .Layout-sidebar a[href*=avatars]{width:120px!important;height:120px!important;display:block!important}html.gsm-anim-prepare .Layout.gsm-stars-layout{grid-template-columns:296px minmax(0,1fr) auto!important}html.gsm-anim-prepare .gsm-stars-layout .Layout-sidebar{width:296px!important;min-width:296px!important}html.gsm-anim-prepare .gsm-stars-layout .Layout-sidebar .avatar-user.width-full,html.gsm-anim-prepare .gsm-stars-layout .Layout-sidebar a[href*=avatars] img,html.gsm-anim-prepare .gsm-stars-layout .Layout-sidebar a[href*=avatars]{width:296px!important;height:296px!important}html.gsm-anim-prepare .gsm-stars-layout .Layout-sidebar .user-status-circle-badge-container{top:262px!important;left:262px!important}.gsm-stars-layout .Layout-sidebar .vcard-names .p-name,.gsm-stars-layout .Layout-sidebar .vcard-names .p-nickname,.gsm-stars-layout .Layout-sidebar h1.vcard-names{font-size:16px!important}.gsm-stars-layout .Layout-sidebar .p-nickname{font-size:14px!important}.gsm-stars-layout .Layout-sidebar .border-top.pt-3.mt-3.clearfix.hide-sm.hide-md{display:block!important}.gsm-stars-layout .Layout-sidebar .js-profile-editable-replace{overflow:visible!important}.gsm-stars-layout .Layout-sidebar .user-status-container.position-relative{position:static!important}.gsm-stars-layout .Layout-sidebar .user-status-circle-badge-container{z-index:100!important;margin:0!important;position:absolute!important;inset:86px auto auto 86px!important}.gsm-stars-layout .Layout-sidebar .h-card{overflow-clip-margin:16px!important;word-wrap:break-word!important;overflow:clip!important}.gsm-stars-layout .Layout-sidebar .js-profile-editable-replace{word-wrap:break-word!important}.gsm-stars-layout .Layout-sidebar .btn{padding:3px 10px!important;font-size:12px!important}.gsm-stars-layout .Layout-sidebar .flex-order-2{font-size:13px!important}.gsm-stars-layout .Layout-main{flex:1!important;max-width:100%!important}turbo-frame#user-starred-repos .d-lg-flex.gutter-lg{margin-left:0!important;margin-right:0!important;display:block!important}turbo-frame#user-starred-repos .col-lg-9{width:100%!important;max-width:100%!important;padding-left:0!important;padding-right:0!important}html.gsm-hide-lists .stars-lists-hidden,html.gsm-hide-lists turbo-frame#user-profile-frame>div>#profile-lists-container,html.gsm-hide-lists turbo-frame#user-profile-frame>div>div.blankslate{display:none!important}.stars-grid-container{grid-template-columns:repeat(auto-fill,minmax(340px,1fr))!important;gap:16px!important;padding:16px 0!important;display:grid!important}.stars-grid-card{border:1px solid var(--borderColor-default,#d1d9e0)!important;background:var(--bgColor-default,#fff)!important;box-sizing:border-box!important;border-radius:6px!important;flex-direction:column!important;min-width:0!important;padding:16px!important;display:flex!important}.stars-grid-card:hover{box-shadow:0 1px 3px #00000014}.stars-grid-card .stars-card-header h3{white-space:nowrap!important;text-overflow:ellipsis!important;min-width:0!important;margin:0!important;font-size:14px!important;font-weight:600!important;overflow:hidden!important}.stars-grid-card .stars-card-header h3 a{color:var(--fgColor-accent,#0969da)!important}.stars-grid-card .stars-card-desc{color:var(--fgColor-muted,#656d76)!important;-webkit-line-clamp:3!important;-webkit-box-orient:vertical!important;min-width:0!important;margin:0 0 12px!important;font-size:12px!important;line-height:1.5!important;display:-webkit-box!important;overflow:hidden!important}.stars-grid-card .stars-card-meta{color:var(--fgColor-muted,#656d76)!important;flex-wrap:wrap!important;align-items:center!important;gap:4px 12px!important;min-width:0!important;margin-top:auto!important;font-size:12px!important;display:flex!important}.stars-grid-card .stars-card-meta .stars-meta-main{white-space:nowrap!important;flex-wrap:nowrap!important;align-items:center!important;gap:12px!important;display:flex!important}.stars-grid-card .stars-card-meta .stars-meta-updated{white-space:nowrap!important}.stars-grid-card .stars-card-meta a{color:var(--fgColor-muted,#656d76)!important;text-decoration:none!important}.stars-grid-card .stars-card-meta a:hover{color:var(--fgColor-accent,#0969da)!important}.stars-grid-card .stars-card-meta .repo-language-color{border-radius:50%!important;width:12px!important;height:12px!important;display:inline-block!important;position:relative!important;top:1px!important}.stars-card-tags{flex-wrap:wrap;align-items:center;gap:6px;min-height:22px;margin:0 0 8px;display:flex}.stars-tag{padding:var(--base-size-2,2px) var(--base-size-8,8px);border-radius:var(--base-size-12,12px);font-size:var(--text-body-size-small,12px);font-weight:var(--base-text-weight-normal,400);background:var(--bgColor-accent-muted,#ddf4ff);color:var(--fgColor-accent,#0969da);white-space:nowrap;cursor:pointer;align-items:center;line-height:1.4;display:inline-flex}.stars-tag .stars-tag-del{cursor:pointer;opacity:0;width:0;font-size:14px;line-height:1;transition:width .15s,opacity .15s,margin .15s;overflow:hidden}.stars-tag:hover .stars-tag-del{opacity:.6;width:12px;margin-left:6px}.stars-tag .stars-tag-del:hover{opacity:1}.stars-tag-add{border:1px dashed var(--borderColor-default,#d1d9e0);width:22px;height:22px;color:var(--fgColor-muted,#656d76);cursor:pointer;opacity:0;background:0 0;border-radius:50%;justify-content:center;align-items:center;transition:opacity .15s;display:inline-flex;position:relative}.stars-tag-add:before,.stars-tag-add:after{content:\"\";background:currentColor;border-radius:1px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.stars-tag-add:before{width:10px;height:1.5px}.stars-tag-add:after{width:1.5px;height:10px}.stars-grid-card:hover .stars-tag-add{opacity:1}.stars-tag-add:hover{background:var(--bgColor-accent-muted,#ddf4ff);color:var(--fgColor-accent,#0969da);border-color:var(--fgColor-accent,#0969da)}.stars-tag-input{border:1px solid var(--fgColor-accent,#0969da);background:var(--bgColor-default,#fff);height:22px;color:var(--fgColor-default,#1f2328);box-sizing:border-box;border-radius:12px;outline:none;align-items:center;width:80px;padding:0 8px;font-size:12px;line-height:1.4;display:inline-flex}.stars-tag-input::placeholder{color:var(--fgColor-muted,#656d76);opacity:.7}.stars-card-notes{border-top:1px dashed var(--borderColor-default,#d1d9e0);cursor:text;min-height:20px;margin-top:10px;padding-top:8px}.stars-card-notes[data-gsm-card-state=locked-pending],.stars-card-notes[data-gsm-card-state=locked-empty]{cursor:default}.stars-card-notes-text{color:var(--fgColor-muted,#656d76);white-space:pre-wrap;word-break:break-word;max-height:60px;font-size:12px;line-height:1.5;overflow-y:auto}.stars-card-notes-placeholder{color:var(--fgColor-muted,#656d76);opacity:0;font-size:12px;font-style:italic;transition:opacity .15s}.stars-grid-card:hover .stars-card-notes-placeholder{opacity:.5}.stars-card-notes-edit{width:100%;color:var(--fgColor-default,#1f2328);background:var(--bgColor-default,#fff);border:1px solid var(--fgColor-accent,#0969da);resize:vertical;box-sizing:border-box;border-radius:4px;outline:none;min-height:40px;max-height:120px;padding:4px 6px;font-family:inherit;font-size:12px;line-height:1.5}.stars-grid-container .paginate-container{grid-column:1/-1!important}.gsm-page-input{text-align:center;font-variant-numeric:tabular-nums;min-width:0;padding-left:6px;padding-right:6px}.gsm-header-row{align-items:center;gap:16px;display:flex}.gsm-header-row>h2{flex:1;min-width:0}.gsm-top-pager,.gsm-sync-btn{flex:none}.gsm-sync-btn .octicon{margin-right:4px}@media (prefers-reduced-motion:no-preference){.gsm-sync-btn[aria-busy=true] .octicon-sync{animation:1s linear infinite gsm-spin}}.gsm-sync-btn[aria-disabled=true]{pointer-events:none;cursor:progress}.gsm-sync-btn.gsm-sync-failed,.gsm-sync-btn.gsm-sync-failed .octicon{color:var(--fgColor-danger,#cf222e)}.gsm-sync-status{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}@keyframes gsm-spin{to{transform:rotate(360deg)}}.stars-original-hidden{display:none!important}.stars-tag-filter .has-active{border-color:var(--fgColor-accent,#0969da)!important;color:var(--fgColor-accent,#0969da)!important}.stars-tag-filter .gsm-tag-chips{box-sizing:border-box;overscroll-behavior:contain;flex-wrap:wrap;align-content:flex-start;align-items:center;gap:8px;width:320px;max-height:300px;margin:0;padding:12px;list-style:none;display:flex;overflow-y:auto}.gsm-tag-chip{padding:var(--base-size-2,2px) var(--base-size-12,12px);border-radius:var(--borderRadius-full,100vh);font-size:var(--text-body-size-small,12px);font-weight:var(--base-text-weight-normal,400);line-height:var(--text-body-lineHeight-small,20px);border:var(--borderWidth-thin,1px) solid transparent;background:var(--bgColor-accent-muted,#ddf4ff);color:var(--fgColor-accent,#0969da);white-space:nowrap;cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;font-family:inherit;display:inline-flex}.gsm-tag-chip:hover{border-color:var(--fgColor-accent,#0969da)}.gsm-tag-chip[aria-checked=true]{background:var(--bgColor-accent-emphasis,#0969da);color:var(--fgColor-onEmphasis,#fff);border-color:#0000}.gsm-tag-chips-empty{color:var(--fgColor-muted,#59636e);font-size:var(--text-body-size-small,12px);line-height:var(--text-body-lineHeight-small,20px);-webkit-user-select:none;user-select:none}.stars-tag-active{background:var(--fgColor-accent,#0969da)!important;color:#fff!important}.stars-custom-filter .has-active{border-color:var(--fgColor-accent,#0969da)!important;color:var(--fgColor-accent,#0969da)!important}.stars-custom-filter .ActionListWrap{min-width:180px;max-height:300px;overflow-y:auto}.stars-custom-filter .gsm-check-visual{margin-left:var(--control-medium-gap,8px);align-items:center;display:inline-flex}.gsm-sort-group{align-items:stretch;display:inline-flex}.gsm-sort-group>.Button{border-radius:0}.gsm-sort-group>.Button:first-child{border-radius:6px 0 0 6px}.gsm-sort-group>.gsm-dir-btn{border-radius:0 6px 6px 0;margin-left:-1px;padding:0 8px;position:relative}.gsm-sort-group>.gsm-dir-btn:hover,.gsm-sort-group>.gsm-dir-btn:focus{z-index:1}.stars-card-header{align-items:center!important;gap:8px!important;margin-bottom:8px!important;display:flex!important}.stars-card-header h3{flex:1!important;min-width:0!important}.stars-star-btn{cursor:pointer;opacity:0;color:var(--fgColor-muted,#656d76);background:0 0;border:none;flex-shrink:0;padding:2px;line-height:0;transition:opacity .15s}.stars-grid-card:hover .stars-star-btn{opacity:1}.stars-star-btn.starred{color:#e3b341}.stars-grid-container.gsm-other-stars .stars-star-btn.starred{opacity:1}.stars-star-btn.unstarred{opacity:1;color:var(--fgColor-muted,#656d76)}.stars-star-btn:hover{opacity:1!important}turbo-frame#user-starred-repos .col-lg-3[data-gsm-topics-src],.gsm-stars-layout .Layout-sidebar,.stars-right-sidebar{display:none!important}.Layout.gsm-stars-layout{grid-template-columns:1fr!important}.gsm-stars-layout .Layout-main{grid-column:1!important;max-width:none!important}}.gsm-turbo-hidden{visibility:hidden!important}@keyframes gsm-grid-in{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}mark.gsm-search-hit{background:var(--bgColor-attention-muted);color:var(--fgColor-default);border-radius:2px;padding:0 1px}html.gsm-turbo-entry .stars-grid-container{animation:.3s both gsm-grid-in}.gsm-setup-banner{border:1px solid var(--borderColor-default,#d0d7de);background:var(--bgColor-muted,#f6f8fa);color:var(--fgColor-default,#1f2328);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:16px;padding:12px 16px;font-size:14px;display:flex}.gsm-setup-banner .btn{flex:none}.gsm-setup-banner .gsm-token-help{color:var(--fgColor-muted,#656d76);flex:1 0 100%;font-size:12px;line-height:1.5}.gsm-token-row{flex-wrap:wrap;align-items:center;gap:8px;width:100%;display:inline-flex}.gsm-token-row[hidden]{display:none!important}.gsm-token-row .gsm-token-msg{margin-right:auto}.gsm-token-row input{border:1px solid var(--borderColor-default,#d0d7de);background:var(--bgColor-default,#fff);min-width:240px;color:var(--fgColor-default,#1f2328);border-radius:6px;flex:1;padding:6px 10px;font-size:13px}.gsm-token-msg{color:var(--fgColor-danger,#d1242f);font-size:12px}.gsm-account-banner{border:1px solid var(--borderColor-attention-muted,#d4a72c66);background:var(--bgColor-attention-muted,#fff8c5);color:var(--fgColor-default,#1f2328);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:16px;padding:12px 16px;font-size:14px;display:flex}.gsm-account-banner .gsm-account-msg{min-width:0;color:var(--fgColor-attention,#9a6700);flex:320px;line-height:1.5}.gsm-account-banner .btn{flex:none}.gsm-account-dismiss{opacity:.8}.gsm-account-dismiss:hover{opacity:1}\n@media (min-width:1200px){.gsm-stars-layout .Layout-sidebar{grid-column:1!important;display:block!important}.Layout.gsm-stars-layout{grid-template-columns:180px 1fr 220px!important}.gsm-stars-layout .Layout-main{grid-column:2!important;max-width:none!important}.stars-right-sidebar{word-wrap:break-word!important;grid-column:3!important;width:220px!important;min-width:220px!important;padding-top:32px!important;display:block!important;overflow:hidden!important}.stars-right-sidebar h2{margin-bottom:12px!important;font-size:16px!important}.stars-right-sidebar article{margin-bottom:8px!important}.stars-right-sidebar article h1{font-size:14px!important}}"); markStarsLayout(); } function removeLayoutStyles() { layoutStyleEl?.remove(); layoutStyleEl = null; unmarkStarsLayout(); } function createScope(name) { const disposers = []; const timers = new Set(); const rafs = new Set(); let disposed = false; const scope = { name, get disposed() { return disposed; }, addListener(target, type, handler, options) { if (disposed) return; target.addEventListener(type, handler, options); disposers.push(() => target.removeEventListener(type, handler, options)); }, timeout(fn, ms) { if (disposed) return -1; const id = window.setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; }, guardedTimeout(fn, ms) { const gen = generation; return scope.timeout(() => { if (gen !== generation) return; fn(); }, ms); }, raf(fn) { if (disposed) return -1; const id = window.requestAnimationFrame((ts) => { rafs.delete(id); fn(ts); }); rafs.add(id); return id; }, dispose() { if (disposed) return; disposed = true; for (const off of disposers.splice(0)) off(); for (const id of timers) window.clearTimeout(id); for (const id of rafs) window.cancelAnimationFrame(id); timers.clear(); rafs.clear(); } }; return scope; } var generation = 0; function currentGeneration() { return generation; } function beginGeneration() { generation += 1; return generation; } function ifCurrent(gen, fn) { if (gen === generation) fn(); } var HIDE_CLASS = "gsm-boot-hidden"; var HIDE_STYLE_ID = "gsm-boot-hide-style"; var FAILSAFE_MS = 4e3; var hideInstalledAt = 0; function isStarsPage() { return /[?&]tab=stars/.test(location.search); } function installBootHide() { const root = document.documentElement; if (!root || root.classList.contains(HIDE_CLASS)) return; if (!isStarsPage()) return; if (!isDesktop()) return; const style = document.createElement("style"); style.id = HIDE_STYLE_ID; style.textContent = [ `html.${HIDE_CLASS} {`, " background-color: var(--bgColor-default, transparent) !important;", "}", `html.${HIDE_CLASS} body {`, " display: none !important;", "}" ].join("\n"); root.appendChild(style); root.classList.add(HIDE_CLASS); hideInstalledAt = performance.now(); console.log("[github-star-manager] 防闪烁隐藏已挂载"); window.setTimeout(() => revealBootHide("4s 兜底"), FAILSAFE_MS); } function revealBootHide(reason = "未注明原因") { const root = document.documentElement; if (!root || !root.classList.contains(HIDE_CLASS)) return; const elapsed = hideInstalledAt ? Math.round(performance.now() - hideInstalledAt) : 0; console.log(`[github-star-manager] 防闪烁解除: ${reason} (${elapsed}ms)`); root.classList.remove(HIDE_CLASS); document.getElementById(HIDE_STYLE_ID)?.remove(); } function revealTurboHide() { document.querySelectorAll(".gsm-turbo-hidden").forEach((el) => el.classList.remove("gsm-turbo-hidden")); } var NOOP_NOTICE_HANDLE = { complete: () => {}, update: () => {}, dismiss: () => {} }; var NOTICE_LIFETIME_MS = 3e3; var TICK_MS = 100; var SLIDE_MS = 180; var EASE_IN = "cubic-bezier(0.22, 0.61, 0.36, 1)"; var EASE_OUT = "cubic-bezier(0.55, 0.06, 0.68, 0.19)"; var KIND_STYLE = { info: { bg: "var(--bgColor-accent-muted, #ddf4ff)", border: "var(--borderColor-accent-muted, #54aeff66)", fg: "var(--fgColor-accent, #0969da)", icon: "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" }, success: { bg: "var(--bgColor-success-muted, #dafbe1)", border: "var(--borderColor-success-muted, #4ac26b66)", fg: "var(--fgColor-success, #1a7f37)", icon: "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm1.5 0a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm10.28-1.72-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l1.47 1.47 3.97-3.97a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" }, warn: { bg: "var(--bgColor-attention-muted, #fff8c5)", border: "var(--borderColor-attention-muted, #d4a72c66)", fg: "var(--fgColor-attention, #9a6700)", icon: "M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" }, danger: { bg: "var(--bgColor-danger-muted, #ffebe9)", border: "var(--borderColor-danger-muted, #ff818266)", fg: "var(--fgColor-danger, #d1242f)", icon: "M2.344 2.343h-.001a8 8 0 0 1 11.314 11.314A8.002 8.002 0 0 1 .234 10.089a8 8 0 0 1 2.11-7.746Zm1.06 10.253a6.5 6.5 0 1 0 9.108-9.275 6.5 6.5 0 0 0-9.108 9.275ZM6.03 4.97 8 6.94l1.97-1.97a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l1.97 1.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-1.97 1.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L6.94 8 4.97 6.03a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018Z" } }; var BTN_SHADOW = "var(--button-default-shadow-resting, 0 1px 0 0 #1f23280a)"; var ICON_X = "M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"; var CARD_SHADOW = "0 1px 3px rgba(0, 0, 0, 0.08)"; var BTN_TRANSITION = "color 80ms cubic-bezier(0.33, 1, 0.68, 1), background-color 80ms cubic-bezier(0.33, 1, 0.68, 1), border-color 80ms cubic-bezier(0.33, 1, 0.68, 1)"; var SVG_NS = "http://www.w3.org/2000/svg"; function octicon(d) { const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("width", "16"); svg.setAttribute("height", "16"); svg.setAttribute("fill", "currentColor"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS(SVG_NS, "path"); path.setAttribute("d", d); svg.appendChild(path); return svg; } function withHover(el, rest, hover, active) { const apply = (v) => { for (const k of Object.keys(v)) el.style[k] = v[k]; }; el.addEventListener("mouseenter", () => apply(hover)); el.addEventListener("mouseleave", () => apply(rest)); if (active) { el.addEventListener("mousedown", () => apply(active)); el.addEventListener("mouseup", () => apply(hover)); } } var container = null; var ticker = null; var hovering = false; var items = new Set(); var HEADER_GAP_PX = 8; var HEADER_SELECTOR = "div.header-wrapper.js-header-wrapper"; function syncStackTop(el) { const hdr = document.querySelector(HEADER_SELECTOR); const bottom = hdr ? hdr.getBoundingClientRect().bottom : 0; el.style.top = `${Math.max(bottom + HEADER_GAP_PX, HEADER_GAP_PX)}px`; } var positionRaf = 0; var stackScope = null; function ensureStackPositionTracking() { if (stackScope) return; const scope = createScope("gsm-notify-position"); stackScope = scope; const resync = () => { if (positionRaf) return; positionRaf = scope.raf(() => { positionRaf = 0; if (container && container.isConnected) syncStackTop(container); }); }; scope.addListener(window, "scroll", resync, { capture: true, passive: true }); scope.addListener(window, "resize", resync, { passive: true }); scope.addListener(document, "visibilitychange", resync); } function disposeNotificationStack() { for (const item of [...items]) items.delete(item); stopTicker(); hovering = false; positionRaf = 0; container?.remove(); container = null; stackScope?.dispose(); stackScope = null; } function ensureContainer() { if (!isDesktop()) return null; if (container && container.isConnected) return container; hovering = false; stopTicker(); const el = document.createElement("div"); el.className = "gsm-notify-stack"; el.setAttribute("role", "region"); el.setAttribute("aria-label", "GithubStarManager 通知"); Object.assign(el.style, { position: "fixed", right: "16px", zIndex: "9999", display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "8px", maxWidth: "min(450px, 92vw)", pointerEvents: "auto", fontFamily: "inherit" }); el.addEventListener("mouseenter", () => { hovering = true; }); el.addEventListener("mouseleave", () => { hovering = false; }); document.body.appendChild(el); container = el; syncStackTop(el); ensureStackPositionTracking(); return el; } function ensureTicker() { if (ticker !== null) return; ticker = window.setInterval(() => { if (container && !container.isConnected) { hovering = false; for (const item of [...items]) items.delete(item); stopTicker(); return; } if (hovering) return; for (const item of [...items]) { item.remaining -= TICK_MS; if (item.remaining <= 0) removeItem(item); } if (items.size === 0) stopTicker(); }, TICK_MS); } function stopTicker() { if (ticker === null) return; window.clearInterval(ticker); ticker = null; } function slideIn(el) { el.style.transition = "none"; el.style.transform = "translateY(-8px)"; el.style.opacity = "0"; el.offsetWidth; el.style.transition = `transform ${SLIDE_MS}ms ${EASE_IN}, opacity ${SLIDE_MS}ms ${EASE_IN}`; el.style.transform = "translateY(0)"; el.style.opacity = "1"; } function removeItem(item) { if (!items.has(item)) return; items.delete(item); item.el.style.transition = `transform ${SLIDE_MS}ms ${EASE_OUT}, opacity ${SLIDE_MS}ms ${EASE_OUT}`; item.el.style.transform = "translateY(-8px)"; item.el.style.opacity = "0"; window.setTimeout(() => item.el.remove(), SLIDE_MS); if (items.size === 0) stopTicker(); } function buildItem(text, kind, opts) { const style = KIND_STYLE[kind]; const box = document.createElement("div"); box.className = `gsm-notice gsm-notice-${kind}`; Object.assign(box.style, { display: "flex", alignItems: "center", gap: "12px", width: "max-content", maxWidth: "min(450px, 92vw)", padding: "12px 16px", border: `1px solid ${style.border}`, borderRadius: "var(--borderRadius-medium, 0.375rem)", background: style.bg, boxShadow: CARD_SHADOW, color: "var(--fgColor-default, #1f2328)", fontFamily: "inherit", fontSize: "var(--text-body-size-medium, 0.875rem)", lineHeight: "1.5" }); const icon = octicon(style.icon); icon.classList.add("gsm-notice-icon"); icon.style.flex = "0 0 auto"; icon.style.color = style.fg; const label = document.createElement("span"); label.className = "gsm-notice-text"; label.textContent = text; Object.assign(label.style, { flex: "1 1 auto", minWidth: "0", wordBreak: "break-word" }); let buttonWrap = null; if (opts.actionLabel && opts.onAction) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "gsm-notice-action"; btn.textContent = opts.actionLabel; const rest = { background: "var(--button-default-bgColor-rest, #f6f8fa)", borderColor: "var(--button-default-borderColor-rest, #d1d9e0)" }; Object.assign(btn.style, { flex: "0 0 auto", cursor: "pointer", padding: "6px 14px", fontSize: "var(--text-body-size-medium, 0.875rem)", fontWeight: "500", lineHeight: "20px", whiteSpace: "nowrap", borderRadius: "var(--borderRadius-medium, 0.375rem)", borderWidth: "1px", borderStyle: "solid", boxShadow: BTN_SHADOW, transition: BTN_TRANSITION, color: "var(--button-default-fgColor-rest, #25292e)", ...rest }); withHover(btn, rest, { ...rest, background: "var(--button-default-bgColor-hover, #eff2f5)" }, { ...rest, background: "var(--button-default-bgColor-active, #e6eaef)" }); btn.addEventListener("click", () => { btn.disabled = true; btn.style.opacity = "0.6"; (async () => { try { await opts.onAction(); } finally { btn.disabled = false; btn.style.opacity = ""; } })(); }); buttonWrap = btn; } const close = document.createElement("button"); close.type = "button"; close.className = "gsm-notice-close"; close.setAttribute("aria-label", "关闭"); Object.assign(close.style, { display: "flex", alignItems: "center", justifyContent: "center", flex: "0 0 auto", padding: "2px", marginRight: "-6px", cursor: "pointer", border: "none", background: "transparent", color: "inherit" }); close.appendChild(octicon(ICON_X)); withHover(close, { opacity: "1" }, { opacity: "0.7" }, { opacity: "0.5" }); const item = { el: box, buttonWrap, labelEl: label, remaining: NOTICE_LIFETIME_MS, handle: null }; box.append(icon, label); if (buttonWrap) box.append(buttonWrap); box.append(close); close.addEventListener("click", () => removeItem(item)); item.handle = { complete(doneText) { if (doneText) label.textContent = doneText; label.style.textDecoration = "line-through"; label.style.color = "var(--fgColor-muted, #59636e)"; buttonWrap?.remove(); item.buttonWrap = null; item.remaining = NOTICE_LIFETIME_MS; }, update(next) { label.textContent = next; }, dismiss() { removeItem(item); } }; return item; } function pushNotice(text, kind = "info", opts = {}) { const host = ensureContainer(); if (!host) { console.log(`[github-star-manager] 窄视口:通知未显示(只留在控制台):${text}`); return NOOP_NOTICE_HANDLE; } const item = buildItem(text, kind, opts); host.appendChild(item.el); items.add(item); slideIn(item.el); ensureTicker(); return item.handle; } var filterState = { tags: [], langs: [], types: [], sort: "created", direction: "desc", searchQuery: "", page: 1, totalPages: 1 }; function hasActiveFilter() { return filterState.tags.length > 0 || filterState.langs.length > 0 || filterState.types.length > 0 || !!filterState.searchQuery; } var NEW_STARS_SELF = /^\/stars\/?$/; var NEW_STARS_USER = /^\/stars\/([^/]+)\/?$/; function ownerLoginFromPath() { const m = NEW_STARS_USER.exec(location.pathname); return m ? decodeURIComponent(m[1]) : ""; } function metaContent(name) { const raw = document.querySelector(`meta[name="${name}"]`)?.content; return typeof raw === "string" ? raw.trim() : ""; } function getViewerId() { return metaContent("octolytics-actor-id"); } function getViewerLogin() { return metaContent("user-login") || metaContent("octolytics-actor-login"); } function getUserLogin() { return metaContent("user-login"); } function getPageOwnerId() { return metaContent("octolytics-dimension-user_id"); } function getPageOwnerLogin() { return metaContent("octolytics-dimension-user_login") || ownerLoginFromPath(); } function isStarsListingPage() { if (isStarsPage()) return true; return NEW_STARS_SELF.test(location.pathname) || NEW_STARS_USER.test(location.pathname); } function getStarsPageScope() { if (!isStarsListingPage()) return "own"; const viewerId = getViewerId(); const viewerLogin = getViewerLogin(); const hasSession = !!viewerId || !!viewerLogin; if (NEW_STARS_SELF.test(location.pathname)) return hasSession ? "own" : "unknown"; const ownerFromPath = ownerLoginFromPath(); if (ownerFromPath) { if (!viewerLogin) return "other"; return ownerFromPath.toLowerCase() === viewerLogin.toLowerCase() ? "own" : "other"; } const ownerId = getPageOwnerId(); const ownerLogin = getPageOwnerLogin(); if (ownerId && viewerId) return ownerId === viewerId ? "own" : "other"; if (ownerLogin && viewerLogin) return ownerLogin.toLowerCase() === viewerLogin.toLowerCase() ? "own" : "other"; if (!hasSession && (ownerId || ownerLogin)) return "other"; return "unknown"; } function detectTokenKind(tok) { if (tok.startsWith("github_pat_")) return "fine-grained"; if (tok.startsWith("ghp_") || tok.startsWith("gho_")) return "classic"; return null; } function isClassicCredential(tok) { return detectTokenKind(tok) === "classic"; } function getToken() { return gmGet(STORAGE_KEYS.githubPat, "") || ""; } var TOKEN_TEMPLATE_URL = "https://github.com/settings/personal-access-tokens/new?name=GithubStarManager&description=GithubStarManager%20userscript%20-%20Account%20permission%3A%20Starring%20write&expires_in=90&starring=write"; var TOKEN_CLASSIC_URL = "https://github.com/settings/tokens/new?scopes=repo&description=GithubStarManager"; var TOKEN_KIND_HELP = "classic(ghp_,或 gh CLI OAuth 的 gho_):读写都走官方 REST,可 star/unstar 任意公开仓库。fine-grained(github_pat_):可读列表,但 GitHub 不允许它 star/unstar 别人的公开仓库。"; function openTokenCreator() { gmOpenInTab(TOKEN_TEMPLATE_URL); } function openClassicTokenCreator() { gmOpenInTab(TOKEN_CLASSIC_URL); } var savedHandler = null; function setTokenSavedHandler(fn) { savedHandler = fn; } function notifyTokenSaved() { savedHandler?.(); } async function pasteFromClipboard(input) { try { input.value = (await navigator.clipboard.readText()).trim(); input.focus(); } catch { input.focus(); input.select(); console.warn("[github-star-manager] 剪贴板读取被拒,请手动 Ctrl+V 粘贴 token"); } } var issueHandler = null; function setTokenIssueHandler(fn) { issueHandler = fn; } function notifyTokenIssue(detail) { console.error(`[github-star-manager] Token 问题:${detail}`); if (issueHandler) issueHandler(detail); } function fingerprint(token) { let h = 2166136261; for (let i = 0; i < token.length; i += 1) { h ^= token.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h.toString(16).padStart(8, "0"); } function getTokenIdentitySync(token) { if (!token) return null; return gmGet(STORAGE_KEYS.accountIdentity, {})[fingerprint(token)] ?? null; } function rememberTokenIdentity(token, identity) { if (!token) return; const fp = fingerprint(token); const cache = gmGet(STORAGE_KEYS.accountIdentity, {}); gmSet(STORAGE_KEYS.accountIdentity, { ...cache, [fp]: identity }); } async function resolveTokenIdentity(token) { let resp; try { resp = await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" } }); } catch (err) { console.warn("[github-star-manager] 取 token 身份失败(网络层)", err); return { ok: false, reason: "unknown" }; } if (resp.status === 401) { notifyTokenIssue("401 Bad credentials:Token 已失效或被撤销"); return { ok: false, reason: "dead" }; } if (!resp.ok) { console.warn(`[github-star-manager] 取 token 身份失败(HTTP ${resp.status})`); return { ok: false, reason: "unknown" }; } try { const data = await resp.json(); const id = typeof data.id === "number" ? String(data.id) : typeof data.id === "string" ? data.id : ""; if (!id) return { ok: false, reason: "unknown" }; const identity = { id, login: typeof data.login === "string" ? data.login : "" }; rememberTokenIdentity(token, identity); return { ok: true, identity }; } catch (err) { console.warn("[github-star-manager] 解析 token 身份响应失败", err); return { ok: false, reason: "unknown" }; } } async function setTokenVerified(raw) { const tok = raw.trim(); if (!tok) { gmSet(STORAGE_KEYS.githubPat, ""); return { result: "cleared", kind: null }; } const kind = detectTokenKind(tok); if (!kind) return { result: "invalid", kind: null }; const res = await resolveTokenIdentity(tok); if (res.ok) { gmSet(STORAGE_KEYS.githubPat, tok); return { result: "saved", kind }; } if (res.reason === "dead") return { result: "dead", kind }; gmSet(STORAGE_KEYS.githubPat, tok); console.warn("[github-star-manager] 未能确认 Token 所属账号(临时故障),已保存 token。本次归属暂按当前登录账号处理;若该账号不是 Token 的主人,标签/备注/宽限期备份会暂存在登录者的命名空间下,下次加载身份确认成功后会切到 Token 账号(此窗口内写的数据不会自动搬过去)。"); return { result: "saved-unverified", kind }; } var memoFp = ""; var memoViewer = ""; var memoId = ""; var memoValid = false; function getStorageUserId() { const tok = getToken(); const fp = tok ? fingerprint(tok) : ""; const viewer = getViewerId(); if (memoValid && memoFp === fp && memoViewer === viewer) return memoId; const id = (tok ? getTokenIdentitySync(tok)?.id : "") || viewer; memoFp = fp; memoViewer = viewer; memoId = id; memoValid = true; return id; } function tagsKey(userId) { return STORAGE_KEYS.tagsPrefix + userId; } function loadAllTags() { const userId = getStorageUserId(); if (!userId) return {}; return gmGet(tagsKey(userId), {}); } function saveTags(repoId, tagsArray) { const userId = getStorageUserId(); if (!userId) { console.warn("[github-star-manager] 取不到归属账号(无 token 身份且取不到登录者),已跳过标签写入(避免写进无隔离的旧键)"); return; } const key = tagsKey(userId); const all = gmGet(key, {}); if (tagsArray.length === 0) delete all[repoId]; else all[repoId] = tagsArray; gmSet(key, all); } function getTags(repoId) { return loadAllTags()[repoId] || []; } function notesKey(userId) { return STORAGE_KEYS.notesPrefix + userId; } function loadAllNotes() { const userId = getStorageUserId(); if (!userId) return {}; return gmGet(notesKey(userId), {}); } function saveNote(repoId, text) { const userId = getStorageUserId(); if (!userId) { console.warn("[github-star-manager] 取不到归属账号(无 token 身份且取不到登录者),已跳过备注写入(避免写进无隔离的旧键)"); return; } const key = notesKey(userId); const all = gmGet(key, {}); if (!text.trim()) delete all[repoId]; else all[repoId] = text; gmSet(key, all); } function getNote(repoId) { return loadAllNotes()[repoId] || ""; } function hasApiData() { const meta = gmGet(STORAGE_KEYS.fullSyncMeta, {}); return !!meta.lastFullSyncAt && (meta.count ?? 0) > 0; } function loadRepoCache() { return gmGet(STORAGE_KEYS.repoCache, {}); } function saveRepoCache(all) { gmSet(STORAGE_KEYS.repoCache, all); } function saveRepoData(repoId, data) { const all = loadRepoCache(); all[repoId] = Object.assign({}, all[repoId] || {}, data); saveRepoCache(all); } function numericRepoId(value) { if (typeof value === "number" && !Number.isSafeInteger(value)) return ""; const text = typeof value === "string" || typeof value === "number" ? String(value) : ""; return /^[1-9]\d*$/.test(text) ? text : ""; } function repoFullName(value) { if (typeof value !== "string") return ""; if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\/[a-zA-Z0-9_.-]+$/.test(value)) return ""; const repo = value.split("/")[1]; return repo === "." || repo === ".." ? "" : value; } function repoTarget(repoId, fullName = "") { const projectedName = repoFullName(repoId); return { repoId: projectedName ? void 0 : repoId || void 0, fullName: repoFullName(fullName) || projectedName || void 0 }; } function repoTargetKey(target) { const id = numericRepoId(target.repoId); if (target.repoId && !id) return ""; const name = repoFullName(target.fullName); return id ? `repo:${id}` : name ? `name:${name.toLowerCase()}` : ""; } function encodedRepoName(fullName) { if (!repoFullName(fullName)) throw new Error("非法仓库地址"); return fullName.split("/").map(encodeURIComponent).join("/"); } function starredUrl(fullName) { return `https://api.github.com/user/starred/${encodedRepoName(fullName)}`; } function pendingDeleteKey(userId) { return STORAGE_KEYS.pendingDeletePrefix + userId; } function loadPendingDelete() { const userId = getStorageUserId(); if (!userId) return {}; return gmGet(pendingDeleteKey(userId), {}); } function savePendingDelete(all) { const userId = getStorageUserId(); if (!userId) { console.warn("[github-star-manager] 取不到归属账号,已跳过宽限期备份写入(避免写进无隔离的旧键)"); return; } gmSet(pendingDeleteKey(userId), all); } function getPendingInGrace(repoId, now = Date.now()) { if (!repoId) return null; const entry = loadPendingDelete()[repoId]; if (!entry) return null; return (entry.unstarredAt || 0) + 864e5 > now ? entry : null; } function listRestorable(now = Date.now()) { const pending = loadPendingDelete(); const out = []; for (const repoId in pending) { const e = pending[repoId]; const at = e.unstarredAt || 0; const remainingMs = at + GRACE_PERIOD - now; if (remainingMs <= 0) continue; out.push({ repoId, name: e.name || repoId, unstarredAt: at, remainingMs, tags: e._tags || [], hasNote: !!(e._note && e._note.trim()) }); } out.sort((a, b) => a.remainingMs - b.remainingMs); return out; } function formatRemaining(ms) { const totalMinutes = Math.max(0, Math.floor(ms / 6e4)); const h = Math.floor(totalMinutes / 60); const m = totalMinutes % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m`; return "<1m"; } function markRepoUnstarred(repoId, seed) { const cache = loadRepoCache(); const existing = cache[repoId]; const tags = getTags(repoId); const note = getNote(repoId); if (!existing && !seed && tags.length === 0 && !note) return; const pending = loadPendingDelete(); pending[repoId] = Object.assign({}, existing || seed || {}, { name: seed?.name || existing?.name || "", unstarredAt: Date.now(), _tags: tags, _note: note }); savePendingDelete(pending); if (existing) { delete cache[repoId]; saveRepoCache(cache); } saveTags(repoId, []); saveNote(repoId, ""); } function markRepoStarred(repoId, resolvedName) { const pending = loadPendingDelete(); if (!pending[repoId]) { const cache = loadRepoCache(); if (resolvedName && cache[repoId] && cache[repoId].name !== resolvedName) { cache[repoId].name = resolvedName; saveRepoCache(cache); } return; } const entry = pending[repoId]; if (resolvedName) entry.name = resolvedName; const tags = entry._tags || []; const note = entry._note || ""; delete entry.unstarredAt; delete entry._tags; delete entry._note; const cache = loadRepoCache(); cache[repoId] = entry; saveRepoCache(cache); if (tags.length > 0) saveTags(repoId, tags); if (note) saveNote(repoId, note); delete pending[repoId]; savePendingDelete(pending); } function prepareResolvedRestore(sourceKey, target) { if (sourceKey === target.repoId) return () => { markRepoStarred(sourceKey, target.fullName); return true; }; if (!repoFullName(sourceKey) || !numericRepoId(target.repoId)) return null; const owner = getStorageUserId(); const inspect = () => { if (!owner || getStorageUserId() !== owner) return null; const pending = loadPendingDelete(); const cache = loadRepoCache(); const tags = loadAllTags(); const notes = loadAllNotes(); const has = (map, key) => Object.prototype.hasOwnProperty.call(map, key); if (!has(pending, sourceKey) || [ cache, pending, tags, notes ].some((map) => has(map, target.repoId)) || [ cache, tags, notes ].some((map) => has(map, sourceKey))) return null; return { pending, cache }; }; if (!inspect()) return null; return () => { const state = inspect(); if (!state) return false; const entry = { ...state.pending[sourceKey], name: target.fullName }; const tags = entry._tags || []; const note = entry._note || ""; delete entry.unstarredAt; delete entry._tags; delete entry._note; state.cache[target.repoId] = entry; saveRepoCache(state.cache); if (tags.length) saveTags(target.repoId, tags); if (note) saveNote(target.repoId, note); delete state.pending[sourceKey]; savePendingDelete(state.pending); return true; }; } function addPendingFromImport(repoId, tags, note, name = "") { if (!repoId) return false; const pending = loadPendingDelete(); const existing = pending[repoId]; const now = Date.now(); const live = existing && (existing.unstarredAt || 0) + 864e5 > now ? existing : void 0; const hasTags = tags.length > 0; const hasNote = !!(note && note.trim()); if (!live && !hasTags && !hasNote) return false; pending[repoId] = Object.assign({ name: "" }, live || {}, { name: live?.name || name || "", unstarredAt: live ? live.unstarredAt ?? now : now, _tags: hasTags ? tags : live?._tags || [], _note: hasNote ? note : live?._note || "" }); savePendingDelete(pending); return true; } function cleanupExpiredUnstarred() { const pending = loadPendingDelete(); const now = Date.now(); let changed = false; for (const repoId in pending) if (pending[repoId].unstarredAt && now - pending[repoId].unstarredAt > 864e5) { delete pending[repoId]; changed = true; } if (changed) savePendingDelete(pending); } var otherPage = null; var starOverrides = new Map(); function setViewStarOverride(repoId, starred) { if (!otherPage) return; starOverrides.set(repoId, starred); } function getViewStarOverride(repoId) { return starOverrides.get(repoId); } function setOtherPageRepos(repos, ownerLogin) { otherPage = { ownerLogin, repos }; } function getOtherPageRepos() { return otherPage; } function isReadOnlyView() { return otherPage !== null; } function resetViewContext() { otherPage = null; starOverrides.clear(); } function loadViewerCacheForView() { if (!hasApiData() || !hasViewerIdentity()) return null; return loadRepoCache(); } function isStarredByViewer(repoId, viewerCache) { const override = getViewStarOverride(repoId); if (override !== void 0) return override; return !!viewerCache?.[repoId]; } function hasViewerIdentity() { return !!getViewerId(); } function getCardState(repoId, viewerCache, now = Date.now()) { if (viewerCache && isStarredByViewer(repoId, viewerCache)) return "editable"; if (!hasViewerIdentity()) return "locked-empty"; return getPendingInGrace(repoId, now) ? "locked-pending" : "locked-empty"; } function getOwnPageCardState(repoId, now = Date.now()) { if (!hasViewerIdentity()) return "editable"; return getPendingInGrace(repoId, now) ? "locked-pending" : "editable"; } function readCardDisplayData(repoId, state, now = Date.now()) { const tags = getTags(repoId); const note = getNote(repoId); if (tags.length > 0 || note) return { tags, note }; const grace = state !== "editable" && hasViewerIdentity() ? getPendingInGrace(repoId, now) : null; if (grace) return { tags: grace._tags || [], note: grace._note || "" }; return { tags, note }; } var readonly_default = "@media (min-width:768px){.gsm-ro-tags{align-items:center;gap:var(--base-size-4,4px);margin-top:var(--base-size-8,8px);cursor:default;flex-wrap:wrap;display:flex}.gsm-ro-tag{padding:var(--base-size-2,2px) var(--base-size-8,8px);border-radius:var(--base-size-12,12px);font-size:var(--text-body-size-small,12px);font-weight:var(--base-text-weight-normal,400);background:var(--bgColor-accent-muted,#ddf4ff);color:var(--fgColor-accent,#0969da);white-space:nowrap;cursor:default;align-items:center;line-height:1.4;display:inline-flex}}"; var CARDS_TITLE_LOCKED = "标签与备注只能在你自己的 stars 页面上修改(本页为只读显示)"; var CARDS_TITLE_PENDING = "已取消 star:标签与备注保留 24 小时,期间不可编辑"; var styleEl = null; function ensureStyles() { if (!styleEl || !styleEl.isConnected) styleEl = gmAddStyle(readonly_default); } function buildTagRow(tags, title) { const row = document.createElement("div"); row.className = "gsm-ro-tags"; row.title = title; for (const tag of tags) { const pill = document.createElement("span"); pill.className = "gsm-ro-tag"; pill.textContent = tag; row.appendChild(pill); } return row; } function buildNoteRow(note, title) { const row = document.createElement("div"); row.className = "stars-card-notes-text gsm-ro-notes"; row.title = title; row.textContent = note; return row; } function renderTagsReadOnly(container, tags, title = CARDS_TITLE_LOCKED) { container.innerHTML = ""; if (tags.length === 0) return; ensureStyles(); container.appendChild(buildTagRow(tags, title)); } function renderNotesReadOnly(container, note, title = CARDS_TITLE_LOCKED) { container.innerHTML = ""; if (!note) return; ensureStyles(); container.appendChild(buildNoteRow(note, title)); } function exitReadOnlyMode() { styleEl?.remove(); styleEl = null; document.querySelectorAll(".gsm-ro-tags, .gsm-ro-notes").forEach((el) => el.remove()); } var editBindings = new WeakMap(); function disposeNotesEditor(notesContainer) { const bound = editBindings.get(notesContainer); if (!bound) return; notesContainer.removeEventListener("click", bound); editBindings.delete(notesContainer); } function renderNotes(notesContainer) { const repoId = notesContainer.dataset.repoId || ""; if (!repoId) return; disposeNotesEditor(notesContainer); const note = getNote(repoId); notesContainer.innerHTML = ""; if (note) { const textEl = document.createElement("div"); textEl.className = "stars-card-notes-text"; textEl.textContent = note; notesContainer.appendChild(textEl); } else { const placeholder = document.createElement("div"); placeholder.className = "stars-card-notes-placeholder"; placeholder.textContent = "添加备注…"; notesContainer.appendChild(placeholder); } const onEdit = (e) => { e.stopPropagation(); if (notesContainer.querySelector(".stars-card-notes-edit")) return; notesContainer.removeEventListener("click", onEdit); if (editBindings.get(notesContainer) === onEdit) editBindings.delete(notesContainer); notesContainer.innerHTML = ""; const textarea = document.createElement("textarea"); textarea.className = "stars-card-notes-edit"; textarea.value = getNote(repoId); textarea.placeholder = "输入备注…"; notesContainer.appendChild(textarea); textarea.focus(); let cancelled = false; function commit() { if (cancelled) return; const val = textarea.value.trim(); saveNote(repoId, val); renderNotes(notesContainer); } textarea.addEventListener("blur", () => commit()); textarea.addEventListener("keydown", (ev) => { if (ev.key === "Escape") { cancelled = true; renderNotes(notesContainer); } }); }; notesContainer.addEventListener("click", onEdit); editBindings.set(notesContainer, onEdit); } function toggleTagFilter(tag) { const idx = filterState.tags.indexOf(tag); if (idx >= 0) filterState.tags.splice(idx, 1); else filterState.tags.push(tag); applyFilters(); refreshTagPillStates(); } function updateTagFilterButton() { const btn = document.querySelector(".stars-tag-filter .Button"); if (!btn) return; const label = btn.querySelector(".Button-label"); if (label) label.textContent = filterState.tags.length > 0 ? `Tags: ${filterState.tags.length} selected` : "Tags"; btn.classList.toggle("has-active", filterState.tags.length > 0); } function renderTagFilterBar() { const filterRow = getNativeFilterRow(); if (!filterRow) return; const existing = filterRow.querySelector(".stars-tag-filter"); if (existing) existing.remove(); if (!hasAnyTags()) return; const container = document.createElement("div"); container.className = "stars-tag-filter mb-1 mb-lg-0 mr-2"; const btnLabel = filterState.tags.length > 0 ? `Tags: ${filterState.tags.length} selected` : "Tags"; const btn = document.createElement("button"); btn.type = "button"; btn.id = "stars-tag-filter-button"; btn.setAttribute("popovertarget", "stars-tag-filter-overlay"); btn.setAttribute("aria-controls", "stars-tag-filter-list"); btn.setAttribute("aria-haspopup", "true"); btn.className = "Button--secondary Button--medium Button"; if (filterState.tags.length > 0) btn.classList.add("has-active"); btn.innerHTML = "" + TRIANGLE_DOWN_SVG + ""; const btnLabelEl = btn.querySelector(".Button-label"); if (btnLabelEl) btnLabelEl.textContent = btnLabel; const overlay = document.createElement("anchored-position"); overlay.id = "stars-tag-filter-overlay"; overlay.setAttribute("anchor", "stars-tag-filter-button"); overlay.setAttribute("align", "start"); overlay.setAttribute("side", "outside-bottom"); overlay.setAttribute("anchor-offset", "normal"); overlay.setAttribute("popover", "auto"); const overlayInner = document.createElement("div"); overlayInner.className = "Overlay Overlay--size-auto"; const overlayBody = document.createElement("div"); overlayBody.className = "Overlay-body Overlay-body--paddingNone"; const menuList = document.createElement("ul"); menuList.id = "stars-tag-filter-list"; menuList.className = "gsm-tag-chips"; menuList.setAttribute("role", "menu"); renderTagFilterList(menuList); overlayBody.appendChild(menuList); overlayInner.appendChild(overlayBody); overlay.appendChild(overlayInner); container.appendChild(btn); container.appendChild(overlay); filterRow.insertBefore(container, filterRow.firstChild); } function renderTagFilterList(menuList) { menuList.innerHTML = ""; const candidates = computeTagCandidates(); if (candidates.length === 0) { const li = document.createElement("li"); li.className = "gsm-tag-chips-empty"; li.setAttribute("role", "none"); li.textContent = "当前筛选结果暂无标签"; menuList.appendChild(li); return; } candidates.forEach((tag) => { const li = document.createElement("li"); li.setAttribute("role", "none"); const chip = document.createElement("button"); chip.type = "button"; chip.className = "gsm-tag-chip"; chip.setAttribute("role", "menuitemcheckbox"); chip.setAttribute("aria-checked", String(filterState.tags.includes(tag))); chip.textContent = tag; chip.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); toggleTagFilter(tag); }); li.appendChild(chip); menuList.appendChild(li); }); } function refreshTagFilterBar() { const filterRow = getNativeFilterRow(); if (!filterRow) return; const existing = filterRow.querySelector(".stars-tag-filter"); if (!hasAnyTags()) { existing?.remove(); return; } if (!existing) { renderTagFilterBar(); return; } updateTagFilterButton(); const menuList = existing.querySelector("#stars-tag-filter-list"); if (menuList) renderTagFilterList(menuList); } function refreshTagPillStates() { document.querySelectorAll(".stars-card-tags .stars-tag").forEach((pill) => { const tagText = pill.childNodes[0]?.textContent || ""; if (filterState.tags.includes(tagText)) pill.classList.add("stars-tag-active"); else pill.classList.remove("stars-tag-active"); }); } function renderTags(tagsContainer, opts = {}) { const repoId = tagsContainer.dataset.repoId || ""; if (!repoId) return; const filterToggle = opts.filterToggle !== false; const tags = getTags(repoId); tagsContainer.innerHTML = ""; tags.forEach((tag, idx) => { const span = document.createElement("span"); span.className = "stars-tag"; if (filterToggle && filterState.tags.includes(tag)) span.classList.add("stars-tag-active"); span.textContent = tag; if (filterToggle) span.addEventListener("click", (e) => { e.stopPropagation(); toggleTagFilter(tag); }); const del = document.createElement("span"); del.className = "stars-tag-del"; del.textContent = "×"; del.addEventListener("click", (e) => { e.stopPropagation(); const current = getTags(repoId); current.splice(idx, 1); saveTags(repoId, current); renderTags(tagsContainer, opts); if (filterToggle) applyFilters(); }); span.appendChild(del); tagsContainer.appendChild(span); }); const addBtn = document.createElement("span"); addBtn.className = "stars-tag-add"; addBtn.title = "添加标签"; addBtn.addEventListener("click", (e) => { e.stopPropagation(); if (tagsContainer.querySelector(".stars-tag-input")) return; addBtn.style.display = "none"; const input = document.createElement("input"); input.type = "text"; input.className = "stars-tag-input"; input.placeholder = "标签名"; tagsContainer.appendChild(input); input.focus(); function commit() { const val = input.value.trim(); if (val) { const current = getTags(repoId); if (current.includes(val)) { input.remove(); addBtn.style.display = ""; return; } current.push(val); saveTags(repoId, current); renderTags(tagsContainer, opts); if (filterToggle) applyFilters(); } else { input.remove(); addBtn.style.display = ""; } } input.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); commit(); } if (ev.key === "Escape") { input.remove(); addBtn.style.display = ""; } }); input.addEventListener("blur", () => commit()); }); tagsContainer.appendChild(addBtn); } var TAGS_SELECTOR = ".stars-card-tags"; var NOTES_SELECTOR = ".stars-card-notes"; var STATE_ATTR = "gsmCardState"; function renderCardTagAndNoteAreas(card, view, viewerCache = null) { const tagsContainer = card.querySelector(TAGS_SELECTOR); const notesContainer = card.querySelector(NOTES_SELECTOR); if (!tagsContainer && !notesContainer) return; const repoId = card.dataset.repoId || ""; if (!repoId && view === "other") return; const state = repoId ? view === "own" ? getOwnPageCardState(repoId) : getCardState(repoId, viewerCache) : "editable"; if (tagsContainer) tagsContainer.dataset[STATE_ATTR] = state; if (notesContainer) notesContainer.dataset[STATE_ATTR] = state; if (state === "editable") { if (tagsContainer) renderTags(tagsContainer, { filterToggle: view === "own" }); if (notesContainer) { disposeNotesEditor(notesContainer); renderNotes(notesContainer); } return; } const hadFocus = card.contains(document.activeElement); const { tags, note } = readCardDisplayData(repoId, state); const title = state === "locked-pending" ? CARDS_TITLE_PENDING : CARDS_TITLE_LOCKED; if (tagsContainer) renderTagsReadOnly(tagsContainer, tags, title); if (notesContainer) { disposeNotesEditor(notesContainer); renderNotesReadOnly(notesContainer, note, title); } if (hadFocus) { const btn = card.querySelector(".stars-star-btn"); if (btn) btn.focus({ preventScroll: true }); else { if (!card.hasAttribute("tabindex")) card.tabIndex = -1; card.focus({ preventScroll: true }); } } } var SOURCE_URL = "https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml"; var LANG_COLOR_FALLBACK = "#8b949e"; var FETCH_COOLDOWN_MS = 3e4; var colorMap = {}; var fallbackLangs = new Set(); var pendingMisses = new Set(); var inflight$1 = null; var nextFetchAt = 0; var cacheLoaded = false; var inited = false; function loadCache() { if (cacheLoaded) return; cacheLoaded = true; const cached = gmGet(STORAGE_KEYS.langColors, null); if (cached && typeof cached === "object" && !Array.isArray(cached)) colorMap = cached; } function parseLangColors(yml) { const out = {}; let name = ""; for (const ln of yml.split("\n")) { const m = ln.match(/^(?:"([^"]+)"|([^ \t][^:]*)):\s*$/); if (m) { name = (m[1] || m[2] || "").trim().toLowerCase(); continue; } const c = ln.match(/^ {2}color: "(#[0-9a-fA-F]{6})"$/); if (c && name) out[name] = c[1]; } return out; } function recolorDots() { document.querySelectorAll("[data-gsm-lang]").forEach((el) => { const lang = decodeURIComponent(el.dataset.gsmLang || ""); el.setAttribute("style", `background-color: ${getLangColor(lang)}`); }); } var FETCH_TIMEOUT_MS = 2e4; async function fetchLangColorTable() { const ctrl = new AbortController(); const timer = window.setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { const resp = await fetch(SOURCE_URL, { cache: "no-cache", signal: ctrl.signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return await resp.text(); } finally { window.clearTimeout(timer); } } var fetchEnabled = true; function setLangColorFetchEnabled(enabled) { const prev = fetchEnabled; fetchEnabled = enabled; return prev; } function fetchLangColors() { if (!fetchEnabled) return Promise.resolve(); if (inflight$1) return inflight$1; if (Date.now() < nextFetchAt) return Promise.resolve(); nextFetchAt = Date.now() + FETCH_COOLDOWN_MS; inflight$1 = fetchLangColorTable().then((text) => { const parsed = parseLangColors(text); const size = Object.keys(parsed).length; if (size === 0) throw new Error("解析结果为空"); colorMap = parsed; gmSet(STORAGE_KEYS.langColors, parsed); for (const k of pendingMisses) if (!(k in colorMap)) fallbackLangs.add(k); for (const k of Array.from(fallbackLangs)) if (k in colorMap) fallbackLangs.delete(k); pendingMisses.clear(); nextFetchAt = 0; console.log(`[github-star-manager] 语言色已更新:${size} 语言(当前回退 ${fallbackLangs.size})`); recolorDots(); }).catch((e) => { console.warn("[github-star-manager] 语言色获取失败,沿用现有数据", e); }).finally(() => { inflight$1 = null; }); return inflight$1; } function initLangColors() { loadCache(); if (inited) return; inited = true; fetchLangColors(); } function getLangColor(lang) { loadCache(); const key = lang?.trim().toLowerCase(); if (!key) return LANG_COLOR_FALLBACK; const hit = colorMap[key]; if (hit) return hit; if (fallbackLangs.has(key)) return LANG_COLOR_FALLBACK; pendingMisses.add(key); fetchLangColors(); return LANG_COLOR_FALLBACK; } var MUTATION_GAP_MS = 1e3; var queue = []; var live = new Set(); var active = null; var completion = 0; var completedKeys = new Map(); var lastStartAt = 0; var draining = false; var sleep$1 = (ms) => new Promise((r) => window.setTimeout(r, ms)); function enqueueMutation(mutation) { if (!mutation.key || isMutationQueued(mutation.key)) return null; let resolve; const done = new Promise((r) => { resolve = r; }); const item = { keys: new Set([mutation.key]), completionAtEnqueue: completion, label: mutation.label, run: mutation.run, cancelled: false, started: false, resolve }; queue.push(item); live.add(item); const handle = { label: mutation.label, cancelQueued() { if (item.started) return false; if (item.cancelled) return true; item.cancelled = true; live.delete(item); return true; }, isQueued() { return !item.started && !item.cancelled; }, done }; if (!draining) drain(); return handle; } function isMutationQueued(key) { return [...live].some((it) => it.keys.has(key)); } function claimMutationTarget(key) { if (!active) return true; if ([...live].some((it) => it !== active && it.keys.has(key))) return false; if ((completedKeys.get(key) ?? 0) > active.completionAtEnqueue) return false; active.keys.add(key); return true; } async function waitForMutationSlot() { const wait = lastStartAt + MUTATION_GAP_MS - Date.now(); if (wait > 0) await sleep$1(wait); lastStartAt = Date.now(); } async function drain() { draining = true; try { while (queue.length > 0) { const item = queue.shift(); if (item.cancelled) { item.resolve(null); continue; } const wait = lastStartAt + MUTATION_GAP_MS - Date.now(); if (wait > 0) await sleep$1(wait); if (item.cancelled) { item.resolve(null); continue; } item.started = true; active = item; let result = null; try { result = await item.run(); } catch (err) { console.error("[github-star-manager] 变异请求内部异常(队列继续):", err); result = null; } item.resolve(result); completion += 1; for (const key of item.keys) completedKeys.set(key, completion); live.delete(item); active = null; } } finally { draining = false; active = null; completedKeys.clear(); } } function hasRateLimitSignal(resp, bodyText = "") { return resp.status === 429 || !!resp.headers.get("retry-after") || resp.headers.get("x-ratelimit-remaining") === "0" || /secondary rate limit/i.test(bodyText); } async function apiRead(url, token, options) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2e4); try { let resp; try { resp = await fetch(url, { cache: "no-store", credentials: "omit", redirect: "follow", signal: controller.signal, headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", ...token ? { Authorization: `Bearer ${token}` } : {} } }); } catch { return { ok: false, reason: "network", status: 0 }; } options.onResponse?.(resp); if (resp.url && new URL(resp.url).origin !== "https://api.github.com") return { ok: false, reason: "target-mismatch", status: resp.status }; if (hasRateLimitSignal(resp)) return { ok: false, reason: "rate-limited", status: resp.status }; const remaining = resp.headers.get("x-ratelimit-remaining"); if (remaining !== null && Number(remaining) < 10) return { ok: false, reason: "rate-limited", status: resp.status }; let body = ""; try { if (resp.status === 200 || resp.status === 403) body = await resp.text(); } catch { return { ok: false, reason: "network", status: 0 }; } return { response: resp, body }; } finally { clearTimeout(timeout); controller.abort(); } } function failure(resp, text) { if (resp.status === 401) return { ok: false, reason: "unauthorized", status: 401 }; if (resp.status === 404) return { ok: false, reason: "not-found", status: 404 }; if (resp.status === 403 || resp.status === 429) return { ok: false, reason: hasRateLimitSignal(resp, text) ? "rate-limited" : "permission-denied", status: resp.status }; return { ok: false, reason: "unknown", status: resp.status }; } async function readRepository(path, token, expectedId, options) { const read = await apiRead(`https://api.github.com${path}`, token, options); if ("reason" in read) return read; const { response, body } = read; if (response.status !== 200) return failure(response, body); try { const data = JSON.parse(body); const id = numericRepoId(data.id); const name = repoFullName(data.full_name); if (!id || !name) return { ok: false, reason: "unknown", status: 200 }; if (expectedId && expectedId !== id) return { ok: false, reason: "target-mismatch", status: 200 }; return { ok: true, target: { repoId: id, fullName: name } }; } catch { return { ok: false, reason: "unknown", status: 200 }; } } async function resolveRepoTarget(token, target, options = {}) { const id = numericRepoId(target.repoId); const name = repoFullName(target.fullName); if (target.repoId && !id) return { ok: false, reason: "invalid-target", status: 0 }; if (id) { const result = await readRepository(`/repositories/${id}`, token, id, options); if (result.ok || result.reason !== "not-found" || !name) return result; } else if (!name) return { ok: false, reason: "invalid-target", status: 0 }; return readRepository(`/repos/${encodedRepoName(name)}`, token, id, options); } async function checkRepoStarred(token, target, options = {}) { const resolved = await resolveRepoTarget(token, target, options); if (!resolved.ok) return resolved; const read = await apiRead(starredUrl(resolved.target.fullName), token, options); if ("reason" in read) return read; const { response, body } = read; if (response.status === 204 || response.status === 404) return { ok: true, gone: response.status === 404, target: resolved.target }; return failure(response, body); } async function collectStarredChecks(token, targets, remaining, onResponse) { const floor = 10; if (remaining !== null && remaining < targets.length * 3 + floor) throw new Error("速率余量不足以完成仓库核对,本轮放弃"); const results = []; for (const target of targets) { const result = await checkRepoStarred(token, target, { onResponse: (response) => { onResponse?.(response); const header = response.headers.get("x-ratelimit-remaining"); const observed = header === null ? NaN : Number(header); if (remaining !== null) remaining -= 1; if (Number.isFinite(observed)) remaining = remaining === null ? observed : Math.min(remaining, observed); if (remaining !== null && remaining < floor) throw new Error("仓库核对期间速率余量不足,本轮放弃"); } }); if (!result.ok && (result.reason === "rate-limited" || result.reason === "unauthorized")) throw new Error(result.reason === "rate-limited" ? "仓库核对触发限流,本轮放弃" : "Token 已失效,本轮放弃"); results.push(result); } return results; } function writeFailureMessage(reason, status) { switch (reason) { case "invalid-target": return "仓库地址无效,无法操作。"; case "target-mismatch": return "仓库名称已指向另一个仓库,请同步后重试。"; case "target-busy": return "该仓库已有操作,请稍候。"; case "no-credential": return "需要配置 GitHub Token,或先登录 github.com。"; case "unauthorized": return "Token 已失效或在别处被撤销,请重新配置。"; case "permission-denied": return "权限不足:当前 Token 不能改这个仓库的星标。"; case "rate-limited": return "请求过于频繁,已暂停。请稍后再试。"; case "not-found": return "仓库不存在,或当前账号无权访问。"; case "requires-classic": return "当前 Token 改不了这个仓库的星标(fine-grained 对他人公开仓库只读)。请改用 classic Token,或先登录 github.com 再试。"; case "csrf-failed": return "GitHub 拒绝了这次操作。请刷新页面后重试;若仍失败,请配置 classic Token。"; case "network": return "网络错误,请检查网络后重试。"; default: return `操作失败(HTTP ${status})`; } } function hasWebSession() { const body = document.body; if (!body || !body.classList.contains("logged-in")) return false; return getUserLogin() !== ""; } function formAction(fullName, wantStar) { return `/${encodedRepoName(fullName)}/${wantStar ? "star" : "unstar"}`; } function findOnPageForm(fullName, wantStar) { const action = formAction(fullName, wantStar); return document.querySelector(`form[action="${action}"]`); } async function restWrite(pat, fullName, wantStar) { const url = starredUrl(fullName); let resp; try { await waitForMutationSlot(); resp = await fetch(url, { method: wantStar ? "PUT" : "DELETE", redirect: "error", credentials: "omit", headers: { Authorization: `Bearer ${pat}`, "X-GitHub-Api-Version": "2022-11-28" } }); } catch (err) { return { ok: false, reason: "network", status: 0, detail: String(err) }; } if (resp.status === 204 || resp.status === 304) return { ok: true, via: "rest" }; if (resp.status === 401) return { ok: false, reason: "unauthorized", status: 401 }; if (resp.status === 403 || resp.status === 429) { let text = ""; try { text = await resp.text(); } catch {} if (hasRateLimitSignal(resp, text)) return { ok: false, reason: "rate-limited", status: resp.status, detail: text.slice(0, 200) }; return { ok: false, reason: "permission-denied", status: resp.status, detail: text.slice(0, 200) }; } if (resp.status === 404) return { ok: false, reason: "not-found", status: 404 }; return { ok: false, reason: "unknown", status: resp.status }; } function placeholderToken() { const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; const bytes = new Uint8Array(86); crypto.getRandomValues(bytes); let out = ""; for (const b of bytes) out += ALPHABET[b % 64]; return out; } function offPageBody() { const fd = new FormData(); fd.append("authenticity_token", placeholderToken()); fd.append("context", "user_stars"); return fd; } async function webWrite(fullName, wantStar, tokenOverride) { const action = formAction(fullName, wantStar); const form = tokenOverride ? null : findOnPageForm(fullName, wantStar); let body; if (tokenOverride) { body = new FormData(); body.append("authenticity_token", tokenOverride); body.append("context", "user_stars"); } else if (form) body = new FormData(form); else body = offPageBody(); let resp; try { await waitForMutationSlot(); resp = await fetch(`https://github.com${action}`, { method: "POST", redirect: "error", credentials: "same-origin", headers: { "GitHub-Verified-Fetch": "true", "X-Requested-With": "XMLHttpRequest", Accept: "application/json" }, body }); } catch (err) { return { ok: false, reason: "network", status: 0, detail: String(err) }; } let text = ""; try { text = await resp.text(); } catch {} const json = (() => { try { return JSON.parse(text || "null"); } catch { return null; } })(); if (resp.ok) return { ok: true, via: "web" }; const detail = `HTTP ${resp.status}|json=${json === null ? "no" : "yes"}|${text.slice(0, 200)}`; if (resp.status === 422) return { ok: false, reason: "csrf-failed", status: 422, detail }; if (resp.status === 401) return { ok: false, reason: "unauthorized", status: 401, detail }; if (resp.status === 403 || resp.status === 429) { if (hasRateLimitSignal(resp, text)) return { ok: false, reason: "rate-limited", status: resp.status, detail }; return { ok: false, reason: "permission-denied", status: resp.status, detail }; } if (resp.status === 404) return { ok: false, reason: "not-found", status: 404, detail }; return { ok: false, reason: "unknown", status: resp.status, detail }; } async function writeResolved(patOrEmpty, fullName, wantStar) { if (patOrEmpty && isClassicCredential(patOrEmpty)) { const r = await restWrite(patOrEmpty, fullName, wantStar); if (r.ok) return r; if (r.reason === "permission-denied" && hasWebSession()) { console.warn("[github-star-manager] REST 写被拒(权限),改用浏览器会话路径重试"); return webWrite(fullName, wantStar); } return r; } if (hasWebSession()) return webWrite(fullName, wantStar); if (!patOrEmpty) return { ok: false, reason: "no-credential", status: 0 }; const r = await restWrite(patOrEmpty, fullName, wantStar); if (r.ok) return r; return r.reason === "permission-denied" ? { ok: false, reason: "requires-classic", status: r.status, detail: r.detail } : r; } function onPageTarget(target, wantStar) { const id = numericRepoId(target.repoId); if (!id) return null; for (const menu of document.querySelectorAll(`user-list-menu[data-repository-id="${id}"]`)) { const row = menu.closest("div.col-12, li"); const fullName = repoFullName((row?.querySelector("h3 a[href]")?.getAttribute("href") || "").replace(/^\//, "").replace(/\/$/, "")); if (!fullName) continue; const action = formAction(fullName, wantStar); if (row?.querySelector(`form[action="${action}"]`)) return { repoId: id, fullName }; } return null; } async function setStarState(token, target, wantStar, beforeWrite) { if (!repoTargetKey(target) || target.repoId && !numericRepoId(target.repoId)) return { ok: false, reason: "invalid-target", status: 0 }; const session = hasWebSession(); if (!token && !session) return { ok: false, reason: "no-credential", status: 0 }; const native = session && !isClassicCredential(token) ? onPageTarget(target, wantStar) : null; const result = native ? { ok: true, target: native } : await resolveRepoTarget(token, target); if (!result.ok) { const fallback = session && (result.reason === "not-found" || result.reason === "permission-denied") ? onPageTarget(target, wantStar) : null; if (!fallback) return result; if (beforeWrite && !beforeWrite(fallback)) return { ok: false, reason: "target-busy", status: 0 }; if (!claimMutationTarget(repoTargetKey(fallback))) return { ok: false, reason: "target-busy", status: 0 }; const written = await webWrite(fallback.fullName, wantStar); return written.ok ? { ...written, target: fallback } : written; } if (beforeWrite && !beforeWrite(result.target)) return { ok: false, reason: "target-busy", status: 0 }; if (!claimMutationTarget(repoTargetKey(result.target))) return { ok: false, reason: "target-busy", status: 0 }; const written = await writeResolved(token, result.target.fullName, wantStar); return written.ok ? { ...written, target: result.target } : written; } async function restoreOne(entry) { const { repoId, name } = entry; const target = repoTarget(repoId, name); if (!repoTargetKey(target)) return { repoId, name, ok: false, message: "仓库地址缺失,无法恢复。" }; const localRestore = {}; let conflict = false; const handle = enqueueMutation({ key: repoTargetKey(target), label: name, run: () => setStarState(getToken(), target, true, (resolved) => { localRestore.commit = prepareResolvedRestore(repoId, resolved) ?? void 0; conflict = !localRestore.commit; return !conflict; }) }); if (!handle) return { repoId, name, ok: false, message: "该仓库已有操作,请稍候。" }; const outcome = await handle.done; if (outcome === null) return { repoId, name, ok: false, message: "已取消排队。" }; if (!outcome.ok) { const message = conflict ? "仓库已有本地数据或备份已变化,原备份已保留,请同步后检查。" : writeFailureMessage(outcome.reason, outcome.status); console.warn(`[github-star-manager] 恢复失败:${name}|${outcome.reason}|${outcome.detail || ""}`); return { repoId, name, ok: false, message, stopBatch: outcome.reason === "rate-limited" || outcome.reason === "unauthorized" }; } if (!localRestore.commit || !localRestore.commit()) return { repoId, name, ok: false, message: "远端已加星,但本地数据发生冲突;原备份已保留,请同步后检查。" }; syncCardAfterStarChange(outcome.target.repoId, true); console.log(`[github-star-manager] ★ 已恢复 star:${name}`); return { repoId, name: outcome.target.fullName, ok: true }; } async function restoreMany(entries, opts = {}) { const total = entries.length; const failed = []; let succeeded = 0; let done = 0; let cancelled = false; for (const e of entries) { if (opts.token?.cancelled) { cancelled = true; break; } const result = await restoreOne({ repoId: e.repoId, name: e.name }); done += 1; if (result.ok) succeeded += 1; else failed.push(result); opts.onProgress?.(done, total, result); if (result.stopBatch) { cancelled = done < total; break; } } console.log(`[github-star-manager] 批量恢复结束:成功 ${succeeded}/${total}${failed.length ? `,失败 ${failed.length}` : ""}${cancelled ? "(已停止,后续未执行)" : ""}`); return { total, succeeded, failed, cancelled }; } var liveNotices = new Map(); function estimateBatchSeconds(count) { return Math.max(1, Math.round(count * MUTATION_GAP_MS / 1e3)); } function pushRestoreNotice(repoId, name, source) { const text = source === "manual" ? `已取消 star:${name}` : `检测到取消 star:${name}`; const actionLabel = source === "manual" ? "撤销" : "恢复"; liveNotices.get(repoId)?.dismiss(); let handle = null; handle = pushNotice(text, "warn", { actionLabel, onAction: async () => { const r = await restoreOne({ repoId, name }); if (r.ok) handle?.complete(`✓ ${name} 已恢复`); else window.alert(`恢复失败:${r.message || name}`); } }); liveNotices.set(repoId, handle); return handle; } function buildCardFromCache(repoId, data) { const card = document.createElement("div"); card.className = "stars-grid-card stars-grid-card-cached"; card.dataset.repoId = repoId; const repoHref = "/" + (data.name || ""); card.dataset.repoName = "/" + (data.name || ""); const nameParts = (data.name || "").split("/"); const owner = nameParts[0] || ""; const repo = nameParts[1] || data.name || "Unknown"; let cardHTML = "
${escapeHtml(data.desc)}
`; else cardHTML += "No description
"; cardHTML += ``; cardHTML += ""; cardHTML += ``; card.innerHTML = cardHTML; return card; } function setStarButtonVisual(btn, isStarred) { btn.classList.toggle("starred", isStarred); btn.classList.toggle("unstarred", !isStarred); btn.innerHTML = isStarred ? STAR_FILL_SVG : STAR_EMPTY_SVG; btn.title = isStarred ? "Unstar" : "Star"; } function createStarButtonElement(isStarred) { const btn = document.createElement("button"); btn.className = "stars-star-btn"; btn.type = "button"; setStarButtonVisual(btn, isStarred); return btn; } function createStarButtonForCached(card, data, isStarred) { const repoId = card.dataset.repoId || ""; const requestTarget = repoTarget(repoId, data.name); if (!repoTargetKey(requestTarget)) return; const fullName = data.name || repoId; const btn = createStarButtonElement(isStarred ?? !data.unstarredAt); let inflight = null; btn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); if (inflight && inflight.handle.isQueued()) { const target = inflight.target; inflight.handle.cancelQueued(); inflight = null; setStarButtonVisual(btn, !target); pushNotice(`已取消排队:${fullName}`, "info"); return; } if (inflight) return; const target = !btn.classList.contains("starred"); setStarButtonVisual(btn, target); const handle = enqueueMutation({ key: repoTargetKey(requestTarget), label: fullName, run: () => setStarState(getToken(), requestTarget, target) }); if (!handle) { setStarButtonVisual(btn, !target); pushNotice("该仓库已有操作,请稍候。", "info"); return; } inflight = { handle, target }; handle.done.then((outcome) => { const isCurrent = inflight !== null && inflight.handle === handle; if (isCurrent) inflight = null; if (outcome === null) { if (isCurrent) setStarButtonVisual(btn, !target); return; } if (!outcome.ok) { if (isCurrent) setStarButtonVisual(btn, !target); const message = writeFailureMessage(outcome.reason, outcome.status); console.warn(`[github-star-manager] 星标写入失败:${fullName}|${outcome.reason}|${outcome.detail || ""}`); if (isCurrent) window.alert(`${target ? "加星" : "取消 star"}失败:${message}`); if (outcome.reason === "unauthorized") notifyTokenIssue("401 Bad credentials:Token 已失效或被撤销"); return; } data.name = outcome.target.fullName; requestTarget.fullName = outcome.target.fullName; if (repoId) { if (target) markRepoStarred(repoId, outcome.target.fullName); else markRepoUnstarred(repoId, data); setViewStarOverride(repoId, target); } syncCardAfterStarChange(repoId, target); if (!target) pushRestoreNotice(repoId, outcome.target.fullName, "manual"); }); }); const header = card.querySelector(".stars-card-header"); if (header) header.appendChild(btn); } function searchTerms() { return filterState.searchQuery.toLowerCase().split(/\s+/).filter((t) => t.length > 0); } function sortResults(results) { const dir = filterState.direction === "asc" ? -1 : 1; const tie = (a, b) => (a.data.name || "").localeCompare(b.data.name || ""); results.sort((a, b) => { const key = filterState.sort; if (key === "stars" || key === "forks") { const av = key === "stars" ? a.data.stars : a.data.forks; const bv = key === "stars" ? b.data.stars : b.data.forks; if (av === void 0 && bv === void 0) return tie(a, b); if (av === void 0) return 1; if (bv === void 0) return -1; if (av !== bv) return (bv - av) * dir; return tie(a, b); } const av = key === "created" ? a.data.starredAt : a.data.updatedAt; const bv = key === "created" ? b.data.starredAt : b.data.updatedAt; if (!av && !bv) return tie(a, b); if (!av) return 1; if (!bv) return -1; if (av !== bv) return (av < bv ? 1 : -1) * dir; return tie(a, b); }); } function typeMatches(data, type) { switch (type) { case "public": return !data.private; case "private": return !!data.private; case "source": return !data.fork; case "fork": return !!data.fork; case "mirror": return !!data.mirror; case "template": return !!data.isTemplate; default: return true; } } function queryRepos(skip) { const otherPage = getOtherPageRepos(); if (otherPage) return Object.keys(otherPage.repos).map((repoId) => ({ repoId, data: otherPage.repos[repoId] })); const cache = loadRepoCache(); const allTags = loadAllTags(); const allNotes = loadAllNotes(); const terms = searchTerms(); const results = []; for (const repoId in cache) { const data = cache[repoId]; if (skip !== "type" && filterState.types.length > 0) { if (!filterState.types.some((t) => typeMatches(data, t))) continue; } if (skip !== "lang" && filterState.langs.length > 0) { if (!filterState.langs.some((l) => l === "(none)" ? !data.lang : (data.lang || "").toLowerCase() === l.toLowerCase())) continue; } const repoTags = allTags[repoId] || []; if (filterState.tags.length > 0 && !filterState.tags.every((ft) => repoTags.includes(ft))) continue; if (terms.length > 0) { const [author, repo] = (data.name || "").toLowerCase().split("/"); const desc = (data.desc || "").toLowerCase(); const tagTexts = repoTags.map((t) => t.toLowerCase()); const note = (allNotes[repoId] || "").toLowerCase(); if (!terms.every((term) => (author || "").includes(term) || (repo || "").includes(term) || desc.includes(term) || tagTexts.some((t) => t.includes(term)) || note.includes(term))) continue; } results.push({ repoId, data }); } sortResults(results); return results; } function computeTagCandidates() { const allTags = loadAllTags(); const counts = new Map(); for (const { repoId } of queryRepos()) for (const tag of allTags[repoId] || []) counts.set(tag, (counts.get(tag) || 0) + 1); for (const tag of filterState.tags) if (!counts.has(tag)) counts.set(tag, 0); return Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([tag]) => tag); } function hasAnyTags() { const allTags = loadAllTags(); for (const repoId in allTags) if (allTags[repoId].length > 0) return true; return false; } function computeLanguageCandidates() { const langs = new Set(); for (const { data } of queryRepos("lang")) if (data.lang) langs.add(data.lang); for (const l of filterState.langs) if (l !== "(none)") langs.add(l); return Array.from(langs).sort((a, b) => a.localeCompare(b)); } var LANG_NONE = "(none)"; var LANG_NONE_LABEL = "None"; function hasLangNoneCandidate() { if (filterState.langs.includes("(none)")) return true; for (const { data } of queryRepos("lang")) if (!data.lang) return true; return false; } function computeTypeCandidates() { const present = new Set(); for (const { data } of queryRepos("type")) for (const opt of TYPE_OPTIONS) if (typeMatches(data, opt.value)) present.add(opt.value); for (const t of filterState.types) present.add(t); return TYPE_OPTIONS.map((o) => o.value).filter((v) => present.has(v)); } function renderBrowsePage(page) { const gridContainer = document.querySelector(".stars-grid-container"); if (!gridContainer) return 0; const readOnly = isReadOnlyView(); const viewerCache = readOnly ? loadViewerCacheForView() : null; const canShowStar = !readOnly || viewerCache !== null; const results = queryRepos(); const pageSize = readOnly ? Math.max(results.length, 1) : 30; const totalPages = Math.max(1, Math.ceil(results.length / pageSize)); filterState.page = Math.min(Math.max(1, page), totalPages); filterState.totalPages = totalPages; const start = (filterState.page - 1) * pageSize; const terms = searchTerms(); const bottomPager = gridContainer.querySelector(".paginate-container.gsm-local-pager"); const frag = document.createDocumentFragment(); for (const { repoId, data } of results.slice(start, start + pageSize)) { const card = buildCardFromCache(repoId, data); frag.appendChild(card); renderCardTagAndNoteAreas(card, readOnly ? "other" : "own", viewerCache); if (readOnly) { if (canShowStar) createStarButtonForCached(card, data, isStarredByViewer(repoId, viewerCache)); continue; } createStarButtonForCached(card, data); if (terms.length > 0) highlightMatchesInCard(card, terms); } if (bottomPager) frag.appendChild(bottomPager); gridContainer.replaceChildren(frag); if (!readOnly) updateLocalPagers(); return results.length; } function updateLocalPagers() { document.querySelectorAll(".paginate-container.gsm-local-pager").forEach((pager) => { const info = pager.querySelector(".gsm-page-info"); if (info) info.textContent = `${filterState.page} / ${filterState.totalPages}`; pager.querySelector("[data-gsm-page=\"prev\"]")?.classList.toggle("disabled", filterState.page <= 1); pager.querySelector("[data-gsm-page=\"next\"]")?.classList.toggle("disabled", filterState.page >= filterState.totalPages); }); } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function highlightMatchesInCard(card, terms) { if (terms.length === 0) return; const re = new RegExp(`(${terms.map(escapeRegExp).join("|")})`, "gi"); card.querySelectorAll(".stars-card-header a, .stars-card-desc, .stars-card-tags, .stars-card-notes").forEach((root) => { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const textNodes = []; let node; while (node = walker.nextNode()) textNodes.push(node); for (const textNode of textNodes) { const parent = textNode.parentElement; if (!parent || parent.closest("textarea, input, [contenteditable=\"true\"], script, style")) continue; const text = textNode.nodeValue; if (!text || !re.test(text)) continue; re.lastIndex = 0; const frag = document.createDocumentFragment(); let last = 0; for (const m of text.matchAll(re)) { const idx = m.index ?? 0; if (idx > last) frag.appendChild(document.createTextNode(text.slice(last, idx))); const mark = document.createElement("mark"); mark.className = "gsm-search-hit"; mark.textContent = m[0]; frag.appendChild(mark); last = idx + m[0].length; } if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); textNode.replaceWith(frag); } }); } function renderFilterInfoBar(count) { document.querySelectorAll(".stars-tag-info-bar").forEach((el) => el.remove()); if (!hasActiveFilter()) return; const colLg9 = getStarsMainColumn(); if (!colLg9) return; const gridContainer = colLg9.querySelector(".stars-grid-container"); if (!gridContainer) return; const nativeBar = getNativeFilterBar(colLg9); if (nativeBar) hideNativeNode(nativeBar); const bar = document.createElement("div"); bar.className = "stars-tag-info-bar TableObject border-bottom color-border-muted py-3"; const infoDiv = document.createElement("div"); infoDiv.className = "TableObject-item TableObject-item--primary"; const infoSpan = document.createElement("span"); infoSpan.setAttribute("role", "status"); let desc = "" + count + " repos"; if (filterState.searchQuery) desc += " matching \"" + escapeHtml(filterState.searchQuery) + "\""; if (filterState.tags.length > 0) desc += " with tags: " + filterState.tags.map((t) => "" + escapeHtml(t) + "").join(", "); if (filterState.types.length > 0) { const labels = filterState.types.map((t) => TYPE_OPTIONS.find((o) => o.value === t)?.label || t); desc += " · type: " + escapeHtml(labels.join(", ")) + ""; } if (filterState.langs.length > 0) { const names = filterState.langs.map((l) => l === "(none)" ? LANG_NONE_LABEL : l); desc += " · language: " + escapeHtml(names.join(", ")) + ""; } infoSpan.innerHTML = desc; infoDiv.appendChild(infoSpan); const clearLink = document.createElement("a"); clearLink.className = "issues-reset-query text-normal TableObject-item text-right"; clearLink.href = "#"; clearLink.innerHTML = " Clear filter"; clearLink.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); exitCustomMode(); }); bar.appendChild(infoDiv); bar.appendChild(clearLink); colLg9.insertBefore(bar, gridContainer); } function applyFilters(opts = {}) { if (!isDesktop()) return; if (isReadOnlyView()) return; updateLocalFilterControls(); refreshTagFilterBar(); const filtered = hasActiveFilter(); const colLg9 = getStarsMainColumn(); if (colLg9) { const nativeBar = getNativeFilterBar(colLg9); if (nativeBar) { if (filtered) hideNativeNode(nativeBar); else showNativeNode(nativeBar); } } if (!filtered) document.querySelectorAll(".stars-tag-info-bar").forEach((el) => el.remove()); const total = renderBrowsePage(opts.keepPage ? filterState.page : 1); if (filtered) renderFilterInfoBar(total); } function exitCustomMode() { filterState.tags = []; filterState.langs = []; filterState.types = []; filterState.searchQuery = ""; filterState.page = 1; const searchInput = document.querySelector("input[placeholder*=\"Search starred\"]"); if (searchInput) searchInput.value = ""; applyFilters(); refreshTagPillStates(); history.pushState({}, "", location.pathname + "?tab=stars"); markUrlParsed(); } var lastParsedUrlSignature = null; function urlFilterSignature() { const p = new URLSearchParams(location.search); return `sort=${p.get("sort") ?? ""}|direction=${p.get("direction") ?? ""}|language=${p.get("language") ?? ""}|type=${p.get("type") ?? ""}`; } function markUrlParsed() { lastParsedUrlSignature = urlFilterSignature(); } function canonicalLangName(raw) { const lower = raw.toLowerCase(); for (const data of Object.values(loadRepoCache())) if (data.lang && data.lang.toLowerCase() === lower) return data.lang; return lower; } function initFiltersFromUrl() { const sig = urlFilterSignature(); if (sig === lastParsedUrlSignature) return; lastParsedUrlSignature = sig; const p = new URLSearchParams(location.search); const sort = p.get("sort"); filterState.sort = sort === "created" || sort === "updated" || sort === "stars" || sort === "forks" ? sort : "created"; filterState.direction = p.get("direction") === "asc" ? "asc" : "desc"; const langParam = p.get("language") || ""; filterState.langs = langParam ? Array.from(new Set(langParam.split(",").map((s) => s.trim()).filter(Boolean).map((s) => /^\(?none\)?$/i.test(s) ? LANG_NONE : canonicalLangName(s)))) : []; const VALID_TYPES = [ "public", "private", "source", "fork", "mirror", "template" ]; const typeParam = p.get("type") || ""; filterState.types = typeParam ? Array.from(new Set(typeParam.split(",").map((s) => s.trim().toLowerCase()).filter((s) => VALID_TYPES.includes(s)))) : []; filterState.page = 1; } function hideNativeFilterMenus() { for (const id of [ "stars-type-filter-menu-button", "stars-language-filter-menu-button", "stars-sort-menu-button" ]) { const btn = document.getElementById(id); const menu = btn ? btn.closest("action-menu") : null; if (menu instanceof HTMLElement) hideNativeNode(menu); } } function updateLocalFilterControls() { hideNativeFilterMenus(); const filterRow = getNativeFilterRow(); if (!filterRow) return; if (!filterRow.querySelector(".gsm-type-filter")) { const anchorNode = filterRow.querySelector(".stars-tag-filter") || filterRow.firstChild; const typeMenu = buildTypeMenu(); const langMenu = buildLangMenu(); const sortMenu = buildSortGroup(); if (anchorNode && anchorNode.parentNode === filterRow) filterRow.insertBefore(typeMenu, anchorNode.nextSibling); else filterRow.appendChild(typeMenu); filterRow.insertBefore(langMenu, typeMenu.nextSibling); filterRow.insertBefore(sortMenu, langMenu.nextSibling); } refreshTypeMenu(filterRow.querySelector(".gsm-type-filter")); const langContainer = filterRow.querySelector(".gsm-lang-filter"); if (langContainer) refreshLangMenu(langContainer); const sortContainer = filterRow.querySelector(".gsm-sort-filter"); if (sortContainer) refreshSortGroup(sortContainer); } function buildMultiSelectItem(label, checked, onClick) { const li = document.createElement("li"); li.className = "ActionListItem"; li.setAttribute("role", "none"); const content = document.createElement("a"); content.className = "ActionListContent"; content.setAttribute("role", "menuitemcheckbox"); content.setAttribute("aria-checked", String(checked)); const labelEl = document.createElement("span"); labelEl.className = "ActionListItem-label"; labelEl.textContent = label; const check = document.createElement("span"); check.className = "ActionListItem-visual ActionListItem-visual--trailing gsm-check-visual"; check.innerHTML = CHECK_SVG; check.style.visibility = checked ? "visible" : "hidden"; content.append(labelEl, check); content.addEventListener("click", (e) => { e.preventDefault(); onClick(); }); li.appendChild(content); return li; } function toggleTypeSelection(value) { const idx = filterState.types.indexOf(value); if (idx >= 0) filterState.types.splice(idx, 1); else filterState.types.push(value); applyFilters(); } function typeButtonLabel() { const n = filterState.types.length; if (n === 0) return "Type"; if (n === 1) { const l = filterState.types[0]; const opt = TYPE_OPTIONS.find((o) => o.value === l); return "Type: " + (opt ? opt.label : l); } return `Type: ${n} selected`; } function buildTypeMenu() { const typeContainer = document.createElement("div"); typeContainer.className = "stars-custom-filter gsm-type-filter mb-1 mb-lg-0 mr-2"; const typeBtnEl = document.createElement("button"); typeBtnEl.type = "button"; typeBtnEl.id = "stars-custom-type-button"; typeBtnEl.setAttribute("popovertarget", "stars-custom-type-overlay"); typeBtnEl.setAttribute("aria-haspopup", "true"); typeBtnEl.className = "Button--secondary Button--medium Button"; typeBtnEl.innerHTML = "" + TRIANGLE_DOWN_SVG + ""; const typeOverlay = document.createElement("anchored-position"); typeOverlay.id = "stars-custom-type-overlay"; typeOverlay.setAttribute("anchor", "stars-custom-type-button"); typeOverlay.setAttribute("align", "start"); typeOverlay.setAttribute("side", "outside-bottom"); typeOverlay.setAttribute("anchor-offset", "normal"); typeOverlay.setAttribute("popover", "auto"); const typeInner = document.createElement("div"); typeInner.className = "Overlay Overlay--size-auto"; const typeBody = document.createElement("div"); typeBody.className = "Overlay-body Overlay-body--paddingNone"; const typeList = document.createElement("ul"); typeList.className = "ActionListWrap--inset ActionListWrap"; typeList.setAttribute("role", "menu"); typeBody.appendChild(typeList); typeInner.appendChild(typeBody); typeOverlay.appendChild(typeInner); typeContainer.appendChild(typeBtnEl); typeContainer.appendChild(typeOverlay); return typeContainer; } function refreshTypeMenu(typeContainer) { const btn = typeContainer.querySelector(".Button"); if (btn) { btn.classList.toggle("has-active", filterState.types.length > 0); const labelEl = btn.querySelector(".Button-label"); if (labelEl) labelEl.textContent = typeButtonLabel(); } const typeList = typeContainer.querySelector("ul"); if (!typeList) return; typeList.innerHTML = ""; typeList.appendChild(buildMultiSelectItem("All", filterState.types.length === 0, () => { filterState.types = []; applyFilters(); })); for (const value of computeTypeCandidates()) { const opt = TYPE_OPTIONS.find((o) => o.value === value); if (!opt) continue; typeList.appendChild(buildMultiSelectItem(opt.label, filterState.types.includes(value), () => toggleTypeSelection(value))); } } function langSelected(lang) { return filterState.langs.some((l) => l.toLowerCase() === lang.toLowerCase()); } function toggleLangSelection(lang) { const idx = filterState.langs.findIndex((l) => l.toLowerCase() === lang.toLowerCase()); if (idx >= 0) filterState.langs.splice(idx, 1); else filterState.langs.push(lang); applyFilters(); } function langButtonLabel() { const n = filterState.langs.length; if (n === 0) return "Language"; if (n === 1) { const l = filterState.langs[0]; return "Language: " + (l === "(none)" ? LANG_NONE_LABEL : l); } return `Language: ${n} selected`; } function buildLangMenu() { const langContainer = document.createElement("div"); langContainer.className = "stars-custom-filter gsm-lang-filter mb-1 mb-lg-0"; const langBtnEl = document.createElement("button"); langBtnEl.type = "button"; langBtnEl.id = "stars-custom-lang-button"; langBtnEl.setAttribute("popovertarget", "stars-custom-lang-overlay"); langBtnEl.setAttribute("aria-haspopup", "true"); langBtnEl.className = "Button--secondary Button--medium Button"; langBtnEl.innerHTML = "" + TRIANGLE_DOWN_SVG + ""; const langOverlay = document.createElement("anchored-position"); langOverlay.id = "stars-custom-lang-overlay"; langOverlay.setAttribute("anchor", "stars-custom-lang-button"); langOverlay.setAttribute("align", "start"); langOverlay.setAttribute("side", "outside-bottom"); langOverlay.setAttribute("anchor-offset", "normal"); langOverlay.setAttribute("popover", "auto"); const langInner = document.createElement("div"); langInner.className = "Overlay Overlay--size-auto"; const langBody = document.createElement("div"); langBody.className = "Overlay-body Overlay-body--paddingNone"; const langList = document.createElement("ul"); langList.className = "ActionListWrap--inset ActionListWrap"; langList.setAttribute("role", "menu"); langBody.appendChild(langList); langInner.appendChild(langBody); langOverlay.appendChild(langInner); langContainer.appendChild(langBtnEl); langContainer.appendChild(langOverlay); return langContainer; } function refreshLangMenu(langContainer) { const btn = langContainer.querySelector(".Button"); if (btn) { btn.classList.toggle("has-active", filterState.langs.length > 0); const labelEl = btn.querySelector(".Button-label"); if (labelEl) labelEl.textContent = langButtonLabel(); } const langList = langContainer.querySelector("ul"); if (!langList) return; langList.innerHTML = ""; langList.appendChild(buildMultiSelectItem("All languages", filterState.langs.length === 0, () => { filterState.langs = []; applyFilters(); })); for (const lang of computeLanguageCandidates()) langList.appendChild(buildMultiSelectItem(lang, langSelected(lang), () => toggleLangSelection(lang))); if (hasLangNoneCandidate()) langList.appendChild(buildMultiSelectItem(LANG_NONE_LABEL, filterState.langs.includes(LANG_NONE), () => toggleLangSelection(LANG_NONE))); } function buildSortGroup() { const sortContainer = document.createElement("div"); sortContainer.className = "stars-custom-filter gsm-sort-filter mb-1 mb-lg-0 ml-2"; const group = document.createElement("div"); group.className = "gsm-sort-group"; const sortBtnEl = document.createElement("button"); sortBtnEl.type = "button"; sortBtnEl.id = "stars-custom-sort-button"; sortBtnEl.setAttribute("popovertarget", "stars-custom-sort-overlay"); sortBtnEl.setAttribute("aria-haspopup", "true"); sortBtnEl.className = "Button--secondary Button--medium Button"; sortBtnEl.innerHTML = "" + TRIANGLE_DOWN_SVG + ""; const dirBtn = document.createElement("button"); dirBtn.type = "button"; dirBtn.id = "stars-custom-dir-button"; dirBtn.className = "Button--secondary Button--medium Button gsm-dir-btn"; dirBtn.setAttribute("aria-label", "切换排序方向"); dirBtn.addEventListener("click", () => { filterState.direction = filterState.direction === "desc" ? "asc" : "desc"; applyFilters(); }); group.append(sortBtnEl, dirBtn); sortContainer.appendChild(group); const sortOverlay = document.createElement("anchored-position"); sortOverlay.id = "stars-custom-sort-overlay"; sortOverlay.setAttribute("anchor", "stars-custom-sort-button"); sortOverlay.setAttribute("align", "start"); sortOverlay.setAttribute("side", "outside-bottom"); sortOverlay.setAttribute("anchor-offset", "normal"); sortOverlay.setAttribute("popover", "auto"); const sortInner = document.createElement("div"); sortInner.className = "Overlay Overlay--size-auto"; const sortBody = document.createElement("div"); sortBody.className = "Overlay-body Overlay-body--paddingNone"; const sortList = document.createElement("ul"); sortList.className = "ActionListWrap--inset ActionListWrap"; sortList.setAttribute("role", "menu"); sortBody.appendChild(sortList); sortInner.appendChild(sortBody); sortOverlay.appendChild(sortInner); sortContainer.appendChild(sortOverlay); return sortContainer; } function refreshSortGroup(sortContainer) { const sortLabelEl = sortContainer.querySelector(".Button-label"); if (sortLabelEl) sortLabelEl.textContent = "Sort by: " + (SORT_OPTIONS.find((o) => o.key === filterState.sort) || SORT_OPTIONS[0]).label; const dirBtn = sortContainer.querySelector("#stars-custom-dir-button"); if (dirBtn) { const isAsc = filterState.direction === "asc"; dirBtn.title = isAsc ? "排序方向:升序(点击切为降序)" : "排序方向:降序(点击切为升序)"; dirBtn.innerHTML = "" + (isAsc ? ARROW_UP_SVG : ARROW_DOWN_SVG) + ""; } const sortList = sortContainer.querySelector("ul"); if (!sortList) return; sortList.innerHTML = ""; SORT_OPTIONS.forEach((opt) => { const li = document.createElement("li"); li.className = "ActionListItem"; li.setAttribute("role", "none"); const content = document.createElement("a"); content.className = "ActionListContent"; content.setAttribute("role", "menuitemradio"); content.setAttribute("aria-checked", String(filterState.sort === opt.key)); const label = document.createElement("span"); label.className = "ActionListItem-label"; label.textContent = opt.label; content.appendChild(label); content.addEventListener("click", (e) => { e.preventDefault(); filterState.sort = opt.key; sortContainer.querySelector("#stars-custom-sort-overlay")?.hidePopover(); applyFilters(); }); li.appendChild(content); sortList.appendChild(li); }); } var getGitHubPat = getToken; async function promptForToken(notify = true) { const cur = getGitHubPat(); const masked = cur ? `${cur.slice(0, 12)}…${cur.slice(-4)}` : "未设置"; const input = window.prompt(`GitHub PAT,用于全量同步 star 列表、以及加星/取消星。 · classic:ghp_ 前缀(OAuth 的 gho_ 同样适用)——读列表与写星标都可用,推荐。 同步私有仓库需勾选 repo scope;只涉及公开仓库时无需勾选。 · fine-grained:github_pat_ 前缀——可读列表,但 GitHub 不允许它改别人的公开仓库星标。 仓库范围建议选 All repositories(否则私有仓库的 star 会被漏读而误判为已取关)。 (留空 = 删除当前 token 并重新打开配置面板;保存后立即生效) 当前:${masked}`, ""); if (input === null) return; const outcome = await setTokenVerified(input); if (outcome.result === "invalid") { window.alert("无法识别的 token 前缀:预期 ghp_ / gho_(classic)或 github_pat_(fine-grained)。未保存。"); return; } if (outcome.result === "cleared") { console.log("[github-star-manager] token 已清除,外部 unstar 核对与 P4 同步暂停"); notifyTokenIssue("Token 已清除"); return; } if (outcome.result === "dead") { window.alert("这个 Token 已失效或被撤销(401),未保存 —— 请到配置面板换一个 Token。"); return; } console.log(`[github-star-manager] token 已保存(${outcome.kind ?? "未知类型"}${outcome.result === "saved-unverified" ? ",但暂时未能确认所属账号" : ""}),外部 unstar 核对与 P4 同步生效`); if (notify) notifyTokenSaved(); } function registerTokenMenu() { gmRegisterMenuCommand("⭐ 设置 GitHub Token", () => { promptForToken(); }); gmRegisterMenuCommand("🔑 快捷创建 Token:classic(推荐,可写星标)", () => { openClassicTokenCreator(); }); gmRegisterMenuCommand("🔑 快捷创建 Token:fine-grained(仅读)", () => { openTokenCreator(); }); } function confirmExternalUnstar(repoId, path) { const pending = loadPendingDelete(); const existed = !!pending[repoId]; const cache = loadRepoCache(); if (!existed) { pending[repoId] = Object.assign({}, cache[repoId] || { name: path.replace(/^\//, "") }, { name: path.replace(/^\//, "") || cache[repoId]?.name || "", unstarredAt: Date.now(), _tags: getTags(repoId), _note: getNote(repoId) }); savePendingDelete(pending); } if (cache[repoId]) { delete cache[repoId]; saveRepoCache(cache); } if (getTags(repoId).length > 0) saveTags(repoId, []); if (getNote(repoId)) saveNote(repoId, ""); if (!existed) console.log(`[github-star-manager] ★ 核对确认外部 unstar: ${path}(标签/备注已备份入 24h 宽限期区,期间在详情页重新 star 可恢复)`); syncCardAfterStarChange(repoId, false); return !existed; } function syncCardAfterStarChange(repoId, isStarred) { const card = document.querySelector(`.stars-grid-card[data-repo-id="${repoId}"]`); if (!card) return; const btn = card.querySelector(".stars-star-btn"); if (btn && btn.classList.contains("starred") !== isStarred) setStarButtonVisual(btn, isStarred); if (isReadOnlyView()) { renderCardTagAndNoteAreas(card, "other", loadViewerCacheForView()); return; } renderCardTagAndNoteAreas(card, "own"); if (filterState.tags.length > 0 || filterState.langs.length > 0 || filterState.types.length > 0 || filterState.searchQuery) applyFilters({ keepPage: true }); } function applyExternalUnstar(repoId, path) { return confirmExternalUnstar(repoId, path); } function decide(identity, sessionId, sessionLogin, writeTarget) { const shared = { hasToken: true, writeTarget, tokenId: identity.id, sessionId, tokenLogin: identity.login, sessionLogin }; if (identity.id === sessionId) return { state: "match", ...shared }; return { state: "mismatch", ...shared }; } function unknown(hasToken) { return { state: "unknown", hasToken }; } async function run() { const token = getToken(); if (!token) return unknown(false); const sessionId = getViewerId(); if (!sessionId) return unknown(true); if (!hasWebSession()) return unknown(true); const sessionLogin = getViewerLogin(); const writeTarget = isClassicCredential(token) ? "token" : "session"; const cached = getTokenIdentitySync(token); if (cached) return decide(cached, sessionId, sessionLogin, writeTarget); const res = await resolveTokenIdentity(token); if (!res.ok) return unknown(true); return decide(res.identity, sessionId, sessionLogin, writeTarget); } var inflight = null; function evaluateAccountMatch() { if (!isDesktop()) { console.log("[github-star-manager] 窄视口:跳过 Token 归属校验(不显示任何脚本 UI)"); return Promise.resolve(unknown(true)); } if (inflight) return inflight; inflight = run().catch((err) => { console.error("[github-star-manager] 归属校验执行失败", err); return unknown(true); }).finally(() => { inflight = null; }); return inflight; } function accountPairKey(verdict) { if (!verdict.tokenId || !verdict.sessionId) return null; return `${verdict.tokenId}#${verdict.sessionId}`; } var placer = null; var openTokenConfig = null; function setAccountBannerPlacer(fn) { placer = fn; } function setOpenTokenConfigHandler(fn) { openTokenConfig = fn; } var PAIR_ATTR = "data-gsm-account-pair"; function isDismissed(verdict) { const key = accountPairKey(verdict); if (!key) return false; return gmGet(STORAGE_KEYS.accountBannerDismissed, "") === key; } function bannerMessage$1(verdict) { const tokenWho = verdict.tokenLogin || `另一个账号(id ${verdict.tokenId || "未知"})`; const sessionWho = verdict.sessionLogin || `当前账号(id ${verdict.sessionId || "未知"})`; const head = `⚠️ Token 归属与当前登录不一致:Token 属于 @${tokenWho},而浏览器登录的是 @${sessionWho}。`; if (verdict.writeTarget === "token") return head + `列表里的星标数据取自 @${tokenWho},加星 / 取消星通常也记到 @${tokenWho}(不会改动 @${sessionWho}),但你正在 @${sessionWho} 的 Stars 页面上操作,容易看错账号。若 @${tokenWho} 的 Token 被 GitHub 拒绝,操作会改以 @${sessionWho} 的身份进行。标签、备注与取消 star 后的备份都属于 @${tokenWho}。请换成与当前登录匹配的 Token,或改用 Token 所属的账号登录。`; return head + `列表里的星标数据取自 @${tokenWho},但在这里加星 / 取消星会记录到 @${sessionWho},两边会不一致。标签、备注与取消 star 后的备份都属于 @${tokenWho}。请换成与当前登录匹配的 Token,或改用 Token 所属的账号登录。`; } function removeExisting() { document.querySelectorAll(".gsm-account-banner").forEach((el) => el.remove()); } function repositionAccountBanner() { const bar = document.querySelector(".gsm-account-banner"); if (!bar || !placer) return; if (!placer(bar)) bar.remove(); } function showAccountBanner(verdict) { const pair = accountPairKey(verdict) || ""; const existing = document.querySelector(".gsm-account-banner"); if (existing && existing.getAttribute(PAIR_ATTR) === pair) return; if (isDismissed(verdict)) { removeExisting(); return; } if (!placer) return; removeExisting(); const bar = document.createElement("div"); bar.className = "gsm-account-banner"; bar.setAttribute(PAIR_ATTR, pair); bar.setAttribute("role", "alert"); const msg = document.createElement("span"); msg.className = "gsm-account-msg"; msg.textContent = bannerMessage$1(verdict); const openConfig = document.createElement("button"); openConfig.className = "btn"; openConfig.type = "button"; openConfig.textContent = "打开 Token 配置"; openConfig.addEventListener("click", () => openTokenConfig?.()); const dismiss = document.createElement("button"); dismiss.className = "btn gsm-account-dismiss"; dismiss.type = "button"; dismiss.textContent = "关闭"; dismiss.title = "关闭这条提示(换 Token 或换登录账号后会再次提醒)"; dismiss.addEventListener("click", () => { const key = accountPairKey(verdict); if (key) gmSet(STORAGE_KEYS.accountBannerDismissed, key); bar.remove(); }); bar.append(msg, openConfig, dismiss); if (!placer(bar)) bar.remove(); } function mountAccountGuard() { const gen = currentGeneration(); evaluateAccountMatch().then((verdict) => { if (gen !== currentGeneration()) return; if (!isDesktop()) return; if (verdict.state === "mismatch") { showAccountBanner(verdict); return; } if (verdict.state === "match" || !verdict.hasToken) removeExisting(); }).catch((err) => console.warn("[github-star-manager] 归属警告渲染失败", err)); } var PAGE_SIZE = 100; var MAX_PAGES = 200; var WAVE_CONCURRENCY = 6; var WAVE_GAP_MS = 1e3; var RATE_FLOOR = 10; var PROBE_COOLDOWN_MS = 6e4; var FULL_SYNC_TTL_MS = 1728e5; var syncing = false; var lastProbeAt = 0; var scanRemaining = null; var syncState = { phase: "idle" }; var syncStateListeners = new Set(); var lastSyncSummary; function getSyncState() { return { ...syncState }; } function subscribeSyncState(listener) { syncStateListeners.add(listener); return () => { syncStateListeners.delete(listener); }; } function setSyncState(next) { syncState = next; for (const listener of [...syncStateListeners]) try { listener(syncState); } catch (err) { console.error("[github-star-manager] 同步状态订阅者抛错(已隔离,不影响同步):", err); } } function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function normEtag(e) { return e.startsWith("W/") ? e.slice(2) : e; } function parseItem(raw) { if (!raw || typeof raw !== "object") return null; const item = raw; const repo = item.repository ?? item.repo ?? item; if (typeof repo.id !== "number" || !numericRepoId(repo.id) || !repoFullName(repo.full_name)) return null; const meta = { name: String(repo.full_name) }; if (typeof repo.description === "string") meta.desc = repo.description; else if (repo.description === null) meta.desc = ""; if (typeof repo.language === "string" && repo.language) meta.lang = repo.language; if (typeof repo.stargazers_count === "number") meta.stars = repo.stargazers_count; if (typeof repo.forks_count === "number") meta.forks = repo.forks_count; if (typeof repo.pushed_at === "string") meta.updatedAt = repo.pushed_at; if (typeof repo.private === "boolean") meta.private = repo.private; if (typeof repo.fork === "boolean") meta.fork = repo.fork; if (typeof repo.is_template === "boolean") meta.isTemplate = repo.is_template; if ("mirror_url" in repo) meta.mirror = repo.mirror_url != null; return { repoId: String(repo.id), path: String(repo.full_name), starredAt: typeof item.starred_at === "string" ? item.starred_at : void 0, meta }; } function parseTotalPages(link) { if (!link) return 1; const m = link.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/); if (m) return Number(m[1]); return link.includes("rel=\"next\"") ? null : 1; } function apiHeaders(tok, ifNoneMatch) { const h = { Authorization: `Bearer ${tok}`, Accept: "application/vnd.github.star+json", "X-GitHub-Api-Version": "2022-11-28" }; if (ifNoneMatch) h["If-None-Match"] = ifNoneMatch; return h; } function authIssueMessage(resp) { const retryAfter = resp.headers.get("retry-after"); const exhausted = resp.headers.get("x-ratelimit-remaining") === "0"; if (resp.status === 401) return "token 无效(401),已上报到初始化面板"; if (resp.status === 403) { if (retryAfter) return `触发次级速率限制(retry-after ${retryAfter}s),稍后再试`; if (exhausted) return "主速率限制已用尽,稍后再试"; return "403 权限不足(Token 不能访问该资源)"; } return `HTTP ${resp.status}`; } async function fetchStarredPage(tok, page, signal, ifNoneMatch) { const resp = await fetch(pageUrl(page), { cache: "no-store", signal, headers: apiHeaders(tok, ifNoneMatch) }); if (resp.status === 304) return { page, etag: "", link: null, body: [], notModified: true }; if (resp.status === 401 || resp.status === 403) { reportAuthIssue(resp); throw new Error(authIssueMessage(resp)); } if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const remainingHeader = resp.headers.get("x-ratelimit-remaining"); const remaining = remainingHeader === null ? NaN : Number(remainingHeader); if (Number.isFinite(remaining)) scanRemaining = scanRemaining === null ? remaining : Math.min(scanRemaining, remaining); if (Number.isFinite(remaining) && remaining < RATE_FLOOR) throw new Error(`速率余量 ${remaining} < ${RATE_FLOOR},本次放弃(须为逐条核对留出余量)`); const body = await resp.json(); if (!Array.isArray(body)) throw new Error("响应不是数组(Accept 头未生效?)"); return { page, etag: normEtag(resp.headers.get("etag") || ""), link: resp.headers.get("link"), body }; } function pickRealError(reasons) { return reasons.find((e) => !(e instanceof Error && e.name === "AbortError")) ?? reasons[0]; } async function runWaves(pagesRange, ctrl, worker) { let firstErr = null; for (let i = 0; i < pagesRange.length; i += WAVE_CONCURRENCY) { if (firstErr !== null || ctrl.signal.aborted) break; const waveStart = Date.now(); const reasons = (await Promise.allSettled(pagesRange.slice(i, i + WAVE_CONCURRENCY).map((p) => worker(p)))).filter((r) => r.status === "rejected").map((r) => r.reason); if (reasons.length > 0) { firstErr = pickRealError(reasons); ctrl.abort(); break; } if (ctrl.signal.aborted) break; const remain = WAVE_GAP_MS - (Date.now() - waveStart); if (remain > 0 && i + WAVE_CONCURRENCY < pagesRange.length) await sleep(remain); } if (firstErr !== null) throw firstErr; } function buildLocalSlices(cache) { const entries = Object.entries(cache); if (entries.length === 0) return null; for (const [, e] of entries) if (!e.starredAt) return null; const sorted = entries.map(([repoId, e]) => ({ repoId, e })).sort((a, b) => a.e.starredAt < b.e.starredAt ? 1 : -1); const slices = []; for (let i = 0; i < sorted.length; i += PAGE_SIZE) slices.push(sorted.slice(i, i + PAGE_SIZE).map(({ repoId, e }) => ({ repoId, path: e.name || "", starredAt: e.starredAt, meta: { name: e.name, desc: e.desc, lang: e.lang, stars: e.stars, forks: e.forks, updatedAt: e.updatedAt, private: e.private, fork: e.fork, isTemplate: e.isTemplate, mirror: e.mirror } }))); return slices; } async function pullAllUnconditional(tok) { const ctrl = new AbortController(); const first = await fetchStarredPage(tok, 1, ctrl.signal); if (first.notModified) throw new Error("无条件拉取收到 304(不应发生)"); const totalPages = parseTotalPages(first.link); if (totalPages === null) throw new Error("Link 头含 rel=\"next\" 却解析不到 rel=\"last\"(总页数不可知),整体放弃"); if (totalPages > MAX_PAGES) throw new Error(`超过 ${MAX_PAGES} 页上限(Link 预知 ${totalPages} 页),结果不完整,整体放弃`); const results = new Array(totalPages + 1); results[1] = first; if (totalPages > 1) { const rest = []; for (let p = 2; p <= totalPages; p++) rest.push(p); await runWaves(rest, ctrl, async (page) => { results[page] = await fetchStarredPage(tok, page, ctrl.signal); }); } const items = []; const etags = []; let rawSeen = 0; for (let p = 1; p <= totalPages; p++) { const r = results[p]; if (!r || r.notModified) throw new Error(`第 ${p} 页未取回(并发中断),整体放弃`); etags.push(r.etag); rawSeen += r.body.length; for (const raw of r.body) { const it = parseItem(raw); if (!it) throw new Error(`第 ${p} 页有解析失败条目(响应形态不符),整体放弃`); items.push(it); } } if (rawSeen > 0 && items.length === 0) throw new Error(`拉到 ${rawSeen} 条但解析为 0(响应形态不符),整体放弃`); return { items, pages: totalPages, etags }; } async function fullPullOutcome(tok) { const full = await pullAllUnconditional(tok); return { kind: "synced", items: full.items, pages: full.pages, etags: full.etags, hybrid: false, freshIds: new Set(full.items.map((it) => it.repoId)), bodyPages: full.pages, slicePages: 0 }; } async function scanStarred(tok, meta) { const baseline = meta.etags?.map(normEtag); if (!(!!meta.lastFullSyncAt && Date.now() - meta.lastFullSyncAt <= FULL_SYNC_TTL_MS && !!baseline && baseline.length > 0 && baseline.every((e) => !!e))) { console.log("[github-star-manager] 无逐页基线/超 48h TTL → 无条件整表拉取(重建基线)"); return fullPullOutcome(tok); } const baselinePages = baseline.length; const ctrl = new AbortController(); const bodyPagesMap = new Map(); const modPages = []; const scanRange = []; for (let p = 1; p <= baselinePages + (baselinePages < MAX_PAGES ? 1 : 0); p++) scanRange.push(p); await runWaves(scanRange, ctrl, async (page) => { const r = await fetchStarredPage(tok, page, ctrl.signal, page <= baselinePages ? baseline[page - 1] : meta.tailEtag); if (r.notModified) { if (page <= baselinePages) modPages.push(page); } else bodyPagesMap.set(page, r); }); let nextTailEtag = meta.tailEtag; const tailResp = bodyPagesMap.get(baselinePages + 1); if (tailResp) nextTailEtag = tailResp.body.length > 0 ? void 0 : tailResp.etag || void 0; if (![...bodyPagesMap.entries()].some(([p, bp]) => bp.body.length > 0 || p <= baselinePages)) return { kind: "unchanged", nextTailEtag }; const parsedBodies = new Map(); let rawSeen = 0; for (const [p, bp] of bodyPagesMap) { const arr = []; rawSeen += bp.body.length; for (const raw of bp.body) { const it = parseItem(raw); if (!it) throw new Error(`第 ${p} 页有解析失败条目(响应形态不符),整体放弃`); arr.push(it); } parsedBodies.set(p, arr); } let contentPages = 0; for (const p of modPages) contentPages = Math.max(contentPages, p); for (const [p, arr] of parsedBodies) if (arr.length > 0) contentPages = Math.max(contentPages, p); const needSlicePages = modPages.filter((p) => p <= contentPages); let slices = null; if (needSlicePages.length > 0) { const built = buildLocalSlices(loadRepoCache()); let usable = built !== null && needSlicePages.every((p) => p <= built.length); if (usable && built) for (const [p, arr] of parsedBodies) { if (p > contentPages || arr.length === 0) continue; const expectedIds = new Set(built[p - 1].map((it) => it.repoId)); if (expectedIds.size === 0) continue; let overlap = 0; for (const it of arr) if (expectedIds.has(it.repoId)) overlap += 1; if (overlap / Math.max(expectedIds.size, arr.length) < .5) { console.log(`[github-star-manager] 切片阀门:第 ${p} 页正文与本地切片重叠过低(${overlap}/${Math.max(expectedIds.size, arr.length)})→ 排序模型失真`); usable = false; break; } } if (!usable) { console.log("[github-star-manager] 本地切片不可用(缓存缺 star 时间/覆盖不足/模型失真)→ 回落无条件整表"); return fullPullOutcome(tok); } slices = built; } if ((parsedBodies.get(baselinePages + 1)?.length ?? 0) >= PAGE_SIZE) { console.log("[github-star-manager] 尾页满页(增长可能超一页)→ 回落无条件整表"); return fullPullOutcome(tok); } const items = []; const freshIds = new Set(); const seen = new Set(); const etags = []; let slicePages = 0; for (let p = 1; p <= contentPages; p++) { const body = parsedBodies.get(p); if (!body && !slices) throw new Error(`第 ${p} 页需切片但切片不可用(内部状态异常),整体放弃`); etags.push(body ? bodyPagesMap.get(p).etag : baseline[p - 1] ?? ""); const source = body ?? slices[p - 1] ?? []; if (!body) slicePages += 1; for (const it of source) { if (seen.has(it.repoId)) continue; seen.add(it.repoId); items.push(it); if (body) freshIds.add(it.repoId); } } return { kind: "synced", items, pages: contentPages, etags, hybrid: slicePages > 0, freshIds, bodyPages: contentPages - slicePages, slicePages, nextTailEtag }; } function pageUrl(page) { return `https://api.github.com/user/starred?per_page=${PAGE_SIZE}&page=${page}&sort=created&direction=desc`; } function reportAuthIssue(resp) { if (resp.status === 401) notifyTokenIssue("401 Bad credentials:Token 已失效或被撤销"); else if (resp.status === 403) { const retryAfter = resp.headers.get("retry-after"); const exhausted = resp.headers.get("x-ratelimit-remaining") === "0"; if (!retryAfter && !exhausted) notifyTokenIssue("403 权限不足:当前 Token 无权访问该资源"); } } function emitSyncReport(source, r) { if (r.added === 0 && r.restored === 0 && r.unstarred === 0) { pushNotice(`同步完成:无变化(共 ${r.total} 个 star)`, "info"); if (r.refreshed > 0) console.log(`[github-star-manager] 同步:元数据刷新 ${r.refreshed} 条(不进简报)`); return; } const parts = []; if (r.unstarred > 0) parts.push(`取消 star ${r.unstarred}`); if (r.added > 0) parts.push(`新增 ${r.added}`); if (r.restored > 0) parts.push(`恢复 ${r.restored}`); pushNotice(`同步完成:${parts.join("、")}(共 ${r.total} 个 star)`, r.unstarred > 0 ? "danger" : "success"); for (const it of r.unstarredItems) pushRestoreNotice(it.repoId, it.name, "report"); if (source === "auto") console.log("[github-star-manager] 自动同步产出变化,简报已弹出"); if (r.refreshed > 0) console.log(`[github-star-manager] 同步:元数据刷新 ${r.refreshed} 条(不进简报)`); } function rerenderAfterSync(changed) { if (changed <= 0) return; if (!document.querySelector(".stars-grid-container")) return; applyFilters({ keepPage: true }); } async function runFullSync(source) { if (syncing) { console.log("[github-star-manager] P4 同步:已有一次同步在进行中,本次触发被忽略(不排队)"); return null; } let tok = getGitHubPat(); if (!tok && source === "button") { notifyTokenIssue("同步需要 Token —— 请在下方输入框粘贴后点「保存并同步」"); tok = getGitHubPat(); } if (!tok) { console.log("[github-star-manager] P4 同步:未配置 token,已取消(TM 菜单 →「⭐ 设置 GitHub Token」)"); setSyncState({ phase: "failed", reason: "未配置 Token", lastSummary: lastSyncSummary }); return null; } let produced; let failureReason; syncing = true; setSyncState({ phase: "running" }); try { const storedMeta = gmGet(STORAGE_KEYS.fullSyncMeta, {}); scanRemaining = null; const scan = await scanStarred(tok, storedMeta); if (scan.kind === "unchanged") { console.log(`[github-star-manager] ETag 304:${(storedMeta.etags ?? []).length} 页全部无变化(免额度),跳过整表比对`); const healedEt = storedMeta.etags?.map(normEtag); gmSet(STORAGE_KEYS.fullSyncMeta, { ...storedMeta, ...healedEt ? { etags: healedEt } : {}, ...scan.nextTailEtag ? { tailEtag: scan.nextTailEtag } : {}, lastFullSyncAt: Date.now() }); const total = storedMeta.count ?? 0; pushNotice(`同步完成:无变化(共 ${total} 个 star)`, "info"); produced = { pages: 0, total, added: 0, restored: 0, unstarred: 0, backfilled: 0 }; return produced; } console.log(`[github-star-manager] ★ P4 扫描:${scan.pages} 页(正文 ${scan.bodyPages} + 本地切片 ${scan.slicePages},${scan.hybrid ? "切片混合模式:local-only 嫌疑逐条核对" : "全正文权威模式"})→ 三向比对…`); const remoteMap = new Map(scan.items.map((it) => [it.repoId, it])); const cacheBefore = loadRepoCache(); const suspects = Object.keys(cacheBefore).filter((id) => !remoteMap.has(id)); const checked = scan.hybrid ? await collectStarredChecks(tok, suspects.map((id) => repoTarget(id, cacheBefore[id].name)), scanRemaining, (response) => { if (response.status === 401 || response.status === 403) reportAuthIssue(response); }) : []; const unstarredItems = []; let unstarred = 0; for (const [index, repoId] of suspects.entries()) { let path = cacheBefore[repoId].name || ""; if (scan.hybrid) { const result = checked[index]; if (result.ok && !result.gone) { console.log(`[github-star-manager] 嫌疑核对:${cacheBefore[repoId].name || repoId} 仍 star(切片平局误报),保留`); continue; } if (!result.ok) { console.log(`[github-star-manager] 嫌疑核对不可判定(网络/异常状态),本轮不动:${cacheBefore[repoId].name || repoId}`); continue; } path = result.target.fullName; } if (applyExternalUnstar(repoId, path)) { unstarred += 1; unstarredItems.push({ repoId, name: path.replace(/^\//, "") || repoId }); } } const pending = loadPendingDelete(); let restored = 0; let added = 0; for (const it of remoteMap.values()) { if (cacheBefore[it.repoId]) continue; const patch = { ...it.meta }; if (it.starredAt) patch.starredAt = it.starredAt; if (pending[it.repoId]) { markRepoStarred(it.repoId); saveRepoData(it.repoId, patch); restored += 1; } else { saveRepoData(it.repoId, patch); added += 1; } } const cache = loadRepoCache(); let backfilled = 0; let refreshed = 0; for (const it of remoteMap.values()) { const entry = cache[it.repoId]; if (!entry) continue; if (it.starredAt && entry.starredAt !== it.starredAt) { entry.starredAt = it.starredAt; backfilled += 1; } if (!scan.freshIds.has(it.repoId)) continue; const m = it.meta; let metaChanged = false; if (m.name !== void 0 && entry.name !== m.name) { entry.name = m.name; metaChanged = true; } if (m.desc !== void 0 && entry.desc !== m.desc) { entry.desc = m.desc; metaChanged = true; } if (m.lang !== void 0 && entry.lang !== m.lang) { entry.lang = m.lang; metaChanged = true; } if (m.stars !== void 0 && entry.stars !== m.stars) { entry.stars = m.stars; metaChanged = true; } if (m.forks !== void 0 && entry.forks !== m.forks) { entry.forks = m.forks; metaChanged = true; } if (m.updatedAt !== void 0 && entry.updatedAt !== m.updatedAt) { entry.updatedAt = m.updatedAt; metaChanged = true; } if (m.private !== void 0 && entry.private !== m.private) { entry.private = m.private; metaChanged = true; } if (m.fork !== void 0 && entry.fork !== m.fork) { entry.fork = m.fork; metaChanged = true; } if (m.isTemplate !== void 0 && entry.isTemplate !== m.isTemplate) { entry.isTemplate = m.isTemplate; metaChanged = true; } if (m.mirror !== void 0 && entry.mirror !== m.mirror) { entry.mirror = m.mirror; metaChanged = true; } if (metaChanged) refreshed += 1; } for (const [index, repoId] of suspects.entries()) { const result = checked[index]; if (result?.ok && !result.gone && cache[repoId] && cache[repoId].name !== result.target.fullName) { cache[repoId].name = result.target.fullName; refreshed += 1; } } saveRepoCache(cache); const summary = { pages: scan.pages, total: scan.items.length, added, restored, unstarred, backfilled }; console.log(`[github-star-manager] ★ P4 全量同步完成:${summary.total} 个 star / ${scan.pages} 页(正文 ${scan.bodyPages} + 切片 ${scan.slicePages})— 新增 ${added}、恢复 ${restored}、外部 unstar ${unstarred}、回填 star 时间 ${backfilled}、元数据刷新 ${refreshed}`); document.querySelector(".gsm-setup-banner")?.remove(); emitSyncReport(source, { total: summary.total, added, restored, unstarred, refreshed, unstarredItems }); rerenderAfterSync(added + restored + unstarred + backfilled + refreshed); const outMeta = { etags: scan.etags, lastFullSyncAt: Date.now(), count: scan.items.length }; const ownerId = getStorageUserId(); if (ownerId) outMeta.accountId = ownerId; if (scan.nextTailEtag) outMeta.tailEtag = scan.nextTailEtag; gmSet(STORAGE_KEYS.fullSyncMeta, outMeta); const noEtag = scan.etags.filter((e) => !e).length; console.log(`[github-star-manager] ETag 基线:${scan.etags.length} 页已保存${noEtag ? `(${noEtag} 页响应缺 ETag 头,下次扫描直接整表)` : "(下次扫描逐页 304 免额度)"}`); produced = summary; return summary; } catch (err) { failureReason = err instanceof Error ? err.message : String(err); console.error("[github-star-manager] ★ P4 全量同步失败(未改动任何数据):", failureReason); return null; } finally { syncing = false; if (produced) lastSyncSummary = produced; setSyncState(failureReason ? { phase: "failed", reason: failureReason, lastSummary: lastSyncSummary } : { phase: "idle", lastSummary: lastSyncSummary }); mountAccountGuard(); } } function registerSyncMenu() { gmRegisterMenuCommand("🔄 立即全量同步(GitHub API)", () => { runFullSync("button"); }); } var SYNC_DEFAULT_TITLE = "同步 GitHub 全量 star 列表(逐页 ETag 快筛 + 整表比对 + 回填 star 时间)"; var mountedSync = null; var syncLiveRegion = null; var syncFailedVisualTimer; var SYNC_FAILED_VISUAL_MS = 2e4; function setSyncFailedVisual(btn, on) { if (syncFailedVisualTimer !== void 0) { window.clearTimeout(syncFailedVisualTimer); syncFailedVisualTimer = void 0; } btn.classList.toggle("gsm-sync-failed", on); if (!on) return; syncFailedVisualTimer = window.setTimeout(() => { syncFailedVisualTimer = void 0; btn.classList.remove("gsm-sync-failed"); }, SYNC_FAILED_VISUAL_MS); } function unmountSyncButton() { mountedSync?.unsubscribe(); mountedSync = null; syncLiveRegion = null; if (syncFailedVisualTimer !== void 0) { window.clearTimeout(syncFailedVisualTimer); syncFailedVisualTimer = void 0; } } function ensureSyncLiveRegion(host) { if (syncLiveRegion && syncLiveRegion.isConnected) return syncLiveRegion; const el = document.createElement("span"); el.className = "gsm-sync-status"; el.setAttribute("role", "status"); el.setAttribute("aria-live", "polite"); host.appendChild(el); syncLiveRegion = el; return el; } function mountSyncButton(row) { unmountSyncButton(); row.querySelector(".gsm-sync-btn")?.remove(); const pager = row.querySelector(".gsm-top-pager"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "Button Button--secondary Button--medium gsm-sync-btn"; btn.innerHTML = SYNC_SVG + " Sync"; btn.title = SYNC_DEFAULT_TITLE; btn.addEventListener("click", () => { runFullSync("button"); }); if (pager) row.insertBefore(btn, pager); else row.appendChild(btn); renderSyncButton(btn, row, getSyncState()); mountedSync = { unsubscribe: subscribeSyncState((state) => renderSyncButton(btn, row, state)) }; } function renderSyncButton(btn, host, state) { setSyncFailedVisual(btn, state.phase === "failed"); if (state.phase === "running") { btn.setAttribute("aria-busy", "true"); btn.setAttribute("aria-disabled", "true"); btn.title = SYNC_DEFAULT_TITLE; ensureSyncLiveRegion(host).textContent = "正在同步 GitHub star 列表…"; return; } btn.removeAttribute("aria-busy"); btn.removeAttribute("aria-disabled"); if (syncLiveRegion && syncLiveRegion.isConnected) syncLiveRegion.textContent = ""; if (state.phase === "failed") { btn.title = `上次同步失败:${state.reason ?? "未知原因"}(点此重试)`; return; } const s = state.lastSummary; btn.title = s ? `上次同步:${s.total} 个 star / ${s.pages} 页 — 新增 ${s.added}、恢复 ${s.restored}、外部 unstar ${s.unstarred}、回填 star 时间 ${s.backfilled}` : SYNC_DEFAULT_TITLE; } function scheduleProbeSync() { if (!isDesktop()) return; const gen = currentGeneration(); window.setTimeout(() => { ifCurrent(gen, () => { if (!isDesktop()) return; probeAndSync(); }); }, 2e3); } async function probeAndSync() { if (syncing) return; if (Date.now() - lastProbeAt < PROBE_COOLDOWN_MS) return; if (!getGitHubPat()) return; lastProbeAt = Date.now(); await runFullSync("auto"); } var searchScope = null; function interceptSearchForm() { const searchInput = document.querySelector("input[placeholder*=\"Search starred\"]"); if (!searchInput) return; searchScope?.dispose(); const scope = createScope("gsm-search"); searchScope = scope; const form = searchInput.closest("form"); if (form) scope.addListener(form, "submit", (e) => { if (!isDesktop()) return; e.preventDefault(); e.stopPropagation(); activateSearch(searchInput.value.trim()); }); scope.addListener(searchInput, "keydown", (e) => { if (!isDesktop()) return; if (e.key !== "Enter") return; e.preventDefault(); e.stopPropagation(); activateSearch(searchInput.value.trim()); }); const existingQ = new URLSearchParams(location.search).get("q"); if (existingQ) { searchInput.value = existingQ; scope.timeout(() => activateSearch(existingQ), 50); } } function disposeSearchInterception() { searchScope?.dispose(); searchScope = null; } function activateSearch(query) { if (!query) { clearSearch(); return; } filterState.searchQuery = query; applyFilters(); } function clearSearch() { filterState.searchQuery = ""; const searchInput = document.querySelector("input[placeholder*=\"Search starred\"]"); if (searchInput) searchInput.value = ""; applyFilters(); refreshTagPillStates(); } var TITLE_LINK = "h3 a[href]"; var META_BLOCK_SELECTOR = "div.f6"; var META_BLOCK_CLASS = "color-fg-muted"; function directChild(parent, predicate) { for (const child of Array.from(parent.children)) if (child instanceof HTMLElement && predicate(child)) return child; return null; } function textOf(el) { return (el?.textContent ?? "").replace(/\s+/g, " ").trim(); } function parseCount(text) { const digits = text.replace(/[^\d]/g, ""); if (!digits) return void 0; const n = Number(digits); return Number.isFinite(n) ? n : void 0; } function extractRepoFromItem(item) { const name = repoFullName((item.querySelector(TITLE_LINK)?.getAttribute("href") ?? "").replace(/^\/+/, "").replace(/\/+$/, "")); if (!name) return null; const repoId = (item.querySelector("user-list-menu[data-repository-id]")?.dataset.repositoryId ?? "").trim() || name; const data = { name }; const descBlock = directChild(item, (el) => el.tagName === "DIV" && el.classList.contains("py-1")); const desc = textOf(descBlock?.querySelector("p") ?? descBlock); if (desc) data.desc = desc; const meta = directChild(item, (el) => el.matches(META_BLOCK_SELECTOR) && el.classList.contains(META_BLOCK_CLASS)); if (meta) { const lang = textOf(meta.querySelector("[itemprop=\"programmingLanguage\"]")); if (lang) data.lang = lang; const stars = parseCount(textOf(meta.querySelector("a[href$=\"/stargazers\"]"))); if (stars !== void 0) data.stars = stars; const forks = parseCount(textOf(meta.querySelector("a[href$=\"/forks\"]"))); if (forks !== void 0) data.forks = forks; const rel = meta.querySelector("relative-time[datetime]"); const iso = rel?.getAttribute("datetime") ?? ""; if (rel && iso) { const label = (rel.previousSibling?.textContent ?? "").replace(/\s+/g, " ").trim().toLowerCase(); if (label.includes("starred")) data.starredAt = iso; else if (label.includes("updated")) data.updatedAt = iso; } } return { repoId, data, item }; } function collectPageRepos(scope) { const out = []; const seen = new Set(); const take = (item) => { const parsed = extractRepoFromItem(item); if (!parsed || seen.has(parsed.repoId)) return; seen.add(parsed.repoId); out.push(parsed); }; for (const item of getRepoItems(scope)) take(item); if (out.length > 0) return out; for (const li of scope.querySelectorAll("li")) { if (li.closest("li") !== li) continue; if (!li.querySelector("user-list-menu[data-repository-id], a[href$=\"/stargazers\"]")) continue; take(li); } return out; } var TOP_PAGER_CLASS = "gsm-top-pager"; function mountTopPager(scope, source) { if (!isDesktop()) return null; const h2 = scope.querySelector("h2.f3-light"); const row = h2?.parentElement; if (!source || !row || !h2) return null; row.classList.add("gsm-header-row"); row.querySelector(`.${TOP_PAGER_CLASS}`)?.remove(); const top = source.cloneNode(true); top.classList.add(TOP_PAGER_CLASS); top.removeAttribute("id"); top.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); const ref = Array.from(row.children).find((el) => el instanceof HTMLElement && el !== h2); if (ref) { const cs = getComputedStyle(ref); top.style.marginTop = cs.marginTop; top.style.marginBottom = cs.marginBottom; } row.appendChild(top); return row; } var GRID_CLASS = "stars-grid-container"; var RO_GRID_CLASS = "gsm-other-stars"; function findGridAnchor(firstItem) { const parent = firstItem.parentElement; if (!parent) return null; if (firstItem.tagName === "LI") { const ul = firstItem.closest("ul"); if (ul?.parentElement) return { parent: ul.parentElement, before: ul }; } return { parent, before: firstItem }; } function getScanScope() { return getStarsMainColumn() ?? document.getElementById("user-starred-repos") ?? document.querySelector("main") ?? document.body; } function enterOtherStarsView() { if (!isDesktop()) { exitOtherStarsView(); return false; } const ownerLogin = getPageOwnerLogin(); if (document.querySelector(`.${GRID_CLASS}`)) { ensureLayoutStyles(); return true; } const scope = getScanScope(); const repos = collectPageRepos(scope); if (repos.length === 0) return false; const first = repos[0].item; const anchor = findGridAnchor(first); if (!anchor) return false; setLangColorFetchEnabled(false); ensureLayoutStyles(); hideListsSection(); const table = {}; for (const { repoId, data } of repos) table[repoId] = data; setOtherPageRepos(table, ownerLogin); for (const { item } of repos) hideNativeNode(item); const grid = document.createElement("div"); grid.className = `${GRID_CLASS} ${RO_GRID_CLASS}`; anchor.parent.insertBefore(grid, anchor.before); moveTopicsToRightSidebar(); mountTopPager(scope, scope.querySelector(".paginate-container:not(.gsm-top-pager)")); renderBrowsePage(1); return true; } function exitOtherStarsView() { document.querySelectorAll(`.${GRID_CLASS}`).forEach((el) => el.remove()); document.querySelectorAll(".gsm-top-pager").forEach((el) => el.remove()); document.querySelectorAll(".gsm-header-row").forEach((el) => el.classList.remove("gsm-header-row")); restoreTopicsFromRightSidebar(); removeLayoutStyles(); resetViewContext(); exitReadOnlyMode(); setLangColorFetchEnabled(true); } function exitOtherStarsViewIfActive() { if (!getOtherPageRepos()) return false; exitOtherStarsView(); return true; } function teardownStarsView(reason) { console.log(`[github-star-manager] teardown(回滚到原生视图):${reason}`); restoreTopicsFromRightSidebar(); unmountSyncButton(); document.querySelectorAll(".stars-grid-container, .gsm-top-pager, .gsm-sync-btn, .gsm-sync-status").forEach((el) => el.remove()); document.querySelectorAll(".gsm-type-filter, .gsm-lang-filter, .gsm-sort-filter, .stars-tag-filter, .stars-tag-info-bar, .gsm-setup-banner, .gsm-account-banner").forEach((el) => el.remove()); document.querySelectorAll(".gsm-header-row").forEach((el) => el.classList.remove("gsm-header-row")); document.querySelectorAll(`[${GSM_HIDDEN_ATTR}]`).forEach((el) => { el.style.removeProperty("display"); el.removeAttribute(GSM_HIDDEN_ATTR); }); document.querySelectorAll(".stars-original-hidden").forEach((el) => el.classList.remove("stars-original-hidden")); const root = document.documentElement; if (!isDesktop()) { clearListsHiddenMarks(); root.classList.remove("gsm-hide-lists"); } root.classList.remove("gsm-anim-prepare", "gsm-turbo-entry"); revealTurboHide(); revealBootHide(reason); disposeNotificationStack(); disposeSearchInterception(); exitOtherStarsView(); } function interceptPagination() { window.addEventListener("click", (e) => { if (e.defaultPrevented) return; if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; if (!isDesktop() || !isStarsPage()) return; const t = e.target instanceof Element ? e.target : null; if (!t) return; const info = t.closest("button.gsm-page-info"); if (info) { e.preventDefault(); e.stopImmediatePropagation(); enterPageInput(info); return; } const link = t.closest("a[data-gsm-page]"); if (!link) return; e.preventDefault(); e.stopImmediatePropagation(); const target = link.dataset.gsmPage || ""; const cur = filterState.page || 1; if (target === "prev") navigateToLocalPage(cur - 1); else if (target === "next") navigateToLocalPage(cur + 1); else navigateToLocalPage(target); }, { capture: true, passive: false }); } function navigateToLocalPage(raw) { const page = clampPageNumber(raw, filterState.totalPages || 1); if (page === null || page === (filterState.page || 1)) return false; renderBrowsePage(page); return true; } function clampPageNumber(raw, total) { let n; if (typeof raw === "number") n = raw; else { const s = String(raw ?? "").trim(); if (!/^-?\d+$/.test(s)) return null; n = Number.parseInt(s, 10); } if (Number.isNaN(n)) return null; const page = Math.trunc(n); if (page < 1) return 1; if (page > total) return total; return page; } function enterPageInput(cell) { const parent = cell.parentElement; if (!parent || parent.querySelector(".gsm-page-input")) return; const total = filterState.totalPages || 1; const cur = filterState.page || 1; const input = document.createElement("input"); input.type = "text"; input.inputMode = "numeric"; input.className = "btn BtnGroup-item gsm-page-input"; input.value = String(cur); input.spellcheck = false; input.autocomplete = "off"; input.setAttribute("pattern", "[0-9]*"); input.setAttribute("enterkeyhint", "go"); input.setAttribute("aria-label", `输入页码后回车跳转(共 ${total} 页)`); const rect = cell.getBoundingClientRect(); const cs = getComputedStyle(cell); input.style.boxSizing = "border-box"; input.style.width = `${Math.round(rect.width)}px`; input.style.fontFamily = cs.fontFamily; input.style.fontSize = cs.fontSize; input.style.fontWeight = cs.fontWeight; input.style.lineHeight = cs.lineHeight; cell.replaceWith(input); input.focus(); input.select(); let settled = false; const finish = (commit) => { if (settled) return; settled = true; if (input.isConnected) { input.replaceWith(cell); cell.textContent = `${filterState.page || 1} / ${filterState.totalPages || 1}`; cell.focus({ preventScroll: true }); } if (commit) navigateToLocalPage(input.value); }; input.addEventListener("keydown", (ev) => { if (ev.isComposing) return; if (ev.key === "Enter") { ev.preventDefault(); finish(true); return; } if (ev.key === "Escape") { ev.preventDefault(); ev.stopPropagation(); finish(false); } }); input.addEventListener("blur", () => { queueMicrotask(() => { if (input.isConnected) finish(true); }); }); } var repositionHandler = null; function setHideListsRepositionHandler(fn) { repositionHandler = fn; } function menuLabel() { return `🙈 隐藏 Lists 区块(${isHideListsEnabled() ? "开" : "关"})`; } function registerHideListsMenu() { let menuId; const refresh = () => { if (menuId === void 0) return; const updated = gmRegisterMenuCommand(menuLabel(), onToggle, { id: menuId }); if (updated !== void 0) menuId = updated; }; const onToggle = () => { gmSet(STORAGE_KEYS.hideLists, !isHideListsEnabled()); applyHideListsGate(); hideListsSection(); console.log(`[github-star-manager] Hide Lists 切换为 ${isHideListsEnabled() ? "开(隐藏)" : "关(显示)"}`); repositionHandler?.(); refresh(); }; menuId = gmRegisterMenuCommand(menuLabel(), onToggle); } function transformStarsList() { if (!isDesktop()) return false; initLangColors(); hideListsSection(); if (!document.getElementById("user-starred-repos")) return false; const colLg9 = getStarsMainColumn(); if (!colLg9) return false; const repoItems = getRepoItems(colLg9); if (repoItems.length === 0 && !colLg9.querySelector(".stars-grid-container")) return false; if (colLg9.querySelector(".stars-grid-container")) return true; const gridContainer = document.createElement("div"); gridContainer.className = "stars-grid-container"; repoItems.forEach((item) => item.classList.add("stars-original-hidden")); const nativePager = colLg9.querySelector(".paginate-container"); if (nativePager) nativePager.classList.add("stars-original-hidden"); gridContainer.appendChild(buildLocalPager()); colLg9.appendChild(gridContainer); const headerRow = mountTopPager(colLg9, gridContainer.querySelector(".paginate-container")); if (headerRow) mountSyncButton(headerRow); moveTopicsToRightSidebar(); interceptSearchForm(); applyFilters(); return true; } function buildLocalPager() { const wrap = document.createElement("div"); wrap.className = "paginate-container gsm-local-pager"; const group = document.createElement("div"); group.className = "BtnGroup"; const prev = document.createElement("a"); prev.className = "btn BtnGroup-item"; prev.href = "#"; prev.dataset.gsmPage = "prev"; prev.textContent = "Previous"; const info = document.createElement("button"); info.type = "button"; info.className = "btn BtnGroup-item gsm-page-info"; info.title = "点击输入要跳到的页码"; info.textContent = "…"; const next = document.createElement("a"); next.className = "btn BtnGroup-item"; next.href = "#"; next.dataset.gsmPage = "next"; next.textContent = "Next"; group.append(prev, info, next); wrap.appendChild(group); return wrap; } function isEmptyText(s) { return !s.trim(); } function dedupe(arr) { return [...new Set(arr)]; } function sameTags(a, b) { return a.length === b.length && a.every((t, i) => t === b[i]); } function buildExportPackage() { const userId = getStorageUserId(); if (!userId) return null; const tags = loadAllTags(); const notes = loadAllNotes(); const keptNotes = {}; for (const repoId of Object.keys(notes)) if (!isEmptyText(notes[repoId])) keptNotes[repoId] = notes[repoId]; return { kind: EXPORT_KIND, schemaVersion: 1, exportedAt: new Date().toISOString(), user: { id: userId }, data: { tags, notes: keptNotes, repoNames: buildRepoNames([...Object.keys(tags), ...Object.keys(keptNotes)]) } }; } function buildRepoNames(repoIds) { const cache = loadRepoCache(); const names = {}; for (const repoId of repoIds) { const name = cache[repoId]?.name; if (repoFullName(name)) names[repoId] = name; } return names; } function isPlainObject(v) { return typeof v === "object" && v !== null && !Array.isArray(v); } function validateTagMap(v, path) { if (!isPlainObject(v)) return `${path} 不是对象`; for (const k of Object.keys(v)) { const arr = v[k]; if (!Array.isArray(arr) || arr.some((t) => typeof t !== "string")) return `${path}.${k} 不是字符串数组`; } return null; } function validateNoteMap(v, path) { if (!isPlainObject(v)) return `${path} 不是对象`; for (const k of Object.keys(v)) if (typeof v[k] !== "string") return `${path}.${k} 不是字符串`; return null; } function validateNameMap(v, path) { if (!isPlainObject(v)) return `${path} 不是对象`; for (const k of Object.keys(v)) if (typeof v[k] !== "string") return `${path}.${k} 不是字符串`; return null; } function validateRepoCache(v, path) { if (!isPlainObject(v)) return `${path} 不是对象`; for (const k of Object.keys(v)) { const entry = v[k]; if (!isPlainObject(entry)) return `${path}.${k} 不是对象`; if (typeof entry.name !== "string") return `${path}.${k}.name 缺失或不是字符串`; if (entry.lang !== void 0 && typeof entry.lang !== "string") return `${path}.${k}.lang 不是字符串`; } return null; } function validateExportPackage(raw) { if (!isPlainObject(raw)) return { ok: false, reason: "文件内容不是 JSON 对象" }; if (raw.kind !== "github-star-manager-export") return { ok: false, reason: "不是本脚本导出的文件(kind 不匹配)" }; if (raw.schemaVersion !== 1) return { ok: false, reason: `导出包版本不支持:文件为 ${String(raw.schemaVersion)},本脚本支持 1` }; if (typeof raw.exportedAt !== "string") return { ok: false, reason: "缺少导出时间" }; const user = raw.user; if (!isPlainObject(user) || typeof user.id !== "string" || !user.id) return { ok: false, reason: "导出包未标注所属用户" }; const currentId = getStorageUserId(); if (!currentId) return { ok: false, reason: "当前页面取不到 GitHub 用户 ID(未登录?),无法确认归属,已拒绝导入" }; if (user.id !== currentId) return { ok: false, reason: `导出包属于用户 ${user.id},当前登录用户为 ${currentId},不允许跨账号导入` }; const data = raw.data; if (!isPlainObject(data)) return { ok: false, reason: "缺少 data 段" }; let err = validateTagMap(data.tags, "tags") ?? validateNoteMap(data.notes, "notes"); if (!err && data.repoNames !== void 0) err = validateNameMap(data.repoNames, "repoNames"); if (!err && data.repoCache !== void 0) err = validateRepoCache(data.repoCache, "repoCache"); if (err) return { ok: false, reason: `数据格式不合法:${err}` }; return { ok: true, pkg: raw }; } function applyImportPackage(pkg) { const localTags = loadAllTags(); const localNotes = loadAllNotes(); const localCache = loadRepoCache(); const report = { tagRepos: 0, tagsWritten: 0, tagsRemoved: 0, notesApplied: 0, notesOverwritten: 0, notesSkippedEmpty: 0, pendingAdded: 0, cacheAvailable: hasApiData() }; const repoIds = new Set([...Object.keys(pkg.data.tags), ...Object.keys(pkg.data.notes)]); for (const repoId of repoIds) { const incomingTags = dedupe((pkg.data.tags[repoId] || []).filter((t) => t.trim())); const incomingNote = pkg.data.notes[repoId]; if (!localCache[repoId]) { const name = repoFullName(pkg.data.repoNames?.[repoId] ?? ""); if (addPendingFromImport(repoId, incomingTags, incomingNote || "", name)) report.pendingAdded += 1; continue; } const incoming = incomingTags; const local = localTags[repoId] || []; if (incoming.length > 0 && !sameTags(local, incoming)) { saveTags(repoId, incoming); report.tagsWritten += 1; report.tagsRemoved += local.filter((t) => !incoming.includes(t)).length; } if (incomingNote === void 0) continue; if (isEmptyText(incomingNote)) { report.notesSkippedEmpty += 1; continue; } const localNote = localNotes[repoId] || ""; if (incomingNote === localNote) continue; if (localNote) report.notesOverwritten += 1; saveNote(repoId, incomingNote); report.notesApplied += 1; } report.tagRepos = Object.keys(loadAllTags()).length; return report; } var afterImport = null; function setAfterImportHandler(fn) { afterImport = fn; } function doExport() { const pkg = buildExportPackage(); if (!pkg) { window.alert("导出失败:取不到当前 GitHub 用户 ID(未登录?)。"); return; } const filename = buildExportFilename(pkg.user.id); if (!gmDownloadFile(new Blob([JSON.stringify(pkg, null, 2)], { type: "application/json" }), filename)) { window.alert("下载未能启动:本脚本依赖 Tampermonkey 的 GM_download。请在 TM 设置(需 Advanced 模式)的「下载」区把 json 加入允许的扩展名,并确认下载功能已开启。"); return; } console.log(`[github-star-manager] 已导出:${filename}(标签仓库 ${Object.keys(pkg.data.tags).length}、备注 ${Object.keys(pkg.data.notes).length})`); } function reportText(r) { const parts = [`标签:覆盖写入 ${r.tagsWritten} 个仓库(当前共 ${r.tagRepos} 个仓库带标签)`, `备注:写入 ${r.notesApplied} 条`]; if (r.tagsRemoved > 0) parts.push(`其中被替换掉的本地标签 ${r.tagsRemoved} 条`); if (r.notesOverwritten > 0) parts.push(`其中覆盖本地原有备注 ${r.notesOverwritten} 条`); if (r.notesSkippedEmpty > 0) parts.push(`跳过空备注 ${r.notesSkippedEmpty} 条`); if (r.pendingAdded > 0) parts.push(`本地不存在 ${r.pendingAdded} 个仓库,其标签/备注已放入 24h 宽限期`); return parts.join(";"); } function confirmText(pkg, currentId, needSync) { const tagCount = Object.keys(pkg.data.tags).length; const noteCount = Object.keys(pkg.data.notes).length; return `将从导出包导入到当前账号(${currentId}):\n\n导出时间:${new Date(pkg.exportedAt).toLocaleString()}\n带标签的仓库:${tagCount}\n备注:${noteCount}\n\n导入方式(逐个仓库按本机的 star 记录判断):\n· 本机有该 star 记录 ⇒ 写入该仓库:文件里的标签/备注**覆盖**本地的;\n 但文件里没有(或为空)的标签/备注**不会**清空你本地的。\n· 本机确认没有该 star(或本机尚未同步过)⇒ 放入 24 小时宽限期,\n 期间 star 回来、或任意一次成功同步到「远端已 star」即自动恢复;\n 超过 24 小时仍未 star 的,其标签/备注会被删除。\n\n` + (needSync ? "本机尚未完成过一次完整同步,导入前会先自动同步一次以确认本地 star 记录。\n若同步失败(例如未配置 Token),上面的仓库会全部进入 24 小时宽限期 ——\n请在 24 小时内完成一次同步,否则它们的标签/备注会被删除。\n\n" : "") + "注意:导入会**覆盖**这些仓库本地的标签与备注(文件里没给的部分按上面的规则保留),\n且不会自动备份,请确认已保存好当前数据。\n\n确定导入吗?"; } function importFromFile(file) { const reader = new FileReader(); reader.onerror = () => { window.alert("读取文件失败,请重试。"); }; reader.onload = async () => { let raw; try { raw = JSON.parse(String(reader.result)); } catch { window.alert("文件不是合法的 JSON,已取消导入。"); return; } const result = validateExportPackage(raw); if (!result.ok) { window.alert(`导入已取消:${result.reason}`); return; } const pkg = result.pkg; const needSync = !hasApiData(); if (!window.confirm(confirmText(pkg, pkg.user.id, needSync))) { console.log("[github-star-manager] 导入已取消(用户取消确认)"); return; } if (needSync) { pushNotice("导入前先同步一次,以确认本地 star 记录…", "info"); try { await runFullSync("button"); } catch (e) { console.warn("[github-star-manager] 导入前的同步未能完成,未能确认的仓库将进入宽限期:", e); } } let report; try { report = applyImportPackage(pkg); } catch (e) { console.error("[github-star-manager] 导入写入失败:", e); window.alert(`导入写入失败:${e instanceof Error ? e.message : String(e)}`); return; } console.log(`[github-star-manager] 导入完成:${reportText(report)}`); const pendingHint = report.pendingAdded === 0 ? "" : `\n\n${report.pendingAdded} 个仓库本地不存在,其标签/备注已放入 24 小时宽限期:\n· 重新 star、或任意一次成功同步到「远端已 star」,即自动恢复;\n· 24 小时内没有任何一次成功同步${report.cacheAvailable ? "" : "(本机尚未同步过)"},这些标签/备注会被删除。`; window.alert(`导入完成。\n\n${reportText(report)}${pendingHint}\n\n如需刷新仓库元数据,请点标题行 Sync 或 TM 菜单「🔄 立即全量同步」。`); afterImport?.(report); }; reader.readAsText(file); } var importDialogKeyHandler = null; function pickFile() { closeImportDialog(); const input = document.createElement("input"); input.type = "file"; input.accept = "application/json,.json"; input.style.display = "none"; input.addEventListener("change", () => { const file = input.files && input.files[0]; closeImportDialog(); if (file) importFromFile(file); }); const overlay = document.createElement("div"); overlay.className = "gsm-import-overlay"; Object.assign(overlay.style, { position: "fixed", inset: "0", zIndex: "9999", background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center" }); const box = document.createElement("div"); box.className = "gsm-import-box"; box.setAttribute("role", "dialog"); box.setAttribute("aria-label", "导入 GithubStarManager 数据"); Object.assign(box.style, { position: "relative", display: "flex", flexDirection: "column", alignItems: "center", gap: "14px", width: "min(480px, 86vw)", padding: "40px 28px", background: "var(--color-canvas-default, #fff)", border: "2px dashed var(--color-border-default, #d0d7de)", borderRadius: "12px", boxShadow: "0 8px 40px rgba(0,0,0,0.35)", fontFamily: "inherit", textAlign: "center", transition: "border-color 0.15s, background-color 0.15s" }); const title = document.createElement("div"); title.textContent = "📥 导入数据"; Object.assign(title.style, { fontSize: "20px", fontWeight: "600", color: "var(--color-fg-default, #1f2328)" }); const hint = document.createElement("div"); hint.className = "gsm-import-hint"; hint.textContent = "把导出的 JSON 文件拖到这里,或点击下方按钮选择文件"; Object.assign(hint.style, { fontSize: "14px", color: "var(--color-fg-muted, #656d76)" }); const pick = document.createElement("button"); pick.type = "button"; pick.textContent = "选择文件"; Object.assign(pick.style, { cursor: "pointer", fontSize: "15px", padding: "8px 22px", borderRadius: "6px", border: "1px solid var(--color-border-default, #d0d7de)", background: "var(--color-btn-bg, #f6f8fa)", color: "var(--color-btn-text, #1f2328)" }); pick.addEventListener("click", () => input.click()); const close = document.createElement("button"); close.type = "button"; close.textContent = "×"; close.setAttribute("aria-label", "关闭"); Object.assign(close.style, { position: "absolute", top: "10px", right: "12px", border: "none", background: "transparent", cursor: "pointer", fontSize: "20px", lineHeight: "1", color: "var(--color-fg-muted, #656d76)" }); close.addEventListener("click", () => closeImportDialog()); box.append(title, hint, pick, close, input); overlay.appendChild(box); let dragDepth = 0; const highlight = (on) => { box.style.borderColor = on ? "var(--color-accent-emphasis, #0969da)" : "var(--color-border-default, #d0d7de)"; box.style.backgroundColor = on ? "var(--color-accent-subtle, #ddf4ff)" : "var(--color-canvas-default, #fff)"; hint.textContent = on ? "松手开始导入" : "把导出的 JSON 文件拖到这里,或点击下方按钮选择文件"; }; overlay.addEventListener("dragenter", (e) => { e.preventDefault(); dragDepth += 1; highlight(true); }); overlay.addEventListener("dragover", (e) => { e.preventDefault(); }); overlay.addEventListener("dragleave", () => { dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) highlight(false); }); overlay.addEventListener("drop", (e) => { e.preventDefault(); const file = e.dataTransfer?.files?.[0]; closeImportDialog(); if (file) importFromFile(file); }); overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) closeImportDialog(); }); document.body.appendChild(overlay); importDialogKeyHandler = (e) => { if (e.key === "Escape") closeImportDialog(); }; window.addEventListener("keydown", importDialogKeyHandler); } function closeImportDialog() { if (importDialogKeyHandler) { window.removeEventListener("keydown", importDialogKeyHandler); importDialogKeyHandler = null; } document.querySelector(".gsm-import-overlay")?.remove(); } function registerExportImportMenu() { gmRegisterMenuCommand("📤 导出数据(标签/备注)", () => { doExport(); }); gmRegisterMenuCommand("📥 导入数据(标签/备注)", () => { pickFile(); }); } var keyHandler = null; var boxStyle = { position: "relative", display: "flex", flexDirection: "column", gap: "12px", width: "min(560px, 92vw)", maxHeight: "min(640px, 86vh)", padding: "20px 22px", background: "var(--color-canvas-default, #fff)", border: "1px solid var(--color-border-default, #d0d7de)", borderRadius: "12px", boxShadow: "0 8px 40px rgba(0,0,0,0.35)", fontFamily: "inherit", color: "var(--color-fg-default, #1f2328)" }; function closeDialog() { if (keyHandler) { window.removeEventListener("keydown", keyHandler); keyHandler = null; } document.querySelector(".gsm-restore-overlay")?.remove(); } function el(tag, style, text) { const node = document.createElement(tag); Object.assign(node.style, style); if (text !== void 0) node.textContent = text; return node; } function openRestoreDialog() { closeDialog(); const overlay = el("div", { position: "fixed", inset: "0", zIndex: "9999", background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center" }); overlay.className = "gsm-restore-overlay"; const box = el("div", boxStyle); box.setAttribute("role", "dialog"); box.setAttribute("aria-label", "恢复已取消的 star"); const title = el("div", { fontSize: "18px", fontWeight: "600" }, "♻️ 恢复已取消的 star"); const hint = el("div", { fontSize: "13px", color: "var(--color-fg-muted, #656d76)" }, "取消 star 时标签与备注会被保留 24 小时,期间可以在这里恢复。超时的条目已从列表消失。"); const list = el("div", { display: "flex", flexDirection: "column", gap: "6px", overflowY: "auto", border: "1px solid var(--color-border-default, #d0d7de)", borderRadius: "8px", padding: "8px", minHeight: "80px" }); const status = el("div", { fontSize: "13px", color: "var(--color-fg-muted, #656d76)", minHeight: "18px" }); const actions = el("div", { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" }); const btnPrimary = { cursor: "pointer", fontSize: "14px", padding: "6px 14px", borderRadius: "6px", border: "1px solid var(--color-border-default, #d0d7de)", background: "var(--color-btn-primary-bg, #1f883d)", color: "#fff" }; const btnPlain = { cursor: "pointer", fontSize: "14px", padding: "6px 14px", borderRadius: "6px", border: "1px solid var(--color-border-default, #d0d7de)", background: "var(--color-btn-bg, #f6f8fa)", color: "var(--color-btn-text, #1f2328)" }; const btnAll = el("button", btnPlain, "全选"); const btnNone = el("button", btnPlain, "全不选"); const btnRun = el("button", btnPrimary, "恢复选中"); const btnStop = el("button", btnPlain, "取消执行"); const btnClose = el("button", btnPlain, "关闭"); btnAll.type = "button"; btnNone.type = "button"; btnRun.type = "button"; btnStop.type = "button"; btnClose.type = "button"; btnStop.hidden = true; const close = el("button", { position: "absolute", top: "10px", right: "12px", border: "none", background: "transparent", cursor: "pointer", fontSize: "20px", lineHeight: "1", color: "var(--color-fg-muted, #656d76)" }, "×"); close.type = "button"; close.setAttribute("aria-label", "关闭"); close.addEventListener("click", () => { if (!running) closeDialog(); }); const rows = new Map(); const checked = new Set(); let running = false; const cancelToken = { cancelled: false }; function refreshButtons() { const n = checked.size; btnRun.textContent = n > 0 ? `恢复选中(${n})` : "恢复选中"; btnRun.disabled = running || n === 0; btnRun.style.opacity = btnRun.disabled ? "0.6" : ""; btnAll.disabled = running; btnNone.disabled = running; btnClose.disabled = running; } function removeRow(repoId) { rows.get(repoId)?.box.remove(); rows.delete(repoId); checked.delete(repoId); if (rows.size === 0 && !list.querySelector(".gsm-restore-empty")) { const empty = el("div", { fontSize: "13px", color: "var(--color-fg-muted, #656d76)", padding: "8px" }, "当前没有可恢复的条目。"); empty.className = "gsm-restore-empty"; list.appendChild(empty); } refreshButtons(); } function bindRow(entry) { const row = el("div", { display: "flex", alignItems: "center", gap: "10px", padding: "6px 8px", borderRadius: "6px", border: "1px solid transparent" }); const cb = document.createElement("input"); cb.type = "checkbox"; cb.addEventListener("change", () => { if (cb.checked) checked.add(entry.repoId); else checked.delete(entry.repoId); refreshButtons(); }); const name = el("span", { flex: "1 1 auto", fontSize: "14px", wordBreak: "break-all" }, entry.name); const marks = []; if (entry.tags.length > 0) marks.push(`${entry.tags.length} 个标签`); if (entry.hasNote) marks.push("备注"); const extra = el("span", { flex: "0 0 auto", fontSize: "12px", color: "var(--color-fg-muted, #656d76)" }, marks.join(" · ")); const remain = el("span", { flex: "0 0 auto", fontSize: "12px", color: "var(--color-fg-muted, #656d76)", minWidth: "56px", textAlign: "right" }, `剩 ${formatRemaining(entry.remainingMs)}`); const one = el("button", { ...btnPlain, fontSize: "13px", padding: "3px 10px", flex: "0 0 auto" }, "☆ 恢复"); one.type = "button"; one.addEventListener("click", () => { if (running) return; one.disabled = true; status.textContent = `正在恢复 ${entry.name}…`; restoreOne({ repoId: entry.repoId, name: entry.name }).then((r) => { if (r.ok) { status.textContent = `✓ ${entry.name} 已恢复`; removeRow(entry.repoId); } else { status.textContent = `✗ ${entry.name}:${r.message || "恢复失败"}`; one.disabled = false; window.alert(`恢复失败:${entry.name} —— ${r.message || "未知原因"}`); } }); }); row.append(cb, name, extra, remain, one); rows.set(entry.repoId, { entry, box: row }); list.appendChild(row); } btnAll.addEventListener("click", () => { list.querySelectorAll("input[type=\"checkbox\"]").forEach((cb) => { cb.checked = true; }); for (const repoId of rows.keys()) checked.add(repoId); refreshButtons(); }); btnNone.addEventListener("click", () => { list.querySelectorAll("input[type=\"checkbox\"]").forEach((cb) => { cb.checked = false; }); checked.clear(); refreshButtons(); }); btnClose.addEventListener("click", () => closeDialog()); btnStop.addEventListener("click", () => { cancelToken.cancelled = true; status.textContent = "正在停止…(已发出的请求不会回滚)"; }); btnRun.addEventListener("click", () => { if (running) return; const targets = [...checked].map((id) => rows.get(id)?.entry).filter((e) => !!e); if (targets.length === 0) return; const seconds = estimateBatchSeconds(targets.length); if (!window.confirm(`将恢复 ${targets.length} 个仓库的 star。\n\n· 逐个执行,每次间隔至少 1 秒,预计约 ${seconds} 秒完成;\n· 执行中可以点「取消执行」停住后续(已经恢复的不会回滚);\n· 失败不会自动重试。\n\n确定开始吗?`)) return; running = true; cancelToken.cancelled = false; btnStop.hidden = false; refreshButtons(); status.textContent = `0/${targets.length} 开始…`; restoreMany(targets, { token: cancelToken, onProgress: (done, total, result) => { status.textContent = `${done}/${total} ${result.ok ? "✓" : "✗"} ${result.name}${result.ok ? "" : `:${result.message || "失败"}`}`; if (result.ok) removeRow(result.repoId); } }).then((summary) => { running = false; btnStop.hidden = true; refreshButtons(); const failedText = summary.failed.length > 0 ? `,失败 ${summary.failed.length} 个` : ""; const cancelText = summary.cancelled ? "(已停止,部分未执行)" : ""; status.textContent = `完成:成功 ${summary.succeeded}/${summary.total}${failedText}${cancelText}`; if (summary.failed.length > 0) { const lines = summary.failed.map((f) => `· ${f.name}:${f.message || "失败"}`).join("\n"); window.alert(`恢复完成:成功 ${summary.succeeded}/${summary.total}${cancelText}\n\n失败 ${summary.failed.length} 个:\n${lines}`); } else pushNotice(`恢复完成:成功 ${summary.succeeded}/${summary.total}${cancelText}`, "success"); }); }); actions.append(btnAll, btnNone, btnRun, btnStop, btnClose); box.append(title, hint, list, status, actions, close); overlay.appendChild(box); overlay.addEventListener("mousedown", (e) => { if (e.target === overlay && !running) closeDialog(); }); document.body.appendChild(overlay); keyHandler = (e) => { if (e.key === "Escape" && !running) closeDialog(); }; window.addEventListener("keydown", keyHandler); const entries = listRestorable(); if (entries.length === 0) { const empty = el("div", { fontSize: "13px", color: "var(--color-fg-muted, #656d76)", padding: "8px" }, "当前没有可恢复的条目。"); empty.className = "gsm-restore-empty"; list.appendChild(empty); } else for (const e of entries) bindRow(e); refreshButtons(); } function registerRestoreMenu() { gmRegisterMenuCommand("♻️ 恢复已取消的 star(24h 内)", () => { openRestoreDialog(); }); } var starsSetupDone = false; function ensureStarsSetup() { applyHideListsGate(); ensureLayoutStyles(); if (starsSetupDone) return; starsSetupDone = true; cleanupExpiredUnstarred(); } function deactivateStars() { removeLayoutStyles(); document.documentElement.classList.remove("gsm-anim-prepare"); document.documentElement.classList.remove("gsm-turbo-entry"); } function exitStarsView(reason) { beginGeneration(); teardownStarsView(reason); deactivateStars(); } function isReadOnlyScope() { return isStarsListingPage() && getStarsPageScope() !== "own"; } var navScope = createScope("gsm-nav"); var starsNavPending = false; var navFailsafeTimer; function armNavFailsafe() { if (navFailsafeTimer !== void 0) window.clearTimeout(navFailsafeTimer); navFailsafeTimer = window.setTimeout(() => { navFailsafeTimer = void 0; starsNavPending = false; if (!(document.documentElement.classList.contains("gsm-boot-hidden") || !!document.querySelector(".gsm-turbo-hidden"))) return; exitStarsView("导航 4s 兜底"); }, 4e3); } function revealAfterTransform(animate) { const root = document.documentElement; const wasHidden = root.classList.contains("gsm-boot-hidden") || !!document.querySelector(".gsm-turbo-hidden"); if (navFailsafeTimer !== void 0) { window.clearTimeout(navFailsafeTimer); navFailsafeTimer = void 0; } revealTurboHide(); if (!animate || !wasHidden) { root.classList.remove("gsm-anim-prepare"); revealBootHide("转换成功(直载)"); return; } root.classList.add("gsm-turbo-entry"); root.classList.add("gsm-anim-prepare"); revealBootHide("转换成功(turbo 入场)"); document.body && document.body.offsetHeight; root.classList.remove("gsm-anim-prepare"); } function transformAndReveal(animate, retries = 12) { beginGeneration(); const root = document.documentElement; if (!isDesktop()) { starsNavPending = false; revealAfterTransform(false); return; } if (isReadOnlyScope()) { starsNavPending = false; revealAfterTransform(false); const mounted = enterOtherStarsView(); console.log(mounted ? "[github-star-manager] 他人的 stars 页:只读网格(零网络,数据仅来自页面已渲染条目)" : "[github-star-manager] 他人的 stars 页:未接管(页面上没有条目 / 结构未识别)"); return; } exitOtherStarsViewIfActive(); if (animate) root.classList.add("gsm-anim-prepare"); ensureStarsSetup(); if (!hasApiData()) { starsNavPending = false; revealAfterTransform(false); hideListsSection(); showSetupBanner(); return; } let done = false; try { initFiltersFromUrl(); done = transformStarsList(); } catch (err) { console.error("[github-star-manager] transformStarsList 执行失败", err); } if (done) { starsNavPending = false; revealAfterTransform(animate); scheduleProbeSync(); mountAccountGuard(); return; } if (retries > 0) { if (retries === 12) console.log("[github-star-manager] 转换目标未就绪,150ms 后重试"); navScope.guardedTimeout(() => transformAndReveal(animate, retries - 1), 150); return; } console.error("[github-star-manager] 转换重试耗尽,已恢复原生页面(选择器可能再次失配)"); revealTurboHide(); deactivateStars(); revealBootHide("转换失败/重试耗尽"); } function showSetupBanner(issueDetail) { if (!isDesktop()) { console.log("[github-star-manager] 窄视口:不显示配置面板(如需配置 Token,请用 TM 菜单「⭐ 设置 GitHub Token」)"); return; } const exist = document.querySelector(".gsm-setup-banner"); if (exist) { if (issueDetail) { const m = exist.querySelector(".gsm-setup-msg"); if (m) m.textContent = bannerMessage(issueDetail); } return; } const host = getStarsMainColumn() || document.getElementById("user-starred-repos"); if (!host) return; const bar = document.createElement("div"); bar.className = "gsm-setup-banner"; bar.innerHTML = ""; bar.querySelector(".gsm-setup-msg").textContent = bannerMessage(issueDetail); const focusInput = () => { tokRow.hidden = false; tokInput.focus(); }; const quickClassic = document.createElement("button"); quickClassic.className = "btn btn-primary"; quickClassic.type = "button"; quickClassic.textContent = "快速获取 Token(classic,推荐)"; quickClassic.title = TOKEN_KIND_HELP; quickClassic.addEventListener("click", () => { openClassicTokenCreator(); focusInput(); }); const quickFine = document.createElement("button"); quickFine.className = "btn"; quickFine.type = "button"; quickFine.textContent = "快速获取 Token(fine-grained,仅读)"; quickFine.title = TOKEN_KIND_HELP; quickFine.addEventListener("click", () => { openTokenCreator(); focusInput(); }); const kindHelp = document.createElement("span"); kindHelp.className = "gsm-token-help"; kindHelp.textContent = TOKEN_KIND_HELP; const tokRow = document.createElement("span"); tokRow.className = "gsm-token-row"; tokRow.hidden = false; const tokInput = document.createElement("input"); tokInput.type = "text"; tokInput.spellcheck = false; tokInput.placeholder = "ghp_ / gho_(classic)或 github_pat_(也可直接 Ctrl+V 粘贴)"; const tokMsg = document.createElement("span"); tokMsg.className = "gsm-token-msg"; const tokPaste = document.createElement("button"); tokPaste.className = "btn"; tokPaste.type = "button"; tokPaste.textContent = "从剪贴板粘贴"; tokPaste.addEventListener("click", () => void pasteFromClipboard(tokInput)); const tokSave = document.createElement("button"); tokSave.className = "btn btn-primary"; tokSave.type = "button"; tokSave.textContent = "保存并同步"; tokSave.addEventListener("click", () => { if (!tokInput.value.trim()) { tokMsg.textContent = "请输入 Token(如需清除 Token,请用油猴菜单「⭐ 设置 GitHub Token」留空提交)"; return; } tokSave.disabled = true; tokMsg.textContent = "正在确认 Token 所属账号…"; setTokenVerified(tokInput.value).then((outcome) => { tokSave.disabled = false; if (outcome.result === "invalid") { tokMsg.textContent = "前缀不对:预期 ghp_ / gho_(classic)或 github_pat_(fine-grained)"; return; } if (outcome.result === "dead") { tokMsg.textContent = "这个 Token 已失效或被撤销(401),未保存"; return; } console.log(`[github-star-manager] Token 已保存(${outcome.kind ?? "未知类型"}${outcome.result === "saved-unverified" ? ",暂时未能确认所属账号" : ""}),自动触发全量同步`); bar.remove(); notifyTokenSaved(); }); }); tokRow.append(tokInput, tokMsg, tokPaste, tokSave); bar.append(quickClassic, quickFine, kindHelp, tokRow); placeSetupBanner(bar, host); } function placeSetupBanner(bar, host) { if (isHideListsEnabled()) { const slot = document.querySelector("#user-profile-frame > div"); const slotTarget = slot && slot.querySelector(":scope > div.blankslate") || slot && slot.querySelector(":scope > #profile-lists-container"); if (slotTarget && slotTarget !== bar) slotTarget.before(bar); else host.prepend(bar); } else host.prepend(bar); } function repositionSetupBanner() { const bar = document.querySelector(".gsm-setup-banner"); if (!bar) return; const host = getStarsMainColumn() || document.getElementById("user-starred-repos"); if (!host) return; placeSetupBanner(bar, host); } function bannerMessage(issueDetail) { if (issueDetail) return `🔑 ${issueDetail} —— 请用下方按钮重新配置 Token,保存后会自动全量同步恢复。`; return "⭐ GithubStarManager 需要一次性全量同步(GitHub API):可用下方按钮打开预填好权限的 Token 创建页,或手动填写;保存后会自动开始全量同步。"; } function registerNavListeners() { document.addEventListener("turbo:before-frame-render", (event) => { const frame = event.target; if (frame.id !== "user-profile-frame" && frame.id !== "user-starred-repos") return; if (!isDesktop()) return; const detail = event.detail; const newFrame = detail && detail.newFrame; if (!(isStarsPage() || starsNavPending || frame.id === "user-starred-repos" || !!(newFrame && newFrame.querySelector("#user-starred-repos")))) { exitStarsView("切至非 Stars 标签(渲染前)"); return; } frame.classList.add("gsm-turbo-hidden"); armNavFailsafe(); }); let lastFrameRenderAt = 0; document.addEventListener("turbo:frame-render", (event) => { const frameId = event.target.id; if (frameId === "user-profile-frame" || frameId === "user-starred-repos") lastFrameRenderAt = performance.now(); if (frameId === "user-starred-repos") { const arrive = starsNavPending; navScope.guardedTimeout(() => transformAndReveal(arrive), 100); } else if (frameId === "user-profile-frame") navScope.guardedTimeout(() => { if (!isReadOnlyScope()) hideListsSection(); const pf = document.getElementById("user-profile-frame"); if (!pf) return; if (!pf.querySelector("#user-starred-repos")) exitStarsView("切至非 Stars 标签(渲染后)"); else transformAndReveal(starsNavPending); }, 100); }); document.addEventListener("turbo:before-visit", (event) => { if (!isDesktop()) return; if (!lastFrameRenderAt || performance.now() - lastFrameRenderAt > 1e3) return; const url = event.detail?.url; if (!url) return; try { if (new URL(url, location.href).href !== location.href) return; } catch { return; } event.preventDefault(); }); document.addEventListener("turbo:before-render", () => { if (!isDesktop()) return; if (!starsNavPending && !isStarsPage()) return; installBootHide(); }); document.addEventListener("turbo:load", () => { if (!isDesktop()) return; if (!isStarsListingPage()) { starsNavPending = false; exitStarsView("非 Stars 页 turbo:load"); return; } if (!isStarsPage() && !isReadOnlyScope()) return; const arrive = starsNavPending; navScope.guardedTimeout(() => transformAndReveal(arrive), 200); }); document.addEventListener("click", (e) => { const t = e.target instanceof Element ? e.target : null; if (t && t.closest("a[href*=\"tab=stars\"]")) { starsNavPending = true; armNavFailsafe(); } }); document.addEventListener("visibilitychange", () => { if (!isDesktop()) return; if (document.visibilityState !== "visible") return; if (!isStarsListingPage()) return; if (isReadOnlyScope()) return; mountAccountGuard(); }); document.addEventListener("click", (e) => { if (!isDesktop()) return; const target = e.target instanceof Element ? e.target : null; if (!(target ? target.closest("a.issues-reset-query") : null)) return; e.preventDefault(); exitCustomMode(); }); } function init() { registerNavListeners(); subscribeBreakpointChange((desktop) => { if (desktop) { if ((isStarsPage() || isReadOnlyScope()) && !document.querySelector(".stars-grid-container")) transformAndReveal(false); return; } exitStarsView("跨断点收窄(窄视口)"); }); registerTokenMenu(); registerSyncMenu(); registerHideListsMenu(); registerRestoreMenu(); registerExportImportMenu(); setAfterImportHandler(() => { if (isStarsPage() && document.querySelector(".stars-grid-container")) applyFilters({ keepPage: true }); else if (isReadOnlyScope()) renderBrowsePage(1); }); setHideListsRepositionHandler(() => { repositionSetupBanner(); repositionAccountBanner(); }); setAccountBannerPlacer((bar) => { const host = getStarsMainColumn() || document.getElementById("user-starred-repos"); if (!host) return false; placeSetupBanner(bar, host); return true; }); setOpenTokenConfigHandler(() => showSetupBanner()); setTokenSavedHandler(() => { document.querySelector(".gsm-setup-banner")?.remove(); mountAccountGuard(); runFullSync("button").then((sum) => { if (sum && isStarsPage() && !document.querySelector(".stars-grid-container")) transformAndReveal(false); }); }); setTokenIssueHandler((detail) => { showSetupBanner(detail); mountAccountGuard(); }); const repoIdMeta = getRepoIdMeta(); if (!isStarsPage() && !!repoIdMeta) { cleanupExpiredUnstarred(); return; } if (!isStarsListingPage()) return; if (isReadOnlyScope()) { transformAndReveal(false); return; } if (!isStarsPage()) return; ensureStarsSetup(); transformAndReveal(false); } function whenReady(fn) { if (document.readyState !== "loading") { fn(); return; } document.addEventListener("DOMContentLoaded", fn, { once: true }); } console.log("[github-star-manager] script loaded (document-start)"); applyHideListsGate(); installBootHide(); interceptPagination(); whenReady(() => { try { init(); } catch (err) { console.error("[github-star-manager] init 失败,解除防闪烁隐藏", err); revealTurboHide(); deactivateStars(); revealBootHide("init 异常"); } }); })();