Data, network, synchronization, and platform modules for Awesome LinuxDo Reader Lite.
Ezt a szkriptet nem ajánlott közvetlenül telepíteni. Ez egy könyvtár más szkriptek számára, amik tartalmazzák a // @require https://update.greasyfork.org/scripts/591595/1904489/Awesome%20LinuxDo%20Reader%20Lite%20Platform%20Library.js hivatkozást.
// ==UserScript==
// @name Awesome LinuxDo Reader Lite Platform Library
// @name:zh-CN Awesome LinuxDo Reader Lite 平台库
// @namespace https://github.com/sunbigfly/awesome-linuxdo-reader
// @version 1.5.2
// @description Data, network, synchronization, and platform modules for Awesome LinuxDo Reader Lite.
// @description:zh-CN 缓存、集合、Discourse、网络、队列、同步、通知与监控平台模块
// @author sunbigfly
// @license MIT
// @homepageURL https://github.com/sunbigfly/awesome-linuxdo-reader
// @supportURL https://github.com/sunbigfly/awesome-linuxdo-reader/issues
// @match https://linux.do/*
// @grant none
// ==/UserScript==
/* Awesome LinuxDo Reader Lite 1.5.2 - main-lite-platform
* 缓存、集合、Discourse、网络、队列、同步、通知与监控平台模块
* 项目 TypeScript 源码保持可读;固定版本第三方依赖压缩打包。
* 不要直接编辑此文件;修改 lite/src 后重新构建。
*/
(function () {
'use strict';
const root = globalThis;
const runtimeKey = "__AWESOME_LINUXDO_READER_LITE_MODULE_RUNTIME__";
let runtime = root[runtimeKey];
if (runtime === undefined) {
const factories = new Map();
const sourceHashes = new Map();
const modules = new Map();
const libraries = new Set();
let started = false;
const externalModuleIds = Object.freeze({"@xsai/generate-text":"vendor/xsai-generate-text.js"});
const resolve = (parentId, request) => {
const externalId = externalModuleIds[request];
if (externalId) return externalId;
if (!request.startsWith('.')) {
throw new Error(`[main-lite] unsupported external module: ${request}`);
}
const parts = parentId.split('/');
parts.pop();
for (const part of request.split('/')) {
if (!part || part === '.') continue;
if (part === '..') {
if (!parts.length) {
throw new Error(`[main-lite] module escapes root: ${parentId} -> ${request}`);
}
parts.pop();
} else {
parts.push(part);
}
}
const resolved = parts.join('/');
return /\.(?:js|json)$/.test(resolved) ? resolved : `${resolved}.js`;
};
const requireModule = (id) => {
const cached = modules.get(id);
if (cached) return cached.exports;
const factory = factories.get(id);
if (!factory) throw new Error(`[main-lite] missing module: ${id}`);
const module = { exports: {} };
modules.set(id, module);
try {
factory(module, module.exports, (request) => (
requireModule(resolve(id, request))
));
} catch (error) {
modules.delete(id);
throw error;
}
return module.exports;
};
runtime = Object.freeze({
schemaVersion: 1,
sourceVersion: "1.5.2",
register(id, factory, sourceHash) {
const currentHash = sourceHashes.get(id);
if (currentHash !== undefined) {
if (currentHash !== sourceHash) {
throw new Error(`[main-lite] conflicting module: ${id}`);
}
return;
}
factories.set(id, factory);
sourceHashes.set(id, sourceHash);
},
markLibrary(name) {
libraries.add(name);
},
start(entryId, expectedLibraries) {
for (const name of expectedLibraries) {
if (!libraries.has(name)) {
throw new Error(`[main-lite] missing library: ${name}`);
}
}
if (started) return requireModule(entryId);
started = true;
try {
return requireModule(entryId);
} catch (error) {
started = false;
throw error;
}
},
});
Object.defineProperty(root, runtimeKey, {
configurable: true,
enumerable: false,
writable: false,
value: runtime,
});
}
if (runtime.schemaVersion !== 1 || runtime.sourceVersion !== "1.5.2") {
throw new Error('[main-lite] Library 版本不匹配');
}
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: !0 });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from == "object" || typeof from == "function")
for (let key of __getOwnPropNames(from))
!__hasOwnProp.call(to, key) && key !== except && __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: !0 }), mod);
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: !0 }) : target,
mod
));
/* Source: lite/src/cache/browser-asset-cache.ts */
runtime.register("src/cache/browser-asset-cache.js", function(module, exports, require) {
var browser_asset_cache_exports = {};
__export(browser_asset_cache_exports, {
READER_ASSET_CACHE_GROUPS: () => READER_ASSET_CACHE_GROUPS,
ReaderBrowserAssetCacheRepository: () => ReaderBrowserAssetCacheRepository
});
module.exports = __toCommonJS(browser_asset_cache_exports);
const READER_ASSET_CACHE_GROUPS = Object.freeze([
Object.freeze({
id: "avatar",
label: "头像",
cacheName: "linuxdo-enhanced-reader:avatars:v1"
}),
Object.freeze({
id: "emoji",
label: "表情",
cacheName: "linuxdo-enhanced-reader:emoji-images:v1"
}),
Object.freeze({
id: "original",
label: "原图",
cacheName: "linuxdo-enhanced-reader:lightbox-images:v1"
})
]), CACHE_STAT_BATCH_SIZE = 32;
function responseBytes(response) {
const header = response.headers.get("content-length"), declared = header === null ? Number.NaN : Number(header);
return Number.isFinite(declared) && declared >= 0 ? declared : response.blob().then((blob) => blob.size);
}
function emptyGroup(definition, state) {
return Object.freeze({
...definition,
count: 0,
bytes: 0,
state
});
}
class ReaderBrowserAssetCacheRepository {
#storage;
constructor(storage) {
this.#storage = storage;
}
async stats() {
let existing;
try {
existing = new Set(await this.#storage.keys());
} catch {
const errors2 = READER_ASSET_CACHE_GROUPS.map(({ label }) => `${label}缓存目录不可用`);
return Object.freeze({
count: 0,
bytes: 0,
groups: Object.freeze(
READER_ASSET_CACHE_GROUPS.map((group) => emptyGroup(group, "error"))
),
errors: Object.freeze(errors2)
});
}
const errors = [], groups = [];
for (const definition of READER_ASSET_CACHE_GROUPS) {
if (!existing.has(definition.cacheName)) {
groups.push(emptyGroup(definition, "missing"));
continue;
}
try {
const cache = await this.#storage.open(definition.cacheName), requests = await cache.keys();
let bytes = 0;
for (let offset = 0; offset < requests.length; offset += CACHE_STAT_BATCH_SIZE) {
const batch = requests.slice(
offset,
offset + CACHE_STAT_BATCH_SIZE
), sizes = await Promise.all(batch.map(async (request) => {
const response = await cache.match(request);
return response ? responseBytes(response) : 0;
}));
bytes += sizes.reduce((total, size) => total + size, 0);
}
groups.push(Object.freeze({
...definition,
count: requests.length,
bytes,
state: "available"
}));
} catch {
errors.push(`${definition.label}缓存统计失败`), groups.push(emptyGroup(definition, "error"));
}
}
return Object.freeze({
count: groups.reduce((total, group) => total + group.count, 0),
bytes: groups.reduce((total, group) => total + group.bytes, 0),
groups: Object.freeze(groups),
errors: Object.freeze(errors)
});
}
async clear() {
const deleted = [], missing = [], failed = [];
return await Promise.all(READER_ASSET_CACHE_GROUPS.map(async (definition) => {
try {
await this.#storage.delete(definition.cacheName) ? deleted.push(definition.id) : missing.push(definition.id);
} catch {
failed.push(definition.id);
}
})), Object.freeze({
deleted: Object.freeze(deleted),
missing: Object.freeze(missing),
failed: Object.freeze(failed)
});
}
}
}, "2d25f3dbe9f24884170a8d18afbc4fa6b627c60f06d2f902087cdd418d9a21c7");
/* Source: lite/src/cache/cache-coordination.ts */
runtime.register("src/cache/cache-coordination.js", function(module, exports, require) {
var cache_coordination_exports = {};
__export(cache_coordination_exports, {
BroadcastCacheCoordinationChannel: () => BroadcastCacheCoordinationChannel,
BrowserCacheCoordinationStatePort: () => BrowserCacheCoordinationStatePort,
CrossTabCacheCoordinator: () => CrossTabCacheCoordinator,
normalizeCacheInvalidation: () => normalizeCacheInvalidation
});
module.exports = __toCommonJS(cache_coordination_exports);
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function uniqueStrings(values) {
if (!values) return;
const normalized = [...new Set(values.map(String).map((value) => value.trim()).filter(Boolean))].sort();
return normalized.length ? Object.freeze(normalized) : void 0;
}
function normalizeCacheInvalidation(query) {
if (query.all) return Object.freeze({ all: !0 });
const ids = uniqueStrings(query.ids), kinds = uniqueStrings(query.kinds), tags = uniqueStrings(query.tags);
return !ids && !kinds && !tags ? null : Object.freeze({
...ids ? { ids } : {},
...kinds ? { kinds } : {},
...tags ? { tags } : {}
});
}
function normalizeState(raw, now, staleAfterMs) {
const source = raw && typeof raw == "object" ? raw : {}, flights = Array.isArray(source.flights) ? source.flights.filter((flight) => !flight || typeof flight != "object" ? !1 : String(flight.token || "").length > 0 && String(flight.flightId || "").length > 0 && String(flight.ownerId || "").length > 0 && Number.isFinite(flight.epoch) && Number(flight.expiresAt) > now && Number(flight.heartbeatAt) > now - staleAfterMs) : [], failures = Array.isArray(source.failures) ? source.failures.filter((failure) => !failure || typeof failure != "object" ? !1 : String(failure.token || "").length > 0 && String(failure.flightId || "").length > 0 && Number(failure.expiresAt) > now && (failure.kind === "cloudflare" || failure.kind === "rate-limit") && Number.isSafeInteger(Number(failure.status)) && Number(failure.status) >= 400) : [];
return {
schemaVersion: 1,
epoch: Math.max(0, Math.floor(Number(source.epoch) || 0)),
updatedAt: Math.max(0, Number(source.updatedAt) || 0),
flights: flights.map((flight) => Object.freeze({
token: String(flight.token),
flightId: String(flight.flightId),
ownerId: String(flight.ownerId),
epoch: Math.max(0, Math.floor(Number(flight.epoch) || 0)),
heartbeatAt: Number(flight.heartbeatAt),
expiresAt: Number(flight.expiresAt)
})).sort((left, right) => left.expiresAt - right.expiresAt).slice(-128),
failures: failures.map((failure) => Object.freeze({
token: String(failure.token),
flightId: String(failure.flightId),
expiresAt: Number(failure.expiresAt),
status: Number(failure.status),
cloudflareMitigated: failure.cloudflareMitigated === !0,
kind: failure.kind
})).sort((left, right) => left.expiresAt - right.expiresAt).slice(-128)
};
}
function immutableState(state) {
return Object.freeze({
schemaVersion: 1,
epoch: state.epoch,
updatedAt: state.updatedAt,
flights: Object.freeze([...state.flights]),
failures: Object.freeze([...state.failures])
});
}
class BrowserCacheCoordinationStatePort {
atomic;
#storage;
#storageKey;
#lockName;
#locks;
#onError;
constructor(options) {
if (this.#storage = options.storage, this.#storageKey = String(options.storageKey).trim(), this.#lockName = String(options.lockName).trim(), !this.#storageKey || !this.#lockName)
throw new Error("cache coordination storageKey/lockName 不能为空");
this.#locks = options.locks ?? null, this.atomic = !!this.#locks, this.#onError = options.onError ?? (() => {
});
}
async read(now, staleAfterMs) {
return immutableState(this.#readMutable(now, staleAfterMs));
}
async transact(now, staleAfterMs, operation) {
const execute = async () => {
const state = this.#readMutable(now, staleAfterMs), result = await operation(state);
state.updatedAt = now;
try {
this.#storage.setItem(this.#storageKey, JSON.stringify(state));
} catch (error) {
throw this.#onError(error), error;
}
return result;
};
return this.#locks ? this.#locks.request(this.#lockName, { mode: "exclusive" }, execute) : execute();
}
#readMutable(now, staleAfterMs) {
try {
return normalizeState(
JSON.parse(this.#storage.getItem(this.#storageKey) || "null"),
now,
staleAfterMs
);
} catch (error) {
return this.#onError(error), normalizeState(null, now, staleAfterMs);
}
}
}
class BroadcastCacheCoordinationChannel {
#channel;
#listeners = /* @__PURE__ */ new Set();
#onError;
constructor(options) {
const name = String(options.name).trim();
if (!name) throw new Error("BroadcastChannel name 不能为空");
this.#onError = options.onError ?? (() => {
});
const factory = options.factory === void 0 ? typeof BroadcastChannel > "u" ? null : (channelName) => new BroadcastChannel(channelName) : options.factory;
let channel = null;
try {
channel = factory?.(name) ?? null, channel?.addEventListener("message", this.#onMessage);
} catch (error) {
this.#onError(error);
}
this.#channel = channel;
}
publish(message) {
try {
this.#channel?.postMessage(message);
} catch (error) {
this.#onError(error);
}
}
subscribe(listener) {
return this.#listeners.add(listener), () => this.#listeners.delete(listener);
}
close() {
this.#channel?.removeEventListener("message", this.#onMessage), this.#channel?.close(), this.#listeners.clear();
}
#onMessage = (event) => {
for (const listener of this.#listeners) listener(event.data);
};
}
class CrossTabCacheCoordinator {
coordinationMode;
#sourceId;
#channel;
#state;
#flightTtlMs;
#flightStaleMs;
#now;
#createId;
#onError;
#listeners = /* @__PURE__ */ new Set();
#waiters = /* @__PURE__ */ new Set();
#unsubscribe;
#closed = !1;
constructor(options) {
if (this.#sourceId = String(options.sourceId).trim(), !this.#sourceId) throw new Error("cache coordinator sourceId 不能为空");
this.#channel = options.channel, this.#state = options.state, this.#flightTtlMs = positiveInteger(options.flightTtlMs, "flightTtlMs"), this.#flightStaleMs = positiveInteger(options.flightStaleMs, "flightStaleMs"), this.#now = options.now ?? Date.now, this.#createId = options.createId ?? (() => `${this.#sourceId}:${this.#now().toString(36)}:${Math.random().toString(36).slice(2)}`), this.#onError = options.onError ?? (() => {
}), this.coordinationMode = options.state.atomic ? "atomic" : "mutation-only", this.#unsubscribe = this.#channel.subscribe((message) => this.#receive(message));
}
publish(query) {
const normalized = normalizeCacheInvalidation(query);
!normalized || this.#closed || this.#channel.publish(Object.freeze({
schemaVersion: 1,
sourceId: this.#sourceId,
type: "invalidate",
query: normalized
}));
}
subscribeInvalidation(listener) {
return this.#closed ? () => {
} : (this.#listeners.add(listener), () => this.#listeners.delete(listener));
}
async acquireFlight(rawToken) {
const token = String(rawToken).trim();
if (!token || !this.#state.atomic) return this.#uncoordinatedLease(token);
const now = this.#now();
try {
const lease = await this.#state.transact(now, this.#flightStaleMs, (state) => {
const existing = state.flights.find((flight2) => flight2.token === token);
if (existing)
return Object.freeze({
producer: !1,
token,
flightId: existing.flightId,
epoch: existing.epoch,
expiresAt: existing.expiresAt,
coordinated: !0
});
const flightId = this.#createId(), flight = Object.freeze({
token,
flightId,
ownerId: this.#sourceId,
epoch: state.epoch,
heartbeatAt: now,
expiresAt: now + this.#flightTtlMs
});
return state.flights.push(flight), Object.freeze({
producer: !0,
token,
flightId,
epoch: state.epoch,
expiresAt: flight.expiresAt,
coordinated: !0
});
});
return this.#publishState(), lease;
} catch (error) {
return this.#onError(error), this.#uncoordinatedLease(token);
}
}
async renewFlight(lease) {
if (!lease.coordinated || !lease.producer || !this.#state.atomic) return !1;
const now = this.#now();
try {
const renewed = await this.#state.transact(now, this.#flightStaleMs, (state) => {
const index = state.flights.findIndex((flight) => flight.token === lease.token && flight.flightId === lease.flightId && flight.ownerId === this.#sourceId);
if (index < 0) return !1;
const current = state.flights[index];
return current ? (state.flights[index] = Object.freeze({
...current,
heartbeatAt: now,
expiresAt: now + this.#flightTtlMs
}), !0) : !1;
});
return renewed && this.#publishState(), renewed;
} catch (error) {
return this.#onError(error), !1;
}
}
async releaseFlight(lease) {
if (!lease.coordinated || !lease.producer || !this.#state.atomic) return;
const now = this.#now();
try {
await this.#state.transact(now, this.#flightStaleMs, (state) => {
state.flights = state.flights.filter((flight) => flight.token !== lease.token || flight.flightId !== lease.flightId || flight.ownerId !== this.#sourceId);
}), this.#publishState();
} catch (error) {
this.#onError(error);
}
}
async waitForFlight(rawToken, signal, deadline = this.#now() + this.#flightTtlMs * 2) {
const token = String(rawToken).trim();
if (!token || !this.#state.atomic) return !0;
for (; !this.#closed && this.#now() < deadline; ) {
if (signal?.aborted) throw this.#abortError(signal);
let state;
try {
state = await this.#state.read(this.#now(), this.#flightStaleMs);
} catch (error) {
return this.#onError(error), !0;
}
const flight = state.flights.find((candidate) => candidate.token === token);
if (!flight) return !0;
await this.#wait(
Math.min(1e3, Math.max(25, flight.expiresAt - this.#now()), deadline - this.#now()),
signal
);
}
if (signal?.aborted) throw this.#abortError(signal);
return !1;
}
async failFlight(lease, failure) {
if (!lease.coordinated || !lease.producer || !this.#state.atomic) return !1;
const now = this.#now();
try {
const committed = await this.#state.transact(
now,
this.#flightStaleMs,
(state) => {
const index = state.flights.findIndex((flight) => flight.token === lease.token && flight.flightId === lease.flightId && flight.ownerId === this.#sourceId);
return index < 0 ? !1 : (state.flights.splice(index, 1), state.failures = state.failures.filter((candidate) => candidate.flightId !== lease.flightId), state.failures.push(Object.freeze({
token: lease.token,
flightId: lease.flightId,
expiresAt: now + this.#flightTtlMs,
status: failure.status,
cloudflareMitigated: failure.cloudflareMitigated,
kind: failure.kind
})), !0);
}
);
return committed && this.#publishState(), committed;
} catch (error) {
return this.#onError(error), !1;
}
}
async readFlightFailure(lease) {
if (!lease.coordinated || !this.#state.atomic) return null;
try {
const failure = (await this.#state.read(this.#now(), this.#flightStaleMs)).failures?.find((candidate) => candidate.token === lease.token && candidate.flightId === lease.flightId);
return failure ? Object.freeze({
status: failure.status,
cloudflareMitigated: failure.cloudflareMitigated,
kind: failure.kind
}) : null;
} catch (error) {
return this.#onError(error), null;
}
}
async invalidateWrites() {
if (!this.#state.atomic)
return this.#publishState(), 0;
const now = this.#now();
try {
const epoch = await this.#state.transact(now, this.#flightStaleMs, (state) => (state.epoch += 1, state.epoch));
return this.#publishState(), epoch;
} catch (error) {
return this.#onError(error), 0;
}
}
async commitFlight(lease, operation) {
if (!lease.coordinated || !this.#state.atomic)
return await operation(), !0;
try {
return await this.#state.transact(this.#now(), this.#flightStaleMs, async (state) => state.epoch === lease.epoch && state.flights.some((flight) => flight.token === lease.token && flight.flightId === lease.flightId && flight.ownerId === this.#sourceId) ? (await operation(), !0) : !1);
} catch (error) {
return this.#onError(error), !1;
}
}
close() {
this.#closed || (this.#closed = !0, this.#unsubscribe(), this.#channel.close(), this.#listeners.clear(), this.#notifyWaiters());
}
#receive(raw) {
if (!raw || typeof raw != "object") return;
const message = raw;
if (!(message.schemaVersion !== 1 || message.sourceId === this.#sourceId || message.type !== "invalidate" && message.type !== "state")) {
if (message.type === "invalidate") {
const query = normalizeCacheInvalidation(message.query ?? {});
if (query)
for (const listener of this.#listeners)
try {
listener(query);
} catch (error) {
this.#onError(error);
}
}
this.#notifyWaiters();
}
}
#publishState() {
this.#closed || (this.#channel.publish(Object.freeze({
schemaVersion: 1,
sourceId: this.#sourceId,
type: "state"
})), this.#notifyWaiters());
}
#notifyWaiters() {
for (const waiter of this.#waiters) waiter();
this.#waiters.clear();
}
#wait(milliseconds, signal) {
return new Promise((resolve, reject) => {
let settled = !1;
const finish = (error) => {
settled || (settled = !0, clearTimeout(timer), this.#waiters.delete(wake), signal?.removeEventListener("abort", abort), error !== void 0 ? reject(error) : resolve());
}, wake = () => finish(), abort = () => finish(this.#abortError(signal)), timer = setTimeout(wake, Math.max(0, milliseconds));
this.#waiters.add(wake), signal?.addEventListener("abort", abort, { once: !0 });
});
}
#abortError(signal) {
return signal?.reason ?? new DOMException("Aborted", "AbortError");
}
#uncoordinatedLease(token) {
return Object.freeze({
producer: !0,
token,
flightId: "",
epoch: 0,
expiresAt: 0,
coordinated: !1
});
}
}
}, "426097a26de19d79f96aa759e8005745ffdcb42c7015d0cc7750b2e276348878");
/* Source: lite/src/cache/cache-identity.ts */
runtime.register("src/cache/cache-identity.js", function(module, exports, require) {
var cache_identity_exports = {};
__export(cache_identity_exports, {
sharedCacheIdToken: () => sharedCacheIdToken
});
module.exports = __toCommonJS(cache_identity_exports);
function sharedCacheIdToken(value) {
const source = String(value);
let hash = 2166136261;
for (let index = 0; index < source.length; index += 1)
hash ^= source.charCodeAt(index), hash = Math.imul(hash, 16777619);
return (hash >>> 0).toString(36);
}
}, "fca985f86f39ba26872e25e42a855fbaff32da679df43591f16ca5e46a5e1157");
/* Source: lite/src/cache/discourse-application-cache-invalidation.ts */
runtime.register("src/cache/discourse-application-cache-invalidation.js", function(module, exports, require) {
var discourse_application_cache_invalidation_exports = {};
__export(discourse_application_cache_invalidation_exports, {
DiscourseApplicationCacheInvalidationCoordinator: () => DiscourseApplicationCacheInvalidationCoordinator
});
module.exports = __toCommonJS(discourse_application_cache_invalidation_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_lifecycle = require("../kernel/lifecycle.js");
function record(value) {
if (!value || typeof value != "object" || Array.isArray(value)) return null;
const source = value, toJSON = source.toJSON;
if (typeof toJSON != "function") return source;
try {
return record(toJSON.call(value)) ?? source;
} catch {
return source;
}
}
function positiveId(value) {
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function postIdentity(value) {
const envelope = record(value), post = record(envelope?.post) ?? envelope;
return Object.freeze({
postId: positiveId(post?.id),
topicId: positiveId(post?.topic_id) ?? positiveId(record(post?.topic)?.id)
});
}
function eventPost(value) {
const envelope = record(value);
return record(envelope?.post) ?? envelope;
}
class DiscourseApplicationCacheInvalidationCoordinator {
scope;
#options;
#pending = /* @__PURE__ */ new Set();
#scheduled = !1;
#flushPromise = null;
constructor(options) {
this.#options = options, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#bindReactionEvents(), this.scope.add(options.composerEvents.subscribe((event) => {
this.#onComposerEvent(event);
})), this.scope.add(() => {
this.#pending.clear(), this.#scheduled = !1;
});
}
flush() {
if (this.scope.destroyed) return this.#flushPromise ?? Promise.resolve();
if (this.#scheduled = !1, !this.#pending.size) return this.#flushPromise ?? Promise.resolve();
const tags = Object.freeze([...this.#pending].sort());
this.#pending.clear();
const transaction = (this.#flushPromise ?? Promise.resolve()).catch(() => {
}).then(async () => {
try {
await this.#options.cache.invalidate({ tags });
} catch (cause) {
this.#report(cause);
}
});
return this.#flushPromise = transaction, transaction.finally(() => {
this.#flushPromise === transaction && (this.#flushPromise = null), this.#pending.size && this.#schedule();
}), transaction;
}
destroy() {
this.scope.destroy();
}
#bindReactionEvents() {
const listener = (payload) => {
const post = eventPost(payload), identity = postIdentity(post ?? payload);
if (post)
try {
this.#options.onPostChanged?.(post);
} catch (cause) {
this.#report(cause);
}
this.#queue(["reactions-given"], identity);
};
this.scope.add((0, import_native_host_api.discourseNativeAppEventSubscription)(
this.#options.host,
"discourse-reactions:reaction-toggled",
listener,
(cause) => this.#report(cause)
));
}
#onComposerEvent(event) {
const identity = postIdentity(event.payload), fallbackTopicId = event.kind === "edited" ? positiveId(this.#options.currentTopicId()) : null;
this.#queue([], Object.freeze({
postId: identity.postId,
topicId: identity.topicId ?? fallbackTopicId
}));
}
#queue(baseTags, identity) {
if (!this.scope.destroyed) {
for (const tag of baseTags) this.#pending.add(tag);
identity.postId !== null && this.#pending.add(`post:${identity.postId}`), identity.topicId !== null && this.#pending.add(`topic:${identity.topicId}`), this.#schedule();
}
}
#schedule() {
this.#scheduled || this.scope.destroyed || !this.#pending.size || (this.#scheduled = !0, queueMicrotask(() => {
this.scope.destroyed || (this.#scheduled = !1, this.flush());
}));
}
#report(cause) {
try {
this.#options.onError?.(cause);
} catch {
}
}
}
}, "fe45e48ea5b25ee591dcd5cd1a9e7b072dbf709b3b0d371f04232238fac05d59");
/* Source: lite/src/cache/indexeddb-response-cache-store.ts */
runtime.register("src/cache/indexeddb-response-cache-store.js", function(module, exports, require) {
var indexeddb_response_cache_store_exports = {};
__export(indexeddb_response_cache_store_exports, {
IndexedDbResponseCacheStore: () => IndexedDbResponseCacheStore,
selectResponseCachePruneIds: () => selectResponseCachePruneIds
});
module.exports = __toCommonJS(indexeddb_response_cache_store_exports);
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function matches(entry, query) {
return !!(query.all || query.ids?.includes(entry.id) || query.kinds?.includes(entry.kind) || query.tags?.some((tag) => entry.tags.includes(tag)));
}
function selectResponseCachePruneIds(entries, options) {
const sorted = [...entries].sort(
(left, right) => left.storedAt - right.storedAt || left.id.localeCompare(right.id)
), remove = /* @__PURE__ */ new Set();
let retainedEntries = 0, retainedBytes = 0;
for (const entry of sorted) {
if (entry.schemaVersion !== 1 || !Number.isFinite(entry.expiresAt) || entry.permanent !== !0 && entry.expiresAt <= options.now) {
remove.add(entry.id);
continue;
}
entry.permanent !== !0 && (retainedEntries += 1, retainedBytes += Math.max(0, Number(entry.bytes) || 0));
}
for (const entry of sorted)
if (!remove.has(entry.id)) {
if (retainedEntries <= options.maxEntries && retainedBytes <= options.maxBytes) break;
entry.permanent !== !0 && (remove.add(entry.id), retainedEntries -= 1, retainedBytes -= Math.max(0, Number(entry.bytes) || 0));
}
return Object.freeze([...remove]);
}
class IndexedDbResponseCacheStore {
#databaseName;
#storeName;
#operationTimeoutMs;
#maxEntries;
#maxBytes;
#factory;
#now;
#onError;
#databasePromise = null;
#writesSincePrune = 0;
#prunePromise = null;
constructor(options) {
if (this.#databaseName = String(options.databaseName).trim(), this.#storeName = String(options.storeName).trim(), !this.#databaseName || !this.#storeName)
throw new Error("IndexedDB databaseName/storeName 不能为空");
this.#operationTimeoutMs = positiveInteger(
options.operationTimeoutMs,
"operationTimeoutMs"
), this.#maxEntries = positiveInteger(options.maxEntries, "maxEntries"), this.#maxBytes = positiveInteger(options.maxBytes, "maxBytes"), this.#factory = options.factory === void 0 ? typeof indexedDB > "u" ? null : indexedDB : options.factory, this.#now = options.now ?? Date.now, this.#onError = options.onError ?? (() => {
});
}
async read(id) {
const result = await this.#transaction(
"readonly",
null,
(store, setValue) => {
const request = store.get(id);
request.onsuccess = () => {
const value = request.result;
setValue(value ?? null);
};
}
);
return result.ok || this.#report(result.error), result.ok ? result.value : null;
}
async write(entry) {
let result = await this.#put(entry);
!result.ok && this.#quotaError(result.error) && (await this.prune(!0), result = await this.#put(entry)), result.ok ? this.#recordSuccessfulWrite() : this.#report(result.error);
}
async invalidate(query) {
if (query.all) {
const result2 = await this.#transaction(
"readwrite",
!1,
(store) => {
store.clear();
}
);
return result2.ok || this.#report(result2.error), Object.freeze({ ok: result2.ok, error: result2.error });
}
const result = await this.#transaction(
"readwrite",
!1,
(store, setValue) => {
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
setValue(!0);
return;
}
const entry = cursor.value;
matches(entry, query) && cursor.delete(), cursor.continue();
};
}
);
return result.ok || this.#report(result.error), Object.freeze({ ok: result.ok, error: result.error });
}
async merge(id, update) {
let result = await this.#mergeEntry(id, update);
return !result.ok && this.#quotaError(result.error) && (await this.prune(!0), result = await this.#mergeEntry(id, update)), result.ok ? this.#recordSuccessfulWrite() : this.#report(result.error), result.ok ? result.value : null;
}
#mergeEntry(id, update) {
return this.#transaction(
"readwrite",
null,
(store, setValue) => {
const request = store.get(id);
request.onsuccess = () => {
try {
const next = update(
request.result ?? null
), write = store.put(next);
write.onsuccess = () => setValue(next);
} catch {
try {
store.transaction.abort();
} catch {
}
}
};
}
);
}
async records() {
const entries = await this.snapshotEntries();
return Object.freeze(entries.map((entry) => Object.freeze({
id: entry.id,
kind: entry.kind,
tags: Object.freeze([...entry.tags]),
storedAt: entry.storedAt,
expiresAt: entry.expiresAt,
bytes: Math.max(0, Number(entry.bytes) || 0),
...entry.permanent === !0 ? { permanent: !0 } : {}
})));
}
async snapshotEntries() {
const result = await this.#transaction(
"readonly",
[],
(store, setValue) => {
const request = store.getAll();
request.onsuccess = () => setValue(
Array.isArray(request.result) ? request.result : []
);
}
);
if (!result.ok) {
const error = result.error ?? new Error("IndexedDB cache directory unavailable");
throw this.#report(error), error;
}
return Object.freeze(result.value.map((entry) => Object.freeze({
...entry,
tags: Object.freeze([...entry.tags])
})));
}
async prune(forceQuotaRecovery = !1) {
const entries = await this.#allEntries(), ids = selectResponseCachePruneIds(entries, {
now: this.#now(),
maxEntries: this.#maxEntries,
maxBytes: this.#maxBytes
}), pruneIds = ids.length ? ids : forceQuotaRecovery ? [...entries].filter((entry) => entry.permanent !== !0).sort((left, right) => left.storedAt - right.storedAt).slice(0, Math.max(1, Math.ceil(entries.length / 4))).map((entry) => entry.id) : [];
if (!pruneIds.length) return;
const result = await this.#deleteIds(pruneIds);
result.ok || this.#report(result.error);
}
async close() {
await this.#prunePromise, (await this.#databasePromise)?.close(), this.#databasePromise = null;
}
#open() {
if (this.#databasePromise) return this.#databasePromise;
const factory = this.#factory;
if (!factory) return Promise.resolve(null);
let promise;
return promise = new Promise((resolve) => {
let settled = !1, request;
const finish = (database, error) => {
if (settled) {
database?.close();
return;
}
settled = !0, clearTimeout(timeoutId), error !== void 0 && this.#report(error), resolve(database);
}, timeoutId = setTimeout(
() => finish(null, new Error("IndexedDB open timeout")),
this.#operationTimeoutMs
);
try {
request = factory.open(this.#databaseName, 1);
} catch (error) {
finish(null, error);
return;
}
request.onupgradeneeded = () => {
const database = request.result, store = database.objectStoreNames.contains(this.#storeName) ? request.transaction?.objectStore(this.#storeName) : database.createObjectStore(this.#storeName, { keyPath: "id" });
store && (store.indexNames.contains("kind") || store.createIndex("kind", "kind"), store.indexNames.contains("tags") || store.createIndex("tags", "tags", { multiEntry: !0 }), store.indexNames.contains("storedAt") || store.createIndex("storedAt", "storedAt"));
}, request.onsuccess = () => {
const database = request.result;
database.onversionchange = () => {
database.close(), this.#databasePromise === promise && (this.#databasePromise = null);
}, finish(database);
}, request.onerror = () => finish(null, request.error), request.onblocked = () => finish(null, new Error("IndexedDB open blocked"));
}), this.#databasePromise = promise, promise.then((database) => {
!database && this.#databasePromise === promise && (this.#databasePromise = null);
}), promise;
}
async #transaction(mode, initialValue, operation) {
const database = await this.#open();
return database ? new Promise((resolve) => {
let settled = !1, value = initialValue, transaction = null;
const finish = (ok, error = null) => {
settled || (settled = !0, clearTimeout(timeoutId), resolve({ ok, value, error }));
}, timeoutId = setTimeout(() => {
try {
transaction?.abort();
} catch {
}
finish(!1, new Error("IndexedDB transaction timeout"));
}, this.#operationTimeoutMs);
try {
const activeTransaction = database.transaction(this.#storeName, mode);
transaction = activeTransaction, activeTransaction.oncomplete = () => finish(!0), activeTransaction.onerror = () => finish(!1, activeTransaction.error), activeTransaction.onabort = () => finish(!1, activeTransaction.error), operation(activeTransaction.objectStore(this.#storeName), (nextValue) => {
value = nextValue;
});
} catch (error) {
finish(!1, error);
}
}) : { ok: !1, value: initialValue, error: new Error("IndexedDB unavailable") };
}
#put(entry) {
return this.#transaction("readwrite", !1, (store, setValue) => {
const request = store.put(entry);
request.onsuccess = () => setValue(!0);
});
}
#recordSuccessfulWrite() {
if (this.#writesSincePrune += 1, this.#writesSincePrune < 32 || (this.#writesSincePrune = 0, this.#prunePromise)) return;
const pruning = this.prune().catch((error) => this.#report(error)).finally(() => {
this.#prunePromise === pruning && (this.#prunePromise = null);
});
this.#prunePromise = pruning;
}
async #allEntries() {
const result = await this.#transaction(
"readonly",
[],
(store, setValue) => {
const request = store.getAll();
request.onsuccess = () => setValue(
Array.isArray(request.result) ? request.result : []
);
}
);
return result.ok || this.#report(result.error), result.ok ? result.value : [];
}
#deleteIds(ids) {
return this.#transaction("readwrite", !1, (store, setValue) => {
for (const id of ids) store.delete(id);
setValue(!0);
});
}
#quotaError(error) {
return error instanceof DOMException ? error.name === "QuotaExceededError" : String(error?.name ?? "") === "QuotaExceededError";
}
#report(error) {
error != null && this.#onError(error);
}
}
}, "9e8743dbf8e5060e156abde4e4e0a5e5eb3815966802707f2a91a9c54243500b");
/* Source: lite/src/cache/reader-cache-management-surface.ts */
runtime.register("src/cache/reader-cache-management-surface.js", function(module, exports, require) {
var reader_cache_management_surface_exports = {};
__export(reader_cache_management_surface_exports, {
ReaderCacheManagementSurface: () => ReaderCacheManagementSurface
});
module.exports = __toCommonJS(reader_cache_management_surface_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_reader_settings_dom = require("../settings/reader-settings-dom.js");
const CATEGORIES = Object.freeze([
{
id: "history",
title: "浏览历史与岁月史书",
help: "勾选“浏览历史与岁月史书”后再点下方清理,会删除阅读器保存的主题、最近阅读楼层、查看时间和岁月史书 404 记录;不会删除浏览器本身的访问历史,也不会由收到 404 自动触发清理。",
retention: "最多 365 天"
},
{
id: "topics",
title: "帖子与楼层内容",
help: "勾选“帖子与楼层内容”后清理,会删除本机保存的帖子信息、楼层正文、回复关系、主题快照和相关接口数据;若当前正打开主题,会先安全结束旧会话并立即联网重建,避免旧快照在清理后写回。",
retention: "接口 7 天 · 快照 30 天"
},
{
id: "users",
title: "用户资料卡",
help: "勾选“用户资料卡”后清理,会删除用户名、简介、徽章、用户组、关注列表、账户摘要,以及用户观察已缓存的公开历史和采集断点;观察名单仍保留。不会删除 Connect 近 400 天的本机信任观察历史,头像仍归“头像、表情与原图”单独管理。",
retention: "资料 1 天 · 观察历史直到主动清理"
},
{
id: "notifications",
title: "通知与消息",
help: "勾选“通知与消息”后清理,会删除通知分页、回复展开、已读状态和跳转位置等缓存;不会删除站点账号里的真实消息。",
retention: "最多 180 天"
},
{
id: "responses",
title: "收藏、回应与其他数据",
help: "勾选“收藏、回应与其他数据”后清理,会删除收藏列表、给出的回应与点赞、翻译和其他通用接口结果;不会撤销站点上的真实收藏或回应。",
retention: "按接口 8 分钟–180 天"
},
{
id: "assets",
title: "头像、表情与原图",
help: "勾选“头像、表情与原图”后清理,会删除统一图片响应和旧版头像、Boost 表情、实际查看过的原图资源;不会删除帖子中的线上图片。",
retention: "接口 30 天 · 兼容缓存 7–90 天"
}
]);
function categoryOf(record) {
return record.kind.startsWith("topic-offline-artifact") || record.tags.some((tag) => tag.startsWith("topic-offline-artifact")) ? null : record.kind === "images" || record.tags.includes("images") ? "assets" : record.kind.includes("notification") || record.tags.includes("notifications") ? "notifications" : record.kind === "topics" || record.kind.startsWith("discourse-topic") || record.tags.some((tag) => tag.startsWith("topic:") || tag.startsWith("post:")) ? "topics" : record.kind === "users" || record.kind === "external-user-summary" || record.kind === "user-observation-history" || record.tags.includes("users") ? "users" : record.kind.includes("bookmark") || record.tags.includes("bookmarks") || record.tags.some((tag) => tag.startsWith("bookmark-")) ? "responses" : record.permanent === !0 ? null : "responses";
}
function formatBytes(rawBytes) {
const bytes = Math.max(0, Number(rawBytes) || 0);
return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
}
function assetCacheDetail(snapshot) {
return [...snapshot.groups.map((group) => {
const state = group.state === "error" ? "统计失败" : `${group.count} 条 · ${formatBytes(group.bytes)}`;
return `${group.label}:${state}`;
}), ...snapshot.errors].join(";");
}
function identityValue(record, key) {
const queryOffset = record.id.indexOf("?");
if (queryOffset < 0) return "";
try {
return new URLSearchParams(record.id.slice(queryOffset + 1)).get(key) ?? "";
} catch {
return "";
}
}
function taggedValues(records, prefix) {
const values = /* @__PURE__ */ new Set();
for (const record of records)
for (const tag of record.tags)
tag.startsWith(prefix) && tag.length > prefix.length && values.add(tag.slice(prefix.length));
return values;
}
function categoryBytes(records) {
return records.reduce(
(total, record) => total + Math.max(0, Number(record.bytes) || 0),
0
);
}
function withApplicationCacheDetail(detail, application) {
return application?.detail ? `${detail} · ${application.detail}` : detail;
}
function categoryStatText(id, records, history, assetCaches, application) {
const bytes = categoryBytes(records);
if (id === "history") {
const topicIds = new Set(history.map(
(entry) => Number(entry.topicId)
).filter((topicId) => Number.isSafeInteger(topicId) && topicId > 0)), chronicle = history.filter((entry) => Number(entry.status) === 404 && ["topic", "reply", "boost"].includes(String(
entry.kind ?? ""
)));
return withApplicationCacheDetail(
`${topicIds.size} 个主题 · ${history.length - chronicle.length} 条浏览记录 · ${chronicle.length} 条 404 记录 · ${formatBytes(new TextEncoder().encode(JSON.stringify(history)).byteLength)} · 本机保存`,
application
);
}
if (id === "topics") {
const snapshots = records.filter((record) => record.id.includes("|snapshot:topic:")).length;
return withApplicationCacheDetail(`${taggedValues(records, "topic:").size} 个主题 · 已缓存 ${taggedValues(records, "post:").size} 个楼层 · 快照 ${snapshots} / 接口 ${Math.max(0, records.length - snapshots)} · 共 ${records.length} 条记录 · ${formatBytes(bytes)} · 本机保存`, application);
}
if (id === "users") {
const users = new Set(taggedValues(records, "user:"));
for (const record of records) {
const username = identityValue(record, "username");
username && users.add(username.toLocaleLowerCase());
}
return withApplicationCacheDetail(
`${users.size} 个用户 · 共 ${records.length} 条记录 · ${formatBytes(bytes)} · 本机保存`,
application
);
}
if (id === "notifications") {
const pages = records.filter((record) => record.kind === "discourse-notification-page").length;
return withApplicationCacheDetail(
`分页 ${pages} / 回复展开 ${Math.max(0, records.length - pages)} · 共 ${records.length} 条记录 · ${formatBytes(bytes)} · 本机保存`,
application
);
}
if (id === "responses") {
let bookmarks = 0, reactions = 0;
for (const record of records) {
const collection = identityValue(record, "collection");
collection === "bookmarks" ? bookmarks += 1 : collection.includes("reaction") && (reactions += 1);
}
const other = Math.max(0, records.length - bookmarks - reactions);
return withApplicationCacheDetail(
`收藏 ${bookmarks} / 回应 ${reactions} / 其他 ${other} · 共 ${records.length} 条响应 · ${formatBytes(bytes)} · 本机保存`,
application
);
}
const groups = assetCaches?.groups.map((group) => `${group.label} ${group.count} 个(${formatBytes(group.bytes)})`) ?? [], legacyCount = assetCaches?.count ?? 0, legacyBytes = assetCaches?.bytes ?? 0;
return withApplicationCacheDetail([
...groups,
`接口图片 ${records.length} 个(${formatBytes(bytes)})`,
`共 ${legacyCount + records.length} 个资源 · ${formatBytes(legacyBytes + bytes)} · 本机保存`
].join(" · "), application);
}
class ReaderCacheManagementSurface {
scope;
#options;
#root;
#selects = /* @__PURE__ */ new Map();
#stats = /* @__PURE__ */ new Map();
#clear;
#refreshCurrent;
#configActions;
#configFile;
#configStatus;
#status;
#refreshToken = 0;
#busy = !1;
constructor(options) {
this.#options = options, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const document = options.document;
if (this.#root = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-category-groups"
), options.configuration) {
const configSection = (0, import_reader_settings_dom.settingsSection)(
document,
"导入与导出设置",
"配置文件包含当前偏好(性能项仅保存目标值)、其他适用站点、翻译规则与 WebDAV 非敏感选项;导入后仍按当前设备、网络与 429 状态自适应。生效策略、请求与性能记录、阅读队列、浏览历史、帖子内容、缓存和账号数据不会写入文件;翻译 API Key、WebDAV 用户名和密码始终排除。",
!0
);
configSection.dataset.settingCategory = "config-management";
const actions = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-config-actions"
), exportButton = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-config-action ldp-config-export",
"",
"download",
"导出设置"
), importButton = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-config-action ldp-config-import",
"",
"upload",
"导入设置"
), resetButton = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-config-action ldp-config-reset danger",
"",
"rotate-ccw",
"恢复全部默认"
);
actions.append(exportButton, importButton, resetButton);
const file = (0, import_reader_settings_dom.settingsElement)(
document,
"input",
"ldp-config-file"
);
file.type = "file", file.accept = "application/json,.json", file.hidden = !0;
const status = (0, import_reader_settings_dom.settingsElement)(
document,
"small",
"ldp-cache-note ldp-config-status"
);
status.role = "status", status.setAttribute("aria-live", "polite");
const body = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-config-body"
);
body.append(actions, file, status), configSection.append(body), this.#root.append(configSection), this.#configActions = Object.freeze([
exportButton,
importButton,
resetButton
]), this.#configFile = file, this.#configStatus = status, this.scope.listen(
exportButton,
"click",
() => void this.#exportConfiguration()
), this.scope.listen(importButton, "click", () => {
file.value = "", file.click();
}), this.scope.listen(
file,
"change",
() => void this.#importConfiguration()
), this.scope.listen(
resetButton,
"click",
() => void this.#resetConfiguration()
);
} else
this.#configActions = Object.freeze([]), this.#configFile = null, this.#configStatus = null;
const section = (0, import_reader_settings_dom.settingsSection)(
document,
"本地缓存",
"阅读器会在本机保存浏览历史、帖子与楼层、用户资料、通知、收藏和图片等可复用数据。清理后不会影响站点账号内容,需要时会重新联网获取。",
!0
);
section.dataset.settingCategory = "local-cache";
const content = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-category-content"
), list = (0, import_reader_settings_dom.settingsElement)(document, "div", "ldp-cache-list");
for (const definition of CATEGORIES) {
const row = (0, import_reader_settings_dom.settingsElement)(document, "label", "ldp-cache-row");
row.dataset.settingHelp = definition.help;
const input = (0, import_reader_settings_dom.settingsElement)(document, "input", "ldp-cache-select");
input.type = "checkbox", input.value = definition.id;
const copy = (0, import_reader_settings_dom.settingsCopy)(
document,
"",
definition.title
), stat = copy.querySelector("small") ?? (0, import_reader_settings_dom.settingsElement)(document, "small");
stat.dataset.cacheSize = definition.id, stat.parentElement || copy.append(stat);
const retention = (0, import_reader_settings_dom.settingsElement)(document, "em");
retention.dataset.cacheRetention = definition.id, retention.textContent = definition.retention, row.append(input, copy, retention), list.append(row), this.#selects.set(definition.id, input), this.#stats.set(definition.id, stat), this.scope.listen(input, "change", () => this.#syncButtons());
}
this.#clear = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-cache-clear",
"",
"trash",
"清理已选缓存"
), this.#clear.disabled = !0, this.#clear.dataset.settingHelp = "只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。", this.#status = (0, import_reader_settings_dom.settingsElement)(document, "small", "ldp-cache-note"), this.#status.role = "status", this.#status.setAttribute("aria-live", "polite"), content.append(list, this.#clear, this.#status), section.append(content), this.#root.append(section), options.host.append(this.#root), this.scope.add(() => this.#root.remove()), this.scope.listen(this.#clear, "click", () => void this.#clearSelected()), options.headerActions ? (this.#refreshCurrent = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-reader-refresh ldp-icon-btn",
"清除当前帖子缓存并刷新",
"rotate-ccw"
), options.headerActions.append(this.#refreshCurrent), this.scope.add(() => this.#refreshCurrent?.remove()), this.scope.listen(
this.#refreshCurrent,
"click",
() => void this.#clearCurrent()
)) : this.#refreshCurrent = null, this.sync(), this.refresh();
}
sync() {
this.#refreshCurrent && (this.#refreshCurrent.disabled = this.#busy || !this.#options.currentTopicAvailable());
}
async refresh() {
const token = ++this.#refreshToken, [recordsResult, assetCachesResult, applicationCachesResult] = await Promise.allSettled([
this.#options.responses.records(),
Promise.resolve(this.#options.assetCaches?.stats() ?? null),
Promise.resolve(this.#options.applicationCaches?.stats() ?? null)
]);
if (token !== this.#refreshToken || this.scope.destroyed) return !1;
const incomplete = /* @__PURE__ */ new Set(), responseCategories = CATEGORIES.filter(
({ id }) => id !== "history"
).map(({ id }) => id), records = recordsResult.status === "fulfilled" ? recordsResult.value : Object.freeze([]);
if (recordsResult.status === "rejected") {
this.#options.onError?.(recordsResult.reason);
for (const category of responseCategories) incomplete.add(category);
}
const assetCaches = assetCachesResult.status === "fulfilled" ? assetCachesResult.value : null;
assetCachesResult.status === "rejected" && (this.#options.onError?.(assetCachesResult.reason), incomplete.add("assets")), assetCaches?.errors.length && incomplete.add("assets");
const applicationCaches = applicationCachesResult.status === "fulfilled" ? applicationCachesResult.value : null;
if (applicationCachesResult.status === "rejected") {
this.#options.onError?.(applicationCachesResult.reason);
for (const category of responseCategories) incomplete.add(category);
}
const grouped = new Map(
CATEGORIES.map(({ id }) => [id, []])
);
let history = Object.freeze([]);
try {
history = Object.freeze([
...this.#options.history.snapshot.entries,
...this.#options.chronicle?.snapshot.records ?? []
]);
} catch (cause) {
this.#options.onError?.(cause), incomplete.add("history");
}
for (const record of records) {
const category = categoryOf(record);
category && grouped.get(category).push(record);
}
const assetTarget = this.#stats.get("assets");
assetTarget && (assetCaches ? assetTarget.dataset.ldpTooltipLabel = assetCacheDetail(assetCaches) : delete assetTarget.dataset.ldpTooltipLabel);
for (const { id } of CATEGORIES) {
const target = this.#stats.get(id);
if (target) {
const detail = categoryStatText(
id,
grouped.get(id) ?? [],
history,
assetCaches,
applicationCaches?.categories[id]
);
target.textContent = incomplete.has(id) ? `${detail} · 统计不完整` : detail;
}
}
const managedResponseRecords = [...grouped.values()].reduce(
(total2, categoryRecords) => total2 + categoryRecords.length,
0
), total = history.length + managedResponseRecords + (assetCaches?.count ?? 0) + Object.values(applicationCaches?.categories ?? {}).reduce(
(sum, category) => sum + Math.max(0, Number(category?.records) || 0),
0
);
if (incomplete.size) {
const labels = CATEGORIES.filter(({ id }) => incomplete.has(id)).map(({ title }) => title);
return this.#status.textContent = `共 ${total} 条已知本地记录;统计不完整:${labels.join("、")}`, !1;
}
return this.#status.textContent = `共 ${total} 条本地记录`, !0;
}
destroy() {
this.scope.destroy();
}
async #clearSelected() {
if (this.#busy) return;
const selected = new Set(
[...this.#selects].filter(([, input]) => input.checked).map(([id]) => id)
);
if (!selected.size) return;
this.#setBusy(!0, "等待确认清理已选缓存…");
let releasePreparation = () => {
};
try {
if (this.#options.configuration) {
const labels = CATEGORIES.filter(({ id }) => selected.has(id)).map(({ title }) => title);
if (!await this.#options.configuration.confirm({
title: "清理所选本地缓存?",
message: `将清理:${labels.join("、")}。`,
note: "只删除本机缓存,不删除站点账号或 WebDAV 远端数据;已同步记录可能在后续同步时重新合并回来。需要的数据将按需联网获取。",
confirmLabel: "清理已选缓存",
tone: "danger",
icon: "trash"
})) {
this.#status.textContent = "已取消缓存清理。";
return;
}
}
this.#status.textContent = "正在清理已选缓存…";
const failures = /* @__PURE__ */ new Map(), fail = (categories, message, cause) => {
for (const category of categories) {
const messages = failures.get(category) ?? [];
messages.push(message), failures.set(category, messages);
}
cause !== void 0 && this.#options.onError?.(cause);
}, clearing = new Set(selected);
if (this.#options.prepareClear)
try {
const prepared = await this.#options.prepareClear([...selected]);
releasePreparation = prepared.release ?? releasePreparation;
for (const category of prepared.failed)
selected.has(category) && fail([category], "同步基线未能安全解除");
} catch (cause) {
fail([...selected], "缓存清理准备失败", cause);
}
for (const category of failures.keys()) clearing.delete(category);
if (clearing.has("assets") && this.#options.assetCaches)
try {
const result = await this.#options.assetCaches.clear();
result.failed.length && fail(
["assets"],
`兼容图片缓存 ${result.failed.join(",")} 未清理`
);
} catch (cause) {
fail(["assets"], "兼容图片缓存未清理", cause);
}
const responseCategories = [...clearing].filter(
(id) => id !== "history"
);
if (responseCategories.length)
try {
const ids = (await this.#options.responses.records()).filter((record) => {
const category = categoryOf(record);
return category !== null && clearing.has(category);
}).map((record) => record.id);
if (ids.length) {
let report = null;
if (this.#options.responses.invalidateWithReport ? report = await this.#options.responses.invalidateWithReport({ ids }) : await this.#options.responses.invalidate({ ids }), report && !report.complete) {
for (const failure of report.failures)
this.#options.onError?.(failure.cause);
fail(responseCategories, "统一响应缓存未完整失效");
}
}
} catch (cause) {
fail(responseCategories, "统一响应缓存未清理", cause);
}
if (clearing.size && this.#options.applicationCaches)
try {
const result = await this.#options.applicationCaches.clear([...clearing]);
for (const category of result.failed)
clearing.has(category) && fail([category], "应用内存热缓存未清理");
} catch (cause) {
fail([...clearing], "应用内存热缓存未清理", cause);
}
if (clearing.has("history"))
try {
this.#options.history.clear(), this.#options.chronicle?.clear();
} catch (cause) {
fail(["history"], "浏览历史或岁月史书未清理", cause);
}
if (clearing.has("assets"))
try {
this.#options.clearImageObjectUrls?.();
} catch (cause) {
fail(["assets"], "当前图片对象未释放", cause);
}
for (const [id, input] of this.#selects)
selected.has(id) && !failures.has(id) && (input.checked = !1);
releasePreparation(), releasePreparation = () => {
};
const refreshed = await this.refresh();
if (failures.size) {
const labels = CATEGORIES.filter(({ id }) => failures.has(id)).map(({ title }) => title);
this.#options.notify?.("部分本地缓存未能清理"), this.#status.textContent = `部分缓存已清理;未完成:${labels.join("、")}。已保留勾选,可重试。`;
} else
this.#options.notify?.("已清理所选本地缓存"), this.#status.textContent = refreshed ? "清理完成;需要的数据会按需重新获取。" : "清理已完成,但最新缓存统计读取失败;可稍后重新打开数据管理确认。";
} catch (cause) {
this.#options.onError?.(cause), this.#status.textContent = "清理失败,请稍后重试。";
} finally {
try {
releasePreparation();
} catch (cause) {
this.#options.onError?.(cause);
}
this.#setBusy(!1);
}
}
async #clearCurrent() {
if (!(this.#busy || !this.#options.currentTopicAvailable())) {
this.#setBusy(!0, "正在重建当前主题缓存…"), this.#refreshCurrent?.classList.add("is-refreshing");
try {
const result = await this.#options.clearCurrentTopic(), refreshed = await this.refresh();
if (result?.restored) {
const message = result.message ?? "当前主题刷新失败,已恢复刷新前内容;可稍后重试。";
this.#options.notify?.(message), this.#status.textContent = message;
return;
}
if (result && !result.complete) {
this.#options.notify?.(
"当前主题已重新获取,但部分旧缓存未能清理"
), this.#status.textContent = result.message ?? "当前主题已从原站重新获取,但部分旧缓存未能清理;可再次重试。";
return;
}
this.#options.notify?.("当前主题缓存已清理并重新获取"), this.#status.textContent = refreshed ? "当前主题已从原站重新获取。" : "当前主题已从原站重新获取,但最新缓存统计读取失败。";
} catch (cause) {
this.#options.onError?.(cause), this.#status.textContent = "当前主题重建失败,原缓存不会作为恢复前提。";
} finally {
this.#refreshCurrent?.classList.remove("is-refreshing"), this.#setBusy(!1);
}
}
}
async #exportConfiguration() {
const configuration = this.#options.configuration;
if (!(!configuration || this.#busy)) {
this.#setBusy(!0, "正在导出设置…"), this.#setConfigStatus("正在导出设置…");
try {
const payload = await configuration.export(), filename = configuration.filename?.(payload) ?? `awesome-linuxdo-reader-settings-${payload.exportedAt.slice(0, 10)}.json`;
await configuration.saveTextFile(
`${JSON.stringify(payload, null, 2)}
`,
filename
), this.#setConfigStatus(
`已导出 ${payload.settingsCount} 项偏好及安全扩展配置;性能项为目标值,不含运行时策略与日志。`
), this.#options.notify?.("设置配置已导出");
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus("导出失败,请稍后重试。");
} finally {
this.#setBusy(!1);
}
}
}
async #importConfiguration() {
const configuration = this.#options.configuration, file = this.#configFile?.files?.[0];
if (!(!configuration || !file || this.#busy)) {
this.#setBusy(!0);
try {
let imported;
try {
imported = configuration.prepare(
JSON.parse(await file.text())
);
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus(
"配置文件无效或版本不匹配,请选择本阅读器导出的 JSON 文件。"
);
return;
}
if (!await configuration.confirm({
title: "导入这份设置配置?",
message: `将使用“${file.name}”覆盖当前阅读器设置。`,
note: imported.includesPortableSections ? "性能目标会立即应用,并继续按当前设备、网络与 429 状态自适应;生效策略和请求/性能记录不会从文件导入。API Key、WebDAV 用户名和密码不会从文件导入;仅复用本机同地址已有凭据,新 WebDAV 地址会关闭定时同步。浏览历史、阅读队列和缓存不会改变。" : "这是旧版配置,只覆盖阅读器偏好(含性能目标);性能目标会立即应用并继续自适应,生效策略和请求/性能记录不会从文件导入。其他适用站点、翻译和 WebDAV 设置保持不变。浏览历史、阅读队列和缓存不会改变。",
confirmLabel: "导入设置",
tone: "primary",
icon: "upload"
}) || this.scope.destroyed) return;
this.#status.textContent = "正在导入设置…", this.#setConfigStatus("正在导入设置…");
try {
const result = await configuration.apply(imported), skipped = result.skippedSections.length ? `;当前环境跳过 ${result.skippedSections.join("、")}` : "", autoSync = result.webDavAutoSyncDisabled ? ";WebDAV 缺少本机同地址凭据,定时同步已关闭" : "";
this.#setConfigStatus(
`导入完成,已应用 ${result.settingsCount} 项偏好(性能项为目标值)${skipped}${autoSync}。`
), this.#options.notify?.("设置配置已导入");
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus(
cause instanceof Error && cause.message.includes("回滚不完整") ? "导入失败,部分设置可能未恢复;请重新打开设置逐项核对。" : "导入失败,当前设置保持不变。"
);
}
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus("无法确认导入,当前设置保持不变。");
} finally {
this.#setBusy(!1);
}
}
}
async #resetConfiguration() {
const configuration = this.#options.configuration;
if (!(!configuration || this.#busy)) {
this.#setBusy(!0);
try {
if (!await configuration.confirm({
title: "恢复全部默认设置?",
message: "当前偏好(含性能目标)、其他适用站点、翻译和 WebDAV 设置都会恢复默认。",
note: "性能项恢复推荐目标,运行时仍会自适应;请求/性能记录不会被删除。翻译 API Key、WebDAV 用户名和密码会从本机设置中清除;阅读队列图标位置会恢复默认,队列条目、浏览历史、帖子缓存和账号数据不会被删除。",
confirmLabel: "恢复全部默认",
tone: "danger",
icon: "rotate-ccw"
}) || this.scope.destroyed) return;
this.#status.textContent = "正在恢复默认设置…", this.#setConfigStatus("正在恢复默认设置…"), await configuration.reset(), this.#setConfigStatus(
"全部设置已恢复默认;性能项将继续按运行环境自适应。"
), this.#options.notify?.("全部设置已恢复默认");
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus(
cause instanceof Error && cause.message.includes("回滚不完整") ? "恢复失败,部分设置可能未恢复;请重新打开设置逐项核对。" : "恢复失败,当前设置保持不变。"
);
} finally {
this.#setBusy(!1);
}
}
}
#setBusy(busy, status = "") {
this.#busy = busy;
for (const action of this.#configActions) action.disabled = busy;
this.#configFile && (this.#configFile.disabled = busy), this.#clear.disabled = busy || ![...this.#selects.values()].some((input) => input.checked), status && (this.#status.textContent = status), this.sync();
}
#syncButtons() {
this.#clear.disabled = this.#busy || ![...this.#selects.values()].some((input) => input.checked);
}
#setConfigStatus(message) {
this.#configStatus && (this.#configStatus.textContent = message);
}
}
}, "495aff91d326a0cfaa60ffdc9717b3443671fdf4a8c740b1dabcb75ba0c43caa");
/* Source: lite/src/cache/reader-collection-page-repository.ts */
runtime.register("src/cache/reader-collection-page-repository.js", function(module, exports, require) {
var reader_collection_page_repository_exports = {};
__export(reader_collection_page_repository_exports, {
ReaderCollectionPageRepository: () => ReaderCollectionPageRepository
});
module.exports = __toCommonJS(reader_collection_page_repository_exports);
function requiredToken(value, name) {
const normalized = String(value).trim();
if (!normalized) throw new Error(`${name} 不能为空`);
return encodeURIComponent(normalized);
}
function safeInteger(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : null;
}
function positiveSafeInteger(value) {
const numeric = safeInteger(value);
return numeric !== null && numeric > 0 ? numeric : null;
}
function yieldMainThread() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
class ReaderCollectionPageRepository {
#responses;
#scope;
#namespace;
#kind;
#tags;
#normalizeRecord;
#sortRecords;
#mergeRecord;
#pageSize;
#retainForMs;
#permanent;
#coordination;
#writes = /* @__PURE__ */ new Map();
#generationNonce = Math.random().toString(36).slice(2);
#generation = 0;
constructor(options) {
if (this.#responses = options.responses, this.#scope = requiredToken(options.authScope, "集合投影 authScope"), this.#namespace = requiredToken(options.namespace, "集合投影 namespace"), this.#kind = String(options.kind).trim(), !this.#kind) throw new Error("集合投影 kind 不能为空");
if (this.#tags = Object.freeze([...new Set(options.tags.map(String).map((tag) => tag.trim()).filter(Boolean))]), this.#normalizeRecord = options.normalizeRecord, this.#sortRecords = options.sortRecords, this.#mergeRecord = options.mergeRecord ?? ((_stored, incoming) => incoming), this.#pageSize = Math.floor(Number(options.pageSize ?? 60)), !Number.isSafeInteger(this.#pageSize) || this.#pageSize < 1)
throw new RangeError("集合投影 pageSize 必须是正安全整数");
if (this.#retainForMs = Number(
options.retainForMs ?? 15552e6
), !Number.isFinite(this.#retainForMs) || this.#retainForMs <= 0)
throw new RangeError("集合投影 retainForMs 必须是正有限数值");
this.#permanent = options.permanent === !0, this.#coordination = options.coordination;
}
async read(partitionValue, options = {}) {
const partition = requiredToken(partitionValue, "集合投影 partition"), manifestPolicy = this.#policy(partition, "manifest");
options.fresh && this.#responses.forgetMemory({ ids: [manifestPolicy.id] });
const cached = await this.#responses.read(
manifestPolicy
), manifest = this.#manifest(cached.value, partition);
if (!manifest) return null;
const records = [], identities = /* @__PURE__ */ new Set();
for (let start = 0; start < manifest.pages; start += 6) {
const pages = await Promise.all(Array.from(
{ length: Math.min(6, manifest.pages - start) },
(_, offset) => this.#readPage(
manifest,
partition,
start + offset,
options.fresh === !0
)
));
for (const page of pages) {
if (!page) return null;
for (const record of page.records) {
if (identities.has(record.identity)) return null;
identities.add(record.identity), records.push(record);
}
}
start + 6 < manifest.pages && await yieldMainThread();
}
return records.length !== manifest.total ? null : Object.freeze({
records: Object.freeze([...this.#sortRecords(records)]),
totalHint: Math.max(manifest.total, manifest.totalHint),
complete: manifest.complete,
updatedAt: manifest.updatedAt,
...manifest.sourceNextPage === void 0 ? {} : { sourceNextPage: manifest.sourceNextPage },
...manifest.sourcePageSize === void 0 ? {} : { sourcePageSize: manifest.sourcePageSize },
...manifest.sourceOffset === void 0 ? {} : { sourceOffset: manifest.sourceOffset }
});
}
write(partitionValue, records, options = {}) {
const partition = requiredToken(partitionValue, "集合投影 partition"), queued = (this.#writes.get(partition) ?? Promise.resolve()).catch(() => {
}).then(() => this.#withWriteLease(
partition,
() => this.#commit(partition, records, options)
));
return this.#writes.set(partition, queued), queued.finally(() => {
this.#writes.get(partition) === queued && this.#writes.delete(partition);
}).catch(() => {
}), queued;
}
async #commit(partition, incoming, options) {
const manifestPolicy = this.#policy(partition, "manifest");
this.#responses.forgetMemory({ ids: [manifestPolicy.id] });
const previousRead = await this.#responses.read(
manifestPolicy
), previousManifest = this.#manifest(previousRead.value, partition), previous = options.mergeStored === !1 ? null : await this.read(decodeURIComponent(partition), { fresh: !0 }), merged = /* @__PURE__ */ new Map();
for (const record of previous?.records ?? [])
merged.set(record.identity, record);
for (const value of incoming) {
const record = this.#normalizeRecord(value);
if (!record?.identity) continue;
const stored = merged.get(record.identity);
merged.set(
record.identity,
stored ? this.#mergeRecord(stored, record) : record
);
}
const records = Object.freeze([...this.#sortRecords([...merged.values()])]), updatedAt = Math.max(
0,
Math.floor(Number(options.updatedAt ?? Date.now()) || 0)
), replaceCheckpoint = options.checkpointMode === "replace", requestedSourceNextPageValue = options.sourceNextPage === void 0 ? previousManifest?.sourceNextPage : safeInteger(options.sourceNextPage);
if (requestedSourceNextPageValue === null)
throw new RangeError("集合投影 sourceNextPage 必须是非负安全整数");
const requestedSourceNextPage = replaceCheckpoint ? requestedSourceNextPageValue : previousManifest?.sourceNextPage === void 0 && requestedSourceNextPageValue === void 0 ? void 0 : Math.max(
previousManifest?.sourceNextPage ?? 0,
requestedSourceNextPageValue ?? 0
), requestedSourcePageSize = options.sourcePageSize === void 0 ? previousManifest?.sourcePageSize : positiveSafeInteger(options.sourcePageSize);
if (requestedSourcePageSize === null)
throw new RangeError("集合投影 sourcePageSize 必须是正安全整数");
const requestedSourceOffsetValue = options.sourceOffset === void 0 ? previousManifest?.sourceOffset : safeInteger(options.sourceOffset);
if (requestedSourceOffsetValue === null)
throw new RangeError("集合投影 sourceOffset 必须是非负安全整数");
const previousSourceNextPage = previousManifest?.sourceNextPage, previousSourceOffset = previousManifest?.sourceOffset;
let requestedSourceOffset;
replaceCheckpoint ? requestedSourceOffset = requestedSourceOffsetValue : options.sourceOffset === void 0 ? requestedSourceOffset = previousSourceOffset : previousSourceOffset === void 0 ? requestedSourceOffset = requestedSourceOffsetValue : requestedSourceNextPageValue !== void 0 && previousSourceNextPage !== void 0 && requestedSourceNextPageValue !== previousSourceNextPage ? requestedSourceOffset = requestedSourceNextPageValue > previousSourceNextPage ? requestedSourceOffsetValue : previousSourceOffset : requestedSourceOffset = options.sourceOffsetOrder === "descending" ? Math.min(previousSourceOffset, requestedSourceOffsetValue ?? 0) : Math.max(previousSourceOffset, requestedSourceOffsetValue ?? 0);
const generation = `${updatedAt.toString(36)}-${this.#generationNonce}-${(++this.#generation).toString(36)}`, pages = Math.ceil(records.length / this.#pageSize);
for (let start = 0; start < pages; start += 6)
await Promise.all(Array.from(
{ length: Math.min(6, pages - start) },
(_, offset) => {
const page = start + offset;
return this.#responses.write(
this.#policy(partition, "page", page, generation),
Object.freeze({
schemaVersion: 1,
generation,
page,
records: Object.freeze(records.slice(
page * this.#pageSize,
(page + 1) * this.#pageSize
))
}),
{ publish: !1 }
);
}
)), start + 6 < pages && await yieldMainThread();
await this.#responses.write(
this.#policy(partition, "manifest"),
Object.freeze({
schemaVersion: 1,
partition,
generation,
pageSize: this.#pageSize,
total: records.length,
totalHint: Math.max(
records.length,
Math.floor(Number(options.totalHint) || 0)
),
pages,
complete: options.checkpointMode === "advance" && previousManifest?.complete === !0 || options.complete === !0,
updatedAt,
...requestedSourceNextPage === void 0 ? {} : { sourceNextPage: requestedSourceNextPage },
...requestedSourcePageSize === void 0 ? {} : { sourcePageSize: requestedSourcePageSize },
...requestedSourceOffset === void 0 ? {} : { sourceOffset: requestedSourceOffset }
})
), previousManifest && previousManifest.generation !== generation && previousManifest.pages > 0 && await this.#responses.prune({
ids: Object.freeze(Array.from(
{ length: previousManifest.pages },
(_, page) => this.#policy(
partition,
"page",
page,
previousManifest.generation
).id
))
});
}
#manifest(value, partition) {
if (!value || typeof value != "object" || Array.isArray(value)) return null;
const source = value, total = safeInteger(source.total), totalHint = safeInteger(source.totalHint), pages = safeInteger(source.pages), updatedAt = safeInteger(source.updatedAt), sourceNextPage = source.sourceNextPage === void 0 ? void 0 : safeInteger(source.sourceNextPage), sourcePageSize = source.sourcePageSize === void 0 ? void 0 : positiveSafeInteger(source.sourcePageSize), sourceOffset = source.sourceOffset === void 0 ? void 0 : safeInteger(source.sourceOffset);
return source.schemaVersion !== 1 || source.partition !== partition || typeof source.generation != "string" || !source.generation || source.pageSize !== this.#pageSize || total === null || totalHint === null || pages === null || updatedAt === null || sourceNextPage === null || sourcePageSize === null || sourceOffset === null || typeof source.complete != "boolean" || pages !== Math.ceil(total / this.#pageSize) ? null : Object.freeze({
schemaVersion: 1,
partition,
generation: source.generation,
pageSize: this.#pageSize,
total,
totalHint: Math.max(total, totalHint),
pages,
complete: source.complete,
updatedAt,
...sourceNextPage === void 0 ? {} : { sourceNextPage },
...sourcePageSize === void 0 ? {} : { sourcePageSize },
...sourceOffset === void 0 ? {} : { sourceOffset }
});
}
async #withWriteLease(partition, operation) {
const coordination = this.#coordination;
if (!coordination) {
await operation();
return;
}
const token = `reader-collection-projection-write:v1:${this.#policy(partition, "manifest").id}`;
for (; ; ) {
const lease = await coordination.acquireFlight(token);
if (!lease.producer) {
await coordination.waitForFlight(token);
continue;
}
const heartbeat = lease.coordinated ? setInterval(() => {
coordination.renewFlight(lease).catch(() => {
});
}, 1e4) : null;
try {
await operation();
return;
} finally {
heartbeat !== null && clearInterval(heartbeat), await coordination.releaseFlight(lease);
}
}
}
async #readPage(manifest, partition, page, fresh = !1) {
const policy = this.#policy(
partition,
"page",
page,
manifest.generation
);
fresh && this.#responses.forgetMemory({ ids: [policy.id] });
const value = (await this.#responses.read(
policy
)).value;
if (!value || value.schemaVersion !== 1 || value.generation !== manifest.generation || value.page !== page || !Array.isArray(value.records)) return null;
const records = [];
for (const candidate of value.records) {
const record = this.#normalizeRecord(candidate);
if (!record?.identity) return null;
records.push(record);
}
const expected = Math.min(
this.#pageSize,
Math.max(0, manifest.total - page * this.#pageSize)
);
return records.length !== expected ? null : Object.freeze({
schemaVersion: 1,
generation: manifest.generation,
page,
records: Object.freeze(records)
});
}
#policy(partition, part, page = 0, generation = "") {
const id = part === "manifest" ? `reader-collection-projection:${this.#namespace}:manifest:v1:${this.#scope}:${partition}` : `reader-collection-projection:${this.#namespace}:page:v1:${this.#scope}:${partition}:${encodeURIComponent(generation)}:${page}`;
return Object.freeze({
id,
kind: this.#kind,
tags: this.#tags,
freshForMs: this.#retainForMs,
retainForMs: this.#retainForMs,
persist: !0,
...this.#permanent ? { permanent: !0 } : {}
});
}
}
}, "2c54916ac80b81ab088a90cd4e2a7c65fb2e83b6fe617fd31092c7c64abe1452");
/* Source: lite/src/cache/response-repository.ts */
runtime.register("src/cache/response-repository.js", function(module, exports, require) {
var response_repository_exports = {};
__export(response_repository_exports, {
ResponseCacheFlightTimeoutError: () => ResponseCacheFlightTimeoutError,
ResponseCacheInvalidationError: () => ResponseCacheInvalidationError,
ResponseCacheSharedFlightFailureError: () => ResponseCacheSharedFlightFailureError,
ResponseRepository: () => ResponseRepository
});
module.exports = __toCommonJS(response_repository_exports);
var import_cache_identity = require("./cache-identity.js");
class ResponseCacheInvalidationError extends AggregateError {
report;
constructor(report) {
super(
report.failures.map((failure) => failure.cause),
"响应缓存失效未完整提交"
), this.name = "ResponseCacheInvalidationError", this.report = report;
}
}
class ResponseCacheSharedFlightFailureError extends Error {
status;
cloudflareMitigated;
kind;
sharedFlight = !0;
constructor(failure) {
super(
failure.kind === "cloudflare" ? "同一在途请求已遇到 Cloudflare 验证" : "同一在途请求已收到 429"
), this.name = "ResponseCacheSharedFlightFailureError", this.status = failure.status, this.cloudflareMitigated = failure.cloudflareMitigated, this.kind = failure.kind;
}
}
function sharedFlightFailure(error) {
if (!error || typeof error != "object") return null;
const candidate = error, status = Number(candidate.status ?? 0);
return candidate.cloudflareMitigated === !0 ? Object.freeze({
status: Number.isSafeInteger(status) && status >= 400 ? status : 403,
cloudflareMitigated: !0,
kind: "cloudflare"
}) : status === 429 ? Object.freeze({
status: 429,
cloudflareMitigated: !1,
kind: "rate-limit"
}) : null;
}
function nonNegativeFinite(value, name) {
if (!Number.isFinite(value) || value < 0)
throw new RangeError(`${name} 必须是非负有限数值`);
return value;
}
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function normalizePolicy(policy) {
const id = String(policy.id).trim(), kind = String(policy.kind).trim();
if (!id) throw new Error("cache id 不能为空");
if (!kind) throw new Error("cache kind 不能为空");
const freshForMs = nonNegativeFinite(policy.freshForMs, "freshForMs"), retainForMs = nonNegativeFinite(policy.retainForMs, "retainForMs");
if (retainForMs < freshForMs) throw new RangeError("retainForMs 不能小于 freshForMs");
return Object.freeze({
id,
kind,
tags: Object.freeze(
[...new Set(policy.tags.map(String).map((tag) => tag.trim()).filter(Boolean))].sort()
),
freshForMs,
retainForMs,
persist: policy.persist,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
}
function defaultEstimateBytes(value) {
if (value !== null && typeof value == "object" && (typeof Blob < "u" && value instanceof Blob || Object.prototype.toString.call(value) === "[object Blob]")) {
const size = Number(value.size);
if (Number.isFinite(size) && size >= 0) return size;
}
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value.byteLength;
try {
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
} catch {
return 0;
}
}
function matchesInvalidation(entry, query) {
return !!(query.all || query.ids?.includes(entry.id) || query.kinds?.includes(entry.kind) || query.tags?.some((tag) => entry.tags.includes(tag)));
}
class ResponseRepository {
#store;
#maxMemoryEntries;
#maxMemoryBytes;
#now;
#estimateBytes;
#mutationPort;
#flightPort;
#flightHeartbeatMs;
#flightWaitTimeoutMs;
#onPersistenceError;
#memory = /* @__PURE__ */ new Map();
#inflight = /* @__PURE__ */ new Map();
#writes = /* @__PURE__ */ new Map();
#epoch = 0;
constructor(options) {
this.#store = options.store, this.#maxMemoryEntries = positiveInteger(options.maxMemoryEntries, "maxMemoryEntries"), this.#maxMemoryBytes = positiveInteger(options.maxMemoryBytes, "maxMemoryBytes"), this.#now = options.now ?? Date.now, this.#estimateBytes = options.estimateBytes ?? defaultEstimateBytes, this.#mutationPort = options.mutationPort, this.#flightPort = options.flightPort, this.#flightHeartbeatMs = positiveInteger(
options.flightHeartbeatMs ?? 15e3,
"flightHeartbeatMs"
), this.#flightWaitTimeoutMs = positiveInteger(
options.flightWaitTimeoutMs ?? 65e3,
"flightWaitTimeoutMs"
), this.#onPersistenceError = options.onPersistenceError ?? (() => {
});
}
async read(rawPolicy) {
const policy = normalizePolicy(rawPolicy);
let entry = this.#memory.get(policy.id);
if (entry)
this.#memory.delete(policy.id), this.#memory.set(policy.id, entry);
else {
const epoch = this.#epoch;
try {
entry = await this.#store.read(policy.id) ?? void 0;
} catch (error) {
this.#onPersistenceError(error);
}
if (epoch !== this.#epoch)
entry = void 0;
else if (entry && this.#validEntry(entry, policy))
this.#remember(entry);
else {
if (entry)
try {
await this.#store.invalidate({ ids: [policy.id] });
} catch (error) {
this.#onPersistenceError(error);
}
entry = void 0;
}
}
if (!entry || !this.#validEntry(entry, policy))
return Object.freeze({ state: "miss" });
const age = Math.max(0, this.#now() - entry.storedAt);
return entry.permanent !== !0 && (age > policy.retainForMs || entry.expiresAt <= this.#now()) ? (await this.#invalidateWithReport({ ids: [policy.id] }, !0), Object.freeze({ state: "miss" })) : Object.freeze({
state: age <= policy.freshForMs ? "fresh" : "stale",
value: entry.value,
storedAt: entry.storedAt
});
}
/**
* 只从持久层读取,不接受当前标签页的 memory LRU 回退。
*
* 下载存档等用户明确保留的数据必须用它确认 IndexedDB 已真正提交;
* 普通响应仍使用 read() 的容错路径,持久层故障不会扩大成正文请求失败。
*/
async readPersistent(rawPolicy) {
const policy = normalizePolicy(rawPolicy), pending = this.#writes.get(policy.id);
pending && await pending;
let entry;
try {
entry = await this.#store.read(policy.id);
} catch (error) {
throw this.#onPersistenceError(error), error;
}
if (!entry || !this.#validEntry(entry, policy))
return Object.freeze({ state: "miss" });
const age = Math.max(0, this.#now() - entry.storedAt);
return entry.permanent !== !0 && (age > policy.retainForMs || entry.expiresAt <= this.#now()) ? Object.freeze({ state: "miss" }) : Object.freeze({
state: age <= policy.freshForMs ? "fresh" : "stale",
value: entry.value,
storedAt: entry.storedAt
});
}
getOrLoad(rawPolicy, loader, options = {}) {
const policy = normalizePolicy(rawPolicy);
options.signal?.throwIfAborted();
const cacheMode = options.cacheMode ?? "default", requestKey = `${cacheMode}:${policy.id}`, existing = this.#inflight.get(requestKey);
if (existing && !existing.controller.signal.aborted)
return this.#join(existing, options.signal);
const controller = new AbortController(), inflight = {
promise: Promise.resolve(void 0),
policy,
controller,
consumers: /* @__PURE__ */ new Set(),
unabortableConsumer: !1,
settled: !1
}, promise = this.#load(
policy,
loader,
{ ...options, signal: controller.signal },
cacheMode
).finally(() => {
inflight.settled = !0;
});
inflight.promise = promise, this.#inflight.set(requestKey, inflight);
const clearInflight = () => {
this.#inflight.get(requestKey)?.promise === promise && this.#inflight.delete(requestKey);
};
return promise.then(clearInflight, clearInflight), this.#join(inflight, options.signal);
}
#join(inflight, signal) {
if (!signal)
return inflight.unabortableConsumer = !0, inflight.promise;
if (signal.aborted) return Promise.reject(signal.reason);
const consumer = Symbol("cache-consumer");
return inflight.consumers.add(consumer), new Promise((resolve, reject) => {
let settled = !1;
const finish = (value) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), inflight.consumers.delete(consumer), resolve(value));
}, fail = (cause) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), inflight.consumers.delete(consumer), reject(cause));
}, onAbort = () => {
fail(signal.reason), !inflight.settled && !inflight.unabortableConsumer && inflight.consumers.size === 0 && !inflight.controller.signal.aborted && inflight.controller.abort(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: !0 }), inflight.promise.then(finish, fail);
});
}
async write(rawPolicy, value, options = {}) {
const policy = normalizePolicy(rawPolicy), storedAt = Math.max(
this.#now(),
(this.#memory.get(policy.id)?.storedAt ?? -1) + 1
), entry = Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : storedAt + policy.retainForMs,
bytes: Math.max(0, this.#estimateBytes(value)),
value,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
if (this.#remember(entry), !policy.persist) return;
const previous = this.#writes.get(policy.id) ?? Promise.resolve();
let persisted = !1;
const write = previous.catch(() => {
}).then(async () => {
await this.#store.write(entry), persisted = !0;
}).catch((error) => {
this.#onPersistenceError(error);
});
if (this.#writes.set(policy.id, write), await write, persisted && options.publish !== !1)
try {
await this.#mutationPort?.publish({ ids: [policy.id] });
} catch (error) {
this.#onPersistenceError(error);
}
this.#writes.get(policy.id) === write && this.#writes.delete(policy.id);
}
/** WebDAV 等受控迁移入口:仅在远端版本更新时保留原 storedAt 写回。 */
async restore(rawPolicy, value, storedAtValue, options = {}) {
const policy = normalizePolicy(rawPolicy), storedAt = Math.max(0, Math.floor(storedAtValue));
if (!Number.isSafeInteger(storedAt) || policy.permanent !== !0 && storedAt + policy.retainForMs <= this.#now() || ((await this.read(policy)).storedAt ?? -1) >= storedAt) return;
const entry = Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : storedAt + policy.retainForMs,
bytes: Math.max(0, this.#estimateBytes(value)),
value,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
if (this.#remember(entry), !policy.persist) return;
const previous = this.#writes.get(policy.id) ?? Promise.resolve();
let persisted = !1;
const write = previous.catch(() => {
}).then(async () => {
await this.#store.write(entry), persisted = !0;
}).catch((error) => {
this.#onPersistenceError(error);
});
if (this.#writes.set(policy.id, write), await write, persisted && options.publish !== !1)
try {
await this.#mutationPort?.publish({ ids: [policy.id] });
} catch (error) {
this.#onPersistenceError(error);
}
this.#writes.get(policy.id) === write && this.#writes.delete(policy.id);
}
async merge(rawPolicy, incoming, mergeValues) {
const policy = normalizePolicy(rawPolicy), memory = this.#memory.get(policy.id), localValue = !this.#store.merge && memory && this.#validEntry(memory, policy) ? mergeValues(memory.value, incoming) : incoming;
let committed = Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt: Math.max(this.#now(), (memory?.storedAt ?? -1) + 1),
expiresAt: 0,
bytes: Math.max(0, this.#estimateBytes(localValue)),
value: localValue,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
if (committed = Object.freeze({
...committed,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : committed.storedAt + policy.retainForMs
}), this.#remember(committed), !policy.persist) return committed.value;
const previous = this.#writes.get(policy.id) ?? Promise.resolve();
let persisted = !1;
const write = previous.catch(() => {
}).then(async () => {
if (this.#store.merge) {
const merged = await this.#store.merge(
policy.id,
(current) => {
const value = current && this.#validEntry(current, policy) ? mergeValues(current.value, committed.value) : committed.value, storedAt = Math.max(
this.#now(),
(current?.storedAt ?? -1) + 1,
committed.storedAt
);
return Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : storedAt + policy.retainForMs,
bytes: Math.max(0, this.#estimateBytes(value)),
value,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
}
);
if (!merged) return;
committed = merged;
} else
await this.#store.write(committed);
persisted = !0;
}).catch((error) => {
this.#onPersistenceError(error);
});
if (this.#writes.set(policy.id, write), await write, persisted)
try {
await this.#mutationPort?.publish({ ids: [policy.id] });
} catch (error) {
this.#onPersistenceError(error);
}
return this.#writes.get(policy.id) === write && (this.#writes.delete(policy.id), persisted && this.#remember(committed)), committed.value;
}
async invalidate(query, publish = !0) {
const report = await this.#invalidateWithReport(query, publish);
if (!report.complete) throw new ResponseCacheInvalidationError(report);
}
/**
* 删除只由当前 owner 判定为不可达的内部 generation。不广播、不提升全局 epoch、
* 不撤销其他 cache flight;不得用于用户可见缓存清理或领域失效。
*/
async prune(query) {
for (const [id, entry] of this.#memory)
matchesInvalidation(entry, query) && this.#memory.delete(id);
this.#writes.size && await Promise.all(this.#writes.values());
const result = await this.#store.invalidate(query);
if (result && !result.ok)
throw result.error ?? new Error("响应缓存内部 generation 修剪失败");
}
/**
* 只忘记当前标签页的内存副本,让 owner 重新读取共享持久缓存。
* 不删除 IndexedDB、不广播,也不提升全局 epoch。
*/
forgetMemory(query) {
let removed = 0;
for (const [id, entry] of this.#memory)
matchesInvalidation(entry, query) && (this.#memory.delete(id), removed += 1);
return removed;
}
async invalidateWithReport(query, publish = !0) {
return this.#invalidateWithReport(query, publish);
}
async #invalidateWithReport(query, publish) {
this.#epoch += 1;
let memoryEntries = 0;
for (const [id, entry] of this.#memory)
matchesInvalidation(entry, query) && (this.#memory.delete(id), memoryEntries += 1);
this.#writes.size && await Promise.all(this.#writes.values());
const failures = [];
try {
await this.#flightPort?.invalidateWrites();
} catch (error) {
this.#onPersistenceError(error), failures.push(Object.freeze({ stage: "flight", cause: error }));
}
try {
const result = await this.#store.invalidate(query);
result && !result.ok && failures.push(Object.freeze({
stage: "store",
cause: result.error ?? new Error("持久响应缓存失效失败")
}));
} catch (error) {
this.#onPersistenceError(error), failures.push(Object.freeze({ stage: "store", cause: error }));
}
if (publish)
try {
await this.#mutationPort?.publish(query);
} catch (error) {
this.#onPersistenceError(error), failures.push(Object.freeze({ stage: "broadcast", cause: error }));
}
return Object.freeze({
memoryEntries,
failures: Object.freeze(failures),
complete: failures.length === 0
});
}
applyExternalInvalidation(query) {
this.#epoch += 1;
for (const [id, entry] of this.#memory)
matchesInvalidation(entry, query) && this.#memory.delete(id);
}
memoryStats() {
let bytes = 0;
for (const entry of this.#memory.values()) bytes += entry.bytes;
return Object.freeze({ entries: this.#memory.size, bytes });
}
async records() {
this.#writes.size && await Promise.all(this.#writes.values());
let persistent = [];
try {
persistent = await this.#store.records?.() ?? [];
} catch (error) {
throw this.#onPersistenceError(error), error;
}
const records = new Map(
persistent.map((entry) => [entry.id, entry])
);
for (const entry of this.#memory.values())
records.set(entry.id, Object.freeze({
id: entry.id,
kind: entry.kind,
tags: entry.tags,
storedAt: entry.storedAt,
expiresAt: entry.expiresAt,
bytes: entry.bytes,
...entry.permanent === !0 ? { permanent: !0 } : {}
}));
return Object.freeze([...records.values()]);
}
/**
* 仅供受控的数据迁移 owner 导出仍在保留期内的完整缓存记录。
* UI 目录继续使用 records(),避免普通调用方接触响应正文。
*/
async entries(query) {
this.#writes.size && await Promise.all(this.#writes.values());
let persistent = [];
try {
persistent = await this.#store.snapshotEntries?.() ?? [];
} catch (error) {
throw this.#onPersistenceError(error), error;
}
const now = this.#now(), entries = /* @__PURE__ */ new Map();
for (const entry of persistent)
entry.schemaVersion !== 1 || !matchesInvalidation(entry, query) || !Number.isFinite(entry.expiresAt) || entry.permanent !== !0 && entry.expiresAt <= now || entries.set(entry.id, entry);
for (const entry of this.#memory.values())
!matchesInvalidation(entry, query) || entry.permanent !== !0 && entry.expiresAt <= now || entries.set(entry.id, entry);
return Object.freeze([...entries.values()].sort((left, right) => right.storedAt - left.storedAt || left.id.localeCompare(right.id)).map((entry) => Object.freeze({
...entry,
tags: Object.freeze([...entry.tags])
})));
}
#validEntry(entry, policy) {
const tags = Array.isArray(entry.tags) && entry.tags.every((tag) => typeof tag == "string") ? [...new Set(entry.tags)].sort() : null;
return entry.schemaVersion === 1 && entry.id === policy.id && entry.kind === policy.kind && Number.isFinite(entry.storedAt) && entry.storedAt >= 0 && Number.isFinite(entry.expiresAt) && entry.expiresAt >= entry.storedAt && Number.isFinite(entry.bytes) && entry.bytes >= 0 && entry.permanent === !0 == (policy.permanent === !0) && tags !== null && tags.length === policy.tags.length && tags.every((tag, index) => tag === policy.tags[index]);
}
async #load(policy, loader, options, cacheMode) {
const throwIfAborted = () => options.signal?.throwIfAborted(), cached = cacheMode === "no-store" ? Object.freeze({ state: "miss" }) : await this.read(policy);
if (throwIfAborted(), cacheMode === "default" && cached.state === "fresh") return cached.value;
const cachedBeforeRequest = cached.storedAt ?? 0, flightToken = this.#flightPort && policy.persist && cacheMode !== "no-store" ? `v1:${(0, import_cache_identity.sharedCacheIdToken)(policy.id)}:${policy.id.length}` : "";
let lease = null, heartbeat = null;
try {
if (flightToken && this.#flightPort) {
const deadline = this.#now() + this.#flightWaitTimeoutMs;
for (; lease = await this.#flightPort.acquireFlight(flightToken), throwIfAborted(), !lease.producer; ) {
const released = await this.#flightPort.waitForFlight(
flightToken,
options.signal,
deadline
);
if (throwIfAborted(), !released) throw new ResponseCacheFlightTimeoutError();
const shared2 = await this.#readAfterFlight(
policy,
cacheMode,
cachedBeforeRequest
);
if (throwIfAborted(), shared2.state === "hit") return shared2.value;
const failure = await this.#flightPort.readFlightFailure(lease);
if (throwIfAborted(), failure) throw new ResponseCacheSharedFlightFailureError(failure);
}
const shared = await this.#readAfterFlight(
policy,
cacheMode,
cachedBeforeRequest
);
if (throwIfAborted(), shared.state === "hit") return shared.value;
if (lease.coordinated) {
const producerLease = lease;
heartbeat = setInterval(() => {
this.#flightPort?.renewFlight(producerLease).then((renewed) => {
!renewed && heartbeat !== null && (clearInterval(heartbeat), heartbeat = null);
}).catch((error) => {
this.#onPersistenceError(error), heartbeat !== null && (clearInterval(heartbeat), heartbeat = null);
});
}, this.#flightHeartbeatMs);
}
}
const epoch = this.#epoch, value = await loader(options.signal);
return throwIfAborted(), cacheMode !== "no-store" && epoch === this.#epoch && (lease && this.#flightPort ? await this.#flightPort.commitFlight(lease, () => this.write(policy, value)) : await this.write(policy, value)), value;
} catch (error) {
throwIfAborted();
const failure = lease?.producer ? sharedFlightFailure(error) : null;
if (failure && this.#flightPort)
try {
await this.#flightPort.failFlight(lease, failure);
} catch (coordinationError) {
this.#onPersistenceError(coordinationError);
}
const canFallback = options.canFallback?.(error) ?? !0;
if (options.allowStaleOnError !== !1 && cached.state === "stale" && canFallback) {
const value = cached.value;
return options.mapStaleFallback ? options.mapStaleFallback(value, error) : value;
}
throw error;
} finally {
if (heartbeat !== null && clearInterval(heartbeat), lease?.producer && this.#flightPort)
try {
await this.#flightPort.releaseFlight(lease);
} catch (error) {
this.#onPersistenceError(error);
}
}
}
async #readAfterFlight(policy, cacheMode, cachedBeforeRequest) {
this.#memory.delete(policy.id);
const latest = await this.read(policy);
return latest.state === "miss" ? Object.freeze({ state: "miss" }) : cacheMode === "refresh" ? (latest.storedAt ?? 0) > cachedBeforeRequest ? Object.freeze({ state: "hit", value: latest.value }) : Object.freeze({ state: "miss" }) : latest.state === "fresh" ? Object.freeze({ state: "hit", value: latest.value }) : Object.freeze({ state: "miss" });
}
#remember(entry) {
if (this.#memory.delete(entry.id), entry.permanent === !0 && entry.bytes > this.#maxMemoryBytes)
return;
this.#memory.set(entry.id, entry);
let bytes = 0;
for (const value of this.#memory.values()) bytes += value.bytes;
for (; this.#memory.size > this.#maxMemoryEntries || bytes > this.#maxMemoryBytes && this.#memory.size > 1; ) {
let evictedId;
for (const [id, value] of this.#memory)
if (value.permanent === !0) {
evictedId = id;
break;
}
if (evictedId ??= this.#memory.keys().next().value, evictedId === void 0) break;
const oldest = this.#memory.get(evictedId);
this.#memory.delete(evictedId), bytes -= oldest?.bytes ?? 0;
}
}
}
class ResponseCacheFlightTimeoutError extends Error {
constructor() {
super("等待共享缓存请求超时"), this.name = "TimeoutError";
}
}
}, "4071456c5d0195d1a502791abf8f9b0f4ee8e2fa3bcdc9edaaa347bb394eedc2");
/* Source: lite/src/cache/topic-snapshot-repository.ts */
runtime.register("src/cache/topic-snapshot-repository.js", function(module, exports, require) {
var topic_snapshot_repository_exports = {};
__export(topic_snapshot_repository_exports, {
TopicSnapshotRepository: () => TopicSnapshotRepository
});
module.exports = __toCommonJS(topic_snapshot_repository_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_ingest_version = require("../discourse/ingest-version.js"), import_reply_tree = require("../dom/reply-tree.js");
function nonNegativeInteger(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return numeric;
}
function finiteTimestamp(value, name) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负有限时间戳`);
return numeric;
}
function localArchiveStatus(value) {
const status = Number(value);
if (status !== 403 && status !== 404 && status !== 410)
throw new RangeError("本地存档状态必须是 403、404 或 410");
return status;
}
function normalizeStreamPostIds(values) {
return (0, import_identifiers.discoursePostIdStream)(values ?? []);
}
function moderationHiddenPlaceholder(value) {
const cooked = String(value ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
return /社区举报.*临时隐藏/.test(cooked) || /flagged by the community.*temporarily hidden/i.test(cooked);
}
function mergePostEntity(current, incoming) {
if (!current || current === incoming) return incoming;
const currentRecord = current, incomingRecord = incoming, preserveVisibleCooked = incomingRecord.hidden === !0 && moderationHiddenPlaceholder(incomingRecord.cooked) && typeof currentRecord.cooked == "string" && currentRecord.cooked.trim().length > 0 && !moderationHiddenPlaceholder(currentRecord.cooked), merged = {
...currentRecord
};
for (const [key, value] of Object.entries(incomingRecord))
key === "cooked" && preserveVisibleCooked || value !== void 0 && (merged[key] = value);
return Object.freeze(merged);
}
function validTreeSnapshot(value, topicId, posts, removedPostNumbers, onInvalid) {
if (value == null) return null;
const candidate = value;
let valid = candidate.schemaVersion === 2 && candidate.topicId === topicId && Number.isFinite(candidate.savedAt) && Number.isSafeInteger(candidate.expectedPostCount) && Number(candidate.expectedPostCount) >= 0 && !!candidate.tree && Number.isSafeInteger(candidate.tree.revision) && Number(candidate.tree.revision) >= 0 && Array.isArray(candidate.tree.relations) && Array.isArray(candidate.versions) && (candidate.removedVersions === void 0 || Array.isArray(candidate.removedVersions));
if (valid) {
const relations = candidate.tree.relations, relationNumbers = new Set(relations.map((relation) => relation.postNumber)), versionNumbers = /* @__PURE__ */ new Set();
for (const version of candidate.versions) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(version?.postNumber);
if (postNumber === null || versionNumbers.has(postNumber) || !relationNumbers.has(postNumber) || !Number.isFinite(version.observedAt) || version.observedAt < 0 || (0, import_ingest_version.normalizeDiscourseIngestSource)(version.source) === null) {
valid = !1;
break;
}
versionNumbers.add(postNumber);
}
versionNumbers.size !== relationNumbers.size && (valid = !1);
const removedNumbers = /* @__PURE__ */ new Set();
for (const version of candidate.removedVersions ?? []) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(version?.postNumber);
if (postNumber === null || relationNumbers.has(postNumber) || removedNumbers.has(postNumber) || !Number.isFinite(version.observedAt) || version.observedAt < 0 || (0, import_ingest_version.normalizeDiscourseIngestSource)(version.source) === null) {
valid = !1;
break;
}
removedNumbers.add(postNumber);
}
}
if (valid)
try {
const topology = new import_reply_tree.ReplyTreeTopology();
topology.replace(candidate.tree);
for (const entry of posts) {
const rawParentPostNumber = entry.value.reply_to_post_number, parentPostNumber = rawParentPostNumber == null || rawParentPostNumber === "" ? null : (0, import_identifiers.tryDiscoursePostNumber)(rawParentPostNumber);
if (parentPostNumber === null && !(rawParentPostNumber == null || rawParentPostNumber === ""))
throw new Error(
`Topic ${topicId} 楼层 #${entry.postNumber} 的缓存父楼层无效`
);
const storedParentPostNumber = topology.parentOf(entry.postNumber), parentWasRemoved = parentPostNumber !== null && removedPostNumbers.has(parentPostNumber);
if (storedParentPostNumber === void 0 || storedParentPostNumber !== parentPostNumber && !parentWasRemoved)
throw new Error(
`Topic ${topicId} 楼层 #${entry.postNumber} 的正文与回复树缓存关系不一致`
);
}
return value;
} catch (error) {
return onInvalid(error), null;
}
return onInvalid(new Error(`Topic ${topicId} 的内嵌回复树快照无效`)), null;
}
class TopicSnapshotRepository {
topicId;
authScope;
#responses;
#policy;
#archivePolicy;
#now;
#persistenceIdleMs;
#persistenceWait;
#onInvalidSnapshot;
#onInvalidTreeSnapshot;
#posts = /* @__PURE__ */ new Map();
#removedPosts = /* @__PURE__ */ new Map();
#unavailablePosts = /* @__PURE__ */ new Map();
#topic = null;
#unavailableTopic = null;
#topicObservedAt = 0;
#topicSource = null;
#streamPostIds = Object.freeze([]);
#streamObservedAt = 0;
#expectedPostCount = 0;
#tree = null;
#updatedAt = 0;
#restorePromise = null;
#pendingWrite = !1;
#persisting = null;
#persistenceReadyAt = 0;
#readPersistenceDelayMs = () => 0;
#archiveRecordKnown = !1;
constructor(options) {
const topicId = String((0, import_identifiers.discourseTopicId)(options.topicId)), authScope = (0, import_identifiers.discourseAuthScope)(options.authScope);
if (this.topicId = topicId, this.authScope = authScope, this.#responses = options.responseRepository, this.#now = options.now ?? Date.now, this.#persistenceIdleMs = finiteTimestamp(
options.persistenceIdleMs ?? 0,
"persistenceIdleMs"
), this.#persistenceWait = options.persistenceWait ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), this.#onInvalidSnapshot = options.onInvalidSnapshot ?? (() => {
}), this.#onInvalidTreeSnapshot = options.onInvalidTreeSnapshot ?? (() => {
}), this.#policy = Object.freeze({
id: `${authScope}|snapshot:topic:${topicId}`,
kind: "topics",
tags: Object.freeze([`topic:${topicId}`]),
freshForMs: finiteTimestamp(options.freshForMs, "freshForMs"),
retainForMs: finiteTimestamp(options.retainForMs, "retainForMs"),
persist: !0
}), this.#archivePolicy = Object.freeze({
id: `${authScope}|snapshot:topic-archive:${topicId}`,
kind: "topics",
tags: Object.freeze([`topic:${topicId}`, "topic-local-archive"]),
freshForMs: finiteTimestamp(options.freshForMs, "freshForMs"),
retainForMs: finiteTimestamp(options.retainForMs, "retainForMs"),
persist: !0,
permanent: !0
}), this.#policy.retainForMs < this.#policy.freshForMs)
throw new RangeError("retainForMs 不能小于 freshForMs");
}
ingest(input) {
const observedAt = finiteTimestamp(input.observedAt ?? this.#now(), "observedAt");
let topicChanged = !1, streamChanged = !1, streamVersionAccepted = !1, expectedPostCountChanged = !1, ignoredPosts = 0, acceptedPosts = 0;
const changedPostNumbers = [];
if (input.topic !== void 0 && (0, import_ingest_version.shouldReplaceDiscourseVersion)(
{ observedAt: this.#topicObservedAt, source: this.#topicSource },
{ observedAt, source: input.source }
) && (this.#topic = input.topic, this.#topicObservedAt = observedAt, this.#topicSource = input.source, topicChanged = !0, this.#unavailableTopic && observedAt >= this.#unavailableTopic.confirmedAt && (this.#unavailableTopic = null, topicChanged = !0)), input.streamPostIds !== void 0 && (0, import_ingest_version.shouldReplaceDiscourseVersion)(
{ observedAt: this.#streamObservedAt, source: null },
{ observedAt, source: input.source }
)) {
streamVersionAccepted = !0;
const blockedPostIds = new Set(
[...this.#removedPosts.values()].filter((removed) => !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, {
observedAt,
source: input.source
})).map((removed) => removed.postId)
), stream = Object.freeze(
normalizeStreamPostIds(input.streamPostIds).filter((postId) => !blockedPostIds.has(postId))
);
(stream.length !== this.#streamPostIds.length || stream.some((postId, index) => postId !== this.#streamPostIds[index])) && (this.#streamPostIds = stream, streamChanged = !0), this.#streamObservedAt = observedAt;
}
if (input.expectedPostCount !== void 0) {
const blockedRemovalCount = [...this.#removedPosts.values()].filter((removed) => !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, {
observedAt,
source: input.source
})).length, incomingExpectedPostCount = Math.max(
0,
nonNegativeInteger(input.expectedPostCount, "expectedPostCount") - blockedRemovalCount
), nextExpectedPostCount = input.source === "topic-json" && input.topic !== void 0 && topicChanged && streamVersionAccepted ? incomingExpectedPostCount : Math.max(
this.#expectedPostCount,
incomingExpectedPostCount
);
nextExpectedPostCount !== this.#expectedPostCount && (this.#expectedPostCount = nextExpectedPostCount, expectedPostCountChanged = !0);
}
for (const post of input.posts ?? []) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(post.post_number);
if (postNumber === null) {
ignoredPosts += 1;
continue;
}
const current = this.#posts.get(postNumber), removed = this.#removedPosts.get(postNumber);
if (removed && !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, {
observedAt,
source: input.source
}) || current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(
current,
{ observedAt, source: input.source }
) || current && current.value === post && current.observedAt === observedAt) continue;
const value = mergePostEntity(current?.value, post);
this.#removedPosts.delete(postNumber);
const unavailable = this.#unavailablePosts.get(postNumber);
unavailable && observedAt >= unavailable.confirmedAt && this.#unavailablePosts.delete(postNumber), this.#posts.set(postNumber, Object.freeze({
postNumber,
observedAt,
source: input.source,
value
})), acceptedPosts += 1, changedPostNumbers.push(postNumber);
}
return (topicChanged || streamChanged || expectedPostCountChanged || changedPostNumbers.length > 0) && (this.#updatedAt = Math.max(this.#updatedAt, observedAt), this.#queuePersistence()), Object.freeze({
acceptedPosts,
ignoredPosts,
changedPostNumbers: Object.freeze(changedPostNumbers.sort((left, right) => left - right)),
topicChanged,
streamChanged
});
}
markTopicUnavailable(rawStatus, confirmedAt = this.#now()) {
if (this.#topic === null && this.#posts.size === 0) return !1;
const next = Object.freeze({
status: localArchiveStatus(rawStatus),
confirmedAt: finiteTimestamp(confirmedAt, "confirmedAt")
});
return this.#unavailableTopic && this.#unavailableTopic.confirmedAt > next.confirmedAt ? !1 : (this.#unavailableTopic = next, this.#archiveRecordKnown = !0, this.#updatedAt = Math.max(this.#updatedAt, next.confirmedAt), this.#queuePersistence(), !0);
}
markPostUnavailable(rawPostNumber, rawStatus, confirmedAt = this.#now()) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawPostNumber);
if (postNumber === null || !this.#posts.has(postNumber)) return !1;
const next = Object.freeze({
postNumber,
status: localArchiveStatus(rawStatus),
confirmedAt: finiteTimestamp(confirmedAt, "confirmedAt")
}), current = this.#unavailablePosts.get(postNumber);
return current && current.confirmedAt > next.confirmedAt ? !1 : (this.#unavailablePosts.set(postNumber, next), this.#archiveRecordKnown = !0, this.#updatedAt = Math.max(this.#updatedAt, next.confirmedAt), this.#queuePersistence(), !0);
}
localArchiveState() {
return Object.freeze({
topic: this.#unavailableTopic,
posts: Object.freeze(
[...this.#unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
)
});
}
hasLocalArchive() {
return this.#unavailableTopic !== null || this.#unavailablePosts.size > 0;
}
removePost(rawPostNumber, rawPostId, source, observedAt = this.#now()) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawPostNumber);
if (postNumber === null) throw new RangeError("postNumber 必须是正安全整数");
const postId = (0, import_identifiers.discoursePostId)(rawPostId), normalizedObservedAt = finiteTimestamp(observedAt, "observedAt"), current = this.#posts.get(postNumber), currentId = current?.value?.id;
if (currentId !== void 0 && (0, import_identifiers.discoursePostId)(currentId) !== postId)
throw new Error(`楼层 #${postNumber} 与 post.id ${postId} 不一致`);
const removed = this.#removedPosts.get(postNumber), next = Object.freeze({
postNumber,
postId,
observedAt: normalizedObservedAt,
source
});
if (current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, next) || removed && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(removed, next))
return Object.freeze({ removed: !1, postNumber, postId, streamChanged: !1 });
this.#posts.delete(postNumber), this.#unavailablePosts.delete(postNumber), this.#removedPosts.set(postNumber, next);
const nextStream = this.#streamPostIds.filter((candidate) => candidate !== postId), streamChanged = nextStream.length !== this.#streamPostIds.length;
return streamChanged && (this.#streamPostIds = Object.freeze(nextStream), this.#streamObservedAt = normalizedObservedAt), (current || streamChanged) && (this.#expectedPostCount = Math.max(0, this.#expectedPostCount - 1)), this.#updatedAt = Math.max(this.#updatedAt, normalizedObservedAt), this.#queuePersistence(), Object.freeze({ removed: !0, postNumber, postId, streamChanged });
}
restore() {
if (this.#restorePromise) return this.#restorePromise;
const promise = this.#restoreFromCache();
this.#restorePromise = promise;
const clearRestore = () => {
this.#restorePromise === promise && (this.#restorePromise = null);
};
return promise.then(clearRestore, clearRestore), promise;
}
topic() {
return this.#topic;
}
post(postNumber) {
const normalized = (0, import_identifiers.tryDiscoursePostNumber)(postNumber);
return normalized === null ? void 0 : this.#posts.get(normalized)?.value;
}
posts() {
return Object.freeze(
[...this.#posts.values()].sort((left, right) => left.postNumber - right.postNumber).map((entry) => entry.value)
);
}
streamPostIds() {
return this.#streamPostIds;
}
isFresh(now = this.#now()) {
return this.#unavailableTopic !== null ? !0 : this.#updatedAt > 0 ? Math.max(0, finiteTimestamp(now, "now") - this.#updatedAt) <= this.#policy.freshForMs : !1;
}
snapshot(now = this.#now()) {
return Object.freeze({
schemaVersion: 2,
topicId: this.topicId,
authScope: this.authScope,
savedAt: now,
updatedAt: this.#updatedAt,
expectedPostCount: this.#expectedPostCount,
topicObservedAt: this.#topicObservedAt,
topicSource: this.#topicSource,
topic: this.#topic,
streamObservedAt: this.#streamObservedAt,
streamPostIds: this.#streamPostIds,
posts: Object.freeze(
[...this.#posts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
removedPosts: Object.freeze(
[...this.#removedPosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
unavailableTopic: this.#unavailableTopic,
unavailablePosts: Object.freeze(
[...this.#unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
tree: this.#tree
});
}
replyTreeSnapshotStore() {
return Object.freeze({
load: async (topicId) => String(topicId) !== this.topicId ? null : (await this.restore())?.snapshot.tree ?? null,
save: async (topicId, snapshot) => {
if (String(topicId) !== this.topicId || snapshot.topicId !== this.topicId)
throw new Error(`回复树快照 Topic ${topicId} 与仓储 ${this.topicId} 不匹配`);
this.#tree = snapshot, this.#expectedPostCount = Math.max(
this.#expectedPostCount,
snapshot.expectedPostCount
), this.#queuePersistence(), await this.flush();
}
});
}
async flush() {
for (; this.#pendingWrite || this.#persisting; )
this.#persisting || this.#startPersistence(), await this.#persisting;
}
/**
* 缓存重建失败时,把当前完整快照重新写回唯一 snapshot policy。
*
* 正常增量持久化仍走 merge/idle;这里仅用于缓存已失效、且必须立即恢复阅读器的
* rollback 边界,因此直接覆盖刚被清空的同 Topic 快照。
*/
persistCurrentSnapshot() {
return this.#responses.write(
this.hasLocalArchive() ? this.#archivePolicy : this.#policy,
this.snapshot()
);
}
setPersistenceDelayReader(reader) {
return this.#readPersistenceDelayMs = reader, () => {
this.#readPersistenceDelayMs === reader && (this.#readPersistenceDelayMs = () => 0);
};
}
async #restoreFromCache() {
const read = async (policy) => {
const cached2 = await this.#responses.read(
policy
);
if (cached2.state === "miss" || cached2.value === void 0) return null;
try {
const stored2 = this.#normalizeStoredSnapshot(cached2.value);
if (policy === this.#archivePolicy && !(stored2.unavailableTopic || stored2.unavailablePosts?.length))
throw new Error(`Topic ${this.topicId} 的永久存档缺少失效标记`);
return Object.freeze({
policy,
cached: cached2,
stored: stored2
});
} catch (error) {
return this.#onInvalidSnapshot(error), await this.#responses.invalidate({ ids: [policy.id] }), null;
}
}, [archived, regular] = await Promise.all([
read(this.#archivePolicy),
read(this.#policy)
]), restored = archived && regular ? archived.stored.updatedAt >= regular.stored.updatedAt ? archived : regular : archived ?? regular;
if (!restored) return null;
const { cached, policy: restoredPolicy, stored } = restored;
this.#archiveRecordKnown = archived !== null;
const staleArchive = archived !== null && restored === regular, discardedInvalidTree = cached.value.tree !== null && cached.value.tree !== void 0 && stored.tree === null, hadLocalState = this.#topic !== null || this.#posts.size > 0 || this.#removedPosts.size > 0 || this.#streamPostIds.length > 0 || this.#tree !== null || this.hasLocalArchive();
let topicFilled = !1, streamFilled = !1, removalFilled = !1, availabilityFilled = !1;
const addedPostNumbers = [];
this.#topic === null && stored.topic !== null && (this.#topic = stored.topic, this.#topicObservedAt = stored.topicObservedAt, this.#topicSource = stored.topicSource, topicFilled = !0), !this.#streamPostIds.length && stored.streamPostIds.length && (this.#streamPostIds = stored.streamPostIds, this.#streamObservedAt = stored.streamObservedAt, streamFilled = !0);
for (const removed of stored.removedPosts ?? []) {
const current = this.#posts.get(removed.postNumber);
if (current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, removed))
continue;
const existingRemoval = this.#removedPosts.get(removed.postNumber);
if (existingRemoval && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(existingRemoval, removed))
continue;
this.#posts.delete(removed.postNumber), this.#removedPosts.set(removed.postNumber, removed);
const nextStream = this.#streamPostIds.filter(
(postId) => postId !== removed.postId
);
nextStream.length !== this.#streamPostIds.length && (this.#streamPostIds = Object.freeze(nextStream), streamFilled = !0), removalFilled = !0;
}
for (const entry of stored.posts)
this.#removedPosts.has(entry.postNumber) || this.#posts.has(entry.postNumber) || (this.#posts.set(entry.postNumber, entry), addedPostNumbers.push(entry.postNumber));
stored.unavailableTopic && (!this.#unavailableTopic || stored.unavailableTopic.confirmedAt >= this.#unavailableTopic.confirmedAt) && (this.#unavailableTopic = stored.unavailableTopic, availabilityFilled = !0);
for (const unavailable of stored.unavailablePosts ?? []) {
if (!this.#posts.has(unavailable.postNumber)) continue;
const current = this.#unavailablePosts.get(unavailable.postNumber);
current && current.confirmedAt > unavailable.confirmedAt || (this.#unavailablePosts.set(unavailable.postNumber, unavailable), availabilityFilled = !0);
}
this.hasLocalArchive() && (this.#archiveRecordKnown = !0), this.#tree === null && stored.tree !== null && (this.#tree = stored.tree);
const blockedStoredPostCount = stored.posts.filter((entry) => this.#removedPosts.has(entry.postNumber)).length;
if (this.#expectedPostCount = Math.max(
this.#expectedPostCount,
Math.max(0, stored.expectedPostCount - blockedStoredPostCount)
), this.#updatedAt = Math.max(this.#updatedAt, stored.updatedAt), discardedInvalidTree && await this.#responses.invalidate({ ids: [restoredPolicy.id] }), (discardedInvalidTree || hadLocalState && (topicFilled || streamFilled || removalFilled || availabilityFilled || addedPostNumbers.length)) && this.#queuePersistence(), staleArchive) {
const cleanup = await this.#responses.invalidateWithReport({
ids: [this.#archivePolicy.id]
});
this.#archiveRecordKnown = !cleanup.complete, cleanup.complete || this.#queuePersistence();
}
return Object.freeze({
snapshot: stored,
addedPostNumbers: Object.freeze(addedPostNumbers.sort((left, right) => left - right)),
topicFilled,
streamFilled
});
}
#normalizeStoredSnapshot(value) {
if (!value || value.schemaVersion !== 2 || value.topicId !== this.topicId || value.authScope !== this.authScope || !Array.isArray(value.posts) || value.removedPosts !== void 0 && !Array.isArray(value.removedPosts) || value.unavailablePosts !== void 0 && !Array.isArray(value.unavailablePosts) || !Array.isArray(value.streamPostIds))
throw new Error(`Topic ${this.topicId} 的正文快照身份或 schema 无效`);
const removedPosts = /* @__PURE__ */ new Map();
for (const rawRemoval of value.removedPosts ?? [])
try {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawRemoval?.postNumber), source = (0, import_ingest_version.normalizeDiscourseIngestSource)(rawRemoval?.source);
if (postNumber === null || source === null || !Number.isFinite(rawRemoval.observedAt) || rawRemoval.observedAt < 0)
throw new Error("删除墓碑字段无效");
const removal = Object.freeze({
postNumber,
postId: (0, import_identifiers.discoursePostId)(rawRemoval.postId),
observedAt: rawRemoval.observedAt,
source
}), current = removedPosts.get(postNumber);
(!current || (0, import_ingest_version.shouldReplaceDiscourseVersion)(current, removal)) && removedPosts.set(postNumber, removal);
} catch (error) {
this.#onInvalidSnapshot(new Error(
`Topic ${this.topicId} 的楼层删除墓碑无效`,
{ cause: error }
));
}
const posts = /* @__PURE__ */ new Map(), blockedStreamPostIds = new Set(
[...removedPosts.values()].map((entry) => entry.postId)
);
for (const rawEntry of value.posts) {
if (rawEntry?.value?.reader_local_archive_placeholder === !0) {
this.#onInvalidSnapshot(new Error(
`Topic ${this.topicId} 的旧版 404 占位楼层已丢弃`
));
continue;
}
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawEntry?.postNumber), source = (0, import_ingest_version.normalizeDiscourseIngestSource)(rawEntry?.source), valuePostNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawEntry?.value?.post_number);
if (postNumber === null || valuePostNumber !== postNumber || source === null || !Number.isFinite(rawEntry.observedAt))
continue;
const postVersion = { observedAt: rawEntry.observedAt, source }, removal = removedPosts.get(postNumber);
if (removal) {
const rawPostId = rawEntry.value.id;
if (rawPostId !== void 0)
try {
const postId = (0, import_identifiers.discoursePostId)(rawPostId);
postId !== removal.postId && (blockedStreamPostIds.add(postId), this.#onInvalidSnapshot(new Error(
`Topic ${this.topicId} 楼层 #${postNumber} 的删除 post.id 不一致`
)));
} catch (error) {
this.#onInvalidSnapshot(error);
}
if (!(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removal, postVersion)) continue;
removedPosts.delete(postNumber), blockedStreamPostIds.delete(removal.postId);
}
const current = posts.get(postNumber);
(!current || (0, import_ingest_version.shouldReplaceDiscourseVersion)(
current,
postVersion
)) && posts.set(postNumber, Object.freeze({
postNumber,
observedAt: rawEntry.observedAt,
source,
value: rawEntry.value
}));
}
const topicSource = value.topicSource === null ? null : (0, import_ingest_version.normalizeDiscourseIngestSource)(value.topicSource);
if (value.topic !== null && topicSource === null)
throw new Error(`Topic ${this.topicId} 的正文来源无效`);
const normalizedPosts = Object.freeze(
[...posts.values()].sort((left, right) => left.postNumber - right.postNumber)
), unavailableTopic = value.unavailableTopic ? Object.freeze({
status: localArchiveStatus(value.unavailableTopic.status),
confirmedAt: finiteTimestamp(
value.unavailableTopic.confirmedAt,
"unavailableTopic.confirmedAt"
)
}) : null, unavailablePosts = /* @__PURE__ */ new Map();
for (const rawUnavailable of value.unavailablePosts ?? [])
try {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawUnavailable?.postNumber);
if (postNumber === null || !posts.has(postNumber)) continue;
const unavailable = Object.freeze({
postNumber,
status: localArchiveStatus(rawUnavailable.status),
confirmedAt: finiteTimestamp(
rawUnavailable.confirmedAt,
"unavailablePost.confirmedAt"
)
});
if (posts.get(postNumber).observedAt > unavailable.confirmedAt) continue;
const current = unavailablePosts.get(postNumber);
(!current || current.confirmedAt <= unavailable.confirmedAt) && unavailablePosts.set(postNumber, unavailable);
} catch (error) {
this.#onInvalidSnapshot(error);
}
return Object.freeze({
schemaVersion: 2,
topicId: this.topicId,
authScope: this.authScope,
savedAt: finiteTimestamp(value.savedAt, "savedAt"),
updatedAt: finiteTimestamp(value.updatedAt, "updatedAt"),
expectedPostCount: nonNegativeInteger(value.expectedPostCount, "expectedPostCount"),
topicObservedAt: finiteTimestamp(value.topicObservedAt, "topicObservedAt"),
topicSource,
topic: value.topic,
streamObservedAt: finiteTimestamp(value.streamObservedAt, "streamObservedAt"),
streamPostIds: Object.freeze(
normalizeStreamPostIds(value.streamPostIds).filter((postId) => !blockedStreamPostIds.has(postId))
),
posts: normalizedPosts,
removedPosts: Object.freeze(
[...removedPosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
unavailableTopic: unavailableTopic && unavailableTopic.confirmedAt >= value.topicObservedAt ? unavailableTopic : null,
unavailablePosts: Object.freeze(
[...unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
tree: validTreeSnapshot(
value.tree,
this.topicId,
normalizedPosts,
new Set(removedPosts.keys()),
this.#onInvalidTreeSnapshot
)
});
}
#mergeStoredSnapshots(storedValue, incomingValue) {
let stored;
try {
stored = this.#normalizeStoredSnapshot(storedValue);
} catch (error) {
return this.#onInvalidSnapshot(error), this.#normalizeStoredSnapshot(incomingValue);
}
const incoming = this.#normalizeStoredSnapshot(incomingValue), posts = /* @__PURE__ */ new Map(), removals = /* @__PURE__ */ new Map(), applyRemoval = (removal) => {
const post = posts.get(removal.postNumber);
if (post && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(post, removal)) return;
const current = removals.get(removal.postNumber);
current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, removal) || (posts.delete(removal.postNumber), removals.set(removal.postNumber, removal));
}, applyPost = (post) => {
const removal = removals.get(post.postNumber);
if (removal && !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removal, post)) return;
const current = posts.get(post.postNumber);
current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, post) || (removals.delete(post.postNumber), posts.set(post.postNumber, post));
};
for (const snapshot of [stored, incoming]) {
for (const removal of snapshot.removedPosts ?? []) applyRemoval(removal);
for (const post of snapshot.posts) applyPost(post);
}
let topic = stored.topic, topicObservedAt = stored.topicObservedAt, topicSource = stored.topicSource;
incoming.topic !== null && incoming.topicSource !== null && (0, import_ingest_version.shouldReplaceDiscourseVersion)(
{ observedAt: topicObservedAt, source: topicSource },
{
observedAt: incoming.topicObservedAt,
source: incoming.topicSource
}
) && (topic = incoming.topic, topicObservedAt = incoming.topicObservedAt, topicSource = incoming.topicSource);
const incomingStreamWins = incoming.streamObservedAt >= stored.streamObservedAt, streamPostIds = incomingStreamWins ? incoming.streamPostIds : stored.streamPostIds, blockedPostIds = new Set(
[...removals.values()].map((removal) => removal.postId)
), incomingStateWins = incoming.updatedAt >= stored.updatedAt, unavailableTopic = [stored.unavailableTopic, incoming.unavailableTopic].filter((value) => value != null).sort((left, right) => right.confirmedAt - left.confirmedAt)[0] ?? null, unavailablePosts = /* @__PURE__ */ new Map();
for (const snapshot of [stored, incoming])
for (const unavailable of snapshot.unavailablePosts ?? []) {
const current = unavailablePosts.get(unavailable.postNumber);
(!current || current.confirmedAt <= unavailable.confirmedAt) && unavailablePosts.set(unavailable.postNumber, unavailable);
}
for (const [postNumber, unavailable] of unavailablePosts) {
const post = posts.get(postNumber);
(!post || post.observedAt > unavailable.confirmedAt) && unavailablePosts.delete(postNumber);
}
const tree = stored.tree ? incoming.tree && incoming.tree.savedAt >= stored.tree.savedAt ? incoming.tree : stored.tree : incoming.tree;
return Object.freeze({
schemaVersion: 2,
topicId: this.topicId,
authScope: this.authScope,
savedAt: Math.max(stored.savedAt, incoming.savedAt),
updatedAt: Math.max(stored.updatedAt, incoming.updatedAt),
expectedPostCount: incomingStateWins && incoming.expectedPostCount > 0 ? incoming.expectedPostCount : stored.expectedPostCount,
topicObservedAt,
topicSource,
topic,
streamObservedAt: incomingStreamWins ? incoming.streamObservedAt : stored.streamObservedAt,
streamPostIds: Object.freeze(
streamPostIds.filter((postId) => !blockedPostIds.has(postId))
),
posts: Object.freeze(
[...posts.values()].sort(
(left, right) => left.postNumber - right.postNumber
)
),
removedPosts: Object.freeze(
[...removals.values()].sort(
(left, right) => left.postNumber - right.postNumber
)
),
unavailableTopic: unavailableTopic && unavailableTopic.confirmedAt >= topicObservedAt ? unavailableTopic : null,
unavailablePosts: Object.freeze(
[...unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
tree
});
}
#queuePersistence() {
this.#pendingWrite = !0, this.#persistenceReadyAt = Math.max(
this.#persistenceReadyAt,
this.#now() + this.#persistenceIdleMs
), this.#persisting || this.#startPersistence();
}
async #waitForPersistenceWindow() {
for (; this.#pendingWrite; ) {
const repositoryDelay = Math.max(
0,
this.#persistenceReadyAt - this.#now()
), rawActivityDelay = Number(
this.#readPersistenceDelayMs(this.#persistenceIdleMs)
), activityDelay = Number.isFinite(rawActivityDelay) ? Math.max(0, rawActivityDelay) : 0, delayMs = Math.max(repositoryDelay, activityDelay);
if (delayMs <= 0) return;
await this.#persistenceWait(delayMs);
}
}
#startPersistence() {
this.#persisting = Promise.resolve().then(async () => {
for (; this.#pendingWrite; ) {
if (await this.#waitForPersistenceWindow(), !this.#pendingWrite) continue;
this.#pendingWrite = !1;
const snapshot = this.snapshot(), archived = !!(snapshot.unavailableTopic || snapshot.unavailablePosts?.length), policy = archived ? this.#archivePolicy : this.#policy;
await this.#responses.merge(
policy,
snapshot,
(stored, incoming) => this.#mergeStoredSnapshots(stored, incoming)
), archived ? this.#archiveRecordKnown = !0 : this.#archiveRecordKnown && (await this.#responses.invalidate({ ids: [this.#archivePolicy.id] }), this.#archiveRecordKnown = !1);
}
}).finally(() => {
this.#persisting = null, this.#pendingWrite || (this.#persistenceReadyAt = 0), this.#pendingWrite && this.#startPersistence();
});
}
}
}, "47e7c0f0e76eeb5c7b5fce66c0227e242262b7875ec453b9aed5d144881dbc78");
/* Source: lite/src/collection/reader-collection-filter-model.ts */
runtime.register("src/collection/reader-collection-filter-model.js", function(module, exports, require) {
var reader_collection_filter_model_exports = {};
__export(reader_collection_filter_model_exports, {
readerCollectionDateKey: () => readerCollectionDateKey
});
module.exports = __toCommonJS(reader_collection_filter_model_exports);
function readerCollectionDateKey(value) {
const timestamp = typeof value == "number" ? value : Date.parse(value);
if (!Number.isFinite(timestamp)) return "";
const date = new Date(timestamp);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0")
].join("-");
}
}, "b5ee6d24616f884aa55ed482a8e2dea6ba7551cc340a582a254d2251287af0ae");
/* Source: lite/src/collection/reader-collection-floating-window.ts */
runtime.register("src/collection/reader-collection-floating-window.js", function(module, exports, require) {
var reader_collection_floating_window_exports = {};
__export(reader_collection_floating_window_exports, {
READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY: () => READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY,
READER_COLLECTION_FLOATING_WINDOW_PLACEMENT: () => READER_COLLECTION_FLOATING_WINDOW_PLACEMENT,
READER_COLLECTION_FLOATING_WINDOW_POLICY: () => READER_COLLECTION_FLOATING_WINDOW_POLICY,
ReaderCollectionFloatingWindow: () => ReaderCollectionFloatingWindow,
ReaderCollectionNodeCache: () => ReaderCollectionNodeCache,
ReaderCollectionProgressView: () => ReaderCollectionProgressView,
ReaderCollectionScrollWindow: () => ReaderCollectionScrollWindow
});
module.exports = __toCommonJS(reader_collection_floating_window_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_html_element = require("../dom/html-element.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_floating_window_frame = require("../shell/reader-floating-window-frame.js");
const READER_COLLECTION_FLOATING_WINDOW_POLICY = Object.freeze({
minWidth: 320,
minHeight: 460,
defaultWidth: 560,
defaultHeight: 680
}), READER_COLLECTION_FLOATING_WINDOW_PLACEMENT = "center", READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY = "linuxdo-enhanced-reader:collection-window:v1";
class ReaderCollectionNodeCache {
#entries = /* @__PURE__ */ new Map();
node(keyValue, record, variantValue, create) {
const key = String(keyValue), variant = String(variantValue), cached = this.#entries.get(key);
if (cached?.record === record && cached.variant === variant)
return cached.node;
const node2 = create();
return this.#entries.set(key, Object.freeze({ record, variant, node: node2 })), node2;
}
prune(keys) {
const retained = new Set(keys);
for (const key of this.#entries.keys())
retained.has(key) || this.#entries.delete(key);
}
clear() {
this.#entries.clear();
}
}
class ReaderCollectionScrollWindow {
scope;
#list;
#identity;
#loadMore;
#onError;
#pages = /* @__PURE__ */ new Map();
#streamKey = "";
#records = Object.freeze([]);
#loading = !1;
#hasMore = !1;
#pending = !1;
#retryBoundaryAfterPending = !1;
#boundaryMicrotaskPending = !1;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#list = options.list, this.#identity = options.identity, this.#loadMore = options.loadMore, this.#onError = options.onError ?? (() => {
}), options.pager.hidden = !0, options.pager.setAttribute("aria-hidden", "true"), this.#list.dataset.collectionScrollLoading = "false", this.scope.listen(this.#list, "scroll", () => {
this.#requestMoreIfNearEnd(!0);
}, { passive: !0 }), this.scope.add(() => {
this.#boundaryMicrotaskPending = !1, this.#retryBoundaryAfterPending = !1, this.#pages.clear(), this.#records = Object.freeze([]), delete this.#list.dataset.collectionScrollLoading;
});
}
get records() {
return this.#records;
}
project(input) {
const page = Math.max(0, Math.floor(input.page));
input.streamKey !== this.#streamKey && (this.#streamKey = input.streamKey, this.#pages.clear(), this.#list.scrollTop = 0);
for (const index of [...this.#pages.keys()])
index > page && this.#pages.delete(index);
input.loading || this.#pages.set(page, Object.freeze([...input.records]));
const records = [], identities = /* @__PURE__ */ new Set();
for (let index = 0; index <= page; index += 1)
for (const record of this.#pages.get(index) ?? []) {
const identity = this.#identity(record);
identities.has(identity) || (identities.add(identity), records.push(record));
}
return this.#records = Object.freeze(records), this.sync({ loading: input.loading, hasMore: input.hasMore }), this.#records;
}
sync(state) {
this.#loading = state.loading, this.#hasMore = state.hasMore, this.#list.dataset.collectionScrollLoading = String(
state.loading || this.#pending
), this.#scheduleBoundaryCheck();
}
update(identity, replace) {
this.replaceWhere(
(record) => this.#identity(record) === identity,
replace
);
}
replaceWhere(predicate, replace) {
for (const [page, records] of this.#pages) {
let changed = !1;
const next = records.map((record) => predicate(record) ? (changed = !0, replace(record)) : record);
changed && this.#pages.set(page, Object.freeze(next));
}
this.#rebuildRecords();
}
forget(predicate) {
for (const [page, records] of this.#pages)
this.#pages.set(page, Object.freeze(records.filter((record) => !predicate(record))));
this.#rebuildRecords();
}
#rebuildRecords() {
const records = [], identities = /* @__PURE__ */ new Set();
for (const page of [...this.#pages.keys()].sort((left, right) => left - right))
for (const record of this.#pages.get(page) ?? []) {
const identity = this.#identity(record);
identities.has(identity) || (identities.add(identity), records.push(record));
}
this.#records = Object.freeze(records);
}
#scheduleBoundaryCheck() {
this.scope.destroyed || this.#boundaryMicrotaskPending || (this.#boundaryMicrotaskPending = !0, queueMicrotask(() => {
this.#boundaryMicrotaskPending = !1, this.scope.destroyed || this.#requestMoreIfNearEnd();
}));
}
#requestMoreIfNearEnd(explicitScroll = !1) {
const scrollTop = Math.max(0, Number(this.#list.scrollTop) || 0), clientHeight = Math.max(0, Number(this.#list.clientHeight) || 0), scrollHeight = Math.max(0, Number(this.#list.scrollHeight) || 0);
(explicitScroll || clientHeight > 0) && scrollTop + clientHeight >= scrollHeight - 96 && this.#requestMore();
}
#requestMore() {
if (this.#pending) {
this.#retryBoundaryAfterPending = !0;
return;
}
this.#loading || !this.#hasMore || (this.#pending = !0, this.#list.dataset.collectionScrollLoading = "true", Promise.resolve(this.#loadMore()).catch(this.#onError).finally(() => {
this.#pending = !1, this.#list.dataset.collectionScrollLoading = String(this.#loading), this.#retryBoundaryAfterPending && (this.#retryBoundaryAfterPending = !1, this.#scheduleBoundaryCheck());
}));
}
}
class ReaderCollectionFloatingWindow {
scope;
frame;
#toggle;
#content;
#isOpen;
#requestClose;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#toggle = options.toggle, this.#content = options.content, this.#isOpen = options.isOpen, this.#requestClose = options.requestClose, this.frame = new import_reader_floating_window_frame.ReaderFloatingWindowFrame({
document: options.document,
mount: options.mount,
title: options.title,
ariaLabel: options.ariaLabel,
icon: options.icon,
variant: options.variant,
tabId: options.variant,
tabOrder: options.tabOrder,
requestOpen: options.requestOpen,
zIndex: 2147483584,
...options.geometryStorage ? { geometryStorage: options.geometryStorage } : {},
geometryStorageKey: READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY,
policy: READER_COLLECTION_FLOATING_WINDOW_POLICY,
placement: READER_COLLECTION_FLOATING_WINDOW_PLACEMENT,
...options.notify ? { notify: options.notify } : {},
onClose: () => {
this.#content.hidden = !0, this.#requestClose();
},
parentScope: this.scope
}), this.frame.element.classList.add("is-user-observation-list"), this.#content.hidden = !0, this.frame.body.append(this.#content), this.#toggle.setAttribute("aria-haspopup", "dialog"), this.scope.listen(options.document, "pointerdown", (event) => {
this.#isOpen() && this.frame.dismissFromPointerEvent(event);
}, !0), this.scope.listen(options.document, "keydown", (eventValue) => {
const event = eventValue;
!this.#isOpen() || !this.frame.dismissFromEscapeEvent(event) || !this.frame.active && this.#toggle.isConnected && this.#toggle.focus({ preventScroll: !0 });
}, !0), this.scope.add(() => {
this.#toggle.setAttribute("aria-expanded", "false");
});
}
get isOpen() {
return this.frame.isOpen;
}
attachHeaderActions(options) {
options.root.classList.add("ldp-reader-floating-window-extra-actions"), options.root.setAttribute("role", "group"), options.root.setAttribute("aria-label", options.label);
for (const button of options.buttons)
button.classList.add("ldp-reader-floating-window-extra-action");
const divider = this.frame.element.ownerDocument.createElement("span");
divider.className = "ldp-reader-floating-window-action-divider", divider.setAttribute("aria-hidden", "true"), options.root.append(divider), this.frame.actions.prepend(options.root);
}
sync(open) {
if (this.scope.destroyed) return;
const opening = open && !this.frame.isOpen;
this.#content.hidden = !open, this.#toggle.setAttribute("aria-expanded", String(open)), opening ? this.frame.open() : open || this.frame.close();
}
destroy() {
this.scope.destroy();
}
}
class ReaderCollectionProgressView {
scope;
element;
#document;
#retry;
#onError;
#retrying = !1;
constructor(options) {
this.#document = options.document, this.#retry = options.retry, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.element = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-user-observation-progress ldp-collection-cache-progress"
), this.element.hidden = !0, this.scope.listen(this.element, "click", (event) => {
const target = event.target, retry = typeof target?.closest == "function" ? target.closest(
".ldp-user-observation-progress-retry"
) : null;
!retry || !this.element.contains(retry) || this.#retrying || (event.preventDefault(), event.stopPropagation(), this.#retrying = !0, this.#syncRetryButton(retry), Promise.resolve().then(() => this.#retry()).catch(this.#onError).finally(() => {
this.#retrying = !1;
const current = this.element.querySelector(
".ldp-user-observation-progress-retry"
);
current && this.#syncRetryButton(current);
}));
});
}
#syncRetryButton(button) {
button.disabled = this.#retrying, button.dataset.ldpRequestBusy = this.#retrying ? "1" : "0", button.setAttribute("aria-busy", String(this.#retrying)), button.replaceChildren(
(0, import_reader_icon.createReaderIcon)(
this.#document,
this.#retrying ? "loader" : "rotate-ccw"
),
this.#document.createTextNode(this.#retrying ? "重试中" : "重试")
);
}
render(snapshot) {
if (this.element.hidden = !snapshot.visible, !snapshot.visible) {
this.element.replaceChildren();
return;
}
this.element.dataset.phase = snapshot.state;
const copy = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-user-observation-progress-copy"
);
if (copy.append(
(0, import_html_element.htmlElement)(this.#document, "strong", "", snapshot.label),
(0, import_html_element.htmlElement)(this.#document, "span", "", snapshot.detail)
), snapshot.retryable) {
const retry = this.#document.createElement("button");
retry.type = "button", retry.className = "ldp-user-observation-progress-retry", this.#syncRetryButton(retry), copy.append(retry);
}
const total = Math.max(1, Math.floor(snapshot.total)), completed = Math.max(
0,
Math.min(total, Number(snapshot.completed) || 0)
), completeSegments = Math.floor(completed), partialProgress = completed - completeSegments, segments = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-user-observation-progress-segments"
);
segments.setAttribute("role", "progressbar"), segments.setAttribute("aria-label", "后台缓存进度"), segments.setAttribute("aria-valuemin", "0"), segments.setAttribute("aria-valuemax", String(total)), segments.setAttribute(
"aria-valuenow",
String(Math.round(completed * 1e3) / 1e3)
), segments.setAttribute("aria-valuetext", snapshot.valueText), segments.style.gridTemplateColumns = `repeat(${total}, minmax(0, 1fr))`, segments.append(...Array.from({ length: total }, (_, index) => {
const segment = (0, import_html_element.htmlElement)(this.#document, "span", "");
return index < completeSegments ? segment.classList.add("is-complete") : index === completeSegments && completed < total && (segment.classList.add(
snapshot.state === "error" ? "is-error" : snapshot.state === "waiting" ? "is-waiting" : "is-active"
), partialProgress > 0 && snapshot.state !== "error" && (segment.classList.add("is-partial"), segment.style.setProperty(
"--ldp-collection-progress",
`${Math.round(partialProgress * 100)}%`
))), segment;
})), this.element.replaceChildren(copy, segments);
}
destroy() {
this.scope.destroy();
}
}
}, "2d28396e95b4a7c0c9dca6205a0446b7d172f0fb6ef10cb582d94e4e030c5957");
/* Source: lite/src/collection/reader-collection-hydration.ts */
runtime.register("src/collection/reader-collection-hydration.js", function(module, exports, require) {
var reader_collection_hydration_exports = {};
__export(reader_collection_hydration_exports, {
readerCollectionResumePosition: () => readerCollectionResumePosition,
runReaderCollectionHydrationLease: () => runReaderCollectionHydrationLease,
runReaderCollectionWorkers: () => runReaderCollectionWorkers
});
module.exports = __toCommonJS(reader_collection_hydration_exports);
function positiveSafeInteger(value, fallback) {
const normalized = Math.floor(Number(value));
return Number.isSafeInteger(normalized) && normalized > 0 ? normalized : fallback;
}
function nonNegativeSafeInteger(value) {
const normalized = Math.floor(Number(value));
return Number.isSafeInteger(normalized) && normalized >= 0 ? normalized : null;
}
function readerCollectionResumePosition(checkpoint, targetPageSizeValue, legacyPageSizeValue = targetPageSizeValue) {
const targetPageSize = positiveSafeInteger(targetPageSizeValue, 1), legacyPageSize = positiveSafeInteger(
checkpoint.sourcePageSize,
positiveSafeInteger(legacyPageSizeValue, targetPageSize)
), storedOffset = nonNegativeSafeInteger(checkpoint.sourceOffset), storedPage = nonNegativeSafeInteger(checkpoint.sourceNextPage) ?? 0, offset = storedOffset ?? storedPage * legacyPageSize;
return Object.freeze({
page: Math.floor(offset / targetPageSize),
offset
});
}
async function runReaderCollectionWorkers(options) {
const concurrency = positiveSafeInteger(options.concurrency, 1), maxTasks = options.maxTasks === void 0 ? Number.MAX_SAFE_INTEGER : Math.max(0, Math.floor(Number(options.maxTasks) || 0));
let started = 0, completed = 0;
const worker = async () => {
for (; started < maxTasks && (options.shouldContinue?.() ?? !0); ) {
const key = options.claim();
if (key === null) return;
started += 1;
try {
await options.run(key), completed += 1;
} finally {
options.release?.(key);
}
}
};
return await Promise.all(Array.from(
{ length: Math.min(concurrency, Math.max(1, maxTasks)) },
() => worker()
)), Object.freeze({ started, completed });
}
async function runReaderCollectionHydrationLease(options) {
const coordination = options.coordination, token = String(options.token).trim();
if (!coordination || !token)
return await options.beforeRun?.(), options.signal?.throwIfAborted(), await options.run(), "producer";
options.signal?.throwIfAborted();
const lease = await coordination.acquireFlight(token);
if (options.signal?.throwIfAborted(), !lease.producer)
return await coordination.waitForFlight(
token,
options.signal
) ? "consumer" : "consumer-timeout";
const heartbeatMs = positiveSafeInteger(options.heartbeatMs, 1e4), heartbeat = lease.coordinated ? setInterval(() => {
coordination.renewFlight(lease).catch(
options.onError ?? (() => {
})
);
}, heartbeatMs) : null;
try {
return await options.beforeRun?.(), options.signal?.throwIfAborted(), await options.run(), "producer";
} finally {
heartbeat !== null && clearInterval(heartbeat), await coordination.releaseFlight(lease);
}
}
}, "166c8ac2e1883e299191ca0bc5e86c31f6bbbe06974a722778507882459f29ca");
/* Source: lite/src/collection/reader-header-popover-position.ts */
runtime.register("src/collection/reader-header-popover-position.js", function(module, exports, require) {
var reader_header_popover_position_exports = {};
__export(reader_header_popover_position_exports, {
ReaderHeaderPopoverPosition: () => ReaderHeaderPopoverPosition,
ReaderHeaderPopoverSurface: () => ReaderHeaderPopoverSurface
});
module.exports = __toCommonJS(reader_header_popover_position_exports);
var import_floating_surface_wheel = require("../dom/floating-surface-wheel.js");
class ReaderHeaderPopoverPosition {
scope;
#document;
#toggle;
#popover;
#preferredPlacement;
#frame = null;
constructor(options) {
this.#document = options.document, this.#toggle = options.toggle, this.#popover = options.popover, this.#preferredPlacement = options.preferredPlacement ?? "bottom", this.scope = options.parentScope.child();
const viewport = this.#document.defaultView;
viewport && this.scope.listen(viewport, "resize", () => this.schedule());
for (const type of [
"ldp-reader-window-change",
"ldp-reader-workspace-change"
])
this.scope.listen(options.root, type, () => this.schedule());
this.scope.add(() => {
this.#frame !== null && viewport && viewport.cancelAnimationFrame(this.#frame), this.#frame = null, this.#popover.style.removeProperty("left"), this.#popover.style.removeProperty("top"), delete this.#popover.dataset.placement;
});
}
position() {
const viewport = this.#document.defaultView;
if (!viewport || this.scope.destroyed || this.#popover.hidden || !this.#popover.isConnected) return;
const buttonRect = this.#toggle.getBoundingClientRect(), popoverRect = this.#popover.getBoundingClientRect(), gap = 8, margin = 12, left = Math.max(
margin,
Math.min(
viewport.innerWidth - popoverRect.width - margin,
buttonRect.right - popoverRect.width
)
), below = buttonRect.bottom + gap, above = buttonRect.top - popoverRect.height - gap, spaceBelow = viewport.innerHeight - margin - below, spaceAbove = buttonRect.top - gap - margin, belowFits = popoverRect.height <= spaceBelow, aboveFits = popoverRect.height <= spaceAbove, placeAbove = this.#preferredPlacement === "top" ? aboveFits || !belowFits && spaceAbove >= spaceBelow : !belowFits && (aboveFits || spaceAbove > spaceBelow), nextLeft = `${Math.round(left)}px`, nextTop = `${Math.round(placeAbove ? Math.max(margin, above) : below)}px`;
this.#popover.style.left !== nextLeft && (this.#popover.style.left = nextLeft), this.#popover.style.top !== nextTop && (this.#popover.style.top = nextTop), this.#popover.dataset.placement = placeAbove ? "top" : "bottom";
}
schedule() {
const viewport = this.#document.defaultView;
!viewport || this.scope.destroyed || this.#popover.hidden || this.#frame !== null || (this.#frame = viewport.requestAnimationFrame(() => {
this.#frame = null, this.position();
}));
}
destroy() {
this.scope.destroy();
}
}
class ReaderHeaderPopoverSurface {
scope;
#document;
#toggle;
#popover;
#isOpen;
#requestClose;
#position;
#open = !1;
constructor(options) {
this.#document = options.document, this.#toggle = options.toggle, this.#popover = options.popover, this.#isOpen = options.isOpen, this.#requestClose = options.requestClose, this.scope = options.parentScope.child(), this.#position = new ReaderHeaderPopoverPosition({
document: options.document,
root: options.root,
toggle: options.toggle,
popover: options.popover,
parentScope: this.scope,
...options.preferredPlacement === void 0 ? {} : { preferredPlacement: options.preferredPlacement }
}), this.scope.add((0, import_floating_surface_wheel.bindFloatingSurfaceWheel)(this.#popover)), this.scope.listen(
this.#document,
options.outsideEvent ?? "pointerdown",
(event) => {
!this.#isOpen() || event.composedPath().includes(this.#toggle) || event.composedPath().includes(this.#popover) || this.#requestClose();
},
options.outsideCapture ?? !0
), this.scope.listen(this.#document, "keydown", (eventValue) => {
const event = eventValue;
event.key !== "Escape" || !this.#isOpen() || (event.preventDefault(), event.stopImmediatePropagation(), this.#requestClose(), this.#toggle.isConnected && this.#toggle.focus({ preventScroll: !0 }));
}), this.scope.add(() => {
this.#open = !1, this.#popover.hidden = !0, this.#toggle.setAttribute("aria-expanded", "false");
});
}
sync(open) {
if (this.scope.destroyed) return;
const opening = open && !this.#open;
this.#open = open, this.#popover.hidden = !open, this.#toggle.setAttribute("aria-expanded", String(open)), opening && this.#position.position();
}
position() {
this.#position.position();
}
destroy() {
this.scope.destroy();
}
}
}, "b013b73a3e23c577b69c4e0505a798ad10bb1227c0fedf9e81a8aef8e00cc847");
/* Source: lite/src/collection/reader-popover-filter-controls.ts */
runtime.register("src/collection/reader-popover-filter-controls.js", function(module, exports, require) {
var reader_popover_filter_controls_exports = {};
__export(reader_popover_filter_controls_exports, {
ReaderPopoverFilterDisclosure: () => ReaderPopoverFilterDisclosure,
createReaderPopoverSearch: () => createReaderPopoverSearch,
createReaderPopoverSearchTools: () => createReaderPopoverSearchTools,
createReaderTaxonomyFilter: () => createReaderTaxonomyFilter,
syncReaderFilterOptions: () => syncReaderFilterOptions
});
module.exports = __toCommonJS(reader_popover_filter_controls_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_event_target = require("../dom/event-target.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_collection_filter_model = require("./reader-collection-filter-model.js");
function ownerLabel(owner) {
return owner === "notification" ? "消息" : owner === "history" ? "浏览历史" : "收藏与回应";
}
function monthStart(value = /* @__PURE__ */ new Date()) {
return new Date(value.getFullYear(), value.getMonth(), 1);
}
function closestTarget(event, selector) {
const target = event.target;
return typeof target?.closest == "function" ? target.closest(selector) : null;
}
function taxonomyLabel(name) {
return name === "category" ? "类别" : "标签";
}
function createReaderPopoverSearch(document, name, placeholder, label, clearLabel, renderIcon) {
const root = document.createElement("label");
root.className = "ldp-popover-search", root.append((0, import_reader_icon.renderReaderIcon)(document, "search", renderIcon));
const input = document.createElement("input");
input.className = `ldp-popover-search-input ldp-${name}-search`, input.type = "search", input.autocomplete = "off", input.spellcheck = !1, input.placeholder = placeholder, input.setAttribute("aria-label", label);
const clear = document.createElement("button");
return clear.className = `ldp-popover-search-clear ldp-${name}-search-clear`, clear.type = "button", clear.setAttribute("aria-label", clearLabel), clear.append((0, import_reader_icon.renderReaderIcon)(document, "x", renderIcon)), clear.hidden = !0, root.append(input, clear), Object.freeze({ root, input, clear });
}
function createReaderTaxonomyFilter(document, owner, name) {
const label = taxonomyLabel(name), select = document.createElement("select");
select.className = [
"ldp-reader-select",
"ldp-popover-taxonomy-filter",
`ldp-${owner}-taxonomy-filter`,
`ldp-${owner}-${name}-filter`
].join(" "), select.setAttribute("aria-label", `按${label}筛选${ownerLabel(owner)}`);
const all = document.createElement("option");
return all.value = "", all.textContent = label, select.append(all), select;
}
function createReaderPopoverSearchTools(document, owner, placeholder, label, clearLabel, renderIcon) {
const search = createReaderPopoverSearch(
document,
owner,
placeholder,
label,
clearLabel,
renderIcon
), category = createReaderTaxonomyFilter(
document,
owner,
"category"
), tag = createReaderTaxonomyFilter(document, owner, "tag"), calendarToggle = document.createElement("button");
calendarToggle.type = "button", calendarToggle.className = "ldp-user-observation-calendar-toggle", calendarToggle.setAttribute("aria-label", `按日期筛选${ownerLabel(owner)}`), calendarToggle.setAttribute("aria-haspopup", "dialog"), calendarToggle.setAttribute("aria-expanded", "false"), calendarToggle.append(
(0, import_reader_icon.renderReaderIcon)(document, "clock", renderIcon),
document.createTextNode("活动日历")
);
const calendar = document.createElement("div");
calendar.className = "ldp-user-observation-calendar", calendar.hidden = !0, calendar.setAttribute("role", "dialog"), calendar.setAttribute("aria-label", `${ownerLabel(owner)}活动日历`);
const calendarHeader = document.createElement("div");
calendarHeader.className = "ldp-user-observation-calendar-head";
const previousMonth = document.createElement("button");
previousMonth.type = "button", previousMonth.dataset.userObservationCalendarMonth = "-1", previousMonth.setAttribute("aria-label", "上个月"), previousMonth.append((0, import_reader_icon.renderReaderIcon)(document, "chevron-left", renderIcon));
const calendarTitle = document.createElement("strong");
calendarTitle.className = "ldp-user-observation-calendar-title";
const nextMonth = document.createElement("button");
nextMonth.type = "button", nextMonth.dataset.userObservationCalendarMonth = "1", nextMonth.setAttribute("aria-label", "下个月"), nextMonth.append((0, import_reader_icon.renderReaderIcon)(document, "chevron-right", renderIcon));
const today = document.createElement("button");
today.type = "button", today.dataset.userObservationCalendarToday = "", today.textContent = "今天";
const clearDate = document.createElement("button");
clearDate.type = "button", clearDate.dataset.userObservationCalendarClear = "", clearDate.textContent = "清除", calendarHeader.append(previousMonth, calendarTitle, nextMonth, today, clearDate);
const weekdays = document.createElement("div");
weekdays.className = "ldp-user-observation-calendar-weekdays", weekdays.setAttribute("aria-hidden", "true");
for (const text of ["一", "二", "三", "四", "五", "六", "日"]) {
const weekday = document.createElement("span");
weekday.textContent = text, weekdays.append(weekday);
}
const calendarGrid = document.createElement("div");
calendarGrid.className = "ldp-user-observation-calendar-grid", calendar.append(calendarHeader, weekdays, calendarGrid);
const sort = document.createElement("select");
sort.className = "ldp-reader-select ldp-user-observation-sort-filter", sort.setAttribute("aria-label", `${ownerLabel(owner)}排序字段`);
const sortOptions = Object.freeze(owner === "history" ? [
["recent-viewed", "最近查看时间"],
["first-viewed", "首次查看时间"]
] : [["time", "时间排序"]]);
for (const [value, text] of sortOptions) {
const option = document.createElement("option");
option.value = value, option.textContent = text, sort.append(option);
}
const sortDirection = document.createElement("button");
sortDirection.type = "button", sortDirection.className = "ldp-user-observation-sort-direction", sortDirection.append(
(0, import_reader_icon.renderReaderIcon)(document, "chevron-down", renderIcon),
document.createTextNode("降序")
);
const reset = document.createElement("button");
reset.type = "button", reset.className = "ldp-user-observation-filter-reset", reset.textContent = "重置";
const filters = document.createElement("div");
filters.className = `ldp-popover-taxonomy-filters ldp-user-observation-filter-panel ldp-user-observation-taxonomy-filters ldp-${owner}-taxonomy-filters`, filters.hidden = !0, filters.append(
category,
tag,
calendarToggle,
sort,
sortDirection,
reset,
calendar
);
const filterToggle = document.createElement("button");
filterToggle.type = "button", filterToggle.className = `ldp-user-observation-filter-toggle ldp-${owner}-filter-toggle`, filterToggle.setAttribute("aria-label", "综合筛选与排序"), filterToggle.setAttribute("aria-expanded", "false"), filterToggle.title = "综合筛选与排序", filterToggle.append((0, import_reader_icon.renderReaderIcon)(
document,
"header-settings",
renderIcon
));
const root = document.createElement("div");
return root.className = `ldp-popover-search-tools ldp-user-observation-detail-tools ldp-${owner}-search-tools`, search.root.classList.add("ldp-user-observation-search", "is-detail"), root.append(search.root, filterToggle, filters), Object.freeze({
root,
search,
filters,
filterToggle,
category,
tag,
calendarToggle,
calendar,
sort,
sortDirection,
reset
});
}
class ReaderPopoverFilterDisclosure {
scope;
#document;
#toggle;
#panel;
#calendarToggle;
#calendar;
#calendarTitle;
#calendarGrid;
#sort;
#sortDirection;
#reset;
#onDateChange;
#onSortChange;
#onDirectionChange;
#onReset;
#date = "";
#direction = "desc";
#dayCounts = /* @__PURE__ */ new Map();
#calendarMonth = monthStart();
constructor(options) {
const tools = options.search.closest(
".ldp-popover-search-tools"
), toggle = tools?.querySelector(
".ldp-user-observation-filter-toggle"
), panel = tools?.querySelector(
".ldp-user-observation-filter-panel"
), calendarToggle = tools?.querySelector(
".ldp-user-observation-calendar-toggle"
), calendar = tools?.querySelector(
".ldp-user-observation-calendar"
), calendarTitle = calendar?.querySelector(
".ldp-user-observation-calendar-title"
), calendarGrid = calendar?.querySelector(
".ldp-user-observation-calendar-grid"
), sort = tools?.querySelector(
".ldp-user-observation-sort-filter"
), sortDirection = tools?.querySelector(
".ldp-user-observation-sort-direction"
), reset = tools?.querySelector(
".ldp-user-observation-filter-reset"
);
if (!toggle || !panel || !calendarToggle || !calendar || !calendarTitle || !calendarGrid || !sort || !sortDirection || !reset) throw new Error("集合搜索缺少完整筛选控件");
this.#document = options.search.ownerDocument, this.#toggle = toggle, this.#panel = panel, this.#calendarToggle = calendarToggle, this.#calendar = calendar, this.#calendarTitle = calendarTitle, this.#calendarGrid = calendarGrid, this.#sort = sort, this.#sortDirection = sortDirection, this.#reset = reset, this.#onDateChange = options.onDateChange, this.#onSortChange = options.onSortChange, this.#onDirectionChange = options.onDirectionChange, this.#onReset = options.onReset, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.listen(toggle, "click", () => {
const expanded = this.#panel.hidden === !0;
this.#panel.hidden = !expanded, this.#toggle.setAttribute("aria-expanded", String(expanded)), this.#toggle.classList.toggle("is-open", expanded), expanded || this.#setCalendarExpanded(!1);
}), this.scope.listen(calendarToggle, "click", () => {
this.#setCalendarExpanded(this.#calendar.hidden === !0);
}), this.scope.listen(calendar, "click", (event) => {
this.#onCalendarClick(event);
}), this.scope.listen(sort, "change", () => {
this.#onSortChange(this.#sort.value);
}), this.scope.listen(sortDirection, "click", () => {
this.#onDirectionChange(this.#direction === "desc" ? "asc" : "desc");
}), this.scope.listen(this.#reset, "click", () => {
this.#setCalendarExpanded(!1), this.#onReset();
}), this.scope.listen(this.#document, "pointerdown", (event) => {
this.#calendar.hidden || (0, import_event_target.eventPathIncludes)(event, this.#calendar) || (0, import_event_target.eventPathIncludes)(event, this.#calendarToggle) || this.#setCalendarExpanded(!1);
}, !0);
}
sync(snapshot) {
this.#date = snapshot.date, this.#direction = snapshot.direction, this.#dayCounts = snapshot.dayCounts;
for (const option of this.#sort.options)
option.selected = option.value === snapshot.sort;
this.#toggle.classList.toggle("has-active-filter", snapshot.active), this.#syncCalendarToggle(), this.#syncSortDirectionButton(), this.#calendar.hidden || this.#renderCalendar();
}
#setCalendarExpanded(expanded) {
if (this.#calendar.hidden = !expanded, this.#calendarToggle.setAttribute("aria-expanded", String(expanded)), this.#calendarToggle.classList.toggle("is-open", expanded), expanded) {
if (this.#date) {
const selected = /* @__PURE__ */ new Date(`${this.#date}T00:00:00`);
Number.isFinite(selected.getTime()) && (this.#calendarMonth = monthStart(selected));
}
this.#renderCalendar(), this.#positionCalendar();
return;
}
for (const property of [
"top",
"left",
"transform",
"--ldp-user-observation-calendar-anchor-x"
]) this.#calendar.style.removeProperty(property);
this.#calendar.removeAttribute("data-placement");
}
#positionCalendar() {
const boundary = this.#panel.closest(
".ldp-reader-floating-window-body"
)?.getBoundingClientRect(), panel = this.#panel.getBoundingClientRect(), toggle = this.#calendarToggle.getBoundingClientRect(), calendar = this.#calendar.getBoundingClientRect();
if (!boundary || [
boundary.top,
boundary.right,
boundary.bottom,
boundary.left,
panel.top,
panel.bottom,
toggle.left,
toggle.right,
calendar.width,
calendar.height
].some((value) => !Number.isFinite(value)) || boundary.width <= 0 || boundary.height <= 0 || calendar.width <= 0 || calendar.height <= 0) return;
const inset = 8, gap = 6, boundaryTop = boundary.top + inset, boundaryBottom = boundary.bottom - inset, belowTop = panel.bottom + gap, aboveTop = panel.top - gap - calendar.height, belowSpace = boundaryBottom - belowTop, aboveSpace = panel.top - gap - boundaryTop, placeAbove = belowSpace < calendar.height && aboveSpace > belowSpace, maximumTop = Math.max(boundaryTop, boundaryBottom - calendar.height), viewportTop = Math.min(
Math.max(placeAbove ? aboveTop : belowTop, boundaryTop),
maximumTop
), minimumLeft = boundary.left + inset, maximumLeft = Math.max(
minimumLeft,
boundary.right - inset - calendar.width
), toggleCenter = (toggle.left + toggle.right) / 2, viewportLeft = Math.min(
Math.max(toggleCenter - calendar.width / 2, minimumLeft),
maximumLeft
), anchorX = Math.min(
Math.max(toggleCenter - viewportLeft, 14),
calendar.width - 14
);
this.#calendar.style.top = `${Math.round(viewportTop - panel.top)}px`, this.#calendar.style.left = `${Math.round(viewportLeft - panel.left)}px`, this.#calendar.style.transform = "none", this.#calendar.style.setProperty(
"--ldp-user-observation-calendar-anchor-x",
`${Math.round(anchorX)}px`
), this.#calendar.dataset.placement = placeAbove ? "top" : "bottom";
}
#onCalendarClick(event) {
const target = closestTarget(
event,
"[data-user-observation-calendar-month],[data-user-observation-calendar-day],[data-user-observation-calendar-today],[data-user-observation-calendar-clear]"
);
if (!target) return;
const monthOffset = target.dataset.userObservationCalendarMonth;
if (monthOffset !== void 0) {
const offset = Number(monthOffset);
if (!Number.isInteger(offset) || offset === 0) return;
this.#calendarMonth = new Date(
this.#calendarMonth.getFullYear(),
this.#calendarMonth.getMonth() + offset,
1
), this.#renderCalendar();
return;
}
if (target.dataset.userObservationCalendarToday !== void 0) {
const now = /* @__PURE__ */ new Date();
this.#calendarMonth = monthStart(now);
const day2 = (0, import_reader_collection_filter_model.readerCollectionDateKey)(now.getTime());
(this.#dayCounts.get(day2) ?? 0) > 0 ? this.#onDateChange(day2) : this.#renderCalendar();
return;
}
if (target.dataset.userObservationCalendarClear !== void 0) {
this.#onDateChange("");
return;
}
const day = target.dataset.userObservationCalendarDay;
day && (this.#dayCounts.get(day) ?? 0) > 0 && this.#onDateChange(day);
}
#syncCalendarToggle() {
const label = this.#date || "活动日历", span = this.#document.createElement("span");
span.textContent = label, this.#calendarToggle.replaceChildren(
(0, import_reader_icon.renderReaderIcon)(this.#document, "clock"),
span
), this.#calendarToggle.title = this.#date ? `当前筛选 ${this.#date}` : "按当前分类查看每月活跃程度";
}
#syncSortDirectionButton() {
const ascending = this.#direction === "asc";
this.#sortDirection.replaceChildren(
(0, import_reader_icon.renderReaderIcon)(
this.#document,
ascending ? "chevron-up" : "chevron-down"
),
this.#document.createTextNode(ascending ? "升序" : "降序")
), this.#sortDirection.setAttribute(
"aria-label",
ascending ? "切换为降序" : "切换为升序"
), this.#sortDirection.title = ascending ? "当前升序" : "当前降序";
}
#renderCalendar() {
const year = this.#calendarMonth.getFullYear(), month = this.#calendarMonth.getMonth(), today = (0, import_reader_collection_filter_model.readerCollectionDateKey)(Date.now());
this.#calendarTitle.textContent = `${year}年${String(month + 1).padStart(2, "0")}月`;
const firstWeekday = (new Date(year, month, 1).getDay() + 6) % 7, daysInMonth = new Date(year, month + 1, 0).getDate(), monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`, maximum = Math.max(
0,
...[...this.#dayCounts].filter(([day]) => day.startsWith(monthPrefix)).map(([, count]) => count)
), cells = [];
for (let index = 0; index < 42; index += 1) {
const dayNumber = index - firstWeekday + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) {
const empty = this.#document.createElement("span");
empty.className = "ldp-user-observation-calendar-empty", empty.setAttribute("aria-hidden", "true"), cells.push(empty);
continue;
}
const day = `${monthPrefix}${String(dayNumber).padStart(2, "0")}`, count = this.#dayCounts.get(day) ?? 0, level = count === 0 || maximum === 0 ? 0 : Math.max(1, Math.ceil(count / maximum * 4)), button = this.#document.createElement("button");
button.type = "button", button.className = "ldp-user-observation-calendar-day", button.dataset.userObservationCalendarDay = day, button.dataset.activityLevel = String(level), button.disabled = count <= 0, button.classList.toggle("is-selected", day === this.#date), button.setAttribute("aria-pressed", String(day === this.#date)), button.setAttribute(
"aria-label",
`${month + 1}月${dayNumber}日,${count} 条当前分类记录`
), day === today && button.setAttribute("aria-current", "date");
const dayLabel = this.#document.createElement("span");
dayLabel.textContent = String(dayNumber);
const countLabel = this.#document.createElement("small");
countLabel.textContent = count ? String(count) : "", button.append(dayLabel, countLabel), cells.push(button);
}
this.#calendarGrid.replaceChildren(...cells);
const clear = this.#calendar.querySelector(
"[data-user-observation-calendar-clear]"
);
clear && (clear.disabled = !this.#date);
}
destroy() {
this.scope.destroy();
}
}
function syncReaderFilterOptions(select, allLabel, emptyLabel, options, selected) {
const signature = JSON.stringify(options);
if (select.dataset.optionSignature !== signature) {
const all = select.ownerDocument.createElement("option");
all.value = "", all.textContent = options.length ? allLabel : emptyLabel, select.replaceChildren(all, ...options.map((entry) => {
const option = select.ownerDocument.createElement("option");
return option.value = entry.value, option.textContent = `${entry.label} · ${entry.count}`, option;
})), select.dataset.optionSignature = signature;
}
select.disabled = options.length === 0;
let matched = !1;
for (const option of select.options) {
const active = !matched && option.value === selected;
option.selected = active, active && (matched = !0);
}
!matched && select.options[0] && (select.options[0].selected = !0);
}
}, "d2b04a4f50afc67d91bf5c89cabfccf98c192b42a8f7bff81592e11b599dfedf");
/* Source: lite/src/collection/reader-unwanted-topic-filter-editor.ts */
runtime.register("src/collection/reader-unwanted-topic-filter-editor.js", function(module, exports, require) {
var reader_unwanted_topic_filter_editor_exports = {};
__export(reader_unwanted_topic_filter_editor_exports, {
ReaderUnwantedTopicFilterEditor: () => ReaderUnwantedTopicFilterEditor
});
module.exports = __toCommonJS(reader_unwanted_topic_filter_editor_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_html_element = require("../dom/html-element.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_settings_dom = require("../settings/reader-settings-dom.js"), import_reader_unwanted_topic_filter = require("./reader-unwanted-topic-filter.js");
const RULE_TABS = Object.freeze([
Object.freeze({
name: "categories",
title: "主题类别",
description: "从宿主类别目录选择;保存稳定类别 ID。",
placeholder: "搜索类别名称、slug 或 ID"
}),
Object.freeze({
name: "labels",
title: "主题标签",
description: "从宿主 Label 搜索结果选择真实标签。",
placeholder: "搜索 Label"
}),
Object.freeze({
name: "topicAuthors",
title: "OP 用户",
description: "匹配主题作者;重名结果需选择具体 @username。",
placeholder: "搜索用户名称、@username 或 ID"
}),
Object.freeze({
name: "topicFields",
title: "字符匹配",
description: "匹配指定 Topic 字段,支持普通包含与正则表达式。",
placeholder: "输入字符或正则表达式"
}),
Object.freeze({
name: "postAuthors",
title: "楼层用户",
description: "只隐藏所选用户的楼层本体,保留其回复树。",
placeholder: "搜索用户名称、@username 或 ID"
})
]), RULE_NAMES = Object.freeze(RULE_TABS.map((tab) => tab.name));
function mutableDraft(value) {
const normalized = (0, import_reader_unwanted_topic_filter.normalizeReaderUnwantedTopicFilterPreferences)(value);
return {
enabled: normalized.enabled,
categories: [...normalized.categories],
labels: [...normalized.labels],
topicAuthors: [...normalized.topicAuthors],
topicFields: [...normalized.topicFields],
postAuthors: [...normalized.postAuthors]
};
}
function frozenDraft(value) {
return (0, import_reader_unwanted_topic_filter.normalizeReaderUnwantedTopicFilterPreferences)(value);
}
function valueKey(value) {
return String(value ?? "").trim().toLocaleLowerCase("zh-CN");
}
function valuesEqual(left, right) {
return left.enabled === right.enabled && RULE_NAMES.every((name) => left[name].length === right[name].length && left[name].every((entry, index) => entry === right[name][index]));
}
function closestTarget(event, selector) {
const target = event.target;
return typeof target?.closest == "function" ? target.closest(selector) : null;
}
class ReaderUnwantedTopicFilterEditor {
scope;
element;
#document;
#preferences;
#catalog;
#notify;
#onError;
#enabled;
#tabs = /* @__PURE__ */ new Map();
#activeTitle;
#activeDescription;
#cardSearch;
#cards;
#addInput;
#topicField;
#regexMode;
#topicOptions;
#addButton;
#lookupStatus;
#results;
#status;
#reset;
#save;
#active = "categories";
#baseline = import_reader_unwanted_topic_filter.DEFAULT_READER_UNWANTED_TOPIC_FILTER_PREFERENCES;
#draft = mutableDraft(import_reader_unwanted_topic_filter.DEFAULT_READER_UNWANTED_TOPIC_FILTER_PREFERENCES);
#candidates = Object.freeze([]);
#lookupTimer = null;
#lookupSequence = 0;
#saving = !1;
constructor(options) {
this.#document = options.document, this.#preferences = options.preferences, this.#catalog = options.catalog, this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.element = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-settings"
);
const workbench = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-workbench"
), sidebar = (0, import_html_element.htmlElement)(
options.document,
"aside",
"ldp-unwanted-topic-filter-sidebar"
), enabledSwitch = (0, import_reader_settings_dom.settingsSwitch)(
options.document,
"启用预设自动过滤",
"ldp-unwanted-filter-enabled"
);
this.#enabled = enabledSwitch.input;
const master = (0, import_html_element.htmlElement)(
options.document,
"label",
"ldp-unwanted-topic-filter-master"
), masterCopy = (0, import_html_element.htmlElement)(options.document, "span", "");
masterCopy.append(
(0, import_html_element.htmlElement)(options.document, "strong", "", "自动过滤"),
(0, import_html_element.htmlElement)(options.document, "small", "", "手动免打扰始终可用")
), master.append(masterCopy, enabledSwitch.root);
const tabList = (0, import_html_element.htmlElement)(
options.document,
"nav",
"ldp-unwanted-topic-filter-tabs"
);
tabList.setAttribute("aria-label", "自动过滤规则类别");
for (const tab of RULE_TABS) {
const button = options.document.createElement("button");
button.type = "button", button.dataset.unwantedRuleTab = tab.name, button.textContent = tab.title, button.setAttribute("aria-pressed", String(tab.name === this.#active)), this.#tabs.set(tab.name, button), tabList.append(button);
}
sidebar.append(tabList);
const content = (0, import_html_element.htmlElement)(
options.document,
"section",
"ldp-unwanted-topic-filter-content"
), activeHead = (0, import_html_element.htmlElement)(
options.document,
"header",
"ldp-unwanted-topic-filter-active-head"
), activeCopy = (0, import_html_element.htmlElement)(options.document, "span", "");
this.#activeTitle = (0, import_html_element.htmlElement)(options.document, "strong"), this.#activeDescription = (0, import_html_element.htmlElement)(options.document, "small"), activeCopy.append(this.#activeTitle, this.#activeDescription), this.#cardSearch = options.document.createElement("input"), this.#cardSearch.type = "search", this.#cardSearch.autocomplete = "off", this.#cardSearch.placeholder = "搜索已添加规则", this.#cardSearch.setAttribute("aria-label", "搜索并定位已添加规则");
const cardSearchLabel = (0, import_html_element.htmlElement)(
options.document,
"label",
"ldp-unwanted-topic-filter-card-search"
);
cardSearchLabel.append(
(0, import_reader_icon.createReaderIcon)(options.document, "search"),
this.#cardSearch
), activeHead.append(activeCopy, cardSearchLabel), this.#cards = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-cards"
), this.#cards.setAttribute("aria-label", "已添加规则");
const add = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-add"
), addControls = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-add-controls"
);
this.#topicOptions = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-topic-options"
), this.#topicField = options.document.createElement("select"), this.#topicField.className = "ldp-reader-select", this.#topicField.setAttribute("aria-label", "字符匹配字段");
for (const [value, label] of [
["title", "主题标题"],
["category", "主题类别"],
["label", "主题标签"],
["user", "OP 用户"]
]) {
const option = options.document.createElement("option");
option.value = value, option.textContent = label, this.#topicField.append(option);
}
const regexLabel = (0, import_html_element.htmlElement)(
options.document,
"label",
"ldp-unwanted-topic-filter-regex"
);
this.#regexMode = options.document.createElement("input"), this.#regexMode.type = "checkbox", regexLabel.append(this.#regexMode, (0, import_html_element.htmlElement)(
options.document,
"span",
"",
"正则表达式"
)), this.#topicOptions.append(this.#topicField);
const inputLabel = (0, import_html_element.htmlElement)(
options.document,
"label",
"ldp-unwanted-topic-filter-add-input"
);
inputLabel.append((0, import_reader_icon.createReaderIcon)(options.document, "search")), this.#addInput = options.document.createElement("input"), this.#addInput.type = "search", this.#addInput.autocomplete = "off", this.#addInput.setAttribute("aria-label", "查询并添加规则"), inputLabel.append(this.#addInput), this.#addButton = options.document.createElement("button"), this.#addButton.type = "button", this.#addButton.className = "ldp-unwanted-topic-filter-add-button", this.#addButton.append(
(0, import_reader_icon.createReaderIcon)(options.document, "plus"),
(0, import_html_element.htmlElement)(options.document, "span", "", "添加")
), addControls.append(this.#topicOptions, inputLabel, this.#addButton), this.#lookupStatus = (0, import_html_element.htmlElement)(
options.document,
"p",
"ldp-unwanted-topic-filter-lookup-status"
), this.#lookupStatus.setAttribute("role", "status"), this.#lookupStatus.setAttribute("aria-live", "polite");
const helpRow = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-help-row"
);
helpRow.append(this.#lookupStatus, regexLabel), this.#results = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-results"
), this.#results.setAttribute("role", "listbox"), add.append(addControls, helpRow, this.#results), content.append(activeHead, this.#cards, add), workbench.append(sidebar, content);
const footer = (0, import_html_element.htmlElement)(
options.document,
"footer",
"ldp-unwanted-topic-filter-footer"
);
this.#status = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-unwanted-topic-filter-status"
), this.#status.setAttribute("role", "status"), this.#status.setAttribute("aria-live", "polite");
const actions = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-actions"
);
this.#reset = this.#textButton("恢复默认", "rotate-ccw"), this.#save = this.#textButton("保存设置", "check"), this.#save.classList.add("ldp-unwanted-topic-filter-save"), actions.append(this.#reset, this.#save), footer.append(this.#status, master, actions), this.element.append(workbench, footer), this.#listen(), this.#preferences.subscribe((preferences) => {
this.#changeCount() || this.#accept(preferences);
}, this.scope), this.scope.add(() => this.#clearLookup()), this.#accept(this.#preferences.read());
}
open() {
this.#accept(this.#preferences.read()), this.#syncActive();
}
async saveIfChanged(showFeedback = !1) {
const update = this.#preferences.update;
if (typeof update != "function" || this.#saving) return !1;
const value = frozenDraft(this.#draft);
if (valuesEqual(value, this.#baseline)) return !0;
this.#saving = !0, this.#refreshStatus();
try {
return await update.call(this.#preferences, value), this.#accept(value), showFeedback && this.#notify("自动过滤设置已保存"), !0;
} catch (cause) {
return this.#onError(cause), this.#notify("自动过滤设置保存失败"), !1;
} finally {
this.#saving = !1, this.#refreshStatus();
}
}
destroy() {
this.scope.destroy();
}
#textButton(label, icon) {
const button = this.#document.createElement("button");
return button.type = "button", button.append(
(0, import_reader_icon.createReaderIcon)(this.#document, icon),
(0, import_html_element.htmlElement)(this.#document, "span", "", label)
), button;
}
#listen() {
this.scope.listen(this.element, "click", (event) => {
const tab = closestTarget(
event,
"[data-unwanted-rule-tab]"
);
if (tab) {
const name = tab.dataset.unwantedRuleTab;
RULE_NAMES.includes(name) && (this.#active = name, this.#syncActive());
return;
}
const remove = closestTarget(
event,
"[data-unwanted-rule-remove]"
);
if (remove) {
const index = Number(remove.dataset.unwantedRuleRemove);
Number.isSafeInteger(index) && index >= 0 && (this.#draft[this.#active].splice(index, 1), this.#renderCards(), this.#refreshStatus());
return;
}
const candidate = closestTarget(
event,
"[data-unwanted-rule-candidate]"
);
if (candidate) {
const index = Number(candidate.dataset.unwantedRuleCandidate), value = this.#candidates[index]?.value;
value && this.#addValue(value);
}
}), this.scope.listen(this.#enabled, "change", () => {
this.#draft.enabled = this.#enabled.checked, this.#refreshStatus();
}), this.scope.listen(this.#cardSearch, "input", () => this.#renderCards()), this.scope.listen(this.#addInput, "input", () => this.#scheduleLookup()), this.scope.listen(this.#addInput, "focus", () => this.#scheduleLookup()), this.scope.listen(this.#topicField, "change", () => this.#refreshAddState()), this.scope.listen(this.#regexMode, "change", () => this.#refreshAddState()), this.scope.listen(this.#addButton, "click", () => {
this.#active === "topicFields" && this.#addTopicField();
}), this.scope.listen(this.#addInput, "keydown", (event) => {
const keyboard = event;
keyboard.key !== "Enter" || this.#active !== "topicFields" || (keyboard.preventDefault(), this.#addTopicField());
}), this.scope.listen(this.#reset, "click", () => {
this.#draft = mutableDraft(
import_reader_unwanted_topic_filter.DEFAULT_READER_UNWANTED_TOPIC_FILTER_PREFERENCES
), this.#syncAll();
}), this.scope.listen(this.#save, "click", () => {
this.saveIfChanged(!0);
});
}
#accept(preferences) {
this.#baseline = (0, import_reader_unwanted_topic_filter.normalizeReaderUnwantedTopicFilterPreferences)(preferences), this.#draft = mutableDraft(this.#baseline), this.#syncAll();
}
#syncAll() {
this.#enabled.checked = this.#draft.enabled, this.#syncActive(), this.#refreshStatus();
}
#syncActive() {
const tab = RULE_TABS.find((entry) => entry.name === this.#active);
for (const [name, button] of this.#tabs)
button.setAttribute("aria-pressed", String(name === this.#active));
this.#activeTitle.textContent = tab.title, this.#activeDescription.textContent = tab.description, this.#addInput.value = "", this.#addInput.placeholder = tab.placeholder, this.#cardSearch.value = "", this.#topicOptions.hidden = this.#active !== "topicFields", this.#addButton.hidden = this.#active !== "topicFields", this.#clearLookup(), this.#lookupStatus.textContent = this.#active === "topicFields" ? "输入字符后添加;正则使用 /表达式/标志 形式。" : "输入后自动查询宿主,必须选择一个真实候选。", this.#renderCards(), this.#refreshAddState();
}
#changeCount() {
const value = frozenDraft(this.#draft);
return +(value.enabled !== this.#baseline.enabled) + RULE_NAMES.reduce((count, name) => count + Number(
value[name].length !== this.#baseline[name].length || value[name].some((entry, index) => entry !== this.#baseline[name][index])
), 0);
}
#refreshStatus() {
const count = this.#changeCount();
this.#status.textContent = this.#saving ? "正在保存…" : count ? `有 ${count} 项未保存` : "已与当前设置同步", this.#save.disabled = this.#saving || count === 0, this.#reset.disabled = valuesEqual(
frozenDraft(this.#draft),
import_reader_unwanted_topic_filter.DEFAULT_READER_UNWANTED_TOPIC_FILTER_PREFERENCES
);
}
#renderCards() {
const values = this.#draft[this.#active], query = valueKey(this.#cardSearch.value), cards = values.map((value, index) => {
const presentation = this.#rulePresentation(value), card = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-unwanted-topic-filter-card"
), copy = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-unwanted-topic-filter-card-copy"
);
copy.append(
(0, import_html_element.htmlElement)(this.#document, "strong", "", presentation.label),
...presentation.detail ? [(0, import_html_element.htmlElement)(this.#document, "small", "", presentation.detail)] : []
);
const remove = this.#document.createElement("button");
remove.type = "button", remove.dataset.unwantedRuleRemove = String(index), remove.setAttribute("aria-label", `删除规则 ${presentation.label}`), remove.title = "删除规则", remove.append((0, import_reader_icon.createReaderIcon)(this.#document, "x")), card.append(copy, remove);
const matches = !query || valueKey(
`${presentation.label} ${presentation.detail} ${value}`
).includes(query);
return card.classList.toggle("is-search-match", !!(query && matches)), card.classList.toggle("is-search-miss", !!(query && !matches)), card;
});
if (!cards.length) {
this.#cards.replaceChildren((0, import_html_element.htmlElement)(
this.#document,
"p",
"ldp-unwanted-topic-filter-card-empty",
"当前类别还没有规则。"
));
return;
}
this.#cards.replaceChildren(...cards);
const firstMatch = query ? this.#cards.querySelector(
".ldp-unwanted-topic-filter-card.is-search-match"
) : null;
firstMatch && (firstMatch.classList.add("is-located"), queueMicrotask(() => {
firstMatch.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
}));
}
#rulePresentation(value) {
if (this.#active === "categories") {
const category = this.#catalog.categories().find((entry) => entry.id === Number(value));
return Object.freeze(category ? {
label: this.#categoryLabel(category),
detail: ""
} : { label: value, detail: "" });
}
if (this.#active === "labels")
return Object.freeze({ label: `#${value}`, detail: "" });
if (this.#active === "topicAuthors")
return Object.freeze({ label: `@${value}`, detail: "" });
if (this.#active === "postAuthors")
return Object.freeze({ label: `@${value}`, detail: "" });
const separator = value.indexOf(":"), field = separator > 0 ? value.slice(0, separator) : "title", matcher = separator > 0 ? value.slice(separator + 1) : value, fieldName = (/* @__PURE__ */ new Map([
["title", "主题标题"],
["category", "主题类别"],
["label", "主题标签"],
["user", "OP 用户"],
["topic", "Topic ID"]
])).get(valueKey(field)) ?? field;
return Object.freeze({
label: `${fieldName}:${matcher}`,
detail: ""
});
}
#categoryLabel(category) {
const parent = category.parentCategoryId ? this.#catalog.categories().find((entry) => entry.id === category.parentCategoryId) : null;
if (parent) {
const parentName = valueKey(parent.name), categoryName = valueKey(category.name);
if (categoryName === parentName || categoryName.startsWith(`${parentName},`) || categoryName.startsWith(`${parentName},`)) return category.name;
}
return `${parent ? `${parent.name} / ` : ""}${category.name}`;
}
#clearLookup() {
this.#lookupSequence += 1, this.#lookupTimer !== null && clearTimeout(this.#lookupTimer), this.#lookupTimer = null, this.#candidates = Object.freeze([]), this.#results.replaceChildren(), this.#results.hidden = !0;
}
#scheduleLookup() {
if (this.#clearLookup(), this.#active === "topicFields") {
this.#refreshAddState();
return;
}
const query = this.#addInput.value.trim();
if (!query) {
this.#lookupStatus.textContent = "输入后自动查询宿主,必须选择一个真实候选。";
return;
}
const sequence = this.#lookupSequence;
this.#lookupStatus.textContent = "正在查询宿主…", this.#lookupTimer = setTimeout(() => {
this.#lookupTimer = null, this.#lookup(query).then((candidates) => {
if (sequence !== this.#lookupSequence) return;
const existing = new Set(
this.#draft[this.#active].map((value) => valueKey(value))
);
this.#candidates = Object.freeze(candidates.filter((candidate) => !existing.has(valueKey(candidate.value)))), this.#renderCandidates(
candidates.length - this.#candidates.length
);
}).catch((cause) => {
sequence === this.#lookupSequence && (this.#onError(cause), this.#lookupStatus.textContent = "宿主查询失败,请稍后重试。", this.#results.replaceChildren(), this.#results.hidden = !0);
});
}, 240);
}
async #lookup(query) {
if (this.#active === "categories") {
const normalized = valueKey(query);
return Object.freeze(this.#catalog.categories().filter((category) => valueKey(
`${this.#categoryLabel(category)} ${category.slug} ${category.id}`
).includes(normalized)).slice(0, 30).map((category) => Object.freeze({
value: String(category.id),
label: this.#categoryLabel(category),
detail: `${category.slug || "无 slug"} · #${category.id}`,
searchText: `${category.name} ${category.slug} ${category.id}`
})));
}
if (this.#active === "labels") {
const tags = await this.#catalog.searchTags({
query,
categoryId: 0,
selected: Object.freeze([])
});
return Object.freeze(tags.slice(0, 30).map((tag) => Object.freeze({
value: tag.name,
label: `#${tag.name}`,
detail: tag.id ? `Label #${tag.id}` : "Label",
searchText: `${tag.name} ${tag.id ?? ""}`
})));
}
const users = await this.#catalog.searchUsers(query);
return Object.freeze(users.slice(0, 30).map((user) => this.#userCandidate(user)));
}
#userCandidate(user) {
return Object.freeze({
value: user.username,
label: user.name || `@${user.username}`,
detail: `@${user.username}${user.id ? ` · #${user.id}` : ""}`,
searchText: `${user.name} ${user.username} ${user.id ?? ""}`
});
}
#renderCandidates(excludedCount = 0) {
this.#lookupStatus.textContent = this.#candidates.length ? `找到 ${this.#candidates.length} 个候选,请选择具体项。` : excludedCount > 0 ? "匹配项已经全部添加。" : "宿主中没有匹配的合法字段。", this.#results.hidden = !this.#candidates.length, this.#results.replaceChildren(...this.#candidates.map((candidate, index) => {
const button = this.#document.createElement("button");
return button.type = "button", button.dataset.unwantedRuleCandidate = String(index), button.setAttribute("role", "option"), button.append(
(0, import_html_element.htmlElement)(this.#document, "strong", "", candidate.label),
(0, import_html_element.htmlElement)(this.#document, "small", "", candidate.detail)
), button;
}));
}
#addValue(value) {
const normalized = value.trim();
if (!normalized) return;
const values = this.#draft[this.#active], key = valueKey(normalized);
values.some((entry) => valueKey(entry) === key) || values.push(normalized), this.#addInput.value = "", this.#clearLookup(), this.#lookupStatus.textContent = "已加入规则草稿;保存设置后生效。", this.#renderCards(), this.#refreshStatus();
}
#addTopicField() {
if (this.#active !== "topicFields") return;
const input = this.#addInput.value.trim();
if (!input) return;
let matcher = input;
this.#regexMode.checked && !matcher.startsWith("/") && (matcher = `/${matcher.replace(/\//g, "\\/")}/i`);
const rule = `${this.#topicField.value || "title"}:${matcher}`;
if (!(0, import_reader_unwanted_topic_filter.readerUnwantedTopicFieldRuleIsValid)(rule)) {
this.#lookupStatus.textContent = "正则表达式无效,请检查斜杠、标志或括号。", this.#addButton.disabled = !0;
return;
}
this.#addValue(rule);
}
#refreshAddState() {
if (this.#active !== "topicFields") {
this.#addButton.disabled = !0;
return;
}
const input = this.#addInput.value.trim();
let matcher = input;
this.#regexMode.checked && matcher && !matcher.startsWith("/") && (matcher = `/${matcher.replace(/\//g, "\\/")}/i`);
const rule = `${this.#topicField.value || "title"}:${matcher}`, valid = !!(input && (0, import_reader_unwanted_topic_filter.readerUnwantedTopicFieldRuleIsValid)(rule));
this.#addButton.disabled = !valid, this.#lookupStatus.textContent = input ? valid ? "规则格式有效,可添加到当前草稿。" : "正则表达式无效,请检查斜杠、标志或括号。" : "输入字符后添加;正则使用 /表达式/标志 形式。";
}
}
}, "32bbea274af3aa2858ec1a89afc696054e8cb25ca80a22acde9bcf515f4ee4e9");
/* Source: lite/src/collection/reader-unwanted-topic-filter.ts */
runtime.register("src/collection/reader-unwanted-topic-filter.js", function(module, exports, require) {
var reader_unwanted_topic_filter_exports = {};
__export(reader_unwanted_topic_filter_exports, {
DEFAULT_READER_UNWANTED_TOPIC_FILTER_PREFERENCES: () => DEFAULT_READER_UNWANTED_TOPIC_FILTER_PREFERENCES,
normalizeReaderUnwantedTopicFilterPreferences: () => normalizeReaderUnwantedTopicFilterPreferences,
readerPreferencesUnwantedTopicFilterAdapter: () => readerPreferencesUnwantedTopicFilterAdapter,
readerUnwantedPostAuthorMatches: () => readerUnwantedPostAuthorMatches,
readerUnwantedTopicFieldRuleIsValid: () => readerUnwantedTopicFieldRuleIsValid,
readerUnwantedTopicFilterMatch: () => readerUnwantedTopicFilterMatch,
readerUnwantedTopicFilterPreferencesEqual: () => readerUnwantedTopicFilterPreferencesEqual
});
module.exports = __toCommonJS(reader_unwanted_topic_filter_exports);
const DEFAULT_READER_UNWANTED_TOPIC_FILTER_PREFERENCES = Object.freeze({
enabled: !1,
categories: Object.freeze([]),
labels: Object.freeze([]),
topicAuthors: Object.freeze([]),
topicFields: Object.freeze([]),
postAuthors: Object.freeze([])
}), readerPreferencesUnwantedTopicFilterAdapter = Object.freeze({
read: (preferences) => normalizeReaderUnwantedTopicFilterPreferences({
enabled: preferences.unwantedTopicFilterEnabled,
categories: preferences.unwantedTopicFilterCategories,
labels: preferences.unwantedTopicFilterLabels,
topicAuthors: preferences.unwantedTopicFilterTopicAuthors,
topicFields: preferences.unwantedTopicFilterTopicFields,
postAuthors: preferences.unwantedTopicFilterPostAuthors
}),
createPatch: (preferences) => Object.freeze({
unwantedTopicFilterEnabled: preferences.enabled,
unwantedTopicFilterCategories: preferences.categories,
unwantedTopicFilterLabels: preferences.labels,
unwantedTopicFilterTopicAuthors: preferences.topicAuthors,
unwantedTopicFilterTopicFields: preferences.topicFields,
unwantedTopicFilterPostAuthors: preferences.postAuthors
})
});
function text(value, maximum = 80) {
return String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
}
function values(value, transform = (entry) => entry) {
const source = Array.isArray(value) ? value : String(value ?? "").split(/[\n,,]+/), result = /* @__PURE__ */ new Map();
for (const item of source) {
const entry = transform(text(item)), key = entry.toLocaleLowerCase("zh-CN");
if (key && !result.has(key) && result.set(key, entry), result.size >= 100) break;
}
return Object.freeze([...result.values()]);
}
function userValues(value) {
return values(value, (entry) => entry.replace(/^@+/, "").trim());
}
function labelValues(value) {
return values(value, (entry) => entry.replace(/^[##]+/, "").trim());
}
function normalizeReaderUnwantedTopicFilterPreferences(value) {
return Object.freeze({
enabled: value?.enabled === !0,
categories: values(value?.categories),
labels: labelValues(value?.labels),
topicAuthors: userValues(value?.topicAuthors),
topicFields: values(value?.topicFields),
postAuthors: userValues(value?.postAuthors)
});
}
function readerUnwantedTopicFilterPreferencesEqual(left, right) {
return left.enabled === right.enabled && [
"categories",
"labels",
"topicAuthors",
"topicFields",
"postAuthors"
].every((name) => {
const key = name;
return left[key].length === right[key].length && left[key].every((entry, index) => entry === right[key][index]);
});
}
function comparisonKey(value) {
return text(value).toLocaleLowerCase("zh-CN");
}
function matchingSet(values2, candidates) {
const candidateKeys = new Set(candidates.map(comparisonKey).filter(Boolean));
return Object.freeze(values2.filter((value) => candidateKeys.has(comparisonKey(value))));
}
function fieldValue(name, input) {
return name === "title" || name === "标题" ? input.title : name === "category" || name === "类别" || name === "分类" ? [input.categoryId, input.categoryName, input.categorySlug].filter((value) => value != null).join(" ") : name === "label" || name === "tag" || name === "标签" ? (input.labels ?? []).join(" ") : name === "user" || name === "author" || name === "用户" || name === "作者" ? input.authorUsername ?? "" : name === "topic" || name === "id" ? String(input.topicId) : "";
}
function topicFieldMatch(rule, input) {
const separator = rule.indexOf(":");
if (separator < 1)
return topicFieldValueMatches(rule, input.title);
const name = comparisonKey(rule.slice(0, separator)), expected = rule.slice(separator + 1).trim();
return !!(expected && topicFieldValueMatches(
expected,
fieldValue(name, input)
));
}
function topicFieldRegularExpression(value) {
const source = value.trim();
if (!source.startsWith("/")) return null;
const match = source.match(/^\/([\s\S]*)\/([imsu]*)$/);
if (!match) return !1;
try {
return new RegExp(match[1] ?? "", match[2] ?? "");
} catch {
return !1;
}
}
function topicFieldValueMatches(expected, actual) {
const expression = topicFieldRegularExpression(expected);
return expression === !1 ? !1 : expression ? expression.test(actual) : comparisonKey(actual).includes(comparisonKey(expected));
}
function readerUnwantedTopicFieldRuleIsValid(ruleValue) {
const rule = text(ruleValue);
if (!rule) return !1;
const separator = rule.indexOf(":"), expected = separator < 1 ? rule : rule.slice(separator + 1).trim();
return expected ? topicFieldRegularExpression(expected) !== !1 : !1;
}
function readerUnwantedTopicFilterMatch(preferencesValue, input) {
const preferences = normalizeReaderUnwantedTopicFilterPreferences(
preferencesValue
);
if (!preferences.enabled) return null;
const matches = [];
for (const category of matchingSet(preferences.categories, [
input.categoryId,
input.categoryName,
input.categorySlug
])) matches.push(Object.freeze({
kind: "category",
rule: category,
label: `类别:${input.categoryName || input.categorySlug || category}`
}));
for (const label of matchingSet(preferences.labels, input.labels ?? []))
matches.push(Object.freeze({
kind: "label",
rule: label,
label: `标签:${label}`
}));
for (const author of matchingSet(
preferences.topicAuthors,
[input.authorUsername]
)) matches.push(Object.freeze({
kind: "topic-author",
rule: author,
label: `OP:@${author}`
}));
for (const field of preferences.topicFields.filter((rule) => topicFieldMatch(rule, input))) matches.push(Object.freeze({
kind: "topic-field",
rule: field,
label: `字段:${field}`
}));
const first = matches[0];
return first ? Object.freeze({
...first,
label: matches.map((match) => match.label).join(";"),
matches: Object.freeze(matches)
}) : null;
}
function readerUnwantedPostAuthorMatches(preferencesValue, username) {
const preferences = normalizeReaderUnwantedTopicFilterPreferences(
preferencesValue
);
if (!preferences.enabled) return !1;
const candidate = comparisonKey(String(username ?? "").replace(/^@+/, ""));
return !!(candidate && preferences.postAuthors.some((entry) => comparisonKey(entry) === candidate));
}
}, "2abe1ca947c6902a70fee8e99a0637c6f4705c9fe64ed7f5739e1d3df5fd42da");
/* Source: lite/src/collection/reader-unwanted-topic-repository.ts */
runtime.register("src/collection/reader-unwanted-topic-repository.js", function(module, exports, require) {
var reader_unwanted_topic_repository_exports = {};
__export(reader_unwanted_topic_repository_exports, {
READER_UNWANTED_TOPIC_MAX_RECORDS: () => READER_UNWANTED_TOPIC_MAX_RECORDS,
READER_UNWANTED_TOPIC_STORAGE_KEY: () => READER_UNWANTED_TOPIC_STORAGE_KEY,
ReaderUnwantedTopicRepository: () => ReaderUnwantedTopicRepository,
mergeReaderUnwantedTopicValues: () => mergeReaderUnwantedTopicValues,
normalizeReaderUnwantedTopicRecord: () => normalizeReaderUnwantedTopicRecord
});
module.exports = __toCommonJS(reader_unwanted_topic_repository_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_signal = require("../kernel/signal.js"), import_reader_account_scoped_storage = require("../state/reader-account-scoped-storage.js");
const READER_UNWANTED_TOPIC_STORAGE_KEY = "linuxdo-enhanced-reader:unwanted-topics", READER_UNWANTED_TOPIC_MAX_RECORDS = 2e3;
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function text(value, maximum) {
return String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
}
function timestamp(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
function categoryId(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
function matchedCategoryValue(value) {
for (const part of value.split(";")) {
const match = part.trim().match(/^类别[::]\s*(.+)$/);
if (match?.[1]) return match[1].trim();
}
return "";
}
function normalizeHref(value, topicId) {
const href = text(value, 512);
if (!href) return `/t/${topicId}`;
try {
const parsed = new URL(href, "https://reader.invalid");
return /^https?:$/.test(parsed.protocol) ? parsed.origin === "https://reader.invalid" ? `${parsed.pathname}${parsed.search}${parsed.hash}` : parsed.href : `/t/${topicId}`;
} catch {
return `/t/${topicId}`;
}
}
function normalizeLabel(value) {
return text(value, 36).replace(/^[##]+/, "").trim();
}
function normalizeLabels(value) {
if (!Array.isArray(value)) return Object.freeze([]);
const labels = /* @__PURE__ */ new Map();
for (const item of value) {
const label = normalizeLabel(item), key = label.toLocaleLowerCase("zh-CN");
if (key && !labels.has(key) && labels.set(key, label), labels.size >= 24) break;
}
return Object.freeze([...labels.values()]);
}
function searchText(input) {
return [
input.title,
`Topic ${input.topicId}`,
input.href,
input.note,
...input.labels,
input.categoryId === null ? "" : `类别 ${input.categoryId}`,
input.categoryName,
input.categorySlug,
input.matchedRule
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
}
function normalizeReaderUnwantedTopicRecord(value) {
const source = record(value), topicId = (0, import_identifiers.tryDiscourseTopicId)(source?.topicId);
if (!source || topicId === null) return null;
const hiddenAt = timestamp(source.hiddenAt), updatedAt = timestamp(source.updatedAt) || hiddenAt;
if (!hiddenAt || !updatedAt) return null;
const matchedRule = text(source.matchedRule, 2e3), legacyCategory = matchedCategoryValue(matchedRule), normalizedCategoryId = categoryId(source.categoryId) ?? categoryId(legacyCategory), normalizedCategoryName = text(source.categoryName, 120) || (categoryId(legacyCategory) === null ? legacyCategory : ""), base = Object.freeze({
topicId,
title: text(source.title, 180) || `帖子 #${topicId}`,
href: normalizeHref(source.href, topicId),
note: text(source.note, 240),
labels: normalizeLabels(source.labels),
categoryId: normalizedCategoryId,
categoryName: normalizedCategoryName,
categorySlug: text(source.categorySlug, 120),
source: source.source === "automatic" ? "automatic" : "manual",
matchedRule,
matchedCategory: source.matchedCategory === !0 || !!legacyCategory,
hiddenAt: Math.min(hiddenAt, updatedAt),
updatedAt: Math.max(hiddenAt, updatedAt)
});
return Object.freeze({
...base,
searchText: searchText(base)
});
}
function mergeReaderUnwantedTopicValues(local, remote) {
const left = normalizeReaderUnwantedTopicRecord(local), right = normalizeReaderUnwantedTopicRecord(remote);
if (!left) return right;
if (!right) return left;
if (left.topicId !== right.topicId)
return left.updatedAt >= right.updatedAt ? left : right;
const recent = left.updatedAt >= right.updatedAt ? left : right, older = recent === left ? right : left;
return normalizeReaderUnwantedTopicRecord({
...older,
...recent,
title: recent.title || older.title,
href: recent.href || older.href,
labels: [...older.labels, ...recent.labels],
categoryId: recent.categoryId ?? older.categoryId,
categoryName: recent.categoryName || older.categoryName,
categorySlug: recent.categorySlug || older.categorySlug,
matchedRule: recent.matchedRule || older.matchedRule,
matchedCategory: recent.matchedCategory || older.matchedCategory,
hiddenAt: Math.min(left.hiddenAt, right.hiddenAt),
updatedAt: Math.max(left.updatedAt, right.updatedAt)
});
}
class ReaderUnwantedTopicRepository {
changes = new import_signal.Signal();
#storage;
#key;
#accountStorage;
#maxRecords;
#now;
#snapshot = Object.freeze({
records: Object.freeze([]),
revision: 0,
source: "fallback"
});
constructor(options) {
if (this.#storage = options.storage, this.#accountStorage = options.key === void 0 && options.authScope !== void 0 ? (0, import_reader_account_scoped_storage.readerAccountScopedStorageIdentity)(
READER_UNWANTED_TOPIC_STORAGE_KEY,
options.authScope
) : null, this.#key = text(
options.key ?? this.#accountStorage?.key ?? READER_UNWANTED_TOPIC_STORAGE_KEY,
512
), !this.#key) throw new Error("不想看 storage key 不能为空");
if (this.#maxRecords = Math.floor(Number(
options.maxRecords ?? READER_UNWANTED_TOPIC_MAX_RECORDS
)), !Number.isSafeInteger(this.#maxRecords) || this.#maxRecords < 1)
throw new RangeError("不想看 maxRecords 必须是正安全整数");
this.#now = options.now ?? Date.now;
}
get snapshot() {
return this.#snapshot;
}
get storageKey() {
return this.#key;
}
load() {
return this.#readAndCommit("initial");
}
reloadExternal() {
return this.#readAndCommit("external-reload");
}
#readAndCommit(source) {
try {
const stored = this.#accountStorage ? (0, import_reader_account_scoped_storage.readReaderAccountScopedString)(this.#storage, this.#accountStorage) : this.#storage.getItem(this.#key), raw = stored === null ? [] : JSON.parse(stored);
if (!Array.isArray(raw)) throw new TypeError("不想看存储值必须是数组");
const records = this.#normalizeMany(raw);
return JSON.stringify(records) !== JSON.stringify(raw) && this.#persist(records), this.#commit(records, source);
} catch {
return this.#commit([], "fallback");
}
}
has(topicIdValue) {
const topicId = (0, import_identifiers.tryDiscourseTopicId)(topicIdValue);
return topicId !== null && this.#snapshot.records.some((entry) => entry.topicId === topicId);
}
isManuallyHidden(topicIdValue) {
const topicId = (0, import_identifiers.tryDiscourseTopicId)(topicIdValue);
return topicId !== null && this.#snapshot.records.some((entry) => entry.topicId === topicId && entry.source === "manual");
}
ordered() {
return this.#ordered(this.#snapshot.records);
}
remember(input) {
this.#mergeStoredBeforeMutation();
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), previous = this.#snapshot.records.find((entry) => entry.topicId === topicId), now = this.#now(), incoming = normalizeReaderUnwantedTopicRecord({
topicId,
title: input.title,
href: input.href,
note: previous?.note ?? "",
labels: previous?.labels ?? [],
categoryId: input.categoryId === void 0 ? previous?.categoryId ?? null : input.categoryId,
categoryName: input.categoryName === void 0 ? previous?.categoryName ?? "" : input.categoryName,
categorySlug: input.categorySlug === void 0 ? previous?.categorySlug ?? "" : input.categorySlug,
source: input.source ?? previous?.source ?? "manual",
matchedRule: input.matchedRule ?? previous?.matchedRule ?? "",
matchedCategory: input.matchedCategory ?? previous?.matchedCategory ?? !1,
hiddenAt: previous?.hiddenAt ?? now,
updatedAt: now
});
return this.#persistAndCommit([
incoming,
...this.#snapshot.records.filter((entry) => entry.topicId !== topicId)
], "remember");
}
update(topicIdValue, patch) {
this.#mergeStoredBeforeMutation();
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue), previous = this.#snapshot.records.find((entry) => entry.topicId === topicId);
if (!previous) return this.#snapshot;
const next = normalizeReaderUnwantedTopicRecord({
...previous,
...Object.hasOwn(patch, "note") ? { note: patch.note } : {},
...Object.hasOwn(patch, "labels") ? { labels: patch.labels } : {},
updatedAt: this.#now()
});
return this.#persistAndCommit([
next,
...this.#snapshot.records.filter((entry) => entry.topicId !== topicId)
], "update");
}
remove(topicIdValue) {
return this.removeMany([topicIdValue]);
}
removeMany(topicIdValues) {
this.#mergeStoredBeforeMutation();
const topicIds = /* @__PURE__ */ new Set();
for (const value of topicIdValues)
try {
topicIds.add((0, import_identifiers.discourseTopicId)(value));
} catch {
}
if (!topicIds.size) return this.#snapshot;
const next = this.#snapshot.records.filter((entry) => !topicIds.has(entry.topicId));
return next.length === this.#snapshot.records.length ? this.#snapshot : this.#persistAndCommit(next, "remove");
}
clear() {
return this.#mergeStoredBeforeMutation(), this.#persistAndCommit([], "clear");
}
replaceExternal(values) {
return this.#persistAndCommit(this.#normalizeMany(values), "external-sync");
}
#normalizeMany(values) {
const records = /* @__PURE__ */ new Map();
for (const value of values) {
const incoming = normalizeReaderUnwantedTopicRecord(value);
if (!incoming) continue;
const previous = records.get(incoming.topicId), merged = previous ? mergeReaderUnwantedTopicValues(previous, incoming) : incoming;
merged && records.set(merged.topicId, merged);
}
return this.#ordered([...records.values()]);
}
#mergeStoredBeforeMutation() {
let stored;
try {
stored = this.#accountStorage ? (0, import_reader_account_scoped_storage.readReaderAccountScopedString)(this.#storage, this.#accountStorage) : this.#storage.getItem(this.#key);
} catch {
return;
}
(stored ?? "[]") !== JSON.stringify(this.#snapshot.records) && this.#readAndCommit("external-reload");
}
#ordered(values) {
return Object.freeze([...values].sort((left, right) => right.hiddenAt - left.hiddenAt || right.topicId - left.topicId).slice(0, this.#maxRecords));
}
#persistAndCommit(records, source) {
const ordered = this.#ordered(records);
return this.#persist(ordered), this.#commit(ordered, source);
}
#persist(records) {
if (!records.length && this.#storage.removeItem && !this.#accountStorage) {
this.#storage.removeItem(this.#key);
return;
}
this.#storage.setItem(this.#key, JSON.stringify(records));
}
#commit(records, source) {
return this.#snapshot = Object.freeze({
records: Object.freeze([...records]),
revision: this.#snapshot.revision + 1,
source
}), this.changes.emit(this.#snapshot), this.#snapshot;
}
}
}, "4052c2d17b20114900ba83d03eddd38803c002a64c71c446c2b7bc4cfcddf00a");
/* Source: lite/src/collection/reader-unwanted-topic-view.ts */
runtime.register("src/collection/reader-unwanted-topic-view.js", function(module, exports, require) {
var reader_unwanted_topic_view_exports = {};
__export(reader_unwanted_topic_view_exports, {
ReaderUnwantedTopicView: () => ReaderUnwantedTopicView
});
module.exports = __toCommonJS(reader_unwanted_topic_view_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_html_element = require("../dom/html-element.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_floating_window_frame = require("../shell/reader-floating-window-frame.js"), import_reader_unwanted_topic_filter_editor = require("./reader-unwanted-topic-filter-editor.js"), import_reader_collection_floating_window = require("./reader-collection-floating-window.js");
function closestTarget(event, selector) {
const target = event.target;
return typeof target?.closest == "function" ? target.closest(selector) : null;
}
function defaultRelativeTime(timestamp) {
const elapsed = Math.max(0, Date.now() - timestamp);
return elapsed < 6e4 ? "刚刚" : elapsed < 36e5 ? `${Math.floor(elapsed / 6e4)} 分钟前` : elapsed < 864e5 ? `${Math.floor(elapsed / 36e5)} 小时前` : elapsed < 30 * 864e5 ? `${Math.floor(elapsed / 864e5)} 天前` : new Date(timestamp).toLocaleDateString("zh-CN");
}
function labelKey(value) {
return String(value ?? "").replace(/^[##]+/, "").replace(/\s+/g, " ").trim().slice(0, 36).toLocaleLowerCase("zh-CN");
}
function categoryRecordKey(record) {
if (!record.matchedCategory) return "";
if (record.categoryId !== null) return `id:${record.categoryId}`;
const value = record.categoryName || record.categorySlug;
return value ? `name:${labelKey(value)}` : "";
}
function matchedRuleValues(record, labels) {
const accepted = new Set(labels);
return Object.freeze(record.matchedRule.split(";").flatMap((part) => {
const match = part.trim().match(/^([^::]+)[::]\s*(.+)$/);
return match?.[1] && match[2] && accepted.has(match[1].trim()) ? [match[2].trim()] : [];
}));
}
class ReaderUnwantedTopicView {
scope;
window;
#document;
#topics;
#filterPreferences;
#filterCatalog;
#openTarget;
#relativeTime;
#notify;
#onError;
#topicPane;
#settingsPane;
#filterEditor;
#settingsButton;
#backButton;
#search;
#searchResult;
#filterBar;
#addFilter;
#labelFilter;
#categoryFilter;
#topicLabelFilter;
#topicAuthorFilter;
#topicFieldFilter;
#postAuthorFilter;
#sourceFilter;
#labelOptions;
#bulkToggle;
#bulkBar;
#bulkStatus;
#bulkSelectAll;
#bulkRestore;
#bulkDone;
#list;
#footerStatus;
#loadMore;
#pageSize = 40;
#visibleLimit = this.#pageSize;
#mode = "topics";
#topicDraft = null;
#bulkMode = !1;
#bulkSelection = /* @__PURE__ */ new Set();
#activeFilters = /* @__PURE__ */ new Set();
#filterControls = /* @__PURE__ */ new Map();
#categoryLabels = /* @__PURE__ */ new Map();
constructor(options) {
this.#document = options.document, this.#topics = options.topics, this.#filterPreferences = options.filterPreferences, this.#filterCatalog = options.filterCatalog, this.#openTarget = options.openTarget ?? (() => !1), this.#relativeTime = options.relativeTime ?? defaultRelativeTime, this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.window = new import_reader_floating_window_frame.ReaderFloatingWindowFrame({
document: options.document,
mount: options.mount,
title: "不想再看",
ariaLabel: "不想看的 Topic",
icon: "eye-off",
variant: "unwanted-topics",
tabId: "unwanted-topics",
tabOrder: 70,
requestOpen: () => this.open(),
zIndex: 2147483586,
...options.storage ? { geometryStorage: options.storage } : {},
geometryStorageKey: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY,
policy: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_POLICY,
placement: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_PLACEMENT,
notify: this.#notify,
onClose: () => {
this.#mode === "settings" && this.#filterEditor?.saveIfChanged(!1);
},
parentScope: this.scope
}), this.#backButton = options.document.createElement("button"), this.#backButton.type = "button", this.#backButton.className = "ldp-reader-floating-window-back", this.#backButton.hidden = !0, this.#backButton.setAttribute("aria-label", "返回不想看列表"), this.#backButton.title = "返回", this.#backButton.append((0, import_reader_icon.createReaderIcon)(options.document, "chevron-left")), this.#settingsButton = options.document.createElement("button"), this.#settingsButton.type = "button", this.#settingsButton.className = "ldp-reader-floating-window-extra-action ldp-unwanted-topic-settings-button", this.#settingsButton.hidden = typeof options.filterPreferences?.update != "function" || !options.filterCatalog, this.#settingsButton.setAttribute("aria-label", "设置免打扰与自动过滤"), this.#settingsButton.title = "免打扰设置", this.#settingsButton.append((0, import_reader_icon.createReaderIcon)(options.document, "settings")), this.window.actions.prepend(this.#backButton, this.#settingsButton), this.#topicPane = (0, import_html_element.htmlElement)(options.document, "div", "ldp-unwanted-topic-pane");
const tools = (0, import_html_element.htmlElement)(options.document, "div", "ldp-unwanted-topic-tools"), searchLabel = (0, import_html_element.htmlElement)(
options.document,
"label",
"ldp-unwanted-topic-search"
);
searchLabel.append((0, import_reader_icon.createReaderIcon)(options.document, "search")), this.#search = options.document.createElement("input"), this.#search.type = "search", this.#search.placeholder = "搜索 Topic、标注、标签、类别、用户或规则", this.#search.setAttribute("aria-label", "搜索不想看的 Topic"), this.#searchResult = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-unwanted-topic-search-result"
), this.#searchResult.hidden = !0, this.#searchResult.setAttribute("aria-live", "polite"), searchLabel.append(this.#search, this.#searchResult), this.#filterBar = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-bar"
), this.#labelFilter = this.#createFilterControl(
"labels",
"自定义标签",
"ldp-unwanted-topic-label-filter"
), this.#categoryFilter = this.#createFilterControl(
"categories",
"主题类别",
"ldp-unwanted-topic-category-filter"
), this.#topicLabelFilter = this.#createFilterControl(
"topic-labels",
"主题标签",
"ldp-unwanted-topic-hit-label-filter"
), this.#topicAuthorFilter = this.#createFilterControl(
"topic-authors",
"OP 用户",
"ldp-unwanted-topic-author-filter"
), this.#topicFieldFilter = this.#createFilterControl(
"topic-fields",
"字符规则",
"ldp-unwanted-topic-field-filter"
), this.#postAuthorFilter = this.#createFilterControl(
"post-authors",
"楼层用户",
"ldp-unwanted-topic-post-author-filter"
), this.#sourceFilter = this.#createFilterControl(
"sources",
"收纳方式",
"ldp-unwanted-topic-source-filter"
), this.#addFilter = options.document.createElement("select"), this.#addFilter.className = "ldp-reader-select ldp-unwanted-topic-add-filter", this.#addFilter.setAttribute("aria-label", "添加不想看筛选"), this.#labelOptions = options.document.createElement("datalist"), this.#labelOptions.id = "ldp-unwanted-topic-label-options", this.#bulkToggle = this.#actionButton(
"批量管理",
"list-checks",
"ldp-unwanted-topic-bulk-toggle"
), this.#bulkToggle.setAttribute("aria-label", "批量管理"), this.#bulkToggle.title = "批量管理";
const filterActions = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-actions"
);
filterActions.append(this.#addFilter, this.#bulkToggle), this.#filterBar.append(
...[...this.#filterControls.values()].map((control) => control.wrapper),
filterActions
), tools.append(searchLabel, this.#filterBar, this.#labelOptions), this.#bulkBar = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-bulk-bar"
), this.#bulkBar.hidden = !0, this.#bulkStatus = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-unwanted-topic-bulk-status"
);
const bulkActions = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-bulk-actions"
);
this.#bulkSelectAll = this.#actionButton(
"全选当前结果",
"check-square",
"ldp-unwanted-topic-bulk-select-all"
), this.#bulkRestore = this.#actionButton(
"批量恢复",
"rotate-ccw",
"ldp-unwanted-topic-bulk-restore"
), this.#bulkDone = this.#actionButton(
"完成",
"check",
"ldp-unwanted-topic-bulk-done"
), bulkActions.append(
this.#bulkSelectAll,
this.#bulkRestore,
this.#bulkDone
), this.#bulkBar.append(this.#bulkStatus, bulkActions), this.#list = (0, import_html_element.htmlElement)(options.document, "div", "ldp-unwanted-topic-list"), this.#list.setAttribute("role", "feed");
const footer = (0, import_html_element.htmlElement)(options.document, "footer", "ldp-unwanted-topic-footer");
this.#footerStatus = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-unwanted-topic-footer-status"
), this.#loadMore = options.document.createElement("button"), this.#loadMore.type = "button", this.#loadMore.className = "ldp-unwanted-topic-load-more", this.#loadMore.dataset.unwantedTopicLoadMore = "true", this.#loadMore.textContent = "加载更多", footer.append(this.#footerStatus, this.#loadMore), this.#topicPane.append(tools, this.#bulkBar, this.#list, footer), this.#filterEditor = options.filterPreferences && options.filterCatalog ? new import_reader_unwanted_topic_filter_editor.ReaderUnwantedTopicFilterEditor({
document: options.document,
preferences: options.filterPreferences,
catalog: options.filterCatalog,
notify: this.#notify,
onError: this.#onError,
parentScope: this.scope
}) : null, this.#settingsPane = this.#filterEditor?.element ?? (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-unwanted-topic-filter-settings"
), this.#settingsPane.hidden = !0, this.window.body.append(this.#topicPane, this.#settingsPane), this.scope.listen(this.#search, "input", () => {
this.#visibleLimit = this.#pageSize, this.#render();
});
for (const control of this.#filterControls.values())
this.scope.listen(control.select, "change", () => {
this.#visibleLimit = this.#pageSize, this.#render();
});
this.scope.listen(this.#addFilter, "change", () => {
const kind = this.#addFilter.value;
this.#filterControls.has(kind) && this.#activeFilters.add(kind);
for (const option of [...this.#addFilter.options])
option.selected = option.value === "";
this.#visibleLimit = this.#pageSize, this.#render();
}), this.scope.listen(this.#bulkToggle, "click", () => {
this.#bulkMode = !0, this.#topicDraft = null, this.#bulkSelection.clear(), this.#render();
}), this.scope.listen(this.#bulkDone, "click", () => {
this.#bulkMode = !1, this.#bulkSelection.clear(), this.#render();
}), this.scope.listen(this.#bulkSelectAll, "click", () => {
const records = this.#matchingRecords(this.#topics.ordered()), allSelected = records.length > 0 && records.every((record) => this.#bulkSelection.has(record.topicId));
for (const record of records)
allSelected ? this.#bulkSelection.delete(record.topicId) : this.#bulkSelection.add(record.topicId);
this.#render();
}), this.scope.listen(this.#bulkRestore, "click", () => {
const selected = [...this.#bulkSelection];
selected.length && (this.#topics.removeMany(selected), this.#bulkSelection.clear(), this.#notify(`已批量恢复 ${selected.length} 个 Topic`));
}), this.scope.listen(this.#topicPane, "click", (event) => this.#onClick(event)), this.scope.listen(this.#topicPane, "input", (event) => this.#onInput(event)), this.scope.listen(this.#topicPane, "change", (event) => this.#onChange(event)), this.scope.listen(this.#topicPane, "keydown", (event) => this.#onKeyDown(
event
)), this.scope.listen(this.#settingsButton, "click", () => this.#showSettings()), this.scope.listen(this.#backButton, "click", () => {
this.#returnToTopics();
}), this.scope.listen(options.document, "pointerdown", (event) => {
this.window.dismissFromPointerEvent(event);
}, !0), this.scope.listen(options.document, "keydown", (event) => {
this.window.dismissFromEscapeEvent(event);
}, !0), this.#topics.changes.subscribe(() => this.#render(), this.scope), this.#render();
}
open() {
this.#showTopics(), this.window.open();
}
close() {
this.window.close();
}
destroy() {
this.scope.destroy();
}
#createFilterControl(kind, label, className) {
const wrapper = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-unwanted-topic-filter-condition"
);
wrapper.hidden = !0;
const select = this.#document.createElement("select");
select.className = `ldp-reader-select ${className}`, select.setAttribute("aria-label", `按${label}筛选不想看的 Topic`);
const remove = this.#document.createElement("button");
return remove.type = "button", remove.className = "ldp-unwanted-topic-filter-remove", remove.dataset.unwantedTopicFilterRemove = kind, remove.setAttribute("aria-label", `移除${label}筛选条件`), remove.title = `移除${label}条件`, remove.append((0, import_reader_icon.createReaderIcon)(this.#document, "x")), wrapper.append(select, remove), this.#filterControls.set(kind, Object.freeze({
kind,
label,
wrapper,
select
})), select;
}
#actionButton(label, icon, className) {
const button = this.#document.createElement("button");
return button.type = "button", button.className = className, button.append(
(0, import_reader_icon.createReaderIcon)(this.#document, icon),
(0, import_html_element.htmlElement)(this.#document, "span", "", label)
), button;
}
#showTopics() {
this.#mode = "topics", this.#topicPane.hidden = !1, this.#settingsPane.hidden = !0, this.#backButton.hidden = !0, this.#settingsButton.hidden = typeof this.#filterPreferences?.update != "function" || !this.#filterEditor, this.window.setTitle("不想再看"), this.window.meta.textContent = "", this.window.setIcon("eye-off"), this.#render();
}
#showSettings() {
typeof this.#filterPreferences?.update != "function" || !this.#filterEditor || (this.#mode = "settings", this.#topicPane.hidden = !0, this.#settingsPane.hidden = !1, this.#backButton.hidden = !1, this.#settingsButton.hidden = !0, this.window.setTitle("免打扰与自动过滤"), this.window.meta.textContent = "", this.window.setIcon("settings"), this.#filterEditor.open());
}
async #returnToTopics() {
this.#filterEditor && !await this.#filterEditor.saveIfChanged(!1) || this.#showTopics();
}
#labelCounts(records) {
const counts = /* @__PURE__ */ new Map();
for (const record of records)
for (const label of record.labels) {
const key = labelKey(label);
if (!key) continue;
const previous = counts.get(key);
counts.set(key, {
label: previous?.label ?? label,
count: (previous?.count ?? 0) + 1
});
}
return Object.freeze([...counts].map(([key, value]) => Object.freeze({ key, ...value })).sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-CN")));
}
#valueCounts(records, values) {
const counts = /* @__PURE__ */ new Map();
for (const record of records) {
const seen = /* @__PURE__ */ new Set();
for (const value of values(record)) {
const label = value.replace(/\s+/g, " ").trim(), key = label.toLocaleLowerCase("zh-CN");
if (!key || seen.has(key)) continue;
seen.add(key);
const previous = counts.get(key);
counts.set(key, {
label: previous?.label ?? label,
count: (previous?.count ?? 0) + 1
});
}
}
return Object.freeze([...counts].map(([key, value]) => Object.freeze({ key, ...value })).sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-CN")));
}
#refreshCategoryLabels() {
this.#categoryLabels.clear();
for (const category of this.#filterCatalog?.categories() ?? []) {
const label = category.name.trim() || category.slug.trim();
label && this.#categoryLabels.set(category.id, label);
}
}
#categoryLabel(record) {
return record.matchedCategory ? (record.categoryId === null ? "" : this.#categoryLabels.get(record.categoryId) ?? "") || record.categoryName || record.categorySlug || (record.categoryId === null ? "" : `类别 #${record.categoryId}`) : "";
}
#categoryCounts(records) {
const counts = /* @__PURE__ */ new Map();
for (const record of records) {
const key = categoryRecordKey(record), label = this.#categoryLabel(record);
if (!key || !label) continue;
const previous = counts.get(key);
counts.set(key, {
label: previous?.label ?? label,
count: (previous?.count ?? 0) + 1
});
}
return Object.freeze([...counts].map(([key, value]) => Object.freeze({ key, ...value })).sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-CN")));
}
#matchingRecords(all) {
const query = this.#search.value.trim().toLocaleLowerCase("zh-CN"), labelFilter = this.#labelFilter.value, categoryFilter = this.#categoryFilter.value, topicLabelFilter = this.#topicLabelFilter.value, topicAuthorFilter = this.#topicAuthorFilter.value, topicFieldFilter = this.#topicFieldFilter.value, postAuthorFilter = this.#postAuthorFilter.value, sourceFilter = this.#sourceFilter.value;
return Object.freeze(all.filter((entry) => (!query || entry.searchText.includes(query) || this.#categoryLabel(entry).toLocaleLowerCase("zh-CN").includes(query)) && (!labelFilter || entry.labels.some((label) => labelKey(label) === labelFilter)) && (!categoryFilter || categoryRecordKey(entry) === categoryFilter) && (!topicLabelFilter || matchedRuleValues(entry, ["标签"]).some((value) => value.toLocaleLowerCase("zh-CN") === topicLabelFilter)) && (!topicAuthorFilter || matchedRuleValues(entry, ["OP"]).some((value) => value.toLocaleLowerCase("zh-CN") === topicAuthorFilter)) && (!topicFieldFilter || matchedRuleValues(entry, ["字段"]).some((value) => value.toLocaleLowerCase("zh-CN") === topicFieldFilter)) && (!postAuthorFilter || matchedRuleValues(
entry,
["楼层用户", "楼层"]
).some((value) => value.toLocaleLowerCase("zh-CN") === postAuthorFilter)) && (!sourceFilter || entry.source === sourceFilter)));
}
#matchedRuleWithoutCategory(record) {
return record.matchedCategory ? record.matchedRule.split(";").map((entry) => entry.trim()).filter((entry) => entry && !/^类别[::]/.test(entry)).join(";") : record.matchedRule;
}
#renderFilterControl(kind, entries, allLabel) {
const control = this.#filterControls.get(kind), previous = control.select.value, allOption = this.#document.createElement("option");
allOption.value = "", allOption.textContent = `${allLabel} ${entries.length}`, control.select.replaceChildren(
allOption,
...entries.map((entry) => {
const option = this.#document.createElement("option");
return option.value = entry.key, option.textContent = `${entry.label} ${entry.count}`, option;
})
);
const selected = entries.some((entry) => entry.key === previous) ? previous : "";
for (const option of [...control.select.options])
option.selected = option.value === selected;
entries.length || this.#activeFilters.delete(kind), control.wrapper.hidden = !this.#activeFilters.has(kind);
}
#render() {
const all = this.#topics.ordered();
this.#topicDraft && !all.some((entry) => entry.topicId === this.#topicDraft?.topicId) && (this.#topicDraft = null), this.#refreshCategoryLabels();
const labels = this.#labelCounts(all), categories = this.#categoryCounts(all), topicLabels = this.#valueCounts(all, (record) => matchedRuleValues(record, ["标签"])), topicAuthors = this.#valueCounts(all, (record) => matchedRuleValues(record, ["OP"])), topicFields = this.#valueCounts(all, (record) => matchedRuleValues(record, ["字段"])), postAuthors = this.#valueCounts(all, (record) => matchedRuleValues(record, ["楼层用户", "楼层"])), sources = Object.freeze([
Object.freeze({
key: "automatic",
label: "自动过滤",
count: all.filter((record) => record.source === "automatic").length
}),
Object.freeze({
key: "manual",
label: "手动免打扰",
count: all.filter((record) => record.source === "manual").length
})
].filter((entry) => entry.count > 0));
this.#renderFilterControl("labels", labels, "全部标签"), this.#renderFilterControl("categories", categories, "全部类别"), this.#renderFilterControl("topic-labels", topicLabels, "全部主题标签"), this.#renderFilterControl("topic-authors", topicAuthors, "全部 OP 用户"), this.#renderFilterControl("topic-fields", topicFields, "全部字符规则"), this.#renderFilterControl("post-authors", postAuthors, "全部楼层用户"), this.#renderFilterControl("sources", sources, "全部来源"), this.#labelOptions.replaceChildren(...labels.map((entry) => {
const option = this.#document.createElement("option");
return option.value = entry.label, option.label = `${entry.count} 个 Topic`, option;
}));
const availableConditions = [...this.#filterControls.values()].filter(
(control) => !this.#activeFilters.has(control.kind) && control.select.options.length > 1
), addOption = this.#document.createElement("option");
addOption.value = "", addOption.textContent = availableConditions.length ? "+ 添加筛选" : "筛选已全部添加", addOption.hidden = availableConditions.length > 0, this.#addFilter.replaceChildren(
addOption,
...availableConditions.map((control) => {
const option = this.#document.createElement("option");
return option.value = control.kind, option.textContent = control.label, option;
})
), this.#addFilter.disabled = availableConditions.length === 0;
const records = this.#matchingRecords(all), matchingIds = new Set(records.map((record) => record.topicId));
for (const topicId of this.#bulkSelection)
matchingIds.has(topicId) || this.#bulkSelection.delete(topicId);
this.#mode === "topics" && (this.window.meta.textContent = [
`${all.length} 个 Topic`,
...labels.length ? [`${labels.length} 个标签`] : [],
...categories.length ? [`${categories.length} 个命中类别`] : []
].join(" · "));
const filtering = !!(this.#search.value.trim() || [...this.#filterControls.values()].some((control) => !!control.select.value));
if (this.#searchResult.hidden = !filtering, this.#searchResult.textContent = filtering ? `${records.length} 条` : "", this.#bulkToggle.hidden = this.#bulkMode, this.#bulkToggle.disabled = all.length === 0, this.#bulkBar.hidden = !this.#bulkMode, this.#bulkMode) {
const selectedCount = this.#bulkSelection.size, allSelected = records.length > 0 && records.every((record) => this.#bulkSelection.has(record.topicId));
this.#bulkStatus.textContent = `当前结果 ${records.length} 个 · 已选 ${selectedCount} 个`, this.#bulkSelectAll.disabled = records.length === 0, this.#bulkSelectAll.querySelector("span").textContent = allSelected ? "取消全选" : "全选当前结果", this.#bulkRestore.disabled = selectedCount === 0, this.#bulkRestore.querySelector("span").textContent = selectedCount ? `批量恢复 ${selectedCount} 个` : "批量恢复";
}
const visibleRecords = records.slice(0, this.#visibleLimit);
this.#list.replaceChildren(...visibleRecords.map((entry) => this.#topicRow(entry))), records.length || this.#list.append((0, import_html_element.htmlElement)(
this.#document,
"p",
"ldp-unwanted-topic-empty",
filtering ? "没有匹配的 Topic。" : "点击列表里的免打扰图标后,Topic 会消失并收进这里。"
)), this.#footerStatus.textContent = all.length ? this.#bulkMode ? "批量管理只作用于当前搜索与筛选结果。" : "点击 Topic 右侧编辑,可添加标注和多个标签。" : "这里不会修改原站通知级别。", this.#loadMore.hidden = visibleRecords.length >= records.length, this.#loadMore.textContent = this.#loadMore.hidden ? "已全部加载" : `加载更多(${visibleRecords.length}/${records.length})`;
}
#topicRow(record) {
const row = (0, import_html_element.htmlElement)(this.#document, "article", "ldp-unwanted-topic-row");
row.dataset.unwantedTopicId = String(record.topicId);
const editing = this.#topicDraft?.topicId === record.topicId;
row.classList.toggle("is-editing", editing);
const heading = (0, import_html_element.htmlElement)(this.#document, "header", "ldp-unwanted-topic-head");
if (!editing) {
let leading;
if (this.#bulkMode) {
const select = (0, import_html_element.htmlElement)(
this.#document,
"label",
"ldp-unwanted-topic-select"
), checkbox = this.#document.createElement("input");
checkbox.type = "checkbox", checkbox.checked = this.#bulkSelection.has(record.topicId), checkbox.dataset.unwantedTopicSelect = String(record.topicId), checkbox.setAttribute("aria-label", `选择 ${record.title}`), select.append(checkbox), leading = select;
} else {
const open = this.#document.createElement("button");
open.type = "button", open.className = "ldp-unwanted-topic-open", open.dataset.unwantedTopicOpen = String(record.topicId), open.setAttribute("aria-label", `打开 ${record.title}`), open.append((0, import_reader_icon.createReaderIcon)(this.#document, "eye-off")), leading = open;
}
const copy = (0, import_html_element.htmlElement)(this.#document, "span", "ldp-unwanted-topic-copy"), meta = (0, import_html_element.htmlElement)(this.#document, "small", "");
meta.append(
this.#document.createTextNode(
`Topic ${record.topicId} · ${this.#relativeTime(record.hiddenAt)}`
)
);
const category = this.#categoryLabel(record);
category && meta.append(
this.#document.createTextNode(" · "),
(0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-unwanted-topic-category",
category
)
);
const remainingRule = this.#matchedRuleWithoutCategory(record);
record.source === "automatic" && remainingRule && meta.append(this.#document.createTextNode(` · ${remainingRule}`)), copy.append((0, import_html_element.htmlElement)(this.#document, "strong", "", record.title), meta);
const rowActions2 = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-unwanted-topic-row-actions"
);
if (!this.#bulkMode) {
rowActions2.append(this.#restoreButton(record));
const edit = this.#document.createElement("button");
edit.type = "button", edit.className = "ldp-unwanted-topic-edit", edit.dataset.unwantedTopicEdit = String(record.topicId), edit.setAttribute("aria-label", `编辑 ${record.title} 的标注和标签`), edit.title = "编辑标注和标签", edit.append((0, import_reader_icon.createReaderIcon)(this.#document, "pencil")), rowActions2.append(edit);
}
return heading.append(leading, copy, rowActions2), row.append(heading), row;
}
heading.classList.add("is-editing");
const draft = this.#topicDraft, fields = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-unwanted-topic-fields"), noteLabel = (0, import_html_element.htmlElement)(this.#document, "label", "ldp-unwanted-topic-note"), note = this.#document.createElement("input");
note.type = "text", note.maxLength = 240, note.value = draft.note, note.placeholder = "可选自定义标注", note.dataset.unwantedTopicNote = String(record.topicId), note.setAttribute("aria-label", `${record.title} 的自定义标注`), noteLabel.append(note);
const tagControl = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-unwanted-topic-labels");
for (const label of draft.labels) {
const chip = this.#document.createElement("button");
chip.type = "button", chip.className = "ldp-unwanted-topic-label", chip.dataset.unwantedTopicRemoveLabel = label, chip.dataset.unwantedTopicId = String(record.topicId), chip.setAttribute("aria-label", `移除标签 ${label}`), chip.append(
(0, import_html_element.htmlElement)(this.#document, "span", "", label),
(0, import_html_element.htmlElement)(this.#document, "span", "ldp-unwanted-topic-label-remove", "×")
), tagControl.append(chip);
}
const labelInput = this.#document.createElement("input");
labelInput.type = "text", labelInput.maxLength = 36, labelInput.placeholder = "添加标签(可多选)", labelInput.autocomplete = "off", labelInput.setAttribute("list", this.#labelOptions.id), labelInput.dataset.unwantedTopicLabelInput = String(record.topicId), labelInput.setAttribute(
"aria-label",
`${record.title} 的标签;可输入或下拉搜索,按 Enter 累加`
), tagControl.append(labelInput), fields.append(noteLabel, tagControl);
const rowActions = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-unwanted-topic-row-actions"
);
rowActions.append(this.#restoreButton(record));
const confirm = this.#document.createElement("button");
return confirm.type = "button", confirm.className = "ldp-unwanted-topic-confirm", confirm.dataset.unwantedTopicConfirm = String(record.topicId), confirm.setAttribute("aria-label", `确认 ${record.title} 的标注和标签`), confirm.title = "确认", confirm.append((0, import_reader_icon.createReaderIcon)(this.#document, "check")), rowActions.append(confirm), heading.append(fields, rowActions), row.append(heading), row;
}
#restoreButton(record) {
const restore = this.#document.createElement("button");
return restore.type = "button", restore.className = "ldp-unwanted-topic-restore", restore.dataset.unwantedTopicRestore = String(record.topicId), restore.setAttribute("aria-label", `恢复显示 ${record.title}`), restore.title = "恢复显示", restore.append((0, import_reader_icon.createReaderIcon)(this.#document, "rotate-ccw")), restore;
}
#onClick(event) {
const removeFilter = closestTarget(
event,
"[data-unwanted-topic-filter-remove]"
);
if (removeFilter) {
const kind = removeFilter.dataset.unwantedTopicFilterRemove, control = this.#filterControls.get(kind);
if (control) {
for (const option of [...control.select.options])
option.selected = option.value === "";
this.#activeFilters.delete(kind), this.#visibleLimit = this.#pageSize, this.#render();
}
return;
}
if (closestTarget(event, "[data-unwanted-topic-load-more]")) {
this.#visibleLimit += this.#pageSize, this.#render();
return;
}
const edit = closestTarget(
event,
"[data-unwanted-topic-edit]"
);
if (edit) {
const topicId2 = Number(edit.dataset.unwantedTopicEdit), record2 = this.#topics.snapshot.records.find((entry) => entry.topicId === topicId2);
record2 && (this.#topicDraft = {
topicId: topicId2,
note: record2.note,
labels: [...record2.labels]
}, this.#render());
return;
}
const confirm = closestTarget(
event,
"[data-unwanted-topic-confirm]"
);
if (confirm && this.#topicDraft?.topicId === Number(confirm.dataset.unwantedTopicConfirm)) {
const draft = this.#topicDraft;
this.#topicDraft = null, this.#topics.update(draft.topicId, {
note: draft.note,
labels: draft.labels
}), this.#notify("标注与标签已保存");
return;
}
const restore = closestTarget(
event,
"[data-unwanted-topic-restore]"
);
if (restore) {
const topicId2 = Number(restore.dataset.unwantedTopicRestore);
this.#topicDraft = null, this.#topics.remove(topicId2), this.#notify("已移出不想看;列表下次渲染时恢复显示");
return;
}
const removeLabel = closestTarget(
event,
"[data-unwanted-topic-remove-label]"
);
if (removeLabel && this.#topicDraft?.topicId === Number(removeLabel.dataset.unwantedTopicId)) {
const target = labelKey(removeLabel.dataset.unwantedTopicRemoveLabel);
this.#topicDraft.labels = this.#topicDraft.labels.filter((label) => labelKey(label) !== target), this.#render();
return;
}
const open = closestTarget(
event,
"[data-unwanted-topic-open]"
);
if (!open) return;
const topicId = Number(open.dataset.unwantedTopicOpen), record = this.#topics.snapshot.records.find((entry) => entry.topicId === topicId);
record && Promise.resolve(this.#openTarget(record)).catch((cause) => {
this.#onError(cause), this.#notify("打开 Topic 失败,请稍后重试");
});
}
#onInput(event) {
const note = closestTarget(
event,
"[data-unwanted-topic-note]"
);
note && this.#topicDraft?.topicId === Number(note.dataset.unwantedTopicNote) && (this.#topicDraft.note = note.value);
}
#onChange(event) {
const selection = closestTarget(
event,
"[data-unwanted-topic-select]"
);
if (selection) {
const topicId = Number(selection.dataset.unwantedTopicSelect);
selection.checked ? this.#bulkSelection.add(topicId) : this.#bulkSelection.delete(topicId), this.#render();
return;
}
const input = closestTarget(
event,
"[data-unwanted-topic-label-input]"
);
input && this.#commitLabel(input);
}
#onKeyDown(event) {
const input = closestTarget(
event,
"[data-unwanted-topic-label-input]"
);
!input || event.key !== "Enter" && event.key !== "," && event.key !== "," || (event.preventDefault(), this.#commitLabel(input));
}
#commitLabel(input) {
const raw = input.value.replace(/[,,]\s*$/, "").trim(), key = labelKey(raw);
if (!key) {
input.value = "";
return;
}
const topicId = Number(input.dataset.unwantedTopicLabelInput);
this.#topicDraft?.topicId === topicId && (input.value = "", !this.#topicDraft.labels.some((label) => labelKey(label) === key) && (this.#topicDraft.labels = [...this.#topicDraft.labels, raw], this.#render()));
}
}
}, "a5b50e51c9df89fb2e912708df37a4c4141837abeee36e75cd5563e734a45249");
/* Source: lite/src/discourse/identifiers.ts */
runtime.register("src/discourse/identifiers.js", function(module, exports, require) {
var identifiers_exports = {};
__export(identifiers_exports, {
discourseAuthScope: () => discourseAuthScope,
discoursePostId: () => discoursePostId,
discoursePostIdStream: () => discoursePostIdStream,
discoursePostIds: () => discoursePostIds,
discoursePostNumber: () => discoursePostNumber,
discoursePostNumbers: () => discoursePostNumbers,
discoursePostReference: () => discoursePostReference,
discourseReplyCursor: () => discourseReplyCursor,
discourseTopicId: () => discourseTopicId,
tryDiscoursePostId: () => tryDiscoursePostId,
tryDiscoursePostNumber: () => tryDiscoursePostNumber,
tryDiscourseTopicId: () => tryDiscourseTopicId
});
module.exports = __toCommonJS(identifiers_exports);
function positiveInteger(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return numeric;
}
function discourseTopicId(value) {
return positiveInteger(value, "topicId");
}
function discoursePostId(value) {
return positiveInteger(value, "postId");
}
function discoursePostNumber(value) {
return positiveInteger(value, "postNumber");
}
function discourseReplyCursor(value) {
const numeric = Number(value ?? 0);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new RangeError("replyCursor 必须是非负安全整数");
return numeric;
}
function discourseAuthScope(value) {
const normalized = String(value ?? "").trim();
if (!normalized) throw new Error("authScope 不能为空");
return normalized;
}
function tryDiscourseTopicId(value) {
try {
return discourseTopicId(value);
} catch {
return null;
}
}
function tryDiscoursePostId(value) {
try {
return discoursePostId(value);
} catch {
return null;
}
}
function tryDiscoursePostNumber(value) {
try {
return discoursePostNumber(value);
} catch {
return null;
}
}
function discoursePostReference(input) {
const postNumber = discoursePostNumber(input.post_number), parent = tryDiscoursePostNumber(input.reply_to_post_number);
if (parent === postNumber)
throw new Error(`楼层 #${postNumber} 不能回复自身`);
return Object.freeze({
postId: tryDiscoursePostId(input.id),
topicId: tryDiscourseTopicId(input.topic_id),
postNumber,
replyToPostNumber: parent
});
}
function discoursePostIds(values) {
const normalized = [...new Set(values.map(discoursePostId))].sort((left, right) => left - right);
if (!normalized.length) throw new Error("postIds 不能为空");
return Object.freeze(normalized);
}
function discoursePostIdStream(values) {
const seen = /* @__PURE__ */ new Set(), stream = [];
for (const value of values) {
const postId = discoursePostId(value);
seen.has(postId) || (seen.add(postId), stream.push(postId));
}
return Object.freeze(stream);
}
function discoursePostNumbers(values) {
const normalized = [...new Set(values.map(discoursePostNumber))].sort((left, right) => left - right);
if (!normalized.length) throw new Error("postNumbers 不能为空");
return Object.freeze(normalized);
}
}, "df62eb641b1752e66e82842fdbbd0c8d27ee6ca3384dbfb60a23197a629599a9");
/* Source: lite/src/discourse/ingest-version.ts */
runtime.register("src/discourse/ingest-version.js", function(module, exports, require) {
var ingest_version_exports = {};
__export(ingest_version_exports, {
discourseObservedAt: () => discourseObservedAt,
normalizeDiscourseIngestSource: () => normalizeDiscourseIngestSource,
shouldReplaceDiscourseRemoval: () => shouldReplaceDiscourseRemoval,
shouldReplaceDiscourseVersion: () => shouldReplaceDiscourseVersion
});
module.exports = __toCommonJS(ingest_version_exports);
const SOURCE_RANK = Object.freeze({
"loader-batch": 1,
"topic-json": 2,
"target-refresh": 3,
"action-response": 4,
"message-bus": 5
});
function discourseObservedAt(value, name = "observedAt") {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负有限时间戳`);
return numeric;
}
function normalizeDiscourseIngestSource(value) {
return typeof value == "string" && Object.hasOwn(SOURCE_RANK, value) ? value : null;
}
function shouldReplaceDiscourseVersion(current, next) {
return current ? next.source === "loader-batch" && current.source !== null && current.source !== "loader-batch" ? !1 : next.observedAt !== current.observedAt ? next.observedAt > current.observedAt : current.source === null ? !0 : SOURCE_RANK[next.source] >= SOURCE_RANK[current.source] : !0;
}
function shouldReplaceDiscourseRemoval(current, next) {
return current.source === "action-response" && (next.source === "loader-batch" || next.source === "topic-json") ? !1 : shouldReplaceDiscourseVersion(current, next);
}
}, "329717cc7f84a32026374e649cf6c00b22be5286b8e69dc5d2178a55f025dcd1");
/* Source: lite/src/discourse/native-composer.ts */
runtime.register("src/discourse/native-composer.js", function(module, exports, require) {
var native_composer_exports = {};
__export(native_composer_exports, {
DiscourseComposerCoordinator: () => DiscourseComposerCoordinator,
DiscourseComposerEventPort: () => DiscourseComposerEventPort,
DiscourseComposerHostIsolation: () => DiscourseComposerHostIsolation,
DiscourseComposerTopicSyncController: () => DiscourseComposerTopicSyncController
});
module.exports = __toCommonJS(native_composer_exports);
var import_identifiers = require("./identifiers.js"), import_native_post_model_factory = require("./native-post-model-factory.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_repeat_action_gate = require("../kernel/repeat-action-gate.js"), import_signal = require("../kernel/signal.js"), import_value_record = require("../kernel/value-record.js");
function modelValue(model, key) {
const value = (0, import_value_record.valueRecord)(model);
if (!value) return;
const getter = value.get;
return typeof getter == "function" ? getter.call(value, key) : value[key];
}
function composerModelTopicId(model) {
const post = modelValue(model, "post"), topic = modelValue(model, "topic") ?? modelValue(post, "topic");
return Number(
modelValue(topic, "id") ?? modelValue(model, "topic_id") ?? modelValue(model, "topicId") ?? modelValue(post, "topic_id") ?? modelValue(post, "topicId")
);
}
function setModelValue(model, key, value) {
const target = (0, import_value_record.valueRecord)(model);
if (!target) throw new Error("Discourse composer model 不可写");
const setter = target.set;
typeof setter == "function" ? setter.call(target, key, value) : target[key] = value;
}
function moduleDefault(host, name) {
const module2 = (0, import_value_record.valueRecord)(host.lookupModule(name)), value = (0, import_value_record.valueRecord)(module2?.default);
if (!value) throw new Error(`Discourse 原生模块未就绪:${name}`);
return value;
}
function normalizedDraftSequence(value) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new Error("Topic 缺少合法 draft_sequence");
return numeric;
}
function normalizedDraftKey(value) {
const normalized = String(value ?? "").trim();
if (!normalized) throw new Error("Topic 缺少 draft_key");
return normalized;
}
function normalizedMentionUsername(value) {
return String(value ?? "").trim().replace(/^@+/, "");
}
function rawMentionsUsername(raw, usernameValue) {
const username = normalizedMentionUsername(usernameValue);
if (!raw || !username) return !1;
const escaped = username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`(^|[^A-Za-z0-9_@-])@${escaped}(?=$|[^A-Za-z0-9_-])`,
"i"
).test(raw);
}
function composerTextInsertion(currentValue, blockValue, startValue, endValue) {
const current = String(currentValue ?? ""), start = Math.min(Math.max(0, startValue), current.length), end = Math.min(Math.max(start, endValue), current.length), before = current.slice(0, start), after = current.slice(end);
let inserted = String(blockValue ?? "").trim();
return before && !/\s$/.test(before) && (inserted = `
${inserted}`), after && !/^\s/.test(after) && (inserted += `
`), Object.freeze({
next: `${before}${inserted}${after}`,
inserted,
cursor: before.length + inserted.length
});
}
function composerRequestKey(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), post = (0, import_identifiers.discoursePostReference)(input.post);
return JSON.stringify([
"reply",
topicId,
post.postId,
post.postNumber,
String(input.initialRaw ?? "").trim(),
String(input.initialRichHtml ?? "").trim(),
normalizedMentionUsername(input.dedupeMention),
input.replaceRaw === !0
]);
}
function composerEditRequestKey(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), post = (0, import_identifiers.discoursePostReference)(input.post);
return JSON.stringify([
"edit",
topicId,
post.postId,
post.postNumber
]);
}
function privateMessageRequestKey(username) {
return JSON.stringify(["private-message", username]);
}
function parseDraft(value) {
if (value == null || value === "") return { reply: "" };
const parsed = JSON.parse(String(value));
if (!parsed || typeof parsed != "object" || Array.isArray(parsed))
throw new Error("Discourse composer draft 格式非法");
const source = parsed;
return Object.freeze({
reply: String(source.reply ?? ""),
...source.whisper === void 0 ? {} : { whisper: source.whisper }
});
}
class DiscourseComposerCoordinator {
scope;
#host;
#document;
#models;
#isolation;
#waitForDelay;
#composerOpenTimeoutMs;
#onError;
#requests = /* @__PURE__ */ new Map();
#queue = Promise.resolve();
#session = null;
#submitPromise = null;
#windowPort = null;
constructor(options) {
this.#host = options.host, this.#document = options.document ?? null, this.#models = new import_native_post_model_factory.DiscourseNativePostModelFactory(options.host), this.#isolation = options.isolation ?? null, this.#waitForDelay = options.waitForDelay ?? ((milliseconds) => new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds))), this.#composerOpenTimeoutMs = Math.max(
0,
Number(options.composerOpenTimeoutMs) || 1600
), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.#requests.clear(), this.#queue = Promise.resolve(), this.#session = null, this.#submitPromise = null;
});
}
isOpen() {
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), model = modelValue(composer, "model");
return modelValue(model, "viewOpen") === !0 || String(modelValue(model, "composeState") ?? "").toLowerCase() === "open";
}
/**
* 在用户点击回复前只读解析宿主 Composer service、Draft 与原生 model。
*
* BrowserDiscourseHostApiPort 会缓存成功解析的 service/module,因此应用存续期间的
* 首次回复不再承担 module resolver 冷启动;这里不会调用 Draft.get、composer.open
* 或创建任何 Topic/Post model。
*/
warmReply() {
this.#assertActive();
try {
return this.#replyRuntime(), this.#models.prepareComposerBindings(), !0;
} catch {
return !1;
}
}
installCloseGuard(options) {
this.#assertActive();
const scope = options.parentScope ? options.parentScope.child() : this.scope.child(), gate = new import_repeat_action_gate.RepeatActionGate(), handledEvents = /* @__PURE__ */ new WeakSet(), consume = (event) => {
event.preventDefault(), event.stopImmediatePropagation();
}, requiresConfirmation = (key, message) => options.enabled() ? gate.confirm(key) ? !1 : (options.notify?.(message), !0) : (gate.clear(), !1), discardComposer = (event) => {
consume(event), this.discard().catch((error) => {
this.#onError(error), options.notify?.("舍弃回复失败,请重试");
});
}, ownsActiveComposer = () => {
const session = this.#session, composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer"));
return !!(session && modelValue(composer, "model") === session.model);
}, onClick = (event) => {
if (handledEvents.has(event)) return;
const control = event.target?.closest(
"#reply-control .toggle-save-and-close,#reply-control .discard-button"
);
if (!control) return;
handledEvents.add(event);
const shouldDiscard = control.classList.contains("discard-button");
if (requiresConfirmation(
shouldDiscard ? "composer:discard" : "composer:close",
shouldDiscard ? "再点一次舍弃回复" : "再点一次关闭回复窗口"
)) {
consume(event);
return;
}
shouldDiscard && ownsActiveComposer() && discardComposer(event);
}, onKeyDown = (event) => {
if (handledEvents.has(event)) return;
const keyboard = event;
if (!(keyboard.key !== "Escape" || keyboard.repeat || keyboard.defaultPrevented || !this.isOpen())) {
if (handledEvents.add(event), requiresConfirmation("composer:escape", "再按一次 Esc 舍弃回复")) {
consume(event);
return;
}
ownsActiveComposer() && discardComposer(event);
}
}, captureTarget = options.document.defaultView ?? options.document;
return scope.listen(captureTarget, "click", onClick, !0), scope.listen(captureTarget, "keydown", onKeyDown, !0), captureTarget !== options.document && (scope.listen(options.document, "click", onClick, !0), scope.listen(options.document, "keydown", onKeyDown, !0)), scope.add(() => gate.clear()), scope;
}
async discard() {
this.#assertActive();
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), model = modelValue(composer, "model");
if (!composer || !(0, import_value_record.valueRecord)(model)) {
this.#session = null;
return;
}
const destroyDraft = composer.destroyDraft;
if (typeof destroyDraft != "function")
throw new Error("Discourse composer.destroyDraft 尚未就绪");
const cleanupErrors = [];
try {
composer.skipAutoSave = !0;
try {
const runloop = (0, import_value_record.valueRecord)(this.#host.lookupModule("@ember/runloop")), cancel = runloop?.cancel;
typeof cancel == "function" && composer._saveDraftDebounce && cancel.call(runloop, composer._saveDraftDebounce);
} catch (error) {
cleanupErrors.push(error);
}
if (await destroyDraft.call(composer), modelValue(composer, "model") === model) {
const mutableModel = (0, import_value_record.valueRecord)(model);
try {
typeof mutableModel?.clearState == "function" && mutableModel.clearState.call(mutableModel);
} catch (error) {
cleanupErrors.push(error);
}
try {
typeof composer.close == "function" && composer.close.call(composer);
} catch (error) {
cleanupErrors.push(error);
}
try {
const appEvents = (0, import_value_record.valueRecord)(
composer.appEvents ?? this.#host.lookup("service:app-events")
);
typeof appEvents?.trigger == "function" && appEvents.trigger.call(appEvents, "composer:cancelled");
} catch (error) {
cleanupErrors.push(error);
}
}
this.#session = null;
} finally {
try {
composer.skipAutoSave = !1;
} catch (error) {
cleanupErrors.push(error);
}
}
if (cleanupErrors.length) {
const cleanupError = new AggregateError(
cleanupErrors,
"Discourse composer 舍弃后清理失败"
);
try {
this.#onError(cleanupError);
} catch {
}
}
}
installSubmitGuard(options) {
if (this.#assertActive(), !this.#isolation)
throw new Error("Discourse Composer submit guard 缺少宿主隔离 owner");
const scope = options.parentScope ? options.parentScope.child() : this.scope.child(), submit = (event) => {
const button = event.target?.closest(
"#reply-control .save-or-cancel button.create"
) ?? null;
!button || button !== this.#submitButton(options.document) || (event.preventDefault(), event.stopImmediatePropagation(), this.#submit(button));
};
return scope.listen(options.document, "click", submit, !0), scope.listen(options.document, "keydown", (event) => {
const keyboard = event;
if (keyboard.isComposing || !(keyboard.ctrlKey || keyboard.metaKey) || keyboard.key !== "Enter" && keyboard.code !== "Enter") return;
const button = this.#submitButton(options.document);
button && (keyboard.preventDefault(), keyboard.stopImmediatePropagation(), this.#submit(button));
}, !0), scope;
}
openReply(input) {
return this.#enqueue(
composerRequestKey(input),
() => this.#openReply(input)
);
}
openEdit(input) {
return this.#enqueue(
composerEditRequestKey(input),
() => this.#openEdit(input)
);
}
openPrivateMessage(usernameValue) {
const username = String(usernameValue).trim().replace(/^@+/, "");
if (!username) throw new Error("私信 username 不能为空");
return this.#enqueue(
privateMessageRequestKey(username),
() => this.#openPrivateMessage(username)
);
}
bindWindow(port) {
return this.#assertActive(), this.#windowPort = port, this.#presentComposerWindow(), () => {
this.#windowPort === port && (this.#windowPort = null);
};
}
#enqueue(key, execute) {
this.#assertActive();
const existing = this.#requests.get(key);
if (existing) return existing;
const request = this.#queue.then(
() => (this.#assertActive(), execute()),
() => (this.#assertActive(), execute())
).catch((error) => {
throw this.#onError(error), error;
}).finally(() => {
this.#requests.get(key) === request && this.#requests.delete(key);
});
return this.#requests.set(key, request), this.#queue = request.then(
() => {
},
() => {
}
), request;
}
destroy() {
this.scope.destroy();
}
async #openPrivateMessage(username) {
if (this.isOpen())
throw new Error("Discourse 原生 composer 正在处理另一项编辑,请先完成或关闭");
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), openNewMessage = composer?.openNewMessage;
if (!composer || typeof openNewMessage != "function")
throw new Error("Discourse 原生 composer.openNewMessage 尚未就绪");
if (await openNewMessage.call(composer, { recipients: username }), this.#assertActive(), !await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生私信浮窗未显示");
if (!this.#presentComposerWindow())
throw typeof composer.close == "function" && composer.close.call(composer), new Error("Reader 私信浮窗未接管");
return this.#focusComposerInput(), Object.freeze({ username });
}
async #openReply(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), postReference = (0, import_identifiers.discoursePostReference)(input.post);
if (input.post.topic_id !== void 0 && (0, import_identifiers.discourseTopicId)(input.post.topic_id) !== topicId)
throw new Error("composer 回复楼层不属于目标 Topic");
const {
composer,
appEvents,
replyAction,
draft: Draft
} = this.#replyRuntime(), topicModel = this.#models.createTopic(input.topic), postModel = this.#models.createPost(input.topic, input.post, topicModel), currentModel = modelValue(composer, "model"), currentTopicId = composerModelTopicId(currentModel), currentOpen = modelValue(currentModel, "viewOpen") === !0 || String(modelValue(currentModel, "composeState") ?? "").toLowerCase() === "open", currentAction = modelValue(currentModel, "action"), currentReply = String(modelValue(currentModel, "reply") ?? ""), initialRaw = String(input.initialRaw ?? "").trim(), initialRichHtml = String(input.initialRichHtml ?? "").trim(), dedupeMention = normalizedMentionUsername(input.dedupeMention);
if (currentOpen && currentTopicId === topicId && currentAction === replyAction && await this.#waitForComposerPopup(640)) {
setModelValue(
currentModel,
"post",
postReference.postNumber === 1 ? null : postModel
), this.#presentComposerWindow();
const duplicateMention = input.replaceRaw !== !0 && initialRaw && rawMentionsUsername(currentReply, dedupeMention);
if (!duplicateMention) {
if (initialRaw && input.replaceRaw === !0)
setModelValue(currentModel, "reply", initialRaw);
else if (initialRaw) {
const composerInput = await this.#waitForComposerInput(640);
if (this.#document && !composerInput)
throw new Error("Discourse 原生回复编辑器未就绪");
this.#insertComposerBlock(
appEvents,
currentModel,
initialRaw,
initialRichHtml,
composerInput
);
}
}
const session2 = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "reply",
reused: !0,
model: currentModel,
...duplicateMention ? { insertionSkipped: "duplicate-mention" } : {}
});
return this.#session = session2, this.#focusComposerInput(), session2;
}
if (currentOpen) {
const popupAvailable = this.#composerPopupAvailable();
if ((currentTopicId === topicId || !popupAvailable) && typeof composer.close == "function")
composer.close.call(composer), await this.#waitForDelay(0);
else
throw new Error(
"Discourse 原生 composer 正在处理另一项编辑,请先完成或关闭"
);
}
const key = normalizedDraftKey(input.topic.draft_key), sequence = normalizedDraftSequence(input.topic.draft_sequence);
let insertAfterOpen = !1, insertionSkipped;
const options = {
action: replyAction,
draftKey: key,
draftSequence: sequence,
skipJumpOnSave: !0,
...postReference.postNumber === 1 ? { topic: topicModel } : { post: postModel }
}, draftResult = (0, import_value_record.valueRecord)(await Draft.get.call(Draft, key));
if (draftResult?.draft) {
const draft = parseDraft(draftResult.draft);
options.draftSequence = normalizedDraftSequence(
draftResult.draft_sequence ?? sequence
), input.replaceRaw !== !0 && initialRaw && rawMentionsUsername(draft.reply, dedupeMention) ? (options.reply = draft.reply, insertionSkipped = "duplicate-mention") : input.replaceRaw === !0 ? options.reply = initialRaw : initialRaw && initialRichHtml ? (options.reply = draft.reply, insertAfterOpen = !0) : options.reply = `${draft.reply}${initialRaw ? `
${initialRaw}` : ""}`, draft.whisper !== void 0 && (options.whisper = draft.whisper);
} else initialRaw && (input.replaceRaw === !0 ? options.reply = initialRaw : initialRichHtml ? insertAfterOpen = !0 : options.quote = initialRaw);
if (currentReply && currentOpen && currentTopicId === topicId && (input.replaceRaw !== !0 && initialRaw && rawMentionsUsername(currentReply, dedupeMention) ? (options.reply = currentReply, insertAfterOpen = !1, insertionSkipped = "duplicate-mention") : input.replaceRaw === !0 ? (options.reply = initialRaw, insertAfterOpen = !1) : initialRaw && initialRichHtml ? (options.reply = currentReply, insertAfterOpen = !0) : options.reply = `${currentReply}${initialRaw ? `
${initialRaw}` : ""}`, delete options.quote), await composer.open.call(composer, options), this.#assertActive(), !await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生回复浮窗未显示");
const model = modelValue(composer, "model");
if (!(0, import_value_record.valueRecord)(model)) throw new Error("Discourse composer.open 未生成 model");
if (this.#presentComposerWindow(), insertAfterOpen && initialRaw) {
const composerInput = await this.#waitForComposerInput(
this.#composerOpenTimeoutMs
);
if (this.#document && !composerInput)
throw new Error("Discourse 原生回复编辑器未就绪");
this.#insertComposerBlock(
appEvents,
model,
initialRaw,
initialRichHtml,
composerInput
);
}
const session = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "reply",
reused: !1,
model,
...insertionSkipped ? { insertionSkipped } : {}
});
return this.#session = session, this.#focusComposerInput(), session;
}
#replyRuntime() {
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), appEvents = (0, import_value_record.valueRecord)(
composer?.appEvents ?? this.#host.lookup("service:app-events")
);
if (!composer || typeof composer.open != "function" || !appEvents)
throw new Error("Discourse 原生 composer service 未就绪");
const replyAction = moduleDefault(this.#host, "discourse/models/composer").REPLY;
if (!replyAction) throw new Error("Discourse Composer.REPLY 未就绪");
const draft = moduleDefault(this.#host, "discourse/models/draft");
if (typeof draft.get != "function")
throw new Error("Discourse Draft.get 未就绪");
return Object.freeze({
composer,
appEvents,
replyAction,
draft
});
}
#presentComposerWindow() {
const popup = this.#document?.querySelector("#reply-control") ?? null;
return !popup || !this.#composerPopupAvailable() ? !1 : this.#windowPort?.open(popup) ?? !1;
}
#composerPopupAvailable() {
if (!this.#document) return !0;
const popup = this.#document.querySelector("#reply-control");
return !(!popup?.isConnected || popup.hidden || popup.classList.contains("closed") || popup.classList.contains("hidden") || popup.classList.contains("d-none") || popup.getAttribute("aria-hidden") === "true" || popup.closest('[hidden],[aria-hidden="true"]'));
}
async #waitForComposerPopup(timeoutMs) {
if (!this.#document) return !0;
const deadline = Date.now() + Math.max(0, timeoutMs);
do {
if (this.#composerPopupAvailable()) return !0;
if (Date.now() >= deadline) return !1;
await this.#waitForDelay(80), this.#assertActive();
} while (Date.now() <= deadline);
return !1;
}
#composerInput() {
const document = this.#document;
if (!document || !this.#composerPopupAvailable()) return null;
const selector = '#reply-control textarea.d-editor-input,#reply-control input.d-editor-input,#reply-control .ProseMirror.d-editor-input[contenteditable="true"],#reply-control [contenteditable="true"].d-editor-input,#reply-control [role="textbox"][contenteditable="true"],#reply-control .ProseMirror[contenteditable="true"],#reply-control textarea', available = (candidate) => {
if (!candidate.isConnected || candidate.hidden || candidate.style.display === "none" || candidate.style.visibility === "hidden" || candidate.matches(
'[disabled],[aria-disabled="true"],[aria-hidden="true"],.hidden,.d-none'
) || candidate.closest('[hidden],[aria-hidden="true"],.hidden,.d-none')) return !1;
const view = document.defaultView;
if (typeof view?.getComputedStyle == "function") {
const style = view.getComputedStyle(candidate);
if (style.display === "none" || style.visibility === "hidden" || typeof candidate.getClientRects == "function" && candidate.getClientRects().length === 0) return !1;
}
return !0;
}, active = document.activeElement;
return active?.matches(selector) && available(active) ? active : [...document.querySelectorAll(selector)].find(available) ?? null;
}
async #waitForComposerInput(timeoutMs) {
if (!this.#document) return null;
const deadline = Date.now() + Math.max(0, timeoutMs);
do {
const input = this.#composerInput();
if (input) return input;
if (Date.now() >= deadline) return null;
await this.#waitForDelay(80), this.#assertActive();
} while (Date.now() <= deadline);
return null;
}
#focusComposerInput() {
const document = this.#document;
if (!document) return;
const focus = () => {
if (!this.#composerPopupAvailable()) return;
const input = this.#composerInput();
if (input)
try {
input.focus({ preventScroll: !0 });
} catch {
input.focus();
}
}, viewport = document.defaultView;
viewport?.requestAnimationFrame ? viewport.requestAnimationFrame(focus) : queueMicrotask(focus);
}
#insertComposerBlock(appEvents, model, raw, richHtml, composerInput = this.#composerInput()) {
const richEditor = richHtml && composerInput?.matches(
'.ProseMirror[contenteditable="true"]'
) ? composerInput : null;
if (richEditor) {
if (this.#insertRichText(richEditor, richHtml, raw)) return;
throw new Error("Discourse 富文本 Composer 引用插入失败");
}
if (composerInput?.matches("textarea,input.d-editor-input")) {
this.#insertTextControlBlock(
composerInput,
model,
raw
);
return;
}
const trigger = appEvents.trigger;
if (typeof trigger != "function")
throw new Error("Discourse app-events 缺少 composer insert-block");
trigger.call(appEvents, "composer:insert-block", raw);
}
#insertTextControlBlock(editor, model, raw) {
const modelRaw = String(modelValue(model, "reply") ?? ""), current = editor.value || modelRaw, wasFocused = editor.ownerDocument.activeElement === editor;
let start = Number.isInteger(editor.selectionStart) ? Number(editor.selectionStart) : current.length, end = Number.isInteger(editor.selectionEnd) ? Number(editor.selectionEnd) : start;
!wasFocused && current && start === 0 && end === 0 && (start = current.length, end = current.length);
const insertion = composerTextInsertion(
current,
raw,
start,
end
);
editor.focus(), this.#setTextControlValue(editor, insertion.next), this.#dispatchTextControlInput(editor, insertion.inserted), setModelValue(model, "reply", insertion.next);
try {
editor.setSelectionRange(insertion.cursor, insertion.cursor);
} catch {
}
if (String(modelValue(model, "reply") ?? "") !== insertion.next || editor.value !== insertion.next)
throw new Error("Discourse Markdown Composer 引用插入失败");
}
#setTextControlValue(editor, value) {
try {
if (editor.value = value, editor.value === value) return;
} catch {
}
const view = editor.ownerDocument.defaultView;
for (const prototype of [
view?.HTMLTextAreaElement?.prototype,
view?.HTMLInputElement?.prototype,
Object.getPrototypeOf(editor)
]) {
if (!prototype) continue;
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
if (setter)
try {
if (setter.call(editor, value), editor.value === value) return;
} catch {
}
}
throw new Error("Discourse Markdown Composer value 不可写");
}
#dispatchTextControlInput(editor, inserted) {
const InputEventConstructor = editor.ownerDocument.defaultView?.InputEvent;
if (typeof InputEventConstructor == "function")
try {
editor.dispatchEvent(new InputEventConstructor("input", {
bubbles: !0,
inputType: "insertText",
data: inserted
}));
} catch {
this.#dispatchComposerInput(editor);
}
else
this.#dispatchComposerInput(editor);
const change = editor.ownerDocument.createEvent("Event");
change.initEvent("change", !0, !1), editor.dispatchEvent(change);
}
#insertRichText(editor, html, raw) {
const document = this.#document;
if (!document) return !1;
try {
editor.focus({ preventScroll: !0 });
} catch {
editor.focus();
}
const view = document.defaultView, DataTransferConstructor = view?.DataTransfer, ClipboardEventConstructor = view?.ClipboardEvent;
if (typeof DataTransferConstructor == "function" && typeof ClipboardEventConstructor == "function")
try {
const before = editor.innerHTML, data = new DataTransferConstructor();
data.setData("text/html", html), data.setData("text/plain", raw), data.setData("text/markdown", raw);
const event = new ClipboardEventConstructor("paste", {
bubbles: !0,
cancelable: !0,
clipboardData: data
});
if (editor.dispatchEvent(event), editor.innerHTML !== before) return !0;
} catch {
}
const execute = document.execCommand;
if (typeof execute == "function")
try {
const before = editor.innerHTML;
if (execute.call(document, "insertHTML", !1, html) && editor.innerHTML !== before)
return this.#dispatchComposerInput(editor), !0;
} catch {
}
const selection = document.getSelection?.() ?? view?.getSelection?.() ?? null;
if (!selection) return !1;
let range;
selection.rangeCount > 0 && editor.contains(selection.anchorNode) ? range = selection.getRangeAt(0) : (range = document.createRange(), range.selectNodeContents(editor), range.collapse(!1), selection.removeAllRanges(), selection.addRange(range));
try {
const before = editor.innerHTML;
range.deleteContents();
const fragment = range.createContextualFragment(html), last = fragment.lastChild;
return range.insertNode(fragment), last && (range.setStartAfter(last), range.collapse(!0), selection.removeAllRanges(), selection.addRange(range)), this.#dispatchComposerInput(editor), editor.innerHTML !== before;
} catch {
return !1;
}
}
#dispatchComposerInput(editor) {
const event = editor.ownerDocument.createEvent("Event");
event.initEvent("input", !0, !1), editor.dispatchEvent(event);
}
async #openEdit(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), postReference = (0, import_identifiers.discoursePostReference)(input.post);
if (input.post.topic_id !== void 0 && (0, import_identifiers.discourseTopicId)(input.post.topic_id) !== topicId)
throw new Error("composer 编辑楼层不属于目标 Topic");
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer"));
if (!composer || typeof composer.open != "function")
throw new Error("Discourse 原生 composer service 未就绪");
const editAction = moduleDefault(this.#host, "discourse/models/composer").EDIT;
if (!editAction) throw new Error("Discourse Composer.EDIT 未就绪");
const topicModel = this.#models.createTopic(input.topic), postModel = this.#models.createPost(
input.topic,
input.post,
topicModel
), currentModel = modelValue(composer, "model"), currentTopicId = Number(
modelValue(
modelValue(currentModel, "topic") ?? modelValue(modelValue(currentModel, "post"), "topic"),
"id"
)
), currentOpen = modelValue(currentModel, "viewOpen") === !0 || String(modelValue(currentModel, "composeState") ?? "").toLowerCase() === "open", currentAction = modelValue(currentModel, "action");
if (currentOpen) {
const currentPostId = Number(
modelValue(modelValue(currentModel, "post"), "id")
);
if (currentTopicId !== topicId || currentAction !== editAction || currentPostId !== postReference.postId)
throw new Error("Discourse 原生 composer 正在处理另一项编辑,请先完成或关闭");
const session2 = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "edit",
reused: !0,
model: currentModel
});
if (!await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生编辑浮窗未显示");
return this.#session = session2, this.#presentComposerWindow(), this.#focusComposerInput(), session2;
}
if (await composer.open.call(composer, {
action: editAction,
post: postModel,
draftKey: normalizedDraftKey(input.topic.draft_key),
draftSequence: normalizedDraftSequence(input.topic.draft_sequence),
skipJumpOnSave: !0
}), this.#assertActive(), !await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生编辑浮窗未显示");
const model = modelValue(composer, "model");
if (!(0, import_value_record.valueRecord)(model)) throw new Error("Discourse composer.open 未生成 edit model");
const session = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "edit",
reused: !1,
model
});
return this.#session = session, this.#presentComposerWindow(), this.#focusComposerInput(), session;
}
#submitButton(document) {
const session = this.#session, composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer"));
if (!session || modelValue(composer, "model") !== session.model || typeof composer?.save != "function") return null;
const button = document.querySelector(
"#reply-control .save-or-cancel button.create"
);
return button && !button.disabled ? button : null;
}
#submit(button) {
if (button.disabled || this.#submitPromise) return;
const session = this.#session, composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), save = composer?.save;
if (!session || !this.#isolation || modelValue(composer, "model") !== session.model || typeof save != "function") return;
const transaction = this.#isolation.run(
session.topicId,
session.action === "edit" ? "edited" : "created",
() => save.call(composer, !0, { jump: !1 })
);
this.#submitPromise = transaction, transaction.catch((error) => {
this.#onError(error);
}).finally(() => {
this.#submitPromise === transaction && (this.#submitPromise = null);
});
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("DiscourseComposerCoordinator 已销毁");
}
}
const COMPOSER_SAVE_EVENTS = Object.freeze([
Object.freeze({
eventName: "composer:created-post",
kind: "created"
}),
Object.freeze({
eventName: "composer:edited-post",
kind: "edited"
}),
Object.freeze({
eventName: "post:created",
kind: "created"
})
]);
function topicRouteTarget(value) {
let segments;
try {
segments = new URL(String(value ?? ""), "https://reader.invalid").pathname.split("/").filter(Boolean);
} catch {
return null;
}
const topicIndex = segments.indexOf("t");
if (topicIndex < 0) return null;
const tail = segments.slice(topicIndex + 1), topicOffset = /^\d+$/.test(tail[0] ?? "") ? 0 : 1, topicId = Number(tail[topicOffset]), postNumber = Number(tail[topicOffset + 1]);
return Number.isSafeInteger(topicId) && topicId > 0 && Number.isSafeInteger(postNumber) && postNumber > 0 ? Object.freeze({ topicId, postNumber }) : null;
}
class DiscourseComposerHostIsolation {
scope;
#host;
#events = new import_signal.Signal();
#onError;
#releaseActive = null;
#suspended = null;
#running = !1;
constructor(options) {
this.#host = options.host, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.#releaseActive?.(), this.#releaseActive = null, this.#resumeTopicController(), this.#events.clear();
});
}
subscribe(listener, scope) {
const owned = import_lifecycle.LifecycleScope.ownedBy(scope ?? this.scope);
return new DiscourseComposerEventPort(this.#host).subscribe(listener, owned), this.#events.subscribe(listener, owned), () => owned.destroy();
}
async run(topicIdValue, kind, execute) {
if (this.scope.destroyed)
throw new Error("Discourse Composer 宿主隔离 owner 已销毁");
if (this.#running)
throw new Error("Discourse Composer save 事务正在进行");
const topicId = Number((0, import_identifiers.discourseTopicId)(topicIdValue)), appEvents = appEventsPort(
(0, import_value_record.valueRecord)(this.#host.lookup("service:composer"))?.appEvents ?? this.#host.lookup("service:app-events")
), originalTrigger = appEvents.trigger;
if (typeof originalTrigger != "function")
throw new Error("Discourse app-events 缺少 trigger");
const expectedNames = new Set(
kind === "edited" ? ["composer:edited-post"] : ["composer:created-post", "post:created"]
), suppressedNames = /* @__PURE__ */ new Set([
"composer:created-post",
"composer:edited-post",
"post:created",
"post:highlight"
]);
let observed = !1;
const trigger = (eventName, payload) => {
const name = String(eventName ?? "");
return expectedNames.has(name) && (observed = !0, this.#emit(Object.freeze({
kind,
payload,
eventName: name
}))), suppressedNames.has(name) ? appEvents : originalTrigger.call(appEvents, eventName, payload);
}, releases = [], release = () => {
for (const cleanup of releases.splice(0).reverse())
try {
cleanup();
} catch (cause) {
this.#report(cause);
}
this.#releaseActive === release && (this.#releaseActive = null);
};
this.#running = !0;
try {
if (appEvents.trigger = trigger, appEvents.trigger !== trigger)
throw new Error("Discourse app-events trigger 无法建立有界隔离");
releases.push(() => {
appEvents.trigger === trigger && (appEvents.trigger = originalTrigger);
});
const routeModule = (0, import_value_record.valueRecord)(
this.#host.lookupModule("discourse/lib/url")
), routeOwner = (0, import_value_record.valueRecord)(routeModule?.default) ?? routeModule, originalRouteTo = routeOwner?.routeTo;
if (routeOwner && typeof originalRouteTo == "function") {
const guardedRouteTo = (value, ...args) => {
if (topicRouteTarget(value)?.topicId !== topicId)
return originalRouteTo.call(routeOwner, value, ...args);
};
routeOwner.routeTo = guardedRouteTo, routeOwner.routeTo === guardedRouteTo && releases.push(() => {
routeOwner.routeTo === guardedRouteTo && (routeOwner.routeTo = originalRouteTo);
});
}
this.#suspendTopicController(topicId), this.#releaseActive = release;
const result = await execute();
return observed || this.#emit(Object.freeze({
kind,
payload: result,
eventName: kind === "edited" ? "composer:edited-post" : "composer:created-post"
})), result;
} finally {
release(), this.#running = !1;
}
}
runActive(kind, execute) {
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), model = modelValue(composer, "model"), topic = modelValue(model, "topic") ?? modelValue(modelValue(model, "post"), "topic");
return this.run(
Number((0, import_identifiers.discourseTopicId)(modelValue(topic, "id"))),
kind,
execute
);
}
destroy() {
this.scope.destroy();
}
#emit(event) {
for (const cause of this.#events.emit(event)) this.#report(cause);
}
#suspendTopicController(topicId) {
if (this.#suspended?.topicId === topicId) return;
this.#resumeTopicController();
const controller = (0, import_value_record.valueRecord)(this.#host.lookup("controller:topic")), hostTopicId = Number(modelValue(modelValue(controller, "model"), "id"));
!controller || hostTopicId !== topicId || typeof controller.unsubscribe != "function" || (controller.unsubscribe.call(controller), this.#suspended = Object.freeze({ controller, topicId }));
}
#resumeTopicController() {
const suspended = this.#suspended;
if (this.#suspended = null, !(!suspended || typeof suspended.controller.subscribe != "function" || Number(
modelValue(modelValue(suspended.controller, "model"), "id")
) !== suspended.topicId))
try {
suspended.controller.subscribe.call(suspended.controller);
} catch (cause) {
this.#report(cause);
}
}
#report(cause) {
try {
this.#onError(cause);
} catch {
}
}
}
function appEventsPort(value) {
const target = (0, import_value_record.valueRecord)(value);
if (!target || typeof target.on != "function" || typeof target.off != "function")
throw new Error("Discourse app-events 缺少 on/off");
return target;
}
function modelJson(value) {
const target = (0, import_value_record.valueRecord)(value);
if (!target || typeof target.toJSON != "function") return value;
try {
return target.toJSON.call(target);
} catch {
return value;
}
}
function eventPostCandidate(value) {
const normalized = modelJson(value), nested = modelValue(normalized, "post");
return nested === void 0 ? normalized : modelJson(nested);
}
function topicIdForPost(value) {
const direct = Number(modelValue(value, "topic_id"));
if (Number.isSafeInteger(direct) && direct > 0) return direct;
const topic = modelValue(value, "topic"), nested = Number(modelValue(topic, "id"));
return Number.isSafeInteger(nested) && nested > 0 ? nested : null;
}
class DiscourseComposerEventPort {
#host;
constructor(host) {
this.#host = host;
}
subscribe(listener, scope) {
let appEvents;
try {
appEvents = appEventsPort(
(0, import_value_record.valueRecord)(this.#host.lookup("service:composer"))?.appEvents ?? this.#host.lookup("service:app-events")
);
} catch {
return () => {
};
}
const bindings = [];
try {
for (const descriptor of COMPOSER_SAVE_EVENTS) {
const bound = (payload) => {
listener(Object.freeze({
kind: descriptor.kind,
payload,
eventName: descriptor.eventName
}));
};
appEvents.on(descriptor.eventName, bound), bindings.push(Object.freeze({
eventName: descriptor.eventName,
listener: bound
}));
}
} catch (error) {
for (const binding of bindings.reverse())
try {
appEvents.off(binding.eventName, binding.listener);
} catch {
}
throw error;
}
let active = !0;
const cleanup = () => {
if (active) {
active = !1;
for (const binding of bindings.reverse())
try {
appEvents.off(binding.eventName, binding.listener);
} catch {
}
}
};
return scope?.add(cleanup), cleanup;
}
}
class DiscourseComposerTopicSyncController {
scope;
changes = new import_signal.Signal();
#topicId;
#events;
#session;
#now;
#schedule;
#cancel;
#onError;
#pending = /* @__PURE__ */ new Map();
#unsubscribe = null;
constructor(options) {
this.#topicId = (0, import_identifiers.discourseTopicId)(options.topicId), this.#events = options.events, this.#session = options.session, this.#now = options.now ?? Date.now, this.#schedule = options.schedule ?? ((callback) => setTimeout(callback, 0)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle)), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.stop(), this.changes.clear();
});
}
get active() {
return this.#unsubscribe !== null;
}
start() {
this.#assertActive(), !this.#unsubscribe && (this.#unsubscribe = this.#events.subscribe((event) => {
this.#handle(event);
}, this.scope));
}
stop() {
const unsubscribe = this.#unsubscribe;
this.#unsubscribe = null, unsubscribe?.();
for (const pending of this.#pending.values())
"handle" in pending && this.#cancel(pending.handle);
this.#pending.clear();
}
destroy() {
this.scope.destroy();
}
#handle(event) {
if (this.scope.destroyed) return;
const candidate = eventPostCandidate(event.payload), topicId = topicIdForPost(candidate);
if (topicId !== null && topicId !== this.#topicId) return;
let reference = null;
if (topicId === this.#topicId)
try {
reference = (0, import_identifiers.discoursePostReference)(
candidate
);
} catch {
reference = null;
}
if (reference && reference.postId !== null) {
this.#cancelPending(event.kind);
try {
event.kind === "created" ? this.#session.ingestCreatedPost(
candidate,
"action-response",
this.#now()
) : this.#session.ingestPosts(
Object.freeze([candidate]),
"action-response",
this.#now()
), this.#emit(Object.freeze({
kind: event.kind,
post: candidate,
postNumber: reference.postNumber,
source: "native-event"
}));
} catch (error) {
this.#onError(error);
}
return;
}
this.#scheduleFallback(event.kind);
}
#scheduleFallback(kind) {
if (this.#pending.has(kind)) return;
const pending = {};
this.#pending.set(kind, pending);
try {
pending.handle = this.#schedule(() => {
this.#pending.get(kind) === pending && (this.#pending.delete(kind), Promise.resolve().then(async () => {
await this.#session.refresh({ background: !1 }), kind === "created" && await this.#session.loadLastPost({ refresh: !0 });
}).then(() => {
this.scope.destroyed || this.#emit(Object.freeze({
kind,
post: null,
postNumber: null,
source: "canonical-refresh"
}));
}).catch(this.#onError));
});
} catch (error) {
this.#pending.get(kind) === pending && this.#pending.delete(kind), this.#onError(error);
}
}
#cancelPending(kind) {
const pending = this.#pending.get(kind);
pending && (this.#pending.delete(kind), "handle" in pending && this.#cancel(pending.handle));
}
#emit(commit) {
if (!this.scope.destroyed)
for (const error of this.changes.emit(commit)) this.#onError(error);
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("DiscourseComposerTopicSyncController 已销毁");
}
}
}, "41f7e0c2861c4a517ecb56480e90811536d897e16b376788be46012bd6349ea5");
/* Source: lite/src/discourse/native-host-api.ts */
runtime.register("src/discourse/native-host-api.js", function(module, exports, require) {
var native_host_api_exports = {};
__export(native_host_api_exports, {
BrowserDiscourseBookmarkNativeState: () => BrowserDiscourseBookmarkNativeState,
BrowserDiscourseHostApiPort: () => BrowserDiscourseHostApiPort,
BrowserDiscourseNativeBookmarkForm: () => BrowserDiscourseNativeBookmarkForm,
BrowserDiscourseNotificationNativeState: () => BrowserDiscourseNotificationNativeState,
discourseAvatarTemplateUrl: () => discourseAvatarTemplateUrl,
discourseDeferredSubscription: () => discourseDeferredSubscription,
discourseNativeAppEventSubscription: () => discourseNativeAppEventSubscription,
discourseNativeBoostsAvailable: () => discourseNativeBoostsAvailable,
discourseNativeCurrentUserBindingAvailable: () => discourseNativeCurrentUserBindingAvailable,
discourseNativeCurrentUsername: () => discourseNativeCurrentUsername,
discourseNativeDefaultSiteTheme: () => discourseNativeDefaultSiteTheme,
discourseNativeEmojiMenu: () => discourseNativeEmojiMenu,
discourseNativeEmojiUrl: () => discourseNativeEmojiUrl,
discourseNativeExactTimeFormatter: () => discourseNativeExactTimeFormatter,
discourseNativeFlagCatalog: () => discourseNativeFlagCatalog,
discourseNativeFollowRoute: () => discourseNativeFollowRoute,
discourseNativeHostRouteRefresh: () => discourseNativeHostRouteRefresh,
discourseNativeIconRenderer: () => discourseNativeIconRenderer,
discourseNativeInitialCurrentUsername: () => discourseNativeInitialCurrentUsername,
discourseNativeJqueryModule: () => discourseNativeJqueryModule,
discourseNativeMenuCloser: () => discourseNativeMenuCloser,
discourseNativePostAdminMenu: () => discourseNativePostAdminMenu,
discourseNativePostEventModel: () => discourseNativePostEventModel,
discourseNativePostRuntimeBindings: () => discourseNativePostRuntimeBindings,
discourseNativeRelativeTimeFormatter: () => discourseNativeRelativeTimeFormatter,
discourseNativeSiteLogoUrl: () => discourseNativeSiteLogoUrl,
discourseNativeTheme: () => discourseNativeTheme,
discourseNativeTopicEditCatalog: () => discourseNativeTopicEditCatalog,
discourseNativeTopicLinks: () => discourseNativeTopicLinks,
discourseNativeTopicPresentation: () => discourseNativeTopicPresentation,
discourseNativeUnwantedTopicRuleCatalog: () => discourseNativeUnwantedTopicRuleCatalog,
discourseNativeUserActionBinding: () => discourseNativeUserActionBinding,
discourseNativeUserModel: () => discourseNativeUserModel
});
module.exports = __toCommonJS(native_host_api_exports);
var import_value_record = require("../kernel/value-record.js"), import_native_message_bus = require("./native-message-bus.js");
const DEFERRED_SUBSCRIPTION_RETRY_DELAYS_MS = Object.freeze([
0,
50,
100,
250,
500,
1e3,
2e3,
4e3,
7e3
]);
function discourseDeferredSubscription(attach) {
let active = !0, attachedCleanup = null, retryIndex = 0, retryTimer = null;
const tryAttach = () => {
if (!active || attachedCleanup) return;
const cleanup = attach();
if (cleanup) {
attachedCleanup = cleanup;
return;
}
if (retryIndex >= DEFERRED_SUBSCRIPTION_RETRY_DELAYS_MS.length) return;
const delay = DEFERRED_SUBSCRIPTION_RETRY_DELAYS_MS[retryIndex++];
if (delay === 0) {
queueMicrotask(tryAttach);
return;
}
retryTimer = setTimeout(() => {
retryTimer = null, tryAttach();
}, delay);
};
return tryAttach(), () => {
active && (active = !1, retryTimer !== null && clearTimeout(retryTimer), retryTimer = null, attachedCleanup?.(), attachedCleanup = null);
};
}
function discourseNativeAppEventSubscription(host, eventNameValue, listener, onError) {
const eventName = String(eventNameValue).trim();
return eventName ? discourseDeferredSubscription(() => {
const appEvents = (0, import_value_record.objectRecord)(host.lookup("service:app-events")), on = appEvents?.on, off = appEvents?.off;
if (typeof on != "function" || typeof off != "function") return null;
const owner = Object.freeze({});
try {
on.call(appEvents, eventName, owner, listener);
} catch (cause) {
return onError?.(cause), () => {
};
}
let active = !0;
return () => {
if (active) {
active = !1;
try {
off.call(appEvents, eventName, owner, listener);
} catch (cause) {
onError?.(cause);
}
}
};
}) : () => {
};
}
function discourseNativeUserModel(host) {
return host.lookupModule("discourse/models/user");
}
function discourseNativeFollowRoute(host, kind) {
return host.lookupModule(
`discourse/plugins/discourse-follow/discourse/routes/${kind}`
);
}
function discourseNativePostEventModel(host) {
return host.lookupModule(
"discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event"
);
}
function discourseNativeBoostsAvailable(host) {
const settings = (0, import_value_record.objectRecord)(host.lookup("service:site-settings"));
return settings && Object.hasOwn(settings, "discourse_boosts_enabled") ? settings.discourse_boosts_enabled === !0 : !!host.lookupModule(
"discourse/plugins/discourse-boosts/discourse/lib/create-boost"
);
}
function discourseNativeMenuCloser(host) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), close = menu?.close;
return typeof close != "function" ? null : (identifier) => {
const normalized = String(identifier).trim();
if (!normalized) throw new Error("Discourse menu identifier 不能为空");
return close.call(menu, normalized);
};
}
function discourseNativeHostRouteRefresh(host) {
const router = (0, import_value_record.objectRecord)(host.lookup("service:router")), refresh = router?.refresh;
if (typeof refresh != "function") return !1;
try {
const transition = refresh.call(router), rejection = (0, import_value_record.objectRecord)(transition)?.catch;
return typeof rejection == "function" && rejection.call(transition, () => {
}), !0;
} catch {
return !1;
}
}
function discourseNativeThemeMode(service) {
const colorMode = String(service?.colorMode ?? "").trim();
if (colorMode === "light" || colorMode === "dark") return colorMode;
if (colorMode === "auto") return "system";
if (service?.lightModeForced) return "light";
if (service?.darkModeForced) return "dark";
if (service?.selectorAvailable) return "system";
const session = (0, import_value_record.objectRecord)(service?.session);
return session ? session.defaultColorSchemeIsDark ? "dark" : "light" : null;
}
function discourseNativeTheme(host) {
return Object.freeze({
apply(mode) {
const service = (0, import_value_record.objectRecord)(
host.lookup("service:interface-color")
), action = service?.[mode === "light" ? "forceLightMode" : mode === "dark" ? "forceDarkMode" : "useAutoMode"];
if (typeof action != "function") return !1;
try {
return action.call(service), !0;
} catch {
return !1;
}
},
subscribe(listener, scope) {
const cleanup = discourseDeferredSubscription(() => {
const service = (0, import_value_record.objectRecord)(
host.lookup("service:interface-color")
), appEvents = (0, import_value_record.objectRecord)(service?.appEvents), on = appEvents?.on, off = appEvents?.off;
if (typeof on != "function" || typeof off != "function")
return null;
const context = Object.freeze({}), onChanged = () => {
const mode = discourseNativeThemeMode(service);
mode && listener(mode);
};
try {
on.call(
appEvents,
"interface-color:changed",
context,
onChanged
);
} catch {
return () => {
};
}
let active = !0;
return () => {
if (active) {
active = !1;
try {
off.call(
appEvents,
"interface-color:changed",
context,
onChanged
);
} catch {
}
}
};
});
return scope.add(cleanup), cleanup;
}
});
}
function discourseNativeDefaultSiteTheme(host) {
const site = host.lookup("service:site"), themeValues = nativeModelValue(site, "user_themes") ?? nativeModelValue(site, "userThemes"), defaultTheme = (Array.isArray(themeValues) ? themeValues : []).find(
(value) => nativeModelValue(value, "default") === !0
), defaultThemeId = Number(
nativeModelValue(defaultTheme, "theme_id") ?? nativeModelValue(defaultTheme, "themeId")
);
if (!Number.isSafeInteger(defaultThemeId) || defaultThemeId === 0)
return "unavailable";
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/theme-selector")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default), owner = typeof module2?.setLocalTheme == "function" ? module2 : defaultExport, currentThemeId = owner?.currentThemeId, setLocalTheme = owner?.setLocalTheme;
if (typeof currentThemeId != "function" || typeof setLocalTheme != "function")
return "unavailable";
try {
if (Number(currentThemeId.call(owner)) === defaultThemeId)
return "already-default";
const currentUser = discourseNativeCurrentUser(host), themeSequence = Number(
nativeModelValue(currentUser, "theme_key_seq") ?? nativeModelValue(currentUser, "themeKeySeq")
);
return !Number.isSafeInteger(themeSequence) || themeSequence < 0 ? "unavailable" : (setLocalTheme.call(owner, [defaultThemeId], themeSequence), "updated");
} catch {
return "failed";
}
}
function discourseNativeTopicEditCatalog(host) {
return Object.freeze({
categories() {
const site = host.lookup("service:site"), source = nativeModelValue(site, "categories");
if (!Array.isArray(source)) return Object.freeze([]);
const categories = source.map((value) => {
const id = Number(nativeModelValue(value, "id")), name = String(nativeModelValue(value, "name") ?? "").trim();
if (!Number.isSafeInteger(id) || id < 1 || !name) return null;
const parentId = Number(
nativeModelValue(value, "parent_category_id")
);
return Object.freeze({
id,
name,
slug: String(nativeModelValue(value, "slug") ?? "").trim(),
color: String(nativeModelValue(value, "color") ?? "").trim().replace(/^#/, ""),
parentCategoryId: Number.isSafeInteger(parentId) && parentId > 0 ? parentId : null
});
}).filter((value) => value !== null);
return Object.freeze(categories);
},
async searchTags(request) {
const tagUtils = (0, import_value_record.objectRecord)(host.lookup("service:tag-utils")), search = tagUtils?.searchTags, settings = host.lookup("service:site-settings");
if (!tagUtils || typeof search != "function" || !(0, import_value_record.objectRecord)(settings))
throw new Error("Discourse 标签搜索服务尚未就绪");
const tagUtilsOwner = tagUtils, selectedIds = request.selected.map((tag) => Number(tag.id)).filter((id) => Number.isSafeInteger(id) && id > 0), selectedNames = request.selected.filter((tag) => !(Number(tag.id) > 0)).map((tag) => tag.name), result = await search.call(
tagUtils,
"/tags/filter/search",
Object.freeze({
q: String(request.query ?? "").trim(),
limit: Math.max(
1,
Number(nativeModelValue(
settings,
"max_tag_search_results"
)) || 20
),
categoryId: request.categoryId > 0 ? request.categoryId : void 0,
filterForInput: !0,
...selectedIds.length ? { selected_tag_ids: selectedIds.slice(0, 100) } : {},
...selectedNames.length ? { selected_tags: selectedNames.slice(0, 100) } : {}
}),
(json) => {
const payload = (0, import_value_record.objectRecord)(json), incoming = Array.isArray(payload?.results) ? payload.results : [], sort = tagUtilsOwner.sortSearchResults;
return typeof sort == "function" ? sort.call(tagUtilsOwner, incoming) : incoming;
}
), values = Array.isArray(result) ? result : [], byName = /* @__PURE__ */ new Map();
for (const value of values) {
const name = String(
nativeModelValue(value, "name") ?? nativeModelValue(value, "text") ?? ""
).trim();
if (!name) continue;
const id = Number(nativeModelValue(value, "id"));
byName.set(name.toLocaleLowerCase(), Object.freeze({
id: Number.isSafeInteger(id) && id > 0 ? id : null,
name
}));
}
return Object.freeze([...byName.values()]);
}
});
}
function discourseNativeUnwantedTopicRuleCatalog(host) {
const topicCatalog = discourseNativeTopicEditCatalog(host);
return Object.freeze({
categories: () => topicCatalog.categories(),
searchTags: (request) => topicCatalog.searchTags(request),
async searchUsers(queryValue) {
const loaded = host.lookupModule("discourse/lib/user-search"), module2 = (0, import_value_record.valueRecord)(loaded), search = typeof loaded == "function" ? loaded : module2?.default;
if (typeof search != "function")
throw new Error("Discourse 用户搜索服务尚未就绪");
const query = String(queryValue ?? "").replace(/^@+/, "").trim();
if (!query) return Object.freeze([]);
const result = await search.call(module2, Object.freeze({
term: query,
includeGroups: !1,
includeStagedUsers: !1,
limit: 20
})), users = (0, import_value_record.valueRecord)(result)?.users, values = Array.isArray(users) ? users : [], byUsername = /* @__PURE__ */ new Map();
for (const value of values) {
const username = String(
nativeModelValue(value, "username") ?? ""
).trim();
if (!username) continue;
const key = username.toLocaleLowerCase("zh-CN");
if (byUsername.has(key)) continue;
const id = Number(nativeModelValue(value, "id"));
byUsername.set(key, Object.freeze({
id: Number.isSafeInteger(id) && id > 0 ? id : null,
username,
name: String(nativeModelValue(value, "name") ?? "").trim(),
avatarTemplate: String(
nativeModelValue(value, "avatar_template") ?? nativeModelValue(value, "avatarTemplate") ?? ""
).trim()
}));
}
return Object.freeze([...byUsername.values()]);
}
});
}
const DISCOURSE_ICON_ALIASES = Object.freeze({
"alert-triangle": "triangle-exclamation",
boost: "rocket",
"check-square": "square-check",
"circle-x": "circle-xmark",
"external-link": "arrow-up-right-from-square",
"eye-off": "eye-slash",
"header-bell": "far-bell",
"header-bookmark": "far-bookmark",
"header-settings": "sliders",
history: "clock-rotate-left",
languages: "language",
"list-checks": "list-check",
mail: "envelope",
"maximize-2": "up-right-and-down-left-from-center",
"minimize-2": "down-left-and-up-right-to-center",
"message-square": "message",
"panel-left": "table-columns",
"panel-right": "table-columns",
"floating-window": "window-maximize",
pin: "thumbtack",
"rotate-ccw": "arrow-rotate-left",
search: "magnifying-glass",
settings: "gear",
share: "share-nodes",
smile: "face-smile",
trash: "trash-can",
x: "xmark"
}), SVG_NAMESPACE = "http://www.w3.org/2000/svg", SAFE_SVG_FRAGMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
function sanitizeInlinedIcon(root) {
const queryAll = root.querySelectorAll;
if (typeof queryAll != "function") return;
const elements = [root, ...Array.from(queryAll.call(root, "*"))];
for (const element of elements) {
const localName = String(element.localName ?? element.tagName).toLowerCase();
if (localName === "script" || localName === "foreignobject") {
element.remove();
continue;
}
for (const attribute of Array.from(element.attributes ?? [])) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on")) {
element.removeAttribute(attribute.name);
continue;
}
(name === "href" || name === "xlink:href") && attribute.value && !attribute.value.startsWith("#") && element.removeAttribute(attribute.name);
}
}
}
function inlineDiscourseIconUse(value, document) {
const svg = value;
if (svg?.nodeType !== 1 || typeof svg.querySelector != "function" || typeof svg.replaceChildren != "function")
return;
const use = svg.querySelector("use"), href = use?.getAttribute("href") ?? use?.getAttribute("xlink:href") ?? "";
if (!href.startsWith("#")) return;
const fragmentId = href.slice(1);
if (!SAFE_SVG_FRAGMENT_ID.test(fragmentId)) return;
const symbol = document.getElementById(fragmentId);
if (!symbol || String(symbol.localName ?? symbol.tagName).toLowerCase() !== "symbol")
return;
const group = document.createElementNS(SVG_NAMESPACE, "g");
for (const child of Array.from(symbol.childNodes))
group.append(child.cloneNode(!0));
if (sanitizeInlinedIcon(group), !group.childNodes.length) return;
const viewBox = symbol.getAttribute("viewBox");
viewBox && !svg.getAttribute("viewBox") && svg.setAttribute("viewBox", viewBox), svg.replaceChildren(group);
}
function discourseNativeIconRenderer(host) {
return (nameValue, document) => {
const name = String(nameValue).trim();
if (!name) return null;
const candidates = Object.freeze([
DISCOURSE_ICON_ALIASES[name],
name
].filter((candidate2) => !!candidate2)), module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/icon-library")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default), owner = typeof module2?.iconElement == "function" ? module2 : defaultExport, iconElement = owner?.iconElement;
if (typeof iconElement == "function")
for (const candidate2 of candidates)
try {
const rendered = iconElement.call(owner, candidate2), node = rendered;
if (node?.nodeType !== 1 || !node.classList) continue;
return node.classList.add("ldp-icon", `ldp-icon-${name}`), node.dataset && (node.dataset.icon = name), node.setAttribute?.("aria-hidden", "true"), inlineDiscourseIconUse(rendered, document), rendered;
} catch {
}
const candidate = candidates[0];
if (!candidate) return null;
const svg = document.createElementNS(
SVG_NAMESPACE,
"svg"
);
svg.classList.add(
"svg-icon",
"icon",
"d-icon",
`d-icon-${candidate}`,
"ldp-icon",
`ldp-icon-${name}`
), svg.dataset.icon = name, svg.setAttribute("aria-hidden", "true");
const use = document.createElementNS(
SVG_NAMESPACE,
"use"
);
return use.setAttribute("href", `#${candidate}`), svg.append(use), svg;
};
}
function discourseAvatarTemplateUrl(template, size, baseUrl) {
const source = String(template).replace(/\{size\}/g, String(size)).trim();
if (!source) return null;
try {
return new URL(source, baseUrl).href;
} catch {
return null;
}
}
function discourseNativeCurrentUser(host) {
const serviceUser = host.lookup("service:current-user");
if (String(nativeModelValue(serviceUser, "username") ?? "").trim())
return serviceUser;
const userModule = (0, import_value_record.valueRecord)(host.lookupModule("discourse/models/user"));
for (const owner of [(0, import_value_record.valueRecord)(userModule?.default), userModule]) {
const current = owner?.current;
if (typeof current == "function")
try {
const user = current.call(owner);
if (user) return user;
} catch {
}
}
return serviceUser ?? null;
}
function discourseNativeCurrentUserBindingAvailable(host) {
const currentUser = host.lookup("service:current-user");
if (currentUser != null) return !0;
const userModule = (0, import_value_record.valueRecord)(host.lookupModule("discourse/models/user"));
return [(0, import_value_record.valueRecord)(userModule?.default), userModule].some((owner) => typeof owner?.current == "function");
}
function discourseNativeCurrentUsername(host) {
return String(
nativeModelValue(
discourseNativeCurrentUser(host),
"username"
) ?? ""
).trim();
}
function discourseNativeInitialCurrentUsername(host) {
const current = discourseNativeCurrentUsername(host);
if (current) return current;
const module2 = (0, import_value_record.valueRecord)(
host.lookupModule("discourse/lib/preload-store")
);
for (const owner of [(0, import_value_record.valueRecord)(module2?.default), module2]) {
const get = owner?.get;
if (typeof get == "function")
try {
const preloaded = get.call(owner, "currentUser"), username = String(
nativeModelValue(preloaded, "username") ?? ""
).trim();
if (username) return username;
} catch {
}
}
return "";
}
function discourseNativeSiteLogoUrl(host, baseUrl, fallbackCandidates = []) {
const settings = (0, import_value_record.objectRecord)(host.lookup("service:site-settings")), candidates = [
settings?.large_icon,
settings?.largeIcon,
settings?.apple_touch_icon,
settings?.appleTouchIcon,
settings?.favicon,
...fallbackCandidates
];
for (const candidate of candidates) {
const value = String(candidate ?? "").trim();
if (value)
try {
const url = new URL(value, baseUrl);
if (url.protocol === "https:" || url.protocol === "http:")
return url.href;
} catch {
}
}
return new URL("/favicon.ico", baseUrl).href;
}
function discourseNativeUserActionBinding(host, usernameValue) {
const username = String(usernameValue).trim().replace(/^@+/, "");
if (!username) throw new Error("User action username 不能为空");
const store = (0, import_value_record.objectRecord)(host.lookup("service:store")), createRecord = store?.createRecord;
if (typeof createRecord != "function")
throw new Error("Discourse 原生 store.createRecord 尚未就绪");
const user = createRecord.call(
store,
"user",
/* Ember Data 会在 createRecord 期间向属性袋补入 store 等内部字段。 */
{ username }
), actingUser = host.lookup("service:current-user");
if (!(0, import_value_record.objectRecord)(user) || !(0, import_value_record.objectRecord)(actingUser))
throw new Error("Discourse 原生用户动作 binding 尚未就绪");
return Object.freeze({
user,
actingUser
});
}
function discourseNativeJqueryModule(host) {
return host.lookupModule("jquery");
}
function discourseNativeFlagCatalog(host) {
const list = (value) => {
if (Array.isArray(value)) return value;
if (typeof (value !== null && (typeof value == "object" || typeof value == "function") ? value[Symbol.iterator] : void 0) != "function") return Object.freeze([]);
try {
return Object.freeze(Array.from(value));
} catch {
return Object.freeze([]);
}
};
return Object.freeze({
flagTypes() {
const site = host.lookup("service:site");
return Object.freeze(list(nativeModelValue(site, "flagTypes")).map((value) => {
const id = Number(nativeModelValue(value, "id")), nameKey = String(
nativeModelValue(value, "name_key") ?? ""
).trim();
if (!Number.isSafeInteger(id) || id <= 0 || !nameKey)
return null;
const appliesToValue = nativeModelValue(value, "applies_to"), appliesTo = Array.isArray(appliesToValue) ? appliesToValue.map(String).map((entry) => entry.trim()).filter(Boolean) : [];
return Object.freeze({
id,
nameKey,
label: String(
nativeModelValue(value, "name") ?? nameKey
).trim() || nameKey,
description: String(
nativeModelValue(value, "description") ?? nativeModelValue(value, "short_description") ?? ""
),
requireMessage: nativeModelValue(value, "require_message") === !0,
enabled: nativeModelValue(value, "enabled") !== !1,
appliesTo: Object.freeze(appliesTo)
});
}).filter(
(value) => value !== null
));
},
messageMaxLength() {
const postActionTypeModule = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/models/post-action-type")
), postActionTypeDefault = (0, import_value_record.objectRecord)(
postActionTypeModule?.default
), maxMessageLength = Number(
postActionTypeModule?.MAX_MESSAGE_LENGTH ?? postActionTypeDefault?.MAX_MESSAGE_LENGTH
);
return Number.isSafeInteger(maxMessageLength) && maxMessageLength > 0 ? maxMessageLength : 500;
}
});
}
function discourseNativeEmojiMenu(host) {
return Object.freeze({
async show(anchor, request) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), component = (0, import_value_record.objectRecord)(host.lookupModule(
"discourse/components/emoji-picker/detached"
))?.default, show = menu?.show;
if (typeof show != "function" || !component)
throw new Error("Discourse 原生表情组件尚未就绪");
const identifier = String(request.identifier).trim(), context = String(request.context).trim();
if (!identifier || !context)
throw new Error("emoji menu identifier/context 不能为空");
await Promise.resolve(show.call(menu, anchor, {
identifier,
groupIdentifier: identifier,
component,
modalForMobile: !1,
strategy: "fixed",
fallbackPlacements: Object.freeze([
"bottom-start",
"top-start",
"bottom-end",
"top-end"
]),
...request.computePosition ? { computePosition: request.computePosition } : {},
data: Object.freeze({
term: "",
context,
didSelectEmoji: request.didSelectEmoji
})
}));
},
close(identifier) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), close = menu?.close, normalized = String(identifier).trim();
if (!(typeof close != "function" || !normalized))
try {
const result = close.call(menu, normalized);
result && typeof result.then == "function" && Promise.resolve(result).catch(() => {
});
} catch {
}
}
});
}
function discourseNativePostAdminMenu(host, options = {}) {
return Object.freeze({
async show(anchor, post, scheduleRerender) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), component = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/components/admin-post-menu")
)?.default, topicController = (0, import_value_record.objectRecord)(host.lookup("controller:topic")), show = menu?.show, send = topicController?.send;
if (typeof show != "function" || !component || typeof send != "function")
throw new Error("Discourse 原生楼层管理菜单尚未就绪");
const topicAction = (name) => () => send.call(topicController, name, post);
await Promise.resolve(show.call(menu, anchor, {
identifier: "admin-post-menu",
component,
modalForMobile: !0,
autofocus: !0,
...options.computePosition ? {
strategy: "fixed",
fallbackPlacements: Object.freeze([
"right-start",
"left-start",
"right-end",
"left-end"
]),
computePosition: (content) => options.computePosition?.(anchor, content)
} : {},
data: Object.freeze({
post,
changeNotice: topicAction("changeNotice"),
changePostOwner: topicAction("changePostOwner"),
grantBadge: topicAction("grantBadge"),
lockPost: topicAction("lockPost"),
permanentlyDeletePost: topicAction("permanentlyDeletePost"),
rebakePost: topicAction("rebakePost"),
showPagePublish: topicAction("showPagePublish"),
togglePostType: topicAction("togglePostType"),
toggleWiki: topicAction("toggleWiki"),
unhidePost: topicAction("unhidePost"),
unlockPost: topicAction("unlockPost"),
scheduleRerender
})
}));
}
});
}
class BrowserDiscourseNativeBookmarkForm {
#host;
constructor(host) {
this.#host = host;
}
build(subjectType, subjectIdValue) {
if (subjectType !== "Post" && subjectType !== "Topic")
throw new Error(`未知收藏目标:${String(subjectType)}`);
const subjectId = Number(subjectIdValue);
if (!Number.isSafeInteger(subjectId) || subjectId < 1)
throw new RangeError("bookmark subjectId 必须是正安全整数");
const api = (0, import_value_record.objectRecord)(this.#host.lookup("service:bookmark-api")), buildNewBookmark = api?.buildNewBookmark;
if (!api || typeof buildNewBookmark != "function")
throw new Error(
"Discourse 原生收藏依赖未就绪:service:bookmark-api"
);
const module2 = (0, import_value_record.objectRecord)(
this.#host.lookupModule("discourse/lib/bookmark-form-data")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default), Constructor = module2?.BookmarkFormData ?? defaultExport?.BookmarkFormData ?? module2?.default;
if (typeof Constructor != "function")
throw new Error("Discourse BookmarkFormData 构造器未就绪");
const bookmark = (0, import_value_record.objectRecord)(
buildNewBookmark.call(api, subjectType, subjectId)
);
if (!bookmark)
throw new Error("bookmark-api.buildNewBookmark 未返回原生模型");
return new Constructor(bookmark);
}
}
function discourseNativePostRuntimeBindings(host) {
return Object.freeze({
topicModel: host.lookupModule("discourse/models/topic"),
topicDetailsModel: host.lookupModule("discourse/models/topic-details"),
postModel: host.lookupModule("discourse/models/post"),
appEvents: host.lookup("service:app-events"),
currentUser: host.lookup("service:current-user"),
siteSettings: host.lookup("service:site-settings")
});
}
function discourseNativeEmojiUrl(host, idValue) {
const id = String(idValue).trim().replace(/^:+|:+$/g, "");
if (!id) return "";
const module2 = (0, import_value_record.objectRecord)(host.lookupModule("discourse/lib/text")), defaultExport = (0, import_value_record.objectRecord)(module2?.default), owner = typeof module2?.emojiUrlFor == "function" ? module2 : defaultExport, emojiUrlFor = owner?.emojiUrlFor;
if (typeof emojiUrlFor != "function") return "";
try {
return String(emojiUrlFor.call(owner, id) ?? "").trim();
} catch {
return "";
}
}
function discourseNativeRelativeTimeFormatter(host) {
let owner = null;
return (timestamp) => {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return "";
if (!owner || typeof owner.relativeAge != "function") {
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/formatter")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default);
owner = typeof module2?.relativeAge == "function" ? module2 : defaultExport;
}
const relativeAge = owner?.relativeAge;
if (typeof relativeAge != "function") return "";
try {
return String(relativeAge.call(owner, date, {
format: "medium-with-ago",
wrapInSpan: !1
}) ?? "");
} catch {
return "";
}
};
}
function discourseNativeExactTimeFormatter(host) {
let owner = null;
return (timestamp) => {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return "";
if (!owner || typeof owner.longDate != "function") {
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/formatter")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default);
owner = typeof module2?.longDate == "function" ? module2 : defaultExport;
}
const longDate = owner?.longDate;
if (typeof longDate != "function") return "";
try {
return String(longDate.call(owner, date) ?? "");
} catch {
return "";
}
};
}
function discourseNativeTopicPresentation(host) {
const urlOwner = () => {
const module2 = (0, import_value_record.objectRecord)(host.lookupModule("discourse/lib/url"));
return typeof module2?.getCategoryAndTagUrl == "function" ? module2 : (0, import_value_record.objectRecord)(module2?.default);
}, avatarOwner = () => {
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/avatar-utils")
);
return typeof module2?.avatarUrl == "function" ? module2 : (0, import_value_record.objectRecord)(module2?.default);
}, categoryModel = (categoryId) => {
const categories = nativeModelValue(
host.lookup("service:site"),
"categories"
);
return Array.isArray(categories) ? categories.find((candidate) => Number(nativeModelValue(candidate, "id")) === categoryId) : void 0;
}, categoryAndTagUrl = (categoryId, tag) => {
const owner = urlOwner(), method = owner?.getCategoryAndTagUrl, category = categoryId > 0 ? categoryModel(categoryId) : null;
if (categoryId > 0 && !category) return "";
if (typeof method == "function")
try {
const resolved = String(method.call(
owner,
category,
!0,
tag
) ?? "");
if (resolved) return resolved;
} catch {
}
const normalizedTag = String(tag ?? "").trim();
if (normalizedTag) return `/tag/${encodeURIComponent(normalizedTag)}`;
const slug = String(nativeModelValue(category, "slug") ?? "").trim();
return categoryId > 0 && slug ? `/c/${encodeURIComponent(slug)}/${categoryId}` : "";
};
return Object.freeze({
avatarSource(template, size) {
const normalized = String(template ?? "").trim();
if (!normalized) return "";
const owner = avatarOwner(), method = owner?.avatarUrl;
if (typeof method != "function")
return normalized.replace(/\{size\}/g, String(size));
try {
return (String(
method.call(owner, normalized, size) ?? ""
).trim() || normalized).replace(/\{size\}/g, String(size));
} catch {
return normalized.replace(/\{size\}/g, String(size));
}
},
categoryName(categoryId) {
return !Number.isSafeInteger(categoryId) || categoryId < 1 ? "" : String(
nativeModelValue(categoryModel(categoryId), "name") ?? ""
).trim();
},
categoryIcon(categoryId) {
return !Number.isSafeInteger(categoryId) || categoryId < 1 ? "" : String(
nativeModelValue(categoryModel(categoryId), "icon") ?? ""
).trim();
},
categoryHref(categoryId, tag) {
const normalizedId = Number.isSafeInteger(categoryId) && categoryId > 0 ? categoryId : 0, normalizedTag = String(tag ?? "").trim();
return categoryAndTagUrl(
normalizedId,
normalizedTag || void 0
);
},
tagHref(tag) {
const normalized = String(tag ?? "").trim();
return normalized ? categoryAndTagUrl(0, normalized) : "";
},
userHref(username) {
const normalized = String(username ?? "").trim();
if (!normalized) return "";
const owner = urlOwner(), userPath = owner?.userPath;
if (typeof userPath == "function")
try {
return String(userPath.call(owner, normalized) ?? "");
} catch {
return "";
}
return `/u/${encodeURIComponent(normalized)}`;
}
});
}
function discourseNativeTopicLinks(host, baseUrl) {
let normalizedBase = "";
try {
normalizedBase = new URL(baseUrl).href;
} catch {
normalizedBase = "";
}
return Object.freeze({
topicHref(topicIdValue, postNumberValue = 0) {
const topicId = Number(topicIdValue), postNumber = Number(postNumberValue);
if (!Number.isSafeInteger(topicId) || topicId < 1 || !Number.isSafeInteger(postNumber) || postNumber < 0 || !normalizedBase)
return "";
const path = `/t/${topicId}${postNumber ? `/${postNumber}` : ""}`;
try {
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/get-url")
), defaultExport = module2?.default, getUrl = typeof defaultExport == "function" ? defaultExport : typeof module2?.getURL == "function" ? module2.getURL : null, nativeValue = getUrl ? String(getUrl.call(module2, path) ?? "").trim() : "", candidate = new URL(nativeValue || path, normalizedBase), segments = candidate.pathname.split("/").filter(Boolean), topicSegmentIndex = segments.indexOf("t"), candidateTopicId = Number(segments[topicSegmentIndex + 1]);
return candidate.origin === new URL(normalizedBase).origin && topicSegmentIndex >= 0 && candidateTopicId === topicId ? candidate.href : new URL(path, normalizedBase).href;
} catch {
return new URL(path, normalizedBase).href;
}
}
});
}
function nativeModelValue(value, key) {
const record = (0, import_value_record.objectRecord)(value), getter = record?.get;
if (typeof getter == "function")
try {
return getter.call(value, key);
} catch {
return;
}
return record?.[key];
}
function setNativeModelValues(value, values) {
const record = (0, import_value_record.objectRecord)(value), setProperties = record?.setProperties;
if (typeof setProperties == "function") {
setProperties.call(value, values);
return;
}
const set = record?.set;
if (typeof set == "function")
for (const [key, entry] of Object.entries(values))
set.call(value, key, entry);
}
function nativeCount(value) {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : null;
}
function currentUserUnreadCount(currentUser) {
const all = nativeCount(nativeModelValue(
currentUser,
"all_unread_notifications_count"
));
if (all !== null) return all;
const normal = nativeCount(nativeModelValue(
currentUser,
"unread_notifications"
)), high = nativeCount(nativeModelValue(
currentUser,
"unread_high_priority_notifications"
));
return normal !== null || high !== null ? (normal ?? 0) + (high ?? 0) : nativeCount(nativeModelValue(
currentUser,
"unread_notification_count"
)) ?? 0;
}
function nativeNotificationRecord(value) {
return (0, import_value_record.objectRecord)(value) ?? Object.freeze({});
}
function nativeNotificationData(value) {
const raw = nativeNotificationRecord(value).data;
if (raw !== null && typeof raw == "object") return raw;
if (typeof raw != "string" || !raw.trim()) return Object.freeze({});
try {
return nativeNotificationRecord(JSON.parse(raw));
} catch {
return Object.freeze({});
}
}
function nativePresentationText(value) {
const toHTML = (0, import_value_record.objectRecord)(value)?.toHTML;
let source = value;
if (typeof toHTML == "function")
try {
source = toHTML.call(value);
} catch {
return "";
}
return String(source ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function fallbackNotificationPresentation(notification, typeName) {
const source = nativeNotificationRecord(notification), data = nativeNotificationData(source);
return Object.freeze({
actor: data.display_username ?? data.username ?? source.username,
typeName,
typeLabel: typeName || "通知",
summary: data.topic_title ?? typeName ?? "通知",
href: data.post_url ?? data.topic_url ?? data.url ?? "",
topicId: source.topic_id ?? data.topic_id,
postNumber: source.post_number ?? data.post_number
});
}
class BrowserDiscourseNotificationNativeState {
#host;
constructor(host) {
this.#host = host;
}
username() {
return String(nativeModelValue(
discourseNativeCurrentUser(this.#host),
"username"
) ?? "").trim().replace(/^@/, "");
}
unreadCount() {
return currentUserUnreadCount(
this.#host.lookup("service:current-user")
);
}
markAllRead() {
const currentUser = this.#host.lookup("service:current-user");
if (!currentUser) return;
const values = {
all_unread_notifications_count: 0,
unread_notifications: 0,
unread_high_priority_notifications: 0,
grouped_unread_notifications: Object.freeze({})
};
nativeModelValue(currentUser, "unread_notification_count") !== void 0 && (values.unread_notification_count = 0), setNativeModelValues(currentUser, Object.freeze(values));
}
markRead(input) {
const currentUser = this.#host.lookup("service:current-user");
if (!currentUser) return;
const normal = nativeCount(nativeModelValue(
currentUser,
"unread_notifications"
)) ?? 0, high = nativeCount(nativeModelValue(
currentUser,
"unread_high_priority_notifications"
)) ?? 0, nextNormal = input.highPriority ? normal : Math.max(0, normal - 1), nextHigh = input.highPriority ? Math.max(0, high - 1) : high, grouped = { ...(0, import_value_record.objectRecord)(nativeModelValue(
currentUser,
"grouped_unread_notifications"
)) ?? {} };
if (input.notificationTypeId !== null) {
const key = String(input.notificationTypeId), count = nativeCount(grouped[key]) ?? 0;
count > 0 && (grouped[key] = count - 1);
}
const values = {
grouped_unread_notifications: Object.freeze(grouped),
unread_notifications: nextNormal,
unread_high_priority_notifications: nextHigh,
all_unread_notifications_count: nextNormal + nextHigh
}, legacy = nativeCount(nativeModelValue(
currentUser,
"unread_notification_count"
));
legacy !== null && (values.unread_notification_count = Math.max(0, legacy - 1)), setNativeModelValues(currentUser, Object.freeze(values));
}
async present(notifications) {
const site = this.#host.lookup("service:site"), lookup = (0, import_value_record.objectRecord)(nativeModelValue(site, "notificationLookup")), typeName = (notification) => {
const source = nativeNotificationRecord(notification);
return String(lookup?.[String(Number(source.notification_type))] ?? "");
}, notificationModule = (0, import_value_record.objectRecord)(
this.#host.lookupModule("discourse/models/notification")
), Notification = (0, import_value_record.objectRecord)(notificationModule?.default), initialize = Notification?.initializeNotifications, managerModule = (0, import_value_record.objectRecord)(
this.#host.lookupModule("discourse/lib/notification-types-manager")
), manager = typeof managerModule?.getRenderDirector == "function" ? managerModule : (0, import_value_record.objectRecord)(managerModule?.default), getRenderDirector = manager?.getRenderDirector, currentUser = this.#host.lookup("service:current-user"), siteSettings = this.#host.lookup("service:site-settings");
if (typeof initialize != "function" || typeof getRenderDirector != "function" || !currentUser || !siteSettings || !site)
return Object.freeze(notifications.map((notification) => fallbackNotificationPresentation(
notification,
typeName(notification)
)));
let models;
try {
const source = notifications.map((notification) => Object.freeze({
...nativeNotificationRecord(notification),
data: nativeNotificationData(notification)
})), initialized = await initialize.call(Notification, source);
models = Array.isArray(initialized) ? initialized : source;
} catch {
return Object.freeze(notifications.map((notification) => fallbackNotificationPresentation(
notification,
typeName(notification)
)));
}
return Object.freeze(notifications.map((notification, index) => {
const model = models[index] ?? notification, modelData = nativeNotificationData(model), resolvedType = typeName(model) || typeName(notification);
try {
const director = (0, import_value_record.objectRecord)(getRenderDirector.call(
manager,
resolvedType,
model,
currentUser,
siteSettings,
site
));
if (!director)
return fallbackNotificationPresentation(
notification,
resolvedType
);
const label = nativePresentationText(director.label), description = nativePresentationText(director.description);
return Object.freeze({
actor: modelData.display_username ?? modelData.username ?? nativeModelValue(model, "username"),
typeName: resolvedType,
typeLabel: nativePresentationText(director.linkTitle) || resolvedType || "通知",
summary: [label, description].filter(Boolean).join(" · "),
href: String(director.linkHref ?? "").trim(),
topicId: nativeModelValue(model, "topic_id") ?? nativeModelValue(model, "topicId") ?? modelData.topic_id,
postNumber: nativeModelValue(model, "post_number") ?? nativeModelValue(model, "postNumber") ?? modelData.post_number
});
} catch {
return fallbackNotificationPresentation(
notification,
resolvedType
);
}
}));
}
subscribeChanged(listener) {
const appEvents = (0, import_value_record.objectRecord)(
this.#host.lookup("service:app-events")
), on = appEvents?.on, off = appEvents?.off, cleanups = [], context = Object.freeze({});
if (typeof on == "function" && typeof off == "function")
try {
on.call(appEvents, "notifications:changed", context, listener), cleanups.push(() => {
try {
off.call(appEvents, "notifications:changed", context, listener);
} catch {
}
});
} catch {
}
const userId = Number(nativeModelValue(
discourseNativeCurrentUser(this.#host),
"id"
));
if (Number.isSafeInteger(userId) && userId > 0) {
const messageBus = new import_native_message_bus.BrowserDiscourseMessageBusPort(this.#host);
let active2 = !0;
const onMessage = () => {
Promise.resolve().then(() => {
active2 && listener();
});
}, channel = `/notification/${userId}`;
try {
messageBus.subscribe(channel, onMessage), cleanups.push(() => {
active2 = !1;
try {
messageBus.unsubscribe(channel, onMessage);
} catch {
}
});
} catch {
active2 = !1;
}
}
let active = !0;
return () => {
if (active) {
active = !1;
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
}
};
}
subscribeClicked(listener) {
return discourseNativeAppEventSubscription(
this.#host,
"user-menu:notification-click",
(payload) => {
const source = (0, import_value_record.objectRecord)(payload), notification = source?.notification, notificationId = Number(nativeModelValue(notification, "id"));
if (!Number.isSafeInteger(notificationId) || notificationId <= 0) return;
const read = nativeModelValue(notification, "read");
listener(Object.freeze({
notificationId,
href: String(source?.href ?? "").trim(),
wasRead: typeof read == "boolean" ? read : null
}));
}
);
}
}
class BrowserDiscourseBookmarkNativeState {
#host;
constructor(host) {
this.#host = host;
}
username() {
return discourseNativeCurrentUsername(this.#host).replace(/^@/, "");
}
findGivenReactions(usernameValue, beforeReactionUserId) {
const username = String(usernameValue).trim().replace(/^@/, "");
if (!username)
return Promise.reject(new Error("回应记录需要当前登录用户名"));
const module2 = (0, import_value_record.objectRecord)(this.#host.lookupModule(
"discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction"
)), model = (0, import_value_record.valueRecord)(module2?.default), findReactions = model?.findReactions;
if (typeof findReactions != "function")
return Promise.reject(new Error("Discourse 回应记录接口尚未就绪"));
const cursor = Number(beforeReactionUserId);
return Promise.resolve(findReactions.call(
model,
"reactions",
username,
Object.freeze({
...Number.isSafeInteger(cursor) && cursor > 0 ? { beforeReactionUserId: cursor } : {}
})
));
}
subscribeChanged(listener) {
return discourseDeferredSubscription(() => {
const appEvents = (0, import_value_record.objectRecord)(
this.#host.lookup("service:app-events")
), on = appEvents?.on, off = appEvents?.off;
if (typeof on != "function" || typeof off != "function") return null;
const context = Object.freeze({}), subscriptions = Object.freeze([
["bookmarks:changed", "bookmarks"],
["discourse-reactions:reaction-toggled", "reactions"]
]), attached = [];
for (const [name, source] of subscriptions) {
const handler = () => listener(source);
try {
on.call(appEvents, name, context, handler), attached.push(Object.freeze({ name, handler }));
} catch {
}
}
let active = !0;
return () => {
if (active) {
active = !1;
for (const entry of attached)
try {
off.call(
appEvents,
entry.name,
context,
entry.handler
);
} catch {
}
}
};
});
}
}
function readyModule(name, value) {
if (typeof value == "function") return !0;
const module2 = (0, import_value_record.objectRecord)(value);
if (module2 === null || Reflect.ownKeys(module2).length === 0) return !1;
if (name !== "discourse/lib/text") return !0;
const defaultExport = (0, import_value_record.objectRecord)(module2.default);
return typeof module2.emojiUrlFor == "function" || typeof defaultExport?.emojiUrlFor == "function";
}
function nonEmptyName(value, kind) {
const normalized = String(value).trim();
if (!normalized) throw new Error(`Discourse ${kind} 名称不能为空`);
return normalized;
}
function moduleName(value) {
const normalized = nonEmptyName(value, "module");
if (normalized !== "jquery" && !normalized.startsWith("discourse/"))
throw new Error(`拒绝解析非 Discourse 原生 module:${normalized}`);
return normalized;
}
function callable(owner, name) {
const candidate = owner?.[name];
return typeof candidate == "function" ? candidate : null;
}
function moduleContainer(value) {
const module2 = (0, import_value_record.objectRecord)(value), defaultExport = (0, import_value_record.objectRecord)(module2?.default), candidate = (0, import_value_record.objectRecord)(defaultExport?.container ?? module2?.container);
return typeof candidate?.lookup == "function" ? candidate : null;
}
class BrowserDiscourseHostApiPort {
#pageWindow;
#modules = /* @__PURE__ */ new Map();
#lookups = /* @__PURE__ */ new Map();
#container = null;
constructor(options) {
const pageWindow = (0, import_value_record.objectRecord)(options.pageWindow);
if (!pageWindow) throw new Error("Discourse page window 不可用");
this.#pageWindow = pageWindow;
}
lookup(nameValue) {
const name = nonEmptyName(nameValue, "container lookup"), volatile = name === "service:current-user";
if (!volatile && this.#lookups.has(name)) return this.#lookups.get(name);
const container = this.#resolveContainer();
if (!container) return null;
try {
const value = container.lookup(name);
return !volatile && value !== null && value !== void 0 && this.#lookups.set(name, value), value ?? null;
} catch {
return null;
}
}
lookupModule(nameValue) {
const name = moduleName(nameValue);
if (this.#modules.has(name)) return this.#modules.get(name);
const broker = (0, import_value_record.objectRecord)(this.#pageWindow.moduleBroker), brokerLookup = callable(broker, "lookup"), requireModule = callable(this.#pageWindow, "require"), requireJsModule = callable(this.#pageWindow, "requirejs"), resolvers = Object.freeze([
() => brokerLookup?.call(broker, name, !0),
() => requireModule?.call(this.#pageWindow, name),
() => requireJsModule?.call(this.#pageWindow, name)
]);
for (const resolve of resolvers)
try {
const value = resolve();
if (readyModule(name, value))
return this.#modules.set(name, value), value;
} catch {
}
return null;
}
#resolveContainer() {
if (this.#container) return this.#container;
const urlContainer = moduleContainer(this.lookupModule("discourse/lib/url")), discourse = (0, import_value_record.objectRecord)(this.#pageWindow.Discourse), fallback = (0, import_value_record.objectRecord)(discourse?.__container__ ?? discourse?.container), fallbackContainer = typeof fallback?.lookup == "function" ? fallback : null;
return this.#container = urlContainer ?? fallbackContainer, this.#container;
}
}
}, "b1692ff3151f3c4ea4ff4e7d17999c87a45e84aa11d07134e5583dc2633f6abc");
/* Source: lite/src/discourse/native-message-bus.ts */
runtime.register("src/discourse/native-message-bus.js", function(module, exports, require) {
var native_message_bus_exports = {};
__export(native_message_bus_exports, {
BrowserDiscourseMessageBusPort: () => BrowserDiscourseMessageBusPort
});
module.exports = __toCommonJS(native_message_bus_exports);
function channelName(value) {
const channel = String(value).trim();
if (!channel.startsWith("/") || channel.includes("://"))
throw new Error("Discourse MessageBus channel 必须是站内 /channel");
return channel;
}
function resolveMessageBus(host) {
const service = host.lookup("service:message-bus");
if (!service || typeof service != "object" || typeof service.subscribe != "function" || typeof service.unsubscribe != "function")
throw new Error("Discourse 原生 service:message-bus 不可用");
return {
owner: service,
subscribe: service.subscribe,
unsubscribe: service.unsubscribe
};
}
class BrowserDiscourseMessageBusPort {
nativeBinding = "service:message-bus";
#host;
constructor(host) {
this.#host = host;
}
subscribe(channelValue, handler) {
const channel = channelName(channelValue);
if (typeof handler != "function") throw new TypeError("MessageBus handler 必须是函数");
const resolved = resolveMessageBus(this.#host);
resolved.subscribe.call(resolved.owner, channel, handler);
}
unsubscribe(channelValue, handler) {
const channel = channelName(channelValue);
if (typeof handler != "function") throw new TypeError("MessageBus handler 必须是函数");
const resolved = resolveMessageBus(this.#host);
resolved.unsubscribe.call(resolved.owner, channel, handler);
}
}
}, "d0bf1383145a984b90fd78cbeddbeeb62db60d8aa3f7058b1742c93585f7f76d");
/* Source: lite/src/discourse/native-post-model-factory.ts */
runtime.register("src/discourse/native-post-model-factory.js", function(module, exports, require) {
var native_post_model_factory_exports = {};
__export(native_post_model_factory_exports, {
DiscourseNativePostModelFactory: () => DiscourseNativePostModelFactory
});
module.exports = __toCommonJS(native_post_model_factory_exports);
var import_identifiers = require("./identifiers.js"), import_native_host_api = require("./native-host-api.js"), import_native_message_bus = require("./native-message-bus.js"), import_value_record = require("../kernel/value-record.js");
function moduleDefault(moduleValue, name) {
const module2 = (0, import_value_record.valueRecord)(moduleValue), value = (0, import_value_record.valueRecord)(module2?.default);
if (!value) throw new Error(`Discourse 原生模块未就绪:${name}`);
return value;
}
function modelValue(value, key) {
const target = (0, import_value_record.valueRecord)(value), getter = target?.get;
return typeof getter == "function" ? getter.call(target, key) : target?.[key];
}
function setModelValue(value, key, next) {
const target = (0, import_value_record.valueRecord)(value), set = target?.set;
if (typeof set == "function") {
set.call(target, key, next);
return;
}
target && (target[key] = next);
}
function reactionId(value) {
return String(value ?? "").trim().replace(/^:+|:+$/g, "");
}
function modelFactory(moduleValue, name) {
const model = moduleDefault(moduleValue, name);
if (typeof model.create != "function")
throw new Error(`Discourse 原生 model 缺少 create:${name}`);
return model;
}
class DiscourseNativePostModelFactory {
#host;
#bindings = null;
constructor(host) {
this.#host = host;
}
/**
* 只解析并校验回复 Composer 必需的 Topic/Post 原生 model。
*
* 该预热入口不创建 model、不读取 draft,也不触发任何宿主请求;实际回复仍由
* DiscourseComposerCoordinator 在用户操作后创建对应 Topic/Post 实例。
*/
prepareComposerBindings() {
const bindings = this.#runtimeBindings();
if (modelFactory(bindings.topicModel, "discourse/models/topic"), typeof modelFactory(bindings.postModel, "discourse/models/post").munge != "function")
throw new Error("Discourse Post.munge 未就绪");
}
createTopic(topic) {
const bindings = this.#runtimeBindings();
return this.#createTopic(topic, bindings.topicModel);
}
createTopicDetails(topic) {
const bindings = this.#runtimeBindings(), TopicDetails = modelFactory(
bindings.topicDetailsModel,
"discourse/models/topic-details"
), details = (0, import_value_record.valueRecord)(topic.details), topicRecord = topic;
return TopicDetails.create({
...details ?? {},
topic: this.#createTopic(topic, bindings.topicModel),
notification_level: topicRecord.notification_level ?? modelValue(details, "notification_level") ?? 1
});
}
createPost(topic, post, topicModel) {
const bindings = this.#runtimeBindings(), owner = topicModel ?? this.#createTopic(topic, bindings.topicModel);
return this.#createPost(topic, post, owner, bindings.postModel);
}
createContext(topic, post) {
const bindings = this.#runtimeBindings(), appEvents = (0, import_value_record.valueRecord)(bindings.appEvents);
if (!appEvents) throw new Error("Discourse app-events service 未就绪");
const topicModel = this.#createTopic(topic, bindings.topicModel);
return Object.freeze({
topic: topicModel,
post: this.#createPost(topic, post, topicModel, bindings.postModel),
appEvents
});
}
/**
* discourse-calendar 的活动报名必须携带插件原生 Event model。
*
* 特殊正文 feature 只提交 canonical event JSON;module lookup 与 model.create 继续
* 收口在同一原生 model 工厂,不能由 DOM 组件自行解析插件模块。
*/
createPostEvent(event, fallbackPostId) {
const EventModel = modelFactory(
(0, import_native_host_api.discourseNativePostEventModel)(this.#host),
"discourse-post-event-event"
), id = Number(event.id) || Number(fallbackPostId);
if (!Number.isSafeInteger(id) || id < 1)
throw new RangeError("活动必须具有正安全整数 ID");
return EventModel.create({
...event,
id
});
}
/**
* Discourse `/client_settings` 是回应目录热更新的唯一宿主事件源。
*
* 收到消息后先更新原生 site-settings service,再通知现有 PostView 重投;本方法
* 不请求 emoji 目录、不维护第二份设置快照,也不暴露 MessageBus 给 UI。
*/
subscribeClientSettings(listener) {
if (typeof listener != "function")
throw new TypeError("client settings listener 必须是函数");
const messageBus = new import_native_message_bus.BrowserDiscourseMessageBusPort(this.#host), handler = (message) => {
const input = (0, import_value_record.valueRecord)(message), name = String(input?.name ?? "").trim();
[
"discourse_reactions_enabled_reactions",
"discourse_reactions_reaction_for_like"
].includes(name) && (setModelValue(
this.#runtimeBindings().siteSettings,
name,
input?.value
), listener());
};
try {
messageBus.subscribe("/client_settings", handler);
} catch {
return () => {
};
}
return () => {
try {
messageBus.unsubscribe("/client_settings", handler);
} catch {
}
};
}
/**
* 需要原生 current-user model 作为参数的动作共用这一窄入口。
*
* 不把 service lookup 暴露给 UI;匿名态返回 null,由动作组件保持原按钮不可用。
*/
currentUser() {
return (0, import_value_record.valueRecord)(this.#runtimeBindings().currentUser);
}
sharedIssueAllowsMultipleSolutions() {
return modelValue(
this.#runtimeBindings().siteSettings,
"solved_allow_multiple_solutions"
) === !0;
}
minimumPostLength() {
const value = Number(modelValue(
this.#runtimeBindings().siteSettings,
"min_post_length"
));
return Number.isSafeInteger(value) && value > 0 ? value : 16;
}
reactionRegistry() {
const bindings = this.#runtimeBindings(), settings = (0, import_value_record.valueRecord)(bindings.siteSettings), configuredValue = modelValue(
settings,
"discourse_reactions_enabled_reactions"
), source = Array.isArray(configuredValue) ? configuredValue : String(configuredValue ?? "").split("|"), configuredIds = Object.freeze(
source.map(reactionId).filter(Boolean)
), mainReaction = reactionId(modelValue(
settings,
"discourse_reactions_reaction_for_like"
));
return Object.freeze({
configuredIds,
mainReaction,
emojiUrl: (id) => (0, import_native_host_api.discourseNativeEmojiUrl)(this.#host, reactionId(id))
});
}
reportContext(topic, post, nameKeys) {
const native = this.createContext(topic, post), actionByName = modelValue(native.post, "actionByName"), actions = [...new Set(nameKeys.map(String).map((name) => name.trim()).filter(Boolean))].map((nameKey) => {
const action = modelValue(actionByName, nameKey), actionRecord = (0, import_value_record.valueRecord)(action);
return modelValue(action, "can_act") !== !0 || !actionRecord ? null : (Number(post.post_number) === 1 && (actionRecord.flagTopic = native.post), Object.freeze({
nameKey,
action
}));
}).filter(
(value) => value !== null
);
return Object.freeze({
post: native.post,
actions: Object.freeze(actions)
});
}
#createTopic(topic, topicModule) {
const topicId = (0, import_identifiers.discourseTopicId)(topic.id), Topic = modelFactory(topicModule, "discourse/models/topic"), details = (0, import_value_record.valueRecord)(topic.details);
return Topic.create({
id: topicId,
title: String(topic.title ?? ""),
fancy_title: String(topic.fancy_title ?? topic.title ?? ""),
slug: String(topic.slug ?? "topic"),
category_id: topic.category_id,
archetype: String(topic.archetype ?? "regular"),
draft_key: topic.draft_key,
draft_sequence: topic.draft_sequence,
posts_count: topic.posts_count,
highest_post_number: topic.highest_post_number,
last_posted_at: topic.last_posted_at,
chunk_size: topic.chunk_size,
details: { can_create_post: !0, ...details ?? {} },
tags: Array.isArray(topic.tags) ? [...topic.tags] : []
});
}
#createPost(topic, post, topicModel, postModule) {
const topicId = (0, import_identifiers.discourseTopicId)(topic.id);
if (post.topic_id !== void 0 && (0, import_identifiers.discourseTopicId)(post.topic_id) !== topicId)
throw new Error("楼层不属于目标 Topic");
const Post = modelFactory(postModule, "discourse/models/post");
if (typeof Post.munge != "function")
throw new Error("Discourse Post.munge 未就绪");
const reference = (0, import_identifiers.discoursePostReference)(post), actions = Array.isArray(post.actions_summary) ? post.actions_summary.map((action) => action && typeof action == "object" ? { ...action } : action) : [], attributes = {
...post,
id: reference.postId,
topic_id: topicId,
post_number: reference.postNumber,
username: String(post.username ?? ""),
name: String(post.name ?? ""),
avatar_template: String(post.avatar_template ?? ""),
post_type: Number(post.post_type ?? 1),
reply_count: Number(post.reply_count ?? 0),
topic: topicModel,
actions_summary: actions
}, munge = Post.munge;
return Post.create(munge.call(Post, attributes));
}
#runtimeBindings() {
if (this.#bindings) return this.#bindings;
const bindings = (0, import_native_host_api.discourseNativePostRuntimeBindings)(this.#host);
return Object.values(bindings).every((value) => value != null) && (this.#bindings = bindings), bindings;
}
}
}, "c492019f71ca4690d179e142f690ae7cd181e8b77625f562c85b5bff3e4330fe");
/* Source: lite/src/discourse/native-presence.ts */
runtime.register("src/discourse/native-presence.js", function(module, exports, require) {
var native_presence_exports = {};
__export(native_presence_exports, {
BrowserDiscoursePresencePort: () => BrowserDiscoursePresencePort
});
module.exports = __toCommonJS(native_presence_exports);
var import_value_record = require("../kernel/value-record.js");
function modelValue(value, key) {
const source = (0, import_value_record.valueRecord)(value);
if (!source) return;
const getter = source.get;
if (typeof getter == "function")
try {
const result = getter.call(value, key);
if (result !== void 0) return result;
} catch {
}
return source[key];
}
function normalizeUsers(value) {
let source = [];
if (Array.isArray(value)) source = value;
else {
const toArray = (0, import_value_record.valueRecord)(value)?.toArray;
if (typeof toArray == "function")
try {
const result = toArray.call(value);
Array.isArray(result) && (source = result);
} catch {
source = [];
}
else value && typeof value[Symbol.iterator] == "function" && (source = [...value]);
}
const users = source.map((candidate) => {
const username = String(
modelValue(candidate, "username") ?? ""
).trim();
return username ? Object.freeze({
username,
name: String(
modelValue(candidate, "name") ?? username
).trim() || username,
avatarTemplate: String(
modelValue(candidate, "avatar_template") ?? modelValue(candidate, "avatarTemplate") ?? ""
).trim()
}) : null;
}).filter((user) => user !== null);
return Object.freeze(users);
}
function topicIdentifier(value) {
const topicId = Number(value);
if (!Number.isSafeInteger(topicId) || topicId < 1)
throw new RangeError("Presence topicId 必须是正安全整数");
return topicId;
}
class BrowserDiscoursePresencePort {
nativeBinding = "service:presence";
#host;
constructor(host) {
this.#host = host;
}
watchReplying(topicIdValue, listener, onError = () => {
}) {
const topicId = topicIdentifier(topicIdValue);
if (typeof listener != "function")
throw new TypeError("Presence listener 必须是函数");
const service = (0, import_value_record.valueRecord)(this.#host.lookup("service:presence")), getChannel = service?.getChannel;
if (typeof getChannel != "function")
return listener(Object.freeze([])), () => {
};
let channel = null;
try {
channel = (0, import_value_record.valueRecord)(
getChannel.call(
service,
`/discourse-presence/reply/${topicId}`
)
);
} catch (error) {
onError(error);
}
if (!channel || typeof channel.subscribe != "function" || typeof channel.unsubscribe != "function")
return listener(Object.freeze([])), () => {
};
const nativeChannel = channel, subscribe = nativeChannel.subscribe, unsubscribe = nativeChannel.unsubscribe;
let active = !0, subscriptionSettled = !1, unsubscribeIssued = !1;
const publish = () => {
if (active)
try {
listener(normalizeUsers(modelValue(nativeChannel, "users")));
} catch (error) {
onError(error);
}
}, change = () => publish();
if (typeof nativeChannel.on == "function")
try {
nativeChannel.on.call(nativeChannel, "change", change);
} catch (error) {
onError(error);
}
const issueUnsubscribe = () => {
if (!unsubscribeIssued) {
unsubscribeIssued = !0;
try {
Promise.resolve(
unsubscribe.call(nativeChannel)
).catch(onError);
} catch (error) {
onError(error);
}
}
};
try {
Promise.resolve(
subscribe.call(nativeChannel)
).then(() => {
subscriptionSettled = !0, active ? publish() : issueUnsubscribe();
}).catch((error) => {
subscriptionSettled = !0, active && (onError(error), listener(Object.freeze([]))), issueUnsubscribe();
});
} catch (error) {
subscriptionSettled = !0, onError(error), listener(Object.freeze([])), issueUnsubscribe();
}
return () => {
if (active) {
if (active = !1, typeof nativeChannel.off == "function")
try {
nativeChannel.off.call(nativeChannel, "change", change);
} catch (error) {
onError(error);
}
subscriptionSettled && issueUnsubscribe();
}
};
}
}
}, "785adf7cee061f235c01912b31517054fd9e90a0c2a16c7bc51fd86ae56433d1");
/* Source: lite/src/discourse/native-request-descriptors.ts */
runtime.register("src/discourse/native-request-descriptors.js", function(module, exports, require) {
var native_request_descriptors_exports = {};
__export(native_request_descriptors_exports, {
DISCOURSE_DIRECT_REPLIES_PAGE_SIZE: () => DISCOURSE_DIRECT_REPLIES_PAGE_SIZE,
DiscourseNativeRequests: () => DiscourseNativeRequests,
assertDiscourseNativeMutationDescriptor: () => assertDiscourseNativeMutationDescriptor,
assertDiscourseNativeReadDescriptor: () => assertDiscourseNativeReadDescriptor,
discourseBasePath: () => discourseBasePath,
discourseNativeTargetFailureIsDefinitive: () => discourseNativeTargetFailureIsDefinitive
});
module.exports = __toCommonJS(native_request_descriptors_exports);
var import_identifiers = require("./identifiers.js");
const nativeReadDescriptorBrand = Symbol("DiscourseNativeReadDescriptor"), nativeMutationDescriptorBrand = Symbol("DiscourseNativeMutationDescriptor"), nativeReadDescriptors = /* @__PURE__ */ new WeakSet(), nativeMutationDescriptors = /* @__PURE__ */ new WeakSet(), DISCOURSE_DIRECT_REPLIES_PAGE_SIZE = 20;
function discourseNativeTargetFailureIsDefinitive(input) {
return input.scope === "single" && input.endpoint === "post-by-number" && (input.status === 404 || input.status === 410);
}
function discourseBasePath(value) {
const normalized = String(value ?? "").trim().replace(/\/+$/, "");
if (normalized && !normalized.startsWith("/") && !/^https?:\/\//i.test(normalized))
throw new Error("basePath 必须是绝对 URL 或以 / 开头的路径");
return normalized;
}
function encodedSlug(value) {
const slug = String(value ?? "topic").trim();
if (!slug) throw new Error("slug 不能为空");
return encodeURIComponent(slug);
}
function readDescriptor(operation, path, options = {}) {
const descriptor = Object.freeze({
operation,
path,
headers: Object.freeze({ ...options.headers ?? {} }),
browserCache: options.browserCache ?? "default",
[nativeReadDescriptorBrand]: !0
});
return nativeReadDescriptors.add(descriptor), descriptor;
}
function assertDiscourseNativeReadDescriptor(value) {
if (value === null || typeof value != "object" || !nativeReadDescriptors.has(value))
throw new Error("读取请求必须来自 Discourse 原生请求目录");
}
function assertDiscourseNativeMutationDescriptor(value) {
if (value === null || typeof value != "object" || !nativeMutationDescriptors.has(value))
throw new Error("写请求必须来自 Discourse 原生请求目录");
}
function targetDescriptor(endpoint, url, refresh) {
return Object.freeze({
endpoint,
url,
descriptor: readDescriptor("target-post", url, {
headers: { Accept: "application/json" },
browserCache: refresh ? "no-store" : "default"
})
});
}
const DiscourseNativeRequests = Object.freeze({
topic(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), basePath = discourseBasePath(input.basePath);
return readDescriptor(
"topic",
`${basePath}/t/${topicId}.json?track_visit=true&forceLoad=true`,
{
headers: {
Accept: "application/json",
"Discourse-Track-View": "true",
"Discourse-Track-View-Topic-Id": String(topicId)
}
}
);
},
postsById(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), query = (0, import_identifiers.discoursePostIds)(input.postIds).map((postId) => `post_ids[]=${encodeURIComponent(postId)}`).join("&");
return readDescriptor(
"posts-by-id",
`${discourseBasePath(input.basePath)}/t/${topicId}/posts.json?${query}`,
{ headers: { Accept: "application/json" } }
);
},
postById(input) {
const postId = (0, import_identifiers.discoursePostId)(input.postId);
return readDescriptor(
"post-by-id",
`${discourseBasePath(input.basePath)}/posts/${postId}.json`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
targetCandidates(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), postNumber = (0, import_identifiers.discoursePostNumber)(input.postNumber), basePath = discourseBasePath(input.basePath), slug = encodedSlug(input.slug), refresh = input.refresh === !0, paths = Object.freeze({
"post-by-number": `${basePath}/posts/by_number/${topicId}/${postNumber}.json`,
"topic-floor": `${basePath}/t/${slug}/${topicId}/${postNumber}.json`,
"topic-query": `${basePath}/t/${slug}/${topicId}.json?post_number=${postNumber}`,
"topic-id-query": `${basePath}/t/${topicId}.json?post_number=${postNumber}`
}), order = input.scope === "around" ? ["topic-floor", "topic-query", "topic-id-query", "post-by-number"] : ["post-by-number", "topic-floor", "topic-id-query"];
return Object.freeze(order.map((endpoint) => targetDescriptor(endpoint, paths[endpoint], refresh)));
},
directReplies(input) {
const parentPostId = (0, import_identifiers.discoursePostId)(input.parentPostId), after = (0, import_identifiers.discourseReplyCursor)(input.after), suffix = after > 0 ? `?after=${after}` : "";
return readDescriptor(
"direct-replies",
`${discourseBasePath(input.basePath)}/posts/${parentPostId}/replies.json${suffix}`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
postVotingComments(input) {
const postId = (0, import_identifiers.discoursePostId)(input.postId), afterCommentId = Number(input.afterCommentId ?? 0);
if (!Number.isSafeInteger(afterCommentId) || afterCommentId < 0)
throw new RangeError("afterCommentId 必须是非负安全整数");
const query = new URLSearchParams({ post_id: String(postId) });
return afterCommentId > 0 && query.set("last_comment_id", String(afterCommentId)), readDescriptor(
"post-voting-comments",
`${discourseBasePath(input.basePath)}/post_voting/comments?${query}`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
boostReportAccess(input) {
const boostId = Number(input.boostId);
if (!Number.isSafeInteger(boostId) || boostId <= 0)
throw new RangeError("boostId 必须是正安全整数");
return readDescriptor(
"boost-report-access",
`${discourseBasePath(input.basePath)}/discourse-boosts/boosts/${boostId}.json`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
endorsableCategories(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
return readDescriptor(
"endorsable-categories",
`${discourseBasePath(input.basePath)}/category-experts/endorsable-categories/${encodeURIComponent(username)}.json`,
{ headers: { Accept: "application/json" } }
);
},
userFollowList(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
if (input.kind !== "following" && input.kind !== "followers")
throw new Error("关注列表类型无效");
return readDescriptor(
"user-follow-list",
`${discourseBasePath(input.basePath)}/u/${encodeURIComponent(username)}/follow/${input.kind}`,
{ headers: { Accept: "application/json" } }
);
},
userBadges(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
return readDescriptor(
"user-badges",
`${discourseBasePath(input.basePath)}/user-badges/${encodeURIComponent(username)}.json`,
{ headers: { Accept: "application/json" } }
);
},
userSummary(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
return readDescriptor(
"user-summary",
`${discourseBasePath(input.basePath)}/u/${encodeURIComponent(username)}/summary.json`,
{ headers: { Accept: "application/json" } }
);
},
userDirectoryStats(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
const query = new URLSearchParams({
period: "all",
order: "likes_received",
username
});
return readDescriptor(
"user-directory-stats",
`${discourseBasePath(input.basePath)}/directory_items.json?${query}`,
{ headers: { Accept: "application/json" } }
);
},
topicTimings(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), postNumbers = (0, import_identifiers.discoursePostNumbers)(input.postNumbers), readTimeMs = Number(input.readTimeMs);
if (!Number.isSafeInteger(readTimeMs) || readTimeMs < 1 || readTimeMs > 6e4)
throw new RangeError("readTimeMs 必须是 1..60000 的安全整数");
const timings = Object.fromEntries(postNumbers.map((postNumber) => [
String(postNumber),
readTimeMs
])), data = Object.freeze({
topic_id: topicId,
topic_time: readTimeMs * postNumbers.length,
timings: Object.freeze(timings)
}), descriptor = Object.freeze({
operation: "topic-timings",
path: `${discourseBasePath(input.basePath)}/topics/timings`,
method: "POST",
headers: Object.freeze({
"Discourse-Background": "true",
"X-SILENCE-LOGGER": "true"
}),
data,
[nativeMutationDescriptorBrand]: !0
});
return nativeMutationDescriptors.add(descriptor), descriptor;
},
topicSummary(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), descriptor = Object.freeze({
operation: "topic-summary",
path: `${discourseBasePath(input.basePath)}/discourse-ai/summarization/t/${topicId}`,
method: "POST",
headers: Object.freeze({ Accept: "application/json" }),
data: Object.freeze({}),
[nativeMutationDescriptorBrand]: !0
});
return nativeMutationDescriptors.add(descriptor), descriptor;
},
topicSummaryImageUpload(input) {
const descriptor = Object.freeze({
operation: "topic-summary-image-upload",
path: `${discourseBasePath(input.basePath)}/uploads.json`,
method: "POST",
headers: Object.freeze({ Accept: "application/json" }),
data: input.formData,
multipart: !0,
[nativeMutationDescriptorBrand]: !0
});
return nativeMutationDescriptors.add(descriptor), descriptor;
}
});
}, "e02199ee304a9a30932ed0e49eb4cc80be4143f88d9fc516734cd45b70331918");
/* Source: lite/src/discourse/native-topic-notification-action.ts */
runtime.register("src/discourse/native-topic-notification-action.js", function(module, exports, require) {
var native_topic_notification_action_exports = {};
__export(native_topic_notification_action_exports, {
BrowserDiscourseTopicNotificationLevelMutationPort: () => BrowserDiscourseTopicNotificationLevelMutationPort
});
module.exports = __toCommonJS(native_topic_notification_action_exports);
var import_native_post_model_factory = require("./native-post-model-factory.js"), import_discourse_action_descriptors = require("../post/discourse-action-descriptors.js"), import_discourse_action_transport = require("../post/discourse-action-transport.js"), import_value_record = require("../kernel/value-record.js");
function modelValue(value, key) {
const source = (0, import_value_record.valueRecord)(value), getter = source?.get;
return typeof getter == "function" ? getter.call(value, key) : source?.[key];
}
class BrowserDiscourseTopicNotificationLevelMutationPort {
#models;
#descriptors = new import_discourse_action_descriptors.DiscourseActionDescriptors();
#actions;
constructor(host) {
this.#models = new import_native_post_model_factory.DiscourseNativePostModelFactory(host), this.#actions = new import_discourse_action_transport.BrowserDiscourseNativeActionPort(host);
}
async setLevel(topic, level) {
const topicId = Number(modelValue(topic, "id"));
if (!Number.isSafeInteger(topicId) || topicId < 1)
throw new RangeError("宿主话题通知动作缺少有效 Topic ID");
const topicDetails = this.#models.createTopicDetails(
topic
), mutation = this.#descriptors.topicNotificationLevel({
topicId,
topicDetails,
level
}), definition = (0, import_discourse_action_transport.discourseActionTransportDefinition)(
mutation.operation,
mutation.targetType
), response = await this.#actions.execute({
definition,
targetId: mutation.targetId,
variant: mutation.variant ?? null,
payload: mutation.payload,
signal: new AbortController().signal,
attempt: 0
});
if (!response.ok)
throw new Error(`宿主话题通知设置失败(HTTP ${response.status || 0})`);
}
}
}, "70e2eaa947da735f595616c706700d003b7deb8e12729a0093c9cc1f662f72f7");
/* Source: lite/src/discourse/reader-native-composer-window.ts */
runtime.register("src/discourse/reader-native-composer-window.js", function(module, exports, require) {
var reader_native_composer_window_exports = {};
__export(reader_native_composer_window_exports, {
DISCOURSE_NATIVE_FLOATING_SELECTOR: () => DISCOURSE_NATIVE_FLOATING_SELECTOR,
ReaderNativeComposerWindowController: () => ReaderNativeComposerWindowController,
discourseNativeFloatingSurfaceVisible: () => discourseNativeFloatingSurfaceVisible,
normalizeReaderNativeComposerGeometry: () => normalizeReaderNativeComposerGeometry,
readerNativeComposerFontPixels: () => readerNativeComposerFontPixels,
readerNativeTopLayerPort: () => readerNativeTopLayerPort,
visibleDiscourseNativeFloatingSurface: () => visibleDiscourseNativeFloatingSurface
});
module.exports = __toCommonJS(reader_native_composer_window_exports);
var import_floating_surface_wheel = require("../dom/floating-surface-wheel.js"), import_event_target = require("../dom/event-target.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_preferences_schema = require("../state/reader-preferences-schema.js");
const GEOMETRY_PROPERTIES = Object.freeze([
"--ldp-composer-left",
"--ldp-composer-top",
"--ldp-composer-width",
"--ldp-composer-height",
"--ldp-composer-transform"
]), APPEARANCE_PROPERTIES = Object.freeze([
"--tertiary",
"--tertiary-low",
"--d-link-color"
]), RESIZE_DIRECTIONS = Object.freeze([
"n",
"s",
"e",
"w",
"ne",
"nw",
"se",
"sw"
]), DISCOURSE_NATIVE_FLOATING_SELECTOR = ".fk-d-menu,.emoji-picker";
function discourseNativeFloatingSurfaceVisible(element, window = element.ownerDocument.defaultView) {
if (!element.isConnected || element.hidden || element.disabled || element.getAttribute("aria-hidden") === "true" || element.classList.contains("hidden") || element.classList.contains("d-none") || element.classList.contains("closed") || element.closest('[hidden],[aria-hidden="true"]')) return !1;
try {
const style = window?.getComputedStyle?.(element);
if (style?.display === "none" || style?.visibility === "hidden" || style?.contentVisibility === "hidden") return !1;
} catch {
}
const rect = element.getBoundingClientRect(), viewportWidth = Number(window?.innerWidth) || Number(element.ownerDocument.documentElement.clientWidth) || 0, viewportHeight = Number(window?.innerHeight) || Number(element.ownerDocument.documentElement.clientHeight) || 0;
return rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0 && (viewportWidth <= 0 || rect.left < viewportWidth) && (viewportHeight <= 0 || rect.top < viewportHeight);
}
function visibleDiscourseNativeFloatingSurface(document, window = document.defaultView) {
return [...document.querySelectorAll(
DISCOURSE_NATIVE_FLOATING_SELECTOR
)].find(
(element) => discourseNativeFloatingSurfaceVisible(element, window)
) ?? null;
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function finite(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function pixels(value) {
return `${Math.round(value * 100) / 100}px`;
}
function viewportLimits(viewport) {
const width = Math.max(1, finite(viewport.width, 1)), height = Math.max(1, finite(viewport.height, 1)), margin = Math.max(0, Math.min(
16,
Math.floor((width - 1) / 2),
Math.floor((height - 1) / 2)
)), maxWidth = Math.max(1, width - margin * 2), maxHeight = Math.max(1, height - margin * 2);
return Object.freeze({
left: margin,
top: margin,
right: width - margin,
bottom: height - margin,
maxWidth,
maxHeight,
minWidth: Math.min(520, maxWidth),
minHeight: Math.min(300, maxHeight)
});
}
function normalizeReaderNativeComposerGeometry(viewport, value) {
const bounds = viewportLimits(viewport), fallbackWidth = Math.min(1200, bounds.maxWidth), fallbackHeight = clamp(
bounds.maxHeight * 0.7,
bounds.minHeight,
bounds.maxHeight
), fallbackLeft = bounds.left + (bounds.maxWidth - fallbackWidth) / 2, fallbackTop = bounds.top + (bounds.maxHeight - fallbackHeight) / 2, width = clamp(
finite(value?.width, fallbackWidth),
bounds.minWidth,
bounds.maxWidth
), height = clamp(
finite(value?.height, fallbackHeight),
bounds.minHeight,
bounds.maxHeight
);
return Object.freeze({
left: clamp(
finite(value?.left, fallbackLeft),
bounds.left,
bounds.right - width
),
top: clamp(
finite(value?.top, fallbackTop),
bounds.top,
bounds.bottom - height
),
width,
height
});
}
function readerNativeComposerFontPixels(viewport, geometry, profile) {
const bounds = viewportLimits(viewport), widthTarget = Math.min(980, bounds.maxWidth), heightTarget = Math.min(720, bounds.maxHeight), widthProgress = clamp(
(geometry.width - bounds.minWidth) / Math.max(1, widthTarget - bounds.minWidth),
0,
1
), heightProgress = clamp(
(geometry.height - bounds.minHeight) / Math.max(1, heightTarget - bounds.minHeight),
0,
1
), automatic = 12 + Math.min(widthProgress, heightProgress) * 4, ratio = finite(profile.composer, import_reader_preferences_schema.READER_FONT_DEFAULT.composer) / import_reader_preferences_schema.READER_FONT_DEFAULT.composer;
return Math.round(clamp(automatic * ratio, 8, 40) * 100) / 100;
}
function readerNativeTopLayerPort() {
return Object.freeze({
isOpen: (element) => {
try {
return element.matches(":popover-open");
} catch {
return !1;
}
},
show: (element) => element.showPopover?.(),
hide: (element) => element.hidePopover?.()
});
}
class ReaderNativeComposerWindowController {
scope;
#document;
#window;
#mount;
#pageRoot;
#readPreferences;
#updatePreferences;
#readFontProfile;
#readAppearance;
#createMutationObserver;
#requestFrame;
#cancelFrame;
#setTimer;
#clearTimer;
#topLayer;
#onError;
#ownedTopLayers = /* @__PURE__ */ new Set();
#composer = null;
#composerRoot = null;
#composerAppearanceOriginal = /* @__PURE__ */ new Map();
#composerScope = null;
#hostObserver = null;
#floatingObserver = null;
#overflowLayers = [];
#chrome = null;
#geometry = null;
#pointer = null;
#pointerX = 0;
#pointerY = 0;
#pointerFrame = 0;
#chromeFrame = 0;
#overflowFrame = 0;
#floatingFrame = 0;
#persistTimer = 0;
#destroyed = !1;
constructor(options) {
this.#document = options.document, this.#window = options.window, this.#mount = options.mount, this.#pageRoot = options.pageRoot ?? options.document.documentElement, this.#readPreferences = options.readPreferences, this.#updatePreferences = options.updatePreferences ?? null, this.#readFontProfile = options.readFontProfile ?? (() => this.#readPreferences().fontProfile), this.#readAppearance = options.readAppearance ?? null, this.#createMutationObserver = options.createMutationObserver ?? ((callback) => new MutationObserver(callback)), this.#requestFrame = options.requestFrame ?? ((callback) => this.#window.requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((frameId) => this.#window.cancelAnimationFrame(frameId)), this.#setTimer = options.setTimer ?? ((callback, milliseconds) => this.#window.setTimeout(callback, milliseconds)), this.#clearTimer = options.clearTimer ?? ((timerId) => this.#window.clearTimeout(timerId)), this.#topLayer = options.topLayer ?? readerNativeTopLayerPort(), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => this.#teardown()), this.scope.listen(this.#window, "resize", () => {
this.#stopPointer(), this.#geometry ? this.#applyGeometry(this.#geometry) : this.#scheduleChrome(), this.#syncTopLayers(), this.#scheduleOverflow();
}), options.preferenceChanges.subscribe(() => {
this.#pointer || !this.#composerVisible() || (this.#geometry = this.#preferredGeometry(), this.#applyGeometry(this.#geometry));
}, this.scope), options.fontChanges?.subscribe((profile) => {
this.syncFont(profile);
}, this.scope), options.appearanceChanges?.subscribe((appearance) => {
this.#syncAppearance(appearance);
}, this.scope), this.#document.body && (this.#hostObserver = this.#createMutationObserver(() => {
this.sync(), this.#observeHostTargets();
}), this.#observeHostTargets(), this.scope.add(() => this.#hostObserver?.disconnect())), this.sync();
}
get geometry() {
return this.#geometry;
}
open(element = null) {
if (this.#destroyed) return !1;
const candidate = element ?? this.#document.querySelector("#reply-control");
return candidate !== this.#composer && this.#bindComposer(candidate), this.#composerAvailable() ? (this.#activate(), !0) : (this.#deactivate(), !1);
}
sync() {
if (this.#destroyed) return;
const candidate = this.#document.querySelector("#reply-control");
if (candidate !== this.#composer && this.#bindComposer(candidate), !this.#composerVisible()) {
this.#deactivate();
return;
}
this.#activate();
}
syncFont(profile = this.#readFontProfile()) {
!this.#composer || !this.#geometry || this.#composer.style.setProperty(
"--ldp-composer-font-size",
`${readerNativeComposerFontPixels(
this.#viewport(),
this.#geometry,
profile
)}px`
);
}
destroy() {
this.scope.destroy();
}
#viewport() {
return Object.freeze({
width: this.#window.innerWidth,
height: this.#window.innerHeight
});
}
#desktop() {
return this.#window.innerWidth > 760;
}
#preferredGeometry() {
const preferences = this.#readPreferences(), preferred = {
...preferences.composerWindowWidth ? { width: preferences.composerWindowWidth } : {},
...preferences.composerWindowHeight ? { height: preferences.composerWindowHeight } : {},
...preferences.composerWindowX ? { left: preferences.composerWindowX } : {},
...preferences.composerWindowY ? { top: preferences.composerWindowY } : {}
};
return normalizeReaderNativeComposerGeometry(
this.#viewport(),
preferred
);
}
#composerVisible() {
const composer = this.#composer;
if (!composer || !this.#composerAvailable()) return !1;
try {
const style = this.#window.getComputedStyle?.(composer), stagedByReader = style?.visibility === "hidden" && this.#pageRoot.classList.contains("ldp-reader-open") && !composer.dataset.ldpReaderComposerPositioned;
if (style?.display === "none" || style?.visibility === "hidden" && !stagedByReader)
return !1;
} catch {
}
return !0;
}
#composerAvailable() {
const composer = this.#composer;
return !!(composer?.isConnected && !composer.hidden && !composer.classList.contains("closed") && !composer.classList.contains("hidden") && !composer.classList.contains("d-none") && composer.getAttribute("aria-hidden") !== "true" && !composer.closest('[hidden],[aria-hidden="true"]'));
}
#bindComposer(composer) {
if (this.#composerScope?.destroy(), this.#composerScope = null, this.#composer && this.#deactivate(), this.#composer = composer, !composer) return;
const scope = this.scope.child();
this.#composerScope = scope;
const observer = this.#createMutationObserver(() => this.sync());
observer.observe(composer, {
attributes: !0,
attributeFilter: ["class", "hidden", "aria-hidden"],
childList: !0,
subtree: !0
}), scope.add(() => observer.disconnect()), scope.add((0, import_floating_surface_wheel.bindFloatingSurfaceWheel)(composer, { capture: !0 })), scope.add(() => {
this.#composer === composer && (this.#deactivate(), this.#composer = null);
});
}
#observeHostTargets() {
const observer = this.#hostObserver, body = this.#document.body;
if (!observer || !body) return;
if (observer.disconnect(), !this.#composer) {
const bootstrap = this.#document.querySelector("#ember-app") ?? body;
observer.observe(bootstrap, { childList: !0, subtree: !0 });
let ancestor2 = bootstrap.parentElement;
for (; ancestor2 && (observer.observe(ancestor2, { childList: !0 }), ancestor2 !== body); )
ancestor2 = ancestor2.parentElement;
return;
}
let ancestor = this.#composer.parentElement;
for (; ancestor && (observer.observe(ancestor, { childList: !0 }), ancestor !== body); )
ancestor = ancestor.parentElement;
}
#activate() {
const composer = this.#composer;
composer && (this.#ensureChrome(), this.#syncComposerRoot(), this.#applyGeometry(this.#geometry ?? this.#preferredGeometry()), composer.classList.contains("fullscreen") && (this.#stopPointer(), composer.style.removeProperty("translate")), this.#syncTopLayers(), this.#scheduleOverflow());
}
#deactivate() {
this.#stopPointer(), this.#persistGeometry(), this.#releaseTopLayers(), this.#chrome && (this.#chrome.hidden = !0), this.#geometry = null, this.#clearGeometry(), this.#clearOverflow(), this.#clearComposerRoot();
}
#applyGeometry(value) {
const composer = this.#composer;
if (!composer) return;
const previous = this.#geometry;
this.#geometry = normalizeReaderNativeComposerGeometry(
this.#viewport(),
value
);
const sizeChanged = !previous || previous.width !== this.#geometry.width || previous.height !== this.#geometry.height;
previous || (composer.style.setProperty("--ldp-composer-left", "0px"), composer.style.setProperty("--ldp-composer-top", "0px"), composer.style.setProperty("--ldp-composer-transform", "none")), sizeChanged && (composer.style.setProperty(
"--ldp-composer-width",
pixels(this.#geometry.width)
), composer.style.setProperty(
"--ldp-composer-height",
pixels(this.#geometry.height)
)), this.#desktop() && !composer.classList.contains("fullscreen") ? composer.style.setProperty(
"translate",
`${pixels(this.#geometry.left)} ${pixels(this.#geometry.top)}`
) : composer.style.removeProperty("translate"), composer.dataset.ldpReaderComposerPositioned = "1", sizeChanged && this.syncFont(), this.#syncChrome(sizeChanged);
}
#clearGeometry() {
const composer = this.#composer;
if (composer) {
delete composer.dataset.ldpReaderComposerPositioned;
for (const property of GEOMETRY_PROPERTIES)
composer.style.removeProperty(property);
composer.style.removeProperty("--ldp-composer-font-size"), composer.style.removeProperty("translate");
}
}
#ensureChrome() {
if (this.#chrome) return this.#chrome;
const chrome = this.#document.createElement("div");
chrome.className = "ldp-composer-window-chrome sciapp-ldp-owned", chrome.hidden = !0;
const drag = this.#document.createElement("button");
drag.type = "button", drag.className = "ldp-composer-drag-handle", drag.setAttribute(
"aria-label",
"拖动回复窗口;方向键可微调位置"
), chrome.append(drag);
for (const direction of RESIZE_DIRECTIONS) {
const handle = this.#document.createElement(
direction === "se" ? "button" : "span"
);
handle.className = "ldp-composer-resize-handle", handle.dataset.resize = direction, direction === "se" ? (handle.type = "button", handle.setAttribute(
"aria-label",
"拖动调整回复窗口大小;方向键可微调"
)) : handle.setAttribute("aria-hidden", "true"), chrome.append(handle);
}
this.#mount.append(chrome), this.scope.listen(chrome, "pointerdown", (event) => {
const pointer = event, eventTarget = (0, import_event_target.eventElement)(pointer), target = eventTarget ? eventTarget.closest(
".ldp-composer-drag-handle,[data-resize]"
) : null;
!target || !chrome.contains(target) || this.#startPointer(
pointer,
target.dataset.resize ?? "move",
target
);
}), this.scope.listen(chrome, "pointermove", (event) => {
this.#movePointer(event);
});
for (const type of ["pointerup", "pointercancel", "lostpointercapture"])
this.scope.listen(chrome, type, (event) => {
this.#stopPointer(event);
});
return this.scope.listen(chrome, "keydown", (event) => {
this.#handleChromeKey(event);
}), this.#chrome = chrome, chrome;
}
#syncChrome(syncSize = !1) {
const chrome = this.#chrome, composer = this.#composer, geometry = this.#geometry;
if (!chrome) return;
const available = !!(geometry && this.#desktop() && this.#composerVisible() && !composer?.classList.contains("fullscreen"));
chrome.hidden = !available, !(!available || !geometry) && (syncSize && (chrome.style.setProperty("--ldp-composer-chrome-width", pixels(geometry.width)), chrome.style.setProperty("--ldp-composer-chrome-height", pixels(geometry.height))), chrome.style.transform = `translate3d(${pixels(geometry.left)},${pixels(geometry.top)},0)`);
}
#scheduleChrome() {
this.#chromeFrame || (this.#chromeFrame = this.#requestFrame(() => {
this.#chromeFrame = 0, this.#syncChrome();
}));
}
#startPointer(event, mode, target) {
!this.#geometry || !this.#desktop() || !this.#composerVisible() || this.#composer?.classList.contains("fullscreen") || event.button !== 0 || (this.#pointer = Object.freeze({
id: event.pointerId,
mode,
startX: event.clientX,
startY: event.clientY,
geometry: this.#geometry,
target
}), this.#pointerX = event.clientX, this.#pointerY = event.clientY, target.setPointerCapture?.(event.pointerId), this.#chrome?.classList.add("is-interacting"), this.#pageRoot.classList.add("ldp-composer-window-interacting"), event.preventDefault(), event.stopPropagation());
}
#movePointer(event) {
!this.#pointer || event.pointerId !== this.#pointer.id || (this.#pointerX = event.clientX, this.#pointerY = event.clientY, this.#pointerFrame || (this.#pointerFrame = this.#requestFrame(() => {
this.#pointerFrame = 0, this.#applyPointer();
})), event.preventDefault());
}
#stopPointer(event) {
const pointer = this.#pointer;
if (!pointer) {
this.#chrome?.classList.remove("is-interacting"), this.#pageRoot.classList.remove("ldp-composer-window-interacting");
return;
}
if (!(event && event.pointerId !== pointer.id)) {
event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY) && (this.#pointerX = event.clientX, this.#pointerY = event.clientY), this.#pointerFrame && this.#cancelFrame(this.#pointerFrame), this.#pointerFrame = 0, this.#applyPointer(), this.#pointer = null;
try {
pointer.target.hasPointerCapture?.(pointer.id) && pointer.target.releasePointerCapture?.(pointer.id);
} catch {
}
this.#chrome?.classList.remove("is-interacting"), this.#pageRoot.classList.remove("ldp-composer-window-interacting"), this.#scheduleOverflow(), this.#persistGeometry();
}
}
#applyPointer() {
const pointer = this.#pointer;
if (!pointer) return;
const deltaX = this.#pointerX - pointer.startX, deltaY = this.#pointerY - pointer.startY;
if (pointer.mode === "move") {
const bounds = viewportLimits(this.#viewport());
this.#applyGeometry({
...pointer.geometry,
left: clamp(
pointer.geometry.left + deltaX,
bounds.left,
bounds.right - pointer.geometry.width
),
top: clamp(
pointer.geometry.top + deltaY,
bounds.top,
bounds.bottom - pointer.geometry.height
)
});
return;
}
this.#applyGeometry(this.#resizeGeometry(
pointer.geometry,
pointer.mode,
deltaX,
deltaY
));
}
#resizeGeometry(start, direction, deltaX, deltaY) {
const bounds = viewportLimits(this.#viewport());
let left = start.left, top = start.top, right = start.left + start.width, bottom = start.top + start.height;
return direction.includes("w") && (left = clamp(start.left + deltaX, bounds.left, right - bounds.minWidth)), direction.includes("e") && (right = clamp(start.left + start.width + deltaX, left + bounds.minWidth, bounds.right)), direction.includes("n") && (top = clamp(start.top + deltaY, bounds.top, bottom - bounds.minHeight)), direction.includes("s") && (bottom = clamp(start.top + start.height + deltaY, top + bounds.minHeight, bounds.bottom)), Object.freeze({
left,
top,
width: right - left,
height: bottom - top
});
}
#handleChromeKey(event) {
if (!this.#geometry || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
const target = (0, import_event_target.eventElement)(event), step = event.shiftKey ? 40 : 12;
if (target?.matches(".ldp-composer-drag-handle")) {
const next = { ...this.#geometry };
event.key === "ArrowLeft" ? next.left -= step : event.key === "ArrowRight" ? next.left += step : event.key === "ArrowUp" ? next.top -= step : next.top += step, this.#applyGeometry(next);
} else if (target?.matches('[data-resize="se"]'))
this.#applyGeometry(this.#resizeGeometry(
this.#geometry,
"se",
event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0,
event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0
));
else
return;
this.#schedulePersist(), event.preventDefault();
}
#schedulePersist() {
this.#persistTimer && this.#clearTimer(this.#persistTimer), this.#persistTimer = this.#setTimer(() => {
this.#persistTimer = 0, this.#persistGeometry();
}, 160);
}
#persistGeometry() {
if (this.#persistTimer && this.#clearTimer(this.#persistTimer), this.#persistTimer = 0, !(!this.#geometry || !this.#desktop() || !this.#updatePreferences))
try {
this.#updatePreferences({
composerWindowWidth: Math.round(this.#geometry.width),
composerWindowHeight: Math.round(this.#geometry.height),
composerWindowX: Math.round(this.#geometry.left),
composerWindowY: Math.round(this.#geometry.top)
});
} catch (cause) {
this.#onError(cause);
}
}
#syncComposerRoot() {
let next = this.#composer;
for (; next?.parentElement && next.parentElement !== this.#document.body; )
next = next.parentElement;
if ((!next || next.parentElement !== this.#document.body) && (next = null), next !== this.#composerRoot && (this.#clearComposerRoot(), this.#composerRoot = next, next)) {
next.dataset.ldpReaderComposerRoot = "1";
for (const property of APPEARANCE_PROPERTIES) {
const priorityReader = next.style;
this.#composerAppearanceOriginal.set(property, Object.freeze({
value: next.style.getPropertyValue(property),
priority: typeof priorityReader.getPropertyPriority == "function" ? priorityReader.getPropertyPriority(property) : ""
}));
}
this.#syncAppearance();
}
}
#clearComposerRoot() {
if (this.#composerRoot) {
for (const [property, previous] of this.#composerAppearanceOriginal)
previous.value ? this.#composerRoot.style.setProperty(
property,
previous.value,
previous.priority
) : this.#composerRoot.style.removeProperty(property);
delete this.#composerRoot.dataset.ldpReaderComposerRoot;
}
this.#composerAppearanceOriginal.clear(), this.#composerRoot = null;
}
#syncAppearance(appearance = this.#readAppearance?.() ?? null) {
!this.#composerRoot || !appearance || (this.#composerRoot.style.setProperty(
"--tertiary",
appearance.accentColor
), this.#composerRoot.style.setProperty(
"--tertiary-low",
appearance.accentLowColor
), this.#composerRoot.style.setProperty(
"--d-link-color",
appearance.linkColor
));
}
#scheduleOverflow() {
this.#overflowFrame || (this.#overflowFrame = this.#requestFrame(() => {
this.#overflowFrame = 0, this.#syncOverflow();
}));
}
#syncOverflow() {
this.#clearOverflow(!1);
const composer = this.#composer;
if (!composer || !this.#composerVisible()) return;
const composerRect = composer.getBoundingClientRect?.();
if (!composerRect || composerRect.width <= 0 || composerRect.height <= 0) return;
const candidates = [composer, ...composer.querySelectorAll("*")].map((candidate) => {
if (candidate.clientWidth <= 0 || candidate.scrollWidth <= candidate.clientWidth + 1 || candidate.closest('.d-editor-button-bar,[role="toolbar"]')) return null;
let overflowX = "";
try {
overflowX = this.#window.getComputedStyle(candidate).overflowX;
} catch {
return null;
}
if (!/(auto|scroll)/.test(overflowX)) return null;
const rect = candidate.getBoundingClientRect(), coveredWidth = Math.max(0, Math.min(rect.right, composerRect.right) - Math.max(rect.left, composerRect.left)), coveredHeight = Math.max(0, Math.min(rect.bottom, composerRect.bottom) - Math.max(rect.top, composerRect.top)), widthCoverage = coveredWidth / composerRect.width, heightCoverage = coveredHeight / composerRect.height;
return widthCoverage >= 0.85 && heightCoverage >= 0.75 ? Object.freeze({
candidate,
coverage: widthCoverage * heightCoverage
}) : null;
}).filter((value) => value !== null).sort((left, right) => right.coverage - left.coverage);
this.#overflowLayers = candidates.length ? [candidates[0].candidate] : [];
for (const layer of this.#overflowLayers)
layer.dataset.ldpReaderComposerOverflow = "1";
}
#clearOverflow(cancelFrame = !0) {
cancelFrame && this.#overflowFrame && this.#cancelFrame(this.#overflowFrame), cancelFrame && (this.#overflowFrame = 0);
for (const layer of this.#overflowLayers)
delete layer.dataset.ldpReaderComposerOverflow;
this.#overflowLayers = [];
}
#promoteTopLayer(element, kind) {
if (element.hasAttribute("popover") && !this.#ownedTopLayers.has(element))
return this.#topLayer.isOpen(element);
this.#ownedTopLayers.has(element) || (element.setAttribute("popover", "manual"), element.dataset.ldpReaderTopLayer = kind, this.#ownedTopLayers.add(element));
try {
if (this.#topLayer.isOpen(element) || this.#topLayer.show(element), this.#topLayer.isOpen(element)) return !0;
} catch (cause) {
this.#onError(cause);
}
return this.#releaseTopLayer(element), !1;
}
#releaseTopLayer(element) {
if (this.#ownedTopLayers.delete(element)) {
try {
this.#topLayer.isOpen(element) && this.#topLayer.hide(element);
} catch (cause) {
this.#onError(cause);
}
element.removeAttribute("popover"), delete element.dataset.ldpReaderTopLayer;
}
}
#syncTopLayers() {
const composer = this.#composer;
if (!composer || !this.#composerVisible()) {
this.#releaseTopLayers();
return;
}
if (!this.#promoteTopLayer(composer, "composer")) {
this.#releaseFloatingTopLayers();
return;
}
this.#chrome && !this.#chrome.hidden && this.#promoteTopLayer(this.#chrome, "chrome"), this.#startFloatingObservation(), this.#scheduleFloating();
}
#startFloatingObservation() {
this.#floatingObserver || !this.#document.body || (this.#floatingObserver = this.#createMutationObserver((mutations) => {
mutations.some((mutation) => this.#floatingMutationRelevant(mutation)) && this.#scheduleFloating();
}), this.#floatingObserver.observe(this.#document.body, {
childList: !0,
subtree: !0,
attributes: !0,
attributeFilter: ["class", "style", "hidden", "aria-hidden", "open"]
}));
}
#floatingMutationRelevant(mutation) {
return (mutation.type === "attributes" ? [mutation.target] : [...mutation.addedNodes, ...mutation.removedNodes]).some((node) => {
const element = node.nodeType === 1 ? node : node.parentNode?.nodeType === 1 ? node.parentNode : null;
return !!(element?.matches(DISCOURSE_NATIVE_FLOATING_SELECTOR) || element?.closest(DISCOURSE_NATIVE_FLOATING_SELECTOR) || element?.querySelector(DISCOURSE_NATIVE_FLOATING_SELECTOR));
});
}
#scheduleFloating() {
this.#floatingFrame || (this.#floatingFrame = this.#requestFrame(() => {
this.#floatingFrame = 0, this.#syncFloatingTopLayers();
}));
}
#syncFloatingTopLayers() {
const composer = this.#composer;
if (!composer || !this.#topLayer.isOpen(composer)) return;
const visible = [...this.#document.querySelectorAll(
DISCOURSE_NATIVE_FLOATING_SELECTOR
)].filter((element) => !(element.closest("#reply-control") || element.contains(composer) || element.closest("dialog[open]") || !discourseNativeFloatingSurfaceVisible(element, this.#window))), surfaces = visible.filter((element) => !visible.some((parent) => parent !== element && parent.contains(element)));
for (const element of [...this.#ownedTopLayers])
element.dataset.ldpReaderTopLayer === "portal" && !surfaces.includes(element) && this.#releaseTopLayer(element);
for (const surface of surfaces) this.#promoteTopLayer(surface, "portal");
}
#releaseFloatingTopLayers() {
this.#floatingFrame && this.#cancelFrame(this.#floatingFrame), this.#floatingFrame = 0, this.#floatingObserver?.disconnect(), this.#floatingObserver = null;
for (const element of [...this.#ownedTopLayers])
element.dataset.ldpReaderTopLayer === "portal" && this.#releaseTopLayer(element);
}
#releaseTopLayers() {
this.#releaseFloatingTopLayers();
for (const element of [...this.#ownedTopLayers].reverse())
this.#releaseTopLayer(element);
}
#teardown() {
if (!this.#destroyed) {
this.#destroyed = !0, this.#stopPointer(), this.#persistGeometry(), this.#composerScope?.destroy(), this.#composerScope = null, this.#hostObserver?.disconnect(), this.#hostObserver = null, this.#releaseTopLayers();
for (const frame of [
this.#pointerFrame,
this.#chromeFrame,
this.#overflowFrame,
this.#floatingFrame
])
frame && this.#cancelFrame(frame);
this.#pointerFrame = 0, this.#chromeFrame = 0, this.#overflowFrame = 0, this.#floatingFrame = 0, this.#persistTimer && this.#clearTimer(this.#persistTimer), this.#persistTimer = 0, this.#pageRoot.classList.remove(
"ldp-composer-window-interacting"
), this.#clearGeometry(), this.#clearOverflow(), this.#clearComposerRoot(), this.#chrome?.remove(), this.#chrome = null, this.#composer = null, this.#geometry = null;
}
}
}
}, "7d6bc57aadd215f81aea59593a589569369ea0826545f18133f7b4fc44597805");
/* Source: lite/src/discourse/reader-native-post-admin-menu.ts */
runtime.register("src/discourse/reader-native-post-admin-menu.js", function(module, exports, require) {
var reader_native_post_admin_menu_exports = {};
__export(reader_native_post_admin_menu_exports, {
positionReaderNativePostAdminMenu: () => positionReaderNativePostAdminMenu
});
module.exports = __toCommonJS(reader_native_post_admin_menu_exports);
var import_reader_native_composer_window = require("./reader-native-composer-window.js");
const ADMIN_MENU_SELECTOR = '.fk-d-menu[data-identifier="admin-post-menu"]', ADMIN_MENU_GAP_PX = 8, ADMIN_MENU_BOUNDARY_PADDING_PX = 8, ADMIN_MENU_FALLBACK_WIDTH_PX = 240, ADMIN_MENU_FALLBACK_HEIGHT_PX = 48;
function positive(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function positionReaderNativePostAdminMenu(options) {
const { document, reader, anchor, content } = options, surface = content.closest(ADMIN_MENU_SELECTOR);
if (!surface || !surface.isConnected || !reader.isConnected || !anchor.isConnected) return !1;
surface.dataset.ldpReaderAdminMenu = "positioned";
const topLayer = options.topLayer ?? (0, import_reader_native_composer_window.readerNativeTopLayerPort)();
if (!surface.hasAttribute("popover")) {
surface.setAttribute("popover", "manual"), surface.dataset.ldpReaderTopLayer = "portal";
try {
topLayer.show(surface);
} catch {
}
topLayer.isOpen(surface) || (surface.removeAttribute("popover"), delete surface.dataset.ldpReaderTopLayer);
}
const viewport = document.documentElement, readerRect = reader.getBoundingClientRect(), anchorRect = anchor.getBoundingClientRect(), surfaceRect = surface.getBoundingClientRect(), viewportWidth = positive(
viewport.clientWidth,
positive(document.defaultView?.innerWidth ?? 0, readerRect.right)
), viewportHeight = positive(
viewport.clientHeight,
positive(document.defaultView?.innerHeight ?? 0, readerRect.bottom)
), readerLeft = readerRect.width > 0 ? readerRect.left : 0, readerRight = readerRect.width > 0 ? readerRect.right : viewportWidth, readerTop = readerRect.height > 0 ? readerRect.top : 0, readerBottom = readerRect.height > 0 ? readerRect.bottom : viewportHeight, leftBound = Math.max(
ADMIN_MENU_BOUNDARY_PADDING_PX,
readerLeft + ADMIN_MENU_BOUNDARY_PADDING_PX
), rightBound = Math.max(
leftBound,
Math.min(
viewportWidth - ADMIN_MENU_BOUNDARY_PADDING_PX,
readerRight - ADMIN_MENU_BOUNDARY_PADDING_PX
)
), topBound = Math.max(
ADMIN_MENU_BOUNDARY_PADDING_PX,
readerTop + ADMIN_MENU_BOUNDARY_PADDING_PX
), bottomBound = Math.max(
topBound,
Math.min(
viewportHeight - ADMIN_MENU_BOUNDARY_PADDING_PX,
readerBottom - ADMIN_MENU_BOUNDARY_PADDING_PX
)
), width = Math.min(
positive(surfaceRect.width, ADMIN_MENU_FALLBACK_WIDTH_PX),
Math.max(1, rightBound - leftBound)
), height = Math.min(
positive(surfaceRect.height, ADMIN_MENU_FALLBACK_HEIGHT_PX),
Math.max(1, bottomBound - topBound)
), rightCandidate = anchorRect.right + ADMIN_MENU_GAP_PX, leftCandidate = anchorRect.left - ADMIN_MENU_GAP_PX - width, left = rightCandidate + width <= rightBound ? rightCandidate : leftCandidate >= leftBound ? leftCandidate : clamp(anchorRect.left, leftBound, Math.max(leftBound, rightBound - width)), top = clamp(
anchorRect.top,
topBound,
Math.max(topBound, bottomBound - height)
);
return surface.style.setProperty(
"--ldp-reader-admin-menu-left",
`${Math.round(left)}px`
), surface.style.setProperty(
"--ldp-reader-admin-menu-top",
`${Math.round(top)}px`
), !0;
}
}, "0be025740cd022ddac5632878106f27ed3be3d12861f238973a0ac991a6058cd");
/* Source: lite/src/history/reader-chronicle-repository.ts */
runtime.register("src/history/reader-chronicle-repository.js", function(module, exports, require) {
var reader_chronicle_repository_exports = {};
__export(reader_chronicle_repository_exports, {
READER_CHRONICLE_MAX_AGE_MS: () => READER_CHRONICLE_MAX_AGE_MS,
READER_CHRONICLE_MAX_RECORDS: () => READER_CHRONICLE_MAX_RECORDS,
READER_CHRONICLE_STORAGE_KEY: () => READER_CHRONICLE_STORAGE_KEY,
ReaderChronicleRepository: () => ReaderChronicleRepository,
mergeReaderChronicleValues: () => mergeReaderChronicleValues,
readerChronicleRecord: () => readerChronicleRecord,
readerChronicleRequestTarget: () => readerChronicleRequestTarget
});
module.exports = __toCommonJS(reader_chronicle_repository_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_signal = require("../kernel/signal.js"), import_reader_account_scoped_storage = require("../state/reader-account-scoped-storage.js");
const READER_CHRONICLE_STORAGE_KEY = "linuxdo-enhanced-reader:chronicle", READER_CHRONICLE_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1e3, READER_CHRONICLE_MAX_RECORDS = 1e3;
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function text(value, maximum = 240) {
return String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
}
function positiveInteger(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
function timestamp(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
function method(value) {
const normalized = text(value, 16).toUpperCase();
return /^[A-Z]+$/.test(normalized) ? normalized : "GET";
}
function kind(value) {
return value === "topic" || value === "reply" || value === "boost" ? value : null;
}
function hash(value) {
let result = 2166136261;
for (let index = 0; index < value.length; index += 1)
result ^= value.charCodeAt(index), result = Math.imul(result, 16777619);
return (result >>> 0).toString(36);
}
function chronicleIdentity(input) {
const target = input.kind === "topic" ? "topic" : input.kind === "boost" ? `boost-${input.boostId ?? 0}` : `post-${input.postNumber ?? 0}-${input.postId ?? 0}`;
return `${input.kind}:${input.topicId}:${target}:${hash(
`${input.requestMethod}:${input.requestPath}`
)}`;
}
function searchText(input) {
const label = input.kind === "topic" ? "主题 帖子 Topic" : input.kind === "reply" ? "回复 楼层 Post" : "Boost";
return [
input.topicTitle,
`Topic ${input.topicId}`,
label,
input.postNumber === null ? "" : `楼层 ${input.postNumber}`,
input.postId === null ? "" : `post ${input.postId}`,
input.boostId === null ? "" : `boost ${input.boostId}`,
"404",
input.requestMethod,
input.requestPath,
input.requestSource,
input.callSite
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
}
function normalizedTitle(value, topicId) {
return text(value, 180) || `帖子 #${topicId}`;
}
function preferredTitle(left, right, topicId) {
const fallback = `帖子 #${topicId}`;
return right && right !== fallback ? right : left || right || fallback;
}
function normalizeRecord(value) {
const source = record(value), targetKind = kind(source?.kind), topicId = (0, import_identifiers.tryDiscourseTopicId)(source?.topicId);
if (!source || !targetKind || topicId === null || Number(source.status) !== 404 || source.bodyCached !== !0)
return null;
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(source.postNumber), postId = (0, import_identifiers.tryDiscoursePostId)(source.postId), boostId = positiveInteger(source.boostId);
if (targetKind === "reply" && postNumber === null && postId === null || targetKind === "boost" && boostId === null) return null;
const requestPath = text(source.requestPath) || `/t/${topicId}`, requestMethod = method(source.requestMethod), firstObservedAt = timestamp(source.firstObservedAt), lastObservedAt = timestamp(source.lastObservedAt) || firstObservedAt;
if (!firstObservedAt || !lastObservedAt) return null;
const base = Object.freeze({
identity: "",
kind: targetKind,
status: 404,
bodyCached: !0,
topicId,
topicTitle: normalizedTitle(source.topicTitle, topicId),
postNumber,
postId,
boostId,
requestPath,
requestMethod,
requestSource: text(source.requestSource, 40) || "reader",
callSite: text(source.callSite, 220),
firstObservedAt: Math.min(firstObservedAt, lastObservedAt),
lastObservedAt: Math.max(firstObservedAt, lastObservedAt),
occurrences: Math.max(1, positiveInteger(source.occurrences) ?? 1)
}), normalized = Object.freeze({
...base,
identity: chronicleIdentity(base)
});
return Object.freeze({
...normalized,
searchText: searchText(normalized)
});
}
function inputRecord(input, now) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), targetKind = kind(input.kind);
if (!targetKind) throw new Error("岁月史书记录类型无效");
if (input.status !== void 0 && Number(input.status) !== 404)
throw new Error("岁月史书只接受 HTTP 404 信号");
if (input.bodyCached !== !0)
throw new Error("岁月史书只接受本机仍有正文的 404");
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(input.postNumber), postId = (0, import_identifiers.tryDiscoursePostId)(input.postId), boostId = positiveInteger(input.boostId);
if (targetKind === "reply" && postNumber === null && postId === null)
throw new Error("回复 404 必须包含楼层或 post.id");
if (targetKind === "boost" && boostId === null)
throw new Error("Boost 404 必须包含 boost.id");
const observedAt = timestamp(input.observedAt) || now, requestPath = text(input.requestPath) || `/t/${topicId}`, requestMethod = method(input.requestMethod), base = Object.freeze({
identity: "",
kind: targetKind,
status: 404,
bodyCached: !0,
topicId,
topicTitle: normalizedTitle(input.topicTitle, topicId),
postNumber,
postId,
boostId,
requestPath,
requestMethod,
requestSource: text(input.requestSource, 40) || "reader",
callSite: text(input.callSite, 220),
firstObservedAt: observedAt,
lastObservedAt: observedAt,
occurrences: 1
}), normalized = Object.freeze({
...base,
identity: chronicleIdentity(base)
});
return Object.freeze({
...normalized,
searchText: searchText(normalized)
});
}
function readerChronicleRecord(value) {
return normalizeRecord(value);
}
function mergeReaderChronicleValues(local, remote) {
const left = normalizeRecord(local), right = normalizeRecord(remote);
if (!left) return right;
if (!right) return left;
if (left.identity !== right.identity)
return left.lastObservedAt >= right.lastObservedAt ? left : right;
const recent = left.lastObservedAt >= right.lastObservedAt ? left : right, older = recent === left ? right : left;
return normalizeRecord({
...older,
...recent,
topicTitle: preferredTitle(
older.topicTitle,
recent.topicTitle,
recent.topicId
),
firstObservedAt: Math.min(left.firstObservedAt, right.firstObservedAt),
lastObservedAt: Math.max(left.lastObservedAt, right.lastObservedAt),
occurrences: Math.max(left.occurrences, right.occurrences)
});
}
function readerChronicleRequestTarget(pathValue) {
const raw = text(pathValue, 512), slash = raw.indexOf("/"), path = slash >= 0 ? raw.slice(slash) : raw, boost = path.match(/\/(?:discourse-boosts\/)?boosts\/(\d+)(?:\.json)?(?:\/|$)/i);
if (boost)
return Object.freeze({
kind: "boost",
topicId: null,
postNumber: null,
postId: null,
boostId: positiveInteger(boost[1])
});
const byNumber = path.match(/\/posts\/by_number\/(\d+)\/(\d+)(?:\.json)?(?:\/|$)/i);
if (byNumber) {
const topicId = positiveInteger(byNumber[1]), postNumber = positiveInteger(byNumber[2]);
if (topicId && postNumber)
return Object.freeze({
kind: postNumber === 1 ? "topic" : "reply",
topicId,
postNumber,
postId: null,
boostId: null
});
}
const topicOffset = path.toLocaleLowerCase().indexOf("/t/");
if (topicOffset >= 0) {
const segments = path.slice(topicOffset + 3).split("/").filter(Boolean).map((segment) => segment.replace(/\.json$/i, "")), first = positiveInteger(segments[0]), topicId = first ?? positiveInteger(segments[1]), postNumber = positiveInteger(first ? segments[1] : segments[2]);
if (topicId)
return Object.freeze({
kind: postNumber && postNumber > 1 ? "reply" : "topic",
topicId,
postNumber,
postId: null,
boostId: null
});
}
const post = path.match(/\/posts\/(\d+)(?:\.json|\/replies(?:\.json)?)?(?:\/|$)/i);
return post ? Object.freeze({
kind: "reply",
topicId: null,
postNumber: null,
postId: positiveInteger(post[1]),
boostId: null
}) : null;
}
function quotaError(error) {
const source = error;
return source?.name === "QuotaExceededError" || source?.name === "NS_ERROR_DOM_QUOTA_REACHED" || source?.code === 22 || source?.code === 1014;
}
class ReaderChronicleRepository {
changes = new import_signal.Signal();
diagnostics = new import_signal.Signal();
#storage;
#key;
#accountStorage;
#maxAgeMs;
#maxRecords;
#now;
#snapshot = Object.freeze({
records: Object.freeze([]),
revision: 0,
source: "fallback"
});
constructor(options) {
if (this.#storage = options.storage, this.#accountStorage = options.key === void 0 && options.authScope !== void 0 ? (0, import_reader_account_scoped_storage.readerAccountScopedStorageIdentity)(
READER_CHRONICLE_STORAGE_KEY,
options.authScope
) : null, this.#key = text(options.key ?? this.#accountStorage?.key ?? READER_CHRONICLE_STORAGE_KEY, 512), !this.#key) throw new Error("chronicle storage key 不能为空");
if (this.#maxAgeMs = Number(options.maxAgeMs ?? READER_CHRONICLE_MAX_AGE_MS), this.#maxRecords = Math.floor(Number(
options.maxRecords ?? READER_CHRONICLE_MAX_RECORDS
)), !Number.isFinite(this.#maxAgeMs) || this.#maxAgeMs <= 0)
throw new RangeError("chronicle maxAgeMs 必须是正有限数值");
if (!Number.isSafeInteger(this.#maxRecords) || this.#maxRecords <= 0)
throw new RangeError("chronicle maxRecords 必须是正安全整数");
this.#now = options.now ?? Date.now;
}
get snapshot() {
return this.#snapshot;
}
get storageKey() {
return this.#key;
}
load() {
return this.#readAndCommit("initial");
}
reloadExternal() {
return this.#readAndCommit("external-reload");
}
ordered() {
return this.#ordered(this.#snapshot.records);
}
remember(input) {
this.#mergeStoredBeforeMutation();
const incoming = inputRecord(input, this.#now()), previous = this.#snapshot.records.find((entry) => entry.identity === incoming.identity), next = previous ? normalizeRecord({
...previous,
...incoming,
topicTitle: preferredTitle(
previous.topicTitle,
incoming.topicTitle,
incoming.topicId
),
firstObservedAt: previous.firstObservedAt,
lastObservedAt: Math.max(
previous.lastObservedAt,
incoming.lastObservedAt
),
occurrences: previous.occurrences + 1
}) : incoming;
return this.#persistAndCommit([
next,
...this.#snapshot.records.filter((entry) => entry.identity !== next.identity)
], "remember");
}
remove(identity) {
this.#mergeStoredBeforeMutation();
const normalized = text(identity, 512);
return !normalized || !this.#snapshot.records.some((entry) => entry.identity === normalized) ? this.#snapshot : this.#persistAndCommit(
this.#snapshot.records.filter((entry) => entry.identity !== normalized),
"remove"
);
}
clear() {
return this.#mergeStoredBeforeMutation(), this.#persistAndCommit([], "clear");
}
replaceExternal(values) {
return this.#persistAndCommit(
this.#normalizeMany(values),
"external-sync"
);
}
#readAndCommit(source) {
let raw;
try {
const stored = this.#accountStorage ? (0, import_reader_account_scoped_storage.readReaderAccountScopedString)(this.#storage, this.#accountStorage) : this.#storage.getItem(this.#key);
raw = stored === null ? [] : JSON.parse(stored);
} catch (cause) {
return this.#diagnose("read-failed", cause), this.#commit([], "fallback");
}
if (!Array.isArray(raw))
return this.#diagnose(
"invalid-stored-value",
new TypeError("岁月史书存储值必须是数组")
), this.#commit([], "fallback");
const normalized = this.#normalizeMany(raw);
if (JSON.stringify(normalized) !== JSON.stringify(raw)) {
this.#diagnose("records-normalized", Object.freeze({
storedCount: raw.length,
acceptedCount: normalized.length
}));
try {
this.#persist(normalized);
} catch {
}
}
return this.#commit(normalized, source);
}
#normalizeMany(values) {
const cutoff = this.#now() - this.#maxAgeMs, records = /* @__PURE__ */ new Map();
for (const value of values) {
const incoming = normalizeRecord(value);
if (!incoming || incoming.lastObservedAt < cutoff) continue;
const current = records.get(incoming.identity), merged = current ? mergeReaderChronicleValues(current, incoming) : incoming;
merged && records.set(merged.identity, merged);
}
return this.#ordered([...records.values()]);
}
#mergeStoredBeforeMutation() {
let stored;
try {
stored = this.#accountStorage ? (0, import_reader_account_scoped_storage.readReaderAccountScopedString)(this.#storage, this.#accountStorage) : this.#storage.getItem(this.#key);
} catch (cause) {
this.#diagnose("read-failed", cause);
return;
}
(stored ?? "[]") !== JSON.stringify(this.#snapshot.records) && this.#readAndCommit("external-reload");
}
#ordered(values) {
return Object.freeze([...values].sort((left, right) => right.lastObservedAt - left.lastObservedAt || right.topicId - left.topicId || left.identity.localeCompare(right.identity)).slice(0, this.#maxRecords));
}
#persistAndCommit(records, source) {
return this.#commit(this.#persist(this.#ordered(records)), source);
}
#persist(records) {
const safe = [...records];
for (; ; )
try {
return safe.length || !this.#storage.removeItem || this.#accountStorage ? this.#storage.setItem(this.#key, JSON.stringify(safe)) : this.#storage.removeItem(this.#key), Object.freeze(safe);
} catch (cause) {
if (!quotaError(cause) || !safe.length)
throw this.#diagnose("write-failed", cause), cause;
const removed = safe.pop();
this.#diagnose("quota-trimmed", Object.freeze({
identity: removed?.identity ?? null,
remainingCount: safe.length
}));
}
}
#commit(records, source) {
const snapshot = Object.freeze({
records: Object.freeze([...records]),
revision: this.#snapshot.revision + 1,
source
});
this.#snapshot = snapshot;
for (const cause of this.changes.emit(snapshot))
this.#diagnose("consumer-failed", cause);
return snapshot;
}
#diagnose(code, cause) {
this.diagnostics.emit(Object.freeze({ code, cause }));
}
}
}, "7cbdfc6ebf4c6461ac643a5f801cb2b116587de05ea017717407860a40ecdf7c");
/* Source: lite/src/history/reader-chronicle-view.ts */
runtime.register("src/history/reader-chronicle-view.js", function(module, exports, require) {
var reader_chronicle_view_exports = {};
__export(reader_chronicle_view_exports, {
ReaderChronicleView: () => ReaderChronicleView
});
module.exports = __toCommonJS(reader_chronicle_view_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_reader_collection_floating_window = require("../collection/reader-collection-floating-window.js"), import_html_element = require("../dom/html-element.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_floating_window_frame = require("../shell/reader-floating-window-frame.js");
const CHRONICLE_BATCH_SIZE = 120, CHRONICLE_TABS = Object.freeze([
["all", "全部"],
["topic", "主题"],
["reply", "回复"],
["boost", "Boost"]
]);
function closestTarget(event, selector) {
const target = event.target;
return typeof target?.closest == "function" ? target.closest(selector) : null;
}
function defaultRelativeTime(timestamp) {
const elapsed = Math.max(0, Date.now() - timestamp);
return elapsed < 6e4 ? "刚刚" : elapsed < 36e5 ? `${Math.floor(elapsed / 6e4)} 分钟前` : elapsed < 864e5 ? `${Math.floor(elapsed / 36e5)} 小时前` : elapsed < 30 * 864e5 ? `${Math.floor(elapsed / 864e5)} 天前` : new Date(timestamp).toLocaleDateString("zh-CN");
}
function kindLabel(record) {
return record.kind === "topic" ? "主题 404" : record.kind === "reply" ? `回复 #${record.postNumber ?? "?"} · 404` : `Boost #${record.boostId ?? "?"} · 404`;
}
function kindIcon(kind) {
return kind === "topic" ? "message-square" : kind === "reply" ? "reply" : "rocket";
}
function sourceLabel(value) {
return value === "reader" ? "Reader" : value === "host" ? "站点" : value === "browser" ? "浏览器" : value || "Reader";
}
function topicFloorCount(records) {
return new Set(records.flatMap((record) => Number.isSafeInteger(record.postNumber) && Number(record.postNumber) > 0 ? [Number(record.postNumber)] : [])).size;
}
function topicDetailLabel(records) {
const floorCount = topicFloorCount(records);
return floorCount > 0 ? `${floorCount} 个楼层` : `${records.length} 条 Topic 记录`;
}
class ReaderChronicleView {
scope;
window;
#document;
#chronicle;
#openTarget;
#relativeTime;
#notify;
#onError;
#tabs;
#search;
#searchResult;
#list;
#footer;
#activeTab = "all";
#visibleLimit = CHRONICLE_BATCH_SIZE;
#expandedTopicIds = /* @__PURE__ */ new Set();
constructor(options) {
this.#document = options.document, this.#chronicle = options.chronicle, this.#openTarget = options.openTarget ?? (() => !1), this.#relativeTime = options.relativeTime ?? defaultRelativeTime, this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.window = new import_reader_floating_window_frame.ReaderFloatingWindowFrame({
document: options.document,
mount: options.mount,
title: "岁月史书",
ariaLabel: "岁月史书 404 搜集栏",
icon: "history",
variant: "chronicle",
tabId: "chronicle",
tabOrder: 60,
requestOpen: () => this.open(),
zIndex: 2147483585,
...options.storage ? { geometryStorage: options.storage } : {},
geometryStorageKey: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY,
policy: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_POLICY,
placement: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_PLACEMENT,
notify: this.#notify,
parentScope: this.scope
});
const pane = (0, import_html_element.htmlElement)(options.document, "div", "ldp-chronicle-pane");
this.#tabs = (0, import_html_element.htmlElement)(options.document, "div", "ldp-chronicle-tabs"), this.#tabs.setAttribute("role", "tablist");
const searchLabel = (0, import_html_element.htmlElement)(
options.document,
"label",
"ldp-chronicle-search"
);
searchLabel.append((0, import_reader_icon.createReaderIcon)(options.document, "search")), this.#search = options.document.createElement("input"), this.#search.type = "search", this.#search.placeholder = "搜索主题、定位、404 信息", this.#search.setAttribute("aria-label", "搜索岁月史书"), this.#searchResult = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-chronicle-search-result"
), this.#searchResult.hidden = !0, this.#searchResult.setAttribute("aria-live", "polite"), searchLabel.append(this.#search, this.#searchResult);
const tools = (0, import_html_element.htmlElement)(options.document, "div", "ldp-chronicle-tools");
tools.append(searchLabel), this.#list = (0, import_html_element.htmlElement)(options.document, "div", "ldp-chronicle-list"), this.#list.setAttribute("role", "feed"), this.#footer = (0, import_html_element.htmlElement)(options.document, "footer", "ldp-chronicle-footer"), pane.append(this.#tabs, tools, this.#list, this.#footer), this.window.body.append(pane), this.scope.listen(this.#search, "input", () => {
this.#visibleLimit = CHRONICLE_BATCH_SIZE, this.#list.scrollTop = 0, this.#render();
}), this.scope.listen(pane, "click", (event) => {
this.#onClick(event);
}), this.scope.listen(this.#list, "scroll", () => {
this.#list.scrollTop + this.#list.clientHeight >= this.#list.scrollHeight - 96 && this.#showMore();
}, { passive: !0 }), this.scope.listen(options.document, "pointerdown", (event) => {
this.window.dismissFromPointerEvent(event);
}, !0), this.scope.listen(options.document, "keydown", (event) => {
this.window.dismissFromEscapeEvent(event);
}, !0), this.#chronicle.changes.subscribe(() => this.#render(), this.scope), this.#render();
}
open() {
this.#render(), this.window.open();
}
close() {
this.window.close();
}
destroy() {
this.scope.destroy();
}
#filteredRecords() {
const query = this.#search.value.trim().toLocaleLowerCase("zh-CN");
return this.#chronicle.ordered().filter((record) => (this.#activeTab === "all" || record.kind === this.#activeTab) && (!query || record.searchText.includes(query)));
}
#render() {
const all = this.#chronicle.ordered(), topicCount = new Set(all.map((record) => record.topicId)).size;
this.window.meta.textContent = `${all.length} 条 · ${topicCount} 个 Topic`, this.#tabs.replaceChildren(...CHRONICLE_TABS.map(([tab, label]) => {
const button = this.#document.createElement("button");
button.type = "button", button.dataset.chronicleTab = tab, button.className = tab === this.#activeTab ? "is-active" : "", button.setAttribute("role", "tab"), button.setAttribute("aria-selected", String(tab === this.#activeTab));
const count = tab === "all" ? all.length : all.filter((record) => record.kind === tab).length;
return button.textContent = `${label} ${count}`, button;
}));
const records = this.#filteredRecords(), searching = !!this.#search.value.trim();
this.#searchResult.hidden = !searching, this.#searchResult.textContent = searching ? `${records.length} 条` : "";
const visible = records.slice(0, this.#visibleLimit), groups = /* @__PURE__ */ new Map();
for (const record of visible) {
const group = groups.get(record.topicId) ?? [];
group.push(record), groups.set(record.topicId, group);
}
this.#list.replaceChildren(...[...groups.values()].map((group) => this.#topicGroup(group))), records.length || this.#list.append((0, import_html_element.htmlElement)(
this.#document,
"p",
"ldp-chronicle-empty",
searching ? "没有匹配的 404 记录。" : "还没有收到可定位的 Topic、回复或 Boost 404 信号。"
)), this.#renderFooter(records.length);
}
#topicGroup(records) {
const latest = records[0], section = (0, import_html_element.htmlElement)(this.#document, "section", "ldp-chronicle-topic");
section.dataset.chronicleTopic = String(latest.topicId);
const expanded = this.#expandedTopicIds.has(latest.topicId);
section.classList.toggle("is-expanded", expanded);
const heading = this.#document.createElement("button");
heading.type = "button", heading.className = "ldp-chronicle-topic-head";
const copy = (0, import_html_element.htmlElement)(this.#document, "span", "ldp-chronicle-topic-copy"), detailLabel = topicDetailLabel(records);
copy.append(
this.#highlighted("strong", latest.topicTitle),
this.#highlighted("small", `Topic #${latest.topicId}`)
);
const time = (0, import_html_element.htmlElement)(
this.#document,
"time",
"",
this.#relativeTime(latest.lastObservedAt)
), entriesId = `ldp-chronicle-topic-records-${latest.topicId}`;
heading.dataset.chronicleTopicToggle = String(latest.topicId), heading.dataset.chronicleTopicDetail = detailLabel, heading.setAttribute("aria-controls", entriesId), heading.setAttribute("aria-expanded", String(expanded)), heading.setAttribute(
"aria-label",
`${expanded ? "收起" : "展开"} ${detailLabel}`
), heading.title = `${expanded ? "收起" : "展开"} ${detailLabel}`;
const chevron = (0, import_reader_icon.createReaderIcon)(
this.#document,
expanded ? "chevron-up" : "chevron-down"
);
chevron.classList.add("ldp-chronicle-topic-chevron"), heading.append(
(0, import_reader_icon.createReaderIcon)(this.#document, "message-square"),
copy,
time,
(0, import_html_element.htmlElement)(this.#document, "span", "ldp-chronicle-topic-count", detailLabel),
chevron
);
const entries = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-chronicle-records");
return entries.id = entriesId, entries.hidden = !expanded, entries.append(...records.map((record) => this.#record(record))), section.append(heading, entries), section;
}
#record(record) {
const button = this.#document.createElement("button");
button.type = "button", button.className = `ldp-chronicle-record is-${record.kind}`, button.dataset.chronicleRecord = record.identity, button.setAttribute(
"aria-label",
`${kindLabel(record)},${record.topicTitle},${record.requestMethod} ${record.requestPath}`
);
const copy = (0, import_html_element.htmlElement)(this.#document, "span", "ldp-chronicle-record-copy"), meta = (0, import_html_element.htmlElement)(this.#document, "span", "ldp-chronicle-record-meta");
meta.append(
this.#highlighted("strong", kindLabel(record)),
(0, import_html_element.htmlElement)(
this.#document,
"small",
"",
`${this.#relativeTime(record.lastObservedAt)}${record.occurrences > 1 ? ` · ${record.occurrences} 次` : ""}`
)
);
const location = record.kind === "topic" ? `Topic #${record.topicId}` : record.kind === "reply" ? `Topic #${record.topicId} · 楼层 #${record.postNumber ?? "?"}` : `Topic #${record.topicId} · 楼层 #${record.postNumber ?? "?"} · Boost #${record.boostId ?? "?"}`;
copy.append(
meta,
this.#highlighted(
"b",
`${record.requestMethod} ${record.requestPath}`
),
this.#highlighted("small", location)
);
const diagnostic = [sourceLabel(record.requestSource), record.callSite].filter(Boolean).join(" · ");
return diagnostic && copy.append(this.#highlighted("small", diagnostic)), button.append(
(0, import_reader_icon.createReaderIcon)(this.#document, kindIcon(record.kind)),
copy,
(0, import_reader_icon.createReaderIcon)(this.#document, "chevron-right")
), button;
}
#highlighted(tag, value) {
const element = this.#document.createElement(tag), query = this.#search.value.trim();
if (!query)
return element.textContent = value, element;
const source = value.toLocaleLowerCase("zh-CN"), needle = query.toLocaleLowerCase("zh-CN");
let cursor = 0, match = source.indexOf(needle);
if (match < 0)
return element.textContent = value, element;
for (; match >= 0; ) {
match > cursor && element.append(this.#document.createTextNode(value.slice(cursor, match)));
const mark = this.#document.createElement("mark");
mark.textContent = value.slice(match, match + needle.length), element.append(mark), cursor = match + needle.length, match = source.indexOf(needle, cursor);
}
return cursor < value.length && element.append(this.#document.createTextNode(value.slice(cursor))), element;
}
#renderFooter(total) {
if (this.#footer.replaceChildren((0, import_html_element.htmlElement)(
this.#document,
"span",
"",
"岁月史书只保留本机仍有正文的 404 记录。"
)), this.#visibleLimit >= total) return;
const more = this.#document.createElement("button");
more.type = "button", more.dataset.chronicleMore = "", more.textContent = `继续显示(${Math.min(this.#visibleLimit, total)} / ${total})`, this.#footer.append(more);
}
#onClick(event) {
const target = closestTarget(
event,
"[data-chronicle-tab],[data-chronicle-topic-toggle],[data-chronicle-record],[data-chronicle-more]"
);
if (!target) return;
const tab = target.dataset.chronicleTab;
if (tab && CHRONICLE_TABS.some(([value]) => value === tab)) {
this.#activeTab = tab, this.#visibleLimit = CHRONICLE_BATCH_SIZE, this.#list.scrollTop = 0, this.#render();
return;
}
if (target.dataset.chronicleMore !== void 0) {
this.#showMore();
return;
}
const topicToggle = Number(target.dataset.chronicleTopicToggle);
if (Number.isSafeInteger(topicToggle) && topicToggle > 0) {
const section = target.closest(".ldp-chronicle-topic"), entries = section?.querySelector(
".ldp-chronicle-records"
);
if (!section || !entries) return;
const expanded = target.getAttribute("aria-expanded") !== "true";
expanded ? this.#expandedTopicIds.add(topicToggle) : this.#expandedTopicIds.delete(topicToggle), section.classList.toggle("is-expanded", expanded), entries.hidden = !expanded, target.setAttribute("aria-expanded", String(expanded));
const detail = target.dataset.chronicleTopicDetail || "楼层记录", label = `${expanded ? "收起" : "展开"} ${detail}`;
target.setAttribute("aria-label", label), target.title = label;
const currentChevron = target.querySelector(
".ldp-chronicle-topic-chevron"
), nextChevron = (0, import_reader_icon.createReaderIcon)(
this.#document,
expanded ? "chevron-up" : "chevron-down"
);
nextChevron.classList.add("ldp-chronicle-topic-chevron"), currentChevron?.replaceWith(nextChevron);
return;
}
const identity = target.dataset.chronicleRecord, record = this.#chronicle.snapshot.records.find((entry) => entry.identity === identity);
record && Promise.resolve(this.#openTarget(
record.topicId,
record.postNumber ?? 1,
record
)).then((opened) => {
opened || (this.#chronicle.remove(record.identity), this.#notify("本地正文已不存在,已从岁月史书移除"));
}).catch((cause) => {
this.#onError(cause), this.#notify("该 404 定位暂时无法打开");
});
}
#showMore() {
const total = this.#filteredRecords().length;
this.#visibleLimit >= total || (this.#visibleLimit = Math.min(total, this.#visibleLimit + CHRONICLE_BATCH_SIZE), this.#render());
}
}
}, "cb2d428b803546cd0bee2639c86d6b0aeb60b7d0f8c2a400423a07e307ad9933");
/* Source: lite/src/history/reader-history-model.ts */
runtime.register("src/history/reader-history-model.js", function(module, exports, require) {
var reader_history_model_exports = {};
__export(reader_history_model_exports, {
normalizeReaderHistoryAnchorPoint: () => normalizeReaderHistoryAnchorPoint,
normalizeReaderHistoryAnchorState: () => normalizeReaderHistoryAnchorState,
normalizeReaderHistoryAnchorStates: () => normalizeReaderHistoryAnchorStates,
normalizeReaderHistoryReplyWindowState: () => normalizeReaderHistoryReplyWindowState,
normalizeReaderHistoryViewport: () => normalizeReaderHistoryViewport
});
module.exports = __toCommonJS(reader_history_model_exports);
var import_identifiers = require("../discourse/identifiers.js");
function record(value) {
return value && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function finite(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function nonNegative(value) {
return Math.max(0, finite(value, 0));
}
function optionalNonNegative(value) {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
}
function optionalPostNumber(value) {
try {
return (0, import_identifiers.discoursePostNumber)(value);
} catch {
return null;
}
}
function normalizeReaderHistoryViewport(value) {
const source = record(value) ?? Object.freeze({ postNumber: value }), postNumber = optionalPostNumber(source.postNumber);
if (postNumber === null) return null;
const scrollTop = nonNegative(source.scrollTop), rawScrollRange = optionalNonNegative(source.scrollRange), rawScrollRatio = optionalNonNegative(source.scrollRatio), hasScrollGeometry = rawScrollRange !== null || rawScrollRatio !== null, scrollRange = rawScrollRange ?? 0, scrollRatio = Math.min(
1,
Math.max(
0,
rawScrollRatio !== null ? rawScrollRatio : scrollRange > 0 ? scrollTop / scrollRange : 0
)
);
return Object.freeze({
postNumber,
postOffset: finite(source.postOffset, 0),
scrollTop,
...hasScrollGeometry ? { scrollRange, scrollRatio } : {}
});
}
function normalizeReaderHistoryAnchorPoint(value) {
const source = record(value), number = optionalPostNumber(source?.number);
return number === null ? null : Object.freeze({
number,
scrollTop: nonNegative(source?.scrollTop),
scrollLeft: nonNegative(source?.scrollLeft),
offset: finite(source?.offset, 12)
});
}
function normalizeReaderHistoryReplyWindowState(value) {
const source = record(value), rootPostNumber = optionalPostNumber(source?.rootPostNumber);
if (rootPostNumber === null) return null;
const descendantRootPostNumber = optionalPostNumber(
source?.descendantRootPostNumber
);
return Object.freeze({
rootPostNumber,
...descendantRootPostNumber !== null && descendantRootPostNumber !== rootPostNumber ? { descendantRootPostNumber } : {},
point: normalizeReaderHistoryAnchorPoint(source?.point)
});
}
function normalizeReaderHistoryQuoteSource(value) {
const source = record(value);
if (!source) return null;
let topicId, postNumber;
try {
topicId = (0, import_identifiers.discourseTopicId)(source.topicId), postNumber = (0, import_identifiers.discoursePostNumber)(source.postNumber);
} catch {
return null;
}
const parentPostNumber = optionalPostNumber(source.parentPostNumber), rawAnchor = record(source.anchor), viewport = normalizeReaderHistoryViewport(rawAnchor?.viewport), anchor = viewport === null ? null : Object.freeze({
viewport,
replyWindow: normalizeReaderHistoryReplyWindowState(
rawAnchor?.replyWindow
),
quoteHighlight: null
});
return Object.freeze({
topicId,
postNumber,
parentPostNumber,
nested: source.nested === !0 && parentPostNumber !== null,
anchor
});
}
function normalizeReaderHistoryQuoteHighlightState(value) {
const source = record(value), postNumber = optionalPostNumber(source?.postNumber), text = String(source?.text ?? source?.quoteText ?? "");
return postNumber === null || !text ? null : Object.freeze({
postNumber,
text,
source: normalizeReaderHistoryQuoteSource(source?.source),
active: source?.active !== !1
});
}
function normalizeReaderHistoryAnchorState(value) {
const raw = record(value), source = raw && "viewport" in raw ? raw : Object.freeze({ viewport: value }), viewport = normalizeReaderHistoryViewport(source.viewport);
return viewport === null ? null : Object.freeze({
viewport,
replyWindow: normalizeReaderHistoryReplyWindowState(source.replyWindow),
quoteHighlight: normalizeReaderHistoryQuoteHighlightState(
source.quoteHighlight
)
});
}
function normalizeReaderHistoryAnchorStates(value) {
const source = record(value), states = {};
if (!source) return Object.freeze(states);
for (const [rawTopicId, rawState] of Object.entries(source)) {
let topicId;
try {
topicId = (0, import_identifiers.discourseTopicId)(rawTopicId);
} catch {
continue;
}
const state = normalizeReaderHistoryAnchorState(rawState);
state && (states[String(topicId)] = state);
}
return Object.freeze(states);
}
}, "b3901132bc6c4446966db7d7226c122039b751b5a1ff6123d40942608630d703");
/* Source: lite/src/history/reader-history-navigation-controller.ts */
runtime.register("src/history/reader-history-navigation-controller.js", function(module, exports, require) {
var reader_history_navigation_controller_exports = {};
__export(reader_history_navigation_controller_exports, {
ReaderHistoryNavigationController: () => ReaderHistoryNavigationController
});
module.exports = __toCommonJS(reader_history_navigation_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_reader_history_model = require("./reader-history-model.js");
function normalizedTopicIds(values, currentTopicId) {
const normalized = /* @__PURE__ */ new Set();
for (const value of values ?? [])
try {
const topicId = (0, import_identifiers.discourseTopicId)(value);
topicId !== currentTopicId && normalized.add(topicId);
} catch {
}
return Object.freeze([...normalized]);
}
function frozenStates(states, topicId, anchor) {
return Object.freeze(topicId === void 0 || anchor === void 0 || anchor === null ? { ...states } : {
...states,
[String(topicId)]: anchor
});
}
class ReaderHistoryNavigationController {
scope;
changes = new import_signal.Signal();
#history;
#port;
#readSortMode;
#onError;
#snapshot = Object.freeze({
activeTopicId: null,
back: Object.freeze([]),
forward: Object.freeze([]),
states: Object.freeze({}),
pending: null,
revision: 0
});
#epoch = 0;
constructor(options) {
this.#history = options.history, this.#port = options.port, this.#readSortMode = options.readSortMode ?? (() => "recent-viewed"), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.#epoch += 1, this.changes.clear();
});
}
get snapshot() {
return this.#snapshot;
}
activate(topicIdValue, source) {
this.#assertActive();
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue);
this.#epoch += 1;
const ordered = this.#history.ordered(this.#readSortMode()).map((entry) => entry.topicId), currentIndex = ordered.indexOf(topicId), firstViewedFixedPosition = this.#readSortMode() === "first-viewed" && currentIndex >= 0, back = source && (source.back || source.forward) ? normalizedTopicIds(source.back, topicId) : normalizedTopicIds(
firstViewedFixedPosition ? ordered.slice(currentIndex + 1) : ordered,
topicId
), forward = source && (source.back || source.forward) ? normalizedTopicIds(source.forward, topicId) : normalizedTopicIds(
firstViewedFixedPosition ? ordered.slice(0, currentIndex).reverse() : [],
topicId
);
return this.#commit({
activeTopicId: topicId,
back,
forward,
states: this.#seedPersistedAnchors(
source ? (0, import_reader_history_model.normalizeReaderHistoryAnchorStates)(source.states) : this.#snapshot.states
),
pending: null
});
}
refreshOrder() {
this.#assertActive();
const topicId = this.#snapshot.activeTopicId;
if (topicId === null) return this.#snapshot;
const states = this.#snapshot.states, refreshed = this.activate(topicId);
return this.#commit({
...refreshed,
states,
pending: null
});
}
captureCurrent() {
this.#assertActive();
const topicId = this.#snapshot.activeTopicId;
if (topicId === null || this.#port.activeTopicId() !== topicId)
return null;
let anchor;
try {
anchor = (0, import_reader_history_model.normalizeReaderHistoryAnchorState)(
this.#port.captureAnchor()
);
} catch (cause) {
return this.#onError(cause), null;
}
return anchor ? (this.#commit({
...this.#snapshot,
states: frozenStates(this.#snapshot.states, topicId, anchor),
pending: null
}), anchor) : null;
}
setAnchor(topicIdValue, value) {
this.#assertActive();
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue), anchor = (0, import_reader_history_model.normalizeReaderHistoryAnchorState)(value);
if (!anchor) throw new TypeError("历史锚点缺少有效 viewport");
return this.#commit({
...this.#snapshot,
states: frozenStates(this.#snapshot.states, topicId, anchor)
});
}
async restore(topicIdValue, value, options = {}) {
this.#assertActive();
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue), anchor = (0, import_reader_history_model.normalizeReaderHistoryAnchorState)(value);
if (!anchor) throw new TypeError("历史锚点缺少有效 viewport");
if (this.#snapshot.activeTopicId !== topicId || this.#port.activeTopicId() !== topicId)
throw new Error(`历史目标 Topic ${topicId} 未处于 active 状态`);
try {
await this.#port.restoreAnchor(topicId, anchor, options);
} catch (cause) {
throw this.#onError(cause), cause;
}
return this.#commit({
...this.#snapshot,
states: frozenStates(this.#snapshot.states, topicId, anchor),
pending: null
}), anchor;
}
async navigate(direction) {
this.#assertActive();
const fromTopicId = this.#snapshot.activeTopicId, targetTopicId = (direction === "back" ? this.#snapshot.back : this.#snapshot.forward)[0] ?? null;
if (fromTopicId === null || targetTopicId === null)
return Object.freeze({
direction,
fromTopicId,
targetTopicId,
status: "unavailable"
});
let states = this.#snapshot.states;
if (this.#port.activeTopicId() === fromTopicId)
try {
const anchor = (0, import_reader_history_model.normalizeReaderHistoryAnchorState)(
this.#port.captureAnchor()
);
anchor && (states = frozenStates(states, fromTopicId, anchor));
} catch (cause) {
this.#onError(cause);
}
const epoch = ++this.#epoch, pending = Object.freeze({
direction,
fromTopicId,
targetTopicId
});
this.#commit({
...this.#snapshot,
states,
pending
});
let opened;
try {
opened = await this.#port.openTopic(targetTopicId);
} catch (cause) {
return epoch !== this.#epoch || this.scope.destroyed ? this.#result(pending, "superseded") : (this.#onError(cause), this.#commit({ ...this.#snapshot, pending: null }), this.#result(pending, "failed", cause));
}
if (epoch !== this.#epoch || this.scope.destroyed || opened.status === "superseded")
return this.#result(pending, "superseded");
if (opened.status === "failed")
return this.#commit({ ...this.#snapshot, pending: null }), this.#result(pending, "failed", opened.cause);
const back = direction === "back" ? this.#snapshot.back.slice(1) : Object.freeze([fromTopicId, ...this.#snapshot.back]), forward = direction === "back" ? Object.freeze([fromTopicId, ...this.#snapshot.forward]) : this.#snapshot.forward.slice(1);
this.#commit({
activeTopicId: targetTopicId,
back: Object.freeze(back),
forward: Object.freeze(forward),
states,
pending: null
});
const targetAnchor = states[String(targetTopicId)];
if (!targetAnchor) return this.#result(pending, "opened");
try {
await this.#port.restoreAnchor(targetTopicId, targetAnchor, {
highlight: !1,
restoreSemanticState: !1
});
} catch (cause) {
return epoch !== this.#epoch || this.scope.destroyed ? this.#result(pending, "superseded") : (this.#onError(cause), this.#result(pending, "restore-failed", cause));
}
return epoch !== this.#epoch || this.scope.destroyed ? this.#result(pending, "superseded") : this.#result(pending, "restored");
}
destroy() {
this.scope.destroy();
}
#result(pending, status, cause) {
return Object.freeze({
direction: pending.direction,
fromTopicId: pending.fromTopicId,
targetTopicId: pending.targetTopicId,
status,
...cause === void 0 ? {} : { cause }
});
}
#seedPersistedAnchors(states) {
const seeded = {
...states
};
for (const entry of this.#history.snapshot.entries) {
const key = String(entry.topicId);
if (seeded[key] || entry.viewport === null) continue;
const anchor = (0, import_reader_history_model.normalizeReaderHistoryAnchorState)({
viewport: entry.viewport
});
anchor && (seeded[key] = anchor);
}
return Object.freeze(seeded);
}
#commit(input) {
const snapshot = Object.freeze({
activeTopicId: input.activeTopicId,
back: Object.freeze([...input.back]),
forward: Object.freeze([...input.forward]),
states: frozenStates(input.states),
pending: input.pending,
revision: this.#snapshot.revision + 1
});
this.#snapshot = snapshot;
for (const cause of this.changes.emit(snapshot)) this.#onError(cause);
return snapshot;
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderHistoryNavigationController 已销毁");
}
}
}, "e68aa52659f0f5ec018c05510945c866433000ad7f008cf1138aa990c4d34e52");
/* Source: lite/src/history/reader-history-navigation-view.ts */
runtime.register("src/history/reader-history-navigation-view.js", function(module, exports, require) {
var reader_history_navigation_view_exports = {};
__export(reader_history_navigation_view_exports, {
ReaderHistoryNavigationView: () => ReaderHistoryNavigationView
});
module.exports = __toCommonJS(reader_history_navigation_view_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const EDGE_TRIGGER_MIN = 0, EDGE_TRIGGER_MAX = 15, BLOCKING_SURFACE_SELECTOR = ".ldp-settings-popover,.ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover";
function normalizedPreferences(value) {
const numeric = Number(value.edgeTriggerPercent), edgeTriggerPercent = Number.isFinite(numeric) ? Math.min(
EDGE_TRIGGER_MAX,
Math.max(EDGE_TRIGGER_MIN, Math.round(numeric))
) : EDGE_TRIGGER_MAX;
return Object.freeze({
edgeTriggerPercent,
buttonsAlwaysVisible: value.buttonsAlwaysVisible === !0
});
}
function eventElement(value) {
return value !== null && typeof value == "object" && value.nodeType === 1 ? value : null;
}
class ReaderHistoryNavigationView {
scope;
#navigation;
#elements;
#window;
#topicTitle;
#onError;
#preferences;
#pointerBounds = null;
#backActive = !1;
#forwardActive = !1;
constructor(options) {
this.#navigation = options.navigation, this.#elements = options.elements, this.#window = options.window ?? null, this.#topicTitle = options.topicTitle ?? (() => null), this.#onError = options.onError ?? (() => {
}), this.#preferences = normalizedPreferences(options.preferences), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const {
root,
modal,
backButton,
forwardButton
} = this.#elements;
this.#listen(modal, "pointerenter", (event) => {
this.#onPointerEnter(event);
}), this.#listen(modal, "pointermove", (event) => {
this.#onPointerMove(event);
}), this.#listen(modal, "pointerleave", () => {
this.#clearActive(), this.#invalidateBounds();
}), this.#listen(root, "ldp-reader-window-change", () => {
this.#invalidateBounds();
}), this.#listen(root, "ldp-reader-workspace-change", () => {
this.#invalidateBounds();
}), this.#window && this.#listen(this.#window, "resize", () => {
this.#invalidateBounds();
}), this.#listen(backButton, "click", () => {
this.#navigate("back");
}), this.#listen(forwardButton, "click", () => {
this.#navigate("forward");
}), this.#navigation.changes.subscribe((snapshot) => {
this.#sync(snapshot);
}, this.scope), this.scope.add(() => {
this.#clearActive(), root.classList.remove("ldp-history-buttons-always-visible");
}), this.applyPreferences(this.#preferences), this.#sync(this.#navigation.snapshot);
}
applyPreferences(value) {
this.#assertActive(), this.#preferences = normalizedPreferences(value), this.#elements.root.classList.toggle(
"ldp-history-buttons-always-visible",
this.#preferences.buttonsAlwaysVisible
), this.#invalidateBounds(), this.#preferences.buttonsAlwaysVisible && this.#clearActive();
}
destroy() {
this.scope.destroy();
}
#sync(snapshot) {
const backTarget = snapshot.back[0] ?? null, forwardTarget = snapshot.forward[0] ?? null, pending = snapshot.pending !== null;
this.#syncDirection("back", backTarget, pending), this.#syncDirection("forward", forwardTarget, pending);
}
#syncDirection(direction, targetTopicId, pending) {
const backward = direction === "back", edge = backward ? this.#elements.backEdge : this.#elements.forwardEdge, button = backward ? this.#elements.backButton : this.#elements.forwardButton, hidden = targetTopicId === null;
if (edge.hidden = hidden, button.hidden = hidden, button.disabled = pending, button.setAttribute("aria-busy", String(pending)), targetTopicId === null) {
button.removeAttribute("title");
return;
}
const title = this.#topicTitle(targetTopicId) ?? `帖子 #${targetTopicId}`, label = `${backward ? "上一条" : "下一条"}历史:${title}`;
button.setAttribute("aria-label", label), button.title = label;
}
#navigate(direction) {
this.#navigation.snapshot.pending || this.#navigation.navigate(direction).catch((error) => {
this.#report(error);
});
}
#onPointerEnter(event) {
this.#preferences.buttonsAlwaysVisible || event.pointerType && event.pointerType !== "mouse" || this.#refreshBounds();
}
#onPointerMove(event) {
if (this.#preferences.buttonsAlwaysVisible || event.pointerType && event.pointerType !== "mouse") {
this.#clearActive();
return;
}
const target = eventElement(event.target);
if (target?.closest(BLOCKING_SURFACE_SELECTOR)) {
this.#clearActive();
return;
}
const bounds = this.#pointerBounds ?? this.#refreshBounds(), overBackButton = target !== null && this.#elements.backButton.contains(target), overForwardButton = target !== null && this.#elements.forwardButton.contains(target), nearBack = bounds.enabled && event.clientX <= bounds.backLimit, nearForward = bounds.enabled && event.clientX >= bounds.forwardLimit;
if (!nearBack && !nearForward && !overBackButton && !overForwardButton) {
this.#clearActive();
return;
}
this.#setActive(
nearBack || overBackButton,
nearForward || overForwardButton
);
}
#refreshBounds() {
const percent = this.#preferences.edgeTriggerPercent;
if (percent <= 0)
return this.#pointerBounds = Object.freeze({
backLimit: 0,
forwardLimit: 0,
enabled: !1
}), this.#pointerBounds;
const rect = this.#elements.modal.getBoundingClientRect(), width = Math.max(0, rect.width), triggerWidth = width * percent / 100;
return this.#pointerBounds = Object.freeze({
backLimit: rect.left + triggerWidth,
forwardLimit: rect.right - triggerWidth,
enabled: width > 0
}), this.#pointerBounds;
}
#invalidateBounds() {
this.#pointerBounds = null;
}
#setActive(back, forward) {
back !== this.#backActive && (this.#backActive = back, this.#elements.backEdge.classList.toggle("is-active", back)), forward !== this.#forwardActive && (this.#forwardActive = forward, this.#elements.forwardEdge.classList.toggle(
"is-active",
forward
));
}
#clearActive() {
this.#setActive(!1, !1);
}
#listen(target, type, listener) {
target.addEventListener(type, listener), this.scope.add(() => {
target.removeEventListener(type, listener);
});
}
#report(error) {
try {
this.#onError(error);
} catch {
}
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderHistoryNavigationView 已销毁");
}
}
}, "4a1c793d3182323a1005971e819bd958a598d250d6442816164cbf2082c7c88e");
/* Source: lite/src/history/reader-history-panel-view.ts */
runtime.register("src/history/reader-history-panel-view.js", function(module, exports, require) {
var reader_history_panel_view_exports = {};
__export(reader_history_panel_view_exports, {
ReaderHistoryPanelView: () => ReaderHistoryPanelView
});
module.exports = __toCommonJS(reader_history_panel_view_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_reader_collection_floating_window = require("../collection/reader-collection-floating-window.js"), import_reader_popover_filter_controls = require("../collection/reader-popover-filter-controls.js"), import_reader_collection_filter_model = require("../collection/reader-collection-filter-model.js"), import_reader_image_fallback = require("../components/reader-image-fallback.js"), import_reader_icon = require("../components/reader-icon.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_search = require("../search/reader-search.js"), import_reader_history_repository = require("./reader-history-repository.js");
const DEFAULT_PAGE_SIZE = 20;
function defaultRelativeTime(timestamp, now) {
const elapsed = Math.max(0, now - timestamp), minute = 6e4, hour = 60 * minute, day = 24 * hour;
return elapsed < minute ? "刚刚" : elapsed < hour ? `${Math.floor(elapsed / minute)} 分钟前` : elapsed < day ? `${Math.floor(elapsed / hour)} 小时前` : `${Math.floor(elapsed / day)} 天前`;
}
class ReaderHistoryPanelView {
scope;
#document;
#history;
#elements;
#pageSize;
#topicHref;
#openEntry;
#changeSortMode;
#confirmDelete;
#notify;
#searchForms;
#avatarSource;
#relativeTime;
#now;
#onError;
#surface;
#filterDisclosure;
#scrollWindow;
#preferences;
#page = 0;
#query = "";
#categoryFilter = "";
#tagFilter = "";
#dateFilter = "";
#sortDirection = "desc";
#multi = !1;
#selectionScope = "page";
#selection = /* @__PURE__ */ new Set();
#visibleTopicIds = Object.freeze([]);
#totalMatches = 0;
#totalPages = 1;
#revision = 0;
#openEpoch = 0;
constructor(options) {
if (this.#document = options.document, this.#history = options.history, this.#elements = options.elements, this.#pageSize = Math.floor(
Number(options.pageSize ?? DEFAULT_PAGE_SIZE)
), !Number.isSafeInteger(this.#pageSize) || this.#pageSize <= 0)
throw new RangeError("历史面板 pageSize 必须是正整数");
this.#topicHref = options.topicHref, this.#openEntry = options.openEntry, this.#changeSortMode = options.changeSortMode, this.#confirmDelete = options.confirmDelete, this.#notify = options.notify ?? (() => {
}), this.#searchForms = options.searchForms ?? ((value) => Object.freeze([(0, import_reader_search.normalizeReaderSearchText)(value)])), this.#avatarSource = options.avatarSource ?? ((template, size) => (0, import_native_host_api.discourseAvatarTemplateUrl)(
template,
size,
this.#document.baseURI
)), this.#now = options.now ?? Date.now, this.#relativeTime = options.relativeTime ?? ((timestamp) => defaultRelativeTime(timestamp, this.#now())), this.#onError = options.onError ?? (() => {
}), this.#preferences = this.#normalizePreferences(options.preferences), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#surface = new import_reader_collection_floating_window.ReaderCollectionFloatingWindow({
document: this.#document,
mount: options.mount,
toggle: this.#elements.toggle,
content: this.#elements.popover,
title: "浏览历史",
ariaLabel: "浏览历史",
icon: "history",
variant: "history",
tabOrder: 20,
...options.storage ? { geometryStorage: options.storage } : {},
parentScope: this.scope,
isOpen: () => this.#surface?.isOpen ?? !1,
requestOpen: () => this.open(),
requestClose: () => this.close(),
notify: this.#notify
}), this.#surface.attachHeaderActions({
root: this.#elements.defaultActions,
buttons: [
this.#elements.sortToggle,
this.#elements.multiButton,
this.#elements.clearButton
],
label: "浏览历史操作"
}), this.#surface.attachHeaderActions({
root: this.#elements.bulkActions,
buttons: [
this.#elements.selectToggle,
this.#elements.deleteSelected,
this.#elements.multiDone
],
label: "浏览历史多选操作"
});
const collectionTitle = this.#elements.popover.querySelector(
".ldp-collection-title"
);
collectionTitle && (collectionTitle.hidden = !0), this.#filterDisclosure = new import_reader_popover_filter_controls.ReaderPopoverFilterDisclosure({
search: this.#elements.search,
onDateChange: (value) => {
this.#dateFilter = value, this.#resetFilteredPage();
},
onSortChange: (value) => {
const mode = value === "first-viewed" ? "first-viewed" : "recent-viewed";
mode !== this.#preferences.sortMode && this.#run(async () => {
await this.#changeSortMode(mode), this.scope.destroyed || this.applyPreferences({ sortMode: mode });
});
},
onDirectionChange: (value) => {
this.#sortDirection = value, this.#resetFilteredPage();
},
onReset: () => this.#resetFilters(),
parentScope: this.scope
});
const pager = this.#elements.pageInfo.parentElement;
if (!pager) throw new Error("历史面板缺少滚动分页锚点");
this.#scrollWindow = new import_reader_collection_floating_window.ReaderCollectionScrollWindow({
list: this.#elements.list,
pager,
identity: (entry) => String(entry.topicId),
loadMore: () => {
this.#page >= this.#totalPages - 1 || (this.#page += 1, this.#render());
},
onError: this.#onError,
parentScope: this.scope
}), this.#bind(), this.#history.changes.subscribe(() => {
this.#pruneSelection(), this.#preferences.sortMode === "recent-viewed" && this.#surface.isOpen && (this.#page = 0), this.#render();
}, this.scope), this.scope.add(() => {
this.#openEpoch += 1, this.#selection.clear();
}), this.#render();
}
get snapshot() {
return Object.freeze({
open: this.#surface.isOpen,
page: this.#page,
query: this.#query,
categoryFilter: this.#categoryFilter,
tagFilter: this.#tagFilter,
dateFilter: this.#dateFilter,
sortDirection: this.#sortDirection,
multi: this.#multi,
selectionScope: this.#selectionScope,
selectedTopicIds: new Set(this.#selection),
visibleTopicIds: this.#visibleTopicIds,
totalMatches: this.#totalMatches,
totalPages: this.#totalPages,
revision: this.#revision
});
}
applyPreferences(preferences) {
this.#assertActive();
const normalized = this.#normalizePreferences(preferences);
normalized.sortMode !== this.#preferences.sortMode && (this.#preferences = normalized, this.#page = 0, this.#selection.clear(), this.#render());
}
open() {
this.#assertActive(), this.#surface.sync(!0), this.#render();
}
close() {
this.scope.destroyed || !this.#surface.isOpen || (this.#surface.sync(!1), this.#multi = !1, this.#selection.clear(), this.#render());
}
toggle() {
this.scope.destroyed || (this.#surface.isOpen ? this.close() : this.open());
}
destroy() {
this.scope.destroy();
}
#normalizePreferences(preferences) {
return Object.freeze({
sortMode: preferences.sortMode === "first-viewed" ? "first-viewed" : "recent-viewed"
});
}
#bind() {
this.#listen(this.#elements.toggle, "click", (event) => {
event.preventDefault(), event.stopPropagation(), this.#surface.isOpen ? this.close() : this.open();
}), this.#listen(this.#elements.sortToggle, "click", () => {
const next = this.#preferences.sortMode === "recent-viewed" ? "first-viewed" : "recent-viewed";
this.#run(() => this.#changeSortMode(next));
}), this.#listen(this.#elements.multiButton, "click", () => {
this.#multi = !0, this.#selectionScope = "page", this.#selection.clear(), this.#render();
}), this.#listen(this.#elements.multiDone, "click", () => {
this.#multi = !1, this.#selection.clear(), this.#render();
}), this.#listen(this.#elements.selectScope, "change", () => {
const selected = [...this.#elements.selectScope.options].find(
(option) => option.selected
);
this.#selectionScope = (selected?.value ?? this.#elements.selectScope.value) === "all" ? "all" : "page", this.#selection.clear(), this.#render();
}), this.#listen(this.#elements.selectToggle, "click", () => {
const ids = this.#selectionScopeIds(), allSelected = ids.length > 0 && ids.every((id) => this.#selection.has(id));
for (const id of ids)
allSelected ? this.#selection.delete(id) : this.#selection.add(id);
this.#render();
}), this.#listen(this.#elements.search, "input", () => {
this.#query = (0, import_reader_search.normalizeReaderSearchText)(this.#elements.search.value), this.#page = 0, this.#selection.clear(), this.#render();
}), this.#listen(this.#elements.searchClear, "click", () => {
this.#elements.search.value = "", this.#query = "", this.#page = 0, this.#selection.clear(), this.#render(), this.#elements.search.focus({ preventScroll: !0 });
}), this.#listen(this.#elements.categoryFilter, "change", () => {
this.#categoryFilter = this.#elements.categoryFilter.value, this.#page = 0, this.#selection.clear(), this.#render();
}), this.#listen(this.#elements.tagFilter, "change", () => {
this.#tagFilter = this.#elements.tagFilter.value, this.#page = 0, this.#selection.clear(), this.#render();
}), this.#listen(this.#elements.deleteSelected, "click", () => {
const selectedTopicIds = [...this.#selection], count = selectedTopicIds.length;
count && this.#run(async () => {
!await this.#confirmDelete({
kind: "selected",
count,
title: "删除所选浏览历史?",
message: `将删除选中的 ${count} 条阅读器浏览历史。`,
note: "不会清除浏览器自身的访问历史。",
confirmLabel: `删除 ${count} 条`
}) || this.scope.destroyed || (this.#history.forgetMany(selectedTopicIds), this.#notify(`已删除 ${count} 条浏览历史`));
});
}), this.#listen(this.#elements.clearButton, "click", () => {
const count = this.#history.snapshot.entries.length;
count && this.#run(async () => {
!await this.#confirmDelete({
kind: "all",
count,
title: "清空浏览历史?",
message: `将删除全部 ${count} 条阅读器浏览历史。`,
note: "此操作无法撤销,但不会清除浏览器自身的访问历史。",
confirmLabel: "清空全部"
}) || this.scope.destroyed || (this.#history.clear(), this.#elements.search.value = "", this.#query = "", this.#categoryFilter = "", this.#tagFilter = "", this.#dateFilter = "", this.#sortDirection = "desc", this.#page = 0, this.#selection.clear(), this.#notify("浏览历史已清空"));
});
}), this.#listen(this.#elements.list, "change", (event) => {
const target = event.target;
if (!target?.matches?.(
".ldp-history-select-input"
)) return;
const checkbox = target, item = checkbox.closest(
"[data-history-topic-id]"
), topicId = Number(item?.dataset.historyTopicId);
if (!(topicId > 0)) return;
const entry = this.#history.entry(topicId);
entry && (checkbox.checked ? this.#selection.add(entry.topicId) : this.#selection.delete(entry.topicId), this.#render());
}), this.#listen(this.#elements.list, "click", (event) => {
const mouseEvent = event, target = mouseEvent.target;
if (!target?.closest) return;
const item = target.closest("[data-history-topic-id]");
if (!item) return;
const entry = this.#history.entry(item.dataset.historyTopicId);
if (!entry || target.closest(".ldp-history-select")) return;
if (target.closest(".ldp-history-delete")) {
mouseEvent.preventDefault(), this.#history.forget(entry.topicId), this.#selection.delete(entry.topicId), this.#notify("已删除这条浏览历史");
return;
}
mouseEvent.preventDefault();
const epoch = ++this.#openEpoch;
this.#run(async () => {
await this.#openEntry(entry), !(epoch !== this.#openEpoch || this.scope.destroyed) && this.#render();
});
});
}
#render() {
if (this.scope.destroyed) return;
const entries = this.#matchingEntries();
this.#pruneSelection(), this.#totalMatches = entries.length, this.#totalPages = Math.max(1, Math.ceil(entries.length / this.#pageSize)), this.#page = Math.max(0, Math.min(this.#page, this.#totalPages - 1));
const pageEntries = entries.slice(0, (this.#page + 1) * this.#pageSize);
if (this.#visibleTopicIds = Object.freeze(
pageEntries.map((entry) => entry.topicId)
), this.#elements.list.replaceChildren(), pageEntries.length)
for (const entry of pageEntries)
this.#elements.list.append(this.#renderEntry(entry));
else {
const empty = this.#document.createElement("div");
empty.className = "ldp-notification-empty", empty.textContent = this.#query || this.#categoryFilter || this.#tagFilter || this.#dateFilter || this.#sortDirection !== "desc" ? "没有匹配的浏览历史" : "暂无浏览历史", this.#elements.list.append(empty);
}
this.#syncControls(entries), this.#filterDisclosure.sync({
active: !!(this.#categoryFilter || this.#tagFilter || this.#dateFilter || this.#preferences.sortMode !== "recent-viewed" || this.#sortDirection !== "desc"),
date: this.#dateFilter,
sort: this.#preferences.sortMode,
direction: this.#sortDirection,
dayCounts: this.#dayCounts()
}), this.#surface.frame.meta.textContent = this.#totalMatches > 0 ? `${this.#totalMatches} 条` : "本地历史", this.#scrollWindow.sync({
loading: !1,
hasMore: this.#page < this.#totalPages - 1
}), this.#revision += 1;
}
#renderEntry(entry) {
const archiveMarker = entry.archiveStatus === null ? null : Object.freeze({
status: entry.archiveStatus,
postNumber: entry.archivePostNumber,
topicTitle: entry.title
}), displayTitle = (0, import_reader_history_repository.readerHistoryArchiveDisplayTitle)(
entry.title,
archiveMarker
), item = this.#document.createElement("div");
if (item.className = [
"ldp-history-item",
"ldp-collection-item",
this.#multi ? "multi" : "",
this.#selection.has(entry.topicId) ? "selected" : ""
].filter(Boolean).join(" "), item.dataset.historyTopicId = String(entry.topicId), item.dataset.historyActor = entry.ownerUsername, entry.archiveStatus !== null && (item.dataset.historyArchiveStatus = String(entry.archiveStatus), entry.archivePostNumber !== null && (item.dataset.historyArchivePostNumber = String(entry.archivePostNumber))), this.#multi) {
const select = this.#document.createElement("label");
select.className = "ldp-history-select ldp-collection-select", select.dataset.ldpTooltipLabel = "选择这条浏览历史";
const checkbox = this.#document.createElement("input");
checkbox.className = "ldp-history-select-input ldp-collection-select-input", checkbox.type = "checkbox", checkbox.checked = this.#selection.has(entry.topicId), checkbox.setAttribute("aria-label", `选择《${displayTitle}》`), select.append(checkbox), item.append(select);
}
const link = this.#document.createElement("a");
link.className = "ldp-notification-item ldp-history-link", link.href = this.#topicHref(entry), link.append(this.#renderAvatar(entry));
const copy = this.#document.createElement("span");
copy.className = "ldp-notification-copy";
const title = this.#document.createElement("span");
title.className = "ldp-notification-title", title.textContent = displayTitle;
const meta = this.#document.createElement("span");
meta.className = "ldp-notification-meta ldp-history-subtitle";
const sortTime = this.#preferences.sortMode === "first-viewed" ? entry.firstViewedAt : entry.viewedAt, category = entry.categoryName || (entry.categoryId === null ? "" : `类别 #${entry.categoryId}`);
if (meta.textContent = [
archiveMarker === null ? "" : (0, import_reader_history_repository.readerHistoryArchiveMarkerLabel)(archiveMarker),
entry.topicSubtitle || `${entry.postsCount} 帖`,
category,
...entry.tags.map((tag) => tag.startsWith("#") ? tag : `#${tag}`),
this.#relativeTime(sortTime)
].filter(Boolean).join(" · "), copy.append(title, meta), link.append(copy), item.append(link), !this.#multi) {
const remove = this.#document.createElement("button");
remove.type = "button", remove.className = "ldp-history-delete ldp-collection-delete", remove.setAttribute("aria-label", "删除这条浏览历史"), remove.append((0, import_reader_icon.createReaderIcon)(this.#document, "trash")), item.append(remove);
}
return item;
}
#renderAvatar(entry) {
const source = entry.avatarTemplate ? this.#avatarSource(entry.avatarTemplate, 64) : null;
let avatar;
if (source) {
const image = this.#document.createElement("img");
image.className = "ldp-notification-avatar", (0, import_reader_image_fallback.replaceImageWithFallbackOnError)(image, () => {
const fallback = this.#document.createElement("span");
return fallback.className = "ldp-notification-avatar ldp-notification-avatar-fallback", fallback.textContent = entry.ownerUsername.slice(0, 1).toLocaleUpperCase() || "◷", fallback;
}), image.src = source, image.alt = "", image.loading = "lazy", image.decoding = "async", avatar = image;
} else {
const fallback = this.#document.createElement("span");
fallback.className = "ldp-notification-avatar ldp-notification-avatar-fallback", entry.avatarTemplate ? fallback.textContent = entry.ownerUsername.slice(0, 1).toLocaleUpperCase() || "?" : fallback.append((0, import_reader_icon.createReaderIcon)(this.#document, "history")), avatar = fallback;
}
if (!entry.ownerUsername) return avatar;
const wrapper = this.#document.createElement("span");
return wrapper.className = "ldp-user-avatar-card", wrapper.dataset.userCard = entry.ownerUsername, wrapper.append(avatar), wrapper;
}
#syncControls(entries) {
const allEntries = this.#history.snapshot.entries, recent = this.#preferences.sortMode === "recent-viewed", sortLabel = recent ? "最近打开优先;点击切换为首次打开顺序" : "首次打开顺序固定;点击切换为最近打开优先";
this.#elements.sortToggle.replaceChildren((0, import_reader_icon.createReaderIcon)(
this.#document,
recent ? "history" : "pin"
)), this.#elements.sortToggle.setAttribute("aria-label", sortLabel), this.#elements.sortToggle.setAttribute(
"aria-pressed",
String(!recent)
), this.#elements.sortToggle.title = sortLabel, this.#elements.defaultActions.hidden = this.#multi, this.#elements.bulkActions.hidden = !this.#multi;
const collectionTitle = this.#elements.bulkActions.closest(
".ldp-collection-title"
);
collectionTitle && (collectionTitle.hidden = !this.#multi), this.#elements.multiButton.disabled = allEntries.length === 0, this.#elements.clearButton.disabled = allEntries.length === 0;
for (const option of this.#elements.selectScope.options)
option.selected = option.value === this.#selectionScope;
const scopeIds = this.#selectionScopeIds(entries), allSelected = scopeIds.length > 0 && scopeIds.every((id) => this.#selection.has(id));
this.#elements.selectToggle.disabled = scopeIds.length === 0, this.#elements.selectToggle.setAttribute(
"aria-pressed",
String(allSelected)
), this.#elements.selectToggle.setAttribute(
"aria-label",
`${allSelected ? "全不选" : "全选"}${this.#selectionScope === "all" ? "全部记录" : "已加载"}浏览历史`
), this.#elements.deleteSelected.disabled = this.#selection.size === 0, this.#elements.deleteSelectedLabel.textContent = String(this.#selection.size), this.#elements.deleteSelectedLabel.hidden = this.#selection.size === 0, this.#elements.searchClear.hidden = this.#query.length === 0, (0, import_reader_popover_filter_controls.syncReaderFilterOptions)(
this.#elements.categoryFilter,
"类别",
"暂无类别",
(0, import_reader_history_repository.readerHistoryFilterOptions)(allEntries, "category"),
this.#categoryFilter
), (0, import_reader_popover_filter_controls.syncReaderFilterOptions)(
this.#elements.tagFilter,
"标签",
"暂无标签",
(0, import_reader_history_repository.readerHistoryFilterOptions)(allEntries, "tag"),
this.#tagFilter
);
}
#matchingEntries() {
const source = this.#history.ordered(this.#preferences.sortMode);
return (this.#sortDirection === "asc" ? [...source].reverse() : source).filter((entry) => {
const timestamp = this.#preferences.sortMode === "first-viewed" ? entry.firstViewedAt : entry.viewedAt;
return (!this.#categoryFilter || (0, import_reader_history_repository.readerHistoryCategoryFilterKey)(entry) === this.#categoryFilter) && (!this.#tagFilter || entry.tags.some((tag) => (0, import_reader_history_repository.readerHistoryTagFilterKey)(tag) === this.#tagFilter)) && (!this.#dateFilter || (0, import_reader_collection_filter_model.readerCollectionDateKey)(timestamp) === this.#dateFilter) && [
entry.title,
entry.topicSubtitle,
entry.categoryName,
...entry.tags
].some((value) => (0, import_reader_search.readerSearchMatches)(
value,
this.#query,
this.#searchForms,
this.#onError
));
});
}
#dayCounts() {
const counts = /* @__PURE__ */ new Map();
for (const entry of this.#history.snapshot.entries) {
const timestamp = this.#preferences.sortMode === "first-viewed" ? entry.firstViewedAt : entry.viewedAt, day = (0, import_reader_collection_filter_model.readerCollectionDateKey)(timestamp);
day && counts.set(day, (counts.get(day) ?? 0) + 1);
}
return new Map([...counts].sort(([left], [right]) => left.localeCompare(right)));
}
#resetFilteredPage() {
this.#page = 0, this.#selection.clear(), this.#render();
}
#resetFilters() {
this.#elements.search.value = "", this.#query = "", this.#categoryFilter = "", this.#tagFilter = "", this.#dateFilter = "", this.#sortDirection = "desc", this.#preferences.sortMode !== "recent-viewed" && this.#run(async () => {
await this.#changeSortMode("recent-viewed"), this.scope.destroyed || this.applyPreferences({ sortMode: "recent-viewed" });
}), this.#resetFilteredPage();
}
#selectionScopeIds(entries = this.#matchingEntries()) {
return this.#selectionScope === "all" ? Object.freeze(entries.map((entry) => entry.topicId)) : this.#visibleTopicIds;
}
#pruneSelection() {
const available = new Set(
this.#history.snapshot.entries.map((entry) => entry.topicId)
);
for (const topicId of [...this.#selection])
available.has(topicId) || this.#selection.delete(topicId);
}
#listen(target, type, listener) {
target.addEventListener(type, listener), this.scope.add(() => target.removeEventListener(type, listener));
}
#run(task) {
try {
const result = task();
result && typeof result.then == "function" && result.catch((cause) => {
this.#onError(cause);
});
} catch (cause) {
this.#onError(cause);
}
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderHistoryPanelView 已销毁");
}
}
}, "3d8b1ed1e4345d2f9e5cc5741eb4a27d0bc28d2a823e27dca8bda27f68fc5202");
/* Source: lite/src/history/reader-history-repository.ts */
runtime.register("src/history/reader-history-repository.js", function(module, exports, require) {
var reader_history_repository_exports = {};
__export(reader_history_repository_exports, {
READER_DELETED_TOPIC_TITLE: () => READER_DELETED_TOPIC_TITLE,
READER_HISTORY_MAX_AGE_MS: () => READER_HISTORY_MAX_AGE_MS,
READER_HISTORY_STORAGE_KEY: () => READER_HISTORY_STORAGE_KEY,
ReaderHistoryRepository: () => ReaderHistoryRepository,
normalizeReaderHistoryEntry: () => normalizeReaderHistoryEntry,
readerHistoryArchiveDisplayTitle: () => readerHistoryArchiveDisplayTitle,
readerHistoryArchiveIsDeletedTopic: () => readerHistoryArchiveIsDeletedTopic,
readerHistoryArchiveMarkerLabel: () => readerHistoryArchiveMarkerLabel,
readerHistoryCategoryFilterKey: () => readerHistoryCategoryFilterKey,
readerHistoryFilterOptions: () => readerHistoryFilterOptions,
readerHistoryTagFilterKey: () => readerHistoryTagFilterKey
});
module.exports = __toCommonJS(reader_history_repository_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_signal = require("../kernel/signal.js"), import_reader_history_model = require("./reader-history-model.js"), import_reader_account_scoped_storage = require("../state/reader-account-scoped-storage.js");
const READER_HISTORY_STORAGE_KEY = "linuxdo-enhanced-reader:history", READER_HISTORY_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1e3, READER_DELETED_TOPIC_TITLE = "标题已删除";
function readerHistoryArchiveIsDeletedTopic(marker) {
return marker !== null && marker.postNumber === null && (marker.status === 404 || marker.status === 410);
}
function readerHistoryArchiveDisplayTitle(title, marker) {
return readerHistoryArchiveIsDeletedTopic(marker) ? READER_DELETED_TOPIC_TITLE : title;
}
function readerHistoryArchiveMarkerLabel(marker) {
const target = marker.postNumber === null ? "Topic" : "楼层";
return marker.status === 403 ? `403 不可用 ${target}` : `${marker.status} 已删除 ${target}`;
}
function readerHistoryCategoryFilterKey(entry) {
if (entry.categoryId !== null) return `category:${entry.categoryId}`;
const name = entry.categoryName.trim().toLocaleLowerCase("zh-CN");
return name ? `category-name:${name}` : "";
}
function readerHistoryTagFilterKey(value) {
const tag = value.trim().toLocaleLowerCase("zh-CN");
return tag ? `tag:${tag}` : "";
}
function readerHistoryFilterOptions(entries, kind) {
const options = /* @__PURE__ */ new Map();
for (const entry of entries) {
const values = kind === "category" ? [[
readerHistoryCategoryFilterKey(entry),
entry.categoryName || `类别 #${entry.categoryId}`
]] : entry.tags.map((tag) => [
readerHistoryTagFilterKey(tag),
tag
]);
for (const [value, label] of values) {
if (!value) continue;
const current = options.get(value);
options.set(value, Object.freeze({
value,
label: current?.label.startsWith("类别 #") && entry.categoryName ? entry.categoryName : current?.label ?? label,
count: (current?.count ?? 0) + 1
}));
}
}
return Object.freeze([...options.values()].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-CN")));
}
function normalizedTimestamp(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
function nonNegativeInteger(value) {
const numeric = Math.floor(Number(value));
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : 0;
}
function normalizedHistoryText(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function normalizedCategoryId(value) {
const numeric = Math.floor(Number(value));
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
function normalizedTagNames(value) {
if (!Array.isArray(value)) return Object.freeze([]);
const names = /* @__PURE__ */ new Map();
for (const item of value) {
const source = item !== null && typeof item == "object" && !Array.isArray(item) ? item : null, name = normalizedHistoryText(
typeof item == "string" ? item : source?.name ?? source?.tag_name ?? source?.slug ?? (typeof source?.id == "string" ? source.id : "")
);
if (!name) continue;
const key = name.toLocaleLowerCase("zh-CN");
names.has(key) || names.set(key, name);
}
return Object.freeze([...names.values()]);
}
function normalizedArchiveStatus(value) {
const status = Number(value);
return status === 403 || status === 404 || status === 410 ? status : null;
}
function normalizedArchivePostNumber(value) {
try {
return (0, import_identifiers.discoursePostNumber)(value);
} catch {
return null;
}
}
function normalizedReadPostNumbers(values) {
const source = values instanceof Set ? [...values] : Array.isArray(values) ? values : [], normalized = /* @__PURE__ */ new Set();
for (const value of source)
try {
normalized.add((0, import_identifiers.discoursePostNumber)(value));
} catch {
}
return Object.freeze([...normalized].sort((left, right) => left - right));
}
function normalizeReaderHistoryEntry(value) {
if (!value || typeof value != "object" || Array.isArray(value)) return null;
const source = value;
let topicId;
try {
topicId = (0, import_identifiers.discourseTopicId)(source.topicId);
} catch {
return null;
}
const viewedAt = normalizedTimestamp(source.viewedAt);
if (!viewedAt) return null;
const readPostNumbers = normalizedReadPostNumbers(source.readPostNumbers);
let postNumber;
try {
postNumber = (0, import_identifiers.discoursePostNumber)(source.postNumber);
} catch {
postNumber = readPostNumbers.at(-1) ?? (0, import_identifiers.discoursePostNumber)(1);
}
const postsCount = Math.max(
nonNegativeInteger(source.postsCount),
postNumber,
readPostNumbers.at(-1) ?? 0
), archiveStatus = normalizedArchiveStatus(source.archiveStatus);
return Object.freeze({
topicId,
title: String(source.title || `帖子 #${topicId}`),
postsCount,
avatarTemplate: String(source.avatarTemplate || ""),
ownerUsername: String(source.ownerUsername || ""),
topicSubtitle: normalizedHistoryText(source.topicSubtitle),
categoryId: normalizedCategoryId(source.categoryId),
categoryName: normalizedHistoryText(source.categoryName),
tags: normalizedTagNames(source.tags),
viewport: (0, import_reader_history_model.normalizeReaderHistoryViewport)(source.viewport),
postNumber,
readPostNumbers: normalizedReadPostNumbers([
...readPostNumbers,
postNumber
]),
archiveStatus,
archivePostNumber: archiveStatus === null ? null : normalizedArchivePostNumber(source.archivePostNumber),
firstViewedAt: normalizedTimestamp(source.firstViewedAt) || viewedAt,
viewedAt
});
}
function entriesEqual(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function quotaError(error) {
const candidate = error;
return candidate?.name === "QuotaExceededError" || candidate?.name === "NS_ERROR_DOM_QUOTA_REACHED" || candidate?.code === 22 || candidate?.code === 1014;
}
class ReaderHistoryRepository {
changes = new import_signal.Signal();
diagnostics = new import_signal.Signal();
#storage;
#key;
#accountStorage;
#maxAgeMs;
#now;
#snapshot = Object.freeze({
entries: Object.freeze([]),
revision: 0,
source: "fallback"
});
constructor(options) {
if (this.#storage = options.storage, this.#accountStorage = options.key === void 0 && options.authScope !== void 0 ? (0, import_reader_account_scoped_storage.readerAccountScopedStorageIdentity)(
READER_HISTORY_STORAGE_KEY,
options.authScope
) : null, this.#key = String(options.key ?? this.#accountStorage?.key ?? READER_HISTORY_STORAGE_KEY).trim(), !this.#key) throw new Error("history storage key 不能为空");
if (this.#maxAgeMs = Number(options.maxAgeMs ?? READER_HISTORY_MAX_AGE_MS), !Number.isFinite(this.#maxAgeMs) || this.#maxAgeMs <= 0)
throw new RangeError("history maxAgeMs 必须是正有限数值");
this.#now = options.now ?? Date.now;
}
get snapshot() {
return this.#snapshot;
}
get storageKey() {
return this.#key;
}
load() {
return this.#readAndCommit("initial");
}
reloadExternal() {
return this.#readAndCommit("external-reload");
}
ordered(mode) {
const firstViewed = mode === "first-viewed";
return Object.freeze([...this.#snapshot.entries].sort(
(left, right) => (firstViewed ? right.firstViewedAt - left.firstViewedAt : right.viewedAt - left.viewedAt) || right.topicId - left.topicId
));
}
entry(topicIdValue) {
let topicId;
try {
topicId = (0, import_identifiers.discourseTopicId)(topicIdValue);
} catch {
return null;
}
return this.#snapshot.entries.find(
(entry) => entry.topicId === topicId
) ?? null;
}
archiveMarker(topicIdValue, postNumberValue) {
const entry = this.entry(topicIdValue);
if (entry?.archiveStatus === null || entry === null) return null;
const postNumber = normalizedArchivePostNumber(postNumberValue);
return entry.archivePostNumber !== null && entry.archivePostNumber !== postNumber ? null : Object.freeze({
status: entry.archiveStatus,
postNumber: entry.archivePostNumber,
topicTitle: entry.title
});
}
remember(input) {
this.#mergeStoredBeforeMutation();
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), previous = this.entry(topicId), now = this.#now(), inputReads = normalizedReadPostNumbers(input.readPostNumbers);
let postNumber;
try {
postNumber = (0, import_identifiers.discoursePostNumber)(
input.postNumber ?? previous?.postNumber ?? 1
);
} catch {
postNumber = previous?.postNumber ?? (0, import_identifiers.discoursePostNumber)(1);
}
const readPostNumbers = normalizedReadPostNumbers([
...previous?.readPostNumbers ?? [],
...inputReads,
postNumber
]), archiveStatus = input.archiveStatus === void 0 ? previous?.archiveStatus ?? null : normalizedArchiveStatus(input.archiveStatus), archivePostNumber = archiveStatus === null ? null : input.archiveStatus === void 0 ? previous?.archivePostNumber ?? null : normalizedArchivePostNumber(input.archivePostNumber), entry = Object.freeze({
topicId,
title: String(
input.title || previous?.title || `帖子 #${topicId}`
),
postsCount: Math.max(
nonNegativeInteger(input.postsCount),
previous?.postsCount ?? 0,
postNumber,
readPostNumbers.at(-1) ?? 0
),
avatarTemplate: String(
input.avatarTemplate || previous?.avatarTemplate || ""
),
ownerUsername: String(
input.ownerUsername || previous?.ownerUsername || ""
),
topicSubtitle: input.topicSubtitle === void 0 ? previous?.topicSubtitle ?? "" : normalizedHistoryText(input.topicSubtitle),
categoryId: input.categoryId === void 0 ? previous?.categoryId ?? null : normalizedCategoryId(input.categoryId),
categoryName: input.categoryName === void 0 ? previous?.categoryName ?? "" : normalizedHistoryText(input.categoryName),
tags: input.tags === void 0 ? previous?.tags ?? Object.freeze([]) : normalizedTagNames(input.tags),
viewport: input.viewport === void 0 ? previous?.viewport ?? null : (0, import_reader_history_model.normalizeReaderHistoryViewport)(input.viewport),
postNumber,
readPostNumbers,
archiveStatus,
archivePostNumber,
firstViewedAt: previous?.firstViewedAt || now,
viewedAt: now
});
return this.#persistAndCommit(
Object.freeze([
entry,
...this.#snapshot.entries.filter(
(candidate) => candidate.topicId !== topicId
)
]),
"remember"
);
}
forget(topicIdValue) {
return this.forgetMany([topicIdValue]);
}
forgetMany(topicIdValues) {
this.#mergeStoredBeforeMutation();
const topicIds = /* @__PURE__ */ new Set();
for (const value of topicIdValues)
try {
topicIds.add((0, import_identifiers.discourseTopicId)(value));
} catch {
}
if (!topicIds.size) return this.#snapshot;
const next = this.#snapshot.entries.filter(
(entry) => !topicIds.has(entry.topicId)
);
return next.length === this.#snapshot.entries.length ? this.#snapshot : this.#persistAndCommit(Object.freeze(next), "forget");
}
clear() {
return this.#mergeStoredBeforeMutation(), this.#persistAndCommit(Object.freeze([]), "clear");
}
replaceExternal(values) {
const cutoff = this.#now() - this.#maxAgeMs, entries = [], seen = /* @__PURE__ */ new Set();
for (const value of values) {
const entry = normalizeReaderHistoryEntry(value);
!entry || entry.viewedAt < cutoff || seen.has(entry.topicId) || (seen.add(entry.topicId), entries.push(entry));
}
const persisted = this.#persist(Object.freeze(entries));
return this.#commit(persisted, "external-sync");
}
#readAndCommit(source) {
let raw;
try {
const stored = this.#accountStorage ? (0, import_reader_account_scoped_storage.readReaderAccountScopedString)(this.#storage, this.#accountStorage) : this.#storage.getItem(this.#key);
raw = stored === null ? [] : JSON.parse(stored);
} catch (cause) {
return this.#diagnose("read-failed", cause), this.#commit(Object.freeze([]), "fallback");
}
if (!Array.isArray(raw))
return this.#diagnose(
"invalid-stored-value",
new TypeError("阅读历史存储值必须是数组")
), this.#commit(Object.freeze([]), "fallback");
const cutoff = this.#now() - this.#maxAgeMs, entries = [], seen = /* @__PURE__ */ new Set();
for (const value of raw) {
const entry = normalizeReaderHistoryEntry(value);
!entry || entry.viewedAt < cutoff || seen.has(entry.topicId) || (seen.add(entry.topicId), entries.push(entry));
}
const frozen = Object.freeze(entries), rawNormalized = raw.map(normalizeReaderHistoryEntry).filter((entry) => entry !== null);
if (!entriesEqual(frozen, rawNormalized)) {
this.#diagnose(
"entries-normalized",
Object.freeze({
storedCount: raw.length,
acceptedCount: frozen.length
})
);
try {
this.#persist(frozen);
} catch {
}
}
return this.#commit(frozen, source);
}
#mergeStoredBeforeMutation() {
let stored;
try {
stored = this.#accountStorage ? (0, import_reader_account_scoped_storage.readReaderAccountScopedString)(this.#storage, this.#accountStorage) : this.#storage.getItem(this.#key);
} catch (cause) {
this.#diagnose("read-failed", cause);
return;
}
(stored ?? "[]") !== JSON.stringify(this.#snapshot.entries) && this.#readAndCommit("external-reload");
}
#persistAndCommit(entries, source) {
const persisted = this.#persist(entries);
return this.#commit(persisted, source);
}
#persist(entries) {
const safe = [...entries];
for (; ; )
try {
return safe.length ? this.#storage.setItem(this.#key, JSON.stringify(safe)) : this.#storage.removeItem && !this.#accountStorage ? this.#storage.removeItem(this.#key) : this.#storage.setItem(this.#key, "[]"), Object.freeze(safe);
} catch (cause) {
if (!quotaError(cause) || !safe.length)
throw this.#diagnose("write-failed", cause), cause;
const removed = safe.pop();
this.#diagnose("quota-trimmed", Object.freeze({
topicId: removed?.topicId ?? null,
remainingCount: safe.length
}));
}
}
#commit(entries, source) {
const snapshot = Object.freeze({
entries: Object.freeze([...entries]),
revision: this.#snapshot.revision + 1,
source
});
this.#snapshot = snapshot;
for (const cause of this.changes.emit(snapshot))
this.#diagnose("consumer-failed", cause);
return snapshot;
}
#diagnose(code, cause) {
this.diagnostics.emit(Object.freeze({ code, cause }));
}
}
}, "ae73da022ef835f9162058dd24d22f3bea8357614a327b9aab9033034bc763d0");
/* Source: lite/src/monitor/reader-resource-monitor.ts */
runtime.register("src/monitor/reader-resource-monitor.js", function(module, exports, require) {
var reader_resource_monitor_exports = {};
__export(reader_resource_monitor_exports, {
ReaderResourceMonitor: () => ReaderResourceMonitor
});
module.exports = __toCommonJS(reader_resource_monitor_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_browser_request_observation = require("../network/browser-request-observation.js"), import_reader_icon = require("../components/reader-icon.js"), import_reader_settings_dom = require("../settings/reader-settings-dom.js");
const RETENTION_MS = 10 * 6e4, REQUEST_RUNTIME_RETENTION_MS = 15 * 6e4, EVIDENCE_WINDOW_MS = 6e4, MAX_EVIDENCE_EVENTS = 1200, MAX_REQUEST_RUNTIME_STATES = 1200, REQUEST_TRACE_MS = 1e4, REQUEST_SLOW_MS = 1800, REQUEST_STUCK_MS = 15e3, REQUEST_TYPE_LABELS = Object.freeze({
topic: "正文",
nested: "二级回复",
avatar: "头像",
media: "图片媒体",
asset: "静态资源",
bookmark: "收藏",
notification: "消息",
realtime: "实时通道",
presence: "在线状态",
search: "搜索",
read: "已读上报",
user: "用户资料",
reaction: "回应操作",
other: "其他"
}), REQUEST_WAIT_REASON_LABELS = Object.freeze({
scheduler: "中央调度",
priority: "优先级",
concurrency: "并发槽",
interval: "启动间隔",
"10s": "10 秒窗口",
"60s": "60 秒窗口",
challenge: "Cloudflare 验证",
"queue-limit": "队列上限",
"viewport-change": "视口变化",
"priority-upgrade": "快车道升级",
"topic-switch": "切换帖子",
"topic-close": "离开帖子",
"context-close": "上下文结束",
"context-closed": "上下文结束",
signal: "主动取消",
cancelled: "主动取消"
}), EXPECTED_CANCELLATION_REASONS = /* @__PURE__ */ new Set([
"cancelled",
"context-close",
"context-closed",
"priority-upgrade",
"signal",
"topic-close",
"topic-switch",
"viewport-change"
]), EMPTY_REQUEST_LANE_COUNTS = Object.freeze({
control: 0,
"topic-batch": 0,
"nested-replies": 0,
"user-card": 0,
translation: 0,
standard: 0
}), METRICS = Object.freeze([
{
id: "heap",
label: "页面内存估计",
detail: "浏览器原生测量;不可用时明确显示未提供",
values: (sample) => [sample.heapBytes],
format: ([value]) => value == null ? "浏览器未提供" : formatBytes(value)
},
{
id: "longTasks",
label: "主线程卡顿",
detail: "最近 10 秒 Long Tasks / Long Animation Frames",
values: (sample) => [
sample.longTasks + sample.longFrames,
sample.longTaskDuration
],
format: ([count, duration]) => `${Math.round(count ?? 0)} 次 · ${Math.round(duration ?? 0)} ms`
},
{
id: "dom",
label: "页面元素",
detail: "阅读器 / 原站当前 DOM",
values: (sample) => [sample.readerDom, sample.hostDom],
format: ([reader, host]) => `${Math.round(reader ?? 0)} / ${Math.round(host ?? 0)} 个`
},
{
id: "floors",
label: "楼层保留",
detail: "已挂载 / 当前帖子会话保留(不等于持久缓存总量)",
values: (sample) => [sample.mountedFloors, sample.retainedFloors],
format: ([mounted, retained]) => `${Math.round(mounted ?? 0)} / ${Math.round(retained ?? 0)} 个`
},
{
id: "nested",
label: "树状楼层",
detail: "当前回复拓扑中的嵌套关系",
values: (sample) => [sample.nestedFloors],
format: ([value]) => `${Math.round(value ?? 0)} 个`
},
{
id: "media",
label: "媒体资源",
detail: "Reader 内图片、音视频与 iframe",
values: (sample) => [sample.media],
format: ([value]) => `${Math.round(value ?? 0)} 个`
},
{
id: "requests",
label: "请求调度",
detail: "当前生效调度器活动 / 排队(不是设置目标值)",
values: (sample) => [sample.activeRequests, sample.queuedRequests],
format: ([active, queued]) => `${Math.round(active ?? 0)} / ${Math.round(queued ?? 0)} 条`
},
{
id: "network",
label: "最近网络",
detail: "最近 60 秒被动观测次数 / 传输量",
values: (sample) => [sample.networkRequests, sample.networkBytes],
format: ([count, bytes]) => `${Math.round(count ?? 0)} 次 · ${formatBytes(bytes ?? 0)}`
}
]);
function formatBytes(rawBytes) {
const bytes = Math.max(0, Number(rawBytes) || 0);
return bytes < 1024 ? `${Math.round(bytes)} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
}
function formatDuration(milliseconds) {
const value = Math.max(0, Number(milliseconds) || 0);
return value < 1e3 ? `${Math.round(value)} ms` : `${(value / 1e3).toFixed(value < 1e4 ? 1 : 0)} s`;
}
function formatPolicyNumber(raw) {
const value = Number(raw);
return Number.isFinite(value) ? String(Number(value.toFixed(2))) : "—";
}
function formatRequestTimestamp(raw) {
const date = new Date(raw);
return `${date.toLocaleTimeString("zh-CN", {
hour12: !1,
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})}.${String(date.getMilliseconds()).padStart(3, "0")}`;
}
function requestDisplayPath(event) {
return `${event.path}${event.queryShape}`;
}
const REQUEST_DECISION_LABELS = Object.freeze({
complete: "完成",
"retry-429": "等待 429 重试",
"stop-429": "429 终止",
"await-cloudflare": "等待过盾",
"require-cloudflare": "转人工验证",
"challenge-passed-retry": "过盾后重试",
"challenge-required": "验证未通过",
"stop-cloudflare-isolated": "Cloudflare 隔离终止",
"stop-cloudflare-unhandled": "Cloudflare 无恢复端口",
"stop-http": "HTTP 终止",
"challenge-probe-passed": "会话探针通过",
"challenge-probe-rate-limited-pass": "会话已过盾但仍 429",
"challenge-probe-blocked": "会话探针仍被盾拦截",
"challenge-probe-failed": "会话探针失败",
"challenge-probe-cancelled": "会话探针取消"
});
function requestDecisionLabel(decision) {
return REQUEST_DECISION_LABELS[decision] ?? REQUEST_WAIT_REASON_LABELS[decision] ?? decision;
}
function requestContractDiagnostic(event) {
const contract = [event.profile, event.namespace, event.lane].filter(Boolean).join(" / ");
return [
event.logicalId ? `链 ${event.logicalId}` : "",
contract,
event.cacheMode ? `缓存 ${event.cacheMode}` : "",
event.identity ? `身份 ${event.identity}` : "",
event.logicalId ? `尝试 ${event.attempt}/${event.max429Retries + event.maxChallengeRetries + 1}` : event.attempt > 1 ? `第 ${event.attempt} 次尝试` : "",
event.logicalId ? `重试上限 429 ${event.max429Retries} · 过盾 ${event.maxChallengeRetries}` : "",
event.blockOnCloudflareChallenge === null ? "" : event.blockOnCloudflareChallenge ? "过盾 共享闸门" : "过盾 仅结束本请求",
event.suppressAfterChallengeWait ? "盾后不追发" : "",
event.droppable === !0 ? "可丢弃" : "",
event.promoted ? "已晋升" : "",
event.joinedConsumers ? `单飞合并 +${event.joinedConsumers}` : "",
event.decision ? `决策 ${requestDecisionLabel(event.decision)}` : "",
event.retryAfter ? `Retry-After ${event.retryAfter} 秒` : ""
].filter(Boolean).join(" · ");
}
const PERFORMANCE_EVENT_LABELS = Object.freeze({
request: "网络请求",
longtask: "长任务",
"long-animation-frame": "长动画帧",
script: "脚本归因",
dom: "DOM 变更",
visibility: "前后台",
capture: "采集状态",
gap: "采样空档"
});
function performanceEventMetric(event) {
return event.kind === "dom" ? `+${event.added ?? 0} / −${event.removed ?? 0}` : event.duration > 0 ? formatDuration(event.duration) : "瞬时";
}
function diagnosticIsoTime(at) {
return new Date(at).toISOString();
}
function diagnosticLogFilename(kind, at) {
const timestamp = diagnosticIsoTime(at).replace(/\.\d{3}Z$/, "Z").replace(/[:]/g, "-").replace("T", "_");
return `linuxdo-reader-${kind}-log-${timestamp}.jsonl`;
}
function diagnosticJsonLines(records) {
return `${records.map((record) => JSON.stringify(record)).join(`
`)}
`;
}
function requestDiagnosticRecord(event, visibility = "unknown") {
return Object.freeze({
recordType: "request",
id: event.id,
logicalId: event.logicalId,
phase: event.phase,
visibility,
queuedAt: event.queuedAt,
queuedAtIso: diagnosticIsoTime(event.queuedAt),
permittedAt: event.permittedAt,
startedAt: event.startedAt,
endedAt: event.endedAt,
permitWaitMs: event.permitWait,
dispatchDurationMs: event.dispatchDuration,
durationMs: event.duration,
method: event.method,
path: event.path,
queryShape: event.queryShape,
transport: event.transport,
source: event.source,
type: event.type,
sameOrigin: event.sameOrigin,
priority: event.priority,
attempt: event.attempt,
recoveryProbe: event.recoveryProbe,
waitReason: event.waitReason,
callSite: event.callSite,
controlReason: event.controlReason,
profile: event.profile,
namespace: event.namespace,
lane: event.lane,
cacheMode: event.cacheMode,
identity: event.identity,
joinedConsumers: event.joinedConsumers,
promoted: event.promoted,
max429Retries: event.max429Retries,
maxChallengeRetries: event.maxChallengeRetries,
blockOnCloudflareChallenge: event.blockOnCloudflareChallenge,
suppressAfterChallengeWait: event.suppressAfterChallengeWait,
droppable: event.droppable,
decision: event.decision,
pending: event.pending,
status: event.status,
cloudflareMitigated: event.cloudflareMitigated,
sizeBytes: event.size,
error: event.error,
rateLimitCode: event.rateLimitCode,
retryAfter: event.retryAfter,
serverLimit: event.serverLimit,
serverRemaining: event.serverRemaining,
serverReset: event.serverReset,
resourceTimed: event.resourceTimed
});
}
function schedulerDiagnosticRecord(snapshot) {
return snapshot ? Object.freeze({
active: snapshot.active,
queued: snapshot.queued,
maxConcurrent: snapshot.maxConcurrent,
queueLimit: snapshot.queueLimit,
disposed: snapshot.disposed,
activeByLane: snapshot.activeByLane,
queuedByLane: snapshot.queuedByLane
}) : null;
}
function permitDiagnosticRecord(snapshot) {
return snapshot ? Object.freeze({
coordinationMode: snapshot.coordinationMode,
shortCount: snapshot.shortCount,
shortBudget: snapshot.shortBudget,
longCount: snapshot.longCount,
longBudget: snapshot.longBudget,
minIntervalMs: snapshot.minIntervalMs,
maxConcurrent: snapshot.maxConcurrent,
instances: snapshot.instances,
queued: snapshot.queued,
active: snapshot.active,
nextPermitDelayMs: snapshot.nextPermitDelay,
blockingReason: snapshot.blockingReason,
challengeState: snapshot.challengeState,
challengeOwned: snapshot.challengeOwned
}) : null;
}
function performanceScriptLabel(sourceFunctionName, sourceUrl, baseHref) {
const functionName = String(sourceFunctionName ?? "").replace(/[\r\n\t]+/g, " ").trim().slice(0, 80);
if (functionName) return functionName;
try {
const source = String(sourceUrl ?? ""), url = /^[A-Za-z][A-Za-z\d+.-]*:\/\//.test(source) ? new URL(source) : new URL(source, baseHref);
return url.username = "", url.password = "", url.search = "", url.hash = "", `${url.origin}${url.pathname}`.slice(0, 120);
} catch {
return "匿名脚本";
}
}
function percentile95(values) {
if (!values.length) return 0;
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] ?? 0;
}
function requestIssue(event, at) {
const cancellationReason = event.controlReason || (event.phase === "cancelled" ? event.error : "");
if (EXPECTED_CANCELLATION_REASONS.has(cancellationReason)) return null;
if (event.controlReason)
return (event.controlReason || event.waitReason || "cancelled") === "queue-limit" ? {
level: "warning",
label: "队列已满",
detail: "辅助请求在发出前被队列上限丢弃"
} : {
level: "warning",
label: "调度取消",
detail: `请求未发出(${requestWaitReasonLabel(event.waitReason)})`
};
if (event.status === 429)
return {
level: "danger",
label: event.cloudflareMitigated ? "Cloudflare 429" : "429 限流",
detail: event.cloudflareMitigated ? "Cloudflare managed challenge 拒绝了当前请求" : event.rateLimitCode || "服务器拒绝了当前请求速率"
};
if ((event.status ?? 0) >= 400)
return {
level: "danger",
label: `HTTP ${event.status}`,
detail: (event.status ?? 0) >= 500 ? "服务器或上游服务返回错误" : "请求参数、权限或目标状态异常"
};
if (event.error) {
const aborted = /abort/i.test(event.error);
return {
level: aborted ? "warning" : "danger",
label: aborted ? "中止/超时" : "网络错误",
detail: event.error
};
}
if (event.phase === "queued") {
const wait = Math.max(0, at - event.queuedAt);
return wait >= REQUEST_STUCK_MS ? {
level: "danger",
label: "排队卡住",
detail: `等待放行已持续 ${formatDuration(wait)}`
} : wait >= 1e3 ? {
level: "warning",
label: "排队过久",
detail: `等待放行已持续 ${formatDuration(wait)}`
} : null;
}
if (event.permitWait >= 1e3)
return {
level: "warning",
label: "排队过久",
detail: `${requestWaitReasonLabel(event.waitReason)}等待 ` + formatDuration(event.permitWait)
};
const duration = event.pending ? Math.max(0, at - event.startedAt) : event.duration;
return event.pending && !["realtime", "presence"].includes(event.type) && duration >= REQUEST_STUCK_MS ? {
level: "danger",
label: "请求卡住",
detail: `网络阶段已持续 ${formatDuration(duration)}`
} : !event.pending && !["realtime", "presence"].includes(event.type) && duration >= REQUEST_SLOW_MS ? {
level: "warning",
label: "响应偏慢",
detail: `网络阶段耗时 ${formatDuration(duration)}`
} : null;
}
function requestWaitReasonLabel(reason) {
return REQUEST_WAIT_REASON_LABELS[reason] ?? "调度";
}
function requestPriorityLabel(priority) {
return priority === "critical" ? "核心" : priority === "interactive" ? "交互插队" : priority === "visible" ? "可见" : priority === "nested" ? "树状可见" : priority === "prefetch" ? "辅助预取" : priority === "background" ? "后台" : "";
}
function requestTimingLabel(event, at) {
const permitWait = event.phase === "queued" ? Math.max(0, at - event.queuedAt) : event.permitWait, queue = permitWait > 0 ? `排 ${formatDuration(permitWait)} · ` : "";
if (event.phase === "queued") return `${queue}等待放行`;
if (event.controlReason) return `${queue}未发出`;
const dispatch = event.dispatchDuration >= 1 ? `放行 ${formatDuration(event.dispatchDuration)} · ` : "", network = event.pending ? Math.max(0, at - event.startedAt) : event.duration;
return `${queue}${dispatch}网 ${formatDuration(network)}`;
}
function requestStatus(event) {
if (event.phase === "queued") return "排队中";
if (event.phase === "running") return "进行中";
if (event.phase === "cancelled") {
const reason = event.controlReason || event.error;
return reason === "viewport-change" ? "滚动取消" : reason === "priority-upgrade" ? "快车道升级" : reason === "topic-switch" ? "切帖取消" : ["topic-close", "context-close", "context-closed"].includes(reason) ? "离页取消" : reason === "queue-limit" ? "已丢弃" : "已取消";
}
return event.status !== null && event.status > 0 ? String(event.status) : event.error ? "ERR" : "完成";
}
function aggregate(samples, metric, kind) {
const width = metric.values(samples.at(-1) ?? emptySample()).length;
return Object.freeze(Array.from({ length: width }, (_, index) => {
const values = samples.map((sample) => metric.values(sample)[index]).filter((value) => value !== null);
return values.length ? kind === "peak" ? Math.max(...values) : values.reduce((sum, value) => sum + value, 0) / values.length : null;
}));
}
function chartPoints(samples, value) {
const entries = samples.map((sample) => ({ at: sample.at, value: value(sample) })).filter((entry) => entry.value !== null && Number.isFinite(entry.value));
if (!entries.length) return "";
if (entries.length === 1) return "0,17 240,17";
const stride = Math.max(1, Math.ceil(entries.length / 120)), plotted = entries.filter((_entry, index) => index % stride === 0 || index === entries.length - 1), values = plotted.map((entry) => entry.value), minimum = Math.min(...values), range = Math.max(...values) - minimum || 1;
return plotted.map((entry, index) => {
const x = index / Math.max(1, plotted.length - 1) * 240, y = 31 - (entry.value - minimum) / range * 28;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
}
function emptySample() {
return {
at: 0,
visibility: "visible",
heapBytes: null,
longTasks: 0,
longTaskDuration: 0,
longFrames: 0,
readerDom: 0,
hostDom: 0,
topicId: null,
mountedFloors: 0,
preparedFloors: 0,
retainedFloors: 0,
nestedFloors: 0,
media: 0,
initializedFromCache: !1,
expectedFloors: 0,
streamFloors: 0,
missingFloors: 0,
unavailableFloors: Object.freeze([]),
mediaDiagnostics: Object.freeze({
catalogImages: 0,
catalogComplete: !1,
catalogPending: !1,
catalogFailedBatches: 0,
persistentCacheEnabled: !1,
objectUrls: 0,
objectUrlLimit: 0,
boundImages: 0,
failedImages: 0,
retryingImages: 0,
crossOriginFailures: 0,
failedPostNumbers: Object.freeze([]),
unavailableSourcePostNumbers: Object.freeze([]),
hlsSources: 0,
nativeHlsSources: 0,
activeHlsPlayers: 0,
hlsLibraryAvailable: !1,
hlsLibrarySupported: !1,
nativeManagedMediaSource: !1
}),
activeRequests: 0,
queuedRequests: 0,
requestMaxConcurrent: 0,
requestQueueLimit: 0,
requestActiveByLane: EMPTY_REQUEST_LANE_COUNTS,
requestQueuedByLane: EMPTY_REQUEST_LANE_COUNTS,
sharedActiveRequests: 0,
sharedQueuedRequests: 0,
sharedMaxConcurrent: 0,
sharedMinIntervalMs: 0,
sharedInstances: 0,
sharedNextPermitDelay: 0,
sharedBlockingReason: "",
sharedCoordinationMode: "unavailable",
shortWindowCount: 0,
shortWindowBudget: 0,
longWindowCount: 0,
longWindowBudget: 0,
challengeState: "idle",
challengeOwned: !1,
networkRequests: 0,
networkBytes: 0
};
}
class ReaderResourceMonitor {
scope;
requests;
#options;
#root;
#health;
#healthState;
#healthDetail;
#updated;
#performancePolicy;
#rows = /* @__PURE__ */ new Map();
#trendWindow;
#trendRows = /* @__PURE__ */ new Map();
#evidenceWindow;
#scopeRows = /* @__PURE__ */ new Map();
#eventLog;
#requestMetrics = /* @__PURE__ */ new Map();
#requestWindow;
#requestTrace;
#requestLegend;
#requestBottleneck;
#requestBottleneckState;
#requestBottleneckDetail;
#topicDiagnostics;
#mediaDiagnostics;
#requestAnomalyWindow;
#requestAnomalies;
#requestTypes;
#requestLog;
#requestObserved;
#samples = [];
#performanceEvents = [];
#visibilityTimeline = [];
#requestRuntimeStates = [];
#requestVisibility = /* @__PURE__ */ new Map();
#activeScope = null;
#activePanel = null;
#selectedPanel = "request";
#heapBytes = null;
#memoryMeasuring = !1;
#lastMemoryAt = 0;
#baselineAt = 0;
#resourceObservationCapability = "not-attempted";
#longTaskCapability = "not-attempted";
#longAnimationFrameCapability = "not-attempted";
#readerMutationCapability = "not-attempted";
#hostMutationCapability = "not-attempted";
#evidenceOverflowDrops = 0;
#evidenceRetentionDrops = 0;
#sampleRetentionDrops = 0;
#visibilityRetentionDrops = 0;
#requestRuntimeRetentionDrops = 0;
#requestRuntimeOverflowDrops = 0;
constructor(options) {
this.#options = options, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.requests = options.requests;
const document = options.document;
this.#root = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-resource-monitor"
);
const tabs = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-log-tabs"
);
tabs.role = "tablist", tabs.setAttribute("aria-label", "日志记录类型");
const requestTab = (0, import_reader_settings_dom.settingsElement)(
document,
"button",
"ldp-settings-log-tab active"
);
requestTab.type = "button", requestTab.role = "tab", requestTab.dataset.settingsLogTab = "request", requestTab.setAttribute("aria-selected", "true"), requestTab.textContent = "请求记录";
const performanceTab = (0, import_reader_settings_dom.settingsElement)(
document,
"button",
"ldp-settings-log-tab"
);
performanceTab.type = "button", performanceTab.role = "tab", performanceTab.dataset.settingsLogTab = "performance", performanceTab.setAttribute("aria-selected", "false"), performanceTab.tabIndex = -1, performanceTab.textContent = "性能记录", tabs.append(requestTab, performanceTab);
const requestPanel = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-log-panel"
);
requestPanel.dataset.settingsLogPanel = "request", requestPanel.role = "tabpanel";
const performancePanel = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-log-panel"
);
performancePanel.dataset.settingsLogPanel = "performance", performancePanel.role = "tabpanel", performancePanel.hidden = !0;
const requestExport = this.#createExportControl(
"request",
"导出请求日志",
"导出当前内存中的完整脱敏请求账本、调度和共享限流快照(JSONL)。"
), performanceExport = this.#createExportControl(
"performance",
"导出性能日志",
"导出十分钟快照、毫秒事件、前后台时间线、关联请求及能力/缺口声明(JSONL)。"
), selectPanel = (name) => {
this.#selectedPanel = name;
const requestActive = name === "request";
requestTab.classList.toggle("active", requestActive), requestTab.setAttribute("aria-selected", String(requestActive)), requestTab.tabIndex = requestActive ? 0 : -1, performanceTab.classList.toggle("active", !requestActive), performanceTab.setAttribute(
"aria-selected",
String(!requestActive)
), performanceTab.tabIndex = requestActive ? -1 : 0, requestPanel.hidden = !requestActive, performancePanel.hidden = requestActive, this.active && this.start(name);
};
this.scope.listen(requestTab, "click", () => selectPanel("request")), this.scope.listen(
performanceTab,
"click",
() => selectPanel("performance")
), this.#health = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-health"
), this.#health.dataset.level = "normal", this.#health.role = "status", this.#healthState = (0, import_reader_settings_dom.settingsElement)(document, "strong"), this.#healthState.textContent = "等待打开日志面板", this.#healthDetail = (0, import_reader_settings_dom.settingsElement)(document, "p"), this.#healthDetail.textContent = "打开本面板后建立新基线,离开面板即停止采集。", this.#updated = (0, import_reader_settings_dom.settingsElement)(
document,
"span",
"ldp-resource-monitor-updated"
), this.#updated.textContent = "未采样", this.#health.append(
this.#healthState,
this.#healthDetail,
this.#updated
);
const performancePolicyBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-limit"
), performancePolicyTitle = (0, import_reader_settings_dom.settingsElement)(document, "h4");
performancePolicyTitle.textContent = "当前生效性能策略", performancePolicyBlock.dataset.resourceMonitorPolicyBlock = "", this.#performancePolicy = (0, import_reader_settings_dom.settingsElement)(document, "p"), this.#performancePolicy.dataset.resourceMonitorPolicy = "", this.#performancePolicy.textContent = "等待性能快照。", performancePolicyBlock.append(
performancePolicyTitle,
this.#performancePolicy
);
const table = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-table"
);
table.role = "table", table.setAttribute("aria-label", "阅读器实时资源数据");
const tableHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-table-head"
);
tableHead.role = "row";
for (const label of ["指标", "当前", "平均", "峰值", "变化趋势"]) {
const cell = (0, import_reader_settings_dom.settingsElement)(document, "span");
cell.textContent = label, tableHead.append(cell);
}
table.append(tableHead);
for (const metric of METRICS) {
const row = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-row"
);
row.role = "row", row.dataset.resourceMonitorRow = metric.id;
const copy = (0, import_reader_settings_dom.settingsElement)(
document,
"span",
"ldp-resource-monitor-copy"
), label = (0, import_reader_settings_dom.settingsElement)(document, "strong");
label.textContent = metric.label;
const detail = (0, import_reader_settings_dom.settingsElement)(document, "small");
detail.textContent = metric.detail, copy.append(label, detail);
const current = (0, import_reader_settings_dom.settingsElement)(
document,
"strong",
"ldp-resource-monitor-current"
);
current.dataset.resourceMonitorMetric = metric.id, current.textContent = "—";
const summary = (0, import_reader_settings_dom.settingsElement)(
document,
"span",
"ldp-resource-monitor-summary"
);
for (const key of ["average", "peak", "trend"]) {
const value = (0, import_reader_settings_dom.settingsElement)(document, "span");
value.dataset[`resourceMonitor${key[0].toUpperCase()}${key.slice(1)}`] = metric.id, value.textContent = "—", summary.append(value);
}
row.append(copy, current, summary), table.append(row), this.#rows.set(metric.id, row);
}
const evidence = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-resource-monitor-evidence"
), evidenceHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-evidence-head"
), evidenceTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
evidenceTitle.textContent = "最近 60 秒前后台实测", this.#evidenceWindow = (0, import_reader_settings_dom.settingsElement)(document, "span"), this.#evidenceWindow.textContent = "等待事件", evidenceHead.append(evidenceTitle, this.#evidenceWindow);
const scopeTable = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-scope-table"
);
scopeTable.role = "table", scopeTable.setAttribute(
"aria-label",
"阅读器与原站前后台资源记录"
);
const scopeHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-scope-head"
);
scopeHead.role = "row";
for (const copy of ["范围", "当前结构", "前台事件", "后台事件"]) {
const cell = (0, import_reader_settings_dom.settingsElement)(document, "span");
cell.textContent = copy, scopeHead.append(cell);
}
scopeTable.append(scopeHead);
for (const [scope, label] of [
["reader", "阅读器"],
["host", "原站 / 未标记"],
["shared", "页面共享"]
]) {
const row = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-scope-row"
);
row.role = "row", row.dataset.resourceMonitorScope = scope;
const name = (0, import_reader_settings_dom.settingsElement)(document, "strong");
name.textContent = label;
const current = (0, import_reader_settings_dom.settingsElement)(document, "span");
current.dataset.resourceMonitorScopeCurrent = "", current.textContent = "—";
const visible = (0, import_reader_settings_dom.settingsElement)(document, "span");
visible.dataset.resourceMonitorScopeVisible = "", visible.textContent = "—";
const hidden = (0, import_reader_settings_dom.settingsElement)(document, "span");
hidden.dataset.resourceMonitorScopeHidden = "", hidden.textContent = "—", row.append(name, current, visible, hidden), scopeTable.append(row), this.#scopeRows.set(scope, row);
}
evidence.append(evidenceHead, scopeTable);
const trends = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-resource-monitor-trends"
), trendHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-trend-head"
), trendTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
trendTitle.textContent = "最近 10 分钟趋势", this.#trendWindow = (0, import_reader_settings_dom.settingsElement)(document, "span"), this.#trendWindow.textContent = "等待采样", trendHead.append(trendTitle, this.#trendWindow);
const trendList = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-trend-list"
);
for (const [key, label] of [
["heapUsed", "页面内存估计"],
["dom", "阅读器页面元素"],
["retainedFloors", "楼层列表保留"]
]) {
const row = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-trend-row"
);
row.dataset.resourceMonitorChart = key;
const copy = (0, import_reader_settings_dom.settingsElement)(document, "span");
copy.textContent = label;
const current = (0, import_reader_settings_dom.settingsElement)(document, "strong");
current.dataset.resourceMonitorChartCurrent = key, current.textContent = "—";
const chart = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg"
);
chart.setAttribute("viewBox", "0 0 240 34"), chart.setAttribute("preserveAspectRatio", "none"), chart.setAttribute("aria-hidden", "true"), chart.append(document.createElementNS(
"http://www.w3.org/2000/svg",
"polyline"
)), row.append(copy, current, chart), trendList.append(row), this.#trendRows.set(key, row);
}
trends.append(trendHead, trendList);
const events = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-resource-monitor-events"
), eventHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-events-head"
), eventTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
eventTitle.textContent = "毫秒级事件记录";
const eventHint = (0, import_reader_settings_dom.settingsElement)(document, "span");
eventHint.textContent = "毫秒时间 · 类型 · 耗时/增减 · 范围 · 原始依据", eventHead.append(eventTitle, eventHint), this.#eventLog = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-resource-monitor-event-log"
), this.#eventLog.role = "log", this.#eventLog.setAttribute("aria-live", "off"), events.append(eventHead, this.#eventLog);
const boundary = (0, import_reader_settings_dom.settingsElement)(
document,
"p",
"ldp-resource-monitor-boundary"
);
boundary.textContent = "“前台 / 后台”取自页面可见状态,网络与性能记录按开始时状态归档;页面元素变更没有原生时间戳,只能按观察器回调时状态记录。阅读器请求可明确归因;未标记请求记为“原站 / 未标记”;无法确认来源的内存与主线程事件记为“页面共享”,不以推算冒充独占数据。", performancePanel.append(
performanceExport,
this.#health,
performancePolicyBlock,
evidence,
table,
trends,
events,
boundary
);
const requestSummary = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-summary"
);
requestSummary.setAttribute("aria-label", "请求速率摘要");
for (const [id, label] of [
["rate10", "最近 10 秒请求"],
["rate60", "最近 60 秒请求"],
["peak", "100ms 内峰值"],
["transfer", "最近 60 秒传输"],
["issues", "最近 60 秒异常"]
]) {
const metric = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-metric"
), copy = (0, import_reader_settings_dom.settingsElement)(document, "span");
copy.textContent = label;
const value = (0, import_reader_settings_dom.settingsElement)(document, "strong");
value.dataset.requestFlowMetric = id, value.textContent = "0 次", metric.append(copy, value), requestSummary.append(metric), this.#requestMetrics.set(id, value);
}
const traceBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-block ldp-request-flow-chart-block"
), traceHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-block-head"
), traceTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
traceTitle.textContent = "毫秒请求脉络", this.#requestWindow = (0, import_reader_settings_dom.settingsElement)(
document,
"span",
"ldp-request-flow-window"
), this.#requestWindow.textContent = "等待采样", traceHead.append(traceTitle, this.#requestWindow), this.#requestTrace = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-trace"
), this.#requestTrace.role = "img", this.#requestTrace.setAttribute(
"aria-label",
"最近 10 秒按来源显示排队、网络耗时与异常点"
);
const axis = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-axis"
);
for (const label of [
"10 秒前",
"虚线排队 · 短线放行 · 实色网络 · 圆点异常",
"现在"
]) {
const value = (0, import_reader_settings_dom.settingsElement)(document, "span");
value.textContent = label, axis.append(value);
}
this.#requestLegend = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-legend"
), this.#requestLegend.setAttribute("aria-label", "请求类型图例"), traceBlock.append(
traceHead,
this.#requestTrace,
axis,
this.#requestLegend
), this.#requestBottleneck = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-bottleneck"
), this.#requestBottleneck.dataset.level = "normal", this.#requestBottleneck.role = "status", this.#requestBottleneckState = (0, import_reader_settings_dom.settingsElement)(document, "strong"), this.#requestBottleneckState.textContent = "采样中", this.#requestBottleneckDetail = (0, import_reader_settings_dom.settingsElement)(document, "p"), this.#requestBottleneckDetail.textContent = "打开帖子并正常滚动后,这里会判断排队、限流、响应延迟或静态资源瓶颈。", this.#requestBottleneck.append(
this.#requestBottleneckState,
this.#requestBottleneckDetail
);
const topicDiagnosticBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-block ldp-request-flow-diagnostic-block"
), topicDiagnosticHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-block-head"
), topicDiagnosticTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
topicDiagnosticTitle.textContent = "帖子与楼层诊断";
const topicDiagnosticHint = (0, import_reader_settings_dom.settingsElement)(document, "span");
topicDiagnosticHint.textContent = "缓存 · 虚拟窗口 · 删除 · 网络 · 限流", topicDiagnosticHead.append(topicDiagnosticTitle, topicDiagnosticHint), this.#topicDiagnostics = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-diagnostics"
), topicDiagnosticBlock.append(
topicDiagnosticHead,
this.#topicDiagnostics
);
const mediaDiagnosticBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-block ldp-request-flow-diagnostic-block"
), mediaDiagnosticHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-block-head"
), mediaDiagnosticTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
mediaDiagnosticTitle.textContent = "图片与媒体诊断";
const mediaDiagnosticHint = (0, import_reader_settings_dom.settingsElement)(document, "span");
mediaDiagnosticHint.textContent = "失败 · 缓存 · 来源 · HLS", mediaDiagnosticHead.append(mediaDiagnosticTitle, mediaDiagnosticHint), this.#mediaDiagnostics = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-diagnostics"
), mediaDiagnosticBlock.append(
mediaDiagnosticHead,
this.#mediaDiagnostics
);
const anomalyBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-block ldp-request-flow-anomaly-block"
), anomalyHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-block-head"
), anomalyTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
anomalyTitle.textContent = "最近异常点", this.#requestAnomalyWindow = (0, import_reader_settings_dom.settingsElement)(document, "span"), this.#requestAnomalyWindow.textContent = "最近 60 秒", anomalyHead.append(anomalyTitle, this.#requestAnomalyWindow), this.#requestAnomalies = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-anomalies"
), anomalyBlock.append(anomalyHead, this.#requestAnomalies);
const typesBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-block ldp-request-flow-types-block"
), typesHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-type-head"
);
for (const [tag, label] of [
["strong", "最近 60 秒按类型"],
["span", "数量"],
["span", "95% 请求耗时"],
["span", "异常"]
]) {
const value = (0, import_reader_settings_dom.settingsElement)(document, tag);
value.textContent = label, typesHead.append(value);
}
this.#requestTypes = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-types"
), typesBlock.append(typesHead, this.#requestTypes);
const logBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-block ldp-request-flow-log-block"
), logHead = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-block-head"
), logTitle = (0, import_reader_settings_dom.settingsElement)(document, "strong");
logTitle.textContent = "毫秒请求记录";
const logHint = (0, import_reader_settings_dom.settingsElement)(document, "span");
logHint.textContent = "显示逻辑链、契约、单飞、重试与过盾决策;仅保留查询键形状,不保存查询值、正文、Cookie、授权头或响应内容", logHead.append(logTitle, logHint), this.#requestLog = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-request-flow-log"
), this.#requestLog.role = "log", this.#requestLog.setAttribute("aria-live", "off"), logBlock.append(logHead, this.#requestLog);
const limitBlock = (0, import_reader_settings_dom.settingsElement)(
document,
"section",
"ldp-request-flow-limit"
);
limitBlock.setAttribute(
"aria-labelledby",
"ldp-request-flow-limit-title"
);
const limitTitle = (0, import_reader_settings_dom.settingsElement)(document, "h4");
limitTitle.id = "ldp-request-flow-limit-title", limitTitle.textContent = "Discourse 全局请求窗口与本地车道", this.#requestObserved = (0, import_reader_settings_dom.settingsElement)(
document,
"p",
"ldp-request-flow-observed"
), this.#requestObserved.textContent = "当前还没有观察到服务器限流信息。";
const laneRules = (0, import_reader_settings_dom.settingsElement)(document, "p"), laneRulesLabel = (0, import_reader_settings_dom.settingsElement)(document, "strong");
laneRulesLabel.textContent = "当前阅读器规则:", laneRules.append(
laneRulesLabel,
document.createTextNode(
"后台 post_ids 正文单槽;可见缺口会提升并复用已有同键请求,需要新批次时可在总预算允许下占用第二个正文槽;树状回复最多两槽。所有车道继续共用本页与跨标签全局预算。"
)
);
const publicLimit = (0, import_reader_settings_dom.settingsElement)(document, "p"), publicLimitLabel = (0, import_reader_settings_dom.settingsElement)(document, "strong");
publicLimitLabel.textContent = "Discourse 公开默认:", publicLimit.append(
publicLimitLabel,
document.createTextNode(
"动态应用请求默认 50 次/10 秒、200 次/分钟;头像、CSS 等静态资源默认 200 次/10 秒。站点管理员、插件或反向代理可以覆盖这些数字。正文 post_ids[] 批次与直属回复共用动态请求窗口,阅读器只按实际载荷把它们分为不同并发车道,不虚构独立服务器额度。"
)
);
const limitBoundary = (0, import_reader_settings_dom.settingsElement)(document, "p"), limitFacts = (0, import_reader_settings_dom.settingsElement)(document, "strong");
limitFacts.textContent = "429、Retry-After 和限流错误码";
const limitLink = (0, import_reader_settings_dom.settingsElement)(document, "a");
limitLink.href = "https://meta.discourse.org/t/available-settings-for-global-rate-limits-and-throttling/78612", limitLink.target = "_blank", limitLink.rel = "noopener", limitLink.textContent = "查看 Discourse 公开说明", limitBoundary.append(
document.createTextNode(
"成功响应通常不提供剩余额度,因此这里用跨标签真实启动次数作为预防账本;服务器拒绝仍以本页收到的 "
),
limitFacts,
document.createTextNode("为准。"),
limitLink
), limitBlock.append(
limitTitle,
this.#requestObserved,
laneRules,
publicLimit,
limitBoundary
), requestPanel.append(
requestExport,
requestSummary,
traceBlock,
this.#requestBottleneck,
topicDiagnosticBlock,
mediaDiagnosticBlock,
anomalyBlock,
typesBlock,
logBlock,
limitBlock
);
const content = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-log-content"
);
content.append(requestPanel, performancePanel), this.#root.append(tabs, content), options.host.append(this.#root), this.scope.add(() => this.#root.remove()), this.scope.add(() => this.stop());
}
#createExportControl(kind, title, description) {
const document = this.#options.document, control = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-log-export-control"
);
control.dataset.logExportControl = kind;
const copy = (0, import_reader_settings_dom.settingsElement)(document, "span", "ldp-log-export-copy"), heading = (0, import_reader_settings_dom.settingsElement)(document, "strong");
heading.textContent = title;
const detail = (0, import_reader_settings_dom.settingsElement)(document, "small");
detail.textContent = description, copy.append(heading, detail);
const actions = (0, import_reader_settings_dom.settingsElement)(document, "span", "ldp-log-export-actions"), button = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-config-action ldp-log-export-button",
"",
"download",
"导出 JSONL"
);
button.dataset.logExport = kind;
const status = (0, import_reader_settings_dom.settingsElement)(document, "small", "ldp-log-export-status");
return status.dataset.logExportStatus = kind, status.role = "status", status.setAttribute("aria-live", "polite"), actions.append(button, status), control.append(copy, actions), this.scope.listen(button, "click", () => {
this.#exportLog(kind, button, status);
}), control;
}
async #exportLog(kind, button, status) {
if (!button.disabled) {
button.disabled = !0, status.textContent = "正在整理…";
try {
const generatedAt = this.#now(), requests = this.requests.snapshot.events, scheduler = schedulerDiagnosticRecord(
this.#options.schedulerSnapshot()
);
let permitSnapshot = null;
try {
permitSnapshot = await this.#options.permitSnapshot();
} catch {
}
const permit = permitDiagnosticRecord(permitSnapshot);
this.#recordRequestRuntimeState(
generatedAt,
scheduler,
permit,
"export"
);
const file = kind === "request" ? this.#requestLogFile(generatedAt, requests, scheduler, permit) : this.#performanceLogFile(
generatedAt,
requests,
scheduler,
permit
);
await this.#saveDiagnosticLog(file);
const count = kind === "request" ? requests.length : this.#samples.length + this.#performanceEvents.length;
status.textContent = `已导出 ${count} 条${kind === "request" ? "请求" : "性能事实"}`;
} catch (error) {
status.textContent = `导出失败:${error instanceof Error ? error.message : "未知错误"}`;
} finally {
button.disabled = !1;
}
}
}
#requestLogFile(generatedAt, requests, scheduler, permit) {
const records = [
{
recordType: "meta",
logType: "request",
schemaVersion: 1,
generatedAt,
generatedAtIso: diagnosticIsoTime(generatedAt),
retention: "current RequestObserver in-memory snapshot",
requestCount: requests.length,
requestRuntimeStateCount: this.#requestRuntimeStates.length,
privacy: "query keys and counts only; no query values, headers, bodies, cookies, authorization, or response content"
},
{
recordType: "runtime-state",
at: generatedAt,
atIso: diagnosticIsoTime(generatedAt),
scheduler,
permit
},
...this.#requestRuntimeStates.map((event) => ({
recordType: "request-runtime-state",
...event,
atIso: diagnosticIsoTime(event.at),
lastObservedAtIso: diagnosticIsoTime(event.lastObservedAt)
})),
...requests.map((event) => requestDiagnosticRecord(event))
];
return Object.freeze({
filename: diagnosticLogFilename("request", generatedAt),
mimeType: "application/x-ndjson;charset=utf-8",
text: diagnosticJsonLines(records)
});
}
#performanceLogFile(generatedAt, requests, scheduler, permit) {
const cutoff = generatedAt - RETENTION_MS, policy = this.#options.performancePolicySnapshot?.() ?? null, timeline = [
...this.#samples.map((sample) => ({
at: sample.at,
record: {
recordType: "sample",
...sample,
atIso: diagnosticIsoTime(sample.at)
}
})),
...this.#performanceEvents.map((event) => ({
at: event.at,
record: {
recordType: "performance-event",
at: event.at,
atIso: diagnosticIsoTime(event.at),
kind: event.kind,
kindLabel: PERFORMANCE_EVENT_LABELS[event.kind] ?? event.kind,
durationMs: event.duration,
visibility: event.visibility,
scope: event.scope,
detail: event.detail,
basis: event.basis,
...event.added === void 0 ? {} : { added: event.added },
...event.removed === void 0 ? {} : { removed: event.removed }
}
})),
...this.#visibilityTimeline.map((marker) => ({
at: marker.at,
record: {
recordType: "visibility-marker",
at: marker.at,
atIso: diagnosticIsoTime(marker.at),
state: marker.state
}
})),
...this.#requestRuntimeStates.map((event) => ({
at: event.lastObservedAt,
record: {
recordType: "request-runtime-state",
...event,
atIso: diagnosticIsoTime(event.at),
lastObservedAtIso: diagnosticIsoTime(event.lastObservedAt)
}
})),
...requests.filter((event) => event.queuedAt >= cutoff).map((event) => ({
at: event.queuedAt,
record: requestDiagnosticRecord(
event,
this.#requestVisibility.get(event.id) ?? "unknown"
)
}))
].filter((entry) => entry.at >= cutoff && entry.at <= generatedAt).sort((left, right) => left.at - right.at), records = [
{
recordType: "meta",
logType: "performance",
schemaVersion: 1,
generatedAt,
generatedAtIso: diagnosticIsoTime(generatedAt),
retentionMs: RETENTION_MS,
sampleIntervalMs: Math.max(
250,
this.#options.sampleIntervalMs ?? 1e3
),
sampleCount: this.#samples.length,
performanceEventCount: this.#performanceEvents.length,
associatedRequestCount: requests.filter(
(event) => event.queuedAt >= cutoff
).length,
privacy: "request query keys and counts only; script URLs exclude credentials, query, and fragment"
},
{
recordType: "runtime-state",
at: generatedAt,
atIso: diagnosticIsoTime(generatedAt),
scheduler,
permit,
performancePolicy: policy
},
this.#performanceCapabilities(generatedAt),
...timeline.map((entry) => entry.record)
];
return Object.freeze({
filename: diagnosticLogFilename("performance", generatedAt),
mimeType: "application/x-ndjson;charset=utf-8",
text: diagnosticJsonLines(records)
});
}
#performanceCapabilities(at) {
const view = this.#options.document.defaultView, performance = this.#options.performance ?? view?.performance, supportedEntryTypes = Array.isArray(
view?.PerformanceObserver?.supportedEntryTypes
) ? [...view.PerformanceObserver.supportedEntryTypes].sort() : [], memorySource = performance?.measureUserAgentSpecificMemory ? "measureUserAgentSpecificMemory" : Number.isFinite(Number(performance?.memory?.usedJSHeapSize)) ? "performance.memory" : "unavailable";
return Object.freeze({
recordType: "capabilities",
at,
atIso: diagnosticIsoTime(at),
captureActive: this.#activePanel === "performance",
activePanel: this.#activePanel,
sampleIntervalMs: Math.max(
250,
this.#options.sampleIntervalMs ?? 1e3
),
memoryIntervalMs: 1e4,
memorySource,
supportedEntryTypes,
observerInstall: {
resource: this.#resourceObservationCapability,
longtask: this.#longTaskCapability,
longAnimationFrame: this.#longAnimationFrameCapability,
readerMutation: this.#readerMutationCapability,
hostMutation: this.#hostMutationCapability
},
retention: {
performanceMs: RETENTION_MS,
requestRuntimeMs: REQUEST_RUNTIME_RETENTION_MS,
maxPerformanceEvents: MAX_EVIDENCE_EVENTS,
maxRequestRuntimeStates: MAX_REQUEST_RUNTIME_STATES
},
discarded: {
evidenceOverflow: this.#evidenceOverflowDrops,
evidenceRetention: this.#evidenceRetentionDrops,
sampleRetention: this.#sampleRetentionDrops,
visibilityRetention: this.#visibilityRetentionDrops,
requestRuntimeOverflow: this.#requestRuntimeOverflowDrops,
requestRuntimeRetention: this.#requestRuntimeRetentionDrops
},
limitations: [
"performance collection runs only while the performance panel is active",
"browser background throttling or freezing can create unfilled gaps",
"memory is page-level and cannot isolate this userscript",
"cross-origin Resource Timing fields can be zero without Timing-Allow-Origin",
"CPU, GC, GPU, FPS, server logs, payloads, and unsupported browser entry types are unavailable"
]
});
}
#recordRequestRuntimeState(at, scheduler, permit, source) {
const signature = JSON.stringify({ scheduler, permit }), previous = this.#requestRuntimeStates.at(-1);
if (previous && JSON.stringify({
scheduler: previous.scheduler,
permit: previous.permit
}) === signature) {
this.#requestRuntimeStates[this.#requestRuntimeStates.length - 1] = Object.freeze({
...previous,
lastObservedAt: at,
observations: previous.observations + 1,
lastSource: source
});
return;
}
if (this.#requestRuntimeStates.push(Object.freeze({
at,
lastObservedAt: at,
observations: 1,
source,
lastSource: source,
scheduler,
permit
})), this.#requestRuntimeStates.length > MAX_REQUEST_RUNTIME_STATES) {
const overflow = this.#requestRuntimeStates.length - MAX_REQUEST_RUNTIME_STATES;
this.#requestRuntimeStates.splice(0, overflow), this.#requestRuntimeOverflowDrops += overflow;
}
}
async #saveDiagnosticLog(file) {
if (this.#options.saveLog) {
await this.#options.saveLog(file);
return;
}
const document = this.#options.document, view = document.defaultView, urlApi = view?.URL ?? globalThis.URL;
if (typeof urlApi.createObjectURL != "function")
throw new Error("当前浏览器不支持本地日志下载");
const BlobConstructor = view?.Blob ?? globalThis.Blob, source = urlApi.createObjectURL(new BlobConstructor([file.text], {
type: file.mimeType
})), link = document.createElement("a");
link.href = source, link.download = file.filename, link.hidden = !0, (document.body ?? this.#root).append(link), link.click(), link.remove();
const timer = setTimeout(() => urlApi.revokeObjectURL(source), 6e4);
this.scope.timer(timer);
}
get active() {
return this.#activeScope !== null;
}
get samples() {
return Object.freeze([...this.#samples]);
}
start(panel = this.#selectedPanel) {
if (this.scope.destroyed || this.#activeScope && this.#activePanel === panel) return;
this.#activeScope && this.stop(), this.#selectedPanel = panel, this.#activePanel = panel, this.#memoryMeasuring = !1, this.#lastMemoryAt = 0, this.#baselineAt = this.#now(), this.#prune(this.#baselineAt);
const active = this.scope.child();
this.#activeScope = active, panel === "performance" && (this.#recordVisibility("开始取证"), active.listen(this.#options.document, "visibilitychange", () => {
this.#recordVisibility("页面可见状态变化");
}), this.requests.changes.subscribe((snapshot) => {
for (const event of snapshot.events)
this.#requestVisibility.has(event.id) || this.#requestVisibility.set(event.id, this.#visibility());
}, active));
const performance = this.#options.performance ?? this.#options.document.defaultView?.performance ?? null, installResourceObservation = (scope) => {
if (!performance) {
this.#resourceObservationCapability = "unavailable";
return;
}
const installed = new import_browser_request_observation.BrowserResourceObservationAdapter({
observer: this.requests,
performance,
...this.#options.createPerformanceObserver ? {
createObserver: this.#options.createPerformanceObserver
} : {}
}).install(scope);
this.#resourceObservationCapability = installed ? "available" : "unavailable";
}, installSampler = (scope) => {
this.#sample();
const timer = setInterval(
() => void this.#sample(),
Math.max(250, this.#options.sampleIntervalMs ?? 1e3)
);
scope.timer(timer, clearInterval);
};
if (panel === "request") {
let capture = null;
const syncCapture = () => {
if (capture?.destroy(), capture = null, this.#visibility() === "hidden") return;
const currentCapture = active.child();
capture = currentCapture, installResourceObservation(currentCapture);
let refreshQueued = !1;
this.requests.changes.subscribe(() => {
refreshQueued || (refreshQueued = !0, queueMicrotask(() => {
refreshQueued = !1, !(currentCapture.destroyed || this.#activeScope !== active || this.#activePanel !== "request" || this.#visibility() === "hidden") && this.#sample();
}));
}, currentCapture), installSampler(currentCapture);
};
active.add(() => {
capture?.destroy(), capture = null;
}), active.listen(
this.#options.document,
"visibilitychange",
syncCapture
), syncCapture();
return;
}
installResourceObservation(active), this.#longTaskCapability = this.#observePerformance(active, "longtask") ? "available" : "unavailable", this.#longAnimationFrameCapability = this.#observePerformance(
active,
"long-animation-frame"
) ? "available" : "unavailable";
const mutationCapabilities = this.#observeMutations(active);
this.#readerMutationCapability = mutationCapabilities.reader ? "available" : "unavailable", this.#hostMutationCapability = mutationCapabilities.host ? "available" : "unavailable", this.#healthState.textContent = "建立基线", this.#healthDetail.textContent = "连续取得 10 个真实快照后开始判断资源压力。", installSampler(active);
}
stop() {
const active = this.#activeScope;
if (!active) return;
if (this.#activePanel === "performance") {
const at = this.#now();
this.#pushEvidence({
at,
visibility: this.#visibilityAt(at),
scope: "shared",
kind: "capture",
duration: 0,
detail: "资源取证暂停",
basis: "监控面板生命周期"
}), this.#visibilityTimeline.push({ at, state: "stopped" });
}
this.#activeScope = null;
const panel = this.#activePanel;
this.#activePanel = null, active.destroy(), panel === "performance" && (this.#healthState.textContent = "采集已暂停", this.#healthDetail.textContent = "监控面板已离开或关闭;observer 与计时器均已释放。");
}
sampleNow() {
return this.#sample();
}
destroy() {
this.scope.destroy();
}
#observePerformance(scope, type) {
const factory = this.#options.createPerformanceObserver ?? ((callback) => {
const Observer = this.#options.document.defaultView?.PerformanceObserver;
if (!Observer) throw new Error("PerformanceObserver unavailable");
return new Observer(callback);
});
let observer = null;
try {
observer = factory((list) => {
const at = this.#now(), timeOrigin = Number(
(this.#options.performance ?? this.#options.document.defaultView?.performance)?.timeOrigin
);
for (const entry of list.getEntries()) {
const startTime = Number(entry.startTime), duration = Math.max(0, Number(entry.duration) || 0), eventAt = Number.isFinite(timeOrigin) && Number.isFinite(startTime) ? timeOrigin + startTime : at - duration;
eventAt < this.#baselineAt || (this.#pushEvidence({
at: eventAt,
kind: type,
duration,
visibility: this.#visibilityAt(eventAt),
scope: "shared",
detail: type === "longtask" ? `页面共享主线程长任务 ${formatDuration(duration)}` : `长动画帧 ${formatDuration(duration)}`,
basis: type === "longtask" ? "PerformanceLongTaskTiming" : "Long Animation Frames"
}), type === "long-animation-frame" && this.#recordFrameScripts(entry, eventAt));
}
this.#prune(at);
}), observer.observe({ type, buffered: !0 });
} catch {
return observer?.disconnect(), !1;
}
const installed = observer;
return scope.add(() => installed.disconnect()), !0;
}
#observeMutations(scope) {
const factory = this.#options.createMutationObserver ?? ((callback) => {
const Observer = this.#options.document.defaultView?.MutationObserver;
if (!Observer) throw new Error("MutationObserver unavailable");
return new Observer(callback);
}), install = (target, evidenceScope) => {
let observer = null;
try {
observer = factory((records) => {
let added = 0, removed = 0;
const readerDocumentRoot = this.#readerDocumentRoot();
for (const record of records)
evidenceScope === "host" && readerDocumentRoot?.contains(record.target) || (added += this.#elementCount(
record.addedNodes,
evidenceScope === "host"
), removed += this.#elementCount(
record.removedNodes,
evidenceScope === "host"
));
if (!added && !removed) return;
const at = this.#now();
this.#pushEvidence({
at,
visibility: this.#visibilityAt(at),
scope: evidenceScope,
kind: "dom",
duration: 0,
added,
removed,
detail: `页面元素 +${added} / −${removed} · ${records.length} 个变更记录`,
basis: "MutationObserver 回调"
});
}), observer.observe(target, { childList: !0, subtree: !0 });
} catch {
return observer?.disconnect(), !1;
}
const installed = observer;
return scope.add(() => installed.disconnect()), !0;
}, reader = install(this.#options.readerRoot, "reader"), hostRoot = this.#options.document.documentElement, host = hostRoot ? install(hostRoot, "host") : !1;
return Object.freeze({ reader, host });
}
#readerDocumentRoot() {
const shadowHost = this.#options.readerRoot.getRootNode().host;
return shadowHost?.nodeType === 1 ? shadowHost : this.#options.readerRoot;
}
#elementTreeCount(element) {
return 1 + element.querySelectorAll("*").length;
}
#elementCount(nodes, excludeReaderRoot = !1) {
let total = 0;
const readerDocumentRoot = excludeReaderRoot ? this.#readerDocumentRoot() : null;
for (const node of Array.from(nodes)) {
if (node.nodeType !== 1) continue;
const element = node;
let count = this.#elementTreeCount(element);
if (readerDocumentRoot) {
if (element === readerDocumentRoot) continue;
element.contains(readerDocumentRoot) && (count -= this.#elementTreeCount(readerDocumentRoot));
}
total += Math.max(0, count);
}
return total;
}
#hostDomCount(readerDom) {
const documentRoot = this.#options.document.documentElement;
if (!documentRoot) return 0;
const total = this.#elementTreeCount(documentRoot), readerDocumentRoot = this.#readerDocumentRoot(), readerDocumentElements = readerDocumentRoot && documentRoot.contains(readerDocumentRoot) ? this.#elementTreeCount(readerDocumentRoot) : this.#options.readerRoot.getRootNode() === this.#options.document ? readerDom : 0;
return Math.max(0, total - readerDocumentElements);
}
#recordFrameScripts(entry, at) {
const scripts = entry.scripts ?? [], groups = /* @__PURE__ */ new Map();
for (const script of scripts) {
const duration = Math.max(0, Number(script.duration) || 0);
if (!duration) continue;
const evidenceScope = this.#scriptScope(script.sourceURL), group = groups.get(evidenceScope) ?? {
duration: 0,
forced: 0,
count: 0,
labels: []
};
group.duration += duration, group.forced += Math.max(
0,
Number(script.forcedStyleAndLayoutDuration) || 0
), group.count += 1;
const label = performanceScriptLabel(
script.sourceFunctionName,
script.sourceURL,
this.#options.document.baseURI
);
group.labels.length < 2 && group.labels.push(label), groups.set(evidenceScope, group);
}
for (const [evidenceScope, group] of groups)
this.#pushEvidence({
at,
visibility: this.#visibilityAt(at),
scope: evidenceScope,
kind: "script",
duration: group.duration,
detail: `长帧内已归因脚本 ${formatDuration(group.duration)}${group.forced ? ` · 强制布局 ${formatDuration(group.forced)}` : ""} · ${group.labels.join(" / ")}`,
basis: `PerformanceScriptTiming · ${group.count} 段`
});
}
#scriptScope(sourceUrl) {
const source = String(sourceUrl ?? "");
if (/Awesome LinuxDo Reader|main-lite|mian-lite|katex@0\.16\.22|pinyin-pro@3\.18\.2|hls\.js@1\.6\.16/i.test(source)) return "reader";
try {
const url = new URL(source, this.#options.document.baseURI), page = new URL(this.#options.document.baseURI);
if (url.origin === page.origin || /(?:^|\.)linux\.do$/i.test(url.hostname) || /(?:^|\.)ldstatic\.com$/i.test(url.hostname)) return "host";
} catch {
}
return "shared";
}
#recordVisibility(reason) {
const at = this.#now(), state = this.#visibility();
this.#visibilityTimeline.at(-1)?.state !== state && (this.#visibilityTimeline.push({ at, state }), this.#pushEvidence({
at,
visibility: state,
scope: "shared",
kind: "visibility",
duration: 0,
detail: `${reason} · 当前${state === "visible" ? "前台" : "后台"}`,
basis: "Page Visibility"
}));
}
#visibilityAt(at) {
for (let index = this.#visibilityTimeline.length - 1; index >= 0; index -= 1) {
const marker = this.#visibilityTimeline[index];
if (!(marker.at > at))
return marker.state === "stopped" ? "unknown" : marker.state;
}
return "unknown";
}
#pushEvidence(event) {
if (this.#performanceEvents.push(Object.freeze(event)), this.#performanceEvents.length > MAX_EVIDENCE_EVENTS) {
const overflow = this.#performanceEvents.length - MAX_EVIDENCE_EVENTS;
this.#performanceEvents.splice(
0,
overflow
), this.#evidenceOverflowDrops += overflow;
}
this.#prune(event.at);
}
async #sample() {
const active = this.#activeScope;
if (!active || active.destroyed) return;
const panel = this.#activePanel;
if (!panel || panel === "request" && this.#visibility() === "hidden") return;
const at = this.#now();
this.#prune(at), panel === "performance" && this.#measureMemory(at, active);
const recentPerformance = this.#performanceEvents.filter(
(event) => event.at >= at - 1e4 && (event.kind === "longtask" || event.kind === "long-animation-frame")
), requestEvents = this.requests.snapshot.events.filter(
(event) => event.phase !== "queued" && !event.controlReason && event.startedAt >= Math.max(at - 6e4, this.#baselineAt)
), scheduler = this.#options.schedulerSnapshot();
let permit = null;
try {
permit = await this.#options.permitSnapshot();
} catch {
}
if (this.#activeScope !== active || active.destroyed) return;
this.#recordRequestRuntimeState(
at,
schedulerDiagnosticRecord(scheduler),
permitDiagnosticRecord(permit),
panel
);
const topic = this.#options.topicSnapshot(), readerDom = panel === "performance" ? this.#options.readerRoot.querySelectorAll("*").length + 1 : 0, sample = Object.freeze({
at,
visibility: this.#visibility(),
heapBytes: this.#heapBytes,
longTasks: recentPerformance.filter(
(event) => event.kind === "longtask"
).length,
longTaskDuration: recentPerformance.filter(
(event) => event.kind === "longtask"
).reduce(
(sum, event) => sum + event.duration,
0
),
longFrames: recentPerformance.filter(
(event) => event.kind === "long-animation-frame"
).length,
readerDom,
hostDom: panel === "performance" ? this.#hostDomCount(readerDom) : 0,
...topic,
activeRequests: scheduler?.active ?? 0,
queuedRequests: scheduler?.queued ?? 0,
requestMaxConcurrent: scheduler?.maxConcurrent ?? 0,
requestQueueLimit: scheduler?.queueLimit ?? 0,
requestActiveByLane: scheduler?.activeByLane ?? EMPTY_REQUEST_LANE_COUNTS,
requestQueuedByLane: scheduler?.queuedByLane ?? EMPTY_REQUEST_LANE_COUNTS,
sharedActiveRequests: permit?.active ?? 0,
sharedQueuedRequests: permit?.queued ?? 0,
sharedMaxConcurrent: permit?.maxConcurrent ?? 0,
sharedMinIntervalMs: permit?.minIntervalMs ?? 0,
sharedInstances: permit?.instances ?? 0,
sharedNextPermitDelay: permit?.nextPermitDelay ?? 0,
sharedBlockingReason: permit?.blockingReason ?? "",
sharedCoordinationMode: permit?.coordinationMode ?? "unavailable",
shortWindowCount: permit?.shortCount ?? 0,
shortWindowBudget: permit?.shortBudget ?? 0,
longWindowCount: permit?.longCount ?? 0,
longWindowBudget: permit?.longBudget ?? 0,
challengeState: permit?.challengeState ?? "idle",
challengeOwned: permit?.challengeOwned ?? !1,
networkRequests: requestEvents.length,
networkBytes: requestEvents.reduce(
(sum, event) => sum + event.size,
0
)
});
if (panel === "performance") {
const previous = this.#samples.at(-1);
if (previous && at - previous.at > 1800) {
const crossedStopped = this.#visibilityTimeline.some((marker) => marker.at > previous.at && marker.at <= at && marker.state === "stopped"), crossedHidden = previous.visibility === "hidden" || this.#visibilityTimeline.some((marker) => marker.at > previous.at && marker.at <= at && marker.state === "hidden");
this.#pushEvidence({
at,
visibility: crossedStopped ? "unknown" : crossedHidden ? "hidden" : this.#visibilityAt(at),
scope: "shared",
kind: "gap",
duration: at - previous.at,
detail: `快照空档 ${formatDuration(at - previous.at)}${crossedStopped ? "(包含取证暂停)" : crossedHidden ? "(包含后台节流或冻结)" : ""};未补造中间样本`,
basis: "High Resolution Time"
});
}
this.#samples.push(sample), this.#prune(at), this.#renderPerformance(sample, requestEvents);
} else
this.#renderRequests(sample, this.requests.snapshot.events);
}
#measureMemory(at, active) {
if (this.#memoryMeasuring || at - this.#lastMemoryAt < 1e4) return;
this.#lastMemoryAt = at;
const performance = this.#options.performance ?? this.#options.document.defaultView?.performance, fallback = Number(performance?.memory?.usedJSHeapSize);
Number.isFinite(fallback) && fallback >= 0 && (this.#heapBytes = fallback), performance?.measureUserAgentSpecificMemory && (this.#memoryMeasuring = !0, performance.measureUserAgentSpecificMemory().then((result) => {
if (this.#activeScope !== active || active.destroyed) return;
const bytes = Number(result.bytes);
Number.isFinite(bytes) && bytes >= 0 && (this.#heapBytes = bytes);
}).catch(() => {
}).finally(() => {
this.#activeScope === active && (this.#memoryMeasuring = !1);
}));
}
#renderPerformance(current, requestEvents) {
const samples = this.#samples;
for (const metric of METRICS) {
const row = this.#rows.get(metric.id), currentValues = metric.values(current), average = aggregate(samples, metric, "average"), peak = aggregate(samples, metric, "peak"), first2 = metric.values(samples[0] ?? current), trend = currentValues.map((value, index) => value === null || first2[index] === null ? null : value - first2[index]);
row.querySelector(
"[data-resource-monitor-metric]"
).textContent = metric.format(currentValues), row.querySelector(
"[data-resource-monitor-average]"
).textContent = metric.format(average), row.querySelector(
"[data-resource-monitor-peak]"
).textContent = metric.format(peak), row.querySelector(
"[data-resource-monitor-trend]"
).textContent = trend.every((value) => value === null) ? "—" : trend.map(
(value) => value === null ? "—" : `${value >= 0 ? "+" : ""}${Math.round(value)}`
).join(" / ");
}
const captureSamples = samples.filter(
(sample) => sample.at >= this.#baselineAt
), baselineSamples = captureSamples.filter(
(sample) => sample.at >= current.at - 12e3
), first = captureSamples.filter(
(sample) => sample.at >= current.at - 6e4
)[0] ?? current, domGrowth = current.readerDom - first.readerDom, retainedFloorGrowth = current.retainedFloors - first.retainedFloors, warnings = [];
let level = "normal";
current.longTaskDuration >= 2e3 ? (level = "danger", warnings.push(`近 10 秒主线程阻塞 ${Math.round(
current.longTaskDuration
)} ms`)) : current.longTaskDuration >= 500 && (level = "warning", warnings.push(`近 10 秒主线程阻塞 ${Math.round(
current.longTaskDuration
)} ms`)), current.queuedRequests >= 10 ? (level = "danger", warnings.push(`中央请求队列 ${current.queuedRequests} 条`)) : current.queuedRequests >= 3 && (level === "normal" && (level = "warning"), warnings.push(`中央请求队列 ${current.queuedRequests} 条`)), domGrowth >= 1e3 && retainedFloorGrowth >= 50 && (level === "normal" && (level = "warning"), warnings.push(
`近一分钟阅读器页面元素增加 ${domGrowth} 个,保留楼层增加 ${retainedFloorGrowth} 个`
));
const establishing = baselineSamples.length < 10;
this.#health.dataset.level = establishing ? "normal" : level, this.#healthState.textContent = establishing ? "建立基线" : warnings.length ? level === "danger" ? "资源压力高" : "需要关注" : "采样正常", this.#healthDetail.textContent = establishing ? `最近 12 秒已取得 ${baselineSamples.length}/10 个真实快照。` : warnings.length ? `${warnings.join(";")}。继续观察趋势,回落后会自动恢复。` : "未发现阅读器页面元素快速膨胀、页面共享主线程卡顿或阅读器请求积压;内存估计不参与自动判定。", this.#updated.textContent = `最近快照 ${new Date(current.at).toLocaleTimeString()} · ${current.visibility === "visible" ? "前台" : "后台"} · 仅内存保留`;
const performancePolicy = this.#options.performancePolicySnapshot?.() ?? null;
this.#performancePolicy.textContent = performancePolicy ? `已含设备与网络自适应:正文批次 ${performancePolicy.pageSize} 楼 · DOM 最多 ${performancePolicy.streamMaxMountedPostCount} 楼 · 屏外预留 ${formatPolicyNumber(performancePolicy.streamOverscanScreens)} 屏 · API 提前 ${formatPolicyNumber(performancePolicy.nestedPrefetchScreens)} 屏 · 本页请求策略上限 ${performancePolicy.requestMaxConcurrent} 路 / ${performancePolicy.requestMinIntervalMs}ms · 窗口目标 ${performancePolicy.requestRateTargetPercent}%;跨标签与服务器实时约束见请求记录。` : "当前运行环境未提供性能策略快照;下方仍显示实际 DOM、请求与主线程记录。", this.#renderTrendCharts(current), this.#renderEvidence(current, requestEvents);
}
#renderTrendCharts(current) {
const rows = {
heapUsed: {
value: (sample) => sample.heapBytes,
current: current.heapBytes === null ? "浏览器未提供" : formatBytes(current.heapBytes)
},
dom: {
value: (sample) => sample.readerDom,
current: `${current.readerDom} 个`
},
retainedFloors: {
value: (sample) => sample.retainedFloors,
current: `${current.retainedFloors} 个`
}
};
for (const [key, definition] of Object.entries(rows)) {
const row = this.#trendRows.get(key);
row.querySelector("polyline")?.setAttribute(
"points",
chartPoints(this.#samples, definition.value)
), row.querySelector(
"[data-resource-monitor-chart-current]"
).textContent = definition.current;
}
const covered = this.#samples.length > 1 ? this.#samples.at(-1).at - this.#samples[0].at : 0, maximumGap = this.#samples.slice(1).reduce(
(maximum, sample, index) => Math.max(maximum, sample.at - this.#samples[index].at),
0
), visible = this.#samples.filter(
(sample) => sample.visibility === "visible"
).length, hidden = this.#samples.length - visible;
this.#trendWindow.textContent = `${covered >= 6e4 ? `${(covered / 6e4).toFixed(1)} 分钟` : `${Math.round(covered / 1e3)} 秒`} · 前 ${visible} / 后 ${hidden}${maximumGap > 1800 ? ` · 最大空档 ${formatDuration(maximumGap)}` : ""}`;
}
#renderEvidence(current, requestEvents) {
const createCell = () => ({
requests: 0,
requestDuration: 0,
bytes: 0,
scripts: 0,
longTasks: 0,
longTaskDuration: 0,
domChanges: 0
}), scopes = {
reader: { visible: createCell(), hidden: createCell() },
host: { visible: createCell(), hidden: createCell() },
shared: { visible: createCell(), hidden: createCell() }
}, cutoff = current.at - EVIDENCE_WINDOW_MS, requests = requestEvents.map((event) => ({
at: event.startedAt,
kind: "request",
visibility: this.#requestVisibility.get(event.id) ?? "unknown",
scope: event.source === "reader" ? "reader" : event.source === "host" ? "host" : "shared",
duration: event.pending ? Math.max(0, current.at - event.startedAt) : event.duration,
bytes: event.size,
diagnostic: requestContractDiagnostic(event),
event
}));
for (const request of requests) {
if (request.at < cutoff || request.at > current.at || request.visibility === "unknown") continue;
const cell = scopes[request.scope][request.visibility];
cell.requests += 1, cell.requestDuration += request.duration, cell.bytes += request.bytes;
}
for (const event of this.#performanceEvents) {
if (event.at < cutoff || event.at > current.at || event.visibility === "unknown") continue;
const cell = scopes[event.scope][event.visibility];
event.kind === "script" && (cell.scripts += event.duration), event.kind === "longtask" && (cell.longTasks += 1, cell.longTaskDuration += event.duration), event.kind === "dom" && (cell.domChanges += (event.added ?? 0) + (event.removed ?? 0));
}
const cellLabel = (cell, evidenceScope) => {
const parts = [];
return cell.requests && parts.push(
`请求 ${cell.requests} / ${formatDuration(cell.requestDuration)}${cell.bytes ? ` / ${formatBytes(cell.bytes)}` : ""}`
), cell.scripts && parts.push(`已归因脚本 ${formatDuration(cell.scripts)}`), evidenceScope === "shared" && cell.longTasks && parts.push(
`长任务 ${cell.longTasks} / ${formatDuration(cell.longTaskDuration)}`
), cell.domChanges && parts.push(`页面元素变更 ${cell.domChanges}`), parts.join(" · ") || "—";
}, currentLabels = {
reader: `${current.readerDom} 个页面元素 · ${current.retainedFloors} 个楼层`,
host: `${current.hostDom} 个页面元素`,
shared: current.heapBytes === null ? "浏览器未提供" : formatBytes(current.heapBytes)
};
for (const evidenceScope of ["reader", "host", "shared"]) {
const row = this.#scopeRows.get(evidenceScope);
row.querySelector(
"[data-resource-monitor-scope-current]"
).textContent = currentLabels[evidenceScope], row.querySelector(
"[data-resource-monitor-scope-visible]"
).textContent = cellLabel(scopes[evidenceScope].visible, evidenceScope), row.querySelector(
"[data-resource-monitor-scope-hidden]"
).textContent = cellLabel(scopes[evidenceScope].hidden, evidenceScope);
}
const events = [
...requests.map((request) => ({
at: request.at,
kind: request.kind,
visibility: request.visibility,
scope: request.scope,
duration: request.duration,
detail: `${request.event.method} ${requestDisplayPath(request.event)} · ${requestStatus(request.event)} · ${formatDuration(request.duration)}${request.bytes ? ` · ${formatBytes(request.bytes)}` : ""}${request.diagnostic ? ` · ${request.diagnostic}` : ""}`,
basis: request.event.resourceTimed ? "PerformanceResourceTiming" : request.scope === "reader" ? "显式 reader 元数据" : request.scope === "host" ? "未标记宿主请求" : "Resource Timing"
})),
...this.#performanceEvents
].filter((event) => event.at >= current.at - RETENTION_MS && event.at <= current.at).sort((left, right) => right.at - left.at).slice(0, 48), recent = events.filter((event) => event.at >= cutoff), visibleCount = recent.filter(
(event) => event.visibility === "visible"
).length, hiddenCount = recent.filter(
(event) => event.visibility === "hidden"
).length, unknownCount = recent.length - visibleCount - hiddenCount;
if (this.#evidenceWindow.textContent = `${current.visibility === "visible" ? "当前前台" : "当前后台"} · 前 ${visibleCount} / 后 ${hiddenCount}${unknownCount ? ` / 未归档 ${unknownCount}` : ""} 个原始事件`, this.#eventLog.replaceChildren(...events.map((event) => {
const row = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-resource-monitor-event-row"
);
row.dataset.performanceEventKind = event.kind, row.dataset.performanceEventScope = event.scope, row.dataset.performanceEventVisibility = event.visibility;
const time = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "time");
time.dateTime = new Date(event.at).toISOString(), time.textContent = formatRequestTimestamp(event.at);
const visibility = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-resource-monitor-event-state"
);
visibility.dataset.visibility = event.visibility, visibility.textContent = event.visibility === "visible" ? "前台" : event.visibility === "hidden" ? "后台" : "未知";
const scope = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-resource-monitor-event-scope"
);
scope.textContent = event.scope === "reader" ? "阅读器" : event.scope === "host" ? "原站" : "页面共享";
const kind = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-resource-monitor-event-kind"
);
kind.textContent = PERFORMANCE_EVENT_LABELS[event.kind] ?? event.kind;
const metric = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-resource-monitor-event-metric"
);
metric.textContent = performanceEventMetric(event);
const detail = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-resource-monitor-event-detail"
), detailText = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"strong",
"ldp-resource-monitor-event-copy"
);
detailText.textContent = event.detail;
const eventBasis = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"small",
"ldp-resource-monitor-event-basis"
);
return eventBasis.textContent = event.basis, detail.append(detailText, eventBasis), row.append(time, visibility, scope, kind, metric, detail), row;
})), !events.length) {
const empty = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-resource-monitor-event-empty"
);
empty.textContent = "等待浏览器性能、网络资源、页面元素或前后台切换事件。", this.#eventLog.append(empty);
}
}
#renderRequests(current, events) {
const at = current.at, sent60 = events.filter(
(event) => (event.endedAt || event.queuedAt) >= at - 6e4
).filter(
(event) => event.phase !== "queued" && !event.controlReason
), recent10 = sent60.filter(
(event) => event.startedAt >= at - REQUEST_TRACE_MS
), issues = events.filter(
(event) => (event.endedAt || event.startedAt) >= at - 6e4
).map((event) => ({ event, issue: requestIssue(event, at) })).filter((entry) => entry.issue !== null), bucketStart = Math.ceil(at / 100) * 100 - REQUEST_TRACE_MS, buckets = Array.from({ length: REQUEST_TRACE_MS / 100 }, () => 0);
for (const event of recent10) {
const bucket = Math.floor((event.startedAt - bucketStart) / 100);
bucket >= 0 && bucket < buckets.length && (buckets[bucket] += 1);
}
const transfer = sent60.reduce(
(total, event) => total + event.size,
0
), metricValues = {
rate10: `${recent10.length} 次 · ${(recent10.length / 10).toFixed(1)}/秒`,
rate60: `${sent60.length} 次 · ${(sent60.length / 60).toFixed(1)}/秒`,
peak: `${Math.max(0, ...buckets)} 次/100ms`,
transfer: formatBytes(transfer),
issues: `${issues.length} 次${issues.some(({ event }) => event.status === 429) ? ` · 429 ${issues.filter(({ event }) => event.status === 429).length}` : ""}`
};
for (const [id, value] of Object.entries(metricValues)) {
const metric = this.#requestMetrics.get(id);
metric && (metric.textContent = value);
}
const queued = recent10.filter(
(event) => event.permitWait > 0.5
).length, liveQueued = events.filter(
(event) => event.phase === "queued"
).length, liveRunning = events.filter(
(event) => event.phase === "running"
).length, laneStatus = [
["交互", "user-card"],
["控制", "control"],
["树状", "nested-replies"],
["正文批次", "topic-batch"],
["翻译", "translation"],
["其他", "standard"]
].map(
([label, lane]) => `${label} ${current.requestActiveByLane[lane]}/${current.requestQueuedByLane[lane]}`
).join(" · ");
this.#requestWindow.textContent = `近 10 秒 ${recent10.length} 已发出 · 排队 ${queued} · 实时 ${liveRunning} 运行 · ${liveQueued} 排队 · 本页生效槽 ${current.activeRequests}/${current.requestMaxConcurrent} · 队列 ${current.queuedRequests}/${current.requestQueueLimit} · 规则 后台正文单槽 · 总预算允许时可见缺口可用第 2 正文槽 · 树状最多 2 槽 · 车道运行/排队:${laneStatus} · 跨标签共享 ${current.sharedActiveRequests} 运行 / ${current.sharedQueuedRequests} 排队 · 实例 ${current.sharedInstances || 1} · 共享生效上限 ${current.sharedMaxConcurrent} · 生效间隔 ${current.sharedMinIntervalMs}ms${current.sharedCoordinationMode === "best-effort" ? "(协调降级)" : ""} · 额度 ${current.shortWindowCount}/${current.shortWindowBudget}(10秒)· ${current.longWindowCount}/${current.longWindowBudget}(60秒)${current.sharedBlockingReason ? ` · ${REQUEST_WAIT_REASON_LABELS[current.sharedBlockingReason] ?? current.sharedBlockingReason}` + (current.sharedNextPermitDelay > 0 ? ` ${formatDuration(current.sharedNextPermitDelay)}` : "") : ""}${current.challengeState === "required" ? " · 等待人工验证" : current.challengeState === "active" ? ` · 验证中${current.challengeOwned ? "(本页)" : "(其他页)"}` : current.challengeState === "passed" ? " · 验证已通过" : ""}`;
const traceEvents = events.filter((event) => {
const lifecycleStart = event.queuedAt || event.startedAt, lifecycleEnd = event.pending ? at : event.endedAt || event.startedAt;
return lifecycleStart <= at && lifecycleEnd >= at - REQUEST_TRACE_MS;
});
this.#renderRequestTrace(traceEvents, at), this.#renderRequestTypes(sent60, at), this.#renderRequestIssues(issues, at), this.#renderRequestLog(events, at), this.#renderTopicDiagnostics(current, issues), this.#renderMediaDiagnostics(current, issues);
const completedDurations = sent60.filter(
(event) => !event.pending && !["realtime", "presence"].includes(event.type) && event.duration > 0
).map((event) => event.duration), p95 = percentile95(completedDurations), active = events.filter(
(event) => event.phase === "running"
).length, resourceCount = sent60.filter(
(event) => ["avatar", "media", "asset"].includes(event.type)
).length, latestLimit = [...events].reverse().find(
(event) => event.status === 429 || !!event.retryAfter || !!event.rateLimitCode || !!event.serverLimit
);
if (latestLimit?.status === 429) {
const details = [
latestLimit.rateLimitCode ? `错误码 ${latestLimit.rateLimitCode}` : "",
latestLimit.retryAfter ? `Retry-After ${latestLimit.retryAfter} 秒` : ""
].filter(Boolean).join(",");
this.#requestObserved.textContent = `本页最近观测:${REQUEST_TYPE_LABELS[latestLimit.type] ?? "请求"} ${latestLimit.method} ${requestDisplayPath(latestLimit)} 收到 ${latestLimit.cloudflareMitigated ? "Cloudflare challenge 429" : "429"}${details ? `(${details})` : ""}${latestLimit.decision ? `;决策 ${requestDecisionLabel(latestLimit.decision)}` : ""}。`;
} else latestLimit?.serverLimit ? this.#requestObserved.textContent = `服务器最近返回:上限 ${latestLimit.serverLimit}${latestLimit.serverRemaining ? `,剩余 ${latestLimit.serverRemaining}` : ""}${latestLimit.serverReset ? `,重置 ${latestLimit.serverReset}` : ""}。` : this.#requestObserved.textContent = `当前未收到 429 或服务器上限头;所有实例共同使用 ${current.shortWindowBudget} 次/10 秒、${current.longWindowBudget} 次/分钟、并发 ${current.sharedMaxConcurrent} 路(${current.sharedInstances || 1} 个实例)。`;
const danger = [...issues].reverse().find(
({ issue }) => issue.level === "danger"
);
if (danger) {
this.#requestBottleneck.dataset.level = "danger", this.#requestBottleneckState.textContent = danger.issue.label;
const caller = danger.event.callSite ? `;发起点 ${danger.event.callSite}` : "", diagnostic = requestContractDiagnostic(danger.event);
this.#requestBottleneckDetail.textContent = `${REQUEST_TYPE_LABELS[danger.event.type] ?? danger.event.type} ${danger.event.method} ${requestDisplayPath(danger.event)}:${danger.issue.detail}${caller}${diagnostic ? `;${diagnostic}` : ""}${danger.event.retryAfter ? `;Retry-After ${danger.event.retryAfter} 秒` : ""}。`;
} else current.challengeState === "required" || current.challengeState === "active" || current.sharedNextPermitDelay > 0 || Math.max(current.queuedRequests, current.sharedQueuedRequests) >= 3 || completedDurations.length >= 5 && p95 >= REQUEST_SLOW_MS ? (this.#requestBottleneck.dataset.level = "warning", this.#requestBottleneckState.textContent = current.challengeState === "required" ? "等待人工验证" : current.challengeState === "active" ? "Cloudflare 验证中" : current.sharedNextPermitDelay > 0 ? REQUEST_WAIT_REASON_LABELS[current.sharedBlockingReason] ?? "共享许可等待" : Math.max(
current.queuedRequests,
current.sharedQueuedRequests
) >= 3 ? "请求排队" : "响应偏慢", this.#requestBottleneckDetail.textContent = `${current.challengeState === "required" ? "已暂停新的 Reader 请求,请点击唯一人工验证入口;" : current.challengeState === "active" ? current.challengeOwned ? "请在本页打开的验证窗口完成验证;" : "另一标签页正在处理唯一验证窗口;" : ""}${current.sharedNextPermitDelay > 0 ? `共享许可仍需等待 ${formatDuration(
current.sharedNextPermitDelay
)};` : ""}本页活动/排队 ${current.activeRequests}/${current.queuedRequests},共享活动/排队 ${current.sharedActiveRequests}/${current.sharedQueuedRequests};最近一分钟 P95 ${p95 ? formatDuration(p95) : "暂无"}。`) : sent60.length >= 12 && resourceCount / sent60.length >= 0.7 && transfer >= 4 * 1024 * 1024 ? (this.#requestBottleneck.dataset.level = "warning", this.#requestBottleneckState.textContent = "资源占用", this.#requestBottleneckDetail.textContent = `头像、图片和静态资源占最近一分钟请求的 ${Math.round(
resourceCount / sent60.length * 100
)}%,已传输 ${formatBytes(transfer)}。`) : liveQueued ? (this.#requestBottleneck.dataset.level = "normal", this.#requestBottleneckState.textContent = "等待放行", this.#requestBottleneckDetail.textContent = `${liveQueued} 条请求已进入中央队列,尚未占用网络连接;下方日志会同步显示放行、取消或完成结果。`) : sent60.length ? (this.#requestBottleneck.dataset.level = "normal", this.#requestBottleneckState.textContent = "节奏正常", this.#requestBottleneckDetail.textContent = `最近一分钟 ${sent60.length} 次请求,${active} 运行、${liveQueued} 排队,P95 ${p95 ? formatDuration(p95) : "暂无"};未发现明显限流、错误或排队瓶颈。`) : (this.#requestBottleneck.dataset.level = "normal", this.#requestBottleneckState.textContent = "等待采样", this.#requestBottleneckDetail.textContent = "打开帖子并滚动后,这里会根据排队、响应耗时、传输量和 429 判断主要瓶颈。");
}
#renderTopicDiagnostics(current, issues) {
const topicLabel = current.topicId === null ? "尚未打开帖子" : `Topic #${current.topicId}`, virtualCount = Math.max(
0,
current.retainedFloors - current.mountedFloors
), missingStreamFloors = Math.max(
0,
current.expectedFloors - current.streamFloors
), hasCoverageGap = missingStreamFloors > 0 || current.missingFloors > 0, networkIssue = [...issues].reverse().find(
({ event }) => event.status !== 429 && !event.controlReason
), rateLimited = issues.some(({ event }) => event.status === 429), rows = [
{
label: "缓存",
level: hasCoverageGap ? "warning" : "normal",
detail: current.topicId === null ? "没有活动 Topic,不存在需要清理的当前帖子缓存。" : missingStreamFloors > 0 ? `${topicLabel} 的 canonical stream 尚缺 ${missingStreamFloors} 个楼层索引(当前 ${current.streamFloors}/${current.expectedFloors});先等待同一 Topic stream 刷新,再按已取得索引补正文,不建议清缓存。` : current.missingFloors > 0 ? `${topicLabel} 的 canonical stream 仍缺 ${current.missingFloors} 条正文;由同一补流链继续请求,不建议先清缓存。` : current.initializedFromCache ? `${topicLabel} 从完整快照启动,已归并 ${current.retainedFloors}/${current.expectedFloors || current.streamFloors} 条正文。` : `${topicLabel} 已由网络与缓存归并,当前没有 stream 正文缺口。`
},
{
label: "虚拟窗口",
level: "normal",
detail: current.topicId === null ? "打开帖子后显示 canonical 保留量与当前 DOM 挂载量。" : virtualCount > 0 ? `当前挂载 ${current.mountedFloors}、已准备 ${current.preparedFloors}、canonical 保留 ${current.retainedFloors};其余 ${virtualCount} 条为离屏停放或惰性 DOM,不是楼层丢失。` : `当前 ${current.mountedFloors} 条 canonical 楼层均在挂载窗口内。`
},
{
label: "已删除楼层",
level: current.unavailableFloors.length ? "danger" : "normal",
detail: current.unavailableFloors.length ? `已由 404/410 确认不可用:${current.unavailableFloors.slice(0, 12).map((postNumber) => `#${postNumber}`).join("、")}${current.unavailableFloors.length > 12 ? " 等" : ""};当前会话不会重复请求。` : "当前会话没有被 404/410 明确判定为不可用的楼层。"
},
{
label: "网络",
level: networkIssue?.issue.level ?? "normal",
detail: networkIssue ? `${networkIssue.issue.label}:${networkIssue.event.method} ${requestDisplayPath(networkIssue.event)}。先检查原站和网络,错误请求会保留在下方记录。` : current.activeRequests || current.queuedRequests ? `本页活动/排队 ${current.activeRequests}/${current.queuedRequests},请求仍由中央调度器处理。` : "最近一分钟没有普通 HTTP、网络中止或慢响应异常。"
},
{
label: "429 / Cloudflare",
level: rateLimited || current.challengeState === "required" || current.challengeState === "active" ? "warning" : "normal",
detail: current.challengeState === "required" ? "后台请求已建立共享硬闸门,等待用户点击唯一 Cloudflare 验证入口;滚动和预取不会继续自动打开新页面。" : current.challengeState === "active" ? `唯一 Cloudflare 验证由${current.challengeOwned ? "本页" : "其他标签页"}处理;不要重复刷新或打开验证窗口。` : rateLimited ? "最近一分钟出现 429;同端点重复或跨端点证据会进入共享冷却,恢复时只放行一个探针。" : "当前未发现 429 或 Cloudflare 验证阻塞。"
}
];
this.#topicDiagnostics.replaceChildren(
...rows.map((diagnostic) => {
const row = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-request-flow-bottleneck"
);
row.dataset.level = diagnostic.level;
const label = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "strong");
label.textContent = diagnostic.label;
const detail = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "p");
return detail.textContent = diagnostic.detail, row.append(label, detail), row;
})
);
}
#renderMediaDiagnostics(current, issues) {
const media = current.mediaDiagnostics, mediaIssue = [...issues].reverse().find(
({ event }) => event.type === "media" && !event.controlReason
), hlsSupported = media.nativeHlsSources > 0 || media.hlsLibrarySupported, rows = [
{
label: "图片加载",
level: media.failedImages ? "danger" : "normal",
detail: media.failedImages ? `当前挂载楼层有 ${media.failedImages} 张图片失败${media.retryingImages ? `,其中 ${media.retryingImages} 张正在重试` : ""}${media.crossOriginFailures ? `;${media.crossOriginFailures} 张来自跨域地址` : ""}。失败楼层:${media.failedPostNumbers.slice(0, 12).map((postNumber) => `#${postNumber}`).join("、") || "未识别"}。` : `当前绑定 ${media.boundImages} 张正文图片,未发现可见重试入口。`
},
{
label: "资源缓存",
level: media.catalogFailedBatches ? "warning" : "normal",
detail: `图片目录 ${media.catalogImages} 项${media.catalogComplete ? "(全帖完整)" : media.catalogPending ? "(正在补齐)" : "(已加载范围)"};本会话 Object URL ${media.objectUrls}/${media.objectUrlLimit || 0};持久 Blob 缓存${media.persistentCacheEnabled ? "已启用" : "未配置"}${media.catalogFailedBatches ? `;全帖目录有 ${media.catalogFailedBatches} 个失败批次` : ""}。`
},
{
label: "来源楼层",
level: media.unavailableSourcePostNumbers.length ? "danger" : "normal",
detail: media.unavailableSourcePostNumbers.length ? `目录中的图片来源楼层已由 404/410 确认不可用:${media.unavailableSourcePostNumbers.slice(0, 12).map((postNumber) => `#${postNumber}`).join("、")};已有 CDN/缓存图片仍可继续显示,不重复请求楼层。` : "当前图片目录没有指向已确认删除楼层的来源。"
},
{
label: "音视频 / HLS",
level: mediaIssue?.issue.level ?? (media.hlsSources > 0 && !hlsSupported ? "warning" : "normal"),
detail: mediaIssue ? `${mediaIssue.issue.detail}:${requestDisplayPath(mediaIssue.event)}。浏览器资源错误可能来自 CDN、CORS、编码或源地址失效。` : media.hlsSources > 0 ? `当前挂载 ${media.hlsSources} 个 HLS 源、原生可播 ${media.nativeHlsSources} 个、活动 Hls.js 实例 ${media.activeHlsPlayers};${hlsSupported ? "原生或 Hls.js capability 可用" : media.hlsLibraryAvailable ? "Hls.js 已加载但当前浏览器报告不支持" : "未发现可用的原生或 Hls.js capability"}。离屏停放时播放器为 0 属于正常释放。` : "当前挂载楼层没有 HLS 源,也没有最近一分钟媒体请求异常。"
}
];
this.#mediaDiagnostics.replaceChildren(
...rows.map((diagnostic) => {
const row = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-request-flow-bottleneck"
);
row.dataset.level = diagnostic.level;
const label = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "strong");
label.textContent = diagnostic.label;
const detail = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "p");
return detail.textContent = diagnostic.detail, row.append(label, detail), row;
})
);
}
#renderRequestTrace(events, at) {
const windowStart = at - REQUEST_TRACE_MS, position = (value) => Math.max(
0,
Math.min(100, (value - windowStart) / REQUEST_TRACE_MS * 100)
), lanes = [
["reader", "阅读器"],
["host", "原站"],
["browser", "资源"]
];
this.#requestTrace.replaceChildren(...lanes.map(([source, label]) => {
const lane = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-request-flow-trace-lane"
);
lane.dataset.requestFlowSource = source;
const copy = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-trace-label"
);
copy.textContent = label;
const track = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-trace-track"
), layerEnds = [
Number.NEGATIVE_INFINITY,
Number.NEGATIVE_INFINITY,
Number.NEGATIVE_INFINITY
], sourceEvents = events.filter((event) => event.source === source).sort(
(left, right) => (left.queuedAt || left.startedAt) - (right.queuedAt || right.startedAt)
).slice(-80);
for (const event of sourceEvents) {
const rawLifecycleStart = event.queuedAt || event.startedAt, rawLifecycleEnd = event.pending ? at : event.endedAt || event.startedAt;
let layer = layerEnds.findIndex(
(end) => end <= rawLifecycleStart
);
layer < 0 && (layer = layerEnds.indexOf(Math.min(...layerEnds))), layerEnds[layer] = rawLifecycleEnd;
const top = 3 + layer * 10, lifecycleStart = Math.max(windowStart, rawLifecycleStart), queueEnd = Math.min(
at,
event.phase === "queued" ? at : event.permittedAt
), wireEnd = ["queued", "running"].includes(event.phase) ? at : Math.max(event.startedAt, event.endedAt), priority = requestPriorityLabel(event.priority), tooltip = [
`${formatRequestTimestamp(event.queuedAt)} · ${event.method} ${requestDisplayPath(event)}`,
`${source === "reader" ? "阅读器" : source === "host" ? "原站" : "资源"} / ${REQUEST_TYPE_LABELS[event.type] ?? event.type} / ${requestStatus(event)}`,
requestTimingLabel(event, at),
priority ? `${priority}优先级` : "",
event.attempt > 1 ? `第 ${event.attempt} 次尝试` : "",
event.callSite ? `发起点 ${event.callSite}` : "",
requestContractDiagnostic(event)
].filter(Boolean).join(" · ");
if (queueEnd > lifecycleStart + 0.5) {
const queue = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"i",
"ldp-request-flow-trace-queue"
);
if (queue.style.setProperty(
"--ldp-request-flow-left",
`${position(lifecycleStart).toFixed(3)}%`
), queue.style.setProperty(
"--ldp-request-flow-width",
`${Math.max(
0,
position(queueEnd) - position(lifecycleStart)
).toFixed(3)}%`
), queue.style.setProperty("--ldp-request-flow-top", `${top}px`), queue.dataset.ldpTooltipLabel = `${event.method} ${requestDisplayPath(event)} · ${formatRequestTimestamp(rawLifecycleStart)} → ${formatRequestTimestamp(queueEnd)} · ${requestWaitReasonLabel(event.waitReason)}排队 ` + formatDuration(queueEnd - rawLifecycleStart), track.append(queue), event.phase !== "queued" && !event.controlReason) {
const permit = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"i",
"ldp-request-flow-trace-permit"
);
permit.style.setProperty(
"--ldp-request-flow-left",
`${position(queueEnd).toFixed(3)}%`
), permit.style.setProperty(
"--ldp-request-flow-top",
`${top}px`
), permit.dataset.ldpTooltipLabel = `${formatRequestTimestamp(queueEnd)} · ${event.method} ${requestDisplayPath(event)} · 调度放行`, track.append(permit);
}
}
if (event.phase !== "queued" && !event.controlReason) {
const wire = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"i",
"ldp-request-flow-trace-wire"
);
wire.dataset.requestFlowType = event.type, wire.dataset.pending = String(event.pending), wire.style.setProperty(
"--ldp-request-flow-left",
`${position(Math.max(windowStart, event.startedAt)).toFixed(3)}%`
), wire.style.setProperty(
"--ldp-request-flow-width",
`${Math.max(
0,
position(wireEnd) - position(Math.max(windowStart, event.startedAt))
).toFixed(3)}%`
), wire.style.setProperty("--ldp-request-flow-top", `${top}px`), wire.dataset.ldpTooltipLabel = tooltip, track.append(wire);
}
const issue = requestIssue(event, at);
if (issue) {
const marker = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"i",
"ldp-request-flow-trace-anomaly"
);
marker.dataset.level = issue.level, marker.style.setProperty(
"--ldp-request-flow-left",
`${Math.max(
0.6,
Math.min(99.4, position(wireEnd))
).toFixed(3)}%`
), marker.style.setProperty(
"--ldp-request-flow-top",
`${top}px`
), marker.dataset.ldpTooltipLabel = `${formatRequestTimestamp(Math.min(at, Math.max(
windowStart,
rawLifecycleEnd
)))} · ${issue.label} · ${event.method} ${requestDisplayPath(event)} · ` + issue.detail, track.append(marker);
}
}
return lane.append(copy, track), lane;
}));
const semanticItems = [
["ldp-request-flow-queue-key", "list", "排队"],
["ldp-request-flow-warning-key", "history", "慢/久候"],
["ldp-request-flow-danger-key", "circle-x", "错误/限流"]
].map(([
className,
iconName,
copy
]) => {
const item = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "span"), key = (0, import_reader_icon.createReaderIcon)(
this.#options.document,
iconName,
`ldp-request-flow-key ${className}`
);
return item.append(key, this.#options.document.createTextNode(copy)), item;
}), types = [...new Set(events.map((event) => event.type))].slice(0, 8), typeItems = (types.length ? types : ["other"]).map((type) => {
const item = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "span");
item.dataset.requestFlowType = type;
const dot = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"i",
"ldp-request-flow-dot"
);
return item.append(
dot,
this.#options.document.createTextNode(
REQUEST_TYPE_LABELS[type] ?? type
)
), item;
});
this.#requestLegend.replaceChildren(...semanticItems, ...typeItems);
}
#renderRequestTypes(events, at) {
const byType = /* @__PURE__ */ new Map();
for (const event of events) {
const list = byType.get(event.type) ?? [];
list.push(event), byType.set(event.type, list);
}
const entries = [...byType].map(([type, list]) => ({
type,
list,
issues: list.filter((event) => requestIssue(event, at)).length,
p95: percentile95(
list.filter(
(event) => !event.pending && !["realtime", "presence"].includes(event.type) && event.duration > 0
).map((event) => event.duration)
)
})).sort((left, right) => right.list.length - left.list.length || left.type.localeCompare(right.type));
this.#requestTypes.replaceChildren(...entries.map((entry) => {
const row = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-request-flow-type-row"
);
row.dataset.requestFlowType = entry.type;
const name = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-type-name"
), dot = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"i",
"ldp-request-flow-dot"
), copy = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "strong");
copy.textContent = REQUEST_TYPE_LABELS[entry.type] ?? entry.type, name.append(dot, copy);
for (const text of [
`${entry.list.length} 次`,
entry.p95 ? formatDuration(entry.p95) : "—",
entry.issues ? String(entry.issues) : "—"
]) {
const value = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "span");
value.textContent = text, row.append(value);
}
return row.prepend(name), row;
})), entries.length || this.#requestTypes.append(this.#requestEmpty("还没有可统计的请求。"));
}
#renderRequestIssues(entries, at) {
this.#requestAnomalyWindow.textContent = entries.length ? `最近 60 秒 ${entries.length} 个 · 红色为失败/限流` : "最近 60 秒未发现异常";
const latest = entries.slice(-8).reverse();
this.#requestAnomalies.replaceChildren(...latest.map(({ event, issue }) => {
const row = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-request-flow-anomaly-row"
);
row.dataset.level = issue.level, row.dataset.requestFlowType = event.type;
const caller = event.callSite || `${event.transport} 发起`, diagnostic = requestContractDiagnostic(event);
row.dataset.ldpTooltipLabel = `${event.method} ${requestDisplayPath(event)} · ${issue.detail} · 发起点 ${caller}${diagnostic ? ` · ${diagnostic}` : ""}`;
const time = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"time",
"ldp-request-flow-anomaly-time"
);
time.dateTime = new Date(event.endedAt || event.startedAt).toISOString(), time.textContent = formatRequestTimestamp(
event.endedAt || event.startedAt
);
const kind = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-anomaly-kind"
);
kind.textContent = issue.label;
const detail = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-anomaly-detail"
), path = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "strong");
path.textContent = `${event.method} ${requestDisplayPath(event)}`;
const timing = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "small");
return timing.textContent = `${event.source === "reader" ? "阅读器" : event.source === "host" ? "原站" : "资源"} / ${REQUEST_TYPE_LABELS[event.type] ?? event.type} · ${requestTimingLabel(event, at)} · ${issue.detail} · 发起点 ${caller}` + (diagnostic ? ` · ${diagnostic}` : ""), detail.append(path, timing), row.append(time, kind, detail), row;
})), latest.length || this.#requestAnomalies.append(
this.#requestEmpty(
"最近 60 秒没有 HTTP 错误、网络中止、慢响应或长时间排队。"
)
);
}
#renderRequestLog(events, at) {
const scrollTop = this.#requestLog.scrollTop, latest = events.slice(-60).reverse();
this.#requestLog.replaceChildren(...latest.map((event) => {
const row = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-request-flow-log-row"
), issue = requestIssue(event, at);
issue && (row.dataset.level = issue.level), row.dataset.requestFlowType = event.type, row.dataset.requestPhase = event.phase, event.logicalId && (row.dataset.requestLogicalId = event.logicalId), event.decision && (row.dataset.requestDecision = event.decision);
const priority = requestPriorityLabel(event.priority), caller = event.callSite || `${event.transport} 发起`, diagnostic = requestContractDiagnostic(event);
row.dataset.ldpTooltipLabel = [
`${event.method} ${requestDisplayPath(event)}`,
priority ? `${priority}优先级` : "",
requestTimingLabel(event, at),
`发起点 ${caller}`,
diagnostic
].filter(Boolean).join(" · ");
const time = (0, import_reader_settings_dom.settingsElement)(this.#options.document, "time");
time.dateTime = new Date(event.queuedAt).toISOString(), time.textContent = formatRequestTimestamp(event.queuedAt);
const source = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-source"
);
source.textContent = event.source === "reader" ? "阅读器" : event.source === "host" ? "原站" : "资源";
const type = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-type-name"
), dot = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"i",
"ldp-request-flow-dot"
);
type.append(
dot,
this.#options.document.createTextNode(
REQUEST_TYPE_LABELS[event.type] ?? event.type
)
);
const priorityName = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-priority"
);
priorityName.textContent = priority || "—";
const status = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-status"
);
status.textContent = requestStatus(event), status.classList.toggle(
"is-error",
issue?.level === "danger"
);
const timing = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-timing"
);
timing.textContent = requestTimingLabel(event, at);
const path = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"span",
"ldp-request-flow-path"
), target = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"strong",
"ldp-request-flow-target"
);
target.textContent = `${requestDisplayPath(event)} ← ${caller}`;
const contract = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"small",
"ldp-request-flow-contract"
);
return contract.textContent = diagnostic || `${event.transport} · 未标记契约`, path.append(target, contract), row.append(time, source, type, priorityName, status, timing, path), row;
})), latest.length || this.#requestLog.append(
this.#requestEmpty(
"打开帖子或操作原站页面后,请求会按时间出现在这里。"
)
), scrollTop && (this.#requestLog.scrollTop = scrollTop);
}
#requestEmpty(text) {
const empty = (0, import_reader_settings_dom.settingsElement)(
this.#options.document,
"div",
"ldp-request-flow-empty"
);
return empty.textContent = text, empty;
}
#prune(at) {
const cutoff = at - RETENTION_MS;
for (; this.#samples.length && this.#samples[0].at < cutoff; )
this.#samples.shift(), this.#sampleRetentionDrops += 1;
for (let index = this.#performanceEvents.length - 1; index >= 0; index -= 1)
this.#performanceEvents[index].at < cutoff && (this.#performanceEvents.splice(index, 1), this.#evidenceRetentionDrops += 1);
for (let index = this.#visibilityTimeline.length - 1; index >= 0; index -= 1)
this.#visibilityTimeline[index].at < cutoff && index !== this.#visibilityTimeline.length - 1 && (this.#visibilityTimeline.splice(index, 1), this.#visibilityRetentionDrops += 1);
const requestRuntimeCutoff = at - REQUEST_RUNTIME_RETENTION_MS;
for (let index = this.#requestRuntimeStates.length - 1; index >= 0; index -= 1)
this.#requestRuntimeStates[index].lastObservedAt < requestRuntimeCutoff && (this.#requestRuntimeStates.splice(index, 1), this.#requestRuntimeRetentionDrops += 1);
const retainedRequestIds = new Set(
this.requests.snapshot.events.map((event) => event.id)
);
for (const id of this.#requestVisibility.keys())
retainedRequestIds.has(id) || this.#requestVisibility.delete(id);
}
#now() {
return this.#options.now?.() ?? Date.now();
}
#visibility() {
return this.#options.document.visibilityState === "hidden" ? "hidden" : "visible";
}
}
}, "7450ca3f7fc1d41ee2644517bb2e30a1a7abd16b7be0c13fe662f126d912605f");
/* Source: lite/src/network/browser-request-observation.ts */
runtime.register("src/network/browser-request-observation.js", function(module, exports, require) {
var browser_request_observation_exports = {};
__export(browser_request_observation_exports, {
BrowserResourceObservationAdapter: () => BrowserResourceObservationAdapter,
DiscourseNativeAjaxObservationAdapter: () => DiscourseNativeAjaxObservationAdapter
});
module.exports = __toCommonJS(browser_request_observation_exports);
function jqueryModule(value) {
const candidate = value && typeof value == "object" && "default" in value ? value.default : value;
return typeof candidate == "function" ? candidate : null;
}
function ajaxResponseHeader(response, name) {
try {
return String(response.getResponseHeader?.(name) ?? "");
} catch {
return "";
}
}
function ajaxFinish(response) {
const status = Number(response.status), cloudflareMitigated = ajaxResponseHeader(response, "cf-mitigated").trim().toLowerCase() === "challenge";
return Object.freeze({
...Number.isFinite(status) && status >= 0 ? { status } : {},
cloudflareMitigated,
rateLimitCode: ajaxResponseHeader(response, "Discourse-Rate-Limit-Error-Code") || ajaxResponseHeader(response, "X-Discourse-Rate-Limit-Error-Code"),
retryAfter: ajaxResponseHeader(response, "Retry-After"),
serverLimit: ajaxResponseHeader(response, "RateLimit-Limit") || ajaxResponseHeader(response, "X-RateLimit-Limit"),
serverRemaining: ajaxResponseHeader(response, "RateLimit-Remaining") || ajaxResponseHeader(response, "X-RateLimit-Remaining"),
serverReset: ajaxResponseHeader(response, "RateLimit-Reset") || ajaxResponseHeader(response, "X-RateLimit-Reset")
});
}
class DiscourseNativeAjaxObservationAdapter {
#observer;
#jqueryModule;
#document;
#namespace;
#hostRequestBudget;
constructor(options) {
this.#observer = options.observer, this.#jqueryModule = options.jqueryModule, this.#document = options.document ?? document, this.#hostRequestBudget = options.hostRequestBudget ?? null;
const namespace = String(options.namespace ?? "mianLiteRequestObserver").trim();
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(namespace))
throw new Error("jQuery ajax observation namespace 非法");
this.#namespace = namespace;
}
install(scope) {
let module2 = null;
try {
module2 = jqueryModule(this.#jqueryModule);
} catch {
return !1;
}
if (!module2) return !1;
let target;
try {
target = module2(this.#document);
} catch {
return !1;
}
if (!target || typeof target.on != "function" || typeof target.off != "function")
return !1;
const active = /* @__PURE__ */ new Map(), borrowedIds = /* @__PURE__ */ new Set(), send = (_event, rawResponse, rawSettings) => {
if (!rawResponse || typeof rawResponse != "object") return;
const settings = rawSettings ?? {}, href = String(settings.url ?? "");
if (!href) return;
const method = String(settings.type ?? settings.method ?? "GET"), readerId = this.#observer.matchActive({
href,
method,
source: "reader",
excludedIds: borrowedIds
});
readerId !== null && borrowedIds.add(readerId);
const id = readerId ?? this.#observer.begin({
href,
method,
transport: "xmlhttprequest",
source: "host"
}), event = this.#observer.snapshot.events.find(
(candidate) => candidate.id === id
), sharedApiResponse = !!(event?.sameOrigin && !["avatar", "media", "asset", "realtime", "presence"].includes(
event.type
)), countsAgainstSharedBudget = sharedApiResponse && event?.source === "host" && event.transport === "xmlhttprequest";
let hostLease = null;
if (countsAgainstSharedBudget && this.#hostRequestBudget && event)
try {
hostLease = this.#hostRequestBudget.recordHostStart({
startedAt: event.startedAt
});
} catch {
}
active.set(rawResponse, {
id,
borrowed: readerId !== null,
hostLease,
sharedResponse: sharedApiResponse && event ? Object.freeze({
source: event.source === "reader" ? "reader" : "host",
href: event.href,
method: event.method,
recoveryProbe: event.recoveryProbe,
blockOnCloudflareChallenge: event.type !== "read"
}) : null
});
}, complete = (_event, rawResponse) => {
if (!rawResponse || typeof rawResponse != "object") return;
const current = active.get(rawResponse);
if (!current) return;
active.delete(rawResponse), current.borrowed && borrowedIds.delete(current.id), current.hostLease?.release();
const finish = ajaxFinish(rawResponse);
if (this.#observer.finish(
current.id,
finish
), current.sharedResponse && this.#hostRequestBudget)
try {
this.#hostRequestBudget.noteObservedResponse({
...current.sharedResponse,
status: finish.status ?? 0,
cloudflareMitigated: finish.cloudflareMitigated === !0,
retryAfter: finish.retryAfter ?? "",
rateLimitCode: finish.rateLimitCode ?? "",
serverLimit: finish.serverLimit ?? "",
serverRemaining: finish.serverRemaining ?? "",
serverReset: finish.serverReset ?? ""
});
} catch {
}
}, sendEvent = `ajaxSend.${this.#namespace}`, completeEvent = `ajaxComplete.${this.#namespace}`;
try {
target.on(sendEvent, send), target.on(completeEvent, complete);
} catch {
try {
target.off(sendEvent, send), target.off(completeEvent, complete);
} catch {
}
return !1;
}
return scope.add(() => {
target.off(sendEvent, send), target.off(completeEvent, complete);
for (const current of active.values())
current.hostLease?.release(), current.borrowed || this.#observer.finish(current.id, {
error: "observer-detached"
});
active.clear(), borrowedIds.clear();
}), !0;
}
}
class BrowserResourceObservationAdapter {
#observer;
#performance;
#createObserver;
constructor(options) {
this.#observer = options.observer, this.#performance = options.performance ?? performance, this.#createObserver = options.createObserver ?? ((callback) => new PerformanceObserver(callback));
}
install(scope) {
let nativeObserver = null;
try {
nativeObserver = this.#createObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType !== "resource") continue;
const resource = entry;
this.#observer.recordResource({
href: resource.name,
initiatorType: resource.initiatorType,
startedAt: this.#performance.timeOrigin + resource.startTime,
endedAt: this.#performance.timeOrigin + (resource.responseEnd || resource.startTime + resource.duration),
status: Number(resource.responseStatus) || 0,
size: Number(resource.transferSize || resource.encodedBodySize) || 0
});
}
}), nativeObserver.observe({ type: "resource", buffered: !0 });
} catch {
return nativeObserver?.disconnect(), !1;
}
const installedObserver = nativeObserver;
return scope.add(() => installedObserver.disconnect()), !0;
}
}
}, "593c4a76fa7321cbe6674854a09ea2cf801136a3491954502eb5c071f8f3bf9c");
/* Source: lite/src/network/browser-shared-request-permit.ts */
runtime.register("src/network/browser-shared-request-permit.js", function(module, exports, require) {
var browser_shared_request_permit_exports = {};
__export(browser_shared_request_permit_exports, {
BrowserSharedRequestPermit: () => BrowserSharedRequestPermit,
READER_BACKGROUND_REQUEST_IDLE_INTERVAL_MS: () => READER_BACKGROUND_REQUEST_IDLE_INTERVAL_MS,
READER_BACKGROUND_REQUEST_MAX_DEFER_MS: () => READER_BACKGROUND_REQUEST_MAX_DEFER_MS,
READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME: () => READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME,
READER_REQUEST_PERMIT_CHANNEL: () => READER_REQUEST_PERMIT_CHANNEL,
READER_REQUEST_PERMIT_LOCK: () => READER_REQUEST_PERMIT_LOCK,
READER_REQUEST_PERMIT_STORAGE_KEY: () => READER_REQUEST_PERMIT_STORAGE_KEY,
browserCloudflareChallengeFeatures: () => browserCloudflareChallengeFeatures,
browserCloudflareChallengeHref: () => browserCloudflareChallengeHref,
isReaderCloudflareChallengeWindow: () => isReaderCloudflareChallengeWindow,
monitorReaderCloudflareChallengeWindow: () => monitorReaderCloudflareChallengeWindow
});
module.exports = __toCommonJS(browser_shared_request_permit_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_request_rate_limit_policy = require("./request-rate-limit-policy.js"), import_request_scheduler = require("./request-scheduler.js");
const READER_REQUEST_PERMIT_STORAGE_KEY = "linuxdo-enhanced-reader:request-permit:v1", READER_REQUEST_PERMIT_LOCK = "linuxdo-enhanced-reader:request-permit-lock:v1", READER_REQUEST_PERMIT_CHANNEL = "linuxdo-enhanced-reader:request-permit-channel:v1", READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME = "ldp-cloudflare-challenge", READER_BACKGROUND_REQUEST_IDLE_INTERVAL_MS = 2500, READER_BACKGROUND_REQUEST_MAX_DEFER_MS = 15e3, READER_CLOUDFLARE_CHALLENGE_MAX_PROBE_INTERVAL_MS = 1e4, READER_RATE_LIMIT_EVIDENCE_WINDOW_MS = 4e3, READER_RATE_LIMIT_MAX_BACKOFF_MS = 6e4, READER_RATE_LIMIT_PROBE_FAILURE_WAIT_MS = 1e3, READER_RATE_LIMIT_PROBE_RECHECK_MS = 500;
function isReaderCloudflareChallengeWindow(window) {
return window.name === READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME;
}
function readerCloudflareChallengeLeaseState(storage, now) {
try {
const stored = storage.getItem(READER_REQUEST_PERMIT_STORAGE_KEY);
if (!stored) return "released";
const parsed = JSON.parse(stored);
if (!parsed || typeof parsed != "object") return "unknown";
const challenge = parsed.challenge;
if (challenge == null) return "released";
if (typeof challenge != "object") return "unknown";
const source = challenge, state = String(source.state ?? ""), expiresAt = Number(source.expiresAt);
return Number.isFinite(expiresAt) ? expiresAt <= now || state === "passed" ? "released" : state === "active" || state === "required" ? "active" : "unknown" : "unknown";
} catch {
return "unknown";
}
}
function monitorReaderCloudflareChallengeWindow(options) {
const schedule = options.schedule ?? ((callback, intervalMs2) => setInterval(callback, intervalMs2)), cancel = options.cancel ?? ((handle) => clearInterval(handle)), intervalMs = Math.max(250, Number(options.intervalMs ?? 1e3)), now = options.now ?? Date.now, onError = options.onError ?? (() => {
});
let timer = null, stopped = !1;
const stop = () => {
stopped || (stopped = !0, timer !== null && cancel(timer), timer = null, options.storageEvents?.removeEventListener("storage", onStorage));
}, check = () => {
if (!(stopped || readerCloudflareChallengeLeaseState(options.storage, now()) !== "released"))
try {
options.close(), stop();
} catch (error) {
onError(error);
}
}, onStorage = (event) => {
const key = event.key;
(key == null || key === READER_REQUEST_PERMIT_STORAGE_KEY) && check();
};
return options.storageEvents?.addEventListener("storage", onStorage), check(), stopped || (timer = schedule(check, intervalMs)), stop;
}
function storedChallengeProbeState(source) {
if (!source) return Object.freeze({});
const token = typeof source.probeToken == "string" ? source.probeToken.trim() : "", notBefore = Number(source.probeNotBefore), backoffMs = Number(source.probeBackoffMs);
return Object.freeze({
...token ? { probeToken: token } : {},
...Number.isFinite(notBefore) && notBefore > 0 ? { probeNotBefore: notBefore } : {},
...Number.isSafeInteger(backoffMs) && backoffMs > 0 ? { probeBackoffMs: backoffMs } : {}
});
}
const CLEAR_RATE_LIMIT_GATE = Object.freeze({
waitMs: 0,
recoveryProbe: !1,
global: !1,
probeWaiting: !1
}), PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
function positiveInteger(value, fallback, name) {
const normalized = Number(value ?? fallback);
if (!Number.isSafeInteger(normalized) || normalized < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return normalized;
}
function nonNegativeInteger(value, name) {
const normalized = Number(value);
if (!Number.isSafeInteger(normalized) || normalized < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return normalized;
}
function unitInterval(value, fallback, name) {
const normalized = Number(value ?? fallback);
if (!Number.isFinite(normalized) || normalized < 0 || normalized > 1)
throw new RangeError(`${name} 必须位于 0 到 1`);
return normalized;
}
function authoritativeRetryAfter(value, now) {
const raw = String(value ?? "").trim();
if (!raw) return !1;
const seconds = Number(raw);
return Number.isFinite(seconds) ? seconds > 0 : Date.parse(raw) > now;
}
function observedRateLimitWindowWaitMs(window) {
return window === "10s" ? 1e4 : window === "60s" || window === "10s+60s" ? 6e4 : 0;
}
function normalizedSourceId(value) {
const normalized = String(value).trim();
if (!normalized) throw new Error("request permit sourceId 不能为空");
return normalized;
}
function activeRateLimitLease(lease) {
return lease.scope === "global" || lease.authoritative || lease.hits >= 2;
}
function emptyState() {
return {
schemaVersion: 1,
updatedAt: 0,
events: [],
intents: [],
active: [],
policies: [],
rateLimits: [],
challenge: null
};
}
function normalizeState(raw, now, longWindowMs) {
const source = raw && typeof raw == "object" ? raw : {}, events = Array.isArray(source.events) ? source.events.map(Number).filter((at) => Number.isFinite(at) && at > now - longWindowMs && at <= now).sort((left, right) => left - right).slice(-1e3) : [], intents = Array.isArray(source.intents) ? source.intents.filter((intent) => !!intent && typeof intent == "object" && typeof intent.id == "string" && typeof intent.ownerId == "string" && intent.priority in PRIORITY_WEIGHT && (intent.rateLimitRoute === void 0 || typeof intent.rateLimitRoute == "string") && Number.isFinite(intent.queuedAt) && Number(intent.expiresAt) > now).map((intent) => Object.freeze({
...intent,
rateLimitRoute: String(intent.rateLimitRoute ?? "").trim()
})).slice(-256) : [], active = Array.isArray(source.active) ? source.active.filter((permit) => !!permit && typeof permit == "object" && typeof permit.id == "string" && typeof permit.ownerId == "string" && Number(permit.expiresAt) > now).slice(-128) : [], policies = Array.isArray(source.policies) ? source.policies.filter((policy) => !!policy && typeof policy == "object" && typeof policy.ownerId == "string" && Number(policy.expiresAt) > now && Number.isSafeInteger(Number(policy.shortBudget)) && Number(policy.shortBudget) > 0 && Number.isSafeInteger(Number(policy.longBudget)) && Number(policy.longBudget) > 0 && Number.isSafeInteger(Number(policy.minIntervalMs)) && Number(policy.minIntervalMs) >= 0 && Number.isSafeInteger(Number(policy.maxConcurrent)) && Number(policy.maxConcurrent) > 0).map((policy) => Object.freeze({
ownerId: policy.ownerId,
shortBudget: Number(policy.shortBudget),
longBudget: Number(policy.longBudget),
minIntervalMs: Number(policy.minIntervalMs),
maxConcurrent: Number(policy.maxConcurrent),
expiresAt: Number(policy.expiresAt)
})).slice(-64) : [], rateLimits = Array.isArray(source.rateLimits) ? source.rateLimits.filter((lease) => !!lease && typeof lease == "object" && ["endpoint", "global"].includes(String(lease.scope)) && typeof lease.route == "string" && lease.route.trim() !== "" && Number.isSafeInteger(Number(lease.hits)) && Number(lease.hits) > 0 && Number.isFinite(Number(lease.lastObservedAt)) && Number.isFinite(Number(lease.retryAt)) && Number(lease.expiresAt) > now).map((lease) => Object.freeze({
scope: lease.scope === "global" ? "global" : "endpoint",
route: String(lease.route),
hits: Number(lease.hits),
authoritative: lease.authoritative === !0,
lastObservedAt: Number(lease.lastObservedAt),
retryAt: Number(lease.retryAt),
expiresAt: Number(lease.expiresAt),
...typeof lease.probeOwnerId == "string" && lease.probeOwnerId ? { probeOwnerId: lease.probeOwnerId } : {},
...Number(lease.probeExpiresAt) > now ? { probeExpiresAt: Number(lease.probeExpiresAt) } : {}
})).slice(-128) : [], challengeSource = source.challenge && typeof source.challenge == "object" ? source.challenge : null, challenge = challengeSource && typeof challengeSource.ownerId == "string" && ["required", "active", "passed"].includes(String(challengeSource.state)) && Number(challengeSource.expiresAt) > now ? Object.freeze({
ownerId: challengeSource.ownerId,
state: String(challengeSource.state) === "passed" ? "passed" : "active",
required: String(challengeSource.state) === "required" || String(challengeSource.state) === "active" && (challengeSource.required === !0 || challengeSource.ownerId === ""),
automaticAttempted: typeof challengeSource.automaticAttempted == "boolean" ? challengeSource.automaticAttempted : challengeSource.ownerId === "",
recoveryProbeAttempted: challengeSource.recoveryProbeAttempted === !0,
...storedChallengeProbeState(challengeSource),
updatedAt: Math.max(
0,
Number(challengeSource.updatedAt) || 0
),
expiresAt: Number(challengeSource.expiresAt)
}) : null;
return {
schemaVersion: 1,
updatedAt: Math.max(0, Number(source.updatedAt) || 0),
events,
intents,
active,
policies,
rateLimits,
/* 旧 v1 的 cooldown/学习字段不会被复制;只保留新式有界 429 lease。 */
challenge
};
}
function challengeOrigin(value) {
const url = new URL(String(value));
if (!["http:", "https:"].includes(url.protocol))
throw new Error("Cloudflare 验证 origin 必须是 HTTP(S)");
return url.origin;
}
function challengeHrefMatchesOrigin(href, origin) {
try {
return new URL(String(href), `${origin}/`).origin === origin;
} catch {
return !1;
}
}
function browserCloudflareChallengeHref(originValue, redirectHref) {
const origin = challengeOrigin(originValue);
let redirect = new URL("/", origin);
try {
const candidate = new URL(String(redirectHref ?? ""), `${origin}/`);
candidate.origin === origin && ["http:", "https:"].includes(candidate.protocol) && !/^\/challenge(?:\/|$)/i.test(candidate.pathname) && (candidate.username = "", candidate.password = "", redirect = candidate);
} catch {
}
const challenge = new URL(
new URL(origin).hostname.toLowerCase() === "linux.do" ? "/challenge" : "/",
origin
);
return challenge.pathname === "/challenge" && challenge.searchParams.set("redirect", redirect.href), challenge.href;
}
function inspectChallengeWindow(popup) {
try {
const document = popup.document;
if (!document || !!(document.querySelector(
'script[src*="/cdn-cgi/challenge-platform/"],#challenge-running,iframe[src*="challenges.cloudflare.com"],.cf-turnstile,input[name="cf-turnstile-response"]'
) || /^(?:Just a moment|请稍候)/i.test(String(document.title ?? "")))) return "pending";
if (document.querySelector(
'meta[name="discourse-base-uri"],meta[name="generator"],#main-outlet,.d-header'
)) return "passed";
const href = String(popup.location?.href ?? ""), location = new URL(href);
return ["http:", "https:"].includes(location.protocol) && !/^\/challenge(?:\/|$)/i.test(location.pathname) && !/^\/cdn-cgi\/challenge-platform(?:\/|$)/i.test(location.pathname) && document.readyState !== "loading" && document.body ? "passed" : "pending";
} catch {
return "pending";
}
}
function browserCloudflareChallengeFeatures(screen) {
const availableWidth = Math.max(0, Number(screen?.availWidth) || 760), availableHeight = Math.max(0, Number(screen?.availHeight) || 720), width = Math.min(760, Math.max(420, availableWidth)), height = Math.min(720, Math.max(520, availableHeight)), left = Math.max(
0,
Math.round(
(Number(screen?.availLeft) || 0) + (availableWidth - width) / 2
)
), top = Math.max(
0,
Math.round(
(Number(screen?.availTop) || 0) + (availableHeight - height) / 2
)
);
return `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
}
class BrowserSharedRequestPermit {
coordinationMode;
scope;
#storage;
#sourceId;
#locks;
#shortWindowMs;
#longWindowMs;
#shortBudget;
#longBudget;
#minIntervalMs;
#maxConcurrent;
#backgroundIdleIntervalMs;
#backgroundMaxDeferMs;
#rateLimitEvidenceWindowMs;
#rateLimitMaxBackoffMs;
#rateLimitJitterRatio;
#random;
#intentTtlMs;
#permitTtlMs;
#policyTtlMs;
#now;
#createId;
#onError;
#challenge;
#challengeOrigin;
#challengeHref;
#challengeLeaseTtlMs;
#challengePassedTtlMs;
#challengePollIntervalMs;
#challengeVerifyIntervalMs;
#challengeMaxWaitMs;
#inspectChallenge;
#verifyChallenge;
#waiters = /* @__PURE__ */ new Set();
#channel;
#fallbackState = emptyState();
#localTransactionTail = Promise.resolve();
#sequence = 0;
#closed = !1;
#challengePromise = null;
#challengeController = null;
#challengeFocusRequested = !1;
#challengeWindow = null;
#challengeReconcilePromise = null;
#challengeProbePromise = null;
#challengeProbeController = null;
constructor(options) {
if (this.#storage = options.storage, this.#sourceId = normalizedSourceId(options.sourceId), this.#locks = options.locks ?? null, this.coordinationMode = this.#locks ? "atomic" : "best-effort", this.#shortWindowMs = positiveInteger(options.shortWindowMs, 1e4, "shortWindowMs"), this.#longWindowMs = positiveInteger(options.longWindowMs, 6e4, "longWindowMs"), this.#longWindowMs < this.#shortWindowMs)
throw new RangeError("longWindowMs 不能小于 shortWindowMs");
this.#shortBudget = positiveInteger(options.shortBudget, 40, "shortBudget"), this.#longBudget = positiveInteger(options.longBudget, 160, "longBudget"), this.#minIntervalMs = nonNegativeInteger(options.minIntervalMs, "minIntervalMs"), this.#maxConcurrent = positiveInteger(options.maxConcurrent, 3, "maxConcurrent"), this.#backgroundIdleIntervalMs = nonNegativeInteger(
options.backgroundIdleIntervalMs ?? READER_BACKGROUND_REQUEST_IDLE_INTERVAL_MS,
"backgroundIdleIntervalMs"
), this.#backgroundMaxDeferMs = nonNegativeInteger(
options.backgroundMaxDeferMs ?? READER_BACKGROUND_REQUEST_MAX_DEFER_MS,
"backgroundMaxDeferMs"
), this.#rateLimitEvidenceWindowMs = positiveInteger(
options.rateLimitEvidenceWindowMs,
READER_RATE_LIMIT_EVIDENCE_WINDOW_MS,
"rateLimitEvidenceWindowMs"
), this.#rateLimitMaxBackoffMs = positiveInteger(
options.rateLimitMaxBackoffMs,
READER_RATE_LIMIT_MAX_BACKOFF_MS,
"rateLimitMaxBackoffMs"
), this.#rateLimitJitterRatio = unitInterval(
options.rateLimitJitterRatio,
0.2,
"rateLimitJitterRatio"
), this.#random = options.random ?? Math.random, this.#intentTtlMs = positiveInteger(options.intentTtlMs, 15e3, "intentTtlMs"), this.#permitTtlMs = positiveInteger(options.permitTtlMs, 35e3, "permitTtlMs"), this.#policyTtlMs = positiveInteger(options.policyTtlMs, 3e4, "policyTtlMs"), this.#now = options.now ?? Date.now, this.#createId = options.createId ?? (() => `${this.#sourceId}:${this.#now().toString(36)}:${++this.#sequence}`), this.#onError = options.onError ?? (() => {
}), this.#challenge = options.challenge ?? null, this.#challengeOrigin = this.#challenge ? challengeOrigin(this.#challenge.origin) : "", this.#challengeHref = this.#challenge ? browserCloudflareChallengeHref(
this.#challengeOrigin,
this.#challenge.redirectHref
) : "", this.#challengeLeaseTtlMs = positiveInteger(
this.#challenge?.leaseTtlMs,
15e3,
"challenge.leaseTtlMs"
), this.#challengePassedTtlMs = positiveInteger(
this.#challenge?.passedTtlMs,
1e4,
"challenge.passedTtlMs"
), this.#challengePollIntervalMs = positiveInteger(
this.#challenge?.pollIntervalMs,
250,
"challenge.pollIntervalMs"
), this.#challengeVerifyIntervalMs = positiveInteger(
this.#challenge?.verifyIntervalMs,
1e3,
"challenge.verifyIntervalMs"
), this.#challengeMaxWaitMs = positiveInteger(
this.#challenge?.maxWaitMs,
12e4,
"challenge.maxWaitMs"
), this.#inspectChallenge = this.#challenge?.inspect ?? inspectChallengeWindow, this.#verifyChallenge = this.#challenge?.verify ?? null, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const factory = options.broadcastChannelFactory === void 0 ? typeof BroadcastChannel > "u" ? null : (name) => new BroadcastChannel(name) : options.broadcastChannelFactory;
let channel = null;
try {
channel = factory?.(READER_REQUEST_PERMIT_CHANNEL) ?? null, channel?.addEventListener("message", this.#onChannelMessage);
} catch (error) {
this.#onError(error);
}
this.#channel = channel, options.storageEvents && this.scope.listen(
options.storageEvents,
"storage",
this.#onStorage
), this.scope.add(() => {
this.#channel?.removeEventListener("message", this.#onChannelMessage), this.#channel?.close();
}), this.scope.add(() => {
this.#closed = !0, this.#challengeController?.abort(
new DOMException("request permit 已销毁", "AbortError")
), this.#challengeController = null, this.#challengeProbeController?.abort(
new DOMException("request permit 已销毁", "AbortError")
), this.#challengeProbeController = null;
try {
this.#challengeWindow?.close?.();
} catch {
}
this.#challengeWindow = null, this.#wake(), this.#removeOwnedState();
});
}
async acquire(input) {
if (this.#assertOpen(), input.signal.aborted) throw this.#abortReason(input.signal);
const intentId = `${this.#sourceId}:intent:${this.#createId()}`, queuedAt = this.#now();
let granted = !1, waitReason = "";
try {
for (; !this.#closed; ) {
if (input.signal.aborted) throw this.#abortReason(input.signal);
const decision = await this.#transact((state, now) => this.#tryGrant(
state,
now,
intentId,
queuedAt,
input.priority,
String(input.rateLimitRoute ?? "").trim()
));
if (decision.granted && decision.permitId)
return granted = !0, this.#permit(
decision.permitId,
decision.recoveryProbe === !0,
waitReason
);
if (waitReason = decision.reason || waitReason, decision.defer)
throw new import_request_scheduler.RequestStartDeferredError(
decision.waitMs,
decision.reason
);
await this.#wait(decision.waitMs, input.signal);
}
throw new Error("BrowserSharedRequestPermit 已销毁");
} finally {
granted || this.#transact((state) => {
state.intents = state.intents.filter((intent) => intent.id !== intentId);
}).catch(this.#onError);
}
}
async noteRateLimit(decision) {
this.#assertOpen();
const route = String(decision.route).trim();
route && await this.#transact((state, now) => {
const scope = decision.scope === "global" ? "global" : "endpoint", leaseRoute = scope === "global" ? "*" : route, index = state.rateLimits.findIndex(
(lease) => lease.scope === scope && lease.route === leaseRoute
), previous = index >= 0 ? state.rateLimits[index] : void 0, repeated = !!(previous && previous.lastObservedAt >= now - this.#rateLimitEvidenceWindowMs), hits = repeated ? previous.hits + 1 : 1, authoritative = scope === "global" || decision.authoritative === !0 || previous?.authoritative === !0 && repeated, exponent = Math.max(0, Math.min(6, hits - 1)), boundedWaitMs = Math.min(
this.#rateLimitMaxBackoffMs,
Math.max(1, decision.waitMs) * 2 ** exponent
), random = Math.max(0, Math.min(1, Number(this.#random()) || 0)), jitterMs = Math.floor(
boundedWaitMs * this.#rateLimitJitterRatio * random
), retryAt = Math.max(
decision.retryAt,
now + boundedWaitMs + jitterMs
), next = Object.freeze({
scope,
route: leaseRoute,
hits,
authoritative,
lastObservedAt: now,
retryAt,
expiresAt: Math.max(
now + this.#rateLimitEvidenceWindowMs,
retryAt + this.#permitTtlMs
)
});
if (index >= 0 ? state.rateLimits[index] = next : state.rateLimits.push(next), scope === "endpoint") {
const corroborating = state.rateLimits.find((lease) => lease.scope === "endpoint" && lease.route !== leaseRoute && lease.lastObservedAt >= now - this.#rateLimitEvidenceWindowMs);
if (corroborating) {
const globalIndex = state.rateLimits.findIndex(
(lease) => lease.scope === "global"
), globalPrevious = globalIndex >= 0 ? state.rateLimits[globalIndex] : void 0, globalRetryAt = Math.max(
retryAt,
corroborating.retryAt,
globalPrevious?.retryAt ?? 0
), globalLease = Object.freeze({
scope: "global",
route: "*",
hits: (globalPrevious?.hits ?? 0) + 1,
authoritative: authoritative || corroborating.authoritative,
lastObservedAt: now,
retryAt: globalRetryAt,
expiresAt: Math.max(
now + this.#rateLimitEvidenceWindowMs,
globalRetryAt + this.#permitTtlMs
)
});
globalIndex >= 0 ? state.rateLimits[globalIndex] = globalLease : state.rateLimits.push(globalLease);
}
}
state.rateLimits.length > 128 && state.rateLimits.splice(0, state.rateLimits.length - 128);
});
}
async noteRateLimitProbeResult(input) {
this.#assertOpen();
const route = String(input.route).trim();
route && await this.#transact((state, now) => {
if (input.recovered) {
state.rateLimits = state.rateLimits.filter(
(lease) => lease.scope !== "global" && lease.route !== route
);
return;
}
state.rateLimits = state.rateLimits.map((lease) => {
if (lease.probeOwnerId !== this.#sourceId || lease.scope !== "global" && lease.route !== route) return lease;
const retryAt = Math.max(
lease.retryAt,
now + READER_RATE_LIMIT_PROBE_FAILURE_WAIT_MS
);
return Object.freeze({
scope: lease.scope,
route: lease.route,
hits: lease.hits,
authoritative: lease.authoritative,
lastObservedAt: lease.lastObservedAt,
retryAt,
expiresAt: Math.max(lease.expiresAt, retryAt + this.#permitTtlMs)
});
});
});
}
noteObservedResponse(input) {
if (!(input.source !== "host" || !input.href)) {
if (input.status === 429) {
const now = this.#now(), window = (0, import_request_rate_limit_policy.rateLimitWindowFromCode)(input.rateLimitCode), hasAuthoritativeRetryAfter = authoritativeRetryAfter(
input.retryAfter,
now
), waitMs = Math.max(
(0, import_request_rate_limit_policy.parseRetryAfterMs)(input.retryAfter, {
now,
fallbackMs: 1500,
minMs: 1e3,
maxMs: this.#rateLimitMaxBackoffMs
}),
observedRateLimitWindowWaitMs(window)
), identity = (0, import_request_rate_limit_policy.endpointRequestIdentity)(
input.href,
input.method,
this.#challengeOrigin || void 0
);
this.noteRateLimit(Object.freeze({
scope: window === "unknown" ? "endpoint" : "global",
waitMs,
retryAt: now + waitMs,
...identity,
window,
authoritative: hasAuthoritativeRetryAfter || window !== "unknown"
})).catch(this.#onError);
}
input.cloudflareMitigated === !0 && input.blockOnCloudflareChallenge !== !1 && this.noteCloudflareChallenge({ href: input.href }).catch(this.#onError);
}
}
async noteCloudflareChallenge(input) {
this.#assertOpen(), !(!this.#challenge || !challengeHrefMatchesOrigin(input.href, this.#challengeOrigin)) && await this.#transact((state, now) => {
if (state.challenge?.state === "passed" && input.force !== !0 || state.challenge?.state === "active" && !state.challenge.required)
return;
const newGeneration = state.challenge?.state === "passed" && input.force === !0;
state.challenge = Object.freeze({
ownerId: "",
state: "active",
required: !0,
automaticAttempted: !newGeneration && state.challenge?.automaticAttempted === !0,
recoveryProbeAttempted: !newGeneration && state.challenge?.recoveryProbeAttempted === !0,
...newGeneration ? {} : storedChallengeProbeState(state.challenge),
updatedAt: now,
expiresAt: now + this.#challengeMaxWaitMs
});
});
}
/**
* 页面重载可能销毁原验证 owner,却留下已完成验证的命名窗口与 required 状态。
* 每个 challenge 世代只允许一个新 context 做一次原生 session 探针;成功即解闸,
* 失败仍保留人工按钮。它不打开窗口、不重放业务请求,并与人工入口共享探针退避。
*/
reconcileCloudflareChallenge() {
if (this.#assertOpen(), !this.#verifyChallenge) return Promise.resolve(!1);
if (this.#challengeReconcilePromise) return this.#challengeReconcilePromise;
const controller = this.scope.abortController(
new DOMException("request permit 已销毁", "AbortError")
), promise = this.#reconcileRequiredChallenge(controller.signal).finally(() => {
this.#challengeReconcilePromise === promise && (this.#challengeReconcilePromise = null);
});
return this.#challengeReconcilePromise = promise, promise;
}
recordHostStart(input) {
this.#assertOpen();
const activeId = `${this.#sourceId}:host:${this.#createId()}`, registration = this.#transact((state, now) => {
this.#rememberPolicy(state, now);
const startedAt = Math.max(
now - 5e3,
Math.min(now, Number(input.startedAt) || now)
);
state.events.push(startedAt), state.events.sort((left, right) => left - right), state.active.push(Object.freeze({
id: activeId,
ownerId: this.#sourceId,
expiresAt: now + this.#permitTtlMs
}));
});
let released = !1;
return Object.freeze({
release: (input2) => {
released || (released = !0, input2 && this.noteObservedResponse(input2), registration.then(() => this.#transact((state) => {
state.active = state.active.filter(
(permit) => permit.id !== activeId
);
})).catch(this.#onError));
}
});
}
async resolveCloudflareChallenge(input) {
if (this.#assertOpen(), !this.#challenge || !challengeHrefMatchesOrigin(input.href, this.#challengeOrigin))
return !1;
if (input.signal.aborted)
throw this.#abortReason(input.signal);
if (input.focus !== !0 && !this.#locks && (await this.noteCloudflareChallenge({ href: input.href }), await this.#transact((state) => {
state.challenge?.state === "active" && state.challenge.required && (state.challenge = Object.freeze({
...state.challenge,
automaticAttempted: !0
}));
})), input.focus === !0 && (this.#challengeFocusRequested = !0, this.#focusChallengeWindow(), this.#postChannelMessage("challenge-focus"), this.#wake()), !this.#challengePromise) {
const controller = new AbortController();
this.#challengeController = controller;
const promise = this.#runChallenge(controller.signal).finally(() => {
this.#challengePromise === promise && (this.#challengePromise = null, this.#challengeController = null, this.#challengeFocusRequested = !1);
});
this.#challengePromise = promise;
}
const shared = this.#challengePromise;
let abort = () => {
};
const cancelled = new Promise((_resolve, reject) => {
abort = () => reject(this.#abortReason(input.signal)), input.signal.addEventListener("abort", abort, { once: !0 });
});
return Promise.race([shared, cancelled]).finally(() => {
input.signal.removeEventListener("abort", abort);
});
}
/** 清除 429 反馈 lease;固定预防窗口必须原样保留,避免恢复后形成追赶突发。 */
async resetRateLimits() {
this.#assertOpen(), await this.#transact((state) => {
state.rateLimits = [];
});
}
async snapshot() {
const now = this.#now(), state = this.#read(now), policy = this.#effectivePolicy(state), blocking = this.#blockingState(state, now, policy), rateLimitBlocking = this.#rateLimitGate(state, now, "", !1), instances = /* @__PURE__ */ new Set([
this.#sourceId,
...state.policies.map((entry) => entry.ownerId),
...state.intents.map((entry) => entry.ownerId),
...state.active.map((entry) => entry.ownerId)
]);
return Object.freeze({
coordinationMode: this.coordinationMode,
shortBudget: policy.shortBudget,
longBudget: policy.longBudget,
minIntervalMs: policy.minIntervalMs,
maxConcurrent: policy.maxConcurrent,
instances: Math.max(1, instances.size),
queued: state.intents.length,
active: state.active.length,
shortCount: state.events.filter((at) => at > now - this.#shortWindowMs).length,
longCount: state.events.length,
challengeState: state.challenge ? state.challenge.state === "active" && state.challenge.required ? "required" : state.challenge.state : "idle",
challengeOwned: state.challenge?.state === "active" && !state.challenge.required && state.challenge.ownerId === this.#sourceId,
nextPermitDelay: Math.max(blocking.waitMs, rateLimitBlocking.waitMs),
blockingReason: (rateLimitBlocking.waitMs > blocking.waitMs ? "rate-limit" : blocking.reason) || (state.intents.length ? "priority" : "")
});
}
/**
* 更新跨标签许可策略,但保留 intent、active lease 与固定窗口事件。
*
* 收紧后的策略只阻止后续 permit;不会中止已在执行的原站请求。
*/
applyRuntimePolicy(policy) {
this.#closed || (this.#shortBudget = positiveInteger(
policy.shortBudget,
this.#shortBudget,
"shortBudget"
), this.#longBudget = positiveInteger(
policy.longBudget,
this.#longBudget,
"longBudget"
), this.#minIntervalMs = nonNegativeInteger(
policy.minIntervalMs,
"minIntervalMs"
), this.#maxConcurrent = positiveInteger(
policy.maxConcurrent,
this.#maxConcurrent,
"maxConcurrent"
), this.#transact((state, now) => {
this.#rememberPolicy(state, now);
}).catch(this.#onError));
}
destroy() {
this.scope.destroy();
}
async #runChallenge(signal) {
const startedAt = this.#now();
for (; !this.#closed && this.#now() - startedAt < this.#challengeMaxWaitMs; ) {
if (signal.aborted) throw this.#abortReason(signal);
const ownership = await this.#transact((state, now) => state.challenge?.state === "passed" ? "passed" : state.challenge?.state === "active" && state.challenge.required && state.challenge.automaticAttempted && !this.#challengeFocusRequested || state.challenge?.state === "active" && !state.challenge.required && state.challenge.ownerId !== this.#sourceId ? "waiting" : (state.challenge = Object.freeze({
ownerId: this.#sourceId,
state: "active",
required: !1,
automaticAttempted: !0,
recoveryProbeAttempted: state.challenge?.recoveryProbeAttempted === !0,
...storedChallengeProbeState(state.challenge),
updatedAt: now,
expiresAt: now + this.#challengeLeaseTtlMs
}), "owner"));
if (ownership === "passed") return !0;
if (ownership === "waiting") {
await this.#wait(this.#challengePollIntervalMs, signal);
continue;
}
try {
return await this.#challengePassesProbe(signal) ? (await this.#completeChallengeLease(), !0) : await this.#ownChallengeWindow(signal, startedAt);
} catch (error) {
try {
this.#challengeWindow?.close?.();
} catch {
}
throw this.#challengeWindow = null, await this.#releaseChallengeLease(), error;
}
}
return !1;
}
async #reconcileRequiredChallenge(signal) {
if (!await this.#transact((state, now) => state.challenge?.state !== "active" || !state.challenge.required || !state.challenge.automaticAttempted || state.challenge.recoveryProbeAttempted === !0 ? !1 : (state.challenge = Object.freeze({
...state.challenge,
ownerId: this.#sourceId,
required: !1,
recoveryProbeAttempted: !0,
updatedAt: now,
expiresAt: now + this.#challengeLeaseTtlMs
}), !0))) return !1;
if (signal.aborted) throw this.#abortReason(signal);
let verified = !1;
try {
verified = await this.#challengePassesProbe(signal);
} catch (error) {
if (signal.aborted) throw error;
this.#onError(error);
}
return verified ? (await this.#completeChallengeLease(), !0) : (await this.#releaseChallengeLease(), !1);
}
async #ownChallengeWindow(signal, startedAt) {
const challenge = this.#challenge;
if (!challenge) return !1;
if (!this.#challengeWindow || this.#challengeWindow.closed === !0)
try {
this.#challengeWindow = challenge.open(
this.#challengeHref,
READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME,
browserCloudflareChallengeFeatures(challenge.screen)
);
} catch (error) {
this.#onError(error), this.#challengeWindow = null;
}
const popup = this.#challengeWindow;
if (!popup)
return await this.#releaseChallengeLease(), !1;
let verifyDelayMs = this.#challengeVerifyIntervalMs;
const maxVerifyDelayMs = Math.max(
verifyDelayMs,
READER_CLOUDFLARE_CHALLENGE_MAX_PROBE_INTERVAL_MS
);
let nextVerifyAt = this.#now() + verifyDelayMs;
const onPopupLoad = () => {
nextVerifyAt = this.#now(), this.#expediteChallengeProbeAfterLoad();
};
popup.addEventListener?.("load", onPopupLoad), this.#focusChallengeWindow();
try {
for (; !this.#closed && this.#now() - startedAt < this.#challengeMaxWaitMs; ) {
if (signal.aborted) throw this.#abortReason(signal);
if (popup.closed === !0)
return this.#challengeWindow = null, await this.#challengePassesProbe(signal) ? (await this.#completeChallengeLease(), !0) : (await this.#releaseChallengeLease(), !1);
const inspectedPassed = this.#inspectChallenge(popup) === "passed", probeDue = !!this.#verifyChallenge && (inspectedPassed || this.#now() >= nextVerifyAt);
if (inspectedPassed && !this.#verifyChallenge) {
await this.#completeChallengeLease();
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !0;
}
if (probeDue) {
const verified = await this.#challengePassesProbe(signal);
if (verifyDelayMs = Math.min(maxVerifyDelayMs, verifyDelayMs * 2), nextVerifyAt = this.#now() + verifyDelayMs, verified) {
await this.#completeChallengeLease();
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !0;
}
}
if (!await this.#transact((state, now) => state.challenge?.state !== "active" || state.challenge.ownerId !== this.#sourceId ? !1 : (state.challenge = Object.freeze({
...state.challenge,
updatedAt: now,
expiresAt: now + this.#challengeLeaseTtlMs
}), !0))) {
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !1;
}
await this.#wait(this.#challengePollIntervalMs, signal);
}
if (await this.#challengePassesProbe(signal)) {
await this.#completeChallengeLease();
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !0;
}
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, await this.#releaseChallengeLease(), !1;
} finally {
popup.removeEventListener?.("load", onPopupLoad);
}
}
#challengePassesProbe(signal) {
if (!this.#verifyChallenge) return Promise.resolve(!1);
if (signal.aborted)
return Promise.reject(this.#abortReason(signal));
if (!this.#challengeProbePromise) {
const controller = new AbortController();
this.#challengeProbeController = controller;
const promise = this.#runChallengeProbe(controller.signal).catch((error) => {
if (controller.signal.aborted)
throw controller.signal.reason ?? error;
return this.#onError(error), !1;
}).finally(() => {
this.#challengeProbePromise === promise && (this.#challengeProbePromise = null, this.#challengeProbeController = null);
});
this.#challengeProbePromise = promise;
}
const shared = this.#challengeProbePromise;
if (!shared) return Promise.resolve(!1);
let abort = () => {
};
const cancelled = new Promise((_resolve, reject) => {
abort = () => reject(this.#abortReason(signal)), signal.addEventListener("abort", abort, { once: !0 });
});
return Promise.race([shared, cancelled]).finally(() => {
signal.removeEventListener("abort", abort);
});
}
async #expediteChallengeProbeAfterLoad() {
try {
await this.#challengeProbePromise;
} catch {
}
try {
await this.#transact((state, now) => {
state.challenge?.state !== "active" || state.challenge.ownerId !== this.#sourceId || (state.challenge = Object.freeze({
...state.challenge,
probeNotBefore: now,
probeBackoffMs: this.#challengeVerifyIntervalMs,
updatedAt: now
}));
});
} catch (error) {
this.#onError(error);
} finally {
this.#wake();
}
}
async #runChallengeProbe(signal) {
const verify = this.#verifyChallenge;
if (!verify) return !1;
if (signal.aborted) throw this.#abortReason(signal);
const probeToken = `${this.#sourceId}:challenge-probe:${this.#createId()}`, maxBackoffMs = Math.max(
this.#challengeVerifyIntervalMs,
READER_CLOUDFLARE_CHALLENGE_MAX_PROBE_INTERVAL_MS
), reservation = await this.#transact((state, now) => {
const challenge = state.challenge;
if (challenge?.state === "passed")
return Object.freeze({ status: "passed", backoffMs: 0 });
if (challenge?.state !== "active" || challenge.ownerId !== this.#sourceId || Number(challenge.probeNotBefore ?? 0) > now)
return Object.freeze({ status: "deferred", backoffMs: 0 });
const storedBackoffMs = Number(challenge.probeBackoffMs), backoffMs = Number.isSafeInteger(storedBackoffMs) && storedBackoffMs > 0 ? Math.max(
this.#challengeVerifyIntervalMs,
Math.min(maxBackoffMs, storedBackoffMs)
) : this.#challengeVerifyIntervalMs;
return state.challenge = Object.freeze({
...challenge,
probeToken,
probeNotBefore: now + backoffMs,
probeBackoffMs: backoffMs,
updatedAt: now,
expiresAt: Math.max(
challenge.expiresAt,
now + this.#challengeLeaseTtlMs
)
}), Object.freeze({ status: "reserved", backoffMs });
});
if (reservation.status === "passed") return !0;
if (reservation.status !== "reserved") return !1;
if (signal.aborted) throw this.#abortReason(signal);
let verified = !1;
try {
verified = await verify(signal);
} catch (error) {
if (signal.aborted) throw error;
this.#onError(error);
}
const settlement = await this.#transact((state, now) => {
const challenge = state.challenge;
if (challenge?.state === "passed") return "passed";
if (!challenge || challenge.ownerId !== this.#sourceId || challenge.probeToken !== probeToken) return "stale";
const backoffMs = verified ? this.#challengeVerifyIntervalMs : Math.min(
maxBackoffMs,
Math.max(
this.#challengeVerifyIntervalMs,
reservation.backoffMs * 2
)
);
return state.challenge = Object.freeze({
...challenge,
probeNotBefore: now + backoffMs,
probeBackoffMs: backoffMs,
updatedAt: now,
expiresAt: Math.max(
challenge.expiresAt,
now + this.#challengeLeaseTtlMs
)
}), "updated";
});
return settlement === "passed" || verified && settlement === "updated";
}
#focusChallengeWindow() {
if (!(!this.#challengeFocusRequested || !this.#challengeWindow)) {
this.#challengeFocusRequested = !1;
try {
this.#challengeWindow.focus?.();
} catch {
}
}
}
async #completeChallengeLease() {
await this.#transact((state, now) => {
state.challenge?.state !== "active" || state.challenge.ownerId !== this.#sourceId || (state.challenge = Object.freeze({
ownerId: this.#sourceId,
state: "passed",
required: !1,
automaticAttempted: !0,
recoveryProbeAttempted: state.challenge.recoveryProbeAttempted === !0,
updatedAt: now,
expiresAt: now + this.#challengePassedTtlMs
}));
});
}
async #releaseChallengeLease() {
await this.#transact((state, now) => {
state.challenge?.state === "active" && state.challenge.ownerId === this.#sourceId && (state.challenge = Object.freeze({
ownerId: "",
state: "active",
required: !0,
automaticAttempted: !0,
recoveryProbeAttempted: state.challenge.recoveryProbeAttempted === !0,
...storedChallengeProbeState(state.challenge),
updatedAt: now,
expiresAt: now + this.#challengeMaxWaitMs
}));
});
}
#tryGrant(state, now, intentId, queuedAt, priority, rateLimitRoute) {
this.#rememberPolicy(state, now);
const policy = this.#effectivePolicy(state);
state.intents.find((intent) => intent.id === intentId) ? state.intents = state.intents.map((intent) => intent.id === intentId ? Object.freeze({
...intent,
rateLimitRoute,
expiresAt: now + this.#intentTtlMs
}) : intent) : state.intents.push(Object.freeze({
id: intentId,
ownerId: this.#sourceId,
priority,
rateLimitRoute,
queuedAt,
expiresAt: now + this.#intentTtlMs
})), state.intents.sort((left, right) => PRIORITY_WEIGHT[left.priority] - PRIORITY_WEIGHT[right.priority] || left.queuedAt - right.queuedAt || left.id.localeCompare(right.id));
const firstEligibleIntent = state.intents.find((intent) => this.#rateLimitGate(
state,
now,
intent.rateLimitRoute,
!1
).waitMs === 0);
if (firstEligibleIntent && firstEligibleIntent.id !== intentId)
return Object.freeze({
granted: !1,
waitMs: 80,
reason: "priority"
});
if (!firstEligibleIntent) {
const rateLimitBlocking2 = this.#rateLimitGate(
state,
now,
rateLimitRoute,
!1
);
return Object.freeze({
granted: !1,
waitMs: Math.max(
25,
rateLimitBlocking2.probeWaiting && !rateLimitBlocking2.global ? Math.min(
rateLimitBlocking2.waitMs,
READER_RATE_LIMIT_PROBE_RECHECK_MS
) : rateLimitBlocking2.waitMs
),
reason: "rate-limit",
defer: !rateLimitBlocking2.global
});
}
const blocking = this.#blockingState(
state,
now,
policy,
priority,
queuedAt
), rateLimitBlocking = this.#rateLimitGate(
state,
now,
rateLimitRoute,
!1
), waitMs = Math.max(blocking.waitMs, rateLimitBlocking.waitMs);
if (waitMs > 0) {
const rateLimitDominates = rateLimitBlocking.waitMs > blocking.waitMs;
return Object.freeze({
granted: !1,
waitMs: rateLimitDominates && rateLimitBlocking.probeWaiting && !rateLimitBlocking.global ? Math.min(waitMs, READER_RATE_LIMIT_PROBE_RECHECK_MS) : waitMs,
reason: rateLimitDominates ? "rate-limit" : blocking.reason,
defer: rateLimitDominates && !rateLimitBlocking.global
});
}
const rateLimitRecoveryProbe = this.#rateLimitGate(
state,
now,
rateLimitRoute,
!0
).recoveryProbe;
state.intents = state.intents.filter((intent) => intent.id !== intentId), state.events.push(now);
const permitId = `${this.#sourceId}:permit:${this.#createId()}`;
return state.active.push(Object.freeze({
id: permitId,
ownerId: this.#sourceId,
expiresAt: now + this.#permitTtlMs
})), Object.freeze({
granted: !0,
permitId,
waitMs: 0,
reason: "",
recoveryProbe: rateLimitRecoveryProbe
});
}
#rateLimitGate(state, now, route, claim) {
if (!state.rateLimits.length) return CLEAR_RATE_LIMIT_GATE;
const matching = state.rateLimits.filter((lease) => activeRateLimitLease(lease) && (lease.scope === "global" || !!route && lease.route === route));
if (!matching.length)
return CLEAR_RATE_LIMIT_GATE;
const global = matching.some((lease) => lease.scope === "global");
let waitMs = 0, probeWaiting = !1;
for (const lease of matching) {
if (lease.retryAt > now) {
waitMs = Math.max(waitMs, lease.retryAt - now);
continue;
}
lease.probeOwnerId && Number(lease.probeExpiresAt) > now && (probeWaiting = !0, waitMs = Math.max(waitMs, Number(lease.probeExpiresAt) - now));
}
return waitMs > 0 || !claim ? Object.freeze({
waitMs,
recoveryProbe: waitMs === 0,
global,
probeWaiting
}) : (state.rateLimits = state.rateLimits.map((lease) => matching.includes(lease) ? Object.freeze({
...lease,
probeOwnerId: this.#sourceId,
probeExpiresAt: now + this.#permitTtlMs,
expiresAt: Math.max(lease.expiresAt, now + this.#permitTtlMs)
}) : lease), Object.freeze({
waitMs: 0,
recoveryProbe: !0,
global,
probeWaiting: !1
}));
}
#rememberPolicy(state, now) {
const policy = Object.freeze({
ownerId: this.#sourceId,
shortBudget: this.#shortBudget,
longBudget: this.#longBudget,
minIntervalMs: this.#minIntervalMs,
maxConcurrent: this.#maxConcurrent,
expiresAt: now + this.#policyTtlMs
}), index = state.policies.findIndex(
(candidate) => candidate.ownerId === this.#sourceId
);
index >= 0 ? state.policies[index] = policy : state.policies.push(policy);
}
#effectivePolicy(state) {
return Object.freeze(this.#configuredPolicy(state));
}
#configuredPolicy(state) {
const effective = {
shortBudget: this.#shortBudget,
longBudget: this.#longBudget,
minIntervalMs: this.#minIntervalMs,
maxConcurrent: this.#maxConcurrent
};
for (const policy of state.policies)
policy.ownerId !== this.#sourceId && (effective.shortBudget = Math.min(
effective.shortBudget,
policy.shortBudget
), effective.longBudget = Math.min(
effective.longBudget,
policy.longBudget
), effective.minIntervalMs = Math.max(
effective.minIntervalMs,
policy.minIntervalMs
), effective.maxConcurrent = Math.min(
effective.maxConcurrent,
policy.maxConcurrent
));
return effective;
}
#blockingState(state, now, policy, priority = "visible", queuedAt = now) {
if (state.challenge?.state === "active")
return Object.freeze({
waitMs: Math.max(25, state.challenge.expiresAt - now),
reason: "challenge",
recoveryProbe: !1
});
const activeDelay = state.active.length >= policy.maxConcurrent ? Math.max(
25,
Math.min(...state.active.map((permit) => permit.expiresAt - now))
) : 0, backgroundActiveDelay = priority === "background" && state.active.length ? Math.max(
25,
Math.min(...state.active.map((permit) => permit.expiresAt - now))
) : 0, shortEvents = state.events.filter(
(at) => at > now - this.#shortWindowMs
), shortWindowDelay = this.#windowDelay(
shortEvents,
policy.shortBudget,
this.#shortWindowMs,
now
), longWindowDelay = this.#windowDelay(
state.events,
policy.longBudget,
this.#longWindowMs,
now
), latest = state.events.at(-1) ?? 0, backgroundDeferRemainingMs = priority === "background" ? Math.max(0, queuedAt + this.#backgroundMaxDeferMs - now) : 0, enforceBackgroundIdle = priority === "background" && backgroundDeferRemainingMs > 0, requestIntervalMs = enforceBackgroundIdle ? Math.max(policy.minIntervalMs, this.#backgroundIdleIntervalMs) : policy.minIntervalMs;
let intervalDelay = Math.max(
0,
latest + requestIntervalMs - now
);
enforceBackgroundIdle && (intervalDelay = Math.min(
intervalDelay,
backgroundDeferRemainingMs
));
const candidates = [
["concurrency", Math.max(activeDelay, backgroundActiveDelay)],
["interval", intervalDelay],
["10s", shortWindowDelay],
["60s", longWindowDelay]
];
let reason = "", waitMs = 0;
for (const [candidateReason, delay] of candidates)
delay > waitMs && (reason = candidateReason, waitMs = delay);
return Object.freeze({ waitMs, reason, recoveryProbe: !1 });
}
#windowDelay(events, budget, windowMs, now) {
if (events.length < budget) return 0;
const boundary = events[events.length - budget];
return boundary === void 0 ? 0 : Math.max(0, boundary + windowMs - now + 1);
}
#permit(permitId, recoveryProbe, waitReason) {
let released = !1;
return Object.freeze({
recoveryProbe,
waitReason,
release: () => {
released || (released = !0, this.#transact((state) => {
state.active = state.active.filter((permit) => permit.id !== permitId);
}).catch(this.#onError));
}
});
}
async #transact(operation) {
const execute = async () => {
const now = this.#now(), state = this.#read(now), result = await operation(state, now);
return state.updatedAt = now, this.#write(state), this.#publish(), this.#wake(), result;
};
if (this.#locks)
return this.#locks.request(
READER_REQUEST_PERMIT_LOCK,
{ mode: "exclusive" },
execute
);
const transaction = this.#localTransactionTail.catch(() => {
}).then(execute);
return this.#localTransactionTail = transaction.then(
() => {
},
() => {
}
), transaction;
}
#read(now) {
try {
const stored = this.#storage.getItem(READER_REQUEST_PERMIT_STORAGE_KEY);
if (stored)
return normalizeState(JSON.parse(stored), now, this.#longWindowMs);
} catch (error) {
if (this.#onError(error), this.#locks) throw error;
}
return normalizeState(
this.#locks ? null : this.#fallbackState,
now,
this.#longWindowMs
);
}
#write(state) {
this.#locks || (this.#fallbackState = normalizeState(state, this.#now(), this.#longWindowMs));
try {
this.#storage.setItem(
READER_REQUEST_PERMIT_STORAGE_KEY,
JSON.stringify(state)
);
} catch (error) {
if (this.#onError(error), this.#locks) throw error;
}
}
#publish() {
this.#postChannelMessage("updated");
}
#postChannelMessage(type) {
try {
this.#channel?.postMessage(Object.freeze({
schemaVersion: 1,
sourceId: this.#sourceId,
type
}));
} catch (error) {
this.#onError(error);
}
}
#wait(milliseconds, signal) {
return new Promise((resolve, reject) => {
let settled = !1;
const earliestWakeAt = this.#now() + 25, finish = (error) => {
settled || (settled = !0, clearTimeout(timer), this.#waiters.delete(wake), signal.removeEventListener("abort", abort), error !== void 0 ? reject(error) : resolve());
}, wake = () => {
const remaining = earliestWakeAt - this.#now();
if (remaining > 0) {
clearTimeout(timer), timer = setTimeout(wake, remaining);
return;
}
finish();
}, abort = () => finish(this.#abortReason(signal));
let timer = setTimeout(
wake,
Math.max(25, Math.min(1e3, milliseconds || 80))
);
this.#waiters.add(wake), signal.addEventListener("abort", abort, { once: !0 });
});
}
async #removeOwnedState() {
try {
await this.#transact((state, now) => {
state.intents = state.intents.filter(
(intent) => intent.ownerId !== this.#sourceId
), state.active = state.active.filter(
(permit) => permit.ownerId !== this.#sourceId
), state.policies = state.policies.filter(
(policy) => policy.ownerId !== this.#sourceId
), state.rateLimits = state.rateLimits.map((lease) => lease.probeOwnerId !== this.#sourceId ? lease : Object.freeze({
scope: lease.scope,
route: lease.route,
hits: lease.hits,
authoritative: lease.authoritative,
lastObservedAt: lease.lastObservedAt,
retryAt: Math.max(
lease.retryAt,
now + READER_RATE_LIMIT_PROBE_FAILURE_WAIT_MS
),
expiresAt: Math.max(
lease.expiresAt,
now + this.#permitTtlMs
)
})), state.challenge?.state === "active" && state.challenge.ownerId === this.#sourceId && (state.challenge = Object.freeze({
ownerId: "",
state: "active",
required: !0,
automaticAttempted: !0,
recoveryProbeAttempted: state.challenge.recoveryProbeAttempted === !0,
...storedChallengeProbeState(state.challenge),
updatedAt: now,
expiresAt: now + this.#challengeMaxWaitMs
}));
});
} catch (error) {
this.#onError(error);
}
}
#wake() {
for (const waiter of this.#waiters) waiter();
this.#waiters.clear();
}
#assertOpen() {
if (this.#closed || this.scope.destroyed)
throw new Error("BrowserSharedRequestPermit 已销毁");
}
#abortReason(signal) {
return signal.reason ?? new DOMException("Aborted", "AbortError");
}
#onChannelMessage = (event) => {
const message = event.data;
message?.schemaVersion === 1 && message.sourceId !== this.#sourceId && message.type === "challenge-focus" && this.#challengePromise && (this.#challengeFocusRequested = !0, this.#focusChallengeWindow()), this.#wake();
};
#onStorage = (event) => {
const key = event.key;
(key === null || key === READER_REQUEST_PERMIT_STORAGE_KEY) && this.#wake();
};
}
}, "adba9b8251b6c7fa63800935d91f471ed0b617fe5dffcb47286acfe406e0a859");
/* Source: lite/src/network/coordinated-request-client.ts */
runtime.register("src/network/coordinated-request-client.js", function(module, exports, require) {
var coordinated_request_client_exports = {};
__export(coordinated_request_client_exports, {
CoordinatedRequestClient: () => CoordinatedRequestClient,
RequestChallengeWaitSuppressedError: () => RequestChallengeWaitSuppressedError,
RequestCloudflareChallengeError: () => RequestCloudflareChallengeError,
RequestRateLimitError: () => RequestRateLimitError,
RequestStatusError: () => RequestStatusError,
abortableDelay: () => abortableDelay,
requestFailureKind: () => requestFailureKind
});
module.exports = __toCommonJS(coordinated_request_client_exports);
var import_request_scheduler = require("./request-scheduler.js");
const PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
function requestFailureKind(status) {
return status === 400 || status === 422 ? "validation" : status === 401 ? "authentication" : status === 403 ? "forbidden" : status === 404 || status === 410 ? "not-found" : status === 408 ? "timeout" : status === 409 || status === 412 ? "conflict" : status === 429 ? "rate-limit" : status === 425 || status >= 500 && status <= 599 ? "server" : status >= 400 && status <= 499 ? "client" : "unknown";
}
class RequestStatusError extends Error {
status;
cloudflareMitigated;
kind;
constructor(status, options = {}) {
super(`HTTP ${status}`), this.name = "RequestStatusError", this.status = status, this.cloudflareMitigated = options.cloudflareMitigated === !0, this.kind = options.kind ?? requestFailureKind(status);
}
}
class RequestCloudflareChallengeError extends RequestStatusError {
href;
constructor(status, href) {
super(status, {
cloudflareMitigated: !0,
kind: "cloudflare"
}), this.name = "RequestCloudflareChallengeError", this.href = String(href);
}
}
class RequestChallengeWaitSuppressedError extends Error {
code = "challenge-superseded";
cloudflareMitigated = !0;
constructor() {
super("排队期间已进入 Cloudflare 验证,本次写入不在过盾后自动追发"), this.name = "RequestChallengeWaitSuppressedError";
}
}
class RequestRateLimitError extends RequestStatusError {
decision;
constructor(decision) {
super(429), this.name = "RequestRateLimitError", this.decision = decision;
}
}
function nonNegativeInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return value;
}
function abortableDelay(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(timer), reject(signal.reason);
}, timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort), resolve();
}, milliseconds);
signal.addEventListener("abort", onAbort, { once: !0 });
});
}
class CoordinatedRequestClient {
scheduler;
rateLimitPolicy;
permitPort;
#delay;
#defaultMax429Retries;
#defaultMaxChallengeRetries;
#observer;
#now;
#onCoordinationError;
#requests = /* @__PURE__ */ new Map();
#disposed = !1;
#logicalSequence = 0;
constructor(options) {
this.rateLimitPolicy = options.rateLimitPolicy, this.permitPort = options.permitPort, this.scheduler = new import_request_scheduler.RequestScheduler({
...options.scheduler,
startGate: options.permitPort
}), this.#delay = options.delay ?? abortableDelay, this.#defaultMax429Retries = nonNegativeInteger(
options.defaultMax429Retries ?? 1,
"defaultMax429Retries"
), this.#defaultMaxChallengeRetries = nonNegativeInteger(
options.defaultMaxChallengeRetries ?? 1,
"defaultMaxChallengeRetries"
), this.#observer = options.observer ?? null, this.#now = options.now ?? Date.now, this.#onCoordinationError = options.onCoordinationError ?? (() => {
});
}
request(options, transport) {
const key = String(options.key).trim();
if (!key) return Promise.reject(new Error("request key 不能为空"));
const priority = options.priority ?? "visible";
if (this.#disposed)
return Promise.reject(new Error("request client 已销毁"));
if (options.signal?.aborted)
return Promise.reject(options.signal.reason);
const existing = this.#requests.get(key);
if (existing && !existing.controller.signal.aborted)
return existing.joinedConsumers += 1, this.#promoteLogical(existing, {
priority,
droppable: options.droppable === !0,
max429Retries: options.max429Retries ?? this.#defaultMax429Retries,
maxChallengeRetries: options.maxChallengeRetries ?? this.#defaultMaxChallengeRetries
}), this.#updateObservation(existing, {
joinedConsumers: existing.joinedConsumers
}), this.#join(existing, options.signal);
const controller = new AbortController(), logical = {
promise: Promise.resolve(void 0),
controller,
consumers: /* @__PURE__ */ new Set(),
unabortableConsumer: !1,
settled: !1,
priority,
droppable: options.droppable === !0,
max429Retries: 0,
maxChallengeRetries: 0,
currentAttemptKey: "",
logicalId: `L${++this.#logicalSequence}`,
observationId: null,
joinedConsumers: 0,
promoted: !1
}, promise = this.#run(logical, options, transport).finally(() => {
logical.settled = !0, this.#requests.get(key) === logical && this.#requests.delete(key);
});
return logical.promise = promise, this.#requests.set(key, logical), this.#join(logical, options.signal);
}
promote(keyValue, promotion) {
const key = String(keyValue).trim();
if (!key || this.#disposed) return !1;
const logical = this.#requests.get(key);
return !logical || logical.controller.signal.aborted || logical.settled ? !1 : (this.#promoteLogical(logical, promotion), !0);
}
#join(logical, signal) {
if (!signal)
return logical.unabortableConsumer = !0, logical.promise;
if (signal.aborted) return Promise.reject(signal.reason);
const consumer = Symbol("request-consumer");
return logical.consumers.add(consumer), new Promise((resolve, reject) => {
let settled = !1;
const finish = (complete, value) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), logical.consumers.delete(consumer), complete(value));
}, fail = (cause) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), logical.consumers.delete(consumer), reject(cause));
}, onAbort = () => {
fail(signal.reason), !logical.settled && !logical.unabortableConsumer && logical.consumers.size === 0 && !logical.controller.signal.aborted && logical.controller.abort(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: !0 }), logical.promise.then(
(value) => finish(resolve, value),
fail
);
});
}
applyRuntimePolicy(policy) {
this.scheduler.applyRuntimePolicy(policy);
}
/**
* 把中央 429 错误投影成长任务可等待的续传凭据。
*
* 使用 retryAt 计算剩余时间,避免错误沿调用栈返回后再次等待完整 Retry-After;
* 等待结束只允许长任务重新排队,绝不替代下一次请求的 scheduler/shared permit。
*/
rateLimitResume(error) {
if (this.#disposed || !(error instanceof RequestRateLimitError)) return null;
const waitMs = Math.max(
0,
Math.ceil(error.decision.retryAt - this.#now())
);
return Object.freeze({
decision: error.decision,
waitMs,
wait: (signal) => this.#delay(waitMs, signal)
});
}
/**
* 把长任务中断统一投影为可取消恢复凭据。
*
* 普通 429 复用 Retry-After;Cloudflare 只等待共享 challenge lease,不在长任务
* 内重放探针、打开第二个窗口或绕过 scheduler。
*/
requestResume(error) {
const rateLimit = this.rateLimitResume(error);
if (rateLimit)
return Object.freeze({
...rateLimit,
kind: "rate-limit"
});
if (this.#disposed || !(error instanceof RequestCloudflareChallengeError) || !this.permitPort.resolveCloudflareChallenge) return null;
const resolve = this.permitPort.resolveCloudflareChallenge.bind(
this.permitPort
);
return Object.freeze({
kind: "cloudflare-challenge",
waitMs: 0,
wait: async (signal) => {
if (signal.aborted) throw signal.reason;
if (!await resolve({
href: error.href,
signal
})) throw error;
}
});
}
/** 清除本实例的 429 范围判定证据;共享固定窗口与已记录启动次数原样保留。 */
async resetRateLimits() {
this.rateLimitPolicy.reset(), await this.permitPort.resetRateLimits?.();
}
destroy() {
if (!this.#disposed) {
this.#disposed = !0;
for (const request of this.#requests.values())
request.controller.abort(new Error("request client 已销毁"));
this.scheduler.destroy();
}
}
async #run(logical, options, transport) {
const method = String(options.method ?? "GET").toUpperCase(), rateLimitRoute = this.rateLimitPolicy.identity(
options.input,
method
).route;
logical.max429Retries = nonNegativeInteger(
options.max429Retries ?? this.#defaultMax429Retries,
"max429Retries"
), logical.maxChallengeRetries = nonNegativeInteger(
options.maxChallengeRetries ?? this.#defaultMaxChallengeRetries,
"maxChallengeRetries"
);
let rateLimitRetries = 0, challengeRetries = 0;
for (let attempt = 0; ; attempt += 1) {
logical.currentAttemptKey = `${options.key}:attempt:${attempt}`;
const queuedAt = this.#now();
let observationId = this.#beginQueuedObservation(
logical,
options,
method,
attempt,
logical.priority,
queuedAt
);
logical.observationId = observationId, this.#updateObservation(logical, {
joinedConsumers: logical.joinedConsumers,
promoted: logical.promoted,
max429Retries: logical.max429Retries,
maxChallengeRetries: logical.maxChallengeRetries,
droppable: logical.droppable
});
let observationStarted = !1, attemptWaitReason = "", attemptRateLimitRecoveryProbe = !1, response;
try {
response = await this.scheduler.schedule(
{
key: logical.currentAttemptKey,
priority: logical.priority,
lane: options.lane ?? "standard",
rateLimitRoute,
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
signal: logical.controller.signal,
droppable: logical.droppable,
onStart: (timing) => {
observationStarted = !0, attemptWaitReason = timing.waitReason, attemptRateLimitRecoveryProbe = timing.recoveryProbe, observationId = this.#markObservationStarted(
observationId,
logical,
options,
method,
attempt,
logical.priority,
timing
), logical.observationId = observationId;
}
},
(signal) => {
if (options.suppressAfterChallengeWait === !0 && attemptWaitReason === "challenge")
throw new RequestChallengeWaitSuppressedError();
return transport({ signal, attempt });
}
);
} catch (error) {
logical.currentAttemptKey = "", attemptRateLimitRecoveryProbe && await this.#noteRateLimitProbeResult(rateLimitRoute, !1);
const reason = this.#errorCode(error);
throw observationId === null ? this.#recordControlled(
logical,
options,
method,
attempt,
reason,
queuedAt
) : observationStarted ? this.#finishObservation(observationId, {
error: reason,
decision: reason,
cloudflareMitigated: !!error && typeof error == "object" && "cloudflareMitigated" in error && error.cloudflareMitigated === !0
}) : this.#cancelObservation(observationId, reason), error;
}
logical.currentAttemptKey = "", attemptRateLimitRecoveryProbe && (response.status !== 429 || response.cloudflareMitigated === !0) && await this.#noteRateLimitProbeResult(rateLimitRoute, !0);
const rateLimitRetryEligible = response.status === 429 && response.cloudflareMitigated !== !0 && [
"critical",
"interactive",
"nested",
"visible"
].includes(logical.priority) && rateLimitRetries < logical.max429Retries, challengeResolutionEligible = response.cloudflareMitigated === !0 && options.blockOnCloudflareChallenge !== !1 && !!this.permitPort.resolveCloudflareChallenge && challengeRetries < logical.maxChallengeRetries, decision = response.ok ? "complete" : response.cloudflareMitigated === !0 ? options.blockOnCloudflareChallenge === !1 ? "stop-cloudflare-isolated" : challengeResolutionEligible ? "await-cloudflare" : this.permitPort.noteCloudflareChallenge || this.permitPort.resolveCloudflareChallenge ? "require-cloudflare" : "stop-cloudflare-unhandled" : response.status === 429 ? rateLimitRetryEligible ? "retry-429" : "stop-429" : "stop-http";
if (this.#finishObservation(observationId, {
status: response.status,
cloudflareMitigated: response.cloudflareMitigated === !0,
decision,
...response.retryAfter === void 0 ? {} : { retryAfter: String(response.retryAfter ?? "") },
...response.rateLimitCode === void 0 ? {} : { rateLimitCode: String(response.rateLimitCode ?? "") },
...response.serverLimit === void 0 ? {} : { serverLimit: String(response.serverLimit ?? "") },
...response.serverRemaining === void 0 ? {} : { serverRemaining: String(response.serverRemaining ?? "") },
...response.serverReset === void 0 ? {} : { serverReset: String(response.serverReset ?? "") }
}), !response.ok && response.cloudflareMitigated === !0) {
if (options.blockOnCloudflareChallenge === !1)
throw new RequestStatusError(response.status, {
cloudflareMitigated: !0
});
if (!this.permitPort.noteCloudflareChallenge && !this.permitPort.resolveCloudflareChallenge)
throw new RequestStatusError(response.status, {
cloudflareMitigated: !0,
kind: "cloudflare"
});
let passed = !1;
try {
this.permitPort.resolveCloudflareChallenge && challengeRetries < logical.maxChallengeRetries ? passed = await this.permitPort.resolveCloudflareChallenge({
href: String(options.input),
signal: logical.controller.signal
}) : await this.permitPort.noteCloudflareChallenge?.({
href: String(options.input),
force: challengeRetries > 0
});
} catch (error) {
if (logical.controller.signal.aborted) throw error;
this.#onCoordinationError(error);
try {
await this.permitPort.noteCloudflareChallenge?.({
href: String(options.input),
force: challengeRetries > 0
});
} catch (fallbackError) {
this.#onCoordinationError(fallbackError);
}
}
if (passed) {
this.rateLimitPolicy.reset(), challengeRetries += 1, this.#updateObservation(logical, {
decision: "challenge-passed-retry"
});
continue;
}
throw this.#updateObservation(logical, {
decision: "challenge-required"
}), new RequestCloudflareChallengeError(
response.status,
String(options.input)
);
}
if (response.status === 429) {
const decision2 = this.rateLimitPolicy.noteRateLimit({
input: options.input,
method,
...response.retryAfter === void 0 ? {} : { retryAfter: response.retryAfter },
knownGlobalWindow: response.knownGlobalRateLimitWindow === !0,
...response.rateLimitWindow === void 0 ? {} : { globalWindow: response.rateLimitWindow }
});
try {
await this.permitPort.noteRateLimit(decision2);
} catch (error) {
this.#onCoordinationError(error);
}
if (!rateLimitRetryEligible) throw new RequestRateLimitError(decision2);
rateLimitRetries += 1, await this.#delay(decision2.waitMs, logical.controller.signal);
continue;
}
if (!response.ok)
throw new RequestStatusError(response.status, {
cloudflareMitigated: response.cloudflareMitigated === !0
});
return response.value;
}
}
async #noteRateLimitProbeResult(route, recovered) {
try {
await this.permitPort.noteRateLimitProbeResult?.({ route, recovered });
} catch (error) {
this.#onCoordinationError(error);
}
}
#promoteLogical(logical, promotion) {
const previousPriority = logical.priority, previousDroppable = logical.droppable, previous429Retries = logical.max429Retries, previousChallengeRetries = logical.maxChallengeRetries;
logical.droppable = logical.droppable && promotion.droppable, promotion.max429Retries !== void 0 && (logical.max429Retries = Math.max(
logical.max429Retries,
nonNegativeInteger(promotion.max429Retries, "max429Retries")
)), promotion.maxChallengeRetries !== void 0 && (logical.maxChallengeRetries = Math.max(
logical.maxChallengeRetries,
nonNegativeInteger(
promotion.maxChallengeRetries,
"maxChallengeRetries"
)
)), PRIORITY_WEIGHT[promotion.priority] < PRIORITY_WEIGHT[logical.priority] && (logical.priority = promotion.priority), (logical.priority !== previousPriority || logical.droppable !== previousDroppable || logical.max429Retries !== previous429Retries || logical.maxChallengeRetries !== previousChallengeRetries) && (logical.promoted = !0, this.#updateObservation(logical, {
priority: logical.priority,
promoted: !0,
max429Retries: logical.max429Retries,
maxChallengeRetries: logical.maxChallengeRetries,
droppable: logical.droppable
})), logical.currentAttemptKey && this.scheduler.promoteQueued(
logical.currentAttemptKey,
logical.priority,
logical.droppable
);
}
#beginQueuedObservation(logical, options, method, attempt, priority, queuedAt) {
if (!this.#observer) return null;
try {
return this.#observer.begin({
href: String(options.input),
method,
transport: "scheduler",
source: "reader",
phase: "queued",
queuedAt,
priority,
attempt: attempt + 1,
recoveryProbe: attempt > 0,
callSite: options.callSite ?? "",
logicalId: logical.logicalId,
profile: options.profile ?? "",
namespace: options.namespace ?? "",
lane: options.lane ?? "standard",
cacheMode: options.cacheMode ?? "",
...options.identity === void 0 ? {} : { identity: options.identity },
max429Retries: logical.max429Retries,
maxChallengeRetries: logical.maxChallengeRetries,
blockOnCloudflareChallenge: options.blockOnCloudflareChallenge !== !1,
suppressAfterChallengeWait: options.suppressAfterChallengeWait === !0,
droppable: logical.droppable
});
} catch (error) {
return this.#onCoordinationError(error), null;
}
}
#markObservationStarted(id, logical, options, method, attempt, priority, timing) {
if (!this.#observer) return null;
try {
return id !== null && this.#observer.markStarted({
id,
queuedAt: timing.queuedAt,
permittedAt: timing.permittedAt,
startedAt: timing.startedAt,
priority,
recoveryProbe: timing.recoveryProbe,
waitReason: timing.waitReason || (timing.permittedAt - timing.queuedAt > 0.5 ? "scheduler" : "")
}) ? id : this.#observer.begin({
href: String(options.input),
method,
transport: "scheduler",
source: "reader",
phase: "running",
queuedAt: timing.queuedAt,
permittedAt: timing.permittedAt,
startedAt: timing.startedAt,
priority,
attempt: attempt + 1,
recoveryProbe: timing.recoveryProbe,
waitReason: timing.waitReason || (timing.permittedAt - timing.queuedAt > 0.5 ? "scheduler" : ""),
callSite: options.callSite ?? "",
logicalId: logical.logicalId,
profile: options.profile ?? "",
namespace: options.namespace ?? "",
lane: options.lane ?? "standard",
cacheMode: options.cacheMode ?? "",
...options.identity === void 0 ? {} : { identity: options.identity },
max429Retries: logical.max429Retries,
maxChallengeRetries: logical.maxChallengeRetries,
blockOnCloudflareChallenge: options.blockOnCloudflareChallenge !== !1,
suppressAfterChallengeWait: options.suppressAfterChallengeWait === !0,
droppable: logical.droppable
});
} catch (error) {
return this.#onCoordinationError(error), null;
}
}
#cancelObservation(id, reason) {
if (this.#observer)
try {
this.#observer.cancel(id, { reason }) || this.#observer.finish(id, { error: reason });
} catch (error) {
this.#onCoordinationError(error);
}
}
#finishObservation(id, input) {
if (!(id === null || !this.#observer))
try {
this.#observer.finish(id, input);
} catch (error) {
this.#onCoordinationError(error);
}
}
#recordControlled(logical, options, method, attempt, reason, queuedAt = this.#now()) {
if (this.#observer)
try {
this.#observer.begin({
href: String(options.input),
method,
transport: "scheduler",
source: "reader",
queuedAt,
startedAt: this.#now(),
priority: options.priority ?? "visible",
attempt: attempt + 1,
recoveryProbe: attempt > 0,
waitReason: reason,
callSite: options.callSite ?? "",
controlReason: reason,
logicalId: logical.logicalId,
profile: options.profile ?? "",
namespace: options.namespace ?? "",
lane: options.lane ?? "standard",
cacheMode: options.cacheMode ?? "",
...options.identity === void 0 ? {} : { identity: options.identity },
max429Retries: logical.max429Retries,
maxChallengeRetries: logical.maxChallengeRetries,
blockOnCloudflareChallenge: options.blockOnCloudflareChallenge !== !1,
suppressAfterChallengeWait: options.suppressAfterChallengeWait === !0,
droppable: logical.droppable
});
} catch (error) {
this.#onCoordinationError(error);
}
}
#updateObservation(logical, input) {
if (!(logical.observationId === null || !this.#observer))
try {
this.#observer.update(logical.observationId, input);
} catch (error) {
this.#onCoordinationError(error);
}
}
#errorCode(error) {
if (error && typeof error == "object") {
const message = "message" in error ? String(error.message ?? "") : "";
if (message.includes("滚出当前视口")) return "viewport-change";
if (message.includes("升级为可见快车道")) return "priority-upgrade";
if (message.includes("已切换") || message.includes("打开已被替代") || message.includes("新的打开事务")) return "topic-switch";
if (message.includes("已关闭") || message.includes("离开 Topic") || message.includes("读取链已结束") || message.includes("Topic session closed")) return "topic-close";
if (message.includes("request client 已销毁") || message.includes("Reader runtime 已销毁") || message.includes("Reader application 已销毁")) return "context-close";
const code = "code" in error && typeof error.code == "string" ? error.code : "";
if (code) return code.slice(0, 80);
const name = "name" in error ? String(error.name ?? "") : "";
if (name) return name.slice(0, 80);
}
return "request-failed";
}
}
}, "4a89e55d79098b3bef1201dae3652bb342c9bdd9a480f4ed7668b60a1ce7e828");
/* Source: lite/src/network/discourse-native-read-transport.ts */
runtime.register("src/network/discourse-native-read-transport.js", function(module, exports, require) {
var discourse_native_read_transport_exports = {};
__export(discourse_native_read_transport_exports, {
BrowserDiscourseNativeAjaxPort: () => BrowserDiscourseNativeAjaxPort,
BrowserDiscourseNativeMutationTransport: () => BrowserDiscourseNativeMutationTransport,
BrowserDiscourseNativeReadTransport: () => BrowserDiscourseNativeReadTransport,
discourseNativeAjaxAvailable: () => discourseNativeAjaxAvailable,
discourseNativeFailureResponse: () => discourseNativeFailureResponse
});
module.exports = __toCommonJS(discourse_native_read_transport_exports);
var import_native_request_descriptors = require("../discourse/native-request-descriptors.js"), import_request_rate_limit_policy = require("./request-rate-limit-policy.js"), import_value_record = require("../kernel/value-record.js");
const nativeReadTransportBrand = Symbol("DiscourseNativeReadTransport"), nativeMutationTransportBrand = Symbol("DiscourseNativeMutationTransport");
function statusFromError(error) {
const record = (0, import_value_record.objectRecord)(error), response = (0, import_value_record.objectRecord)(record?.response), xhr = (0, import_value_record.objectRecord)(record?.jqXHR);
for (const value of [
record?.status,
record?.statusCode,
response?.status,
xhr?.status
]) {
const status = Number(value);
if (Number.isSafeInteger(status) && status >= 100 && status <= 599)
return status;
}
return 0;
}
function responseHeader(error, name) {
const record = (0, import_value_record.objectRecord)(error), candidates = [error, record?.jqXHR, record?.response];
for (const candidate of candidates) {
const getResponseHeader = (0, import_value_record.objectRecord)(candidate)?.getResponseHeader;
if (typeof getResponseHeader == "function")
try {
const value = getResponseHeader.call(candidate, name);
if (value != null && String(value).trim())
return String(value);
} catch {
}
}
return null;
}
function retryAfterFromError(error) {
const record = (0, import_value_record.objectRecord)(error), response = (0, import_value_record.objectRecord)(record?.response);
for (const value of [
record?.retryAfter,
record?.retry_after,
response?.retryAfter,
response?.retry_after,
responseHeader(error, "Retry-After")
])
if (value != null && String(value).trim())
return String(value);
return null;
}
function responseTextFromError(error) {
const record = (0, import_value_record.objectRecord)(error), candidates = [error, record?.jqXHR, record?.response];
for (const candidate of candidates) {
const response = (0, import_value_record.objectRecord)(candidate);
for (const value of [
response?.responseText,
response?.body,
response?.data
])
if (typeof value == "string" && value.trim()) return value;
}
return "";
}
function cloudflareChallengeHtml(error) {
const body = responseTextFromError(error);
return body ? /<title>\s*(?:Just a moment(?:\.\.\.)?|请稍候(?:…|\.\.\.)?)\s*<\/title>/i.test(body) || /window\._cf_chl_opt\b/.test(body) && /\/cdn-cgi\/challenge-platform\//.test(body) : !1;
}
function cloudflareMitigatedFromError(error) {
const record = (0, import_value_record.objectRecord)(error), response = (0, import_value_record.objectRecord)(record?.response), xhr = (0, import_value_record.objectRecord)(record?.jqXHR);
return record?.cloudflareMitigated === !0 || response?.cloudflareMitigated === !0 || xhr?.cloudflareMitigated === !0 || responseHeader(error, "cf-mitigated")?.trim().toLowerCase() === "challenge" || cloudflareChallengeHtml(error);
}
function discourseNativeFailureResponse(error) {
const status = statusFromError(error);
if (!status) return null;
const rateLimitCode = responseHeader(error, "Discourse-Rate-Limit-Error-Code") ?? responseHeader(error, "X-Discourse-Rate-Limit-Error-Code") ?? "", rateLimitWindow = (0, import_request_rate_limit_policy.rateLimitWindowFromCode)(rateLimitCode);
return Object.freeze({
ok: !1,
status,
value: void 0,
retryAfter: retryAfterFromError(error),
rateLimitCode,
rateLimitWindow,
knownGlobalRateLimitWindow: rateLimitWindow !== "unknown",
cloudflareMitigated: cloudflareMitigatedFromError(error)
});
}
function normalizedOrigin(value) {
const normalized = String(value ?? "").trim();
return normalized ? new URL(normalized).origin : "";
}
function nativePath(value, origin) {
const normalized = String(value).trim();
if (!normalized) throw new Error("Discourse 原生读取 path 不能为空");
if (/^[\\/]{2}/.test(normalized))
throw new Error("Discourse 原生读取拒绝跨源 URL");
if (normalized.startsWith("/")) return normalized;
let parsed;
try {
parsed = new URL(normalized);
} catch {
throw new Error("Discourse 原生读取只接受站内绝对路径");
}
if (!origin || parsed.origin !== origin)
throw new Error("Discourse 原生读取拒绝跨源 URL");
return `${parsed.pathname}${parsed.search}`;
}
function resolveNativeAjax(host) {
const loaded = host.lookupModule("discourse/lib/ajax"), module2 = (0, import_value_record.objectRecord)(loaded), defaultExport = (0, import_value_record.objectRecord)(module2?.default), owner = module2 && typeof module2.ajax == "function" ? module2 : defaultExport && typeof defaultExport.ajax == "function" ? defaultExport : null;
if (!owner)
throw new Error("Discourse 原生模块 discourse/lib/ajax#ajax 不可用");
return {
owner,
ajax: owner.ajax
};
}
function discourseNativeAjaxAvailable(host) {
try {
return resolveNativeAjax(host), !0;
} catch {
return !1;
}
}
async function executeNativeAjax(resolved, origin, input) {
if (input.signal.aborted) throw input.signal.reason;
const path = nativePath(input.path, origin), options = {
type: input.method,
...input.headers && Object.keys(input.headers).length ? { headers: { ...input.headers } } : {},
...input.data === void 0 ? {} : { data: input.multipart ? input.data : { ...input.data } },
...input.multipart ? { processData: !1, contentType: !1 } : {},
...input.noStore ? { cache: !1 } : {}
};
let pending;
try {
pending = resolved.ajax.call(resolved.owner, path, options);
} catch (error) {
const failure = discourseNativeFailureResponse(error);
if (!failure) throw error;
return failure;
}
const abort = () => {
try {
pending.abort?.();
} catch {
}
};
input.signal.addEventListener("abort", abort, { once: !0 });
try {
const value = await pending;
if (input.signal.aborted) throw input.signal.reason;
return { ok: !0, status: 200, value };
} catch (error) {
if (input.signal.aborted) throw input.signal.reason;
const failure = discourseNativeFailureResponse(error);
if (!failure) throw error;
return failure;
} finally {
input.signal.removeEventListener("abort", abort);
}
}
class BrowserDiscourseNativeAjaxPort {
nativeBinding = "discourse/lib/ajax#ajax";
#host;
#origin;
#resolved = null;
constructor(host, options = {}) {
this.#host = host, this.#origin = normalizedOrigin(options.origin);
}
request(input) {
return this.#resolved ??= resolveNativeAjax(this.#host), executeNativeAjax(this.#resolved, this.#origin, input);
}
}
class BrowserDiscourseNativeReadTransport {
nativeBinding = "discourse/lib/ajax#ajax";
[nativeReadTransportBrand] = !0;
#ajax;
constructor(host, options = {}) {
this.#ajax = host instanceof BrowserDiscourseNativeAjaxPort ? host : new BrowserDiscourseNativeAjaxPort(host, options);
}
async request(input) {
return (0, import_native_request_descriptors.assertDiscourseNativeReadDescriptor)(input.descriptor), this.#ajax.request({
path: input.descriptor.path,
method: "GET",
signal: input.signal,
headers: input.descriptor.headers,
noStore: input.descriptor.browserCache === "no-store"
});
}
}
class BrowserDiscourseNativeMutationTransport {
nativeBinding = "discourse/lib/ajax#ajax";
[nativeMutationTransportBrand] = !0;
#ajax;
constructor(host, options = {}) {
this.#ajax = host instanceof BrowserDiscourseNativeAjaxPort ? host : new BrowserDiscourseNativeAjaxPort(host, options);
}
request(input) {
return (0, import_native_request_descriptors.assertDiscourseNativeMutationDescriptor)(input.descriptor), this.#ajax.request({
path: input.descriptor.path,
method: input.descriptor.method,
signal: input.signal,
headers: input.descriptor.headers,
data: input.descriptor.data,
multipart: input.descriptor.multipart === !0,
noStore: !0
});
}
}
}, "f7a36c4992ee35870fd61e99a2b31a5eef89e31eecf72338f77e8a4f07d70e59");
/* Source: lite/src/network/domain-request-gateway.ts */
runtime.register("src/network/domain-request-gateway.js", function(module, exports, require) {
var domain_request_gateway_exports = {};
__export(domain_request_gateway_exports, {
DomainRequestGateway: () => DomainRequestGateway
});
module.exports = __toCommonJS(domain_request_gateway_exports);
var import_request_contract = require("./request-contract.js"), import_request_identities = require("./request-identities.js");
const PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
function cachePolicy(contract, settings) {
return Object.freeze({
id: contract.cacheKey,
kind: settings.kind,
tags: Object.freeze([...new Set(settings.tags.map(String))].sort()),
freshForMs: settings.freshForMs,
retainForMs: settings.retainForMs,
persist: settings.persist
});
}
class DomainRequestGateway {
#client;
#responses;
#executions = /* @__PURE__ */ new Map();
constructor(client, responses) {
this.#client = client, this.#responses = responses;
}
loadTopicPosts(input) {
return this.#execute({
...input,
profile: input.profile ?? "topic-visible",
lane: "topic-batch",
namespace: "topic-posts",
identity: (0, import_request_identities.topicPostsRequestIdentity)(input)
});
}
loadTopicTarget(input) {
return this.#execute({
...input,
profile: input.profile ?? "topic-visible",
lane: "topic-batch",
namespace: "topic-target",
identity: (0, import_request_identities.topicRequestIdentity)(input)
});
}
async cachedTopicTarget(input) {
const contract = (0, import_request_contract.createRequestContract)(input.profile ?? "topic-visible", {
namespace: "topic-target",
identity: (0, import_request_identities.topicRequestIdentity)(input)
}), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
loadNestedReplies(input) {
return this.#execute({
...input,
profile: input.profile ?? "nested-visible",
lane: "nested-replies",
namespace: "topic-nested",
identity: (0, import_request_identities.nestedRequestIdentity)(input)
});
}
promoteTopicPosts(input) {
const contract = (0, import_request_contract.createRequestContract)(
input.profile ?? "topic-visible",
{
namespace: "topic-posts",
identity: (0, import_request_identities.topicPostsRequestIdentity)(input),
...input.cacheMode === void 0 ? {} : { cacheMode: input.cacheMode }
}
);
return this.#promoteExecution(contract);
}
promoteNestedReplies(input) {
const contract = (0, import_request_contract.createRequestContract)(
input.profile ?? "nested-visible",
{
namespace: "topic-nested",
identity: (0, import_request_identities.nestedRequestIdentity)(input),
...input.cacheMode === void 0 ? {} : { cacheMode: input.cacheMode }
}
);
return this.#promoteExecution(contract);
}
loadNotificationPage(input) {
return this.#execute({
...input,
profile: input.profile ?? "notification-visible",
lane: "standard",
namespace: "notifications",
identity: (0, import_request_identities.notificationRequestIdentity)(input)
});
}
async cachedNotificationPage(input) {
const contract = (0, import_request_contract.createRequestContract)(
input.profile ?? "notification-visible",
{
namespace: "notifications",
identity: (0, import_request_identities.notificationRequestIdentity)(input)
}
), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
loadCollectionPage(input) {
return this.#execute({
...input,
profile: input.profile ?? "collection-visible",
lane: "standard",
namespace: "reader-collection",
identity: (0, import_request_identities.collectionRequestIdentity)(input)
});
}
async cachedCollectionPage(input) {
const contract = (0, import_request_contract.createRequestContract)(
input.profile ?? "collection-visible",
{
namespace: "reader-collection",
identity: (0, import_request_identities.collectionRequestIdentity)(input)
}
), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
mutate(input) {
return this.#execute({
...input,
cacheMode: "no-store",
profile: "action-critical",
lane: "control",
namespace: "reader-action",
identity: (0, import_request_identities.actionRequestIdentity)(input)
});
}
loadActionPermission(input) {
return this.#execute({
...input,
cacheMode: "no-store",
profile: "action-permission",
lane: "control",
namespace: "reader-action-permission",
identity: (0, import_request_identities.actionRequestIdentity)(input)
});
}
submitReadState(input) {
return this.#execute({
...input,
cacheMode: "no-store",
profile: "read-critical",
lane: "control",
namespace: "topic-read-state",
identity: (0, import_request_identities.readRequestIdentity)(input)
});
}
translate(input) {
return this.#execute({
...input,
profile: input.profile ?? "translation-visible",
lane: "translation",
namespace: "reader-translation",
identity: (0, import_request_identities.translationRequestIdentity)(input)
});
}
loadResource(input) {
return this.#execute({
...input,
profile: input.profile ?? "resource-visible",
lane: "standard",
namespace: "reader-resource",
identity: (0, import_request_identities.resourceRequestIdentity)(input)
});
}
loadUserResource(input) {
return this.#execute({
...input,
profile: input.profile ?? "resource-visible",
lane: input.profile === "user-card-interactive" ? "user-card" : "standard",
namespace: "reader-user",
identity: (0, import_request_identities.userRequestIdentity)(input)
});
}
async cachedUserResource(input) {
const contract = (0, import_request_contract.createRequestContract)(input.profile ?? "resource-visible", {
namespace: "reader-user",
identity: (0, import_request_identities.userRequestIdentity)(input)
}), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
async cachedResource(input) {
const contract = this.#resourceContract(input), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
async cachedTranslation(input) {
const contract = this.#translationCacheContract(input), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
cacheTranslation(input, value) {
const contract = this.#translationCacheContract(input);
return this.#responses.write(cachePolicy(contract, input.cache), value);
}
invalidateResource(input) {
return this.#responses.invalidate({
ids: [this.#resourceContract(input).cacheKey]
});
}
invalidateResourceWithReport(input) {
return this.invalidateResourcesWithReport([input]);
}
invalidateResourcesWithReport(inputs) {
const ids = Object.freeze([...new Set(inputs.map(
(input) => this.#resourceContract(input).cacheKey
))]);
return ids.length ? this.#responses.invalidateWithReport({
ids
}) : Promise.resolve(Object.freeze({
memoryEntries: 0,
failures: Object.freeze([]),
complete: !0
}));
}
#resourceContract(input) {
return (0, import_request_contract.createRequestContract)("resource-visible", {
namespace: "reader-resource",
identity: (0, import_request_identities.resourceRequestIdentity)(input)
});
}
#translationCacheContract(input) {
return (0, import_request_contract.createRequestContract)("translation-visible", {
namespace: "reader-translation-section",
identity: (0, import_request_identities.translationRequestIdentity)(input)
});
}
#execute(input) {
if (input.signal.aborted) return Promise.reject(input.signal.reason);
const contract = (0, import_request_contract.createRequestContract)(input.profile, {
namespace: input.namespace,
identity: input.identity,
...input.cacheMode === void 0 ? {} : { cacheMode: input.cacheMode },
...input.timeoutMs === void 0 ? {} : { timeoutMs: input.timeoutMs }
});
if (contract.cacheMode !== "no-store" && !input.cache)
return Promise.reject(new Error(`${input.profile} 缺少 response cache settings`));
const execution = this.#acquireExecution(contract), network = async (signal) => {
if (input.beforeNetwork && execution.contract.priority === "background" && (await input.beforeNetwork(signal), signal.aborted))
throw signal.reason;
execution.started = !0;
const effective = execution.contract, requestOptions = {
key: effective.key,
input: input.input,
priority: effective.priority,
lane: input.lane,
timeoutMs: effective.timeoutMs,
droppable: effective.droppable,
max429Retries: effective.max429Retries,
maxChallengeRetries: effective.maxChallengeRetries,
blockOnCloudflareChallenge: effective.blockOnCloudflareChallenge !== !1,
suppressAfterChallengeWait: effective.suppressAfterChallengeWait === !0,
callSite: `${effective.profile} / ${input.namespace} / ${input.lane}`,
profile: effective.profile,
namespace: input.namespace,
cacheMode: effective.cacheMode,
identity: input.identity,
...input.method === void 0 ? {} : { method: input.method }
};
return this.#client.request(
{ ...requestOptions, signal },
input.transport
);
};
return (contract.cacheMode === "no-store" ? network(input.signal) : this.#responses.getOrLoad(
cachePolicy(contract, input.cache),
network,
{
cacheMode: contract.cacheMode,
signal: input.signal,
...input.allowStaleOnError === void 0 ? {} : { allowStaleOnError: input.allowStaleOnError },
...input.canFallback === void 0 ? {} : { canFallback: input.canFallback },
...input.mapStaleFallback === void 0 ? {} : { mapStaleFallback: input.mapStaleFallback }
}
)).finally(() => this.#releaseExecution(contract.key, execution));
}
#acquireExecution(contract) {
const existing = this.#executions.get(contract.key);
if (existing)
return existing.consumers += 1, this.#upgradeExecution(existing, contract), existing;
const created = {
contract,
consumers: 1,
started: !1
};
return this.#executions.set(contract.key, created), created;
}
#promoteExecution(contract) {
const execution = this.#executions.get(contract.key);
return execution ? (this.#upgradeExecution(execution, contract), !0) : this.#client.promote?.(
contract.key,
this.#requestPromotion(contract)
) ?? !1;
}
#upgradeExecution(execution, incoming) {
const current = execution.contract, winner = PRIORITY_WEIGHT[incoming.priority] < PRIORITY_WEIGHT[current.priority] ? incoming : current, merged = Object.freeze({
...winner,
droppable: current.droppable && incoming.droppable,
max429Retries: Math.max(
current.max429Retries,
incoming.max429Retries
),
maxChallengeRetries: Math.max(
current.maxChallengeRetries,
incoming.maxChallengeRetries
),
timeoutMs: Math.min(current.timeoutMs, incoming.timeoutMs)
});
(merged.priority !== current.priority || merged.droppable !== current.droppable || merged.max429Retries !== current.max429Retries || merged.maxChallengeRetries !== current.maxChallengeRetries || merged.timeoutMs !== current.timeoutMs) && (execution.contract = merged, execution.started && this.#client.promote?.(
merged.key,
this.#requestPromotion(merged)
));
}
#requestPromotion(contract) {
return Object.freeze({
priority: contract.priority,
droppable: contract.droppable,
max429Retries: contract.max429Retries,
maxChallengeRetries: contract.maxChallengeRetries
});
}
#releaseExecution(key, execution) {
execution.consumers = Math.max(0, execution.consumers - 1), execution.consumers === 0 && this.#executions.get(key) === execution && this.#executions.delete(key);
}
}
}, "bb5459015c86190f3bcc63a743091ec5cbd9b83e2d765c0ba2d93434fd630b3d");
/* Source: lite/src/network/public-resource-request-adapter.ts */
runtime.register("src/network/public-resource-request-adapter.js", function(module, exports, require) {
var public_resource_request_adapter_exports = {};
__export(public_resource_request_adapter_exports, {
BrowserPublicResourceHttpPort: () => BrowserPublicResourceHttpPort,
PublicResourceRequestAdapter: () => PublicResourceRequestAdapter
});
module.exports = __toCommonJS(public_resource_request_adapter_exports);
var import_coordinated_request_client = require("./coordinated-request-client.js"), import_request_rate_limit_policy = require("./request-rate-limit-policy.js");
const publicResourceDescriptorBrand = Symbol("PublicResourceHttpDescriptor"), publicResourceDescriptors = /* @__PURE__ */ new WeakSet();
function normalizedSource(rawSource, baseUrl) {
const source = String(rawSource).trim();
if (!source) throw new Error("资源 URL 不能为空");
const url = new URL(source, baseUrl);
if (!["http:", "https:", "blob:", "data:"].includes(url.protocol))
throw new Error(`不支持的资源协议:${url.protocol}`);
return url.hash = "", url.href;
}
function descriptor(source, validation) {
const value = Object.freeze({
url: source,
...validation === void 0 ? {} : { validation },
[publicResourceDescriptorBrand]: !0
});
return publicResourceDescriptors.add(value), value;
}
function assertDescriptor(input) {
if (!publicResourceDescriptors.has(input))
throw new Error("公共资源请求 descriptor 未登记");
const protocol = new URL(input.url).protocol;
if (!["http:", "https:", "blob:", "data:"].includes(protocol))
throw new Error(`公共资源请求拒绝协议 ${protocol}`);
}
class BrowserPublicResourceHttpPort {
#request;
constructor(options) {
this.#request = options.request;
}
async execute(input, request) {
if (assertDescriptor(input), request.signal.aborted) throw request.signal.reason;
const response = await this.#request(input.url, {
credentials: "omit",
cache: "force-cache",
signal: request.signal
}), contentType = String(response.headers.get("Content-Type") ?? "").trim().toLowerCase(), lastModified = Date.parse(String(
response.headers.get("Last-Modified") ?? ""
)), discourseBlankAvatar = input.validation === "discourse-avatar" && Number.isFinite(lastModified) && new Date(lastModified).getUTCFullYear() <= 1990, accepted = response.ok && (contentType === "" || contentType.startsWith("image/")) && !discourseBlankAvatar, value = accepted ? await response.blob() : new Blob(), rateLimitCode = response.headers.get("Discourse-Rate-Limit-Error-Code") ?? response.headers.get("X-Discourse-Rate-Limit-Error-Code") ?? "", rateLimitWindow = (0, import_request_rate_limit_policy.rateLimitWindowFromCode)(rateLimitCode);
return {
ok: accepted,
status: response.ok && !accepted ? 415 : response.status,
value,
retryAfter: response.headers.get("Retry-After"),
rateLimitCode,
rateLimitWindow,
knownGlobalRateLimitWindow: rateLimitWindow !== "unknown",
serverLimit: response.headers.get("X-RateLimit-Limit"),
serverRemaining: response.headers.get("X-RateLimit-Remaining"),
serverReset: response.headers.get("X-RateLimit-Reset"),
cloudflareMitigated: response.headers.get("cf-mitigated")?.trim().toLowerCase() === "challenge"
};
}
}
class PublicResourceRequestAdapter {
#gateway;
#http;
#baseUrl;
#cache;
constructor(options) {
this.#gateway = options.gateway, this.#http = options.http, this.#baseUrl = options.baseUrl, this.#cache = options.cache;
}
async load(rawSource, options) {
const source = normalizedSource(rawSource, this.#baseUrl), requestDescriptor = descriptor(source, options.validation), variant = options.validation === "discourse-avatar" ? "avatar-blob" : "blob";
if (source.startsWith("blob:") || source.startsWith("data:")) {
const response = await this.#http.execute(requestDescriptor, {
signal: options.signal,
attempt: 0
});
if (!response.ok) throw new import_coordinated_request_client.RequestStatusError(response.status);
return response.value;
}
return this.#gateway.loadResource({
resourceId: source,
variant,
input: source,
signal: options.signal,
cache: this.#cache,
...options.cacheMode === void 0 ? {} : { cacheMode: options.cacheMode },
...options.profile === void 0 ? {} : { profile: options.profile },
transport: (request) => this.#http.execute(requestDescriptor, request)
});
}
cached(rawSource) {
const source = normalizedSource(rawSource, this.#baseUrl);
return source.startsWith("blob:") || source.startsWith("data:") ? Promise.resolve(null) : this.#gateway.cachedResource({
resourceId: source,
variant: "blob",
cache: this.#cache
});
}
invalidate(rawSource) {
const source = normalizedSource(rawSource, this.#baseUrl);
return source.startsWith("blob:") || source.startsWith("data:") ? Promise.resolve() : this.#gateway.invalidateResource({
resourceId: source,
variant: "blob",
cache: this.#cache
});
}
invalidateWithReport(rawSource) {
return this.invalidateManyWithReport([rawSource]);
}
invalidateManyWithReport(rawSources) {
const sources = [...new Set(rawSources.map(
(rawSource) => normalizedSource(rawSource, this.#baseUrl)
))].filter(
(source) => !source.startsWith("blob:") && !source.startsWith("data:")
);
return sources.length ? this.#gateway.invalidateResourcesWithReport(
sources.map((source) => ({
resourceId: source,
variant: "blob"
}))
) : Promise.resolve(Object.freeze({
memoryEntries: 0,
failures: Object.freeze([]),
complete: !0
}));
}
normalize(rawSource) {
return normalizedSource(rawSource, this.#baseUrl);
}
}
}, "f4ce9f1cb8ba6a9e8c20b379a43ee1d44816760142d61347ee497ed2000549d5");
/* Source: lite/src/network/request-contract.ts */
runtime.register("src/network/request-contract.js", function(module, exports, require) {
var request_contract_exports = {};
__export(request_contract_exports, {
createRequestContract: () => createRequestContract,
requestProfileContract: () => requestProfileContract
});
module.exports = __toCommonJS(request_contract_exports);
function cacheModes(...values) {
return Object.freeze(values);
}
const PROFILES = Object.freeze({
"bootstrap-critical": Object.freeze({
priority: "critical",
lifecycle: "application",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 8e3,
max429Retries: 1,
maxChallengeRetries: 1
}),
"action-critical": Object.freeze({
priority: "critical",
lifecycle: "action",
droppable: !1,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"action-permission": Object.freeze({
priority: "visible",
lifecycle: "action",
droppable: !1,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 12e3,
max429Retries: 1,
maxChallengeRetries: 1
}),
"read-critical": Object.freeze({
priority: "critical",
lifecycle: "topic",
droppable: !1,
/*
* timings 可以被 Cloudflare 单独拒绝;通用 session 探针的 200
* 不能证明该端点已解除。保留 checkpoint 并结束本请求,
* 不建立共享验证世代,也不在其他请求过盾后追发旧批次。
*/
blockOnCloudflareChallenge: !1,
suppressAfterChallengeWait: !0,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 0
}),
"topic-visible": Object.freeze({
priority: "visible",
lifecycle: "topic",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh", "no-store"),
defaultTimeoutMs: 2e4,
/*
* Topic 正文/目标楼层把 429 与 cf-mitigated 当作本次用户意图的终态。
* 验证闸门仍会阻止后续启动,但同一逻辑请求不得在过盾后自动重放;
* 下一次请求只能来自新的物理滚动或显式导航。
*/
max429Retries: 0,
maxChallengeRetries: 0
}),
"nested-visible": Object.freeze({
priority: "nested",
lifecycle: "topic",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 12e3,
max429Retries: 0,
maxChallengeRetries: 0
}),
"user-card-interactive": Object.freeze({
priority: "interactive",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 12e3,
max429Retries: 1,
maxChallengeRetries: 1
}),
"translation-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"translation-access": Object.freeze({
priority: "interactive",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 1
}),
"translation-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"notification-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 1
}),
"collection-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 1
}),
"resource-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh", "no-store"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"surface-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"user-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"resource-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"nearby-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "topic",
droppable: !0,
/*
* 近视口宿主 Topic 可以先于普通后台任务,但仍必须让位于真实打开、交互与
* critical 请求。它与普通预取一样不能冻结 Reader 或拉起人工验证。
*/
blockOnCloudflareChallenge: !1,
suppressAfterChallengeWait: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 0
}),
"background-prefetch": Object.freeze({
priority: "background",
lifecycle: "topic",
droppable: !0,
/*
* 自动预取和实时增强没有权力冻结全局 Reader 或拉起人工验证。
* 它们命中 cf-mitigated 时只结束自身,且不得在其他请求过盾后追发旧工作。
*/
blockOnCloudflareChallenge: !1,
suppressAfterChallengeWait: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 0
})
});
function nonEmptyToken(value, name) {
const token = String(value).trim();
if (!token) throw new Error(`${name} 不能为空`);
return token;
}
function encodedIdentity(identity) {
const entries = Object.entries(identity).sort(([left], [right]) => left.localeCompare(right));
if (!entries.length) throw new Error("request identity 不能为空");
return entries.map(([rawKey, rawValue]) => {
const key = nonEmptyToken(rawKey, "identity key"), value = typeof rawValue == "string" ? rawValue.trim() : String(rawValue);
if (!value) throw new Error(`identity ${key} 不能为空`);
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}).join("&");
}
function timeout(value, fallback) {
const resolved = value ?? fallback;
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > 12e4)
throw new RangeError("timeoutMs 必须是 1..120000 的安全整数");
return resolved;
}
function requestProfileContract(profile) {
return PROFILES[profile];
}
function createRequestContract(profile, input) {
const contract = requestProfileContract(profile), cacheMode = input.cacheMode ?? contract.defaultCacheMode;
if (!contract.allowedCacheModes.includes(cacheMode))
throw new Error(`${profile} 不允许 cache mode ${cacheMode}`);
const cacheKey = `${nonEmptyToken(input.namespace, "request namespace")}?${encodedIdentity(input.identity)}`;
return Object.freeze({
...contract,
profile,
cacheKey,
key: `${cacheKey}&cacheMode=${encodeURIComponent(cacheMode)}`,
cacheMode,
timeoutMs: timeout(input.timeoutMs, contract.defaultTimeoutMs)
});
}
}, "a0920906dc4828164718274ee9f09feb1e460e175a7c29b309a6cad15bbc963d");
/* Source: lite/src/network/request-identities.ts */
runtime.register("src/network/request-identities.js", function(module, exports, require) {
var request_identities_exports = {};
__export(request_identities_exports, {
actionRequestIdentity: () => actionRequestIdentity,
collectionRequestIdentity: () => collectionRequestIdentity,
nestedRequestIdentity: () => nestedRequestIdentity,
notificationRequestIdentity: () => notificationRequestIdentity,
readRequestIdentity: () => readRequestIdentity,
resourceRequestIdentity: () => resourceRequestIdentity,
topicPostsRequestIdentity: () => topicPostsRequestIdentity,
topicRequestIdentity: () => topicRequestIdentity,
translationRequestIdentity: () => translationRequestIdentity,
userRequestIdentity: () => userRequestIdentity
});
module.exports = __toCommonJS(request_identities_exports);
var import_identifiers = require("../discourse/identifiers.js");
function token(value, name) {
const normalized = String(value).trim();
if (!normalized) throw new Error(`${name} 不能为空`);
return normalized;
}
function nonNegative(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return numeric;
}
function topicRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
operation: token(input.operation, "operation")
};
return input.postId !== void 0 && (identity.postId = (0, import_identifiers.discoursePostId)(input.postId)), input.postNumber !== void 0 && (identity.postNumber = (0, import_identifiers.discoursePostNumber)(input.postNumber)), input.cursor !== void 0 && (identity.cursor = (0, import_identifiers.discourseReplyCursor)(input.cursor)), Object.freeze(identity);
}
function topicPostsRequestIdentity(input) {
return Object.freeze({
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
postIds: (0, import_identifiers.discoursePostIds)(input.postIds).join(",")
});
}
function nestedRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
parentPostNumber: (0, import_identifiers.discoursePostNumber)(input.parentPostNumber),
after: (0, import_identifiers.discourseReplyCursor)(input.after)
};
return input.parentPostId !== void 0 && (identity.parentPostId = (0, import_identifiers.discoursePostId)(input.parentPostId)), Object.freeze(identity);
}
function notificationRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
group: token(input.group, "group"),
page: nonNegative(input.page, "page")
};
return input.variant !== void 0 && (identity.variant = token(input.variant, "variant")), Object.freeze(identity);
}
function collectionRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
collection: token(input.collection, "collection"),
page: nonNegative(input.page, "page")
};
return input.cursor !== void 0 && (identity.cursor = token(input.cursor, "cursor")), input.variant !== void 0 && (identity.variant = token(input.variant, "variant")), Object.freeze(identity);
}
function userRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
username: token(input.username, "username").replace(/^@/, "").toLocaleLowerCase(),
resource: token(input.resource, "resource")
};
return input.page !== void 0 && (identity.page = nonNegative(input.page, "page")), Object.freeze(identity);
}
function resourceRequestIdentity(input) {
return Object.freeze({
resourceId: token(input.resourceId, "resourceId"),
variant: token(input.variant, "variant")
});
}
function translationRequestIdentity(input) {
return Object.freeze({
provider: token(input.provider, "provider"),
textFingerprint: token(input.textFingerprint, "textFingerprint"),
sourceLanguage: token(input.sourceLanguage, "sourceLanguage"),
targetLanguage: token(input.targetLanguage, "targetLanguage")
});
}
function actionRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
operation: token(input.operation, "operation"),
targetType: token(input.targetType, "targetType"),
targetId: token(input.targetId, "targetId")
};
return input.variant !== void 0 && (identity.variant = token(input.variant, "variant")), Object.freeze(identity);
}
function readRequestIdentity(input) {
return Object.freeze({
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
postNumbers: (0, import_identifiers.discoursePostNumbers)(input.postNumbers).join(",")
});
}
}, "0c95c749957127d654451875f452e302de72d614741b0cfe75b73c8a41238ea8");
/* Source: lite/src/network/request-observer.ts */
runtime.register("src/network/request-observer.js", function(module, exports, require) {
var request_observer_exports = {};
__export(request_observer_exports, {
RequestObserver: () => RequestObserver,
requestObservationType: () => requestObservationType
});
module.exports = __toCommonJS(request_observer_exports);
var import_signal = require("../kernel/signal.js");
function nonNegative(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
}
function positiveInteger(value, fallback) {
const numeric = Math.trunc(Number(value));
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback;
}
function requestUrl(href, baseHref) {
try {
return new URL(String(href), baseHref);
} catch {
return null;
}
}
function requestObservationHref(url) {
return url ? ["http:", "https:"].includes(url.protocol) ? `${url.origin}${url.pathname}`.slice(0, 512) : `${url.protocol.replace(":", "") || "本地"}资源` : "未知请求";
}
function requestObservationPath(url, baseOrigin) {
return url ? ["http:", "https:"].includes(url.protocol) ? `${url.origin === baseOrigin ? "" : url.host}${url.pathname || "/"}`.slice(0, 180) : `${url.protocol.replace(":", "") || "本地"}资源` : "未知请求";
}
const SENSITIVE_QUERY_KEY = /(?:auth|authorization|cookie|key|password|secret|signature|token)/i, DIAGNOSTIC_IDENTITY_KEYS = /* @__PURE__ */ new Set([
"after",
"collection",
"group",
"operation",
"page",
"parentPostId",
"parentPostNumber",
"postId",
"postNumber",
"postIds",
"postNumbers",
"resource",
"targetType",
"topicId"
]);
function requestObservationQueryShape(url) {
if (!url || !["http:", "https:"].includes(url.protocol) || !url.search) return "";
const counts = /* @__PURE__ */ new Map();
for (const [rawKey] of url.searchParams) {
const candidate = String(rawKey).trim(), key = SENSITIVE_QUERY_KEY.test(candidate) ? "credential" : /^[A-Za-z_][A-Za-z0-9_.\[\]-]{0,39}$/.test(candidate) ? candidate : "param";
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const entries = [...counts].sort(([left], [right]) => left.localeCompare(right));
if (!entries.length) return "";
const visible = entries.slice(0, 8).map(([key, count]) => count > 1 ? `${key}×${count}` : key);
return entries.length > visible.length && visible.push("…"), `?${visible.join("&")}`.slice(0, 180);
}
function requestObservationIdentity(identity) {
if (!identity) return "";
const details = [];
for (const [key, rawValue] of Object.entries(identity).sort(([left], [right]) => left.localeCompare(right))) {
if (!DIAGNOSTIC_IDENTITY_KEYS.has(key)) continue;
const value = String(rawValue).trim();
if (value) {
if (key === "postIds" || key === "postNumbers") {
const count = value.split(",").filter(Boolean).length;
details.push(`${key}=${count}项`);
continue;
}
if (["collection", "group", "operation", "resource", "targetType"].includes(key)) {
const token = diagnosticCode(value);
token && details.push(`${key}=${token}`);
continue;
}
/^\d+$/.test(value) && details.push(`${key}=${value.slice(0, 16)}`);
}
}
return details.join(", ").slice(0, 220);
}
function diagnosticText(value, maximum = 220) {
return String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/(?:https?|chrome-extension):\/\/[^\s)]+/g, (raw) => {
try {
const url = new URL(raw);
return `${url.origin}${url.pathname}`;
} catch {
return raw.split(/[?#]/, 1)[0] ?? "";
}
}).trim().slice(0, maximum);
}
function diagnosticCode(value, fallback = "") {
const normalized = diagnosticText(value, 80);
return /^[A-Za-z0-9_.:-]+$/.test(normalized) ? normalized : fallback;
}
function diagnosticError(value) {
const normalized = diagnosticText(value, 80);
return normalized ? /^[A-Za-z0-9_.:-]+$/.test(normalized) || /^(?:Failed to fetch|Load failed|NetworkError)$/i.test(normalized) ? normalized : "request-failed" : "";
}
const CANCELLATION_CODES = /* @__PURE__ */ new Set([
"AbortError",
"cancelled",
"context-close",
"context-closed",
"priority-upgrade",
"signal",
"topic-close",
"topic-switch",
"viewport-change"
]);
function isCancellationCode(value) {
return CANCELLATION_CODES.has(value);
}
function requestObservationType(href, options) {
const url = requestUrl(href, options.baseHref), path = String(url?.pathname ?? "").toLowerCase(), host = String(url?.hostname ?? "").toLowerCase(), initiator = String(options.initiatorType ?? "").toLowerCase(), method = String(options.method ?? "GET").toUpperCase();
return /\/posts\/\d+\/replies(?:\.json)?$/.test(path) ? "nested" : /\/user_avatar\/|\/letter_avatar\//.test(path) || /avatar/.test(host) ? "avatar" : /\/(?:uploads|optimized)\//.test(path) || /\.(?:avif|gif|jpe?g|png|webp|svg|mp4|webm|mp3|ogg)(?:$|\/)/.test(path) ? "media" : /\/bookmarks?(?:\/|\.|$)|remove_bookmarks/.test(path) ? "bookmark" : /\/notifications?(?:\/|\.|$)/.test(path) ? "notification" : /\/message-bus(?:\/|$)/.test(path) ? "realtime" : /\/presence\//.test(path) ? "presence" : /\/search(?:\/|\.|$)|\/filter(?:\/|\.|$)/.test(path) ? "search" : /topic-timings|\/timings(?:\/|\.|$)/.test(path) ? "read" : /\/u\/|\/directory_items|\/session\/current|\/category-experts\//.test(path) ? "user" : /post_actions|user_actions|discourse-reactions|\/boosts?(?:\/|\.|$)|\/emojis\.json$/.test(path) ? "reaction" : /\/t\/|\/posts(?:\/|\.|$)|\/posts\/by_number\//.test(path) ? "topic" : ["GET", "HEAD"].includes(method) ? ["img", "image", "video", "audio"].includes(initiator) ? "media" : ["css", "link", "script", "font"].includes(initiator) ? "asset" : "other" : "reaction";
}
class RequestObserver {
changes = new import_signal.Signal();
#baseHref;
#retentionMs;
#maxEntries;
#now;
#events = [];
#active = /* @__PURE__ */ new Map();
#sequence = 0;
#revision = 0;
constructor(options) {
const base = new URL(options.baseHref);
base.username = "", base.password = "", base.search = "", base.hash = "", this.#baseHref = base.href, this.#retentionMs = positiveInteger(options.retentionMs, 5 * 6e4), this.#maxEntries = positiveInteger(options.maxEntries, 500), this.#now = options.now ?? Date.now;
}
get snapshot() {
return this.#snapshot();
}
begin(input) {
const observedAt = this.#now(), requestedStart = nonNegative(input.startedAt, observedAt), requestedQueue = nonNegative(input.queuedAt, requestedStart), controlReason = diagnosticCode(input.controlReason), phase = controlReason ? "cancelled" : input.phase ?? "running", queuedAt = Math.min(requestedStart, requestedQueue), startedAt = phase === "queued" ? queuedAt : Math.max(queuedAt, requestedStart), permittedAt = phase === "queued" ? queuedAt : Math.min(
startedAt,
Math.max(queuedAt, nonNegative(input.permittedAt, startedAt))
), method = String(input.method ?? "GET").trim().toUpperCase().slice(0, 16) || "GET", url = requestUrl(input.href, this.#baseHref), normalizedHref = requestObservationHref(url), baseOrigin = new URL(this.#baseHref).origin, event = Object.freeze({
id: ++this.#sequence,
href: normalizedHref,
path: requestObservationPath(url, baseOrigin),
queryShape: requestObservationQueryShape(url),
method,
transport: input.transport,
source: input.source,
phase,
type: input.type ?? requestObservationType(input.href, {
baseHref: this.#baseHref,
method,
initiatorType: input.transport
}),
sameOrigin: !!(url && ["http:", "https:"].includes(url.protocol) && url.origin === baseOrigin),
queuedAt,
permittedAt,
startedAt,
endedAt: controlReason ? startedAt : 0,
permitWait: permittedAt - queuedAt,
dispatchDuration: startedAt - permittedAt,
duration: 0,
priority: input.priority ?? null,
attempt: positiveInteger(input.attempt, 1),
recoveryProbe: input.recoveryProbe === !0,
waitReason: diagnosticCode(input.waitReason),
callSite: diagnosticText(input.callSite),
controlReason,
logicalId: diagnosticCode(input.logicalId),
profile: diagnosticCode(input.profile),
namespace: diagnosticCode(input.namespace),
lane: diagnosticCode(input.lane),
cacheMode: diagnosticCode(input.cacheMode),
identity: requestObservationIdentity(input.identity),
joinedConsumers: 0,
promoted: !1,
max429Retries: Math.trunc(nonNegative(input.max429Retries)),
maxChallengeRetries: Math.trunc(nonNegative(input.maxChallengeRetries)),
blockOnCloudflareChallenge: typeof input.blockOnCloudflareChallenge == "boolean" ? input.blockOnCloudflareChallenge : null,
suppressAfterChallengeWait: input.suppressAfterChallengeWait === !0,
droppable: typeof input.droppable == "boolean" ? input.droppable : null,
decision: controlReason,
pending: !controlReason,
status: controlReason ? 0 : null,
cloudflareMitigated: !1,
size: 0,
error: "",
rateLimitCode: "",
retryAfter: "",
serverLimit: "",
serverRemaining: "",
serverReset: "",
resourceTimed: !1
});
return this.#insert(event), event.pending && this.#active.set(event.id, event), this.#prune(startedAt), this.#publish(), event.id;
}
markStarted(input) {
const current = this.#active.get(input.id);
if (!current || current.phase !== "queued") return !1;
const startedAt = Math.max(
current.queuedAt,
nonNegative(input.startedAt, this.#now())
), queuedAt = Math.min(
startedAt,
nonNegative(input.queuedAt, current.queuedAt)
), permittedAt = Math.min(
startedAt,
Math.max(queuedAt, nonNegative(input.permittedAt, startedAt))
), running = Object.freeze({
...current,
phase: "running",
queuedAt,
permittedAt,
startedAt,
permitWait: permittedAt - queuedAt,
dispatchDuration: startedAt - permittedAt,
priority: input.priority === void 0 ? current.priority : input.priority,
recoveryProbe: input.recoveryProbe === !0,
waitReason: diagnosticCode(input.waitReason)
});
return this.#replace(current, running), this.#active.set(input.id, running), this.#prune(startedAt), this.#publish(), !0;
}
cancel(id, input) {
const current = this.#active.get(id);
if (!current || current.phase !== "queued") return !1;
const endedAt = Math.max(
current.queuedAt,
nonNegative(input.endedAt, this.#now())
), reason = diagnosticCode(input.reason, "cancelled"), cancelled = Object.freeze({
...current,
phase: "cancelled",
permittedAt: endedAt,
startedAt: endedAt,
endedAt,
permitWait: endedAt - current.queuedAt,
dispatchDuration: 0,
duration: 0,
waitReason: reason,
controlReason: reason,
decision: reason,
pending: !1,
status: 0
});
return this.#replace(current, cancelled), this.#active.delete(id), this.#prune(endedAt), this.#publish(), !0;
}
matchActive(input) {
const href = requestObservationHref(requestUrl(input.href, this.#baseHref)), method = String(input.method ?? "GET").trim().toUpperCase() || "GET", startedAt = nonNegative(input.startedAt, this.#now()), toleranceMs = nonNegative(input.toleranceMs, 250);
return [...this.#active.values()].filter(
(event) => event.phase === "running" && event.href === href && event.method === method && !input.excludedIds?.has(event.id) && (input.source === void 0 || event.source === input.source) && Math.abs(event.startedAt - startedAt) <= toleranceMs
).sort((left, right) => Math.abs(left.startedAt - startedAt) - Math.abs(right.startedAt - startedAt))[0]?.id ?? null;
}
finish(id, input = {}) {
const current = this.#active.get(id);
if (!current) return !1;
const endedAt = Math.max(current.startedAt, nonNegative(input.endedAt, this.#now())), error = diagnosticError(input.error), cancelled = isCancellationCode(error), controlledCancellation = cancelled && error !== "AbortError", completed = Object.freeze({
...current,
phase: cancelled ? "cancelled" : "finished",
endedAt,
duration: endedAt - current.startedAt,
pending: !1,
status: cancelled ? 0 : input.status === void 0 ? current.status : Math.trunc(nonNegative(input.status)),
cloudflareMitigated: input.cloudflareMitigated === !0,
size: nonNegative(input.size, current.size),
error,
controlReason: controlledCancellation ? error : current.controlReason,
rateLimitCode: diagnosticCode(input.rateLimitCode),
retryAfter: diagnosticText(input.retryAfter, 80),
serverLimit: diagnosticText(input.serverLimit, 80),
serverRemaining: diagnosticText(input.serverRemaining, 80),
serverReset: diagnosticText(input.serverReset, 80),
decision: diagnosticCode(input.decision, current.decision)
}), index = this.#events.findIndex((event) => event.id === id);
return index >= 0 && (this.#events[index] = completed), this.#active.delete(id), this.#prune(endedAt), this.#publish(), !0;
}
update(id, input) {
const current = this.#events.find((event) => event.id === id);
if (!current) return !1;
const next = Object.freeze({
...current,
priority: input.priority === void 0 ? current.priority : input.priority,
joinedConsumers: input.joinedConsumers === void 0 ? current.joinedConsumers : Math.trunc(nonNegative(input.joinedConsumers)),
promoted: current.promoted || input.promoted === !0,
max429Retries: input.max429Retries === void 0 ? current.max429Retries : Math.trunc(nonNegative(input.max429Retries)),
maxChallengeRetries: input.maxChallengeRetries === void 0 ? current.maxChallengeRetries : Math.trunc(nonNegative(input.maxChallengeRetries)),
droppable: input.droppable === void 0 ? current.droppable : input.droppable,
decision: input.decision === void 0 ? current.decision : diagnosticCode(input.decision, current.decision)
}), index = this.#events.findIndex((event) => event.id === id);
return index < 0 ? !1 : (this.#events[index] = next, current.pending && this.#active.set(id, next), this.#publish(), !0);
}
recordResource(input) {
const initiator = String(input.initiatorType ?? "").toLowerCase(), resourceUrl = requestUrl(input.href, this.#baseHref);
if (!resourceUrl || !["http:", "https:"].includes(resourceUrl.protocol)) return 0;
const normalizedHref = requestObservationHref(
resourceUrl
), resourceDuration = Math.max(0, input.endedAt - input.startedAt), repeated = this.#events.find(
(event) => event.resourceTimed && event.href === normalizedHref && Math.abs(event.startedAt - input.startedAt) <= 0.1 && Math.abs(event.duration - resourceDuration) <= 0.1
);
if (repeated) return repeated.id;
if (["fetch", "xmlhttprequest"].includes(initiator)) {
const match = this.#events.filter(
(event) => !event.resourceTimed && event.href === normalizedHref && (event.transport === initiator || initiator === "xmlhttprequest" && event.transport === "scheduler") && Math.abs(event.startedAt - input.startedAt) <= 100
).sort((left, right) => Math.abs(left.startedAt - input.startedAt) - Math.abs(right.startedAt - input.startedAt))[0];
if (match) {
const enriched = Object.freeze({
...match,
duration: resourceDuration,
size: nonNegative(input.size, match.size),
resourceTimed: !0
}), index2 = this.#events.findIndex((event) => event.id === match.id);
return index2 >= 0 && (this.#events[index2] = enriched), match.pending && this.#active.set(match.id, enriched), this.#publish(), match.id;
}
}
const id = this.begin({
href: input.href,
transport: "resource",
source: "browser",
startedAt: input.startedAt,
type: requestObservationType(input.href, {
baseHref: this.#baseHref,
...input.initiatorType === void 0 ? {} : { initiatorType: input.initiatorType }
}),
callSite: input.initiatorType ? `${input.initiatorType} 资源加载` : "浏览器资源加载"
});
this.finish(id, input);
const index = this.#events.findIndex((event) => event.id === id), completed = this.#events[index];
return index >= 0 && completed && (this.#events[index] = Object.freeze({ ...completed, resourceTimed: !0 }), this.#publish()), id;
}
clearCompleted() {
for (let index = this.#events.length - 1; index >= 0; index -= 1)
this.#events[index].pending || this.#events.splice(index, 1);
this.#publish();
}
#insert(event) {
let low = 0, high = this.#events.length;
for (; low < high; ) {
const middle = Math.floor((low + high) / 2), current = this.#events[middle];
current.queuedAt < event.queuedAt || current.queuedAt === event.queuedAt && current.id <= event.id ? low = middle + 1 : high = middle;
}
this.#events.splice(low, 0, event);
}
#replace(current, next) {
const index = this.#events.findIndex((event) => event.id === current.id);
index < 0 || (this.#events.splice(index, 1), this.#insert(next));
}
#prune(at) {
const cutoff = at - this.#retentionMs;
for (let index = this.#events.length - 1; index >= 0; index -= 1) {
const event = this.#events[index], retainedAt = event.endedAt || event.startedAt;
!event.pending && retainedAt < cutoff && this.#events.splice(index, 1);
}
if (this.#events.length <= this.#maxEntries) return;
let overflow = this.#events.length - this.#maxEntries;
for (let index = 0; index < this.#events.length && overflow > 0; ) {
if (this.#events[index].pending) {
index += 1;
continue;
}
this.#events.splice(index, 1), overflow -= 1;
}
}
#publish() {
this.#revision += 1, this.changes.emit(this.#snapshot());
}
#snapshot() {
const queued = [...this.#active.values()].filter(
(event) => event.phase === "queued"
).length, running = this.#active.size - queued;
return Object.freeze({
revision: this.#revision,
queued,
running,
active: running,
completed: this.#events.length - this.#active.size,
events: Object.freeze([...this.#events])
});
}
}
}, "64374a589ac7e6bc0726c98e064bcb3965bf317bd095010e034716e8d8cd2222");
/* Source: lite/src/network/request-rate-limit-policy.ts */
runtime.register("src/network/request-rate-limit-policy.js", function(module, exports, require) {
var request_rate_limit_policy_exports = {};
__export(request_rate_limit_policy_exports, {
RequestRateLimitPolicy: () => RequestRateLimitPolicy,
endpointRequestIdentity: () => endpointRequestIdentity,
parseRetryAfterMs: () => parseRetryAfterMs,
rateLimitWindowFromCode: () => rateLimitWindowFromCode
});
module.exports = __toCommonJS(request_rate_limit_policy_exports);
function rateLimitWindowFromCode(value) {
const short = /10[_-]?(?:secs?|seconds?)/i.test(String(value ?? "")), long = /60[_-]?(?:secs?|seconds?)|minute/i.test(String(value ?? ""));
return short && long ? "10s+60s" : short ? "10s" : long ? "60s" : "unknown";
}
function positiveFinite(value, name) {
if (!Number.isFinite(value) || value <= 0)
throw new RangeError(`${name} 必须是正有限数值`);
return value;
}
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function usableRetryAfter(value, now) {
const raw = String(value ?? "").trim();
if (!raw) return !1;
const seconds = Number(raw);
return Number.isFinite(seconds) ? seconds > 0 : Date.parse(raw) > now;
}
function rateLimitWindowWaitMs(window) {
return window === "10s" ? 1e4 : window === "60s" || window === "10s+60s" ? 6e4 : 0;
}
function parseRetryAfterMs(value, options) {
const minMs = positiveFinite(options.minMs ?? 1e3, "minMs"), maxMs = positiveFinite(options.maxMs ?? 6e4, "maxMs");
if (maxMs < minMs) throw new RangeError("maxMs 不能小于 minMs");
const fallbackMs = positiveFinite(options.fallbackMs, "fallbackMs"), raw = String(value ?? "").trim();
let waitMs = 0;
if (raw) {
const seconds = Number(raw);
waitMs = Number.isFinite(seconds) ? seconds * 1e3 : Math.max(0, Date.parse(raw) - options.now);
}
return waitMs || (waitMs = fallbackMs), Math.max(minMs, Math.min(maxMs, waitMs));
}
function endpointRequestIdentity(input, method = "GET", baseUrl) {
let url;
try {
url = input instanceof URL ? new URL(input.href) : new URL(input, baseUrl);
} catch {
const invalid = `${String(method || "GET").toUpperCase()}:invalid:${String(input).slice(0, 160)}`;
return Object.freeze({ fingerprint: invalid, route: invalid });
}
const normalizedMethod = String(method || "GET").toUpperCase(), params = [...url.searchParams.entries()].filter(([key]) => key !== "_ldp_retry"), identity = (routeOnly) => {
const path = routeOnly ? url.pathname.replace(/\b\d+\b/g, ":id").replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ":uuid") : url.pathname, sorted = params.map(([key, value]) => routeOnly ? [key, ""] : [key, value]).sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)), query = routeOnly ? [...new Set(sorted.map(([key]) => encodeURIComponent(key)))].join("&") : sorted.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
return `${normalizedMethod}:${url.origin}${path}${query ? `?${query}` : ""}`;
};
return Object.freeze({ fingerprint: identity(!1), route: identity(!0) });
}
class RequestRateLimitPolicy {
#options;
#unknownEvidence = [];
constructor(options) {
const retryAfterMinMs = positiveFinite(options.retryAfterMinMs ?? 1e3, "retryAfterMinMs"), retryAfterMaxMs = positiveFinite(
options.retryAfterMaxMs ?? 6e4,
"retryAfterMaxMs"
);
if (retryAfterMaxMs < retryAfterMinMs)
throw new RangeError("retryAfterMaxMs 不能小于 retryAfterMinMs");
this.#options = {
evidenceWindowMs: positiveFinite(options.evidenceWindowMs, "evidenceWindowMs"),
maxEndpointEntries: positiveInteger(options.maxEndpointEntries, "maxEndpointEntries"),
retryAfterFallbackMs: positiveFinite(options.retryAfterFallbackMs, "retryAfterFallbackMs"),
retryAfterMinMs,
retryAfterMaxMs,
now: options.now ?? Date.now,
...options.baseUrl === void 0 ? {} : { baseUrl: options.baseUrl }
};
}
noteRateLimit(observation) {
const at = this.#options.now();
this.#prune(at);
const identity = endpointRequestIdentity(
observation.input,
observation.method,
this.#options.baseUrl
), hasAuthoritativeRetryAfter = usableRetryAfter(
observation.retryAfter,
at
), retryAfterMs = parseRetryAfterMs(observation.retryAfter, {
now: at,
fallbackMs: this.#options.retryAfterFallbackMs,
minMs: this.#options.retryAfterMinMs,
maxMs: this.#options.retryAfterMaxMs
}), authoritativeWindow = observation.knownGlobalWindow ? observation.globalWindow ?? "unknown" : "unknown", waitMs = hasAuthoritativeRetryAfter ? retryAfterMs : Math.max(retryAfterMs, rateLimitWindowWaitMs(authoritativeWindow)), corroboratedGlobal = observation.knownGlobalWindow || this.#unknownEvidence.some(
(event) => event.route !== identity.route && event.at >= at - this.#options.evidenceWindowMs
);
return this.#unknownEvidence.push(Object.freeze({ at, route: identity.route })), this.#prune(at), this.#decision(
corroboratedGlobal ? "global" : "endpoint",
waitMs,
at + waitMs,
identity,
observation.globalWindow ?? "unknown",
hasAuthoritativeRetryAfter || observation.knownGlobalWindow
);
}
identity(input, method = "GET") {
return endpointRequestIdentity(input, method, this.#options.baseUrl);
}
reset() {
this.#unknownEvidence.length = 0;
}
#decision(scope, waitMs, retryAt, identity, window = "unknown", authoritative = !1) {
return Object.freeze({
scope,
waitMs,
retryAt,
...identity,
window,
authoritative
});
}
#prune(at) {
const evidenceCutoff = at - this.#options.evidenceWindowMs;
for (; this.#unknownEvidence.length && this.#unknownEvidence[0].at < evidenceCutoff; )
this.#unknownEvidence.shift();
this.#unknownEvidence.length > this.#options.maxEndpointEntries && this.#unknownEvidence.splice(
0,
this.#unknownEvidence.length - this.#options.maxEndpointEntries
);
}
}
}, "697bfe4cf60edafb78614a1526813cf427a545b7c16b54506866af6ad0c512b4");
/* Source: lite/src/network/request-scheduler.ts */
runtime.register("src/network/request-scheduler.js", function(module, exports, require) {
var request_scheduler_exports = {};
__export(request_scheduler_exports, {
RequestControlError: () => RequestControlError,
RequestScheduler: () => RequestScheduler,
RequestStartDeferredError: () => RequestStartDeferredError,
RequestTimeoutError: () => RequestTimeoutError
});
module.exports = __toCommonJS(request_scheduler_exports);
const REQUEST_LANES = Object.freeze([
"control",
"topic-batch",
"nested-replies",
"user-card",
"translation",
"standard"
]), LANE_CONCURRENCY_CAP = Object.freeze({
control: 1,
/* 当前 Topic 与两批近视口预热可以并行;全局 permit 仍统一限流。 */
"topic-batch": 3,
/* replies.json 最多并行两个父楼;全局 permit 仍统一约束启动间隔与额度。 */
"nested-replies": 2,
"user-card": 2,
/* 五路预加载之外为滚动到眼前的正文预留一路;共享 permit 仍限制启动。 */
translation: 6,
standard: 1
}), PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
class RequestControlError extends Error {
code;
constructor(code) {
super(code), this.name = "AbortError", this.code = code;
}
}
class RequestTimeoutError extends Error {
constructor() {
super("请求超时"), this.name = "TimeoutError";
}
}
class RequestStartDeferredError extends Error {
waitMs;
reason;
constructor(waitMs, reason) {
super(reason || "request-start-deferred"), this.name = "RequestStartDeferredError", this.waitMs = Math.max(1, Math.ceil(Number(waitMs) || 0)), this.reason = String(reason || "deferred");
}
}
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function normalizedKey(value) {
const key = String(value).trim();
if (!key) throw new Error("request key 不能为空");
return key;
}
function abortReason(signal, fallback) {
return signal.reason ?? new RequestControlError(fallback);
}
class RequestScheduler {
#maxConcurrent;
#queueLimit;
#defaultTimeoutMs;
#now;
#startGate;
#onInternalError;
#queue = [];
#tasksByKey = /* @__PURE__ */ new Map();
#activeByLane = /* @__PURE__ */ new Map();
#activeCount = 0;
#sequence = 0;
#pumpQueued = !1;
#permitPending = !1;
#permitLane = null;
#permitTask = null;
#deferredPumpTimer = null;
#deferredPumpAt = 0;
#disposed = !1;
constructor(options) {
this.#maxConcurrent = positiveInteger(options.maxConcurrent, "maxConcurrent"), this.#queueLimit = positiveInteger(options.queueLimit, "queueLimit"), this.#defaultTimeoutMs = positiveInteger(options.defaultTimeoutMs, "defaultTimeoutMs"), this.#now = options.now ?? Date.now, this.#startGate = options.startGate, this.#onInternalError = options.onInternalError ?? (() => {
}), options.scope?.add(() => this.destroy());
}
schedule(options, operation) {
const key = normalizedKey(options.key), priority = options.priority ?? "visible", lane = options.lane ?? "standard";
if (this.#disposed)
return Promise.reject(new RequestControlError("context-closed"));
const existing = this.#tasksByKey.get(key);
if (existing && !existing.externalSignal?.aborted)
return options.droppable !== !0 && (existing.droppable = !1), this.#promoteTask(existing, priority), existing.promise;
if (options.signal?.aborted)
return Promise.reject(abortReason(options.signal, "signal"));
if (this.#waitingCount() >= this.#queueLimit) {
const evicted = options.droppable === !0 ? null : this.#evictLowerPriorityDroppable(priority);
if (!evicted && !this.#preemptDroppablePermit({
priority,
lane,
droppable: options.droppable === !0
}))
return Promise.reject(new RequestControlError("queue-limit"));
evicted && this.#finish(evicted, new RequestControlError("queue-limit"));
}
let resolve, reject;
const promise = new Promise((done, fail) => {
resolve = done, reject = fail;
}), queuedAt = this.#now(), task = {
key,
priority,
lane,
rateLimitRoute: String(options.rateLimitRoute ?? "").trim(),
sequence: this.#sequence++,
queuedAt,
timeoutMs: positiveInteger(
options.timeoutMs ?? this.#defaultTimeoutMs,
"timeoutMs"
),
externalSignal: options.signal,
operation,
onStart: options.onStart,
droppable: options.droppable === !0,
promise,
resolve,
reject,
state: "queued",
controller: null,
externalAbort: null,
permitRestart: !1,
notBefore: queuedAt,
deferredWaitReason: ""
};
return task.externalSignal && (task.externalAbort = () => {
task.state === "queued" ? (this.#removeQueued(task), this.#finish(
task,
abortReason(task.externalSignal, "signal")
)) : task.controller?.abort(abortReason(task.externalSignal, "signal"));
}, task.externalSignal.addEventListener(
"abort",
task.externalAbort,
{ once: !0 }
)), this.#tasksByKey.set(key, task), this.#queue.push(task), this.#preemptDroppablePermit(task), this.#queuePump(), promise;
}
cancelQueued(key) {
const task = this.#tasksByKey.get(String(key));
return !task || task.state !== "queued" ? !1 : (this.#removeQueued(task), this.#finish(task, new RequestControlError("cancelled")), !0);
}
/**
* 原地更新只影响尚未启动的任务;已经取得 permit 的请求自然完成。
*
* 设置切换不得重建 scheduler,否则会丢失 single-flight、排队顺序和当前请求。
*/
applyRuntimePolicy(policy) {
this.#disposed || (this.#maxConcurrent = positiveInteger(
policy.maxConcurrent,
"maxConcurrent"
), this.#queuePump());
}
promoteQueued(key, priority, droppable) {
const task = this.#tasksByKey.get(String(key));
return task ? (droppable === !1 && (task.droppable = !1), this.#promoteTask(task, priority)) : !1;
}
snapshot() {
return this.#sortQueue(), Object.freeze({
active: this.#activeCount,
queued: this.#queue.length + (this.#permitPending ? 1 : 0),
maxConcurrent: this.#maxConcurrent,
queueLimit: this.#queueLimit,
disposed: this.#disposed,
queuedKeys: Object.freeze(this.#queue.map((task) => task.key)),
activeByLane: this.#laneCounts((lane) => this.#activeByLane.get(lane) ?? 0),
queuedByLane: this.#laneCounts((lane) => this.#queue.reduce(
(total, task) => total + (task.lane === lane ? 1 : 0),
this.#permitLane === lane ? 1 : 0
))
});
}
destroy() {
if (!this.#disposed) {
this.#disposed = !0, this.#deferredPumpTimer !== null && (clearTimeout(this.#deferredPumpTimer), this.#deferredPumpTimer = null, this.#deferredPumpAt = 0);
for (const task of this.#queue.splice(0))
this.#finish(task, new RequestControlError("context-closed"));
for (const task of this.#tasksByKey.values())
(task.state === "active" || task.state === "permit") && task.controller?.abort(new RequestControlError("context-closed"));
}
}
#queuePump() {
this.#pumpQueued || this.#disposed || (this.#pumpQueued = !0, queueMicrotask(() => {
this.#pumpQueued = !1, this.#pump();
}));
}
#pump() {
if (!(this.#disposed || this.#permitPending))
for (this.#sortQueue(); this.#activeCount < this.#maxConcurrent && this.#queue.length; ) {
const now = this.#now(), nextIndex = this.#queue.findIndex(
(candidate) => candidate.notBefore <= now && this.#taskCanStart(candidate)
);
if (nextIndex < 0) {
this.#scheduleDeferredPump(now);
return;
}
const [task] = this.#queue.splice(nextIndex, 1);
if (!task) return;
if (task.externalSignal?.aborted) {
this.#finish(task, abortReason(task.externalSignal, "signal"));
continue;
}
const controller = new AbortController();
if (task.controller = controller, !this.#startGate) {
this.#run(task, controller, null, task.queuedAt);
continue;
}
task.state = "permit", this.#permitPending = !0, this.#permitLane = task.lane, this.#permitTask = task;
const permitPromise = Promise.resolve().then(() => this.#startGate.acquire({
key: task.key,
priority: task.priority,
lane: task.lane,
...task.rateLimitRoute ? { rateLimitRoute: task.rateLimitRoute } : {},
signal: controller.signal
})).then((permit) => {
if (!permit || typeof permit.release != "function")
throw new Error("start gate 返回了无效 permit");
if (controller.signal.aborted)
throw this.#releasePermit(permit), abortReason(controller.signal, "signal");
return permit;
}), permitAbort = new Promise((_resolve, reject) => {
controller.signal.addEventListener(
"abort",
() => reject(abortReason(controller.signal, "signal")),
{ once: !0 }
);
});
Promise.race([permitPromise, permitAbort]).then((permit) => {
this.#clearPermitTask(task);
try {
if (this.#disposed || controller.signal.aborted)
throw this.#releasePermit(permit), abortReason(controller.signal, "context-closed");
this.#run(task, controller, permit, this.#now());
} catch (error) {
task.state !== "done" && this.#finish(task, error);
} finally {
this.#queuePump();
}
}, (error) => {
this.#clearPermitTask(task), task.permitRestart && !this.#disposed && !task.externalSignal?.aborted ? (task.permitRestart = !1, task.state = "queued", task.controller = null, this.#queue.push(task)) : error instanceof RequestStartDeferredError && !this.#disposed && !task.externalSignal?.aborted ? (task.state = "queued", task.controller = null, task.notBefore = this.#now() + error.waitMs, task.deferredWaitReason = error.reason, this.#queue.push(task)) : task.state !== "done" && this.#finish(
task,
this.#disposed ? new RequestControlError("context-closed") : error
), this.#queuePump();
});
break;
}
}
#scheduleDeferredPump(now) {
let nextAt = Number.POSITIVE_INFINITY;
for (const task of this.#queue)
task.notBefore > now && (nextAt = Math.min(nextAt, task.notBefore));
Number.isFinite(nextAt) && (this.#deferredPumpTimer !== null && this.#deferredPumpAt <= nextAt || (this.#deferredPumpTimer !== null && clearTimeout(this.#deferredPumpTimer), this.#deferredPumpAt = nextAt, this.#deferredPumpTimer = setTimeout(() => {
this.#deferredPumpTimer = null, this.#deferredPumpAt = 0, this.#queuePump();
}, Math.max(0, nextAt - now))));
}
#sortQueue() {
this.#queue.sort(
(left, right) => PRIORITY_WEIGHT[left.priority] - PRIORITY_WEIGHT[right.priority] || left.sequence - right.sequence
);
}
#promoteTask(task, priority) {
return task.state !== "queued" && task.state !== "permit" || PRIORITY_WEIGHT[priority] >= PRIORITY_WEIGHT[task.priority] ? !1 : (task.priority = priority, task.state === "permit" ? (task.permitRestart = !0, task.controller?.abort(new RequestControlError("cancelled"))) : this.#queuePump(), !0);
}
#preemptDroppablePermit(incoming) {
const waiting = this.#permitTask, controller = waiting?.controller;
return incoming.droppable || !waiting || !waiting.droppable || waiting.state !== "permit" || !controller || controller.signal.aborted || this.#activeCount >= this.#maxConcurrent || !this.#taskCanStart(incoming) || PRIORITY_WEIGHT[incoming.priority] >= PRIORITY_WEIGHT[waiting.priority] ? !1 : (waiting.permitRestart = !1, controller.abort(new RequestControlError("cancelled")), !0);
}
#clearPermitTask(task) {
this.#permitTask === task && (this.#permitPending = !1, this.#permitLane = null, this.#permitTask = null);
}
#waitingCount() {
return this.#queue.length + (this.#permitPending ? 1 : 0);
}
#taskCanStart(task) {
let cap = Math.min(
this.#maxConcurrent,
LANE_CONCURRENCY_CAP[task.lane]
);
return task.lane === "topic-batch" && task.priority === "background" && (cap = Math.min(cap, 1)), (this.#activeByLane.get(task.lane) ?? 0) < cap;
}
#laneCounts(read) {
return Object.freeze(Object.fromEntries(
REQUEST_LANES.map((lane) => [lane, read(lane)])
));
}
#evictLowerPriorityDroppable(incomingPriority) {
const incomingWeight = PRIORITY_WEIGHT[incomingPriority], candidate = this.#queue.filter((task) => task.droppable && PRIORITY_WEIGHT[task.priority] > incomingWeight).sort((left, right) => PRIORITY_WEIGHT[right.priority] - PRIORITY_WEIGHT[left.priority] || right.sequence - left.sequence)[0];
return candidate ? (this.#removeQueued(candidate), candidate) : null;
}
async #run(task, controller, permit, permittedAt) {
task.state = "active", task.controller = controller, task.permitRestart = !1, this.#activeCount += 1, this.#activeByLane.set(
task.lane,
(this.#activeByLane.get(task.lane) ?? 0) + 1
);
const startedAt = this.#now();
try {
const waitReason = String(
permit?.waitReason || task.deferredWaitReason || ""
);
task.deferredWaitReason = "", task.onStart?.(Object.freeze({
queuedAt: task.queuedAt,
permittedAt: Math.max(task.queuedAt, permittedAt),
startedAt: Math.max(permittedAt, startedAt),
recoveryProbe: permit?.recoveryProbe === !0,
waitReason
}));
} catch (error) {
this.#onInternalError(error);
}
const timeoutError = new RequestTimeoutError();
let timedOut = !1;
const timeout = setTimeout(() => {
timedOut = !0, controller.abort(timeoutError);
}, task.timeoutMs), abortPromise = new Promise((_resolve, reject) => {
controller.signal.addEventListener(
"abort",
() => reject(abortReason(controller.signal, "signal")),
{ once: !0 }
);
});
try {
const value = await Promise.race([task.operation(controller.signal), abortPromise]);
task.resolve(value);
} catch (error) {
task.reject(timedOut ? timeoutError : error);
} finally {
clearTimeout(timeout), this.#releasePermit(permit), this.#activeCount = Math.max(0, this.#activeCount - 1);
const laneActive = Math.max(
0,
(this.#activeByLane.get(task.lane) ?? 0) - 1
);
laneActive ? this.#activeByLane.set(task.lane, laneActive) : this.#activeByLane.delete(task.lane), this.#finish(task), this.#queuePump();
}
}
#releasePermit(permit) {
if (permit)
try {
permit.release();
} catch (error) {
this.#onInternalError(error);
}
}
#removeQueued(task) {
const index = this.#queue.indexOf(task);
index >= 0 && this.#queue.splice(index, 1);
}
#finish(task, error) {
task.state !== "done" && (task.state = "done", task.externalSignal && task.externalAbort && task.externalSignal.removeEventListener("abort", task.externalAbort), task.externalAbort = null, task.controller = null, task.permitRestart = !1, this.#tasksByKey.get(task.key) === task && this.#tasksByKey.delete(task.key), error !== void 0 && task.reject(error));
}
}
}, "327537da25b41fe287048edb5b96f8f0976e42a61bccf41c9cbfa438bc8cc0c7");
/* Source: lite/src/notification/discourse-notification-adapter.ts */
runtime.register("src/notification/discourse-notification-adapter.js", function(module, exports, require) {
var discourse_notification_adapter_exports = {};
__export(discourse_notification_adapter_exports, {
BrowserDiscourseNotificationNativeState: () => import_native_host_api.BrowserDiscourseNotificationNativeState,
DiscourseNotificationRequestAdapter: () => DiscourseNotificationRequestAdapter
});
module.exports = __toCommonJS(discourse_notification_adapter_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_native_host_api = require("../discourse/native-host-api.js"), import_native_request_descriptors = require("../discourse/native-request-descriptors.js"), import_reader_notification_model = require("./reader-notification-model.js");
const notificationDescriptorBrand = Symbol(
"DiscourseNotificationPageDescriptor"
), notificationDescriptors = /* @__PURE__ */ new WeakSet();
function nonNegativePage(value) {
const page = Number(value);
if (!Number.isSafeInteger(page) || page < 0)
throw new RangeError("通知页码必须是非负安全整数");
return page;
}
function notificationPageCacheSettings(group, page) {
return Object.freeze({
kind: "discourse-notification-page",
tags: Object.freeze([
// 仅头页随实时事件失效;历史记录本身稳定,深页保留给水位续取。
page === 0 ? "notifications" : "notification-history",
`notification-group:${group}`
]),
freshForMs: page === 0 ? 30 * 6e4 : 4320 * 60 * 60 * 1e3,
retainForMs: 4320 * 60 * 60 * 1e3,
persist: !0
});
}
function notificationPageVariant(group) {
return group.source === "user-actions" ? `user-actions-limit-${group.pageSize}-v1` : void 0;
}
function username(value) {
const normalized = String(value ?? "").trim().replace(/^@/, "");
if (!normalized) throw new Error("通知分类请求需要当前登录用户名");
return normalized;
}
function notificationDescriptor(groupKey, pageValue, currentUsername, previousCursor) {
const group = (0, import_reader_notification_model.readerNotificationGroup)(groupKey), page = nonNegativePage(pageValue), offset = page * group.pageSize, query = new URLSearchParams();
let path;
if (group.source === "notifications")
query.set("offset", String(offset)), query.set("limit", String(group.pageSize)), currentUsername && query.set("username", currentUsername), path = "/notifications.json";
else if (group.source === "user-actions") {
const actor = username(currentUsername);
query.set("offset", String(offset)), query.set("limit", String(group.pageSize)), query.set("username", actor), query.set("filter", group.actionTypes.join(",")), path = "/user_actions.json";
} else if (group.source === "boosts-received") {
const actor = username(currentUsername);
previousCursor && query.set("before_boost_id", previousCursor), path = `/discourse-boosts/users/${encodeURIComponent(actor)}/boosts-received.json`;
} else if (group.source === "reactions-received")
query.set("username", username(currentUsername)), previousCursor && query.set("before_reaction_user_id", previousCursor), path = "/discourse-reactions/posts/reactions-received.json";
else {
const actor = username(currentUsername);
if (!group.path) throw new Error(`私信分类 ${group.key} 缺少原生 path`);
query.set("page", String(page)), path = `/topics/${group.path}/${encodeURIComponent(actor)}.json`;
}
const descriptor = Object.freeze({
group: group.key,
page,
path: `${path}${query.size ? `?${query.toString()}` : ""}`,
[notificationDescriptorBrand]: !0
});
return notificationDescriptors.add(descriptor), descriptor;
}
function assertNotificationDescriptor(value) {
if (value === null || typeof value != "object" || !notificationDescriptors.has(value))
throw new Error("通知读取必须来自具名 Discourse 请求目录");
}
function pickedEntries(payload, source) {
if (source === "notifications")
return Array.isArray(payload.notifications) ? payload.notifications : [];
if (source === "user-actions")
return Array.isArray(payload.user_actions) ? payload.user_actions : [];
if (source === "boosts-received")
return Array.isArray(payload.boosts) ? payload.boosts : [];
if (source === "private-messages") {
const topicList = (0, import_reader_notification_model.notificationRecord)(payload.topic_list);
return Array.isArray(topicList.topics) ? topicList.topics : [];
}
return Array.isArray(payload) ? payload : Array.isArray(payload.reactions) ? payload.reactions : [];
}
function positiveTotal(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : 0;
}
function positiveInteger(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
function consolidatedReplyInfo(value, presented) {
if (presented.typeName !== "replied") return null;
const source = (0, import_reader_notification_model.notificationRecord)(value), data = (0, import_reader_notification_model.notificationData)(source), count = positiveInteger(data.consolidated_count), parentPostNumber = positiveInteger(data.reply_to_post_number), topicId = positiveInteger(
presented.topicId ?? source.topic_id ?? data.topic_id
), latestPostNumber = positiveInteger(
presented.postNumber ?? source.post_number ?? data.post_number
), sourceNotificationId = positiveInteger(source.id);
return count === null || count <= 1 || parentPostNumber === null || topicId === null || latestPostNumber === null || sourceNotificationId === null ? null : Object.freeze({
topicId,
latestPostNumber,
parentPostNumber,
count,
sourceNotificationId
});
}
function topicPosts(value) {
const payload = (0, import_reader_notification_model.notificationRecord)(value), stream = (0, import_reader_notification_model.notificationRecord)(payload.post_stream), candidates = Array.isArray(stream.posts) ? stream.posts : Array.isArray(payload.posts) ? payload.posts : [];
return Object.freeze(candidates.map(import_reader_notification_model.notificationRecord));
}
function topicTaxonomyEntries(value) {
const payload = (0, import_reader_notification_model.notificationRecord)(value), topicList = (0, import_reader_notification_model.notificationRecord)(payload.topic_list), candidates = Array.isArray(topicList.topics) ? topicList.topics : Array.isArray(payload.topics) ? payload.topics : [];
return Object.freeze(candidates.map(import_reader_notification_model.notificationRecord));
}
function replyMatchesBucket(post, parentPostNumber, latestPostNumber) {
const postNumber = positiveInteger(post.post_number);
if (postNumber === null || postNumber > latestPostNumber) return !1;
const replyTo = Math.max(0, Number(post.reply_to_post_number) || 0);
return parentPostNumber === 1 ? replyTo <= 1 && postNumber > 1 : replyTo === parentPostNumber;
}
function expandedReplyValue(notificationValue, post) {
const notification = (0, import_reader_notification_model.notificationRecord)(notificationValue), data = (0, import_reader_notification_model.notificationData)(notification), actor = String(post.username ?? "").trim().replace(/^@/, ""), postNumber = positiveInteger(post.post_number);
return postNumber === null ? notificationValue : Object.freeze({
...notification,
data: Object.freeze({
...data,
consolidated_count: 1,
display_username: actor,
original_username: actor,
acting_user_name: actor,
username: actor,
post_number: postNumber
}),
post_number: postNumber,
created_at: post.created_at ?? notification.created_at,
username: actor,
acting_user_avatar_template: post.avatar_template ?? notification.acting_user_avatar_template
});
}
class DiscourseNotificationRequestAdapter {
authScope;
#gateway;
#ajax;
#native;
#signal;
#replyExpansionCache;
#basePath;
#categoryNameFor;
constructor(options) {
this.#gateway = options.gateway, this.#ajax = options.ajax, this.#native = options.native, this.authScope = (0, import_identifiers.discourseAuthScope)(options.authScope), this.#signal = options.signal, this.#replyExpansionCache = Object.freeze({
...options.replyExpansionCache,
tags: Object.freeze([...options.replyExpansionCache.tags])
}), this.#basePath = String(options.basePath ?? "").trim().replace(/\/+$/, ""), this.#categoryNameFor = options.categoryNameFor ?? (() => "");
}
groups() {
return import_reader_notification_model.READER_NOTIFICATION_GROUP_ORDER;
}
async enrichTopicTaxonomy(pages, options = {}) {
return this.#enrichTopicTaxonomy(pages, options, !1);
}
async enrichCachedTopicTaxonomy(pages) {
return this.#enrichTopicTaxonomy(pages, {}, !0);
}
async #enrichTopicTaxonomy(pages, options, cachedOnly) {
const topicIds = [...new Set(pages.flatMap((page) => page.records.flatMap((record) => record.source !== "private-messages" && record.target !== null && !record.tags.length ? [Number(record.target.topicId)] : [])))].filter((topicId) => positiveInteger(topicId) !== null).sort((left, right) => left - right);
if (!topicIds.length) return pages;
const batches = [];
for (let index = 0; index < topicIds.length; index += 100)
batches.push(topicIds.slice(index, index + 100));
const payloads = await Promise.all(batches.map(async (topicIdBatch) => {
const query = new URLSearchParams({
per_page: String(topicIdBatch.length)
});
for (const topicId of topicIdBatch)
query.append("topic_ids[]", String(topicId));
const path = `/latest.json?${query}`, cache = Object.freeze({
kind: "discourse-notification-topic-taxonomy",
tags: Object.freeze([
"notification-taxonomy",
...topicIdBatch.map((topicId) => `topic:${topicId}`)
]),
freshForMs: this.#replyExpansionCache.freshForMs,
retainForMs: this.#replyExpansionCache.retainForMs,
persist: this.#replyExpansionCache.persist
});
if (cachedOnly) {
const cachedCollectionPage = this.#gateway.cachedCollectionPage;
return typeof cachedCollectionPage != "function" ? null : cachedCollectionPage.call(this.#gateway, {
authScope: this.authScope,
collection: "notification-topic-taxonomy",
page: 0,
variant: `v1:${topicIdBatch.join(",")}`,
cache
});
}
return this.#gateway.loadCollectionPage({
authScope: this.authScope,
collection: "notification-topic-taxonomy",
page: 0,
variant: `v1:${topicIdBatch.join(",")}`,
profile: options.background || options.history ? "background-prefetch" : "collection-visible",
input: path,
signal: this.#signal,
timeoutMs: 2e4,
cache,
allowStaleOnError: !0,
transport: (request) => this.#ajax.request({
path,
method: "GET",
signal: request.signal,
noStore: !1
})
});
})), topics = /* @__PURE__ */ new Map();
for (const payload of payloads)
if (payload !== null)
for (const topic of topicTaxonomyEntries(payload)) {
const topicId = positiveInteger(topic.id ?? topic.topic_id);
topicId !== null && topics.set(topicId, topic);
}
return Object.freeze(pages.map((page) => {
let changed = !1;
const records = page.records.map((record) => {
const topic = record.target === null ? void 0 : topics.get(Number(record.target.topicId));
if (!topic) return record;
const enriched = (0, import_reader_notification_model.withReaderNotificationTopicTaxonomy)(
record,
topic,
this.#categoryNameFor
);
return enriched !== record && (changed = !0), enriched;
});
return changed ? Object.freeze({ ...page, records: Object.freeze(records) }) : page;
}));
}
async #loadConsolidatedReplyPosts(info, refresh) {
const candidate = import_native_request_descriptors.DiscourseNativeRequests.targetCandidates({
basePath: this.#basePath,
topicId: info.topicId,
postNumber: info.latestPostNumber,
scope: "around",
refresh
}).find((entry) => entry.endpoint === "topic-id-query");
if (!candidate)
throw new Error("合并回复缺少 Discourse topic-id-query 目录");
const payload = await this.#gateway.loadTopicTarget({
authScope: this.authScope,
topicId: info.topicId,
operation: "target:around:topic-id-query",
postNumber: info.latestPostNumber,
profile: "background-prefetch",
input: candidate.url,
signal: this.#signal,
cacheMode: refresh ? "refresh" : "default",
timeoutMs: 15e3,
cache: this.#consolidatedReplyCache(info.topicId),
allowStaleOnError: !refresh,
transport: (request) => this.#ajax.request({
path: candidate.descriptor.path,
method: "GET",
signal: request.signal,
headers: candidate.descriptor.headers,
noStore: candidate.descriptor.browserCache === "no-store"
})
});
return topicPosts(payload);
}
#consolidatedReplyCache(topicId) {
return Object.freeze({
...this.#replyExpansionCache,
tags: Object.freeze([.../* @__PURE__ */ new Set([
...this.#replyExpansionCache.tags,
"notifications",
`topic:${topicId}`
])].sort())
});
}
async #loadCachedConsolidatedReplyPosts(info) {
const cachedTopicTarget = this.#gateway.cachedTopicTarget;
if (typeof cachedTopicTarget != "function" || !import_native_request_descriptors.DiscourseNativeRequests.targetCandidates({
basePath: this.#basePath,
topicId: info.topicId,
postNumber: info.latestPostNumber,
scope: "around",
refresh: !1
}).find((entry) => entry.endpoint === "topic-id-query")) return null;
const payload = await cachedTopicTarget.call(this.#gateway, {
authScope: this.authScope,
topicId: info.topicId,
operation: "target:around:topic-id-query",
postNumber: info.latestPostNumber,
profile: "background-prefetch",
cache: this.#consolidatedReplyCache(info.topicId)
});
return payload === null ? null : topicPosts(payload);
}
async #expandNativeNotifications(entries, presented, refresh, cachedOnly = !1) {
const expanded = (await Promise.all(entries.map(async (value, index) => {
const initialPresented = presented[index] ?? Object.freeze({}), info = consolidatedReplyInfo(value, initialPresented);
if (!info)
return Object.freeze([Object.freeze({ value })]);
try {
const seenPostNumbers = /* @__PURE__ */ new Set(), loaded = cachedOnly ? await this.#loadCachedConsolidatedReplyPosts(info) : await this.#loadConsolidatedReplyPosts(info, refresh);
if (loaded === null)
return Object.freeze([Object.freeze({ value })]);
const replies = [...loaded].filter((post) => replyMatchesBucket(
post,
info.parentPostNumber,
info.latestPostNumber
)).sort((left, right) => Number(right.post_number) - Number(left.post_number)).filter((post) => {
const postNumber = positiveInteger(post.post_number);
return postNumber === null || seenPostNumbers.has(postNumber) ? !1 : (seenPostNumbers.add(postNumber), !0);
}).slice(0, info.count);
return replies.length !== info.count ? Object.freeze([Object.freeze({ value })]) : Object.freeze(replies.map((post) => {
const postNumber = positiveInteger(post.post_number);
return Object.freeze({
value: expandedReplyValue(value, post),
identity: `notification:${info.sourceNotificationId}:reply:${postNumber}`,
sourceNotificationId: info.sourceNotificationId
});
}));
} catch {
return Object.freeze([Object.freeze({ value })]);
}
}))).flat(), expandedPresented = await this.#native.present(
expanded.map((entry) => entry.value)
);
return Object.freeze(expanded.map((entry, index) => Object.freeze({
...entry,
presented: expandedPresented[index] ?? Object.freeze({})
})));
}
async loadCached(groupValue, pageValue, options = {}) {
const cachedNotificationPage = this.#gateway.cachedNotificationPage;
if (typeof cachedNotificationPage != "function") return null;
const group = (0, import_reader_notification_model.readerNotificationGroup)(groupValue), page = nonNegativePage(pageValue), variant = notificationPageVariant(group), payload = await cachedNotificationPage.call(this.#gateway, {
authScope: this.authScope,
group: group.key,
page,
...variant ? { variant } : {},
cache: notificationPageCacheSettings(group.key, page)
});
return payload === null ? null : this.#pageFromPayload(group.key, page, payload, options, !0);
}
async load(groupValue, pageValue, options = {}) {
const group = (0, import_reader_notification_model.readerNotificationGroup)(groupValue), page = nonNegativePage(pageValue), variant = notificationPageVariant(group);
let previousCursor = null;
if (page > 0 && (group.source === "boosts-received" || group.source === "reactions-received")) {
let previous = null;
if (!options.refresh)
try {
previous = await this.loadCached(group.key, page - 1, options);
} catch {
previous = null;
}
if (previous ??= await this.load(group.key, page - 1, options), previousCursor = previous.nextCursor, !previous.hasNext || previousCursor === null)
return Object.freeze({
group: group.key,
page,
records: Object.freeze([]),
total: previous.total,
hasNext: !1,
nextCursor: null
});
}
const descriptor = notificationDescriptor(
group.key,
page,
this.#native.username(),
previousCursor
);
assertNotificationDescriptor(descriptor);
const payload = await this.#gateway.loadNotificationPage({
authScope: this.authScope,
group: group.key,
page,
...variant ? { variant } : {},
...options.history ? {
profile: options.visibleHistory ? "surface-prefetch" : "background-prefetch"
} : options.background ? { profile: "surface-prefetch" } : {},
input: descriptor.path,
signal: this.#signal,
...options.refresh ? { cacheMode: "refresh" } : {},
timeoutMs: group.source === "reactions-received" ? 3e4 : 15e3,
cache: notificationPageCacheSettings(group.key, page),
transport: (request) => this.#ajax.request({
path: descriptor.path,
method: "GET",
signal: request.signal,
noStore: options.refresh === !0
})
});
return this.#pageFromPayload(group.key, page, payload, options, !1);
}
async #pageFromPayload(groupValue, page, payload, options, cachedOnly) {
const group = (0, import_reader_notification_model.readerNotificationGroup)(groupValue), source = (0, import_reader_notification_model.notificationRecord)(payload), rawEntries = pickedEntries(source, group.source);
let records;
if (group.source === "notifications") {
const presented = await this.#native.present(rawEntries);
records = (options.expandConsolidated === !1 ? Object.freeze(rawEntries.map((value, index) => Object.freeze({
value,
presented: presented[index] ?? Object.freeze({})
}))) : await this.#expandNativeNotifications(
rawEntries,
presented,
options.refresh === !0,
cachedOnly
)).map((entry) => (0, import_reader_notification_model.normalizeNativeNotification)(
entry.value,
entry.presented,
group.key,
{
...entry.identity === void 0 ? {} : { identity: entry.identity },
...entry.sourceNotificationId === void 0 ? {} : {
sourceNotificationId: entry.sourceNotificationId
},
categoryNameFor: this.#categoryNameFor
}
)).filter((record) => !group.typeNames.length || group.typeNames.includes(record.typeName));
} else group.source === "user-actions" ? records = rawEntries.map((entry) => (0, import_reader_notification_model.normalizeUserActionNotification)(
entry,
group.key,
this.#categoryNameFor
)) : group.source === "boosts-received" ? records = rawEntries.map((entry) => (0, import_reader_notification_model.normalizeBoostNotification)(entry, this.#categoryNameFor)) : group.source === "reactions-received" ? records = rawEntries.map((entry) => (0, import_reader_notification_model.normalizeReactionNotification)(entry, this.#categoryNameFor)) : records = rawEntries.map((entry) => (0, import_reader_notification_model.normalizePrivateMessageNotification)(
entry,
source,
group.key,
this.#native.username(),
this.#categoryNameFor
));
const topicList = (0, import_reader_notification_model.notificationRecord)(source.topic_list), serverTotal = positiveTotal(
source.total_rows_notifications ?? topicList.total_rows
), hasNext = group.source === "notifications" ? source.load_more_notifications === !0 || serverTotal > 0 && (page + 1) * group.pageSize < serverTotal : group.source === "private-messages" && !!topicList.more_topics_url || rawEntries.length >= group.pageSize, total = serverTotal > 0 ? serverTotal + (group.source === "notifications" ? Math.max(0, records.length - rawEntries.length) : 0) : page * group.pageSize + records.length + (hasNext ? group.pageSize : 0), last = (0, import_reader_notification_model.notificationRecord)(rawEntries.at(-1)), nextCursorValue = last.reaction_user_id ?? last.id, nextCursor = String(nextCursorValue ?? "").trim() || null;
return Object.freeze({
group: group.key,
page,
records: (0, import_reader_notification_model.sortReaderNotifications)(records),
total,
hasNext,
nextCursor
});
}
}
}, "02761084dedf0c1bb146318bc9e6e5fd21639625031bd1bc377f25e48221e048");
/* Source: lite/src/notification/reader-notification-controller.ts */
runtime.register("src/notification/reader-notification-controller.js", function(module, exports, require) {
var reader_notification_controller_exports = {};
__export(reader_notification_controller_exports, {
ReaderNotificationController: () => ReaderNotificationController,
readerNotificationRequestCanAutoRetry: () => readerNotificationRequestCanAutoRetry
});
module.exports = __toCommonJS(reader_notification_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_reader_collection_filter_model = require("../collection/reader-collection-filter-model.js"), import_reader_collection_hydration = require("../collection/reader-collection-hydration.js"), import_discourse_action_descriptors = require("../post/discourse-action-descriptors.js"), import_notification_action_feature_commands = require("../post/notification-action-feature-commands.js"), import_reader_search = require("../search/reader-search.js"), import_reader_notification_model = require("./reader-notification-model.js");
const DEFAULT_MAX_CACHED_PAGES = 32, DEFAULT_LIVE_REFRESH_DELAY_MS = 240, DEFAULT_RETRY_DELAY_MS = 600, DEFAULT_OPEN_REVALIDATE_MS = 30 * 6e4, DEFAULT_NATIVE_POLL_INTERVAL_MS = 30 * 6e4, DEFAULT_SYNTHETIC_POLL_INTERVAL_MS = 30 * 6e4, DEFAULT_HISTORY_STEP_DELAY_MS = 250, DEFAULT_HISTORY_RETRY_DELAY_MS = 15e3, LEGACY_USER_ACTION_PAGE_SIZE = 30, HISTORY_PROJECTION_BATCH_PAGES = 4, VISIBLE_HISTORY_LEASE_ROUNDS = 2, READER_NOTIFICATION_REACTION_LIKE_GROUPS = Object.freeze([
"likes",
"reactions"
]);
function notificationProjectionCheckpointNeedsRepair(group, snapshot) {
const descriptor = (0, import_reader_notification_model.readerNotificationGroup)(group);
if (!snapshot.complete || descriptor.source !== "user-actions" || snapshot.sourceOffset === void 0) return !1;
const sourcePageSize = Math.max(
1,
Math.floor(Number(snapshot.sourcePageSize ?? descriptor.pageSize) || 0)
), sourceNextPage = Math.max(
0,
Math.floor(Number(snapshot.sourceNextPage) || 0)
), sourceOffset = Math.max(
0,
Math.floor(Number(snapshot.sourceOffset) || 0)
), minimumCompleteRecords = Math.max(0, sourceOffset - sourcePageSize);
return sourceNextPage > 1 && snapshot.records.length < minimumCompleteRecords;
}
function readerNotificationRequestCanAutoRetry(cause) {
const source = cause !== null && typeof cause == "object" ? cause : Object.freeze({}), status = Number(source.status ?? 0);
if (status === 429) return !1;
if (status === 408 || status === 425 || status >= 500) return !0;
const name = String(source.name ?? ""), message = String(source.message ?? "");
return name === "TimeoutError" || name === "TypeError" || /failed to fetch|network|timeout|timed out|请求超时/i.test(message);
}
function readerNotificationPollBackoffMs(cause) {
const source = cause !== null && typeof cause == "object" ? cause : Object.freeze({});
if (Number(source.status ?? 0) !== 429) return 0;
const explicitMs = Number(source.retryAfterMs ?? source.retry_after_ms), explicitSeconds = Number(source.retryAfter ?? source.retry_after), explicit = Number.isFinite(explicitMs) && explicitMs > 0 ? explicitMs : Number.isFinite(explicitSeconds) && explicitSeconds > 0 ? explicitSeconds * 1e3 : 0;
return explicit > 0 ? Math.max(6e4, explicit) : 6e4;
}
function pageKey(group, page) {
return `${group}:${page}`;
}
function nativePageKey(page) {
return `native:${page}`;
}
function pageRecordSignature(page) {
return page.records.map((record) => record.identity).join("");
}
function sameTarget(left, right) {
return !!(left.target && right.target && left.target.topicId === right.target.topicId && left.target.postNumber === right.target.postNumber);
}
function readRecordKey(record) {
return record.sourceNotificationId === null || record.target === null ? null : [
record.sourceNotificationId,
record.group,
record.target.topicId,
record.target.postNumber,
record.actor.toLocaleLowerCase()
].join(":");
}
class ReaderNotificationController {
scope;
changes = new import_signal.Signal();
#requests;
#projection;
#native;
#actions;
#cache;
#target;
#descriptors = new import_discourse_action_descriptors.DiscourseActionDescriptors();
#commands;
#maxCachedPages;
#liveRefreshDelayMs;
#backgroundWarmDelayMs;
#openRevalidateMs;
#nativePollIntervalMs;
#syntheticPollIntervalMs;
#historyStepDelayMs;
#historyRetryDelayMs;
#visibleHistoryConcurrency;
#historyCoordination;
#historyCoordinationKey;
#retryDelayMs;
#delay;
#schedule;
#cancel;
#now;
#activity;
#searchForms;
#onError;
#historyAbort;
#pages = /* @__PURE__ */ new Map();
#taxonomyFlights = /* @__PURE__ */ new Map();
#historyRecords = /* @__PURE__ */ new Map();
#projectionRecords = /* @__PURE__ */ new Map();
#historyGroups = new Map(import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.map((group) => [
group,
{
pages: /* @__PURE__ */ new Set(),
nextPage: 0,
terminalPage: null,
estimatedPages: 1,
complete: !1,
retryAt: null,
error: null
}
]));
#lastAuthoritativeAt = /* @__PURE__ */ new Map();
#readRecordKeys = /* @__PURE__ */ new Set();
#groups = {
notifications: "all",
messages: "inbox"
};
#open = !1;
#mode = "notifications";
#group = "all";
#page = 0;
#query = "";
#categoryFilter = "";
#tagFilter = "";
#dateFilter = "";
#sortDirection = "desc";
#records = Object.freeze([]);
#total = 0;
#hasNext = !1;
#loading = !1;
#refreshing = !1;
#retrying = !1;
#markingAll = !1;
#stale = !1;
#error = null;
#unreadCount = 0;
#revision = 0;
#snapshotCache = null;
#loadEpoch = 0;
#navigationEpoch = 0;
#selectionFlight = null;
#liveRefresh = null;
#poll = null;
#pollNotBefore = 0;
#historySchedule = null;
#historyLoading = !1;
#historyEpoch = 0;
#historyCursor = 0;
#historyStatus = "idle";
#historyCurrentGroup = null;
#historyError = null;
#historyRetryAt = null;
#backgroundWarm = null;
#backgroundWarming = !1;
#backgroundWarmPending = !1;
#backgroundWarmEpoch = 0;
#backgroundCacheActive = !1;
#backgroundRestore = null;
#projectionPersistAfterRestore = /* @__PURE__ */ new Set();
#projectionCheckpointReplacements = /* @__PURE__ */ new Set();
#historyInFlightGroups = /* @__PURE__ */ new Set();
#nativeRefreshPending = !1;
#nativeChangePending = !1;
#nativeChangeEpoch = 0;
constructor(options) {
if (this.#requests = options.requests, this.#projection = options.projection ?? null, this.#native = options.native, this.#actions = options.actions, this.#cache = options.cache, this.#target = options.target, this.#maxCachedPages = Math.floor(
Number(options.maxCachedPages ?? DEFAULT_MAX_CACHED_PAGES)
), !Number.isSafeInteger(this.#maxCachedPages) || this.#maxCachedPages < 1)
throw new RangeError("通知热缓存页数必须是正安全整数");
if (this.#liveRefreshDelayMs = Number(
options.liveRefreshDelayMs ?? DEFAULT_LIVE_REFRESH_DELAY_MS
), !Number.isFinite(this.#liveRefreshDelayMs) || this.#liveRefreshDelayMs < 0)
throw new RangeError("通知实时刷新延迟必须是非负有限数值");
if (this.#backgroundWarmDelayMs = options.backgroundWarmDelayMs === void 0 ? null : Number(options.backgroundWarmDelayMs), this.#backgroundWarmDelayMs !== null && (!Number.isFinite(this.#backgroundWarmDelayMs) || this.#backgroundWarmDelayMs < 0))
throw new RangeError("通知后台预热延迟必须是非负有限数值");
this.#openRevalidateMs = Number(
options.openRevalidateMs ?? DEFAULT_OPEN_REVALIDATE_MS
), this.#nativePollIntervalMs = Number(
options.nativePollIntervalMs ?? DEFAULT_NATIVE_POLL_INTERVAL_MS
), this.#syntheticPollIntervalMs = Number(
options.syntheticPollIntervalMs ?? DEFAULT_SYNTHETIC_POLL_INTERVAL_MS
), this.#historyStepDelayMs = Number(
options.historyStepDelayMs ?? DEFAULT_HISTORY_STEP_DELAY_MS
), this.#historyRetryDelayMs = Number(
options.historyRetryDelayMs ?? DEFAULT_HISTORY_RETRY_DELAY_MS
), this.#visibleHistoryConcurrency = Number(
options.visibleHistoryConcurrency ?? 1
), this.#historyCoordination = options.historyCoordination, this.#historyCoordinationKey = String(
options.historyCoordinationKey ?? ""
).trim();
for (const [label, value] of [
["通知打开回查间隔", this.#openRevalidateMs],
["原生通知轮询间隔", this.#nativePollIntervalMs],
["合成通知轮询间隔", this.#syntheticPollIntervalMs],
["通知历史回填步进", this.#historyStepDelayMs],
["通知历史回填重试", this.#historyRetryDelayMs]
])
if (!Number.isFinite(value) || value < 0)
throw new RangeError(`${label}必须是非负有限数值`);
if (!Number.isSafeInteger(this.#visibleHistoryConcurrency) || this.#visibleHistoryConcurrency < 1 || this.#visibleHistoryConcurrency > import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.length)
throw new RangeError("通知可见历史并发数必须位于 1 到 7");
if (this.#retryDelayMs = Number(
options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
), !Number.isFinite(this.#retryDelayMs) || this.#retryDelayMs < 0)
throw new RangeError("通知自动重试延迟必须是非负有限数值");
this.#delay = options.delay ?? ((delayMs) => new Promise((resolve) => {
setTimeout(resolve, delayMs);
})), this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle)), this.#now = options.now ?? Date.now, this.#activity = options.activity ?? null, this.#searchForms = options.searchForms ?? ((value) => Object.freeze([(0, import_reader_search.normalizeReaderSearchText)(value)])), this.#onError = options.onError ?? (() => {
}), this.#unreadCount = this.#native.unreadCount(), this.#commands = new import_notification_action_feature_commands.NotificationActionFeatureCommands({
state: {
markAllRead: () => this.#commitAllRead(),
markRead: (notificationId) => this.#commitRead(notificationId),
refresh: () => this.refresh()
}
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#historyAbort = this.scope.abortController(
new Error("通知历史采集已关闭")
), this.scope.add(this.#native.subscribeChanged(() => {
this.#onNativeChanged();
})), this.#native.subscribeClicked && this.scope.add(this.#native.subscribeClicked((click) => {
this.#onNativeClicked(click);
})), this.#activity && this.scope.add(this.#activity.subscribe(() => {
this.#onActivityChanged();
})), this.scope.add(() => {
this.#loadEpoch += 1, this.#navigationEpoch += 1, this.#liveRefresh !== null && this.#cancel(this.#liveRefresh), this.#poll !== null && this.#cancel(this.#poll), this.#historySchedule !== null && this.#cancel(this.#historySchedule), this.#backgroundWarm !== null && this.#cancel(this.#backgroundWarm), this.#liveRefresh = null, this.#poll = null, this.#historySchedule = null, this.#historyEpoch += 1, this.#historyInFlightGroups.clear(), this.#backgroundWarm = null, this.#backgroundWarmEpoch += 1, this.#backgroundCacheActive = !1, this.#backgroundRestore = null, this.#projectionPersistAfterRestore.clear(), this.#taxonomyFlights.clear(), this.#pages.clear(), this.#historyRecords.clear(), this.#lastAuthoritativeAt.clear(), this.#readRecordKeys.clear(), this.changes.clear();
});
}
get snapshot() {
if (this.#snapshotCache?.revision === this.#revision)
return this.#snapshotCache;
const totalPages = this.#hasLocalFilters() ? Math.max(1, Math.ceil(this.#matchingRecords().length / (0, import_reader_notification_model.readerNotificationGroup)(this.#group).pageSize)) : Math.max(
1,
Math.ceil(
this.#total / (0, import_reader_notification_model.readerNotificationGroup)(this.#group).pageSize
)
);
return this.#snapshotCache = Object.freeze({
open: this.#open,
mode: this.#mode,
group: this.#group,
groupCounts: this.#groupCounts(),
page: this.#page,
query: this.#query,
categoryFilter: this.#categoryFilter,
tagFilter: this.#tagFilter,
dateFilter: this.#dateFilter,
sortDirection: this.#sortDirection,
dayCounts: this.#dayCounts(),
categoryOptions: this.#categoryOptions(),
tagOptions: this.#tagOptions(),
records: this.#records,
total: this.#total,
totalPages,
hasNext: this.#hasNext,
loading: this.#loading,
refreshing: this.#refreshing,
retrying: this.#retrying,
markingAll: this.#markingAll,
stale: this.#stale,
unreadCount: this.#unreadCount,
error: this.#error,
history: this.#historySnapshot(),
revision: this.#revision
}), this.#snapshotCache;
}
cacheStats() {
const indexedRecords = [...this.#historyRecords.values()].reduce(
(total, records) => total + records.size,
0
), projectedRecords = new Set(
[...this.#projectionRecords.values()].flatMap((records) => [...records.values()].map((record) => `${record.group}:${record.identity}`))
).size, pagedRecords = new Set(
[...this.#pages.values()].flatMap((entry) => entry.page.records.map((record) => `${record.group}:${record.identity}`))
).size;
return Object.freeze({
pages: this.#pages.size,
records: Math.max(indexedRecords, projectedRecords, pagedRecords)
});
}
#groupCounts() {
const counts = /* @__PURE__ */ new Map(), remember = (group, count) => {
counts.set(group, Math.max(counts.get(group) ?? 0, count));
};
for (const [group, records] of this.#projectionRecords)
remember(group, records.size);
for (const [group, records] of this.#historyRecords)
remember(group, records.size);
for (const [key, entry] of this.#pages)
key.startsWith("native:") || remember(entry.page.group, entry.page.total);
const reactionLikes = READER_NOTIFICATION_REACTION_LIKE_GROUPS.reduce(
(total, group) => total + (counts.get(group) ?? 0),
0
);
remember("reactionLikes", reactionLikes);
const all = import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.reduce(
(total, group) => total + (counts.get(group) ?? 0),
0
);
return remember("all", all), counts;
}
/** application 启动时恢复持久投影;随后与浮窗开关无关地渐进续传。 */
startBackgroundCache() {
if (this.scope.destroyed || this.#backgroundCacheActive || this.#backgroundWarmDelayMs === null || this.#activity !== null && !this.#activityVisible()) return;
this.#backgroundCacheActive = !0;
const restore = this.#restoreBackgroundProjections();
this.#backgroundRestore = restore, restore.catch(this.#onError).finally(() => {
if (this.#backgroundRestore !== restore) return;
this.#backgroundRestore = null;
const pendingGroups = [...this.#projectionPersistAfterRestore];
if (this.#projectionPersistAfterRestore.clear(), !(this.scope.destroyed || !this.#backgroundCacheActive)) {
for (const group of pendingGroups) this.#persistProjection(group);
this.#scheduleHistoryHydration(this.#historyContinuationDelay());
}
});
}
/** 保留已提交分类页断点,只提前重排续传;中央限流仍拥有最终许可。 */
retryBackgroundCache() {
if (!(this.scope.destroyed || this.#historyStatus === "complete")) {
this.#historySchedule !== null && this.#cancel(this.#historySchedule), this.#historySchedule = null;
for (const state of this.#historyGroups.values())
state.retryAt = null, state.error = null;
this.#historyError = null, this.#historyRetryAt = null, this.#historyLoading || (this.#historyStatus = "idle", this.#historyCurrentGroup = null), this.#emit(), this.#scheduleHistoryHydration(0);
}
}
reloadExternalProjection() {
if (!this.#projection || this.scope.destroyed) return Promise.resolve();
const restore = (this.#backgroundRestore ?? Promise.resolve()).catch(() => {
}).then(() => this.#restoreBackgroundProjections(!0));
return this.#backgroundRestore = restore, restore.finally(() => {
this.#backgroundRestore === restore && (this.#backgroundRestore = null);
});
}
async #restoreBackgroundProjections(fresh = !1) {
if (!this.#projection || this.scope.destroyed) return;
const restored = await Promise.all(
import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.map(async (group) => {
try {
return Object.freeze({
group,
snapshot: await this.#projection.read(
group,
fresh ? { fresh: !0 } : void 0
)
});
} catch (cause) {
return this.#onError(cause), Object.freeze({ group, snapshot: null });
}
})
);
if (this.scope.destroyed) return;
const checkpointRepairs = [];
for (const { group, snapshot } of restored) {
if (!snapshot) continue;
const state = this.#historyGroups.get(group), repairCheckpoint = notificationProjectionCheckpointNeedsRepair(group, snapshot);
state.retryAt = null, state.error = null, this.#rememberProjectionRecords(group, snapshot.records, fresh);
const records = /* @__PURE__ */ new Map();
for (const record of snapshot.records)
records.set(record.identity, record);
if (!fresh)
for (const record of this.#historyRecords.get(group)?.values() ?? [])
records.set(record.identity, record);
this.#historyRecords.set(group, records), state.estimatedPages = Math.max(
state.estimatedPages,
repairCheckpoint ? Math.max(1, Math.floor(Number(snapshot.sourceNextPage) || 0)) : 1,
Math.max(
1,
Math.ceil(
Math.max(snapshot.totalHint, records.size) / (0, import_reader_notification_model.readerNotificationGroup)(group).pageSize
)
)
);
const expectedSourcePageSize = (0, import_reader_notification_model.readerNotificationGroup)(group).pageSize, resumed = (0, import_reader_collection_hydration.readerCollectionResumePosition)(
snapshot,
expectedSourcePageSize,
(0, import_reader_notification_model.readerNotificationGroup)(group).source === "user-actions" ? LEGACY_USER_ACTION_PAGE_SIZE : expectedSourcePageSize
), sourceNextPage = snapshot.complete ? Math.max(0, Math.floor(Number(snapshot.sourceNextPage ?? 0))) : resumed.page;
state.nextPage = repairCheckpoint ? 0 : sourceNextPage, state.pages.clear();
for (let page = 0; page < state.nextPage; page += 1)
state.pages.add(page);
if (state.terminalPage = null, state.complete = !1, (!snapshot.complete || repairCheckpoint) && state.nextPage > 0 && (state.estimatedPages = Math.max(
state.estimatedPages,
state.nextPage + 1
)), snapshot.complete && !repairCheckpoint) {
const completedPages = Math.max(
1,
sourceNextPage > 0 ? sourceNextPage : state.estimatedPages
);
state.complete = !0, state.estimatedPages = completedPages, state.nextPage = completedPages, state.terminalPage = completedPages - 1, state.pages.clear();
for (let page = 0; page < completedPages; page += 1)
state.pages.add(page);
} else repairCheckpoint && (this.#projectionCheckpointReplacements.add(group), checkpointRepairs.push(group));
for (const entry of this.#pages.values())
entry.page.group === group && this.#indexHistoryPage(entry.page);
}
this.#refreshAggregateHistoryPages(), this.#historyStatus = [...this.#historyGroups.values()].every(
(state) => state.complete
) ? "complete" : "idle", this.#historyCurrentGroup = null, this.#historyError = null, this.#historyRetryAt = null, checkpointRepairs.length && await Promise.all(checkpointRepairs.map((group) => this.#persistProjection(group))), this.#raiseUnreadCountForCachedRecords(), this.#open && this.#pages.has(pageKey(this.#group, this.#page)) ? this.#renderFromCache() : this.#emit();
}
/** WebDAV 只同步逐条历史投影;原生通知 ID 与已读状态仍由 Discourse 裁决。 */
syncHistoryRecords() {
const records = /* @__PURE__ */ new Map();
for (const group of import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER) {
for (const entry of this.#projectionRecords.get(group)?.values() ?? [])
records.set(entry.identity, entry);
for (const entry of this.#historyRecords.get(group)?.values() ?? [])
records.set(entry.identity, entry);
}
return (0, import_reader_notification_model.sortReaderNotifications)([...records.values()]);
}
applySyncedHistoryRecords(records) {
if (!this.scope.destroyed) {
for (const incoming of records) {
if (!incoming.identity || !import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.includes(incoming.group)) continue;
const indexed = this.#historyRecords.get(incoming.group) ?? /* @__PURE__ */ new Map(), current = indexed.get(incoming.identity);
indexed.set(incoming.identity, current ?? Object.freeze({
...incoming,
sourceNotificationId: null,
read: null
})), this.#historyRecords.set(incoming.group, indexed);
}
this.#refreshAggregateHistoryPages();
for (const group of import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER)
this.#persistProjection(group);
this.#persistProjection("all"), this.#hasLocalFilters() && this.#open ? this.#renderFromCache() : this.#emit();
}
}
#historySnapshot() {
const states = [...this.#historyGroups.values()], completedGroups = states.filter((state) => state.complete).length, loadedPages = states.reduce(
(total, state) => total + state.pages.size,
0
), estimatedPages = states.reduce(
(total, state) => total + Math.max(state.pages.size, state.estimatedPages),
0
), cachedRecords = [...this.#historyRecords.values()].reduce(
(total, records) => total + records.size,
0
), totalGroups = import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.length;
return Object.freeze({
status: this.#historyStatus,
currentGroup: this.#historyCurrentGroup,
completedGroups,
totalGroups,
loadedPages,
estimatedPages,
cachedRecords,
progress: totalGroups > 0 ? completedGroups / totalGroups : 1,
error: this.#historyError,
retryAt: this.#historyRetryAt
});
}
#resetHistoryHydration() {
this.#historyEpoch += 1, this.#historySchedule !== null && this.#cancel(this.#historySchedule), this.#historySchedule = null, this.#historyLoading = !1, this.#historyInFlightGroups.clear(), this.#historyCursor = 0, this.#historyStatus = "idle", this.#historyCurrentGroup = null, this.#historyError = null, this.#historyRetryAt = null, this.#historyRecords.clear();
for (const state of this.#historyGroups.values())
state.pages.clear(), state.nextPage = 0, state.terminalPage = null, state.estimatedPages = 1, state.complete = !1, state.retryAt = null, state.error = null;
}
clearCache() {
this.scope.destroyed || (this.#loadEpoch += 1, this.#navigationEpoch += 1, this.#backgroundWarmEpoch += 1, this.#liveRefresh !== null && this.#cancel(this.#liveRefresh), this.#poll !== null && this.#cancel(this.#poll), this.#historySchedule !== null && this.#cancel(this.#historySchedule), this.#backgroundWarm !== null && this.#cancel(this.#backgroundWarm), this.#liveRefresh = null, this.#poll = null, this.#historySchedule = null, this.#backgroundWarm = null, this.#backgroundWarmPending = !1, this.#nativeRefreshPending = !1, this.#nativeChangePending = !1, this.#pollNotBefore = 0, this.#pages.clear(), this.#resetHistoryHydration(), this.#projectionRecords.clear(), this.#lastAuthoritativeAt.clear(), this.#readRecordKeys.clear(), this.#records = Object.freeze([]), this.#categoryFilter = "", this.#tagFilter = "", this.#dateFilter = "", this.#sortDirection = "desc", this.#total = 0, this.#hasNext = !1, this.#loading = !1, this.#refreshing = !1, this.#retrying = !1, this.#stale = !1, this.#error = null, this.#emit(), this.#schedulePoll(), this.#scheduleBackgroundWarm(), this.#scheduleHistoryHydration(this.#historyContinuationDelay()));
}
async open() {
if (this.scope.destroyed) throw new Error("通知控制器已销毁");
this.#open || (this.#open = !0, this.#emit());
const key = pageKey(this.#group, this.#page), cached = this.#pages.has(key);
cached && this.#renderFromCache(), cached ? this.#nativeChangePending && await this.#runSelectedRequest(() => this.#refreshAfterNativeChange()) : (this.#loading = !0, this.#emit(), this.#projection && await this.#restoreSelectedProjection(this.#navigationEpoch), await this.#runSelectedRequest(() => this.#showSelectedPage()));
}
close() {
this.#open && (this.#open = !1, this.#loadEpoch += 1, this.#navigationEpoch += 1, this.#cancelPoll(), this.#backgroundCacheActive || (this.#backgroundWarmEpoch += 1, this.#backgroundWarmPending = !1, this.#backgroundWarm !== null && this.#cancel(this.#backgroundWarm), this.#backgroundWarm = null, this.#historyEpoch += 1, this.#historySchedule !== null && this.#cancel(this.#historySchedule), this.#historySchedule = null, this.#historyStatus !== "complete" && (this.#historyStatus = "paused", this.#historyCurrentGroup = null)), this.#emit());
}
async toggle() {
this.#open ? this.close() : await this.open();
}
async selectMode(mode) {
if (mode !== "notifications" && mode !== "messages")
throw new Error("未知消息模式");
this.#mode = mode, this.#group = this.#groups[mode], this.#page = 0, this.#query = "", this.#categoryFilter = "", this.#tagFilter = "", this.#dateFilter = "", this.#sortDirection = "desc";
const epoch = this.#beginNavigation();
await this.#runNavigation(epoch);
}
async selectGroup(groupValue) {
const group = (0, import_reader_notification_model.readerNotificationGroup)(groupValue);
this.#mode = group.mode, this.#group = group.key, this.#groups[group.mode] = group.key, this.#page = 0, this.#query = "", this.#categoryFilter = "", this.#tagFilter = "", this.#dateFilter = "", this.#sortDirection = "desc";
const epoch = this.#beginNavigation();
await this.#runNavigation(epoch);
}
setQuery(value) {
const query = (0, import_reader_search.normalizeReaderSearchText)(value);
query !== this.#query && (this.#query = query, this.#localFilterChanged());
}
setCategoryFilter(value) {
const filter = String(value ?? "").trim();
filter !== this.#categoryFilter && (this.#categoryFilter = filter, this.#localFilterChanged());
}
setTagFilter(value) {
const filter = String(value ?? "").trim();
filter !== this.#tagFilter && (this.#tagFilter = filter, this.#localFilterChanged());
}
setDateFilter(value) {
const filter = String(value ?? "").trim();
filter !== this.#dateFilter && (this.#dateFilter = filter, this.#localFilterChanged());
}
setSortDirection(value) {
const direction = value === "asc" ? "asc" : "desc";
direction !== this.#sortDirection && (this.#sortDirection = direction, this.#localFilterChanged());
}
resetFilters() {
this.#hasLocalFilters() && (this.#query = "", this.#categoryFilter = "", this.#tagFilter = "", this.#dateFilter = "", this.#sortDirection = "desc", this.#localFilterChanged());
}
#localFilterChanged() {
if (this.#page = 0, !this.#hasLocalFilters() && this.#consumeNativeRefreshPending() && this.#open) {
this.#runSelectedRequest(() => this.#refreshAfterNativeChange());
return;
}
this.#renderFromCache();
}
async previousPage() {
if (this.#page <= 0) return;
this.#page -= 1;
const epoch = this.#beginNavigation();
await this.#runNavigation(epoch);
}
async nextPage() {
const snapshot = this.snapshot;
if (this.#page >= snapshot.totalPages - 1 && !snapshot.hasNext) return;
this.#page += 1;
const epoch = this.#beginNavigation();
await this.#runNavigation(epoch);
}
async refresh() {
this.scope.destroyed || (await this.#runSelectedRequest(() => this.#refreshAfterNativeChange()), this.#schedulePoll());
}
async markAllAsRead() {
if (!(this.#unreadCount <= 0 || this.scope.destroyed || this.#markingAll)) {
this.#markingAll = !0, this.#emit();
try {
await this.#actions.dispatch(
this.#commands.markAllRead(this.#descriptors.notificationsMarkRead())
);
} finally {
this.scope.destroyed || (this.#markingAll = !1, this.#emit());
}
}
}
async markRecordRead(record) {
const notificationId = record.sourceNotificationId;
if (notificationId === null || record.read !== !1 || this.scope.destroyed)
return;
const childScoped = this.#childReadSourceIds().has(notificationId);
if (this.#setReadRecordState(record, !0), !(childScoped && !this.#allSourceRecordsRead(notificationId)))
try {
await this.#actions.dispatch(this.#commands.markRead(
notificationId,
this.#descriptors.notificationMarkRead({ notificationId })
));
} catch (cause) {
throw this.scope.destroyed || this.#setReadRecordState(record, !1), cause;
}
}
async openRecord(record) {
if (!record.target) return;
const boostId = record.group === "boosts" ? Number(record.identity.match(/^boosts:(\d+)$/)?.[1]) : 0;
await this.#target.openTarget({
topicId: record.target.topicId,
postNumber: record.target.postNumber,
source: record.source === "private-messages" ? "message" : "notification",
...Number.isSafeInteger(boostId) && boostId > 0 ? { boostId } : {},
focus: !0,
highlight: !0
}) && record.sourceNotificationId !== null && record.read === !1 && this.markRecordRead(record).catch((cause) => this.#onError(cause));
}
destroy() {
this.scope.destroy();
}
#beginNavigation() {
const epoch = ++this.#navigationEpoch;
return this.#loadEpoch += 1, this.#cancelPoll(), !this.#backgroundCacheActive && this.#historySchedule !== null && (this.#cancel(this.#historySchedule), this.#historySchedule = null), this.#loading = !this.#pages.has(pageKey(this.#group, this.#page)), this.#refreshing = !1, this.#retrying = !1, this.#stale = !1, this.#error = null, this.#renderFromCache(), epoch;
}
async #runNavigation(epoch) {
const valid = () => !this.scope.destroyed && epoch === this.#navigationEpoch;
try {
if (this.#projection && await this.#restoreSelectedProjection(epoch), !valid()) return;
await this.#runSelectedRequest(async () => {
if (this.#consumeNativeRefreshPending()) {
await this.#refreshAfterNativeChange();
return;
}
await this.#showSelectedPage();
}, valid);
} finally {
valid() && (this.#schedulePoll(), this.#scheduleHistoryHydration(this.#historyContinuationDelay()));
}
}
async #restoreSelectedProjection(epoch) {
if (!this.#projection) return !1;
const group = this.#group, selectedPage = this.#page, key = pageKey(group, selectedPage);
if (this.#pages.has(key)) return !0;
let stored;
try {
if (group === "reactionLikes") {
const [likes, reactions] = await Promise.all([
this.#projection.read("likes"),
this.#projection.read("reactions")
]);
stored = likes || reactions ? Object.freeze({
records: (0, import_reader_notification_model.sortReaderNotifications)([
...likes?.records ?? [],
...reactions?.records ?? []
]),
totalHint: (likes?.totalHint ?? 0) + (reactions?.totalHint ?? 0),
complete: likes?.complete === !0 && reactions?.complete === !0,
updatedAt: Math.max(
likes?.updatedAt ?? 0,
reactions?.updatedAt ?? 0
)
}) : null;
} else
stored = await this.#projection.read(group);
} catch (cause) {
return !this.scope.destroyed && epoch === this.#navigationEpoch && this.#onError(cause), !1;
}
if (this.scope.destroyed || epoch !== this.#navigationEpoch || this.#group !== group || this.#page !== selectedPage || !stored) return !1;
const descriptor = (0, import_reader_notification_model.readerNotificationGroup)(group), start = selectedPage * descriptor.pageSize;
if (start >= stored.records.length && !(selectedPage === 0 && stored.complete)) return !1;
const end = start + descriptor.pageSize;
this.#rememberProjectionRecords(group, stored.records);
const page = Object.freeze({
group,
page: selectedPage,
records: Object.freeze(stored.records.slice(start, end)),
total: Math.max(stored.records.length, stored.totalHint),
hasNext: end < stored.records.length || !stored.complete,
nextCursor: null
});
return this.#cachePage(page, stored.updatedAt, !1), this.#applyPage(page), this.#loading = !1, this.#refreshing = !1, this.#retrying = !1, this.#stale = !1, this.#error = null, this.#raiseUnreadCountForCachedRecords(), this.#emit(), !0;
}
async #runSelectedRequest(task, valid = () => !this.scope.destroyed) {
for (; this.#selectionFlight !== null; ) {
const active = this.#selectionFlight;
try {
await active;
} catch {
}
if (!valid()) return;
}
if (!valid() || this.#deferForRateLimit()) return;
const flight = (async () => {
await task();
})();
this.#selectionFlight = flight;
try {
await flight;
} finally {
this.#selectionFlight === flight && (this.#selectionFlight = null);
}
}
#deferForRateLimit() {
const remainingMs = this.#pollNotBefore - this.#now();
if (!(remainingMs > 0)) return !1;
const cached = this.#pages.get(pageKey(this.#group, this.#page));
return cached ? this.#applyPage(cached.page) : (this.#records = Object.freeze([]), this.#total = 0, this.#hasNext = !1), this.#loading = !1, this.#refreshing = !1, this.#retrying = !1, this.#stale = !!cached, this.#error = Object.assign(
new Error("请求冷却中,将自动重试"),
{ status: 429, retryAfterMs: remainingMs }
), this.#emit(), this.#schedulePoll(), this.#scheduleHistoryHydration(
Math.max(this.#historyRetryDelayMs, remainingMs)
), !0;
}
async #showSelectedPage() {
if (this.#hasLocalFilters()) {
this.#renderFromCache();
return;
}
const key = pageKey(this.#group, this.#page);
if (this.#pages.has(key)) {
this.#renderFromCache();
return;
}
const restored = await this.#restoreSelectedPageFromPersistentCache();
if (!(restored === null || restored)) {
if (this.#pages.has(key)) {
this.#renderFromCache();
return;
}
this.#open ? await this.#refreshAfterNativeChange() : await this.#load(!0);
}
}
async #restoreSelectedPageFromPersistentCache() {
const epoch = ++this.#loadEpoch;
this.#loading = !0, this.#refreshing = !1, this.#retrying = !1, this.#error = null, this.#stale = !1, this.#emit();
let page;
try {
page = await this.#loadCachedRequestedPage(this.#group, this.#page);
} catch (cause) {
if (this.scope.destroyed || epoch !== this.#loadEpoch) return null;
this.#onError(cause), page = null;
}
return this.scope.destroyed || epoch !== this.#loadEpoch ? null : page ? (this.#cachePage(page), page.group !== "all" && page.group !== "reactionLikes" && this.#queueTopicTaxonomyEnrichment([page]), page.group === "all" && this.#inheritAllSyntheticPages(), this.#raiseUnreadCountForCachedRecords(), this.#applyPage(this.#pages.get(
pageKey(this.#group, this.#page)
)?.page ?? page), this.#loading = !1, this.#refreshing = !1, this.#retrying = !1, this.#stale = !1, this.#error = null, this.#emit(), this.#scheduleHistoryHydration(this.#historyContinuationDelay()), !0) : !1;
}
async #loadRequestedPage(group, page, options = {}) {
if (group === "reactionLikes")
return this.#loadReactionLikePage(page, options);
if (group !== "all") {
const history = this.#historyGroups.get(group);
if (page > 0 && history?.complete) {
const descriptor = (0, import_reader_notification_model.readerNotificationGroup)(group), records = (0, import_reader_notification_model.sortReaderNotifications)([
...this.#historyRecords.get(group)?.values() ?? []
]), start = page * descriptor.pageSize, end = start + descriptor.pageSize;
return Object.freeze({
group,
page,
records: Object.freeze(records.slice(start, end)),
total: records.length,
hasNext: end < records.length,
nextCursor: null
});
}
return this.#requests.load(group, page, options);
}
return this.#loadAggregatePage(page, options);
}
async #loadCachedRequestedPage(group, page) {
const requests = this.#requests;
if (typeof requests.loadCached != "function") return null;
const loadCached = (groupKey, groupPage) => requests.loadCached.call(this.#requests, groupKey, groupPage);
if (group === "all") {
if (page > 0) return null;
const [cachedGroups, nativePage] = await Promise.all([
Promise.all(import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.map(
(groupKey) => loadCached(groupKey, 0)
)),
loadCached("all", 0)
]), loadedPages = cachedGroups.filter(
(loaded2) => loaded2 !== null
);
if (!loadedPages.length) return null;
nativePage && this.#cacheNativeAssociationPage(nativePage);
for (const loaded2 of loadedPages) this.#cachePage(loaded2);
return this.#queueTopicTaxonomyEnrichment(loadedPages), this.#indexedAggregatePage(0);
}
if (group === "reactionLikes") {
const loadedPages = (await Promise.all(
READER_NOTIFICATION_REACTION_LIKE_GROUPS.map((groupKey) => loadCached(groupKey, page))
)).filter((loaded2) => loaded2 !== null);
if (!loadedPages.length) return null;
for (const loaded2 of loadedPages) this.#cachePage(loaded2);
return this.#queueTopicTaxonomyEnrichment(loadedPages), this.#indexedReactionLikePage(page);
}
const loaded = await loadCached(group, page);
return loaded || null;
}
async #enrichTopicTaxonomy(pages, options = {}) {
const requests = this.#requests;
if (typeof requests.enrichTopicTaxonomy != "function") return pages;
try {
const enriched = await requests.enrichTopicTaxonomy.call(
this.#requests,
pages,
options
);
return enriched.length === pages.length ? enriched : pages;
} catch (cause) {
return this.#onError(cause), pages;
}
}
#queueTopicTaxonomyEnrichment(pages, options = {}) {
const candidates = pages.filter((page) => page.records.length > 0);
if (!candidates.length || this.scope.destroyed || typeof this.#requests.enrichTopicTaxonomy != "function") return;
const key = candidates.map(
(page) => `${pageKey(page.group, page.page)}:${pageRecordSignature(page)}`
).join("|");
if (this.#taxonomyFlights.has(key)) return;
const flight = this.#enrichTopicTaxonomy(candidates, {
...options,
background: !0
}).then((enrichedPages) => {
if (this.scope.destroyed) return;
let changed = !1;
for (let index = 0; index < candidates.length; index += 1) {
const base = candidates[index], enriched = enrichedPages[index] ?? base, cached = this.#pages.get(pageKey(base.group, base.page))?.page;
!cached || pageRecordSignature(cached) !== pageRecordSignature(base) || enriched === base || (this.#cachePage(enriched), changed = !0);
}
changed && (this.#open ? this.#renderFromCache() : this.#emit());
}).finally(() => {
this.#taxonomyFlights.delete(key);
});
this.#taxonomyFlights.set(key, flight);
}
async #loadReactionLikePage(page, options = {}) {
const aggregate = (0, import_reader_notification_model.readerNotificationGroup)("reactionLikes"), end = (page + 1) * aggregate.pageSize, results = await Promise.all(
READER_NOTIFICATION_REACTION_LIKE_GROUPS.map(async (groupKey) => {
const group = (0, import_reader_notification_model.readerNotificationGroup)(groupKey), state = this.#historyGroups.get(groupKey), pages = [];
let error = null;
const requiredPages = Math.max(1, Math.ceil(end / group.pageSize));
for (let groupPage = 0; groupPage < requiredPages && !(state?.complete && state.terminalPage !== null && groupPage > state.terminalPage); groupPage += 1)
if (!(state?.pages.has(groupPage) && !(options.refresh && groupPage === 0)))
try {
const loaded = await this.#requests.load(groupKey, groupPage, {
...options.refresh && groupPage === 0 ? { refresh: !0 } : {},
...options.background ? { background: !0 } : {},
...options.history ? { history: !0 } : {},
...options.visibleHistory ? { visibleHistory: !0 } : {}
});
if (pages.push(loaded), !loaded.hasNext) break;
} catch (cause) {
error = cause;
break;
}
return Object.freeze({ pages: Object.freeze(pages), error });
})
), loadedPages = results.flatMap((result) => result.pages), failures = results.map((result) => result.error).filter((cause) => cause !== null);
if (!loadedPages.length && failures.length) throw failures[0];
for (const loaded of loadedPages) this.#cachePage(loaded);
this.#queueTopicTaxonomyEnrichment(loadedPages, options);
for (const cause of failures) this.#onError(cause);
return this.#indexedReactionLikePage(page);
}
async #loadAggregatePage(page, options = {}) {
if (page > 0) return this.#indexedAggregatePage(page);
const end = (0, import_reader_notification_model.readerNotificationGroup)("all").pageSize, results = await Promise.all(
import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.map(async (groupKey) => {
const group = (0, import_reader_notification_model.readerNotificationGroup)(groupKey), pages = [];
let error = null;
const requiredPages = Math.max(1, Math.ceil(end / group.pageSize));
for (let groupPage = 0; groupPage < requiredPages; groupPage += 1)
try {
const loaded = await this.#requests.load(
groupKey,
groupPage,
{
...options.refresh ? { refresh: !0 } : {},
...options.background ? { background: !0 } : {},
...options.history ? { history: !0 } : {},
...options.visibleHistory ? { visibleHistory: !0 } : {}
}
);
if (pages.push(loaded), !loaded.hasNext) break;
} catch (cause) {
error = cause;
break;
}
return Object.freeze({ pages: Object.freeze(pages), error });
})
), loadedPages = results.flatMap((result) => result.pages), failures = results.map((result) => result.error).filter((cause) => cause !== null);
if (!loadedPages.length && failures.length) throw failures[0];
for (const cause of failures) this.#onError(cause);
if (!options.valid || options.valid()) {
for (const loaded of loadedPages) this.#cachePage(loaded);
this.#queueTopicTaxonomyEnrichment(loadedPages, options);
}
const indexed = this.#indexedAggregatePage(page);
if (indexed.total > 0 || !loadedPages.some((loaded) => loaded.records.length))
return indexed;
const seen = /* @__PURE__ */ new Set(), records = (0, import_reader_notification_model.sortReaderNotifications)(loadedPages.flatMap((loaded) => loaded.records.filter((record) => seen.has(record.identity) ? !1 : (seen.add(record.identity), !0))));
return Object.freeze({
group: "all",
page,
records: Object.freeze(records.slice(0, end)),
total: records.length,
hasNext: records.length > end,
nextCursor: null
});
}
#indexedAggregateRecords() {
const seen = /* @__PURE__ */ new Set();
return (0, import_reader_notification_model.sortReaderNotifications)(
import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.flatMap((group) => [...this.#historyRecords.get(group)?.values() ?? []].filter((record) => seen.has(record.identity) ? !1 : (seen.add(record.identity), !0)))
);
}
#indexedReactionLikeRecords() {
const seen = /* @__PURE__ */ new Set();
return (0, import_reader_notification_model.sortReaderNotifications)(
READER_NOTIFICATION_REACTION_LIKE_GROUPS.flatMap((group) => [...this.#historyRecords.get(group)?.values() ?? []].filter((record) => seen.has(record.identity) ? !1 : (seen.add(record.identity), !0)))
);
}
#indexedReactionLikePage(page, indexed = this.#indexedReactionLikeRecords()) {
const aggregate = (0, import_reader_notification_model.readerNotificationGroup)("reactionLikes"), start = page * aggregate.pageSize, end = start + aggregate.pageSize, total = READER_NOTIFICATION_REACTION_LIKE_GROUPS.reduce(
(sum, group) => sum + Math.max(
this.#historyRecords.get(group)?.size ?? 0,
this.#pages.get(pageKey(group, 0))?.page.total ?? 0
),
0
), incomplete = READER_NOTIFICATION_REACTION_LIKE_GROUPS.some(
(group) => !this.#historyGroups.get(group)?.complete
);
return Object.freeze({
group: "reactionLikes",
page,
records: Object.freeze(indexed.slice(start, end)),
total: Math.max(total, indexed.length),
hasNext: end < Math.max(total, indexed.length) || incomplete,
nextCursor: null
});
}
#indexedAggregatePage(page, indexed = this.#indexedAggregateRecords()) {
const aggregate = (0, import_reader_notification_model.readerNotificationGroup)("all"), start = page * aggregate.pageSize, end = start + aggregate.pageSize;
return Object.freeze({
group: "all",
page,
records: Object.freeze(indexed.slice(start, end)),
total: indexed.length,
hasNext: end < indexed.length,
nextCursor: null
});
}
#refreshAggregateHistoryPages() {
const indexed = this.#indexedAggregateRecords(), reactionLikes = this.#indexedReactionLikeRecords();
for (const [key, entry] of [...this.#pages])
key.startsWith("reactionLikes:") && this.#pages.set(key, Object.freeze({
page: this.#indexedReactionLikePage(entry.page.page, reactionLikes),
loadedAt: entry.loadedAt
}));
if (reactionLikes.length && !this.#pages.has(pageKey("reactionLikes", 0)) && this.#pages.set(pageKey("reactionLikes", 0), Object.freeze({
page: this.#indexedReactionLikePage(0, reactionLikes),
loadedAt: this.#now()
})), this.#group === "reactionLikes" && !this.#hasLocalFilters()) {
const pageSize2 = (0, import_reader_notification_model.readerNotificationGroup)("reactionLikes").pageSize, totalPages2 = Math.max(1, Math.ceil(
this.#indexedReactionLikePage(0, reactionLikes).total / pageSize2
));
this.#page >= totalPages2 && (this.#page = totalPages2 - 1);
const page2 = this.#indexedReactionLikePage(this.#page, reactionLikes);
this.#pages.set(pageKey("reactionLikes", this.#page), Object.freeze({
page: page2,
loadedAt: this.#now()
})), this.#applyPage(page2);
}
if (!indexed.length) return;
for (const [key, entry] of [...this.#pages])
key.startsWith("all:") && this.#pages.set(key, Object.freeze({
page: this.#indexedAggregatePage(entry.page.page, indexed),
loadedAt: entry.loadedAt
}));
if (this.#pages.has(pageKey("all", 0)) || this.#pages.set(pageKey("all", 0), Object.freeze({
page: this.#indexedAggregatePage(0, indexed),
loadedAt: this.#now()
})), this.#group !== "all" || this.#hasLocalFilters()) return;
const pageSize = (0, import_reader_notification_model.readerNotificationGroup)("all").pageSize, totalPages = Math.max(1, Math.ceil(indexed.length / pageSize));
this.#page >= totalPages && (this.#page = totalPages - 1);
const page = this.#indexedAggregatePage(this.#page, indexed);
this.#pages.set(pageKey("all", this.#page), Object.freeze({
page,
loadedAt: this.#now()
})), this.#applyPage(page);
}
async #load(refresh) {
if (this.scope.destroyed) return;
if (this.#hasLocalFilters()) {
this.#renderFromCache();
return;
}
this.#historySchedule !== null && this.#cancel(this.#historySchedule), this.#historySchedule = null;
const epoch = ++this.#loadEpoch, key = pageKey(this.#group, this.#page), cached = this.#pages.get(key), authoritative = refresh || !!cached;
this.#loading = !cached, this.#refreshing = !!cached, this.#retrying = !1, this.#error = null, this.#stale = !1, cached && this.#applyPage(cached.page), this.#emit();
try {
let page = null;
for (let attempt = 0; attempt < 2; attempt += 1)
try {
page = await this.#loadRequestedPage(
this.#group,
this.#page,
authoritative && this.#page === 0 ? { refresh: !0 } : {}
);
break;
} catch (cause) {
if (attempt > 0 || !readerNotificationRequestCanAutoRetry(cause)) throw cause;
if (this.scope.destroyed || epoch !== this.#loadEpoch || (this.#retrying = !0, this.#emit(), await this.#delay(this.#retryDelayMs), this.scope.destroyed || epoch !== this.#loadEpoch)) return;
}
if (!page) throw new Error("通知自动重试未返回结果");
if (this.scope.destroyed || epoch !== this.#loadEpoch) return;
this.#cachePage(page), page.group !== "all" && page.group !== "reactionLikes" && this.#queueTopicTaxonomyEnrichment([page]), page.group === "all" && this.#inheritAllSyntheticPages(), authoritative && this.#lastAuthoritativeAt.set(key, this.#now()), this.#raiseUnreadCountForCachedRecords(), this.#applyPage(this.#pages.get(key)?.page ?? page), this.#loading = !1, this.#refreshing = !1, this.#retrying = !1, this.#stale = !1, this.#error = null, this.#emit(), this.#scheduleHistoryHydration(this.#historyContinuationDelay());
} catch (cause) {
if (this.scope.destroyed || epoch !== this.#loadEpoch) return;
this.#loading = !1, this.#refreshing = !1, this.#retrying = !1, this.#error = cause, this.#stale = !!cached;
const pollBackoffMs = readerNotificationPollBackoffMs(cause);
pollBackoffMs > 0 && (this.#pollNotBefore = Math.max(
this.#pollNotBefore,
this.#now() + pollBackoffMs
)), this.#onError(cause), this.#emit(), this.#schedulePoll(), this.#scheduleHistoryHydration(
Math.max(this.#historyRetryDelayMs, pollBackoffMs)
);
}
}
#indexHistoryPage(page) {
const state = this.#historyGroups.get(page.group);
if (!state) return;
state.retryAt = null, state.error = null;
const records = this.#historyRecords.get(page.group) ?? /* @__PURE__ */ new Map();
for (const record of page.records) records.set(record.identity, record);
this.#historyRecords.set(page.group, records), state.pages.add(page.page);
const group = (0, import_reader_notification_model.readerNotificationGroup)(page.group);
for (state.estimatedPages = Math.max(
state.estimatedPages,
page.page + 1 + (page.hasNext ? 1 : 0),
Math.ceil(page.total / group.pageSize)
), page.hasNext || (state.terminalPage = page.page); state.pages.has(state.nextPage); ) state.nextPage += 1;
state.complete = state.terminalPage !== null && state.nextPage > state.terminalPage, state.complete && state.terminalPage !== null && (state.estimatedPages = Math.max(1, state.terminalPage + 1));
}
#cachePage(page, loadedAt = this.#now(), persist = !0) {
const key = pageKey(page.group, page.page), nativeRecords = this.#inheritNativeState(page.records), records = this.#inheritReadRecordState(nativeRecords), inherited = records === page.records ? page : Object.freeze({ ...page, records });
this.#pages.delete(key), this.#pages.set(key, Object.freeze({
page: inherited,
loadedAt
})), this.#indexHistoryPage(inherited), inherited.group !== "all" && this.#refreshAggregateHistoryPages(), this.#trimCachedPages(), persist && (this.#persistProjection(page.group), import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.includes(page.group) && this.#persistProjection("all"));
}
#persistProjection(group) {
if (!this.#projection || group === "reactionLikes")
return Promise.resolve();
if (this.#backgroundRestore !== null)
return this.#projectionPersistAfterRestore.add(group), Promise.resolve();
const prefix = `${group}:`, byIdentity = /* @__PURE__ */ new Map();
for (const record of this.#projectionRecords.get(group)?.values() ?? [])
byIdentity.set(record.identity, record);
for (const record of this.#historyRecords.get(group)?.values() ?? [])
byIdentity.set(record.identity, record);
for (const [key, entry] of this.#pages)
if (key.startsWith(prefix))
for (const record of entry.page.records)
byIdentity.set(record.identity, record);
const state = this.#historyGroups.get(group), cachedPages = [...this.#pages.entries()].filter(([key]) => key.startsWith(prefix)).map(([, entry]) => entry.page), complete = state?.complete === !0 || cachedPages.length > 0 && cachedPages.some((page) => !page.hasNext) && cachedPages.every((_, index, pages) => pages.some((candidate) => candidate.page === index)), exactReplacement = complete && (0, import_reader_notification_model.readerNotificationGroup)(group).source === "private-messages";
if (exactReplacement) {
byIdentity.clear();
for (const page of cachedPages)
for (const record of page.records)
byIdentity.set(record.identity, record);
}
const committedRecords = (0, import_reader_notification_model.sortReaderNotifications)([...byIdentity.values()]);
this.#projectionRecords.set(
group,
new Map(committedRecords.map((record) => [record.identity, record]))
);
const totalHint = cachedPages.reduce(
(total, page) => Math.max(total, page.total),
committedRecords.length
), replaceCheckpoint = this.#projectionCheckpointReplacements.has(group);
return this.#projection.write(group, committedRecords, {
mergeStored: !exactReplacement,
totalHint,
complete,
updatedAt: this.#now(),
checkpointMode: replaceCheckpoint ? "replace" : "advance",
...state ? { sourceNextPage: state.nextPage } : {},
...state ? { sourcePageSize: (0, import_reader_notification_model.readerNotificationGroup)(group).pageSize } : {},
...state ? {
sourceOffset: state.nextPage * (0, import_reader_notification_model.readerNotificationGroup)(group).pageSize
} : {}
}).then(() => {
replaceCheckpoint && this.#projectionCheckpointReplacements.delete(group);
}).catch(this.#onError);
}
#rememberProjectionRecords(group, records, replace = !1) {
const remember = (partition, values) => {
const indexed = replace ? /* @__PURE__ */ new Map() : this.#projectionRecords.get(partition) ?? /* @__PURE__ */ new Map();
for (const record of values) indexed.set(record.identity, record);
this.#projectionRecords.set(partition, indexed);
};
if (remember(group, records), group === "all")
for (const child of import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER)
remember(child, records.filter((record) => record.group === child));
}
#persistCachedProjections() {
const groups = /* @__PURE__ */ new Set();
for (const [key, entry] of this.#pages)
key.startsWith("native:") || groups.add(entry.page.group);
for (const group of groups) this.#persistProjection(group);
}
#trimCachedPages() {
for (; this.#pages.size > this.#maxCachedPages; ) {
const keys = [...this.#pages.keys()], selectedKey = pageKey(this.#group, this.#page), oldest = keys.find((key) => !key.startsWith("native:") && !key.endsWith(":0")) ?? keys.find((key) => !key.startsWith("native:") && key !== selectedKey) ?? keys.find((key) => key !== selectedKey) ?? keys[0];
if (oldest === void 0) break;
this.#pages.delete(oldest);
}
}
#cacheNativeAssociationPage(page) {
const key = nativePageKey(page.page), existing = this.#pages.get(key)?.page ?? null, records = existing ? page.records.flatMap((incoming) => {
const notificationId = incoming.sourceNotificationId;
if (notificationId === null) return [incoming];
const matches = existing.records.filter((record) => record.sourceNotificationId === notificationId);
return matches.some((record) => record.identity.startsWith(`notification:${notificationId}:reply:`)) ? matches.map((record) => Object.freeze({
...record,
notificationTypeId: incoming.notificationTypeId,
highPriority: incoming.highPriority,
read: incoming.read,
stateLabel: incoming.read === !0 ? "已读" : incoming.read === !1 ? "未读" : record.stateLabel
})) : [incoming];
}) : page.records, inherited = this.#inheritReadRecordState(
(0, import_reader_notification_model.sortReaderNotifications)(records)
);
this.#pages.set(key, Object.freeze({
page: Object.freeze({
...page,
records: inherited
}),
loadedAt: this.#now()
}));
for (const [cachedKey, entry] of [...this.#pages])
!cachedKey.startsWith("all:") || entry.page.total >= page.total || this.#pages.set(cachedKey, Object.freeze({
...entry,
page: Object.freeze({ ...entry.page, total: page.total })
}));
this.#trimCachedPages();
}
#nativeAssociationNeedsExpansion(page) {
const existing = this.#pages.get(nativePageKey(page.page))?.page.records ?? [];
return page.records.some((record) => {
if (record.sourceNotificationId === null || record.aggregateCount === null) return !1;
const prefix = `notification:${record.sourceNotificationId}:reply:`;
return existing.filter((candidate) => candidate.identity.startsWith(prefix)).length < record.aggregateCount;
});
}
#rememberReadRecord(record) {
const key = readRecordKey(record);
if (key === null) return;
this.#readRecordKeys.delete(key), this.#readRecordKeys.add(key);
const maxRecords = Math.max(64, this.#maxCachedPages * 64);
for (; this.#readRecordKeys.size > maxRecords; ) {
const oldest = this.#readRecordKeys.values().next().value;
if (oldest === void 0) break;
this.#readRecordKeys.delete(oldest);
}
}
#childReadSourceIds(additionalRecords = Object.freeze([])) {
const keys = /* @__PURE__ */ new Map(), records = [
...[...this.#pages.values()].flatMap((entry) => entry.page.records),
...additionalRecords
];
for (const record of records) {
const notificationId = record.sourceNotificationId, key = readRecordKey(record);
if (notificationId === null || key === null) continue;
const sourceKeys = keys.get(notificationId) ?? /* @__PURE__ */ new Set();
sourceKeys.add(key), keys.set(notificationId, sourceKeys);
}
return new Set(
[...keys].filter(([, sourceKeys]) => sourceKeys.size > 1).map(([notificationId]) => notificationId)
);
}
#inheritReadRecordState(records) {
let changed = !1;
const inherited = records.map((record) => {
const key = readRecordKey(record);
return record.read === !0 ? (this.#rememberReadRecord(record), record) : record.sourceNotificationId === null || key === null || !this.#readRecordKeys.has(key) ? record : (changed = !0, Object.freeze({
...record,
read: !0,
stateLabel: "已读"
}));
});
return changed ? Object.freeze(inherited) : records;
}
#setReadRecordState(record, read) {
const targetKey = readRecordKey(record);
if (targetKey !== null) {
read ? this.#rememberReadRecord(record) : this.#readRecordKeys.delete(targetKey);
for (const [key, entry] of [...this.#pages]) {
let changed = !1;
const records = Object.freeze(entry.page.records.map((candidate) => readRecordKey(candidate) !== targetKey ? candidate : (changed = !0, Object.freeze({
...candidate,
read,
stateLabel: read ? "已读" : "未读"
}))));
changed && this.#pages.set(key, Object.freeze({
...entry,
page: Object.freeze({ ...entry.page, records })
}));
}
this.#persistCachedProjections(), this.#renderFromCache();
}
}
#allSourceRecordsRead(notificationId) {
const states = /* @__PURE__ */ new Map();
for (const entry of this.#pages.values())
for (const record of entry.page.records) {
if (record.sourceNotificationId !== notificationId) continue;
const key = readRecordKey(record);
key !== null && states.set(key, (states.get(key) ?? !0) && record.read === !0);
}
return states.size > 0 && [...states.values()].every(Boolean);
}
#inheritNativeState(records) {
const nativeRecords = [...this.#pages.entries()].filter(([key]) => key.startsWith("native:")).flatMap(([, entry]) => entry.page.records).filter((record) => record.sourceNotificationId !== null);
return nativeRecords.length ? Object.freeze(records.map((record) => {
const candidates = nativeRecords.filter((native) => native.group === record.group && sameTarget(native, record)), actor = record.actor.toLocaleLowerCase(), match = candidates.find((native) => native.actor.toLocaleLowerCase() === actor) ?? null;
return match ? Object.freeze({
...record,
sourceNotificationId: match.sourceNotificationId,
notificationTypeId: match.notificationTypeId,
highPriority: match.highPriority,
read: match.read,
stateLabel: match.read === !0 ? "已读" : match.read === !1 ? "未读" : record.stateLabel
}) : record.sourceNotificationId === null ? record : Object.freeze({
...record,
sourceNotificationId: null,
notificationTypeId: null,
highPriority: !1,
read: null,
stateLabel: ""
});
})) : records;
}
#inheritAllSyntheticPages() {
for (const [key, entry] of [...this.#pages]) {
if (key.startsWith("native:")) continue;
const records = this.#inheritReadRecordState(
this.#inheritNativeState(entry.page.records)
);
records !== entry.page.records && (this.#pages.set(key, Object.freeze({
...entry,
page: Object.freeze({ ...entry.page, records })
})), this.#indexHistoryPage(Object.freeze({ ...entry.page, records })));
}
this.#refreshAggregateHistoryPages();
}
#cachedGroupRecords() {
const seen = /* @__PURE__ */ new Set(), records = [], indexedGroups = this.#group === "all" ? import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER : this.#group === "reactionLikes" ? READER_NOTIFICATION_REACTION_LIKE_GROUPS : Object.freeze([this.#group]);
for (const group of indexedGroups)
for (const record of this.#historyRecords.get(group)?.values() ?? [])
seen.has(record.identity) || (seen.add(record.identity), records.push(record));
const prefix = `${this.#group}:`;
for (const [key, entry] of this.#pages)
if (key.startsWith(prefix))
for (const record of entry.page.records)
seen.has(record.identity) || (seen.add(record.identity), records.push(record));
return (0, import_reader_notification_model.sortReaderNotifications)(this.#inheritReadRecordState(records));
}
#hasLocalFilters() {
return !!(this.#query || this.#categoryFilter || this.#tagFilter || this.#dateFilter || this.#sortDirection !== "desc");
}
#dayCounts() {
const counts = /* @__PURE__ */ new Map();
for (const record of this.#cachedGroupRecords()) {
const day = (0, import_reader_collection_filter_model.readerCollectionDateKey)(record.createdAt);
day && counts.set(day, (counts.get(day) ?? 0) + 1);
}
return new Map([...counts].sort(([left], [right]) => left.localeCompare(right)));
}
#categoryOptions() {
return this.#filterOptions("category");
}
#tagOptions() {
return this.#filterOptions("tag");
}
#filterOptions(kind) {
const options = /* @__PURE__ */ new Map();
for (const record of this.#cachedGroupRecords()) {
const values = kind === "category" ? [[
(0, import_reader_notification_model.readerNotificationCategoryFilterKey)(record),
record.categoryName || `类别 #${record.categoryId}`
]] : record.tags.map((tag) => [
(0, import_reader_notification_model.readerNotificationTagFilterKey)(tag),
tag
]);
for (const [value, label] of values) {
if (!value) continue;
const current = options.get(value);
options.set(value, Object.freeze({
value,
label: current?.label.startsWith("类别 #") && record.categoryName ? record.categoryName : current?.label ?? label,
count: (current?.count ?? 0) + 1
}));
}
}
return Object.freeze([...options.values()].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-CN")));
}
#matchingRecords() {
const records = this.#cachedGroupRecords().filter((record) => (0, import_reader_search.readerSearchMatches)(
record.searchText,
this.#query,
this.#searchForms,
this.#onError
) && (!this.#categoryFilter || (0, import_reader_notification_model.readerNotificationCategoryFilterKey)(record) === this.#categoryFilter) && (!this.#tagFilter || record.tags.some((tag) => (0, import_reader_notification_model.readerNotificationTagFilterKey)(tag) === this.#tagFilter)) && (!this.#dateFilter || (0, import_reader_collection_filter_model.readerCollectionDateKey)(record.createdAt) === this.#dateFilter));
return this.#sortDirection === "asc" ? Object.freeze([...records].reverse()) : records;
}
#renderFromCache() {
if (this.#hasLocalFilters()) {
const pageSize = (0, import_reader_notification_model.readerNotificationGroup)(this.#group).pageSize, matches = this.#matchingRecords(), totalPages = Math.max(1, Math.ceil(matches.length / pageSize));
this.#page >= totalPages && (this.#page = totalPages - 1);
const start = this.#page * pageSize;
this.#records = Object.freeze(matches.slice(start, start + pageSize)), this.#total = matches.length, this.#hasNext = this.#page < totalPages - 1, this.#loading = !1, this.#refreshing = !1, this.#error = null, this.#stale = !1, this.#emit();
return;
}
const cached = this.#pages.get(pageKey(this.#group, this.#page));
cached ? this.#applyPage(cached.page) : (this.#records = Object.freeze([]), this.#total = 0, this.#hasNext = !1), this.#emit();
}
#applyPage(page) {
this.#records = page.records, this.#total = page.total, this.#hasNext = page.hasNext, this.#loading = !1;
}
#raiseUnreadCountForCachedRecords() {
const unreadSourceIds = /* @__PURE__ */ new Set();
for (const entry of this.#pages.values())
for (const record of entry.page.records)
record.sourceNotificationId !== null && record.read === !1 && unreadSourceIds.add(record.sourceNotificationId);
this.#unreadCount = Math.max(this.#unreadCount, unreadSourceIds.size);
}
#markSourceReadInCache(notificationId) {
let committed = null, changed = !1;
for (const [key, entry] of [...this.#pages]) {
let pageChanged = !1;
const records = Object.freeze(entry.page.records.map((record) => record.sourceNotificationId !== notificationId || (committed ??= record, this.#rememberReadRecord(record), record.read === !0) ? record : (changed = !0, pageChanged = !0, Object.freeze({
...record,
read: !0,
stateLabel: "已读"
}))));
pageChanged && this.#pages.set(key, Object.freeze({
...entry,
page: Object.freeze({ ...entry.page, records })
}));
}
return Object.freeze({ committed, changed });
}
#commitAllRead() {
for (const [key, entry] of [...this.#pages]) {
const records = Object.freeze(entry.page.records.map((record) => record.sourceNotificationId === null ? record : (this.#rememberReadRecord(record), Object.freeze({
...record,
read: !0,
stateLabel: "已读"
}))));
this.#pages.set(key, Object.freeze({
...entry,
page: Object.freeze({ ...entry.page, records })
}));
}
this.#native.markAllRead(), this.#unreadCount = 0, this.#persistCachedProjections(), this.#renderFromCache();
}
#commitRead(notificationId) {
const { committed } = this.#markSourceReadInCache(notificationId);
committed && (this.#native.markRead({
notificationTypeId: committed.notificationTypeId,
highPriority: committed.highPriority
}), this.#unreadCount = Math.max(0, this.#unreadCount - 1), this.#raiseUnreadCountForCachedRecords(), this.#persistCachedProjections(), this.#renderFromCache());
}
#onNativeClicked(click) {
if (this.scope.destroyed) return;
const { changed } = this.#markSourceReadInCache(click.notificationId);
click.wasRead === !1 && (changed || this.#unreadCount > 0) && (this.#unreadCount = Math.max(0, this.#unreadCount - 1), this.#raiseUnreadCountForCachedRecords(), this.#persistCachedProjections(), this.#renderFromCache()), this.#onNativeChanged(!0);
}
#consumeNativeRefreshPending() {
return !this.#nativeRefreshPending && !this.#nativeChangePending ? !1 : (this.#nativeRefreshPending = !1, this.#liveRefresh !== null && this.#cancel(this.#liveRefresh), this.#liveRefresh = null, this.#invalidateLivePages(), !0);
}
#invalidateLivePages() {
for (const key of [...this.#pages.keys()]) {
if (key.startsWith("native:") || key.startsWith("all:")) {
this.#pages.delete(key);
continue;
}
const separator = key.lastIndexOf(":");
separator >= 0 && Number(key.slice(separator + 1)) === 0 && this.#pages.delete(key);
}
for (const key of [...this.#lastAuthoritativeAt.keys()])
(key.startsWith("native:") || key.startsWith("all:") || key.endsWith(":0")) && this.#lastAuthoritativeAt.delete(key);
}
async #refreshAfterNativeChange() {
if (this.scope.destroyed) return;
if (this.#hasLocalFilters()) {
this.#nativeRefreshPending = !0, this.#renderFromCache();
return;
}
const changeEpoch = this.#nativeChangeEpoch, selectedGroup = this.#group, selectedPage = this.#page, source = (0, import_reader_notification_model.readerNotificationGroup)(selectedGroup).source;
try {
if (selectedGroup === "all" || source === "user-actions" || source === "boosts-received" || source === "reactions-received") {
const selectedRefresh = this.#load(!0), nativeKey = nativePageKey(0), nativeRefresh = this.#nativeChangePending || this.#shouldRevalidate(nativeKey) ? this.#refreshNativeAssociation(
changeEpoch,
selectedGroup,
selectedPage
) : Promise.resolve();
await selectedRefresh, await nativeRefresh;
return;
}
await this.#load(!0);
} finally {
!this.scope.destroyed && changeEpoch === this.#nativeChangeEpoch && (this.#nativeChangePending = !1, this.#nativeRefreshPending = !1);
}
}
async #refreshAllHeadAfterNativeChange() {
if (this.scope.destroyed) return;
const changeEpoch = this.#nativeChangeEpoch, selectedGroup = this.#group, selectedPage = this.#page, selectedFiltering = this.#hasLocalFilters(), valid = () => !this.scope.destroyed && changeEpoch === this.#nativeChangeEpoch;
try {
const page = await this.#loadAggregatePage(0, {
refresh: !0,
...this.#open && selectedGroup === "all" && !selectedFiltering ? {} : { background: !0 },
valid
});
if (!valid() || (this.#cachePage(page), this.#lastAuthoritativeAt.set(pageKey("all", 0), this.#now()), this.#raiseUnreadCountForCachedRecords(), this.#open && !selectedFiltering && selectedPage === 0 && selectedGroup === this.#group && (selectedGroup === "all" || selectedGroup === "reactionLikes" || import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.includes(selectedGroup)) && this.#renderFromCache(), await this.#refreshNativeAssociation(
changeEpoch,
selectedGroup,
selectedPage
), !valid())) return;
const inboxKey = pageKey("inbox", 0);
if (this.#native.username().trim() && !this.#pages.has(inboxKey)) {
const inbox = await this.#requests.load("inbox", 0, {
refresh: !0,
background: !0
});
if (!valid()) return;
this.#cachePage(inbox), this.#lastAuthoritativeAt.set(inboxKey, this.#now());
}
const selectedSource = (0, import_reader_notification_model.readerNotificationGroup)(selectedGroup).source;
this.#open && !selectedFiltering && selectedGroup === this.#group && selectedPage === this.#page && selectedSource === "private-messages" && selectedGroup !== "inbox" ? await this.#load(!0) : this.#open && !selectedFiltering && selectedGroup === "inbox" && selectedGroup === this.#group && selectedPage === this.#page && this.#renderFromCache();
} catch (cause) {
if (!valid()) return;
const pollBackoffMs = readerNotificationPollBackoffMs(cause);
pollBackoffMs > 0 && (this.#pollNotBefore = Math.max(
this.#pollNotBefore,
this.#now() + pollBackoffMs
)), this.#onError(cause);
} finally {
valid() && (this.#nativeChangePending = !1, this.#nativeRefreshPending = !!(this.#hasLocalFilters() && (0, import_reader_notification_model.readerNotificationGroup)(this.#group).source === "private-messages"));
}
}
async #refreshNativeAssociation(changeEpoch, selectedGroup, selectedPage) {
try {
const nativePage = await this.#requests.load("all", 0, {
refresh: !0,
background: !0,
expandConsolidated: !1
});
if (this.scope.destroyed || changeEpoch !== this.#nativeChangeEpoch) return;
const needsExpansion = this.#nativeAssociationNeedsExpansion(nativePage);
if (this.#cacheNativeAssociationPage(nativePage), this.#lastAuthoritativeAt.set(nativePageKey(0), this.#now()), this.#inheritAllSyntheticPages(), this.#raiseUnreadCountForCachedRecords(), selectedGroup === this.#group && selectedPage === this.#page && this.#pages.has(pageKey(selectedGroup, selectedPage)) && this.#renderFromCache(), !needsExpansion) return;
const expandedPage = await this.#requests.load("all", 0, {
background: !0,
expandConsolidated: !0
});
if (this.scope.destroyed || changeEpoch !== this.#nativeChangeEpoch) return;
this.#cacheNativeAssociationPage(expandedPage), this.#inheritAllSyntheticPages(), this.#raiseUnreadCountForCachedRecords(), selectedGroup === this.#group && selectedPage === this.#page && this.#pages.has(pageKey(selectedGroup, selectedPage)) && this.#renderFromCache();
} catch (cause) {
if (this.scope.destroyed || changeEpoch !== this.#nativeChangeEpoch) return;
const pollBackoffMs = readerNotificationPollBackoffMs(cause);
pollBackoffMs > 0 && (this.#pollNotBefore = Math.max(
this.#pollNotBefore,
this.#now() + pollBackoffMs
)), this.#onError(cause);
}
}
#shouldRevalidate(key) {
const observedAt = this.#lastAuthoritativeAt.get(key) ?? this.#pages.get(key)?.loadedAt;
return observedAt === void 0 || this.#now() - observedAt >= this.#openRevalidateMs;
}
#activityVisible() {
if (!this.#activity) return !1;
try {
return this.#activity.visible();
} catch (cause) {
return this.#onError(cause), !1;
}
}
#cancelPoll() {
this.#poll !== null && (this.#cancel(this.#poll), this.#poll = null);
}
#schedulePoll() {
if (this.#cancelPoll(), !this.#open || !this.#activityVisible() || this.scope.destroyed) return;
const delayMs = this.#pollNotBefore - this.#now();
delayMs > 0 && (this.#poll = this.#schedule(() => {
if (this.#poll = null, !(!this.#open || !this.#activityVisible() || this.scope.destroyed)) {
if (this.#loading || this.#refreshing || this.#selectionFlight !== null) {
this.#schedulePoll();
return;
}
this.#unreadCount = this.#native.unreadCount(), this.#runSelectedRequest(() => this.#refreshAfterNativeChange()).finally(() => {
this.#schedulePoll();
});
}
}, delayMs));
}
#activityRecoveryDue(key) {
const observedAt = this.#lastAuthoritativeAt.get(key) ?? this.#pages.get(key)?.loadedAt;
if (observedAt === void 0) return !1;
const source = (0, import_reader_notification_model.readerNotificationGroup)(this.#group).source, recoveryMs = this.#group === "all" || source === "user-actions" || source === "boosts-received" || source === "reactions-received" ? this.#syntheticPollIntervalMs : this.#nativePollIntervalMs;
return this.#now() - observedAt >= Math.max(
this.#openRevalidateMs,
recoveryMs
);
}
#onActivityChanged() {
if (this.scope.destroyed) return;
const visible = this.#activityVisible(), backgroundWasActive = this.#backgroundCacheActive;
if (visible ? this.#backgroundCacheActive || this.startBackgroundCache() : (this.#backgroundWarm !== null && this.#cancel(this.#backgroundWarm), this.#backgroundWarm = null, this.#backgroundWarmPending = !1, this.#backgroundWarmEpoch += 1), this.#backgroundCacheActive && (this.#activity && !visible ? (this.#historySchedule !== null && this.#cancel(this.#historySchedule), this.#historySchedule = null, this.#historyStatus !== "complete" && (this.#historyStatus = "paused", this.#historyCurrentGroup = null, this.#emit())) : backgroundWasActive && (this.#scheduleHistoryHydration(0), this.#scheduleBackgroundWarm(0))), !visible) {
this.#cancelPoll();
return;
}
this.#schedulePoll();
const key = this.#open ? pageKey(this.#group, this.#page) : pageKey("all", 0);
this.#open && this.#hasLocalFilters() || this.#loading || this.#refreshing || this.#selectionFlight !== null || !this.#activityRecoveryDue(key) || (this.#unreadCount = this.#native.unreadCount(), this.#runSelectedRequest(() => this.#open ? this.#refreshAfterNativeChange() : this.#refreshAllHeadAfterNativeChange()));
}
#historyContinuationDelay() {
return this.#open && this.#visibleHistoryConcurrency > 1 ? 0 : this.#historyStepDelayMs;
}
#historyHasReadyGroup(now = this.#now()) {
return [...this.#historyGroups.values()].some((state) => !state.complete && (state.retryAt === null || state.retryAt <= now));
}
#historyRecoveryState() {
let retryAt = null, error = null;
for (const state of this.#historyGroups.values())
state.complete || state.retryAt === null || retryAt !== null && state.retryAt >= retryAt || (retryAt = state.retryAt, error = state.error);
return Object.freeze({ error, retryAt });
}
#scheduleHistoryHydration(delayMs = this.#historyContinuationDelay()) {
this.#backgroundWarmDelayMs === null || !this.#backgroundCacheActive || this.scope.destroyed || this.#historyLoading || this.#historySchedule !== null || this.#historyStatus === "complete" || !this.#native.username().trim() || this.#activity !== null && !this.#activityVisible() || (this.#historySchedule = this.#schedule(() => {
this.#historySchedule = null, this.#runHistoryHydrationStep();
}, Math.max(0, delayMs)));
}
#nextHistoryGroups(limit) {
const groups = [];
let lastIndex = -1;
for (let offset = 0; offset < import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.length; offset += 1) {
const index = (this.#historyCursor + offset) % import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.length, group = import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER[index], state = this.#historyGroups.get(group);
if (!(this.#historyInFlightGroups.has(group) || state.complete || state.retryAt !== null && state.retryAt > this.#now()) && (state.retryAt = null, state.error = null, groups.push(group), lastIndex = index, groups.length >= limit))
break;
}
return lastIndex >= 0 && (this.#historyCursor = (lastIndex + 1) % import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.length), Object.freeze(groups);
}
async #hydrateHistoryGroup(group, epoch, visibleHistory) {
const state = this.#historyGroups.get(group), descriptor = (0, import_reader_notification_model.readerNotificationGroup)(group), pages = [state.nextPage];
if (visibleHistory && descriptor.source === "user-actions") {
const upperBound = Math.max(state.nextPage + 1, state.estimatedPages);
for (let page = state.nextPage + 1; page < upperBound && pages.length < this.#visibleHistoryConcurrency; page += 1)
state.pages.has(page) || pages.push(page);
}
try {
const loadedPages = await Promise.all(pages.map(async (page) => {
let loaded = null;
try {
loaded = await this.#loadCachedRequestedPage(group, page);
} catch (cause) {
this.#onError(cause);
}
const cacheHit = loaded !== null;
return loaded ??= await this.#loadRequestedPage(group, page, {
history: !0,
...visibleHistory ? { visibleHistory: !0 } : {}
}), Object.freeze({ page, loaded, cacheHit });
}));
if (this.scope.destroyed || epoch !== this.#historyEpoch) return null;
for (const result of loadedPages.sort((left, right) => left.page - right.page))
state.terminalPage !== null && result.page > state.terminalPage || (this.#cachePage(result.loaded, this.#now(), !1), result.cacheHit || this.#queueTopicTaxonomyEnrichment([result.loaded], { history: !0 }));
return (state.complete || state.nextPage % HISTORY_PROJECTION_BATCH_PAGES === 0) && await this.#persistProjection(group), loadedPages.every((result) => result.cacheHit);
} catch (cause) {
if (this.scope.destroyed || epoch !== this.#historyEpoch) return null;
this.#onError(cause);
const backoffMs = readerNotificationPollBackoffMs(cause), retryDelay = Math.max(this.#historyRetryDelayMs, backoffMs);
return state.retryAt = this.#now() + retryDelay, state.error = cause, null;
}
}
async #runHistoryHydrationStep() {
if (!this.#backgroundCacheActive || this.scope.destroyed || this.#historyLoading) return;
if (this.#activity && !this.#activityVisible()) {
this.#historyStatus = "paused", this.#historyCurrentGroup = null, this.#emit();
return;
}
const rateLimitBackoffMs = this.#pollNotBefore - this.#now();
if (rateLimitBackoffMs > 0) {
this.#historyStatus = "paused", this.#historyCurrentGroup = null, this.#historyRetryAt = this.#pollNotBefore, this.#emit(), this.#scheduleHistoryHydration(
Math.max(this.#historyRetryDelayMs, rateLimitBackoffMs)
);
return;
}
if (this.#loading || this.#refreshing || this.#retrying || this.#markingAll || this.#selectionFlight !== null) {
this.#historyStatus = "paused", this.#historyCurrentGroup = null, this.#historyRetryAt = null, this.#emit(), this.#scheduleHistoryHydration(this.#historyStepDelayMs);
return;
}
if (!this.#historyHasReadyGroup()) {
const complete2 = [...this.#historyGroups.values()].every(
(state) => state.complete
), recovery2 = this.#historyRecoveryState();
this.#historyStatus = complete2 ? "complete" : recovery2.retryAt === null ? "paused" : "error", this.#historyCurrentGroup = null, this.#historyError = recovery2.error, this.#historyRetryAt = recovery2.retryAt, this.#emit(), !complete2 && recovery2.retryAt !== null && this.#scheduleHistoryHydration(
Math.max(0, recovery2.retryAt - this.#now())
);
return;
}
const epoch = this.#historyEpoch, visibleHistory = this.#open && this.#visibleHistoryConcurrency > 1, openAtStart = this.#open, concurrency = visibleHistory ? this.#visibleHistoryConcurrency : 1;
this.#historyLoading = !0, this.#historyStatus = "loading", this.#historyCurrentGroup = null;
const pendingRecovery = this.#historyRecoveryState();
this.#historyError = pendingRecovery.error, this.#historyRetryAt = pendingRecovery.retryAt, this.#emit();
const results = [], dirtyGroups = /* @__PURE__ */ new Set();
try {
await (0, import_reader_collection_hydration.runReaderCollectionHydrationLease)({
coordination: this.#historyCoordination ?? null,
token: this.#historyCoordinationKey,
signal: this.#historyAbort.signal,
onError: this.#onError,
beforeRun: () => this.#restoreBackgroundProjections(!0),
run: async () => {
await (0, import_reader_collection_hydration.runReaderCollectionWorkers)({
concurrency,
maxTasks: visibleHistory ? concurrency * VISIBLE_HISTORY_LEASE_ROUNDS : 1,
shouldContinue: () => !this.scope.destroyed && epoch === this.#historyEpoch && this.#open === openAtStart && !this.#loading && !this.#refreshing && !this.#retrying && !this.#markingAll && this.#selectionFlight === null && (!this.#activity || this.#activityVisible()),
claim: () => {
const group = this.#nextHistoryGroups(1)[0] ?? null;
return group !== null && this.#historyInFlightGroups.add(group), group;
},
release: (group) => {
this.#historyInFlightGroups.delete(group);
},
run: async (group) => {
concurrency === 1 && (this.#historyCurrentGroup = group, this.#emit()), results.push(await this.#hydrateHistoryGroup(
group,
epoch,
visibleHistory
));
const state = this.#historyGroups.get(group);
state.complete || state.nextPage % HISTORY_PROJECTION_BATCH_PAGES === 0 ? dirtyGroups.delete(group) : dirtyGroups.add(group), !(this.scope.destroyed || epoch !== this.#historyEpoch) && (this.#hasLocalFilters() && this.#open ? this.#renderFromCache() : this.#emit());
}
}), dirtyGroups.size && (await Promise.all([...dirtyGroups].map((group) => this.#persistProjection(group))), dirtyGroups.clear());
}
}) !== "producer" && !this.scope.destroyed && epoch === this.#historyEpoch && await this.#restoreBackgroundProjections(!0);
} catch (cause) {
!this.scope.destroyed && epoch === this.#historyEpoch && (this.#onError(cause), this.#historyError = cause, this.#historyRetryAt = this.#now() + this.#historyRetryDelayMs);
} finally {
this.#historyLoading = !1, this.#historyInFlightGroups.clear();
}
if (this.scope.destroyed || epoch !== this.#historyEpoch) return;
const complete = [...this.#historyGroups.values()].every((candidate) => candidate.complete), recovery = this.#historyRecoveryState(), ready = this.#historyHasReadyGroup();
if (this.#historyStatus = complete ? "complete" : ready ? "loading" : recovery.retryAt === null ? "paused" : "error", this.#historyCurrentGroup = null, this.#historyError = recovery.error, this.#historyRetryAt = recovery.retryAt, this.#hasLocalFilters() && this.#open ? this.#renderFromCache() : this.#emit(), complete) return;
let nextDelay = results.length > 0 && results.every((result) => result === !0) ? 0 : this.#historyContinuationDelay();
!ready && recovery.retryAt !== null && (nextDelay = Math.max(0, recovery.retryAt - this.#now())), this.#scheduleHistoryHydration(nextDelay);
}
#scheduleBackgroundWarm(delayMs = this.#backgroundWarmDelayMs ?? 0) {
if (!(this.#backgroundWarmDelayMs === null || !this.#backgroundCacheActive || this.scope.destroyed || this.#activity !== null && !this.#activityVisible())) {
if (this.#backgroundWarming) {
this.#backgroundWarmPending = !0;
return;
}
this.#backgroundWarm !== null && this.#cancel(this.#backgroundWarm), this.#backgroundWarm = this.#schedule(() => {
this.#backgroundWarm = null, this.#warmBackgroundCollections();
}, Math.max(0, delayMs));
}
}
async #warmBackgroundCollections() {
if (!this.#backgroundCacheActive || this.scope.destroyed || this.#backgroundWarming || this.#activity !== null && !this.#activityVisible()) return;
const rateLimitBackoffMs = this.#pollNotBefore - this.#now();
if (rateLimitBackoffMs > 0) {
this.#scheduleBackgroundWarm(rateLimitBackoffMs);
return;
}
this.#backgroundWarming = !0, this.#backgroundWarmPending = !1;
const epoch = ++this.#backgroundWarmEpoch, valid = () => !this.scope.destroyed && epoch === this.#backgroundWarmEpoch && (this.#activity === null || this.#activityVisible());
try {
const signedIn = !!this.#native.username().trim();
let needsNativeExpansion = !1;
try {
const key = nativePageKey(0), cached = this.#pages.get(key)?.page ?? null;
if (cached) needsNativeExpansion = this.#nativeAssociationNeedsExpansion(cached);
else {
const loaded = await this.#requests.load("all", 0, {
background: !0,
expandConsolidated: !1
});
if (!valid()) return;
needsNativeExpansion = this.#nativeAssociationNeedsExpansion(loaded), this.#pages.has(key) || this.#cacheNativeAssociationPage(loaded);
}
} catch (cause) {
valid() && this.#onError(cause);
}
if (!signedIn || !valid()) return;
try {
const key = pageKey("inbox", 0);
if (!this.#pages.has(key)) {
const loaded = await this.#requests.load("inbox", 0, {
background: !0
});
if (!valid()) return;
this.#pages.has(key) || (this.#cachePage(loaded), this.#queueTopicTaxonomyEnrichment([loaded], {
background: !0
}));
}
} catch (cause) {
valid() && this.#onError(cause);
}
try {
const key = pageKey("all", 0);
if (!this.#pages.has(key)) {
const loaded = await this.#loadAggregatePage(0, {
background: !0,
history: !0,
valid
});
if (!valid()) return;
this.#pages.has(key) || this.#cachePage(loaded);
}
} catch (cause) {
valid() && this.#onError(cause);
}
if (needsNativeExpansion && valid())
try {
const expanded = await this.#requests.load("all", 0, {
background: !0,
expandConsolidated: !0
});
if (!valid()) return;
this.#cacheNativeAssociationPage(expanded);
} catch (cause) {
valid() && this.#onError(cause);
}
this.#inheritAllSyntheticPages(), this.#raiseUnreadCountForCachedRecords();
} finally {
this.#backgroundWarming = !1, this.#backgroundWarmPending && !this.scope.destroyed && (this.#backgroundWarmPending = !1, this.#scheduleBackgroundWarm()), this.#scheduleHistoryHydration(this.#historyContinuationDelay());
}
}
async #onNativeChanged(preserveUnreadCount = !1) {
if (this.scope.destroyed) return;
this.#nativeChangeEpoch += 1, preserveUnreadCount || (this.#unreadCount = this.#native.unreadCount());
const alreadyPending = this.#nativeChangePending;
if (this.#nativeChangePending = !0, alreadyPending)
this.#hasLocalFilters() && (this.#nativeRefreshPending = !0);
else {
try {
await this.#cache.invalidate({ tags: ["notifications"] });
} catch (cause) {
this.#onError(cause);
}
this.#hasLocalFilters() ? this.#nativeRefreshPending = !0 : this.#invalidateLivePages(), this.#backgroundWarmEpoch += 1, this.#backgroundWarm !== null && (this.#cancel(this.#backgroundWarm), this.#backgroundWarm = null);
}
if (this.#emit(), this.#liveRefresh !== null) return;
const refreshSelectedImmediately = this.#open && !this.#hasLocalFilters() && (!this.#activity || this.#activityVisible());
this.#liveRefresh = this.#schedule(() => {
this.#liveRefresh = null, !this.scope.destroyed && this.#runSelectedRequest(() => refreshSelectedImmediately ? this.#refreshAfterNativeChange() : this.#refreshAllHeadAfterNativeChange());
}, refreshSelectedImmediately ? 0 : this.#liveRefreshDelayMs);
}
#emit() {
this.#revision += 1, this.#snapshotCache = null, this.changes.emit(this.snapshot).forEach(this.#onError);
}
}
}, "5074f813cfa85bdfa8b92ab2c1b7cda77a42ae44914ec9a37487de8b179d65b2");
/* Source: lite/src/notification/reader-notification-model.ts */
runtime.register("src/notification/reader-notification-model.js", function(module, exports, require) {
var reader_notification_model_exports = {};
__export(reader_notification_model_exports, {
READER_NOTIFICATION_AGGREGATE_GROUP_ORDER: () => READER_NOTIFICATION_AGGREGATE_GROUP_ORDER,
READER_NOTIFICATION_GROUPS: () => READER_NOTIFICATION_GROUPS,
READER_NOTIFICATION_GROUP_ORDER: () => READER_NOTIFICATION_GROUP_ORDER,
READER_NOTIFICATION_PANEL_GROUP_ORDER: () => READER_NOTIFICATION_PANEL_GROUP_ORDER,
normalizeBoostNotification: () => normalizeBoostNotification,
normalizeNativeNotification: () => normalizeNativeNotification,
normalizePrivateMessageNotification: () => normalizePrivateMessageNotification,
normalizeReactionNotification: () => normalizeReactionNotification,
normalizeStoredReaderNotification: () => normalizeStoredReaderNotification,
normalizeUserActionNotification: () => normalizeUserActionNotification,
notificationData: () => notificationData,
notificationRecord: () => notificationRecord,
notificationSearchText: () => notificationSearchText,
notificationText: () => notificationText,
notificationUsername: () => notificationUsername,
readerNotificationCategoryFilterKey: () => readerNotificationCategoryFilterKey,
readerNotificationGroup: () => readerNotificationGroup,
readerNotificationTagFilterKey: () => readerNotificationTagFilterKey,
sortReaderNotifications: () => sortReaderNotifications,
withReaderNotificationTopicTaxonomy: () => withReaderNotificationTopicTaxonomy
});
module.exports = __toCommonJS(reader_notification_model_exports);
var import_identifiers = require("../discourse/identifiers.js");
function group(input) {
return Object.freeze({
...input,
typeNames: Object.freeze([...input.typeNames ?? []]),
actionTypes: Object.freeze([...input.actionTypes ?? []]),
path: input.path ?? null
});
}
const READER_NOTIFICATION_GROUPS = Object.freeze({
all: group({
key: "all",
mode: "notifications",
source: "notifications",
label: "全部",
icon: "bell",
pageSize: 24
}),
replies: group({
key: "replies",
mode: "notifications",
source: "user-actions",
label: "回复",
icon: "reply",
pageSize: 100,
typeNames: ["replied", "quoted"],
actionTypes: [6, 9]
}),
likes: group({
key: "likes",
mode: "notifications",
source: "user-actions",
label: "赞",
icon: "heart",
pageSize: 100,
typeNames: ["liked", "liked_consolidated"],
actionTypes: [2]
}),
mentions: group({
key: "mentions",
mode: "notifications",
source: "user-actions",
label: "@提及",
icon: "at",
pageSize: 100,
typeNames: ["mentioned", "group_mentioned"],
actionTypes: [7]
}),
edits: group({
key: "edits",
mode: "notifications",
source: "user-actions",
label: "编辑",
icon: "pencil",
pageSize: 100,
typeNames: ["edited"],
actionTypes: [11]
}),
links: group({
key: "links",
mode: "notifications",
source: "user-actions",
label: "链接",
icon: "link",
pageSize: 100,
typeNames: ["linked", "linked_consolidated"],
actionTypes: [17]
}),
boosts: group({
key: "boosts",
mode: "notifications",
source: "boosts-received",
label: "Boost",
icon: "rocket",
pageSize: 20,
typeNames: ["boost"]
}),
reactions: group({
key: "reactions",
mode: "notifications",
source: "reactions-received",
label: "回应",
icon: "smile",
pageSize: 20,
typeNames: ["reaction"]
}),
reactionLikes: group({
key: "reactionLikes",
mode: "notifications",
source: "user-actions",
label: "回应与赞",
icon: "smile",
pageSize: 30,
typeNames: ["reaction", "liked", "liked_consolidated"],
actionTypes: [2]
}),
inbox: group({
key: "inbox",
mode: "messages",
source: "private-messages",
label: "最新",
icon: "mail",
pageSize: 30,
path: "private-messages"
}),
sent: group({
key: "sent",
mode: "messages",
source: "private-messages",
label: "已发送",
icon: "reply",
pageSize: 30,
path: "private-messages-sent"
}),
newMessages: group({
key: "newMessages",
mode: "messages",
source: "private-messages",
label: "新",
icon: "plus",
pageSize: 30,
path: "private-messages-new"
}),
unreadMessages: group({
key: "unreadMessages",
mode: "messages",
source: "private-messages",
label: "未读",
icon: "bell",
pageSize: 30,
path: "private-messages-unread"
}),
archive: group({
key: "archive",
mode: "messages",
source: "private-messages",
label: "归档",
icon: "database",
pageSize: 30,
path: "private-messages-archive"
}),
botMessages: group({
key: "botMessages",
mode: "messages",
source: "private-messages",
label: "机器人聊天",
icon: "message-square",
pageSize: 30,
path: "private-messages-warnings"
})
}), READER_NOTIFICATION_GROUP_ORDER = Object.freeze([
"all",
"replies",
"boosts",
"mentions",
"likes",
"reactions",
"edits",
"links",
"inbox",
"sent",
"newMessages",
"unreadMessages",
"archive",
"botMessages"
]), READER_NOTIFICATION_PANEL_GROUP_ORDER = Object.freeze([
"all",
"replies",
"boosts",
"reactionLikes",
"mentions",
"edits",
"links",
"inbox",
"sent",
"newMessages",
"unreadMessages",
"archive",
"botMessages"
]), READER_NOTIFICATION_AGGREGATE_GROUP_ORDER = Object.freeze([
"replies",
"likes",
"mentions",
"edits",
"links",
"boosts",
"reactions"
]);
function readerNotificationGroup(value) {
const key = String(value ?? "");
return READER_NOTIFICATION_GROUPS[key] ?? READER_NOTIFICATION_GROUPS.all;
}
function nativeNotificationGroup(typeName, requested) {
if (requested !== "all") return readerNotificationGroup(requested);
for (const key of READER_NOTIFICATION_GROUP_ORDER) {
const candidate = READER_NOTIFICATION_GROUPS[key];
if (candidate.mode === "notifications" && candidate.typeNames.includes(typeName))
return candidate;
}
return READER_NOTIFICATION_GROUPS.all;
}
function notificationRecord(value) {
return value !== null && typeof value == "object" ? value : Object.freeze({});
}
function notificationData(value) {
const raw = notificationRecord(value).data;
if (raw !== null && typeof raw == "object") return raw;
if (typeof raw != "string" || !raw.trim()) return Object.freeze({});
try {
return notificationRecord(JSON.parse(raw));
} catch {
return Object.freeze({});
}
}
function notificationText(value) {
return String(value ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function notificationUsername(value) {
return String(value ?? "").trim().replace(/^@/, "");
}
function notificationSearchText(values) {
return values.map((value) => String(value ?? "").toLocaleLowerCase()).join(" ").replace(/\s+/g, "").trim();
}
function positiveId(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
function tagNames(...values) {
const names = /* @__PURE__ */ new Map(), visit = (value) => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
const source = notificationRecord(value), name = notificationText(
typeof value == "string" ? value : source.name ?? source.tag_name ?? source.slug
);
if (!name) return;
const key = name.toLocaleLowerCase("zh-CN");
names.has(key) || names.set(key, name);
};
for (const value of values) visit(value);
return Object.freeze([...names.values()]);
}
function notificationTaxonomy(categoryNameFor, ...values) {
let categoryId = null, categoryName = "";
const tags = [];
for (const value of values) {
const source = notificationRecord(value), category = notificationRecord(source.category);
categoryId ??= positiveId(source.category_id ?? category.id), categoryName ||= notificationText(
source.category_name ?? source.categoryName ?? category.name ?? source.category_slug ?? category.slug
), tags.push(source.tags, source.topic_tags);
}
return !categoryName && categoryId !== null && categoryNameFor && (categoryName = notificationText(categoryNameFor(categoryId))), Object.freeze({
categoryId,
categoryName,
tags: tagNames(tags)
});
}
function readerNotificationCategoryFilterKey(record) {
if (record.categoryId !== null) return `category:${record.categoryId}`;
const name = record.categoryName.trim().toLocaleLowerCase("zh-CN");
return name ? `category-name:${name}` : "";
}
function readerNotificationTagFilterKey(value) {
const tag = value.trim().toLocaleLowerCase("zh-CN");
return tag ? `tag:${tag}` : "";
}
function aggregateCount(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 1 ? numeric : null;
}
function createdAt(value) {
const normalized = String(value ?? "").trim();
return Number.isFinite(Date.parse(normalized)) ? normalized : (/* @__PURE__ */ new Date(0)).toISOString();
}
function targetFrom(topicIdValue, postNumberValue) {
const topicId = (0, import_identifiers.tryDiscourseTopicId)(topicIdValue);
if (!topicId) return null;
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(postNumberValue) ?? (0, import_identifiers.tryDiscoursePostNumber)(1);
return postNumber ? Object.freeze({ topicId, postNumber }) : null;
}
function targetFromHref(value) {
const href = String(value ?? "").trim();
if (!href) return null;
let pathname;
try {
pathname = new URL(href, "https://reader.invalid/").pathname;
} catch {
return null;
}
const segments = pathname.split("/").filter(Boolean), topicIndex = segments.indexOf("t");
if (topicIndex < 0) return null;
const tail = segments.slice(topicIndex + 1), idIndex = tail.findIndex((segment) => Number.isSafeInteger(Number(segment)) && Number(segment) > 0);
return idIndex < 0 ? null : targetFrom(tail[idIndex], tail[idIndex + 1] ?? 1);
}
const TYPE_ICONS = Object.freeze({
mentioned: "at",
group_mentioned: "at",
replied: "reply",
quoted: "message-square",
posted: "message-square",
liked: "heart",
liked_consolidated: "heart",
reaction: "smile",
boost: "rocket",
private_message: "mail",
invited_to_private_message: "user-plus",
group_message_summary: "mail",
edited: "pencil",
linked: "link",
linked_consolidated: "link"
});
function recordResult(input) {
return Object.freeze({
...input,
searchText: notificationSearchText([
input.actor,
input.summary,
input.excerpt,
input.stateLabel,
input.typeLabel,
input.target?.topicId,
input.target?.postNumber,
input.categoryName,
...input.tags
])
});
}
function normalizeStoredReaderNotification(value) {
const source = notificationRecord(value), identity = String(source.identity ?? "").trim(), group2 = String(source.group ?? ""), notificationSource = String(
source.source ?? ""
);
if (!identity || !Object.hasOwn(READER_NOTIFICATION_GROUPS, group2) || ![
"notifications",
"user-actions",
"boosts-received",
"reactions-received",
"private-messages"
].includes(notificationSource)) return null;
const targetValue = notificationRecord(source.target), topicId = (0, import_identifiers.tryDiscourseTopicId)(targetValue.topicId), postNumber = (0, import_identifiers.tryDiscoursePostNumber)(targetValue.postNumber), target = topicId && postNumber ? Object.freeze({ topicId, postNumber }) : null, tags = Object.freeze((Array.isArray(source.tags) ? source.tags : []).map(notificationText).filter(Boolean)), read = source.read === !0 ? !0 : source.read === !1 ? !1 : null;
return recordResult({
identity,
group: group2,
source: notificationSource,
sourceNotificationId: positiveId(source.sourceNotificationId),
notificationTypeId: positiveId(source.notificationTypeId),
highPriority: source.highPriority === !0,
typeName: notificationText(source.typeName),
typeLabel: notificationText(source.typeLabel),
aggregateCount: aggregateCount(source.aggregateCount),
icon: notificationText(source.icon) || "bell",
actor: notificationUsername(source.actor),
avatarFallback: notificationText(source.avatarFallback),
avatarTemplate: String(source.avatarTemplate ?? "").trim(),
summary: notificationText(source.summary),
excerpt: notificationText(source.excerpt),
stateLabel: notificationText(source.stateLabel),
createdAt: createdAt(source.createdAt),
read,
href: String(source.href ?? "").trim(),
target,
categoryId: positiveId(source.categoryId),
categoryName: notificationText(source.categoryName),
tags
});
}
function withReaderNotificationTopicTaxonomy(record, value, categoryNameFor) {
const topic = notificationRecord(value), taxonomy = notificationTaxonomy(categoryNameFor, topic), categoryId = record.categoryId ?? taxonomy.categoryId, categoryName = record.categoryName || (categoryId !== null && categoryId === taxonomy.categoryId ? taxonomy.categoryName : ""), hasTopicTags = Object.hasOwn(topic, "tags") || Object.hasOwn(topic, "topic_tags"), tags = record.tags.length || !hasTopicTags ? record.tags : taxonomy.tags;
return categoryId === record.categoryId && categoryName === record.categoryName && tags.length === record.tags.length && tags.every((tag, index) => tag === record.tags[index]) ? record : recordResult({
...record,
categoryId,
categoryName,
tags
});
}
function notificationActivityVerb(group2, typeName) {
return typeName === "quoted" ? "引用了你" : {
replies: "回复了你",
likes: "赞了你的帖子",
mentions: "@提及了你",
edits: "编辑了帖子",
links: "链接了你的帖子",
boosts: "Boost 了你的帖子",
reactions: "回应了你的帖子"
}[group2] ?? "";
}
function notificationActivitySummary(input) {
const verb = notificationActivityVerb(input.group, input.typeName);
return verb ? `${input.actor ? `@${input.actor} · ` : ""}${verb}${input.title ? ` · ${input.title}` : ""}` : "";
}
function normalizeNativeNotification(value, presented, groupValue, options = {}) {
const source = notificationRecord(value), data = notificationData(source), typeName = String(presented.typeName ?? source.type_name ?? "").trim(), group2 = nativeNotificationGroup(typeName, groupValue), presentedActor = notificationUsername(
presented.actor ?? data.display_username ?? data.original_username ?? data.acting_user_name ?? data.username ?? source.username
), count = typeName === "replied" ? aggregateCount(data.consolidated_count) : null, aggregateActor = count === null ? "" : notificationUsername(
data.original_username ?? data.acting_user_name ?? data.username ?? data.display_username ?? presented.actor
), namedAggregateActor = aggregateActor && aggregateActor !== String(count) && !/^[0-9]+$/.test(aggregateActor) ? aggregateActor : "", actor = count === null ? presentedActor : namedAggregateActor, target = targetFrom(
presented.topicId ?? source.topic_id ?? data.topic_id,
presented.postNumber ?? source.post_number ?? data.post_number
) ?? targetFromHref(
presented.href ?? data.post_url ?? data.topic_url ?? data.url
), id = positiveId(options.sourceNotificationId ?? source.id), timestamp = createdAt(source.created_at), href = String(presented.href ?? "").trim(), title = notificationText(data.topic_title), summary = (count === null ? notificationActivitySummary({
actor: presentedActor,
group: group2.key,
typeName,
title
}) : `${namedAggregateActor ? `@${namedAggregateActor} 等 · ` : ""}${count} 条回复${title ? ` · ${title}` : ""}`) || notificationText(
presented.summary ?? data.topic_title ?? presented.typeLabel ?? typeName ?? "通知"
), taxonomy = notificationTaxonomy(
options.categoryNameFor,
data,
source
);
return recordResult({
identity: options.identity ?? (id ? `notification:${id}` : `notification:${target?.topicId ?? 0}:${target?.postNumber ?? 1}:${timestamp}:${actor}`),
group: group2.key,
source: "notifications",
sourceNotificationId: id,
notificationTypeId: positiveId(source.notification_type),
highPriority: source.high_priority === !0,
typeName,
typeLabel: notificationText(presented.typeLabel ?? (typeName || "通知")),
aggregateCount: count,
icon: TYPE_ICONS[typeName] ?? group2.icon,
actor,
avatarFallback: count === null ? actor.slice(0, 1).toLocaleUpperCase() || "?" : String(count),
avatarTemplate: count === null ? String(
source.acting_user_avatar_template ?? source.avatar_template ?? data.acting_user_avatar_template ?? data.avatar_template ?? ""
).trim() : "",
summary,
excerpt: "",
stateLabel: source.read === !0 ? "已读" : source.read === !1 ? "未读" : "",
createdAt: timestamp,
read: typeof source.read == "boolean" ? source.read : null,
href,
target,
...taxonomy
});
}
function syntheticRecord(input) {
const group2 = readerNotificationGroup(input.group), actor = notificationUsername(input.actor), timestamp = createdAt(input.createdAt), target = targetFrom(input.topicId, input.postNumber), summary = notificationText(input.summary || group2.label), excerpt = notificationText(input.excerpt), stateLabel = notificationText(input.stateLabel);
return recordResult({
identity: `${group2.key}:${input.identity || `${target?.topicId ?? 0}:${target?.postNumber ?? 1}:${timestamp}`}`,
group: group2.key,
source: group2.source,
sourceNotificationId: null,
notificationTypeId: null,
highPriority: !1,
typeName: input.typeName ?? group2.typeNames[0] ?? group2.key,
typeLabel: group2.label,
aggregateCount: null,
icon: input.icon ?? group2.icon,
actor,
avatarFallback: actor.slice(0, 1).toLocaleUpperCase() || "?",
avatarTemplate: String(input.avatarTemplate ?? "").trim(),
summary,
excerpt,
stateLabel,
createdAt: timestamp,
read: input.read ?? null,
href: "",
target,
categoryId: positiveId(input.categoryId),
categoryName: notificationText(input.categoryName),
tags: tagNames(input.tags)
});
}
function normalizeUserActionNotification(value, groupKey, categoryNameFor) {
const action = notificationRecord(value), taxonomy = notificationTaxonomy(categoryNameFor, action), actionType = Number(action.action_type) || 0, actor = notificationUsername(action.acting_username ?? action.username), title = notificationText(action.title), typeName = actionType === 9 ? "quoted" : readerNotificationGroup(groupKey).typeNames[0] ?? groupKey, summary = notificationActivitySummary({
actor,
group: groupKey,
typeName,
title
});
return syntheticRecord({
group: groupKey,
identity: [
actionType,
action.post_id,
action.acting_user_id,
action.created_at
].filter(Boolean).join(":"),
actor,
avatarTemplate: action.acting_avatar_template ?? action.avatar_template,
createdAt: action.created_at,
topicId: action.topic_id,
postNumber: action.post_number,
summary: summary || readerNotificationGroup(groupKey).label,
excerpt: action.excerpt,
typeName,
...taxonomy
});
}
function normalizeReactionNotification(value, categoryNameFor) {
const reaction = notificationRecord(value), post = notificationRecord(reaction.post), topic = notificationRecord(post.topic), user = notificationRecord(reaction.user), taxonomy = notificationTaxonomy(
categoryNameFor,
reaction,
post,
topic
), reactionValue = notificationRecord(reaction.reaction).reaction_value ?? reaction.reaction_value ?? (typeof reaction.reaction == "string" ? reaction.reaction : ""), reactionId = String(reactionValue ?? "").trim().replace(/^:+|:+$/g, ""), actor = notificationUsername(user.username), title = notificationText(
notificationRecord(post.topic).title ?? post.topic_title
), target = targetFromHref(post.url);
return syntheticRecord({
group: "reactions",
identity: String(reaction.id ?? reaction.reaction_user_id ?? ""),
actor,
avatarTemplate: user.avatar_template,
createdAt: reaction.created_at,
topicId: post.topic_id ?? target?.topicId,
postNumber: post.post_number ?? target?.postNumber,
summary: `${actor ? `@${actor} · ` : ""}回应了你的帖子${title ? ` · ${title}` : ""}`,
excerpt: post.excerpt,
typeName: "reaction",
icon: reactionId ? `emoji:${reactionId}` : "smile",
...taxonomy
});
}
function normalizeBoostNotification(value, categoryNameFor) {
const boost = notificationRecord(value), post = notificationRecord(boost.post), topic = notificationRecord(post.topic), user = notificationRecord(boost.user), taxonomy = notificationTaxonomy(
categoryNameFor,
boost,
post,
topic
), actor = notificationUsername(user.username), title = notificationText(post.topic_title), target = targetFromHref(post.url);
return syntheticRecord({
group: "boosts",
identity: String(boost.id ?? ""),
actor,
avatarTemplate: user.avatar_template,
createdAt: boost.created_at,
topicId: post.topic_id ?? target?.topicId,
postNumber: post.post_number ?? target?.postNumber,
summary: `${actor ? `@${actor} · ` : ""}Boost 了你的帖子${title ? ` · ${title}` : ""}`,
excerpt: boost.cooked ?? post.excerpt,
typeName: "boost",
...taxonomy
});
}
function normalizePrivateMessageNotification(value, payloadValue, groupKey, currentUsernameValue, categoryNameFor) {
const topic = notificationRecord(value), taxonomy = notificationTaxonomy(categoryNameFor, topic), payload = notificationRecord(payloadValue), users = new Map(
(Array.isArray(payload.users) ? payload.users : []).map((userValue) => {
const user = notificationRecord(userValue);
return [Number(user.id), user];
})
), participants = (Array.isArray(topic.participants) ? topic.participants : Array.isArray(topic.posters) ? topic.posters : []).map((participantValue) => {
const participant = notificationRecord(participantValue);
return participant.username ? participant : users.get(Number(participant.user_id)) ?? participant;
}), currentUsername = notificationUsername(
currentUsernameValue
).toLocaleLowerCase(), lastPoster = [...users.values()].find(
(user) => notificationUsername(user.username) === notificationUsername(topic.last_poster_username)
), actor = [...participants].reverse().find(
(user) => notificationUsername(user.username).toLocaleLowerCase() !== currentUsername
) ?? lastPoster ?? participants.at(-1) ?? Object.freeze({}), highest = Math.max(
1,
Number(topic.highest_post_number) || Number(topic.posts_count) || 1
), lastRead = Math.max(0, Number(topic.last_read_post_number) || 0), unread = topic.unseen === !0 || Number(topic.unread) > 0 || Number(topic.new_posts) > 0, stateLabel = topic.unseen === !0 || Number(topic.new_posts) > 0 ? "新" : unread ? "未读" : "已读", target = unread && lastRead < highest ? lastRead + 1 : highest, title = notificationText(topic.fancy_title ?? topic.title) || "私信";
return syntheticRecord({
group: groupKey,
identity: String(topic.id ?? ""),
actor: actor.username,
avatarTemplate: actor.avatar_template,
createdAt: topic.last_posted_at ?? topic.bumped_at ?? topic.created_at,
topicId: topic.id,
postNumber: target,
summary: title,
excerpt: [
actor.username ? `@${notificationUsername(actor.username)}` : "",
highest > 1 ? `${highest - 1} 条回复` : ""
].filter(Boolean).join(" · "),
read: !unread,
stateLabel,
typeName: "private_message",
...taxonomy
});
}
function sortReaderNotifications(records) {
return Object.freeze([...records].sort((left, right) => (Date.parse(right.createdAt) || 0) - (Date.parse(left.createdAt) || 0) || right.identity.localeCompare(left.identity)));
}
}, "992b745f59dbfbaab399af7dabbcc5cf897fef4d41d9cc650cfba1a0a894c935");
/* Source: lite/src/notification/reader-notification-panel-view.ts */
runtime.register("src/notification/reader-notification-panel-view.js", function(module, exports, require) {
var reader_notification_panel_view_exports = {};
__export(reader_notification_panel_view_exports, {
ReaderNotificationPanelView: () => ReaderNotificationPanelView
});
module.exports = __toCommonJS(reader_notification_panel_view_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_reader_collection_floating_window = require("../collection/reader-collection-floating-window.js"), import_reader_popover_filter_controls = require("../collection/reader-popover-filter-controls.js"), import_event_target = require("../dom/event-target.js"), import_reader_image_fallback = require("../components/reader-image-fallback.js"), import_reader_icon = require("../components/reader-icon.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_history_repository = require("../history/reader-history-repository.js"), import_reader_notification_model = require("./reader-notification-model.js");
function recordHref(record, baseUrl) {
if (record.target)
return new URL(
`/t/${record.target.topicId}/${record.target.postNumber}`,
baseUrl
).href;
try {
return new URL(record.href || "/my/notifications", baseUrl).href;
} catch {
return new URL("/my/notifications", baseUrl).href;
}
}
function errorMessage(cause) {
return cause instanceof Error ? cause.message : String(cause || "未知错误");
}
function recordDisplayTitle(record, marker) {
if (!(0, import_reader_history_repository.readerHistoryArchiveIsDeletedTopic)(marker)) return record.summary;
const topicTitle = marker?.topicTitle?.trim() ?? "";
return topicTitle && record.summary.endsWith(topicTitle) ? record.summary.slice(0, -topicTitle.length) + import_reader_history_repository.READER_DELETED_TOPIC_TITLE : import_reader_history_repository.READER_DELETED_TOPIC_TITLE;
}
function dateGroup(createdAt, now) {
const timestamp = Date.parse(createdAt);
if (!Number.isFinite(timestamp)) return "更早";
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const yesterday = today.getTime() - 1440 * 60 * 1e3;
return timestamp >= today.getTime() ? "今天" : timestamp >= yesterday ? "昨天" : "更早";
}
class ReaderNotificationPanelView {
scope;
#document;
#controller;
#elements;
#baseUrl;
#relativeTime;
#renderIcon;
#avatarSource;
#emojiSource;
#archiveMarker;
#schedule;
#cancel;
#notify;
#onError;
#surface;
#progress;
#filterDisclosure;
#markAllHeaderActions;
#scrollWindow;
#recordNodes = new import_reader_collection_floating_window.ReaderCollectionNodeCache();
#relativeTimer = null;
#historyCacheCompleted = !1;
constructor(options) {
this.#document = options.document, this.#controller = options.controller, this.#elements = options.elements, this.#baseUrl = new URL(options.baseUrl).href, this.#relativeTime = options.relativeTime, this.#renderIcon = options.renderIcon ?? null, this.#avatarSource = options.avatarSource ?? ((template, size) => (0, import_native_host_api.discourseAvatarTemplateUrl)(template, size, this.#baseUrl)), this.#emojiSource = options.emojiSource ?? (() => ""), this.#archiveMarker = options.archiveMarker ?? (() => null), this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle)), this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#surface = new import_reader_collection_floating_window.ReaderCollectionFloatingWindow({
document: this.#document,
mount: options.mount,
toggle: this.#elements.toggle,
content: this.#elements.popover,
title: "通知私信",
ariaLabel: "通知与私信",
icon: "bell",
variant: "notifications",
tabOrder: 10,
...options.storage ? { geometryStorage: options.storage } : {},
parentScope: this.scope,
isOpen: () => this.#controller.snapshot.open,
requestOpen: () => this.#controller.open(),
requestClose: () => this.#controller.close(),
notify: this.#notify
}), this.#markAllHeaderActions = this.#document.createElement("div"), this.#markAllHeaderActions.append(this.#elements.markAll), this.#surface.attachHeaderActions({
root: this.#markAllHeaderActions,
buttons: [this.#elements.markAll],
label: "通知操作"
}), this.#progress = new import_reader_collection_floating_window.ReaderCollectionProgressView({
document: this.#document,
onError: this.#onError,
retry: async () => {
if (this.#controller.snapshot.stale) {
await this.#controller.refresh();
return;
}
this.#controller.retryBackgroundCache();
},
parentScope: this.scope
}), this.#elements.popover.prepend(this.#progress.element), this.#filterDisclosure = new import_reader_popover_filter_controls.ReaderPopoverFilterDisclosure({
search: this.#elements.search,
onDateChange: (value) => this.#controller.setDateFilter(value),
onSortChange: () => {
},
onDirectionChange: (value) => this.#controller.setSortDirection(value),
onReset: () => this.#controller.resetFilters(),
parentScope: this.scope
});
const pager = this.#elements.pageInfo.parentElement;
if (!pager) throw new Error("通知面板缺少滚动分页锚点");
this.#scrollWindow = new import_reader_collection_floating_window.ReaderCollectionScrollWindow({
list: this.#elements.list,
pager,
identity: (record) => record.identity,
loadMore: () => this.#controller.nextPage(),
onError: this.#onError,
parentScope: this.scope
}), this.#bind(), this.#controller.changes.subscribe((snapshot) => {
this.#render(snapshot);
}, this.scope), this.scope.add(() => {
this.#stopRelativeTimer(), this.#recordNodes.clear(), this.#elements.list.replaceChildren();
}), this.#render(this.#controller.snapshot);
}
destroy() {
this.scope.destroy();
}
syncArchiveMarkers() {
this.scope.destroyed || this.#render(this.#controller.snapshot);
}
#bind() {
this.scope.listen(this.#elements.toggle, "click", () => {
this.#controller.toggle().catch((cause) => {
this.#onError(cause), this.#notify("消息加载失败,请重试");
});
});
for (const tab of this.#elements.modeTabs)
this.scope.listen(tab, "click", () => {
const mode = tab.dataset.notificationMode;
this.#controller.selectMode(mode).catch((cause) => {
this.#onError(cause), this.#notify("消息分类加载失败");
});
});
for (const tab of this.#elements.groupTabs)
this.scope.listen(tab, "click", () => {
const group = tab.dataset.notificationGroup;
this.#controller.selectGroup(group).catch((cause) => {
this.#onError(cause), this.#notify("消息分类加载失败");
});
});
this.scope.listen(this.#elements.search, "input", () => {
this.#controller.setQuery(this.#elements.search.value);
}), this.scope.listen(this.#elements.searchClear, "click", () => {
this.#elements.search.value = "", this.#controller.setQuery(""), this.#elements.search.focus();
}), this.scope.listen(this.#elements.categoryFilter, "change", () => {
this.#controller.setCategoryFilter(
this.#elements.categoryFilter.value
);
}), this.scope.listen(this.#elements.tagFilter, "change", () => {
this.#controller.setTagFilter(this.#elements.tagFilter.value);
}), this.scope.listen(this.#elements.markAll, "click", () => {
this.#controller.markAllAsRead().then(() => {
this.#scrollWindow.replaceWhere(
(record) => record.sourceNotificationId !== null,
(record) => Object.freeze({ ...record, read: !0 })
), this.#render(this.#controller.snapshot), this.#notify("消息已全部标为已读");
}).catch((cause) => {
this.#onError(cause), this.#notify(`标记已读失败:${errorMessage(cause)}`);
});
}), this.scope.listen(this.#elements.list, "click", (eventValue) => {
const event = eventValue, target = event.target, item = target?.closest ? target.closest(".ldp-notification-item") : null;
if (!item || !this.#elements.list.contains(item)) return;
const identity = item.dataset.notificationKey, record = this.#scrollWindow.records.find((candidate) => candidate.identity === identity);
if (record) {
if (record.read === !1 && this.#scrollWindow.update(record.identity, (current) => Object.freeze({ ...current, read: !0 })), !record.target || (0, import_event_target.usesNativeLinkNavigation)(event)) {
this.#controller.markRecordRead(record).catch(this.#onError);
return;
}
event.preventDefault(), this.#controller.openRecord(record).catch((cause) => {
this.#onError(cause), this.#notify("消息目标暂时无法打开");
});
}
});
}
#render(snapshot) {
const {
toggle,
badge,
unreadStatus,
markAll,
newMessage,
search,
searchClear,
categoryFilter,
tagFilter
} = this.#elements;
this.#surface.sync(snapshot.open), this.#syncWindowStatus(snapshot), toggle.classList.toggle("active", snapshot.open);
const badgeText = snapshot.unreadCount > 99 ? "99+" : String(snapshot.unreadCount);
badge.hidden = snapshot.unreadCount <= 0, badge.textContent = snapshot.unreadCount > 0 ? badgeText : "", badge.setAttribute(
"aria-label",
snapshot.unreadCount > 0 ? `未读 ${snapshot.unreadCount} 条` : "没有未读消息"
), toggle.setAttribute(
"aria-label",
snapshot.unreadCount > 0 ? `消息,${snapshot.unreadCount} 条未读` : "消息"
), unreadStatus.hidden = snapshot.unreadCount <= 0, unreadStatus.textContent = snapshot.unreadCount > 0 ? `未读 ${snapshot.unreadCount} 条` : "", unreadStatus.parentElement?.classList.toggle(
"is-empty",
snapshot.unreadCount <= 0
), markAll.disabled = snapshot.unreadCount <= 0 || snapshot.refreshing || snapshot.markingAll, markAll.dataset.ldpRequestBusy = snapshot.markingAll ? "1" : "0", markAll.setAttribute("aria-busy", String(snapshot.markingAll));
const markAllLabel = this.#document.createElement("span");
markAllLabel.textContent = snapshot.markingAll ? "处理中" : "全部已读", markAll.replaceChildren(
(0, import_reader_icon.renderReaderIcon)(
this.#document,
snapshot.markingAll ? "loader" : "check-square",
this.#renderIcon
),
markAllLabel
), markAll.hidden = (0, import_reader_notification_model.readerNotificationGroup)(snapshot.group).source !== "notifications", this.#markAllHeaderActions.hidden = markAll.hidden, unreadStatus.hidden = markAll.hidden || snapshot.unreadCount <= 0, newMessage.hidden = snapshot.mode !== "messages", this.#elements.toolbar.hidden = unreadStatus.hidden && newMessage.hidden;
for (const tab of this.#elements.modeTabs) {
const active = tab.dataset.notificationMode === snapshot.mode;
tab.classList.toggle("active", active), tab.setAttribute("aria-selected", String(active));
}
for (const panel of this.#elements.groupPanels)
panel.hidden = panel.dataset.notificationModePanel !== snapshot.mode;
for (const tab of this.#elements.groupTabs) {
const group = tab.dataset.notificationGroup, active = group === snapshot.group;
tab.classList.toggle("active", active), tab.setAttribute("aria-selected", String(active)), this.#syncGroupTabCount(
tab,
(0, import_reader_notification_model.readerNotificationGroup)(group).label,
snapshot.groupCounts.get(group) ?? 0
);
}
search.value !== snapshot.query && (search.value = snapshot.query), searchClear.hidden = !snapshot.query, (0, import_reader_popover_filter_controls.syncReaderFilterOptions)(
categoryFilter,
"类别",
"暂无类别",
snapshot.categoryOptions,
snapshot.categoryFilter
), (0, import_reader_popover_filter_controls.syncReaderFilterOptions)(
tagFilter,
"标签",
"暂无标签",
snapshot.tagOptions,
snapshot.tagFilter
), this.#filterDisclosure.sync({
active: !!(snapshot.categoryFilter || snapshot.tagFilter || snapshot.dateFilter || snapshot.sortDirection !== "desc"),
date: snapshot.dateFilter,
sort: "time",
direction: snapshot.sortDirection,
dayCounts: snapshot.dayCounts
});
const records = this.#scrollWindow.project({
streamKey: JSON.stringify([
snapshot.mode,
snapshot.group,
snapshot.query,
snapshot.categoryFilter,
snapshot.tagFilter,
snapshot.dateFilter,
snapshot.sortDirection
]),
page: snapshot.page,
records: snapshot.records,
loading: snapshot.loading,
hasMore: snapshot.hasNext || snapshot.page < snapshot.totalPages - 1
});
this.#renderRecords(snapshot, records), snapshot.open ? this.#startRelativeTimer() : this.#stopRelativeTimer();
}
#syncGroupTabCount(tab, label, count) {
let counter = tab.querySelector(
".ldp-collection-tab-count"
);
counter || (counter = this.#document.createElement("span"), counter.className = "ldp-collection-tab-count", counter.setAttribute("aria-hidden", "true"), tab.append(counter)), counter.textContent = String(count), tab.setAttribute("aria-label", `${label},${count} 条`);
}
#syncWindowStatus(snapshot) {
const history = snapshot.history, complete = history.status === "complete", totalStatus = snapshot.total > 0 && snapshot.total !== history.cachedRecords ? `${snapshot.total} 条` : "", cacheStatus = history.cachedRecords > 0 ? `已缓存 ${history.cachedRecords} 条` : "";
if (this.#surface.frame.meta.textContent = [
snapshot.unreadCount > 0 ? `未读 ${snapshot.unreadCount}` : "",
totalStatus,
cacheStatus,
complete ? "历史已到底" : ""
].filter(Boolean).join(" · "), history.status === "idle" && history.completedGroups === 0 && history.cachedRecords === 0 && (this.#historyCacheCompleted = !1), complete && (this.#historyCacheCompleted = !0), this.#historyCacheCompleted) {
this.#progress.render({
visible: !1,
label: "",
detail: "",
state: "complete",
completed: history.totalGroups,
total: history.totalGroups,
valueText: "消息历史缓存已完成"
});
return;
}
if (snapshot.stale) {
this.#progress.render({
visible: !0,
label: "当前页缓存更新失败",
detail: snapshot.error instanceof Error ? snapshot.error.message : "正在显示上次已加载内容",
state: "error",
completed: 0,
total: 1,
valueText: "缓存更新失败",
retryable: !0
});
return;
}
if (snapshot.refreshing) {
this.#progress.render({
visible: !0,
label: "更新当前页缓存",
detail: "后台刷新中,当前内容可继续浏览",
state: "running",
completed: 0,
total: 1,
valueText: "正在更新当前页缓存"
});
return;
}
const failed = history.status === "error", autoRecovering = failed && history.retryAt !== null, running = history.status === "loading", current = history.currentGroup ? (0, import_reader_notification_model.readerNotificationGroup)(history.currentGroup).label : running ? "消息历史" : "等待后台缓存", pageProgress = `已缓存 ${history.loadedPages} 页`, exploring = "总页数探测中";
this.#progress.render({
visible: !complete,
label: autoRecovering ? "消息历史自动续传" : failed ? "消息历史缓存中断" : current,
detail: failed ? autoRecovering ? "断点已保存,将按中央请求许可自动恢复" : "可从已保存断点继续" : `${history.completedGroups} / ${history.totalGroups} 来源 · ${pageProgress} · ${exploring}`,
state: autoRecovering ? "waiting" : failed ? "error" : running ? "running" : "waiting",
completed: history.completedGroups,
total: history.totalGroups,
valueText: `${history.completedGroups}/${history.totalGroups} 来源,${pageProgress},${exploring}` + (autoRecovering ? ",等待自动续传" : ""),
retryable: failed && !autoRecovering
});
}
#renderRecords(snapshot, records) {
const list = this.#elements.list, scrollTop = list.scrollTop;
if (snapshot.retrying && !records.length) {
this.#recordNodes.clear();
const message = this.#document.createElement("div");
message.className = "ldp-notification-empty", message.textContent = "消息加载暂时中断,正在自动重试…", list.replaceChildren(message);
return;
}
if (snapshot.loading && !records.length) {
this.#recordNodes.clear();
const message = this.#document.createElement("div");
message.className = "ldp-notification-empty", message.textContent = "正在加载消息…", list.replaceChildren(message);
return;
}
if (snapshot.error && !snapshot.stale && !records.length) {
this.#recordNodes.clear();
const message = this.#document.createElement("div");
message.className = "ldp-notification-empty", message.textContent = "消息加载失败,请重试", list.replaceChildren(message);
return;
}
if (!records.length) {
this.#recordNodes.clear();
const message = this.#document.createElement("div");
message.className = "ldp-notification-empty", message.textContent = snapshot.query || snapshot.categoryFilter || snapshot.tagFilter || snapshot.dateFilter || snapshot.sortDirection !== "desc" ? "本地缓存中没有匹配消息" : "暂无消息", list.replaceChildren(message);
return;
}
const grouped = /* @__PURE__ */ new Map(), now = Date.now();
for (const record of records) {
const label = dateGroup(record.createdAt, now), records2 = grouped.get(label) ?? [];
records2.push(record), grouped.set(label, records2);
}
const fragment = this.#document.createDocumentFragment(), renderedKeys = [];
for (const [label, records2] of grouped) {
const section = this.#document.createElement("section");
section.className = "ldp-notification-date-group";
const heading = this.#document.createElement("div");
heading.className = "ldp-notification-date-label", heading.textContent = label, section.append(heading);
for (const record of records2) {
const marker = record.target ? this.#archiveMarker(
record.target.topicId,
record.target.postNumber
) : null, variant = marker ? `${marker.status}:${marker.topicTitle ?? ""}:${marker.postNumber ?? ""}` : "";
renderedKeys.push(record.identity), section.append(this.#recordNodes.node(
record.identity,
record,
variant,
() => this.#recordNode(record, marker)
));
}
fragment.append(section);
}
this.#recordNodes.prune(renderedKeys), list.replaceChildren(fragment), list.scrollTop = scrollTop;
}
#recordNode(record, markerValue) {
const item = this.#document.createElement("a");
item.className = "ldp-notification-item ldp-notification-message-item";
const unread = record.read === !1, readStateLabel = unread ? "未读" : "已读";
item.classList.toggle("unread", unread), item.classList.toggle("read", !unread), item.dataset.notificationReadState = unread ? "unread" : "read", item.href = recordHref(record, this.#baseUrl), item.dataset.notificationSource = record.sourceNotificationId === null ? record.source : "notifications", item.dataset.notificationId = String(record.sourceNotificationId ?? 0), item.dataset.notificationKey = record.identity, item.dataset.readerTargetSource = record.source === "private-messages" ? "message" : "notification", item.dataset.readerTargetInterception = "off", item.dataset.ldpPreserveTargetPost = "1", record.target && (item.dataset.notificationTopicId = String(record.target.topicId), item.dataset.notificationPostNumber = String(record.target.postNumber));
const archiveMarker = markerValue !== void 0 ? markerValue : record.target ? this.#archiveMarker(record.target.topicId, record.target.postNumber) : null, archiveLabel = archiveMarker ? (0, import_reader_history_repository.readerHistoryArchiveMarkerLabel)(archiveMarker) : "";
archiveMarker && (item.dataset.localArchiveStatus = String(archiveMarker.status), item.dataset.localArchiveScope = archiveMarker.postNumber === null ? "topic" : "post");
const avatarUrl = this.#avatarSource(record.avatarTemplate, 48);
let avatar;
if (avatarUrl) {
const image = this.#document.createElement("img");
image.className = "ldp-notification-avatar", (0, import_reader_image_fallback.replaceImageWithFallbackOnError)(image, () => {
const fallback = this.#document.createElement("span");
return fallback.className = "ldp-notification-avatar ldp-avatar-fallback", fallback.textContent = record.avatarFallback, fallback.setAttribute("aria-hidden", "true"), fallback;
}), image.src = avatarUrl, image.alt = "", image.loading = "lazy", image.decoding = "async", avatar = image;
} else {
const fallback = this.#document.createElement("span");
fallback.className = "ldp-notification-avatar ldp-avatar-fallback", fallback.textContent = record.avatarFallback, fallback.setAttribute("aria-hidden", "true"), avatar = fallback;
}
if (record.actor) {
const trigger = this.#document.createElement("span");
trigger.className = "ldp-user-avatar-card", trigger.dataset.userCard = record.actor, trigger.dataset.userCardHoverOnly = "", trigger.append(avatar), item.append(trigger);
} else
avatar.setAttribute("aria-hidden", "true"), item.append(avatar);
const typeIcon = this.#document.createElement("span");
typeIcon.className = "ldp-notification-type-icon", typeIcon.dataset.notificationGroup = record.group, typeIcon.setAttribute("aria-hidden", "true");
const reactionEmojiId = record.group === "reactions" && record.icon.startsWith("emoji:") ? record.icon.slice(6) : "";
if (record.group === "likes") {
const emoji = this.#document.createElement("span");
emoji.dataset.notificationEmojiText = "heart", emoji.textContent = "❤️", typeIcon.append(emoji);
} else {
let emojiSource = "";
try {
emojiSource = reactionEmojiId ? this.#emojiSource(reactionEmojiId) : "";
} catch {
}
if (emojiSource) {
const emoji = this.#document.createElement("img");
emoji.className = "emoji", emoji.src = emojiSource, emoji.alt = "", emoji.loading = "lazy", emoji.decoding = "async", (0, import_reader_image_fallback.replaceImageWithFallbackOnError)(emoji, () => {
const fallback = this.#document.createElement("span");
return fallback.append((0, import_reader_icon.renderReaderIcon)(
this.#document,
"smile",
this.#renderIcon
)), fallback;
}), typeIcon.append(emoji);
} else
typeIcon.append((0, import_reader_icon.renderReaderIcon)(
this.#document,
reactionEmojiId ? "smile" : record.icon,
this.#renderIcon
));
}
const copy = this.#document.createElement("span");
copy.className = "ldp-notification-copy";
const title = this.#document.createElement("span");
title.className = "ldp-notification-title";
const titleText = this.#document.createElement("span");
if (titleText.className = "ldp-notification-title-text", titleText.textContent = recordDisplayTitle(record, archiveMarker), title.append(titleText), copy.append(title), record.excerpt) {
const excerpt = this.#document.createElement("span");
excerpt.className = "ldp-notification-excerpt", excerpt.textContent = record.excerpt, copy.append(excerpt);
}
const meta = this.#document.createElement("span");
meta.className = "ldp-notification-meta", meta.dataset.notificationCreatedAt = record.createdAt, meta.dataset.notificationArchiveLabel = archiveLabel, meta.textContent = `${archiveLabel ? `${archiveLabel} · ` : ""}${this.#relativeTime(record.createdAt)}`, copy.append(meta), item.append(typeIcon, copy);
const state = this.#document.createElement("span");
return state.className = "ldp-notification-read-state", state.textContent = readStateLabel, state.setAttribute("aria-label", `消息状态:${readStateLabel}`), item.append(state), item;
}
#startRelativeTimer() {
if (this.#relativeTimer !== null) return;
const update = () => {
if (this.#relativeTimer = null, !(!this.#controller.snapshot.open || this.scope.destroyed)) {
for (const node of this.#elements.list.querySelectorAll(
"[data-notification-created-at]"
)) {
const archiveLabel = node.dataset.notificationArchiveLabel ?? "";
node.textContent = `${archiveLabel ? `${archiveLabel} · ` : ""}${this.#relativeTime(node.dataset.notificationCreatedAt ?? "")}`;
}
this.#relativeTimer = this.#schedule(update, 3e4);
}
};
this.#relativeTimer = this.#schedule(update, 3e4);
}
#stopRelativeTimer() {
this.#relativeTimer !== null && (this.#cancel(this.#relativeTimer), this.#relativeTimer = null);
}
}
}, "81b939d637c8e644b8e0c9803d798d3e1fc8525d732c18180fbfc4bb83e8dfc3");
/* Source: lite/src/queue/reader-open-queue-session.ts */
runtime.register("src/queue/reader-open-queue-session.js", function(module, exports, require) {
var reader_open_queue_session_exports = {};
__export(reader_open_queue_session_exports, {
READER_QUEUE_RESET_SURFACE_POSITIONS_EVENT: () => READER_QUEUE_RESET_SURFACE_POSITIONS_EVENT,
READER_QUEUE_STORAGE_KEY: () => READER_QUEUE_STORAGE_KEY,
ReaderOpenQueueSession: () => ReaderOpenQueueSession,
requestReaderQueueSurfacePositionsReset: () => requestReaderQueueSurfacePositionsReset
});
module.exports = __toCommonJS(reader_open_queue_session_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_reader_icon = require("../components/reader-icon.js"), import_event_target = require("../dom/event-target.js"), import_floating_surface_wheel = require("../dom/floating-surface-wheel.js"), import_html_element = require("../dom/html-element.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_repeat_action_gate = require("../kernel/repeat-action-gate.js"), import_reader_workspace = require("../shell/reader-workspace.js"), import_reader_account_scoped_storage = require("../state/reader-account-scoped-storage.js"), import_reader_userscript_target_adapter = require("../userscript/reader-userscript-target-adapter.js"), import_reader_topic_download_manager = require("./reader-topic-download-manager.js");
const READER_QUEUE_STORAGE_KEY = "linuxdo-enhanced-reader:reader-queue:v1", READER_QUEUE_RESET_SURFACE_POSITIONS_EVENT = "ldp-reader-queue-reset-surface-positions", READER_QUEUE_DOCK_THRESHOLD_PX = 2, READER_QUEUE_PANEL_SHOW_DELAY_MS = 180, READER_QUEUE_PANEL_HIDE_GRACE_MS = 480, READER_QUEUE_CLEAR_CONFIRM_MS = 3e3;
function requestReaderQueueSurfacePositionsReset(document) {
const EventConstructor = document.defaultView?.Event ?? Event;
document.dispatchEvent(new EventConstructor(
READER_QUEUE_RESET_SURFACE_POSITIONS_EVENT
));
}
const TOPIC_ROW = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link", TOPIC_LINK = 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]';
function readerActionLayerOwnsEscape(event) {
return event.composedPath().some((value) => {
const candidate = value;
return typeof candidate.matches == "function" && candidate.matches(".ldp-reader-action-layer");
});
}
function icon(document, name) {
return (0, import_reader_icon.createReaderIcon)(document, name);
}
function button(document, className, label, iconName) {
const result = (0, import_html_element.htmlElement)(document, "button", className);
return result.type = "button", result.setAttribute("aria-label", label), result.append(icon(document, iconName)), result;
}
function reconcileElementChildren(container, children) {
const expected = new Set(children);
for (let index = 0; index < children.length; index += 1) {
const child = children[index], current = container.children.item(index);
current !== child && container.insertBefore(child, current);
}
for (const child of [...container.children])
expected.has(child) || child.remove();
}
function mutationAffectsQueueScan(mutation, rail) {
if (mutation.target === rail || rail.contains(mutation.target)) return !1;
const selector = `${TOPIC_ROW},${TOPIC_LINK},.d-header-icons,.current-user`;
return [...mutation.addedNodes, ...mutation.removedNodes].some((value) => {
if (value.nodeType !== 1) return !1;
const element = value;
return element.matches(selector) || element.querySelector(selector) !== null;
});
}
function mutationAffectsQueueGeometry(mutation) {
const selector = ".ldp-header,.ldp-topic-action-rail";
return [...mutation.addedNodes, ...mutation.removedNodes].some((value) => {
if (value.nodeType !== 1) return !1;
const element = value;
return element.matches(selector) || element.querySelector(selector) !== null;
});
}
function normalizedSurface(value) {
const source = value && typeof value == "object" ? value : {}, numeric = (key, fallback) => {
const result = Number(source[key]);
return Number.isFinite(result) ? Math.min(1, Math.max(0, result)) : fallback;
}, x = numeric("x", 0.02), y = numeric("y", 0.12), dock = source.dock, normalizedDock = [
"left",
"right",
"top",
"bottom",
"title"
].includes(String(dock)) ? dock : Object.hasOwn(source, "dock") ? "" : "left";
return {
x,
y,
// v2 会把当时的默认标题锚点随队列条目一起保存;只迁移这组
// canonical 默认值,保留用户拖动后的 title 与其他自定义位置。
dock: x === 0.02 && y === 0.12 && normalizedDock === "title" ? "left" : normalizedDock
};
}
function normalizedSurfaces(value, legacyValue) {
const source = value && typeof value == "object" ? value : {};
return {
floating: normalizedSurface(
Object.hasOwn(source, "floating") ? source.floating : legacyValue
),
fullpage: normalizedSurface(
Object.hasOwn(source, "fullpage") ? source.fullpage : legacyValue
),
embedded: normalizedSurface(
Object.hasOwn(source, "embedded") ? source.embedded : legacyValue
)
};
}
function defaultSurface(surface) {
return surface.x === 0.02 && surface.y === 0.12 && surface.dock === "left";
}
function normalizedEntry(value, baseUrl) {
if (!value || typeof value != "object") return null;
const source = value, topicId = (0, import_identifiers.tryDiscourseTopicId)(source.topicId);
if (!topicId) return null;
const href = String(source.href ?? `/t/${topicId}`), route = (0, import_reader_userscript_target_adapter.parseReaderUserscriptTopicRoute)(href, baseUrl);
return {
topicId,
title: String(source.title ?? `帖子 #${topicId}`).replace(/\s+/g, " ").trim(),
href: route?.href ?? new URL(`/t/${topicId}`, baseUrl).href,
avatarTemplate: String(source.avatarTemplate ?? ""),
avatarSource: String(source.avatarSource ?? ""),
ownerUsername: String(source.ownerUsername ?? ""),
postNumber: (0, import_identifiers.tryDiscoursePostNumber)(source.postNumber) ?? route?.postNumber ?? null,
addedAt: Math.max(0, Number(source.addedAt) || 0) || Date.now(),
pinned: source.pinned === !0,
loadState: "queued",
loadedCount: 0,
totalCount: 0,
nestedLoadedCount: 0,
nestedTotalCount: 0,
mediaLoadedCount: 0,
mediaTotalCount: 0,
error: ""
};
}
function queueProgress(history) {
return !history || history.postsCount <= 0 ? 0 : Math.max(
0,
Math.min(100, history.readPostNumbers.length / history.postsCount * 100)
);
}
function queueStatus(entry, history, active) {
const readCount = history?.readPostNumbers.length ?? 0, total = history?.postsCount || entry.totalCount || 0, progress = Math.round(queueProgress(history)), pinned = entry.pinned ? "已固定 · " : "";
if (active) {
const currentFloor = history?.postNumber ?? entry.postNumber ?? null;
return `${pinned}阅读进度 ${progress}% · 已读 ${readCount}/${total || "?"}` + (currentFloor ? ` · 当前 #${currentFloor}` : "");
}
const preload = `正文 ${entry.loadedCount}/${entry.totalCount || "?"}`, nested = entry.nestedTotalCount > 0 ? ` · 二级回复 ${entry.nestedLoadedCount}/${entry.nestedTotalCount}` : "", media = entry.mediaTotalCount > 0 ? ` · 图片 ${entry.mediaLoadedCount}/${entry.mediaTotalCount}` : "";
return entry.loadState === "ready" ? `${pinned}已预加载 · ${preload}${nested}${media} · 阅读进度 ${progress}%` : entry.loadState === "loading" ? `${pinned}正在预加载 · ${preload}${nested}${media} · 阅读进度 ${progress}%` : entry.loadState === "partial" ? `${pinned}已分层预加载 · ${preload}${nested}${media} · 阅读进度 ${progress}%` : entry.loadState === "error" ? `${pinned}预加载失败 · 阅读进度 ${progress}%,点击重试` : `${pinned}等待预加载 · 阅读进度 ${progress}%`;
}
class ReaderOpenQueueSession {
scope;
#options;
#workspaceRoot;
#storageKey;
#accountStorage;
#entries = /* @__PURE__ */ new Map();
#rail;
#toggleShell;
#toggle;
#badge;
#dismiss;
#bubbles;
#scrollHint;
#panel;
#count;
#clear;
#list;
#downloadManager;
#avatarIdentity = /* @__PURE__ */ new WeakMap();
#surfaces;
#prefetchTail = Promise.resolve();
#prefetching = /* @__PURE__ */ new Set();
#prefetchControllers = /* @__PURE__ */ new Map();
#closeGate = new import_repeat_action_gate.RepeatActionGate();
#requestFrame;
#cancelFrame;
#resizeObserver;
#observedSurfaceElements = /* @__PURE__ */ new WeakSet();
#scanQueued = !1;
#panelOpen = !1;
#panelPinned = !1;
#renderKey = "";
#surfaceFrame = 0;
#dragFrame = 0;
#syncFrame = 0;
#dragGeometry = null;
#dragging = !1;
#suppressToggleClick = !1;
#closePinnedPanelOnClick = !1;
#hoverOpenTimer = 0;
#hoverCloseTimer = 0;
#clearConfirmTimer = 0;
#clearConfirmationPending = !1;
#activeTopicId = null;
#nativeTriggerItem = null;
#nativeTriggerButton = null;
constructor(options) {
if (this.#options = options, this.#workspaceRoot = options.workspaceRoot ?? options.root.closest("[data-reader-workspace-mode]") ?? options.root, this.#accountStorage = options.storageKey === void 0 && options.authScope !== void 0 ? (0, import_reader_account_scoped_storage.readerAccountScopedStorageIdentity)(
READER_QUEUE_STORAGE_KEY,
options.authScope
) : null, this.#storageKey = String(options.storageKey ?? this.#accountStorage?.key ?? READER_QUEUE_STORAGE_KEY).trim(), !this.#storageKey) throw new Error("reader queue storage key 不能为空");
const view = options.document.defaultView;
this.#requestFrame = options.requestFrame ?? ((callback) => {
const request = view?.requestAnimationFrame;
return typeof request == "function" ? request.call(view, callback) : (callback(0), 0);
}), this.#cancelFrame = options.cancelFrame ?? ((id) => {
const cancel = view?.cancelAnimationFrame;
typeof cancel == "function" && cancel.call(view, id);
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const restored = this.#restore();
this.#surfaces = restored.surfaces;
for (const entry of restored.entries) this.#entries.set(entry.topicId, entry);
const document = options.document;
this.scope.listen(
document,
READER_QUEUE_RESET_SURFACE_POSITIONS_EVENT,
() => this.resetSurfacePositions()
), this.#rail = (0, import_html_element.htmlElement)(document, "aside", "ldp-reader-queue"), this.#toggle = button(
document,
"ldp-reader-queue-toggle",
"阅读队列",
"book-open"
), this.#toggle.setAttribute("aria-expanded", "false"), this.#toggle.setAttribute("aria-pressed", "false"), this.#toggle.setAttribute("aria-haspopup", "listbox"), this.#badge = (0, import_html_element.htmlElement)(document, "b"), this.#toggle.append(this.#badge), this.#dismiss = button(
document,
"ldp-reader-queue-dismiss",
"关闭阅读队列",
"x"
), this.#toggleShell = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-reader-queue-toggle-shell"
), this.#toggleShell.append(this.#toggle, this.#dismiss), this.#bubbles = (0, import_html_element.htmlElement)(document, "div", "ldp-reader-queue-bubbles"), this.#bubbles.setAttribute("aria-label", "队列文章头像,可滚动查看"), this.#scrollHint = button(
document,
"ldp-reader-queue-scroll-hint",
"显示下方更多队列头像",
"chevron-down"
), this.#scrollHint.hidden = !0, this.#scrollHint.dataset.scrollDirection = "1", this.#panel = (0, import_html_element.htmlElement)(document, "section", "ldp-reader-queue-panel"), this.#panel.setAttribute("aria-label", "阅读队列文章列表"), this.#panel.hidden = !0;
const head = (0, import_html_element.htmlElement)(document, "header", "ldp-reader-queue-panel-head"), title = (0, import_html_element.htmlElement)(document, "strong");
title.textContent = "阅读队列", this.#count = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-queue-panel-count"), this.#clear = button(
document,
"ldp-reader-queue-clear",
"移除未固定主题",
"trash"
);
const close = button(
document,
"ldp-reader-queue-close",
"关闭阅读队列",
"x"
);
head.append(title, this.#count), head.append(this.#clear, close), this.#list = (0, import_html_element.htmlElement)(document, "div", "ldp-reader-queue-list"), this.#list.setAttribute("role", "listbox"), this.#list.setAttribute("aria-label", "队列文章"), this.#panel.append(head, this.#list), this.#downloadManager = options.topicDownloads ? new import_reader_topic_download_manager.ReaderTopicDownloadManager({
...options.topicDownloads,
document,
mount: options.topicDownloads.mount ?? this.#panel,
geometryStorage: options.storage,
currentTopic: () => {
const topicId = this.#options.currentTopicId();
if (!topicId) return null;
const entry = this.#entries.get(topicId), history = this.#options.historyEntry(topicId);
return Object.freeze({
topicId,
title: entry?.title || history?.title || `Topic #${topicId}`
});
},
...options.notify ? { notify: options.notify } : {},
parentScope: this.scope
}) : null, this.#downloadManager?.changes.subscribe(() => {
this.#scheduleSurfaceMeasure();
}, this.scope), this.#rail.append(
this.#toggleShell,
this.#bubbles,
this.#scrollHint,
this.#panel
), options.root.append(this.#rail), this.scope.listen(
this.#workspaceRoot,
"ldp-reader-workspace-change",
() => {
this.#cancelSurfaceFrames(), this.#scheduleSurfaceMeasure();
}
), this.#resizeObserver = options.createResizeObserver?.(() => this.#scheduleSurfaceMeasure()) ?? (typeof ResizeObserver == "function" ? new ResizeObserver(() => this.#scheduleSurfaceMeasure()) : null), this.#observeSurfaceElement(options.root), this.#observeSurfaceElement(this.#rail), this.#scheduleSurfaceMeasure(), this.scope.add(() => this.#rail.remove()), this.scope.add(() => {
for (const controller of this.#prefetchControllers.values())
controller.abort(new DOMException("阅读队列已销毁", "AbortError"));
this.#prefetchControllers.clear();
}), this.scope.add(() => this.#closeGate.clear()), this.scope.add(() => {
this.#resizeObserver?.disconnect(), this.#cancelPanelPreview(), this.#cancelPanelClose(), this.#resetClearConfirmation(), this.#cancelSurfaceFrames(), this.#syncFrame && this.#cancelFrame(this.#syncFrame), this.#syncFrame = 0, this.#nativeTriggerItem?.remove(), this.#nativeTriggerItem = null, this.#nativeTriggerButton = null, options.document.documentElement.classList.remove(
"ldp-native-reader-trigger-visible"
);
}), this.scope.listen(this.#rail, "click", (event) => this.#click(event)), this.scope.listen(this.#toggle, "pointerenter", (event) => this.#schedulePanelPreview(event)), this.scope.listen(this.#toggle, "pointerleave", () => this.#cancelPanelPreview()), this.scope.listen(this.#rail, "pointerenter", () => {
this.#rail.classList.add("is-dock-revealed"), this.#cancelPanelClose();
}), this.scope.listen(this.#rail, "pointerleave", () => {
this.#panelOpen || this.#rail.classList.remove("is-dock-revealed"), this.#cancelPanelPreview(), this.#schedulePanelClose();
}), this.scope.listen(this.#panel, "focusin", () => this.#cancelPanelClose()), this.scope.listen(this.#panel, "pointerenter", () => {
this.#cancelPanelClose();
}), this.scope.listen(this.#panel, "focusout", (event) => {
const next = event.relatedTarget;
next && typeof next.nodeType == "number" && this.#panel.contains(next) || this.#schedulePanelClose();
}), this.scope.listen(this.#bubbles, "scroll", () => this.#syncScrollHint(), { passive: !0 }), this.scope.add((0, import_floating_surface_wheel.bindFloatingSurfaceWheel)(this.#bubbles)), this.scope.add((0, import_floating_surface_wheel.bindFloatingSurfaceWheel)(this.#panel)), this.scope.listen(options.root, "pointerdown", (event) => {
const target = (0, import_event_target.eventElement)(event);
this.#panelOpen && !target?.closest(".ldp-reader-queue") && this.#setPanelOpen(!1);
}), this.scope.listen(this.#toggle, "pointerdown", (event) => this.#drag(event)), this.scope.listen(document, "click", (event) => this.#documentClick(event), !0), this.scope.listen(document, "keydown", (event) => this.#keydown(event), !0);
const observer = options.createMutationObserver?.((mutations) => {
mutations.some(mutationAffectsQueueGeometry) && this.#scheduleSurfaceMeasure(), mutations.some((mutation) => mutationAffectsQueueScan(mutation, this.#rail)) && this.#queueScan();
}) ?? (typeof MutationObserver == "function" ? new MutationObserver((mutations) => {
mutations.some(mutationAffectsQueueGeometry) && this.#scheduleSurfaceMeasure(), mutations.some((mutation) => mutationAffectsQueueScan(mutation, this.#rail)) && this.#queueScan();
}) : null);
observer && document.body && this.scope.observe(observer, document.body, {
childList: !0,
subtree: !0
}), this.#scan(), this.sync();
}
get size() {
return this.#entries.size;
}
get storageKey() {
return this.#storageKey;
}
get #surface() {
return this.#surfaces[(0, import_reader_workspace.readerWorkspacePositionMode)(
this.#workspaceRoot.dataset.readerWorkspaceMode
)];
}
syncEntries() {
return Object.freeze([...this.#entries.values()].sort((left, right) => left.addedAt - right.addedAt || left.topicId - right.topicId).map((entry) => Object.freeze({
topicId: entry.topicId,
title: entry.title,
href: entry.href,
avatarTemplate: entry.avatarTemplate,
avatarSource: entry.avatarSource,
ownerUsername: entry.ownerUsername,
postNumber: entry.postNumber,
addedAt: entry.addedAt,
pinned: entry.pinned
})));
}
replaceExternal(values) {
const entries = values.map((value) => normalizedEntry(value, this.#options.document.baseURI)).filter((entry) => entry !== null);
for (const [topicId, controller] of this.#prefetchControllers)
entries.some((entry) => entry.topicId === topicId) || controller.abort(new DOMException("队列已由 WebDAV 更新", "AbortError"));
this.#entries.clear();
for (const entry of entries) this.#entries.set(entry.topicId, entry);
this.#persist(), this.sync();
}
reloadExternal() {
if (this.scope.destroyed) return;
const restored = this.#restore();
for (const [topicId, controller] of this.#prefetchControllers)
restored.entries.some((entry) => entry.topicId === topicId) || controller.abort(
new DOMException("队列已由其他标签更新", "AbortError")
);
this.#entries.clear();
for (const entry of restored.entries) this.#entries.set(entry.topicId, entry);
for (const mode of Object.keys(this.#surfaces))
Object.assign(this.#surfaces[mode], restored.surfaces[mode]);
this.#cancelSurfaceFrames(), this.sync(), this.#scheduleSurfaceMeasure();
}
reloadExternalDownloads() {
return this.#downloadManager?.reloadExternal() ?? Promise.resolve();
}
sync() {
this.#syncNativeReaderTrigger();
const active = this.#options.currentTopicId();
this.#downloadManager?.syncCurrent();
const entries = [...this.#entries.values()].sort((left, right) => Number(right.pinned) - Number(left.pinned) || left.addedAt - right.addedAt), alwaysVisible = this.#options.readPreferences().readerQueueAlwaysVisibleWhenEmpty, renderKey = JSON.stringify({
active,
alwaysVisible,
entries: entries.map((entry) => {
const history = this.#options.historyEntry(entry.topicId);
return [
entry.topicId,
entry.title,
entry.href,
entry.avatarTemplate,
entry.avatarSource,
entry.ownerUsername,
entry.postNumber,
entry.addedAt,
entry.pinned,
this.#prefetching.has(entry.topicId),
entry.loadState,
entry.loadedCount,
entry.totalCount,
entry.nestedLoadedCount,
entry.nestedTotalCount,
entry.mediaLoadedCount,
entry.mediaTotalCount,
entry.error,
history?.avatarTemplate ?? "",
history?.ownerUsername ?? "",
history?.readPostNumbers.length ?? -1,
history?.postsCount ?? -1
];
})
});
if (renderKey !== this.#renderKey) {
const previousBubbleScrollTop = this.#bubbles.scrollTop, activeChanged = active !== this.#activeTopicId, bubbleShells = this.#bubbleShellsByTopic(), rowAvatars = this.#avatarsByTopic(this.#list);
this.#renderKey = renderKey, this.#syncRailPresence(), this.#rail.classList.toggle("is-empty", !entries.length), this.#count.textContent = `${entries.length} 篇`, this.#resetClearConfirmation(), this.#clear.disabled = !entries.some((entry) => !entry.pinned);
const bubbles = entries.map((entry) => this.#bubbleShell(
entry,
active,
bubbleShells.get(entry.topicId)
));
if (reconcileElementChildren(this.#bubbles, bubbles), this.#list.replaceChildren(
...entries.map((entry) => this.#row(
entry,
active,
rowAvatars.get(entry.topicId)
))
), this.#activeTopicId = active, activeChanged) {
const activeBubble = this.#bubbles.querySelector(
".ldp-reader-queue-bubble.is-active"
);
if (activeBubble) {
const top = activeBubble.offsetTop, bottom = top + activeBubble.offsetHeight;
top < this.#bubbles.scrollTop ? this.#bubbles.scrollTop = Math.max(0, top - 4) : bottom > this.#bubbles.scrollTop + this.#bubbles.clientHeight && (this.#bubbles.scrollTop = Math.max(
0,
bottom - this.#bubbles.clientHeight + 4
));
}
} else
this.#bubbles.scrollTop = Math.min(
previousBubbleScrollTop,
Math.max(
0,
this.#bubbles.scrollHeight - this.#bubbles.clientHeight
)
);
this.#syncScrollHint(), this.#syncToggleState(), this.#scheduleSurfaceMeasure();
}
for (const add of this.#options.document.querySelectorAll(
".ldp-reader-queue-add[data-reader-queue-topic-id]"
)) {
const topicId = (0, import_identifiers.tryDiscourseTopicId)(
add.dataset.readerQueueTopicId
), added = topicId ? this.#entries.has(topicId) : !1, stateChanged = add.getAttribute("aria-pressed") !== String(added), label = added ? "移出阅读队列" : "加入阅读队列并后台预加载", labelChanged = add.dataset.ldpTooltipLabel !== label;
if (stateChanged && (add.classList.toggle("is-added", added), add.setAttribute("aria-pressed", String(added)), add.replaceChildren(icon(
this.#options.document,
added ? "check" : "plus"
))), add.setAttribute("aria-label", label), add.dataset.ldpTooltipLabel = label, labelChanged) {
const EventConstructor = add.ownerDocument.defaultView?.Event ?? Event;
add.dispatchEvent(new EventConstructor(
"ldp-tooltip-refresh",
{ bubbles: !0 }
));
}
}
}
#syncRailPresence() {
const alwaysVisible = this.#options.readPreferences().readerQueueAlwaysVisibleWhenEmpty;
this.#rail.hidden = !this.#entries.size && !alwaysVisible, this.#badge.hidden = this.#entries.size === 0, this.#badge.textContent = this.#entries.size ? String(this.#entries.size) : "", this.#dismiss.setAttribute(
"aria-label",
this.#entries.size ? "收起阅读队列头像" : "隐藏空阅读队列入口"
);
}
downloadCurrentTopic() {
const prepared = this.#downloadManager?.prepareCurrentDownload() ?? !1;
return prepared && this.#options.notify?.("请选择下载范围后开始后台下载"), prepared;
}
openTopicDownloadManager() {
return this.#downloadManager?.openManager() ?? !1;
}
refreshSurface() {
this.#scheduleSurfaceMeasure();
}
resetSurfacePositions() {
this.#reloadStoredEntriesForMutation();
for (const surface of Object.values(this.#surfaces))
surface.x = 0.02, surface.y = 0.12, surface.dock = "left";
this.#persist(), this.#cancelSurfaceFrames(), this.#scheduleSurfaceMeasure();
}
toggle() {
if (!(this.scope.destroyed || this.#rail.hidden)) {
if (this.#rail.classList.contains("is-preview-collapsed") && this.#setPreviewExpanded(!0), this.#panelPinned) {
this.#setPanelOpen(!1);
return;
}
this.#panelPinned = !0, this.#setPanelOpen(!0);
}
}
destroy() {
this.scope.destroy();
}
#row(entry, active, reusableAvatar) {
const document = this.#options.document, row = (0, import_html_element.htmlElement)(document, "article", "ldp-reader-queue-row");
row.dataset.queueOpen = String(entry.topicId), row.tabIndex = 0, row.setAttribute("role", "option"), row.setAttribute("aria-selected", String(entry.topicId === active)), row.classList.toggle("is-active", entry.topicId === active), row.classList.toggle("is-pinned", entry.pinned);
const progress = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-queue-row-progress"), history = this.#options.historyEntry(entry.topicId), progressValue = queueProgress(history);
progress.classList.toggle("is-progress-complete", progressValue >= 100), progress.style.setProperty(
"--ldp-reader-queue-progress",
`${progressValue * 3.6}deg`
), this.#avatar(progress, entry, history, reusableAvatar);
const copy = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-queue-row-copy"), title = (0, import_html_element.htmlElement)(document, "strong");
title.textContent = entry.title;
const status = (0, import_html_element.htmlElement)(document, "small");
status.textContent = queueStatus(
entry,
history,
entry.topicId === active
), copy.append(title, status);
const actions = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-queue-row-actions"), pin = button(
document,
"ldp-reader-queue-pin",
entry.pinned ? "取消固定,离开后自动移出队列" : "固定文章,离开后保留在队列",
"pin"
);
pin.dataset.queuePin = String(entry.topicId), pin.classList.toggle("active", entry.pinned), pin.setAttribute("aria-pressed", String(entry.pinned));
const remove = button(
document,
"ldp-reader-queue-remove",
`从阅读队列移除 ${entry.title}`,
"x"
);
if (remove.dataset.queueRemove = String(entry.topicId), entry.loadState === "error") {
const retry = button(
document,
"ldp-reader-queue-retry",
"重新预加载",
"rotate-ccw"
);
retry.dataset.queueRetry = String(entry.topicId), actions.append(retry);
}
return actions.append(pin, remove), row.append(progress, copy, actions), row;
}
#bubbleShell(entry, active, reusable) {
const document = this.#options.document, shell = reusable ?? (0, import_html_element.htmlElement)(
document,
"span",
"ldp-reader-queue-bubble-shell"
);
shell.className = "ldp-reader-queue-bubble-shell", shell.classList.toggle("is-pinned", entry.pinned);
let bubble = shell.querySelector(
":scope > .ldp-reader-queue-bubble"
);
bubble || (bubble = button(
document,
"ldp-reader-queue-bubble",
entry.title,
"message-square"
)), bubble.className = "ldp-reader-queue-bubble", bubble.dataset.queueOpen = String(entry.topicId), bubble.dataset.readerQueueTopicId = String(entry.topicId), bubble.classList.toggle("is-active", entry.topicId === active), bubble.setAttribute(
"aria-current",
String(entry.topicId === active)
), bubble.classList.add(`is-${entry.loadState}`);
const history = this.#options.historyEntry(entry.topicId), progress = queueProgress(history);
bubble.classList.toggle("is-progress-complete", progress >= 100), bubble.style.setProperty(
"--ldp-reader-queue-progress",
`${progress * 3.6}deg`
);
const label = `${entry.title},${queueStatus(
entry,
history,
entry.topicId === active
)}`;
bubble.setAttribute("aria-label", label), bubble.dataset.ldpTooltipLabel = label, this.#avatar(
bubble,
entry,
history,
bubble.querySelector(".ldp-reader-queue-avatar") ?? void 0
);
let status = bubble.querySelector(":scope > i");
status || (status = (0, import_html_element.htmlElement)(document, "i")), reconcileElementChildren(bubble, [
bubble.querySelector(".ldp-reader-queue-avatar"),
status
]);
let remove = shell.querySelector(
":scope > .ldp-reader-queue-bubble-remove"
);
remove || (remove = button(
document,
"ldp-reader-queue-bubble-remove",
`从阅读队列移除 ${entry.title}`,
"x"
)), remove.setAttribute("aria-label", `从阅读队列移除 ${entry.title}`), remove.dataset.queueRemove = String(entry.topicId);
let pin = shell.querySelector(
":scope > .ldp-reader-queue-bubble-pin"
);
return entry.pinned && !pin && (pin = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-queue-bubble-pin"), pin.append(icon(document, "pin"))), reconcileElementChildren(shell, [
bubble,
remove,
...entry.pinned && pin ? [pin] : []
]), shell;
}
#avatar(host, entry, history, reusableAvatar) {
const fallbackText = (entry.ownerUsername || history?.ownerUsername || entry.title).trim().slice(0, 1).toUpperCase() || "?", template = entry.avatarTemplate || history?.avatarTemplate || "", source = entry.avatarSource || (template ? this.#options.avatarSource?.(template, 64) ?? "" : ""), identity = JSON.stringify([fallbackText, source]);
if (reusableAvatar && this.#avatarIdentity.get(reusableAvatar) === identity) {
reusableAvatar.parentElement !== host && host.replaceChildren(reusableAvatar);
return;
}
const avatar = (0, import_html_element.htmlElement)(
this.#options.document,
"span",
"ldp-reader-queue-avatar"
);
this.#avatarIdentity.set(avatar, identity);
const fallback = (0, import_html_element.htmlElement)(
this.#options.document,
"span",
"ldp-reader-queue-avatar-fallback"
);
if (fallback.textContent = fallbackText, avatar.append(fallback), source) {
const image = (0, import_html_element.htmlElement)(this.#options.document, "img");
image.addEventListener("load", () => {
image.classList.add("is-loaded");
}), image.addEventListener("error", () => {
image.remove();
}), image.src = source, image.alt = "", image.loading = "eager", image.decoding = "async", avatar.append(image);
}
host.replaceChildren(avatar);
}
#avatarsByTopic(container) {
const avatars = /* @__PURE__ */ new Map();
for (const host of container.querySelectorAll(
"[data-queue-open]"
)) {
const topicId = (0, import_identifiers.tryDiscourseTopicId)(host.dataset.queueOpen), avatar = host.querySelector(
".ldp-reader-queue-avatar"
);
topicId && avatar && avatars.set(topicId, avatar);
}
return avatars;
}
#bubbleShellsByTopic() {
const shells = /* @__PURE__ */ new Map();
for (const shell of this.#bubbles.querySelectorAll(
":scope > .ldp-reader-queue-bubble-shell"
)) {
const topicId = (0, import_identifiers.tryDiscourseTopicId)(
shell.querySelector("[data-queue-open]")?.dataset.queueOpen
);
topicId && shells.set(topicId, shell);
}
return shells;
}
#click(event) {
const action = (0, import_event_target.eventElement)(event)?.closest(
"[data-queue-open],[data-queue-pin],[data-queue-remove],[data-queue-retry],.ldp-reader-queue-clear,.ldp-reader-queue-close,.ldp-reader-queue-dismiss,.ldp-reader-queue-toggle,.ldp-reader-queue-scroll-hint"
);
if (!action) return;
if (action === this.#scrollHint) {
const direction = Number(this.#scrollHint.dataset.scrollDirection) || 1, behavior = this.#options.document.defaultView?.matchMedia?.("(prefers-reduced-motion: reduce)").matches === !0 ? "auto" : "smooth";
if (direction < 0)
typeof this.#bubbles.scrollTo == "function" ? this.#bubbles.scrollTo({ top: 0, behavior }) : this.#bubbles.scrollTop = 0;
else {
const distance = Math.max(
38,
Math.round(this.#bubbles.clientHeight * 0.72)
);
typeof this.#bubbles.scrollBy == "function" ? this.#bubbles.scrollBy({ top: distance, behavior }) : this.#bubbles.scrollTop += distance;
}
return;
}
if (action === this.#toggle) {
if (this.#suppressToggleClick) {
this.#suppressToggleClick = !1, this.#closePinnedPanelOnClick = !1;
return;
}
if (this.#closePinnedPanelOnClick) {
this.#closePinnedPanelOnClick = !1;
return;
}
this.toggle();
return;
}
if (action === this.#dismiss) {
this.#setPanelOpen(!1), this.#entries.size ? this.#setPreviewExpanded(!1) : (this.#options.updatePreferences({
readerQueueAlwaysVisibleWhenEmpty: !1
}), this.sync());
return;
}
if (action.classList.contains("ldp-reader-queue-close")) {
this.#setPanelOpen(!1);
return;
}
if ((action === this.#clear || action.dataset.queuePin !== void 0 || action.dataset.queueRemove !== void 0) && this.#reloadStoredEntriesForMutation(), action === this.#clear) {
const removable = [...this.#entries.values()].filter((entry) => !entry.pinned);
if (!removable.length) {
this.#resetClearConfirmation();
return;
}
if (!this.#clearConfirmationPending) {
this.#armClearConfirmation(removable.length), this.#options.notify?.(
`再点一次垃圾桶,移除 ${removable.length} 篇未固定主题`
);
return;
}
this.#resetClearConfirmation();
for (const [topicId, entry] of this.#entries)
entry.pinned || this.#remove(topicId);
this.#persist(), this.sync();
return;
}
const pinId = (0, import_identifiers.tryDiscourseTopicId)(action.dataset.queuePin);
if (pinId) {
const entry = this.#entries.get(pinId);
entry && (entry.pinned = !entry.pinned), this.#persist(), this.sync();
return;
}
const removeId = (0, import_identifiers.tryDiscourseTopicId)(action.dataset.queueRemove);
if (removeId) {
this.#remove(removeId), this.#persist(), this.sync();
return;
}
const retryId = (0, import_identifiers.tryDiscourseTopicId)(action.dataset.queueRetry);
if (retryId) {
this.#queuePrefetch(retryId, !0);
return;
}
const openId = (0, import_identifiers.tryDiscourseTopicId)(action.dataset.queueOpen);
openId && this.#open(openId);
}
#documentClick(event) {
const target = (0, import_event_target.eventElement)(event), add = target?.closest(".ldp-reader-queue-add");
if (add) {
event.preventDefault(), event.stopPropagation();
const topicId2 = (0, import_identifiers.tryDiscourseTopicId)(add.dataset.readerQueueTopicId);
if (!topicId2) return;
if (this.#reloadStoredEntriesForMutation(), this.#entries.has(topicId2)) this.#remove(topicId2);
else {
const href = String(add.dataset.readerQueueHref ?? `/t/${topicId2}`);
this.#entries.set(topicId2, {
topicId: topicId2,
title: String(add.dataset.readerQueueTitle ?? `帖子 #${topicId2}`),
href,
avatarTemplate: String(
add.dataset.readerQueueAvatarTemplate ?? ""
),
avatarSource: String(add.dataset.readerQueueAvatar ?? ""),
ownerUsername: String(add.dataset.readerQueueOwner ?? ""),
postNumber: (0, import_reader_userscript_target_adapter.parseReaderUserscriptTopicRoute)(
href,
this.#options.document.baseURI
)?.postNumber ?? null,
addedAt: Date.now(),
pinned: !1,
loadState: "queued",
loadedCount: 0,
totalCount: 0,
nestedLoadedCount: 0,
nestedTotalCount: 0,
mediaLoadedCount: 0,
mediaTotalCount: 0,
error: ""
}), this.#queuePrefetch(topicId2);
}
this.#persist(), this.sync();
return;
}
const native = target?.closest(".ldp-native-reader-trigger");
if (!native) return;
event.preventDefault();
const button2 = native, topicId = (0, import_identifiers.tryDiscourseTopicId)(button2.dataset.topicId);
topicId && this.#open(
topicId,
button2.dataset.triggerSource === "history" ? null : (0, import_identifiers.tryDiscoursePostNumber)(button2.dataset.postNumber),
button2.dataset.triggerSource === "route" ? "link" : "restore"
);
}
#keydown(event) {
const target = (0, import_event_target.eventElement)(event);
if (target === this.#toggle && event.key === "ArrowDown") {
event.preventDefault(), this.#setPanelOpen(!0), this.#list.querySelector(
".ldp-reader-queue-row[data-queue-open]"
)?.focus();
return;
}
const queueRow = target?.closest(
".ldp-reader-queue-row[data-queue-open]"
);
if (queueRow && this.#panel.contains(queueRow)) {
const rows = [...this.#panel.querySelectorAll(
".ldp-reader-queue-row[data-queue-open]"
)], index = rows.indexOf(queueRow);
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const topicId = (0, import_identifiers.tryDiscourseTopicId)(queueRow.dataset.queueOpen);
topicId && this.#open(topicId);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const offset = event.key === "ArrowDown" ? 1 : -1;
rows[(index + offset + rows.length) % rows.length]?.focus();
return;
}
}
if (!(event.key !== "Escape" || event.defaultPrevented)) {
if (event.repeat) {
if (this.#options.readerLightboxOpen?.()) return;
event.preventDefault(), event.stopImmediatePropagation();
return;
}
if (!readerActionLayerOwnsEscape(event) && !this.#options.composerOpen() && !this.#options.readerSurfaceOpen?.()) {
if (this.#panelOpen) {
event.preventDefault(), event.stopImmediatePropagation(), this.#setPanelOpen(!1), this.#toggle.focus();
return;
}
if (!(target?.matches?.(
'input,textarea,select,[contenteditable="true"]'
) || !this.#options.currentTopicId())) {
if (this.#options.closeExpandedReply?.()) {
event.preventDefault(), event.stopImmediatePropagation();
return;
}
if (this.#options.readPreferences().doubleEscapeToCloseReader && !this.#closeGate.confirm("reader:escape")) {
event.preventDefault(), event.stopImmediatePropagation(), this.#options.notify?.("再按一次 Esc 关闭阅读器");
return;
}
event.preventDefault(), event.stopImmediatePropagation(), this.#options.closeReader();
}
}
}
}
#drag(event) {
if (this.#reloadStoredEntriesForMutation(), event.button !== 0) return;
this.#cancelPanelPreview(), this.#closePinnedPanelOnClick = this.#panelPinned, this.#setPanelOpen(!1), this.#cancelSurfaceFrames();
const geometry = this.#measureSurface(), { rail: start, parent } = geometry, offsetX = event.clientX - start.left, offsetY = event.clientY - start.top, originX = event.clientX, originY = event.clientY;
let moved = !1;
this.#suppressToggleClick = !1, this.#toggle.setPointerCapture?.(event.pointerId);
const move = (next) => {
if (next.pointerId !== event.pointerId) return;
if (!moved) {
if (Math.hypot(
next.clientX - originX,
next.clientY - originY
) < 5) return;
moved = !0, this.#dragging = !0, this.#suppressToggleClick = !0, this.#closePinnedPanelOnClick = !1, this.#setPanelOpen(!1), this.#rail.classList.remove(
"is-docked-left",
"is-docked-right",
"is-docked-top",
"is-docked-bottom",
"is-docked-title",
"is-dock-revealed"
), this.#dragGeometry = geometry, this.#rail.classList.add("is-dragging");
}
const width = Math.max(1, parent.width - start.width), height = Math.max(1, parent.height - start.height);
this.#surface.x = Math.min(
1,
Math.max(0, (next.clientX - parent.left - offsetX) / width)
), this.#surface.y = Math.min(
1,
Math.max(0, (next.clientY - parent.top - offsetY) / height)
);
const railLeft = this.#surface.x * width, railTop = this.#surface.y * height, edgeDistance = READER_QUEUE_DOCK_THRESHOLD_PX, titleTop = geometry.header ? geometry.header.bottom - parent.top + parent.height * 8e-3 : Number.POSITIVE_INFINITY, titleLeft = geometry.action ? geometry.action.left + geometry.action.width / 2 - parent.left - start.width / 2 : Number.POSITIVE_INFINITY;
this.#surface.dock = Math.abs(railLeft - titleLeft) <= edgeDistance && Math.abs(railTop - titleTop) <= edgeDistance ? "title" : railLeft <= edgeDistance ? "left" : width - railLeft <= edgeDistance ? "right" : railTop <= edgeDistance ? "top" : height - railTop <= edgeDistance ? "bottom" : "", this.#scheduleDragProjection();
};
let cleanup = () => {
};
const finish = (next) => {
next.pointerId === event.pointerId && (cleanup(), (moved || next.type !== "pointerup") && (this.#closePinnedPanelOnClick = !1), this.#dragging = !1, this.#rail.classList.remove("is-dragging"), this.#toggle.hasPointerCapture?.(event.pointerId) && this.#toggle.releasePointerCapture?.(event.pointerId), moved && (this.#persist(), this.#scheduleSurfaceMeasure()));
};
cleanup = this.scope.add(() => {
this.#options.document.removeEventListener("pointermove", move), this.#options.document.removeEventListener("pointerup", finish), this.#options.document.removeEventListener("pointercancel", finish), this.#toggle.removeEventListener("lostpointercapture", finish), this.#dragging = !1, this.#rail.classList.remove("is-dragging"), this.#dragGeometry = null;
}), this.#options.document.addEventListener("pointermove", move), this.#options.document.addEventListener("pointerup", finish), this.#options.document.addEventListener("pointercancel", finish), this.#toggle.addEventListener("lostpointercapture", finish);
}
#setPanelOpen(open) {
this.#cancelPanelPreview(), this.#cancelPanelClose(), open || (this.#panelPinned = !1), this.#panelOpen = open, this.#rail.classList.toggle("is-dock-revealed", open), this.#panel.hidden = !open, this.#toggle.setAttribute("aria-expanded", String(open)), this.#syncToggleState(), open && this.#scheduleSurfaceMeasure();
}
#setPreviewExpanded(expanded) {
this.#rail.classList.toggle("is-preview-collapsed", !expanded), this.#syncToggleState(), expanded && this.#requestFrame(() => this.#syncScrollHint()), this.#scheduleSurfaceMeasure();
}
#cancelPanelPreview() {
this.#hoverOpenTimer && clearTimeout(this.#hoverOpenTimer), this.#hoverOpenTimer = 0;
}
#schedulePanelPreview(event) {
this.#cancelPanelPreview(), !(event.pointerType === "touch" || this.#dragging || this.#panelOpen || this.scope.destroyed) && (this.#hoverOpenTimer = setTimeout(() => {
this.#hoverOpenTimer = 0, !(this.#dragging || this.scope.destroyed) && this.#setPanelOpen(!0);
}, READER_QUEUE_PANEL_SHOW_DELAY_MS));
}
#cancelPanelClose() {
this.#hoverCloseTimer && clearTimeout(this.#hoverCloseTimer), this.#hoverCloseTimer = 0;
}
#schedulePanelClose() {
if (this.#cancelPanelClose(), this.#panelPinned) return;
const active = (0, import_event_target.deepActiveElement)(this.#options.document);
active && this.#panel.contains(active) || (this.#hoverCloseTimer = setTimeout(() => {
this.#hoverCloseTimer = 0, !this.#panelPinned && this.#setPanelOpen(!1);
}, READER_QUEUE_PANEL_HIDE_GRACE_MS));
}
#armClearConfirmation(count) {
this.#resetClearConfirmation(), this.#clearConfirmationPending = !0, this.#clear.classList.add("is-confirming");
const label = `确认移除 ${count} 篇未固定主题`;
this.#clear.setAttribute("aria-label", label), this.#clear.dataset.ldpTooltipLabel = label, this.#clearConfirmTimer = setTimeout(() => {
this.#clearConfirmTimer = 0, this.#resetClearConfirmation();
}, READER_QUEUE_CLEAR_CONFIRM_MS);
}
#resetClearConfirmation() {
this.#clearConfirmTimer && clearTimeout(this.#clearConfirmTimer), this.#clearConfirmTimer = 0, this.#clearConfirmationPending = !1, this.#clear.classList.remove("is-confirming"), this.#clear.setAttribute("aria-label", "移除未固定主题"), delete this.#clear.dataset.ldpTooltipLabel;
}
#syncToggleState() {
this.#toggle.setAttribute("aria-pressed", String(this.#panelPinned));
const action = this.#rail.classList.contains("is-preview-collapsed") ? "展开收纳箱" : this.#panelPinned ? "关闭收纳箱" : this.#panelOpen ? "固定打开收纳箱" : "打开收纳箱";
this.#toggle.setAttribute(
"aria-label",
this.#entries.size ? `${action};长按拖动可移动,贴边可隐藏;悬停可预览,共 ${this.#entries.size} 篇` : this.#downloadManager ? `${action},可下载或管理当前 Topic;队列 0 篇` : `${action};当前 0 篇`
);
}
#syncScrollHint() {
const maxScroll = Math.max(
0,
this.#bubbles.scrollHeight - this.#bubbles.clientHeight
), hasOverflow = maxScroll >= 2;
if (this.#scrollHint.hidden = !hasOverflow, !hasOverflow) return;
const scrollUp = this.#bubbles.scrollTop >= maxScroll - 2;
this.#scrollHint.classList.toggle("is-up", scrollUp);
const iconName = scrollUp ? "chevron-up" : "chevron-down";
this.#scrollHint.querySelector(`.ldp-icon-${iconName}`) || this.#scrollHint.replaceChildren(icon(
this.#options.document,
iconName
)), this.#scrollHint.dataset.scrollDirection = scrollUp ? "-1" : "1", this.#scrollHint.setAttribute(
"aria-label",
scrollUp ? "回到队列上方头像" : "显示下方更多队列头像"
);
}
#observeSurfaceElement(element) {
!element || !this.#resizeObserver || this.#observedSurfaceElements.has(element) || (this.#observedSurfaceElements.add(element), this.#resizeObserver.observe(element));
}
#cancelSurfaceFrames() {
this.#surfaceFrame && this.#cancelFrame(this.#surfaceFrame), this.#dragFrame && this.#cancelFrame(this.#dragFrame), this.#surfaceFrame = 0, this.#dragFrame = 0;
}
#scheduleSurfaceMeasure() {
if (this.scope.destroyed || this.#dragging || this.#surfaceFrame) return;
let completed = !1;
const frame = this.#requestFrame(() => {
completed = !0, this.#surfaceFrame = 0, !(this.scope.destroyed || this.#dragging) && this.#projectSurface(this.#measureSurface());
});
completed || (this.#surfaceFrame = frame);
}
#scheduleDragProjection() {
if (this.#dragFrame || !this.#dragGeometry) return;
let completed = !1;
const frame = this.#requestFrame(() => {
completed = !0, this.#dragFrame = 0;
const geometry = this.#dragGeometry;
!geometry || this.scope.destroyed || this.#projectSurface(geometry);
});
completed || (this.#dragFrame = frame);
}
#measureSurface() {
const header = this.#options.root.querySelector(
":scope > .ldp-header"
), action = this.#options.root.querySelector(
".ldp-topic-action-rail:not([hidden])"
);
return this.#observeSurfaceElement(header), this.#observeSurfaceElement(action), Object.freeze({
parent: this.#options.root.getBoundingClientRect(),
rail: this.#rail.getBoundingClientRect(),
toggle: this.#toggle.getBoundingClientRect(),
header: header?.getBoundingClientRect() ?? null,
action: action?.getBoundingClientRect() ?? null
});
}
#projectSurface(geometry) {
this.#rail.classList.toggle(
"is-docked-left",
this.#surface.dock === "left"
), this.#rail.classList.toggle(
"is-docked-right",
this.#surface.dock === "right"
);
for (const dock of ["top", "bottom", "title"])
this.#rail.classList.toggle(
`is-docked-${dock}`,
this.#surface.dock === dock
);
this.#rail.classList.add("is-runtime-positioned");
const { parent, rail, toggle, header, action } = geometry, previewHeight = Math.min(
this.#bubbles.scrollHeight,
this.#bubbles.clientHeight > 0 ? this.#bubbles.clientHeight : this.#bubbles.scrollHeight
), spaceBelow = parent.bottom - toggle.bottom;
this.#rail.classList.toggle(
"is-preview-reversed",
!this.#rail.classList.contains("is-preview-collapsed") && spaceBelow < previewHeight && toggle.top - parent.top > spaceBelow
);
const availableWidth = Math.max(1, parent.width - rail.width), availableHeight = Math.max(1, parent.height - rail.height);
let x = this.#surface.x, y = this.#surface.y;
if (parent.width > 0 && parent.height > 0) {
if (this.#surface.dock === "left" && (x = 0), this.#surface.dock === "right" && (x = 1), header && header.height > 0) {
const headerDockY = Math.min(
1,
(header.bottom - parent.top + parent.height * 8e-3) / availableHeight
);
this.#surface.dock === "top" || this.#surface.dock === "title" ? y = headerDockY : this.#surface.dock !== "bottom" && (y = Math.max(y, headerDockY));
}
if (this.#surface.dock === "bottom" && (y = 1), this.#surface.dock === "title" && action && action.width > 0)
x = Math.min(1, Math.max(
0,
(action.left + action.width / 2 - parent.left - rail.width / 2) / availableWidth
));
else if (this.#surface.dock !== "left" && this.#surface.dock !== "right" && action && action.width > 0 && action.height > 0) {
const left = parent.left + x * availableWidth, top = parent.top + y * availableHeight, right = left + rail.width, bottom = top + rail.height;
if (left < action.right && right > action.left && top < action.bottom && bottom > action.top) {
const gap = parent.width * 8e-3;
x = action.left + action.width / 2 < parent.left + parent.width / 2 ? (action.right - parent.left + gap) / availableWidth : (action.left - parent.left - rail.width - gap) / availableWidth, x = Math.min(1, Math.max(0, x));
}
}
}
this.#rail.style.left = `${x * availableWidth / Math.max(1, parent.width) * 100}%`, this.#rail.style.top = `${y * availableHeight / Math.max(1, parent.height) * 100}%`, this.#projectPanel(
geometry,
parent.left + x * availableWidth,
parent.top + y * availableHeight
);
}
#projectPanel(geometry, railLeft, railTop) {
if (!this.#panelOpen || this.#panel.hidden) return;
const panelRect = this.#panel.getBoundingClientRect(), panelWidth = Math.max(
Number(this.#panel.offsetWidth) || 0,
panelRect.width
), panelHeight = Math.max(
Number(this.#panel.offsetHeight) || 0,
panelRect.height
);
if (!(panelWidth > 0) || !(panelHeight > 0)) return;
const { parent, rail, header } = geometry, gap = 10, clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value)), minimumLeft = parent.left + gap, maximumLeft = Math.max(
minimumLeft,
parent.right - gap - panelWidth
), minimumTop = parent.top + gap, maximumTop = Math.max(
minimumTop,
parent.bottom - gap - panelHeight
), railRight = railLeft + rail.width, leftSpace = railLeft - minimumLeft - gap, rightSpace = parent.right - gap - railRight - gap, openLeft = this.#surface.dock === "right" || rightSpace < panelWidth && leftSpace > rightSpace, left = clamp(
openLeft ? railLeft - gap - panelWidth : railRight + gap,
minimumLeft,
maximumLeft
);
let top = clamp(railTop, minimumTop, maximumTop);
const blockers = [
header,
Object.freeze({
left: railLeft,
right: railRight,
top: railTop,
bottom: railTop + rail.height
})
].filter((blocker) => blocker !== null);
for (const blocker of blockers) {
if (left >= blocker.right + gap || left + panelWidth <= blocker.left - gap || top >= blocker.bottom + gap || top + panelHeight <= blocker.top - gap) continue;
const below = clamp(blocker.bottom + gap, minimumTop, maximumTop);
top = below + panelHeight <= parent.bottom - gap ? below : clamp(blocker.top - gap - panelHeight, minimumTop, maximumTop);
}
this.#panel.classList.add("is-collision-positioned"), this.#panel.style.left = `${Math.round(left - railLeft)}px`, this.#panel.style.top = `${Math.round(top - railTop)}px`;
}
async #open(topicId, preferredPostNumber = null, source = "restore") {
const entry = this.#entries.get(topicId), history = this.#options.historyEntry(topicId), anchor = this.#options.historyAnchor(topicId), current = this.#options.currentTopicId(), previous = current ? this.#entries.get(current) : null, postNumber = preferredPostNumber ?? (source === "restore" && (anchor !== null || history !== null) ? null : anchor?.viewport.postNumber ?? history?.postNumber ?? entry?.postNumber) ?? null;
try {
const result = await this.#options.target.openTarget({
topicId,
...postNumber ? { postNumber } : {},
source
});
if (result.topic.status === "failed")
throw result.topic.cause ?? new Error(`Reader 目标 Topic ${topicId} 打开失败`);
if (result.topic.status !== "opened" && result.topic.status !== "reused") return;
source === "restore" && anchor && await this.#options.restoreHistoryAnchor(topicId, anchor), previous && previous.topicId !== topicId && !previous.pinned && (this.#reloadStoredEntriesForMutation(), this.#entries.delete(previous.topicId), this.#persist()), this.sync();
} catch (error) {
this.#options.notify?.(`主题 #${topicId} 打开失败:${String(error)}`);
}
}
#queuePrefetch(topicId, retry = !1) {
const entry = this.#entries.get(topicId);
if (!entry || this.#prefetching.has(topicId)) return;
retry && (entry.error = ""), entry.loadState = "loading", entry.loadedCount = 0, entry.totalCount = 0, entry.nestedLoadedCount = 0, entry.nestedTotalCount = 0, entry.mediaLoadedCount = 0, entry.mediaTotalCount = 0, this.#prefetching.add(topicId);
const controller = new AbortController();
this.#prefetchControllers.set(topicId, controller), this.sync(), this.#prefetchTail = this.#prefetchTail.catch(() => {
}).then(() => this.#options.prefetch(
topicId,
entry.postNumber,
controller.signal,
(progress) => {
controller.signal.aborted || this.#entries.get(topicId) !== entry || (this.#applyPrefetchProgress(entry, progress), this.#scheduleSync());
}
)).then((result) => {
controller.signal.aborted || this.#entries.get(topicId) !== entry || (entry.loadedCount = Math.max(0, Math.floor(result.loadedCount)), entry.totalCount = Math.max(0, Math.floor(result.totalCount)), entry.nestedLoadedCount = Math.max(
0,
Math.floor(result.nestedLoadedCount ?? 0)
), entry.nestedTotalCount = Math.max(
0,
Math.floor(result.nestedTotalCount ?? 0)
), entry.mediaLoadedCount = Math.max(
0,
Math.floor(result.mediaLoadedCount ?? 0)
), entry.mediaTotalCount = Math.max(
0,
Math.floor(result.mediaTotalCount ?? 0)
), entry.loadState = result.complete ? "ready" : "partial", entry.error = "");
}).catch((error) => {
controller.signal.aborted || this.#entries.get(topicId) !== entry || (entry.loadState = "error", entry.error = String(error), this.#options.notify?.(
`主题 #${topicId} 预加载失败:${String(error)}`
));
}).finally(() => {
this.#prefetchControllers.get(topicId) === controller && (this.#prefetchControllers.delete(topicId), this.#prefetching.delete(topicId)), this.scope.destroyed || this.sync();
});
}
#applyPrefetchProgress(entry, progress) {
for (const key of [
"loadedCount",
"totalCount",
"nestedLoadedCount",
"nestedTotalCount",
"mediaLoadedCount",
"mediaTotalCount"
]) {
const value = progress[key];
value !== void 0 && (entry[key] = Math.max(0, Math.floor(value)));
}
}
#scheduleSync() {
if (this.scope.destroyed || this.#syncFrame) return;
let completed = !1;
const frame = this.#requestFrame(() => {
completed = !0, this.#syncFrame = 0, this.scope.destroyed || this.sync();
});
completed || (this.#syncFrame = frame);
}
#remove(topicId) {
this.#prefetchControllers.get(topicId)?.abort(
new DOMException(`主题 ${topicId} 已移出阅读队列`, "AbortError")
), this.#prefetchControllers.delete(topicId), this.#prefetching.delete(topicId), this.#entries.delete(topicId);
}
#queueScan() {
this.#scanQueued || (this.#scanQueued = !0, queueMicrotask(() => {
this.#scanQueued = !1, this.scope.destroyed || this.#scan();
}));
}
#scan() {
const document = this.#options.document;
for (const row of document.querySelectorAll(TOPIC_ROW)) {
if (row.querySelector(".ldp-reader-queue-add")) continue;
const link = row.querySelector(TOPIC_LINK), route = link && (0, import_reader_userscript_target_adapter.parseReaderUserscriptTopicRoute)(
link.href || link.getAttribute("href") || "",
document.baseURI
);
if (!link || !route || route.bypassReader) continue;
const add = button(
document,
"ldp-reader-queue-add",
"加入阅读队列并后台预加载",
"plus"
);
add.dataset.ldpTooltipLabel = "加入阅读队列并后台预加载", add.dataset.readerQueueTopicId = String(route.topicId), add.dataset.readerQueueHref = route.href, add.dataset.readerQueueTitle = String(link.textContent ?? "").replace(/\s+/g, " ").trim() || `帖子 #${route.topicId}`;
const avatar = row.querySelector("img.avatar");
add.dataset.readerQueueAvatarTemplate = avatar?.dataset.ldpAvatarOriginalTemplate || avatar?.dataset.ldpAvatarTemplate || avatar?.dataset.avatarTemplate || "", add.dataset.readerQueueAvatar = avatar?.currentSrc || avatar?.src || "", add.dataset.readerQueueOwner = row.querySelector("[data-user-card]")?.dataset.userCard ?? "", link.after(add);
}
const currentUser = document.querySelector(
".d-header-icons .current-user:has(img.avatar)"
);
if (currentUser) {
let item = currentUser.querySelector(
".ldp-native-reader-trigger-item"
), trigger = item?.querySelector(
".ldp-native-reader-trigger"
) ?? null;
(!item || !trigger) && (item?.remove(), item = (0, import_html_element.htmlElement)(document, "span", "ldp-native-reader-trigger-item"), trigger = button(
document,
"ldp-native-reader-trigger",
"打开阅读器",
"maximize-2"
), item.append(trigger), currentUser.append(item)), this.#nativeTriggerItem = item, this.#nativeTriggerButton = trigger;
}
this.sync();
}
#syncNativeReaderTrigger() {
const document = this.#options.document, item = this.#nativeTriggerItem, button2 = this.#nativeTriggerButton, avatarHost = document.querySelector(
".d-header-icons .current-user:has(img.avatar)"
);
if (!item || !button2 || !avatarHost || this.#options.readerOpen()) {
item && (item.hidden = !0), document.documentElement.classList.remove(
"ldp-native-reader-trigger-visible"
);
return;
}
item.parentElement !== avatarHost && avatarHost.append(item);
const route = (0, import_reader_userscript_target_adapter.parseReaderUserscriptTopicRoute)(
document.location?.href ?? document.baseURI,
document.baseURI
), routeTarget = route && !route.bypassReader ? Object.freeze({
topicId: route.topicId,
postNumber: this.#options.readPreferences().openTopicsAtFirstPost ? (0, import_identifiers.tryDiscoursePostNumber)(1) : route.postNumber,
source: "route",
title: ""
}) : null, history = routeTarget ? null : this.#options.historyEntry(), queue = routeTarget || history ? null : this.#entries.values().next().value, target = routeTarget ?? (history ? Object.freeze({
topicId: history.topicId,
postNumber: null,
source: "history",
title: history.title
}) : queue ? Object.freeze({
topicId: queue.topicId,
postNumber: queue.postNumber,
source: "queue",
title: queue.title
}) : null);
item.hidden = !1, button2.hidden = !1, button2.dataset.topicId = target ? String(target.topicId) : "", button2.dataset.postNumber = target?.postNumber ? String(target.postNumber) : "", button2.dataset.triggerSource = target?.source ?? "empty-history";
const label = target ? target.source === "history" ? target.title ? `打开历史首项:${target.title}` : "打开历史首项" : target.source === "queue" ? target.title ? `打开队列首项:${target.title}` : "打开队列首项" : target.postNumber ? `从 #${target.postNumber} 打开浮窗阅读器` : "打开浮窗阅读器" : "暂无浏览历史";
button2.setAttribute("aria-label", label), button2.dataset.ldpTooltipLabel = label, document.documentElement.classList.add(
"ldp-native-reader-trigger-visible"
);
}
#restore() {
try {
const stored = this.#accountStorage ? (0, import_reader_account_scoped_storage.readReaderAccountScopedString)(
this.#options.storage,
this.#accountStorage
) : this.#options.storage.getItem(this.#storageKey), value = JSON.parse(
stored ?? "null"
), source = Array.isArray(value) ? value : value && typeof value == "object" ? value.entries : [], entries = Array.isArray(source) ? source.map((entry) => normalizedEntry(entry, this.#options.document.baseURI)).filter((entry) => entry !== null) : [], unique = new Map(entries.map((entry) => [entry.topicId, entry])), record = value && !Array.isArray(value) && typeof value == "object" ? value : {}, surfaces = normalizedSurfaces(
record.surfaces,
record.surface
);
return { entries: [...unique.values()], surfaces };
} catch {
return { entries: [], surfaces: normalizedSurfaces(null, null) };
}
}
#reloadStoredEntriesForMutation() {
if (this.scope.destroyed) return;
const restored = this.#restore();
this.#entries.clear();
for (const entry of restored.entries) this.#entries.set(entry.topicId, entry);
}
#persist() {
try {
const entries = [...this.#entries.values()];
if (!entries.length && Object.values(this.#surfaces).every(defaultSurface) && this.#options.storage.removeItem && !this.#accountStorage) {
this.#options.storage.removeItem(this.#storageKey);
return;
}
this.#options.storage.setItem(
this.#storageKey,
JSON.stringify({
version: 2,
entries,
surfaces: this.#surfaces
})
);
} catch (error) {
this.#options.notify?.(
`阅读队列保存失败:${String(error)}`
);
}
}
}
}, "afd82564a21c347c6f3c72f83d364486a2b444017c4209c0ca6ce08336863f1c");
/* Source: lite/src/queue/reader-topic-download-manager.ts */
runtime.register("src/queue/reader-topic-download-manager.js", function(module, exports, require) {
var reader_topic_download_manager_exports = {};
__export(reader_topic_download_manager_exports, {
READER_TOPIC_DOWNLOAD_WINDOW_GEOMETRY_STORAGE_KEY: () => READER_TOPIC_DOWNLOAD_WINDOW_GEOMETRY_STORAGE_KEY,
ReaderTopicDownloadManager: () => ReaderTopicDownloadManager,
parseReaderTopicDownloadPostSelection: () => parseReaderTopicDownloadPostSelection,
readerTopicDownloadCoverage: () => readerTopicDownloadCoverage,
readerTopicDownloadLocalArchivePlan: () => readerTopicDownloadLocalArchivePlan,
selectReaderTopicDownloadPosts: () => selectReaderTopicDownloadPosts
});
module.exports = __toCommonJS(reader_topic_download_manager_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_reader_collection_floating_window = require("../collection/reader-collection-floating-window.js"), import_reader_topic_offline_document = require("../archive/reader-topic-offline-document.js"), import_event_target = require("../dom/event-target.js"), import_html_element = require("../dom/html-element.js"), import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_reader_floating_window_frame = require("../shell/reader-floating-window-frame.js"), import_reader_escape_surface = require("../shell/reader-escape-surface.js"), import_reader_select_surface = require("../shell/reader-select-surface.js");
const MAX_CUSTOM_POST_NUMBERS = 1e5, DOWNLOAD_HISTORY_PAGE_SIZE = 8, DOWNLOAD_REQUEST_AUTO_RESUME_LIMIT = 8, DOWNLOAD_CHALLENGE_AUTO_RESUME_LIMIT = 1, READER_TOPIC_DOWNLOAD_WINDOW_GEOMETRY_STORAGE_KEY = import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY, ALL_POSTS_SELECTION = Object.freeze({
mode: "all",
expression: "",
postNumbers: Object.freeze([])
});
function parseReaderTopicDownloadPostSelection(rawValue) {
const value = String(rawValue);
if (!value) throw new Error("请输入楼层,例如 1,3,8-12");
if (/[^0-9,-]/.test(value))
throw new Error("仅支持数字、英文逗号 , 和连字符 -");
const selected = /* @__PURE__ */ new Set();
for (const token of value.split(",")) {
if (!token) throw new Error("楼层列表中存在空项");
const single = /^(\d+)$/.exec(token), range = /^(\d+)-(\d+)$/.exec(token);
if (!single && !range) throw new Error(`无法识别楼层“${token}”`);
const start = Number(single?.[1] ?? range?.[1]), end = Number(single?.[1] ?? range?.[2]);
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) throw new Error(`楼层范围“${token}”无效`);
for (let postNumber = start; postNumber <= end; postNumber += 1) {
if (!selected.has(postNumber) && selected.size >= MAX_CUSTOM_POST_NUMBERS)
throw new Error(`自定义楼层最多选择 ${MAX_CUSTOM_POST_NUMBERS} 层`);
selected.add(postNumber);
}
}
return Object.freeze([...selected].sort((left, right) => left - right));
}
function normalizedSelection(selection = ALL_POSTS_SELECTION) {
if (selection.mode === "all") return ALL_POSTS_SELECTION;
if (selection.mode === "op")
return Object.freeze({
mode: "op",
expression: "",
postNumbers: Object.freeze([])
});
const expression = String(selection.expression);
return Object.freeze({
mode: "custom",
expression,
postNumbers: parseReaderTopicDownloadPostSelection(expression)
});
}
function sameSelection(left, right) {
return left.mode !== right.mode ? !1 : left.mode !== "custom" ? !0 : left.postNumbers.length === right.postNumbers.length && left.postNumbers.every((postNumber, index) => postNumber === right.postNumbers[index]);
}
function selectionLabel(selection) {
return selection.mode === "all" ? "全部楼层" : selection.mode === "op" ? "只看楼主" : `自定义 ${selection.expression}`;
}
function readerTopicDownloadLocalArchivePlan(input) {
const topicStatus = Number(input.topicStatus), completed = Math.max(0, Math.floor(Number(input.cachedPostCount) || 0));
if (![403, 404, 410].includes(topicStatus) || completed < 1) return null;
const total = Math.max(
completed,
Math.max(0, Math.floor(Number(input.expectedPostCount) || 0)),
Math.max(0, Math.floor(Number(input.streamPostCount) || 0))
), missingCanonicalPostCount = Math.max(
Math.max(0, Math.floor(Number(input.missingStreamPostCount) || 0)),
total - completed
);
return Object.freeze({
completed,
total,
missingCanonicalPostCount,
streamComplete: input.streamComplete === !0 && missingCanonicalPostCount === 0
});
}
function readerTopicDownloadCoverage(input) {
const missingCanonicalPostCount = Math.max(
0,
Math.floor(Number(input.missingCanonicalPostCount) || 0)
);
if (!input.streamComplete && !input.archived)
throw new Error(input.selectionMode === "all" ? `全帖正文尚未补齐:缺少 ${missingCanonicalPostCount} 个 canonical 楼层` : `所选楼层的正文上下文尚未补齐:缺少 ${missingCanonicalPostCount} 个 canonical 楼层`);
const warnings = [];
return input.streamComplete || warnings.push(missingCanonicalPostCount > 0 ? `仅保留当前可用正文,缺少 ${missingCanonicalPostCount} 个楼层` : "仅保留当前可用正文,完整性未能确认"), input.repliesComplete || warnings.push("正文已补齐,部分回复关系无法确认"), Object.freeze({
complete: input.streamComplete && input.repliesComplete,
warning: warnings.join(";")
});
}
function selectReaderTopicDownloadPosts(availablePosts, selection, ownerUsername = "") {
const selected = normalizedSelection(selection);
if (selected.mode === "all")
return Object.freeze({
posts: Object.freeze([...availablePosts]),
mainPostNumbers: null,
expectedPostCount: availablePosts.length,
filenameScope: ""
});
if (selected.mode === "op") {
const starter = availablePosts.find((post) => Number(post.post_number) === 1), opUsername = String(ownerUsername || starter?.username || "").trim();
if (!opUsername) throw new Error("无法识别 Topic 楼主 OP");
const anchors2 = Object.freeze(availablePosts.filter((post) => String(post.username ?? "") === opUsername));
if (!anchors2.length) throw new Error("所选下载范围没有可用正文");
return Object.freeze({
posts: Object.freeze([...availablePosts]),
mainPostNumbers: Object.freeze(anchors2.map((post) => Number(post.post_number))),
expectedPostCount: anchors2.length,
filenameScope: "op"
});
}
const selectedNumbers = new Set(selected.postNumbers), anchors = Object.freeze(availablePosts.filter((post) => selectedNumbers.has(Number(post.post_number)))), foundNumbers = new Set(anchors.map((post) => Number(post.post_number))), missing = selected.postNumbers.filter((postNumber) => !foundNumbers.has(postNumber));
if (missing.length)
throw new Error(`未找到自定义楼层:${missing.slice(0, 12).join(",")}`);
if (!anchors.length) throw new Error("所选下载范围没有可用正文");
return Object.freeze({
posts: Object.freeze([...availablePosts]),
mainPostNumbers: Object.freeze(anchors.map((post) => Number(post.post_number))),
expectedPostCount: selected.postNumbers.length,
filenameScope: `floors-${selected.expression}`.replace(/[,,\s]+/g, "_").replace(/[^0-9_-]/g, "").slice(0, 64)
});
}
function restoredSelection(entry) {
try {
return normalizedSelection({
mode: entry.selectionMode ?? "all",
expression: entry.selectionExpression ?? "",
postNumbers: Object.freeze([])
});
} catch {
return ALL_POSTS_SELECTION;
}
}
function button(document, className, label, iconName) {
const result = (0, import_html_element.htmlElement)(document, "button", className);
return result.type = "button", result.setAttribute("aria-label", label), result.append((0, import_reader_icon.createReaderIcon)(document, iconName)), result;
}
function phaseLabel(task) {
const scope = selectionLabel(task.selection), withProgress = (label) => {
if (task.total < 1) return label;
const percentage = Math.min(
100,
Math.max(0, Math.round(task.completed / task.total * 100))
);
return `${label} · ${percentage}%`;
};
return task.phase === "queued" ? `等待后台下载 · ${scope}` : task.phase === "loading-topic" ? task.detail || "正在读取 Topic" : task.phase === "loading-posts" ? withProgress(
task.detail || `正在检查缓存并补齐正文 ${task.completed}/${task.total || "?"}`
) : task.phase === "loading-replies" ? withProgress(task.detail || "正在检查回复关系") : task.phase === "waiting-rate-limit" ? withProgress(
task.detail || "遇到 HTTP 429,已保存断点并等待自动续传"
) : task.phase === "waiting-challenge" ? withProgress(
task.detail || "等待 Cloudflare 验证通过后自动续传"
) : task.phase === "serializing" ? task.detail || "正在生成离线 HTML" : task.phase === "ready" ? task.detail ? task.detail : task.complete ? `已完成 · ${task.completed}/${task.total} 楼 · ${scope}` : `已导出可用存档 · ${task.completed}/${task.total || "?"} 楼 · ${scope}` : task.phase === "cancelled" ? "已取消" : task.error ? `失败 · ${task.error}` : "下载失败";
}
function normalizedArchiveStatus(value) {
const status = Number(value);
return status === 403 || status === 404 || status === 410 ? status : null;
}
class ReaderTopicDownloadManager {
scope;
changes = new import_signal.Signal();
windowGeometry;
windowPointer;
#options;
#now;
#floatingWindow;
#details;
#summaryCount;
#downloadCurrent;
#downloadCurrentLabel;
#downloadPreview;
#downloadPreviewTitle;
#downloadPreviewMeta;
#selectionMode;
#selectSurface;
#customSelection;
#selectionError;
#historySearch;
#historyCount;
#historyBatchToggle;
#historyBatchBar;
#historySelectPage;
#historySelectionCount;
#historyRemoveSelected;
#list;
#historyPagination;
#historyPagePrevious;
#historyPageLabel;
#historyPageNext;
#tasks = /* @__PURE__ */ new Map();
#requestFrame;
#cancelFrame;
#tail = Promise.resolve();
#renderFrame = 0;
#renderKey = "";
#managerVisible = !1;
#managerOpen = !1;
#historyPage = 0;
#historyBatchMode = !1;
#visibleHistoryTopicIds = Object.freeze([]);
#selectedHistoryTopics = /* @__PURE__ */ new Set();
#removing = /* @__PURE__ */ new Set();
#externalRestore = null;
#viewObjectUrls = /* @__PURE__ */ new Set();
constructor(options) {
this.#options = options, this.#now = options.now ?? Date.now, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const view = options.document.defaultView;
this.#requestFrame = options.requestFrame ?? ((callback) => typeof view?.requestAnimationFrame == "function" ? view.requestAnimationFrame(callback) : (callback(0), 0)), this.#cancelFrame = options.cancelFrame ?? ((id) => view?.cancelAnimationFrame?.(id)), this.#floatingWindow = options.floating ? new import_reader_floating_window_frame.ReaderFloatingWindowFrame({
document: options.document,
mount: options.mount,
title: "主题下载",
ariaLabel: "Topic 下载管理",
icon: "download",
variant: "topic-downloads",
tabId: "topic-downloads",
tabOrder: 40,
requestOpen: () => {
this.openManager();
},
zIndex: 2147483584,
...options.geometryStorage ? { geometryStorage: options.geometryStorage } : {},
geometryStorageKey: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY,
policy: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_POLICY,
placement: import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_PLACEMENT,
...options.notify ? { notify: options.notify } : {},
onClose: () => this.#closeFromFrame(),
parentScope: this.scope
}) : null, this.#details = (0, import_html_element.htmlElement)(
options.document,
"section",
"ldp-topic-download-manager"
), this.#managerVisible = options.floating !== !0, options.floating && (this.#details.hidden = !0);
const summary = (0, import_html_element.htmlElement)(
options.document,
"header",
"ldp-topic-download-summary"
);
summary.append((0, import_reader_icon.createReaderIcon)(options.document, "download"));
const summaryLabel = (0, import_html_element.htmlElement)(options.document, "span");
summaryLabel.textContent = "Topic 下载", this.#floatingWindow ? this.#summaryCount = this.#floatingWindow.meta : (this.#summaryCount = (0, import_html_element.htmlElement)(options.document, "b"), summary.append(summaryLabel, this.#summaryCount));
const toolbar = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-topic-download-toolbar"
), selection = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-topic-download-selection"
), selectionLabelNode = (0, import_html_element.htmlElement)(options.document, "label"), selectionLabelText = (0, import_html_element.htmlElement)(options.document, "span");
selectionLabelText.textContent = "下载范围", this.#selectionMode = options.document.createElement("select"), this.#selectionMode.className = "ldp-reader-select ldp-topic-download-selection-mode", this.#selectionMode.setAttribute("aria-label", "选择 Topic 下载范围");
for (const [value, label] of [
["all", "全部楼层(默认)"],
["custom", "自定义楼层"]
]) {
const option = options.document.createElement("option");
option.value = value, option.textContent = label, option.selected = value === "all", this.#selectionMode.append(option);
}
selectionLabelNode.append(selectionLabelText, this.#selectionMode);
const customRow = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-topic-download-custom-selection"
);
this.#customSelection = options.document.createElement("input"), this.#customSelection.type = "text", this.#customSelection.inputMode = "text", this.#customSelection.placeholder = "输入楼层,例如 1,3,8-12", this.#customSelection.setAttribute("aria-label", "输入自定义下载楼层"), this.#customSelection.pattern = "[0-9]+(?:-[0-9]+)?(?:,[0-9]+(?:-[0-9]+)?)*", this.#customSelection.autocomplete = "off", this.#customSelection.spellcheck = !1, this.#customSelection.enterKeyHint = "done", customRow.append(this.#customSelection), this.#selectionError = (0, import_html_element.htmlElement)(
options.document,
"small",
"ldp-topic-download-selection-error"
), this.#selectionError.hidden = !0, this.#selectionError.setAttribute("aria-live", "polite"), selection.append(selectionLabelNode, customRow, this.#selectionError), this.#downloadCurrent = button(
options.document,
"ldp-topic-download-current",
"开始后台下载当前 Topic",
"download"
), this.#downloadCurrentLabel = (0, import_html_element.htmlElement)(options.document, "span"), this.#downloadCurrentLabel.textContent = "开始后台下载", this.#downloadCurrent.append(this.#downloadCurrentLabel), selection.insertBefore(this.#downloadCurrent, customRow), this.#downloadPreview = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-topic-download-preview"
), this.#downloadPreview.hidden = !0, this.#downloadPreview.setAttribute("role", "note"), this.#downloadPreview.setAttribute("aria-live", "polite"), this.#downloadPreview.setAttribute("aria-atomic", "true"), this.#downloadPreview.append((0, import_reader_icon.createReaderIcon)(options.document, "download"));
const downloadPreviewCopy = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-topic-download-preview-copy"
), downloadPreviewKicker = (0, import_html_element.htmlElement)(
options.document,
"small",
"ldp-topic-download-preview-kicker"
);
downloadPreviewKicker.textContent = "即将下载", this.#downloadPreviewTitle = (0, import_html_element.htmlElement)(options.document, "strong"), this.#downloadPreviewMeta = (0, import_html_element.htmlElement)(options.document, "small"), downloadPreviewCopy.append(
downloadPreviewKicker,
this.#downloadPreviewTitle,
this.#downloadPreviewMeta
), this.#downloadPreview.append(downloadPreviewCopy), selection.append(this.#downloadPreview), toolbar.append(selection);
const history = (0, import_html_element.htmlElement)(
options.document,
"section",
"ldp-topic-download-history"
), historyHead = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-topic-download-history-head"
), historySearchLabel = (0, import_html_element.htmlElement)(
options.document,
"label",
"ldp-topic-download-search"
);
historySearchLabel.append((0, import_reader_icon.createReaderIcon)(options.document, "search")), this.#historySearch = options.document.createElement("input"), this.#historySearch.type = "search", this.#historySearch.placeholder = "搜索标题、Topic ID 或文件名", this.#historySearch.setAttribute("aria-label", "搜索 Topic 下载历史"), historySearchLabel.append(this.#historySearch), this.#historyCount = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-topic-download-history-count"
), this.#historyBatchToggle = button(
options.document,
"ldp-topic-download-batch-toggle",
"进入批量管理",
"list-checks"
);
const batchToggleLabel = (0, import_html_element.htmlElement)(options.document, "span");
batchToggleLabel.textContent = "批量管理", this.#historyBatchToggle.append(batchToggleLabel);
const historyMeta = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-topic-download-history-meta"
);
historyMeta.append(this.#historyCount, this.#historyBatchToggle), historyHead.append(historySearchLabel, historyMeta), this.#historyBatchBar = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-topic-download-batch-bar"
), this.#historyBatchBar.hidden = !0, this.#historySelectPage = button(
options.document,
"ldp-topic-download-select-page",
"选择当前页",
"check-square"
);
const selectPageLabel = (0, import_html_element.htmlElement)(options.document, "span");
selectPageLabel.textContent = "全选本页", this.#historySelectPage.append(selectPageLabel), this.#historySelectionCount = (0, import_html_element.htmlElement)(options.document, "span"), this.#historyRemoveSelected = button(
options.document,
"ldp-topic-download-remove-selected",
"移除已选下载记录",
"trash"
);
const removeSelectedLabel = (0, import_html_element.htmlElement)(options.document, "span");
removeSelectedLabel.textContent = "移除已选", this.#historyRemoveSelected.append(removeSelectedLabel), this.#historyBatchBar.append(
this.#historySelectPage,
this.#historySelectionCount,
this.#historyRemoveSelected
), this.#list = (0, import_html_element.htmlElement)(options.document, "div", "ldp-topic-download-list"), this.#list.setAttribute("aria-live", "polite"), this.#historyPagination = (0, import_html_element.htmlElement)(
options.document,
"nav",
"ldp-topic-download-pagination"
), this.#historyPagination.setAttribute("aria-label", "Topic 下载历史分页"), this.#historyPagePrevious = button(
options.document,
"ldp-topic-download-page-previous",
"上一页",
"chevron-left"
), this.#historyPageLabel = (0, import_html_element.htmlElement)(options.document, "span"), this.#historyPageNext = button(
options.document,
"ldp-topic-download-page-next",
"下一页",
"chevron-right"
), this.#historyPagination.append(
this.#historyPagePrevious,
this.#historyPageLabel,
this.#historyPageNext
), history.append(
historyHead,
this.#historyBatchBar,
this.#list,
this.#historyPagination
), this.#floatingWindow ? (this.#details.append(toolbar, history), this.#floatingWindow.body.append(this.#details)) : (this.#details.append(summary, toolbar, history), options.mount.append(this.#details)), this.#selectSurface = new import_reader_select_surface.ReaderSelectSurface({
document: options.document,
root: this.#details,
parentScope: this.scope
}), this.windowGeometry = this.#floatingWindow?.geometry ?? null, this.windowPointer = this.#floatingWindow?.pointer ?? null, this.scope.add(() => {
this.#selectSurface.destroy(), this.#renderFrame && this.#cancelFrame(this.#renderFrame);
for (const task of this.#tasks.values())
task.controller?.abort(
new DOMException("Topic 下载管理已关闭", "AbortError")
);
for (const objectUrl of this.#viewObjectUrls)
objectUrl.urlApi.revokeObjectURL(objectUrl.value);
this.#viewObjectUrls.clear(), this.#details.remove(), this.changes.clear();
}), this.scope.listen(this.#details, "click", (event) => this.#click(event)), options.floating && (this.scope.listen(options.document, "pointerdown", (event) => {
if (!(!this.#managerVisible || this.#removing.size > 0)) {
if (this.#floatingWindow) {
this.#floatingWindow.dismissFromPointerEvent(event);
return;
}
(0, import_event_target.eventPathIncludes)(event, this.#details) || this.closeManager();
}
}, !0), this.scope.listen(options.document, "keydown", (event) => {
const keyboard = event;
if (!(!this.#managerVisible || this.#removing.size > 0)) {
if (this.#floatingWindow) {
this.#floatingWindow.dismissFromEscapeEvent(keyboard);
return;
}
keyboard.key !== "Escape" || !(0, import_reader_escape_surface.readerEscapeOwnedBy)(options.document, [this.#details]) || (event.preventDefault(), event.stopImmediatePropagation(), this.closeManager());
}
}, !0)), this.scope.listen(this.#selectionMode, "change", () => {
this.#syncSelectionControls(), this.syncCurrent();
}), this.scope.listen(this.#customSelection, "input", () => {
this.#validateCustomSelection(!1), this.syncCurrent();
}), this.scope.listen(this.#historySearch, "input", () => {
this.#historyPage = 0, this.#scheduleRender();
}), this.scope.listen(this.#historyBatchToggle, "click", () => {
this.#historyBatchMode = !this.#historyBatchMode, this.#historyBatchMode || this.#selectedHistoryTopics.clear(), this.#scheduleRender();
}), this.scope.listen(this.#historySelectPage, "click", () => {
const allSelected = this.#visibleHistoryTopicIds.length > 0 && this.#visibleHistoryTopicIds.every((topicId) => this.#selectedHistoryTopics.has(topicId));
for (const topicId of this.#visibleHistoryTopicIds)
allSelected ? this.#selectedHistoryTopics.delete(topicId) : this.#selectedHistoryTopics.add(topicId);
this.#scheduleRender();
}), this.scope.listen(this.#historyRemoveSelected, "click", () => {
this.#confirmAndRemoveSelected();
}), this.scope.listen(this.#list, "change", (event) => {
const input = (0, import_event_target.eventElement)(event)?.closest(
"[data-topic-download-select]"
);
if (!input) return;
const topicId = (0, import_identifiers.discourseTopicId)(Number(input.dataset.topicId));
input.checked ? this.#selectedHistoryTopics.add(topicId) : this.#selectedHistoryTopics.delete(topicId), this.#scheduleRender();
}), this.scope.listen(this.#historyPagePrevious, "click", () => {
this.#historyPage <= 0 || (this.#historyPage -= 1, this.#scheduleRender());
}), this.scope.listen(this.#historyPageNext, "click", () => {
this.#historyPage += 1, this.#scheduleRender();
}), this.#syncSelectionControls(), this.syncCurrent(), this.#render(), this.#restoreArtifacts();
}
get element() {
return this.#floatingWindow?.element ?? this.#details;
}
snapshot() {
return Object.freeze({
open: this.#managerOpen,
tasks: Object.freeze([...this.#tasks.values()].sort((left, right) => right.createdAt - left.createdAt).map((task) => this.#taskSnapshot(task)))
});
}
syncCurrent() {
const current = this.#options.currentTopic(), task = current ? this.#tasks.get(current.topicId) : null, selection = this.#selectionForDuplicateCheck(), active = !!(task && !["ready", "error", "cancelled"].includes(task.phase)), duplicateReady = !!(task?.phase === "ready" && selection && sameSelection(task.selection, selection));
this.#downloadCurrent.disabled = current === null || active, this.#downloadCurrent.classList.toggle("is-active", active), this.#downloadCurrentLabel.textContent = duplicateReady ? "重新生成离线 HTML" : active ? task?.phase === "waiting-rate-limit" ? "等待断点续传" : task?.phase === "waiting-challenge" ? "等待过盾续传" : task?.phase === "queued" ? "已加入下载队列" : "正在后台下载" : "开始后台下载", this.#downloadCurrent.setAttribute(
"aria-label",
duplicateReady && current ? `重新生成 ${current.title} 的${selectionLabel(selection)}离线 HTML` : active && current ? `${current.title} 正在后台下载` : "开始后台下载当前 Topic"
), this.#downloadCurrent.dataset.topicId = current ? String(current.topicId) : "", this.#downloadCurrent.dataset.topicTitle = current?.title ?? "", this.#downloadPreview.hidden = current === null || active || duplicateReady, this.#downloadPreviewTitle.textContent = current?.title ?? "", this.#downloadPreviewMeta.textContent = current ? `Topic #${current.topicId} · ${this.#selectionPreviewLabel()}` : "";
}
#selectionForDuplicateCheck() {
const mode = this.#selectionMode.value;
try {
return normalizedSelection({
mode,
expression: mode === "custom" ? this.#customSelection.value : "",
postNumbers: Object.freeze([])
});
} catch {
return null;
}
}
#selectionPreviewLabel() {
const mode = this.#selectionMode.value;
if (mode === "all") return "全部楼层";
if (mode === "op") return "只看楼主";
const expression = this.#customSelection.value.trim();
return expression ? `自定义 ${expression}` : "自定义楼层(等待输入)";
}
#syncSelectionControls() {
const custom = this.#selectionMode.value === "custom";
this.#customSelection.parentElement.hidden = !custom, custom ? this.#validateCustomSelection(!1) : this.#clearSelectionError();
}
#clearSelectionError() {
this.#selectionError.hidden = !0, this.#selectionError.textContent = "", this.#customSelection.removeAttribute("aria-invalid");
}
#validateCustomSelection(requireValue) {
if (this.#clearSelectionError(), this.#selectionMode.value !== "custom" || !requireValue && !this.#customSelection.value) return !0;
try {
return parseReaderTopicDownloadPostSelection(this.#customSelection.value), !0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return this.#selectionError.textContent = message, this.#selectionError.hidden = !1, this.#customSelection.setAttribute("aria-invalid", "true"), !1;
}
}
#readSelection() {
const mode = this.#selectionMode.value;
if (mode === "custom" && !this.#validateCustomSelection(!0))
return this.#customSelection.focus(), null;
try {
return normalizedSelection({
mode,
expression: mode === "custom" ? this.#customSelection.value : "",
postNumbers: Object.freeze([])
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return this.#selectionError.textContent = message, this.#selectionError.hidden = !1, this.#customSelection.setAttribute("aria-invalid", "true"), this.#customSelection.focus(), null;
}
}
openManager() {
return this.scope.destroyed ? !1 : (this.#managerVisible = !0, this.#managerOpen = !0, this.#details.hidden = !1, this.#details.classList.add("is-open"), this.#floatingWindow?.open(), this.#emit(), !0);
}
closeManager() {
return this.scope.destroyed || !this.#options.floating ? !1 : this.#floatingWindow?.isOpen ? (this.#floatingWindow.close(), !0) : (this.#closeFromFrame(), !0);
}
#closeFromFrame() {
!this.#managerVisible && !this.#managerOpen || (this.#managerVisible = !1, this.#managerOpen = !1, this.#details.classList.remove("is-open"), this.#details.hidden = !0, this.#emit());
}
prepareCurrentDownload() {
return this.syncCurrent(), this.openManager() ? (this.#selectionMode.focus(), !0) : !1;
}
reloadExternal() {
if (this.scope.destroyed) return Promise.resolve();
if (this.#externalRestore) return this.#externalRestore;
const restore = this.#restoreArtifacts(!0).finally(() => {
this.#externalRestore === restore && (this.#externalRestore = null);
});
return this.#externalRestore = restore, restore;
}
enqueueCurrent(regenerateDuplicateReady = !1) {
const current = this.#options.currentTopic();
if (!current) return null;
const selection = this.#readSelection();
return selection ? this.enqueue(
current.topicId,
current.title,
selection,
regenerateDuplicateReady
) : null;
}
enqueue(rawTopicId, rawTitle, selection = ALL_POSTS_SELECTION, regenerateDuplicateReady = !1) {
const topicId = (0, import_identifiers.discourseTopicId)(rawTopicId), title = String(rawTitle || `Topic #${topicId}`).replace(/\s+/g, " ").trim(), selected = normalizedSelection(selection);
this.openManager();
const existing = this.#tasks.get(topicId);
if (existing && (!["ready", "error", "cancelled"].includes(existing.phase) || !regenerateDuplicateReady && existing.phase === "ready" && sameSelection(existing.selection, selected)))
return this.#scheduleRender(), this.#taskSnapshot(existing);
const resumeFromCheckpoint = !!(existing?.resumeAvailable && sameSelection(existing.selection, selected)), task = existing ?? {
topicId,
title,
selection: selected,
phase: "queued",
completed: 0,
total: 0,
detail: "",
error: "",
filename: "",
complete: !1,
archiveStatus: null,
createdAt: this.#now(),
finishedAt: 0,
localDownloadRequestedAt: 0,
artifact: null,
controller: null,
requestResumeCount: 0,
challengeResumeCount: 0,
resumeAvailable: !1
};
return task.phase = "queued", task.selection = selected, task.completed = resumeFromCheckpoint ? task.completed : 0, task.total = resumeFromCheckpoint ? task.total : 0, task.detail = resumeFromCheckpoint ? task.total > 0 ? `正在从 ${Math.min(task.completed, task.total)}/${task.total} 楼断点继续` : "正在从已保存断点继续" : "", task.error = "", task.filename = "", task.complete = !1, task.archiveStatus = null, task.finishedAt = 0, task.artifact = null, task.requestResumeCount = 0, task.challengeResumeCount = 0, task.resumeAvailable = resumeFromCheckpoint, task.controller = new AbortController(), this.#tasks.set(topicId, task), this.#trimTasks(), this.#options.notify?.(resumeFromCheckpoint ? `已从断点继续后台下载:${title}` : `已加入后台下载:${title}`), this.#scheduleRender(), this.#tail = this.#tail.catch(() => {
}).then(() => this.#run(task)), this.#taskSnapshot(task);
}
destroy() {
this.scope.destroy();
}
async #run(task) {
const controller = task.controller;
if (!(!controller || controller.signal.aborted))
try {
for (; ; )
try {
const artifact = await this.#options.worker(
task.topicId,
task.title,
controller.signal,
(progress) => {
controller.signal.aborted || task.controller !== controller || (task.phase = progress.phase, progress.completed !== void 0 && (task.completed = Math.max(0, Math.floor(progress.completed))), progress.total !== void 0 && (task.total = Math.max(0, Math.floor(progress.total))), task.detail = String(progress.detail ?? ""), this.#scheduleRender());
},
task.selection
);
if (controller.signal.throwIfAborted(), task.controller !== controller) return;
task.artifact = artifact, task.filename = artifact.filename, task.completed = artifact.postCount, task.total = artifact.expectedPostCount, task.complete = artifact.complete, task.archiveStatus = normalizedArchiveStatus(artifact.archiveStatus), task.phase = "ready", task.finishedAt = this.#now(), task.detail = "", task.resumeAvailable = !1, task.requestResumeCount = 0, task.challengeResumeCount = 0;
try {
await this.#backupArtifact(task, artifact), task.detail = "已保存到 Reader 下载历史 · 点击下载按钮可保存 HTML 到本地", this.#options.notify?.(
`Topic #${task.topicId} 已保存到下载历史`
);
} catch (error) {
task.detail = `HTML 已生成,Reader 缓存备份失败 · ${String(
error?.message ?? error
)}`, this.#options.notify?.(
`Topic #${task.topicId} HTML 已生成,但 Reader 本地备份失败`
);
}
break;
} catch (error) {
if (task.controller !== controller) return;
if (controller.signal.aborted) throw error;
const resume = this.#options.requestResume?.(error) ?? null;
if (!resume) throw error;
const waitMs = resume.waitMs;
if (task.resumeAvailable = !0, resume.kind === "cloudflare-challenge") {
if (task.challengeResumeCount >= DOWNLOAD_CHALLENGE_AUTO_RESUME_LIMIT) throw error;
task.challengeResumeCount += 1;
} else {
if (task.requestResumeCount >= DOWNLOAD_REQUEST_AUTO_RESUME_LIMIT) throw error;
task.requestResumeCount += 1;
}
task.phase = resume.kind === "cloudflare-challenge" ? "waiting-challenge" : "waiting-rate-limit", task.error = "";
const checkpoint = task.total > 0 ? `,已保存 ${Math.min(task.completed, task.total)}/${task.total} 楼断点` : ",已保存当前断点";
if (resume.kind === "cloudflare-challenge")
task.detail = `Cloudflare 验证未完成${checkpoint} · 等待 Cloudflare 验证通过后自动续传`, this.#options.notify?.(
`Topic #${task.topicId} 已暂停下载并保存断点,等待 Cloudflare 验证通过`
);
else {
const waitLabel = waitMs >= 1e3 ? `${Math.ceil(waitMs / 1e3)} 秒` : waitMs > 0 ? `${waitMs} 毫秒` : "立即", resumeLabel = waitMs > 0 ? `${waitLabel}后` : waitLabel;
task.detail = `遇到 HTTP 429${checkpoint} · ${resumeLabel}自动续传(${task.requestResumeCount}/${DOWNLOAD_REQUEST_AUTO_RESUME_LIMIT})`, this.#options.notify?.(
`Topic #${task.topicId} 遇到 429,已保存断点,${resumeLabel}自动续传`
);
}
if (this.#scheduleRender(), await resume.wait(controller.signal), controller.signal.throwIfAborted(), task.controller !== controller) return;
task.phase = "queued", task.detail = task.total > 0 ? `正在从 ${Math.min(task.completed, task.total)}/${task.total} 楼断点继续` : "正在从已保存断点继续", this.#scheduleRender();
}
} catch (error) {
if (task.controller !== controller) return;
if (controller.signal.aborted)
task.phase = "cancelled", task.error = "";
else {
task.phase = "error";
const resumable = this.#options.requestResume?.(error) ?? null;
resumable && task.resumeAvailable ? resumable.kind === "cloudflare-challenge" ? task.error = task.challengeResumeCount >= DOWNLOAD_CHALLENGE_AUTO_RESUME_LIMIT ? "Cloudflare 验证后再次触发 · 已停止自动续传,断点已保存,可稍后继续下载" : "Cloudflare 验证未完成 · 断点已保存,可继续下载" : task.error = task.requestResumeCount >= DOWNLOAD_REQUEST_AUTO_RESUME_LIMIT ? "HTTP 429 连续续传已达上限 · 断点已保存,可稍后继续下载" : "HTTP 429 · 断点已保存,可继续下载" : task.error = String(
error?.message ?? error
), this.#options.notify?.(
`Topic #${task.topicId} 下载失败:${task.error}`
);
}
task.finishedAt = this.#now();
} finally {
task.controller === controller && (task.controller = null), this.#scheduleRender();
}
}
#click(event) {
const target = (0, import_event_target.eventElement)(event)?.closest(
"[data-topic-download-action],.ldp-topic-download-current"
) ?? null;
if (!target) return;
if (target === this.#downloadCurrent) {
this.enqueueCurrent(!0);
return;
}
const topicId = (0, import_identifiers.discourseTopicId)(Number(target.dataset.topicId)), task = this.#tasks.get(topicId);
if (!task) return;
const action = target.dataset.topicDownloadAction;
if (action === "cancel") {
task.controller?.abort(new DOMException("用户取消 Topic 下载", "AbortError")), task.phase = "cancelled", task.finishedAt = this.#now(), this.#scheduleRender();
return;
}
if (action === "retry") {
this.enqueue(task.topicId, task.title, task.selection);
return;
}
if (action === "remove") {
this.#confirmAndRemove(task);
return;
}
if (action === "save") {
this.#ensureArtifact(task).then(() => this.#saveArtifact(task)).then(() => this.#options.notify?.(
`Topic #${task.topicId} 已触发浏览器下载`
)).catch((error) => {
this.#options.notify?.(
`Topic #${task.topicId} HTML 保存失败:${String(error)}`
);
});
return;
}
action === "view" && this.#ensureArtifact(task).then(() => this.#view(task)).catch((error) => {
this.#options.notify?.(
`Topic #${task.topicId} 离线查看失败:${String(error)}`
);
});
}
async #saveArtifact(task) {
if (!task.artifact) return Promise.resolve();
await Promise.resolve(this.#options.downloads.save(
new Blob([task.artifact.html], { type: "text/html;charset=utf-8" }),
task.artifact.filename
)), task.localDownloadRequestedAt = this.#now();
try {
await this.#backupArtifact(task, task.artifact);
} catch (error) {
this.#options.notify?.(
`Topic #${task.topicId} 已触发下载,但本地下载状态记录失败:${String(error)}`
);
}
this.#scheduleRender();
}
async #confirmAndRemove(task) {
if (!this.#removing.has(task.topicId)) {
this.#removing.add(task.topicId);
try {
const store = this.#options.artifacts, hasCachedHtml = !!await store?.read(task.topicId), choice = await this.#options.confirmRemoval?.(
Object.freeze({
topicId: task.topicId,
title: task.title,
filename: task.filename,
hasCachedHtml,
localDownloadRequestedAt: task.localDownloadRequestedAt
}),
this.#details
) ?? "cancel";
if (choice === "cancel" || this.#tasks.get(task.topicId) !== task) return;
choice === "remove-record-and-cache" ? await store?.remove(task.topicId) : await store?.remove(task.topicId, { preserveHtml: !0 }), task.controller?.abort(
new DOMException("下载记录已移除", "AbortError")
), this.#tasks.delete(task.topicId), this.#selectedHistoryTopics.delete(task.topicId), this.#options.notify?.(
choice === "remove-record-and-cache" ? `Topic #${task.topicId} 下载记录与 Reader 缓存 HTML 已删除` : `Topic #${task.topicId} 下载记录已移除,Reader 缓存 HTML 已保留`
), this.#scheduleRender();
} catch (error) {
this.#options.notify?.(
`Topic #${task.topicId} 下载记录移除失败:${String(error)}`
);
} finally {
this.#removing.delete(task.topicId);
}
}
}
async #confirmAndRemoveSelected() {
const tasks = [...this.#selectedHistoryTopics].map((topicId) => this.#tasks.get(topicId) ?? null).filter((task) => !!task);
if (!(!tasks.length || tasks.some((task) => this.#removing.has(task.topicId)))) {
for (const task of tasks) this.#removing.add(task.topicId);
try {
const store = this.#options.artifacts, contexts = await Promise.all(tasks.map(async (task) => Object.freeze({
topicId: task.topicId,
title: task.title,
filename: task.filename,
hasCachedHtml: !!await store?.read(task.topicId),
localDownloadRequestedAt: task.localDownloadRequestedAt
}))), choice = await this.#options.confirmBulkRemoval?.(
Object.freeze(contexts),
this.#details
) ?? "cancel";
if (choice === "cancel") return;
const activeTasks = tasks.filter((task) => this.#tasks.get(task.topicId) === task);
for (const task of activeTasks)
choice === "remove-record-and-cache" ? await store?.remove(task.topicId) : await store?.remove(task.topicId, { preserveHtml: !0 }), task.controller?.abort(
new DOMException("下载记录已批量移除", "AbortError")
), this.#tasks.delete(task.topicId), this.#selectedHistoryTopics.delete(task.topicId);
this.#options.notify?.(
choice === "remove-record-and-cache" ? `已移除 ${activeTasks.length} 条下载记录及其 Reader 缓存 HTML` : `已移除 ${activeTasks.length} 条下载记录,Reader 缓存 HTML 已保留`
), this.#scheduleRender();
} catch (error) {
this.#options.notify?.(`Topic 下载记录批量移除失败:${String(error)}`);
} finally {
for (const task of tasks) this.#removing.delete(task.topicId);
}
}
}
async #view(task) {
if (!task.artifact) return;
if (this.#options.viewHtml) {
await this.#options.viewHtml(
task.artifact.html,
task.title,
task.topicId
);
return;
}
const view = this.#options.document.defaultView, urlApi = view?.URL ?? globalThis.URL;
if (!view || typeof urlApi.createObjectURL != "function")
throw new Error("当前浏览器不支持本地 HTML 新标签查看");
const objectUrl = urlApi.createObjectURL(new Blob(
[(0, import_reader_topic_offline_document.prepareReaderTopicOfflineBlobHtml)(
task.artifact.html,
this.#options.document
)],
{ type: "text/html;charset=utf-8" }
)), retainedObjectUrl = Object.freeze({
urlApi,
value: objectUrl
});
this.#viewObjectUrls.add(retainedObjectUrl);
const popup = view.open(objectUrl, "_blank");
if (!popup)
throw urlApi.revokeObjectURL(objectUrl), this.#viewObjectUrls.delete(retainedObjectUrl), new Error("浏览器阻止了离线 Topic 新标签页");
popup.opener = null;
const hydrate = () => {
try {
this.#options.hydrateHtmlWindow?.(popup);
} catch (error) {
this.#options.notify?.(
`Topic #${task.topicId} 离线正文水合失败:${String(error)}`
);
}
};
typeof popup.addEventListener == "function" ? popup.addEventListener(
"load",
() => {
hydrate();
},
{ once: !0 }
) : hydrate();
}
#render() {
this.#renderFrame = 0, this.syncCurrent();
const tasks = [...this.#tasks.values()].sort((left, right) => right.createdAt - left.createdAt), query = this.#historySearch.value.trim().toLocaleLowerCase("zh-CN"), filteredTasks = query ? tasks.filter((task) => [
String(task.topicId),
task.title,
task.filename,
selectionLabel(task.selection),
phaseLabel(task),
task.archiveStatus === null ? "" : `${task.archiveStatus} 版本`
].join(" ").toLocaleLowerCase("zh-CN").includes(query)) : tasks, pageCount = Math.max(
1,
Math.ceil(filteredTasks.length / DOWNLOAD_HISTORY_PAGE_SIZE)
);
this.#historyPage = Math.min(this.#historyPage, pageCount - 1);
const pageStart = this.#historyPage * DOWNLOAD_HISTORY_PAGE_SIZE, pageTasks = filteredTasks.slice(
pageStart,
pageStart + DOWNLOAD_HISTORY_PAGE_SIZE
);
this.#visibleHistoryTopicIds = Object.freeze(pageTasks.map((task) => task.topicId)), this.#options.floating && (this.#details.hidden = !this.#managerVisible);
const renderKey = JSON.stringify({
current: this.#downloadCurrent.dataset.topicId,
open: this.#managerOpen,
query,
page: this.#historyPage,
batch: this.#historyBatchMode,
selected: [...this.#selectedHistoryTopics].sort((left, right) => Number(left) - Number(right)),
tasks: tasks.map((task) => [
task.topicId,
task.phase,
task.completed,
task.total,
task.detail,
task.error,
task.filename,
task.archiveStatus,
task.selection.mode,
task.selection.expression,
task.localDownloadRequestedAt
])
});
if (renderKey === this.#renderKey) return;
this.#renderKey = renderKey;
const activeCount = tasks.filter((task) => !["ready", "error", "cancelled"].includes(task.phase)).length;
this.#summaryCount.textContent = activeCount ? `${activeCount} 进行中` : tasks.length ? String(tasks.length) : "", this.#historyCount.textContent = query ? `${filteredTasks.length} / ${tasks.length} 条` : `${tasks.length} 条`, this.#historyBatchToggle.setAttribute(
"aria-pressed",
String(this.#historyBatchMode)
), this.#historyBatchToggle.setAttribute(
"aria-label",
this.#historyBatchMode ? "退出批量管理" : "进入批量管理"
), this.#historyBatchToggle.querySelector("span").textContent = this.#historyBatchMode ? "完成" : "批量管理", this.#historyBatchBar.hidden = !this.#historyBatchMode;
const allPageSelected = pageTasks.length > 0 && pageTasks.every((task) => this.#selectedHistoryTopics.has(task.topicId));
if (this.#historySelectPage.disabled = pageTasks.length === 0, this.#historySelectPage.setAttribute(
"aria-label",
allPageSelected ? "取消选择当前页" : "选择当前页"
), this.#historySelectPage.querySelector("span").textContent = allPageSelected ? "取消本页" : "全选本页", this.#historySelectionCount.textContent = `已选 ${this.#selectedHistoryTopics.size} 条`, this.#historyRemoveSelected.disabled = this.#selectedHistoryTopics.size === 0, this.#list.replaceChildren(...pageTasks.map((task) => this.#row(task))), !pageTasks.length) {
const empty = (0, import_html_element.htmlElement)(this.#options.document, "p", "ldp-topic-download-empty");
empty.textContent = tasks.length ? "没有匹配的下载记录。" : "还没有下载任务。", this.#list.append(empty);
}
this.#historyPagination.hidden = filteredTasks.length <= DOWNLOAD_HISTORY_PAGE_SIZE, this.#historyPageLabel.textContent = `第 ${this.#historyPage + 1} / ${pageCount} 页`, this.#historyPagePrevious.disabled = this.#historyPage === 0, this.#historyPageNext.disabled = this.#historyPage >= pageCount - 1, this.#emit();
}
async #restoreArtifacts(reconcile = !1) {
const store = this.#options.artifacts;
if (!(!store || this.scope.destroyed))
try {
const entries = await store.list(), storedTopicIds = new Set(entries.map((entry) => (0, import_identifiers.discourseTopicId)(entry.topicId)));
if (reconcile)
for (const [topicId, task] of this.#tasks)
storedTopicIds.has(topicId) || task.phase !== "ready" || (this.#tasks.delete(topicId), this.#selectedHistoryTopics.delete(topicId));
for (const entry of entries) {
const topicId = (0, import_identifiers.discourseTopicId)(entry.topicId);
if (this.scope.destroyed) continue;
const current = this.#tasks.get(topicId);
current && current.phase !== "ready" && current.phase !== "error" && current.phase !== "cancelled" || this.#tasks.set(topicId, {
topicId,
title: entry.title,
selection: restoredSelection(entry),
phase: "ready",
completed: entry.postCount,
total: entry.expectedPostCount,
detail: "已从 Reader 本地备份恢复",
error: "",
filename: entry.filename,
complete: entry.complete,
archiveStatus: normalizedArchiveStatus(entry.archiveStatus),
createdAt: entry.createdAt,
finishedAt: entry.finishedAt,
localDownloadRequestedAt: Math.max(0, Number(entry.localDownloadRequestedAt) || 0),
artifact: null,
controller: null,
requestResumeCount: 0,
challengeResumeCount: 0,
resumeAvailable: !1
});
}
this.#trimTasks(), this.#scheduleRender();
} catch (error) {
this.#options.notify?.(`Topic 下载历史读取失败:${String(error)}`);
}
}
async #ensureArtifact(task) {
if (task.artifact) return;
const cached = await this.#options.artifacts?.read(task.topicId) ?? null;
if (!cached) throw new Error("Reader 本地 HTML 备份已不可用");
task.artifact = Object.freeze({
html: cached.html,
filename: cached.filename,
postCount: cached.postCount,
expectedPostCount: cached.expectedPostCount,
complete: cached.complete,
archiveStatus: normalizedArchiveStatus(cached.archiveStatus)
}), task.archiveStatus = normalizedArchiveStatus(cached.archiveStatus);
}
#backupArtifact(task, artifact) {
const store = this.#options.artifacts;
if (!store) return Promise.resolve();
const record = Object.freeze({
topicId: task.topicId,
title: task.title,
selectionMode: task.selection.mode,
selectionExpression: task.selection.expression,
...artifact,
archiveStatus: task.archiveStatus,
createdAt: task.createdAt,
finishedAt: task.finishedAt,
localDownloadRequestedAt: task.localDownloadRequestedAt
});
return store.write(record);
}
#row(task) {
const document = this.#options.document, row = (0, import_html_element.htmlElement)(document, "article", "ldp-topic-download-task");
if (row.classList.add(`is-${task.phase}`), row.dataset.topicId = String(task.topicId), task.archiveStatus !== null && (row.dataset.archiveStatus = String(task.archiveStatus)), this.#historyBatchMode) {
row.classList.add("is-batch");
const selectionLabelNode = (0, import_html_element.htmlElement)(
document,
"label",
"ldp-topic-download-task-selection"
), selectionInput = document.createElement("input");
selectionInput.type = "checkbox", selectionInput.checked = this.#selectedHistoryTopics.has(task.topicId), selectionInput.dataset.topicDownloadSelect = "", selectionInput.dataset.topicId = String(task.topicId), selectionInput.setAttribute(
"aria-label",
`选择 Topic #${task.topicId}:${task.title}`
), selectionLabelNode.append(selectionInput), row.append(selectionLabelNode);
}
const copy = (0, import_html_element.htmlElement)(document, "span", "ldp-topic-download-task-copy"), title = (0, import_html_element.htmlElement)(document, "strong");
title.textContent = task.title;
const state = (0, import_html_element.htmlElement)(document, "small");
if (state.textContent = task.archiveStatus === null ? phaseLabel(task) : `${task.archiveStatus} 版本 · ${phaseLabel(task)}`, copy.append(title, state), ["loading-posts", "loading-replies", "waiting-rate-limit", "waiting-challenge"].includes(task.phase) && task.total > 0) {
const progress = (0, import_html_element.htmlElement)(document, "progress");
progress.max = task.total, progress.value = Math.min(task.total, task.completed), progress.setAttribute("aria-label", phaseLabel(task)), copy.append(progress);
}
const actions = (0, import_html_element.htmlElement)(document, "span", "ldp-topic-download-task-actions"), addAction = (action, label, iconName) => {
const actionButton = button(
document,
`ldp-topic-download-${action}`,
label,
iconName
);
actionButton.dataset.topicDownloadAction = action, actionButton.dataset.topicId = String(task.topicId), actions.append(actionButton);
};
return task.phase === "ready" ? (addAction("view", "查看离线 Topic", "external-link"), addAction("save", "下载 HTML 到本地", "download"), addAction("remove", "移除下载记录", "x")) : task.phase === "error" || task.phase === "cancelled" ? (addAction(
"retry",
task.resumeAvailable ? "继续下载" : "重试下载",
"rotate-ccw"
), addAction("remove", "移除下载记录", "x")) : addAction("cancel", "取消后台下载", "x"), row.append(copy, actions), row;
}
#taskSnapshot(task) {
return Object.freeze({
topicId: task.topicId,
title: task.title,
selection: task.selection,
phase: task.phase,
completed: task.completed,
total: task.total,
detail: task.detail,
error: task.error,
filename: task.filename,
complete: task.complete,
archiveStatus: task.archiveStatus,
createdAt: task.createdAt,
finishedAt: task.finishedAt,
localDownloadRequestedAt: task.localDownloadRequestedAt
});
}
#scheduleRender() {
if (this.scope.destroyed || this.#renderFrame) return;
let completed = !1;
const frame = this.#requestFrame(() => {
completed = !0, this.#render();
});
completed || (this.#renderFrame = frame);
}
#trimTasks() {
const disposable = [...this.#tasks.values()].filter((task) => ["error", "cancelled"].includes(task.phase)).sort((left, right) => right.createdAt - left.createdAt);
for (const task of disposable.slice(20))
this.#tasks.delete(task.topicId), this.#selectedHistoryTopics.delete(task.topicId);
}
#emit() {
for (const error of this.changes.emit(this.snapshot()))
this.#options.notify?.(`Topic 下载管理更新失败:${String(error)}`);
}
}
}, "62b74758e846074d345e19770e0162308c50698704cc6b1c8dc965f7247f90c3");
/* Source: lite/src/sync/reader-webdav-category-ports.ts */
runtime.register("src/sync/reader-webdav-category-ports.js", function(module, exports, require) {
var reader_webdav_category_ports_exports = {};
__export(reader_webdav_category_ports_exports, {
createReaderWebDavActivityHistoryCategoryPort: () => createReaderWebDavActivityHistoryCategoryPort,
createReaderWebDavCategoryPorts: () => createReaderWebDavCategoryPorts,
createReaderWebDavNotificationHistoryCategoryPort: () => createReaderWebDavNotificationHistoryCategoryPort,
createReaderWebDavTranslationCacheCategoryPort: () => createReaderWebDavTranslationCacheCategoryPort,
createReaderWebDavTranslationCategoryPort: () => createReaderWebDavTranslationCategoryPort,
mergeReaderWebDavConnectHistoryValues: () => mergeReaderWebDavConnectHistoryValues,
mergeReaderWebDavHistoryValues: () => mergeReaderWebDavHistoryValues,
readerWebDavActivityHistoryRecordMatchesSchema: () => readerWebDavActivityHistoryRecordMatchesSchema,
readerWebDavBookmarkRecordMatchesSchema: () => readerWebDavBookmarkRecordMatchesSchema,
readerWebDavConnectHistoryRecordMatchesSchema: () => readerWebDavConnectHistoryRecordMatchesSchema,
readerWebDavCustomSiteRecordMatchesSchema: () => readerWebDavCustomSiteRecordMatchesSchema,
readerWebDavHistoryRecordMatchesSchema: () => readerWebDavHistoryRecordMatchesSchema,
readerWebDavNotificationHistoryRecordMatchesSchema: () => readerWebDavNotificationHistoryRecordMatchesSchema,
readerWebDavPreferenceRecordMatchesSchema: () => readerWebDavPreferenceRecordMatchesSchema,
readerWebDavQueueRecordMatchesSchema: () => readerWebDavQueueRecordMatchesSchema,
readerWebDavTopicContextRecordMatchesSchema: () => readerWebDavTopicContextRecordMatchesSchema,
readerWebDavTranslationCacheRecordMatchesSchema: () => readerWebDavTranslationCacheRecordMatchesSchema,
readerWebDavTranslationRemoteValueMatchesSchema: () => readerWebDavTranslationRemoteValueMatchesSchema
});
module.exports = __toCommonJS(reader_webdav_category_ports_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_reader_bookmark_model = require("../bookmark/reader-bookmark-model.js"), import_reader_history_repository = require("../history/reader-history-repository.js"), import_reader_custom_site_repository = require("../site/reader-custom-site-repository.js"), import_reader_notification_model = require("../notification/reader-notification-model.js"), import_reader_translation_config = require("../translation/reader-translation-config.js"), import_reader_webdav_model = require("./reader-webdav-model.js"), import_reader_webdav_secret_codec = require("./reader-webdav-secret-codec.js"), import_reader_webdav_offline_topic_port = require("./reader-webdav-offline-topic-port.js"), import_reader_webdav_history_cache_port = require("./reader-webdav-history-cache-port.js");
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function localRecord(id, value) {
return Object.freeze({ id, value });
}
function readerWebDavPreferenceRecordMatchesSchema(preferences, id, value, normalize, records = []) {
if (!Object.hasOwn(preferences, id)) return !1;
try {
const normalized = normalize({
...preferences,
...Object.fromEntries(records.map((entry) => [entry.id, entry.value])),
[id]: value
});
return Object.hasOwn(normalized, id) && (0, import_reader_webdav_model.readerWebDavFingerprint)(normalized[id]) === (0, import_reader_webdav_model.readerWebDavFingerprint)(value);
} catch {
return !1;
}
}
function readerWebDavTopicContextRecordMatchesSchema(id, value) {
const source = record(value);
return source ? id === "geometry" ? exactKeys(source, ["left", "top", "width", "height"]) && finiteNumber(source.left) && finiteNumber(source.top) && finiteNumber(source.width) && Number(source.width) > 0 && finiteNumber(source.height) && Number(source.height) > 0 : id.startsWith("view:") && id.length > 5 && exactKeys(source, [
"at",
"number",
"scrollTop",
"scrollLeft",
"offset"
]) && finiteNonNegativeNumber(source.at) && positiveSafeInteger(source.number) && finiteNonNegativeNumber(source.scrollTop) && finiteNonNegativeNumber(source.scrollLeft) && finiteNumber(source.offset) : !1;
}
function readerWebDavCustomSiteRecordMatchesSchema(id, value) {
if (typeof value != "string" || value !== id) return !1;
const normalized = (0, import_reader_custom_site_repository.normalizeReaderCustomSiteHost)(value);
return !!(normalized && normalized === value && !(0, import_reader_custom_site_repository.readerBuiltinDiscourseHost)(value));
}
function categoryPort(value) {
return Object.freeze(value);
}
function exactKeys(value, keys) {
const expected = new Set(keys);
return Object.keys(value).length === expected.size && Object.keys(value).every((key) => expected.has(key));
}
function finiteNumber(value) {
return typeof value == "number" && Number.isFinite(value);
}
function finiteNonNegativeNumber(value) {
return finiteNumber(value) && value >= 0;
}
function positiveSafeInteger(value) {
return typeof value == "number" && Number.isSafeInteger(value) && value > 0;
}
function canonicalRecordFieldsMatch(value, normalized, required, optional = []) {
const source = record(value), canonical = record(normalized);
if (!source || !canonical) return !1;
const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
return required.some((key) => !Object.hasOwn(source, key)) || Object.keys(source).some((key) => !allowed.has(key)) ? !1 : Object.entries(source).every(([key, item]) => Object.hasOwn(canonical, key) && (0, import_reader_webdav_model.readerWebDavFingerprint)(item) === (0, import_reader_webdav_model.readerWebDavFingerprint)(canonical[key]));
}
function number(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function text(value) {
return String(value ?? "").trim();
}
function historyValue(value) {
return (0, import_reader_history_repository.normalizeReaderHistoryEntry)(value);
}
function readerWebDavHistoryRecordMatchesSchema(id, value) {
const normalized = historyValue(value);
return normalized !== null && String(normalized.topicId) === id && canonicalRecordFieldsMatch(value, normalized, [
"topicId",
"title",
"postsCount",
"avatarTemplate",
"ownerUsername",
"postNumber",
"readPostNumbers",
"firstViewedAt",
"viewedAt"
], [
"topicSubtitle",
"categoryId",
"categoryName",
"tags",
"viewport",
"archiveStatus",
"archivePostNumber"
]);
}
function mergeReaderWebDavHistoryValues(local, remote) {
const left = historyValue(local), right = historyValue(remote);
if (!left) return right;
if (!right) return left;
const recent = left.viewedAt >= right.viewedAt ? left : right, older = recent === left ? right : left, archived = recent.archiveStatus !== null ? recent : older;
return Object.freeze({
...recent,
postsCount: Math.max(left.postsCount, right.postsCount),
// 历史字段是观察快照;升级归一化会为旧记录补空值,空占位不能
// 抹掉另一设备已经观察到的分类、标签或精确阅读锚点。
topicSubtitle: recent.topicSubtitle || older.topicSubtitle,
categoryId: recent.categoryId ?? older.categoryId,
categoryName: recent.categoryName || older.categoryName,
tags: recent.tags.length ? recent.tags : older.tags,
viewport: recent.viewport ?? older.viewport,
readPostNumbers: Object.freeze([.../* @__PURE__ */ new Set([
...left.readPostNumbers,
...right.readPostNumbers
])].sort((a, b) => a - b)),
archiveStatus: archived.archiveStatus,
archivePostNumber: archived.archivePostNumber,
firstViewedAt: Math.min(left.firstViewedAt, right.firstViewedAt),
viewedAt: Math.max(left.viewedAt, right.viewedAt)
});
}
function queueValue(value) {
const source = record(value), topicId = (0, import_identifiers.tryDiscourseTopicId)(source?.topicId);
return !source || !topicId ? null : Object.freeze({
topicId,
title: text(source.title) || `帖子 #${topicId}`,
href: text(source.href) || `/t/${topicId}`,
avatarTemplate: text(source.avatarTemplate),
avatarSource: text(source.avatarSource),
ownerUsername: text(source.ownerUsername),
postNumber: (0, import_identifiers.tryDiscoursePostNumber)(source.postNumber),
addedAt: Math.max(1, number(source.addedAt, 1)),
pinned: source.pinned === !0
});
}
function readerWebDavQueueRecordMatchesSchema(id, value) {
const normalized = queueValue(value);
return normalized !== null && String(normalized.topicId) === id && canonicalRecordFieldsMatch(value, normalized, [
"topicId",
"title",
"href",
"avatarTemplate",
"avatarSource",
"ownerUsername",
"postNumber",
"addedAt",
"pinned"
]);
}
function mergeQueue(local, remote) {
const left = queueValue(local), right = queueValue(remote);
return left ? right ? Object.freeze({
...right,
...left,
title: left.title || right.title,
href: left.href || right.href,
avatarTemplate: left.avatarTemplate || right.avatarTemplate,
avatarSource: left.avatarSource || right.avatarSource,
ownerUsername: left.ownerUsername || right.ownerUsername,
postNumber: left.postNumber ?? right.postNumber,
addedAt: Math.min(left.addedAt, right.addedAt),
pinned: left.pinned || right.pinned
}) : left : right;
}
function bookmarkTaxonomyValue(source) {
const rawCategoryId = Number(source.categoryId), categoryId = Number.isSafeInteger(rawCategoryId) && rawCategoryId > 0 ? rawCategoryId : null, categoryName = text(source.categoryName), tags = Object.freeze([...new Map(
(Array.isArray(source.tags) ? source.tags : []).map((value) => text(value)).filter(Boolean).map((value) => [value.toLocaleLowerCase("zh-CN"), value])
).values()]);
return Object.freeze({ categoryId, categoryName, tags });
}
function bookmarkValue(value) {
const source = record(value), tab = source?.tab, topicId = (0, import_identifiers.tryDiscourseTopicId)(source?.topicId), postNumber = (0, import_identifiers.tryDiscoursePostNumber)(source?.postNumber), identity = text(source?.identity);
if (!source || tab !== "Topic" && tab !== "Post" || !topicId || !postNumber || !identity) return null;
const rawBookmarkId = Number(source.bookmarkId), bookmarkId = Number.isSafeInteger(rawBookmarkId) && rawBookmarkId > 0 ? rawBookmarkId : null, postId = tab === "Post" ? (0, import_identifiers.tryDiscoursePostId)(source.postId) : null, title = text(source.title) || `帖子 #${topicId}`, authorUsername = text(source.authorUsername), name = text(source.name), taxonomy = bookmarkTaxonomyValue(source);
return Object.freeze({
identity,
tab,
bookmarkId,
topicId,
postId,
postNumber,
title,
authorUsername,
avatarTemplate: text(source.avatarTemplate),
createdAt: text(source.createdAt),
name,
highestPostNumber: Math.max(0, Math.floor(number(
source.highestPostNumber
))),
reaction: "",
excerpt: "",
...taxonomy,
searchText: [
title,
name,
authorUsername,
`@${authorUsername}`,
tab === "Post" ? `楼层 ${postNumber}` : "帖子",
taxonomy.categoryName,
...taxonomy.tags
].filter(Boolean).join(" ").toLocaleLowerCase()
});
}
function bookmarkRemoteValue(value) {
return Object.freeze({
identity: value.identity,
tab: value.tab,
bookmarkId: value.bookmarkId,
topicId: value.topicId,
postId: value.postId,
postNumber: value.postNumber,
title: value.title,
authorUsername: value.authorUsername,
avatarTemplate: value.avatarTemplate,
createdAt: value.createdAt,
name: value.name,
highestPostNumber: value.highestPostNumber,
categoryId: value.categoryId,
categoryName: value.categoryName,
tags: value.tags
});
}
function readerWebDavBookmarkRecordMatchesSchema(id, value) {
const normalized = bookmarkValue(value), remote = normalized ? bookmarkRemoteValue(normalized) : null;
return normalized !== null && normalized.identity === id && canonicalRecordFieldsMatch(value, remote, [
"identity",
"tab",
"bookmarkId",
"topicId",
"postId",
"postNumber",
"title",
"authorUsername",
"avatarTemplate",
"createdAt",
"name",
"highestPostNumber"
], ["categoryId", "categoryName", "tags"]);
}
function mergeBookmark(local, remote) {
const left = bookmarkValue(local), right = bookmarkValue(remote);
if (!left) return remote;
if (!right) return local;
const leftAt = Date.parse(left.createdAt) || 0, rightAt = Date.parse(right.createdAt) || 0, recent = leftAt >= rightAt ? left : right, older = recent === left ? right : left;
return bookmarkRemoteValue(Object.freeze({
...older,
...recent,
categoryId: recent.categoryId ?? older.categoryId,
categoryName: recent.categoryName || older.categoryName,
tags: recent.tags.length ? recent.tags : older.tags
}));
}
const ACTIVITY_TABS = Object.freeze(["Reaction", "Boost", "Reply"]);
function activityHistoryValue(value) {
const source = record(value), tab = source?.tab, topicId = (0, import_identifiers.tryDiscourseTopicId)(source?.topicId), postNumber = (0, import_identifiers.tryDiscoursePostNumber)(source?.postNumber), identity = text(source?.identity);
if (!source || !ACTIVITY_TABS.includes(tab) || !topicId || !postNumber || !identity) return null;
const postId = (0, import_identifiers.tryDiscoursePostId)(source.postId), title = text(source.title) || `帖子 #${topicId}`, authorUsername = text(source.authorUsername), reaction = tab === "Reaction" ? text(source.reaction) : "", excerpt = text(source.excerpt).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(), createdAt = Number.isFinite(Date.parse(text(source.createdAt))) ? text(source.createdAt) : "", taxonomy = bookmarkTaxonomyValue(source);
return Object.freeze({
identity,
tab,
bookmarkId: null,
topicId,
postId,
postNumber,
title,
authorUsername,
avatarTemplate: text(source.avatarTemplate),
createdAt,
name: text(source.name),
highestPostNumber: Math.max(0, Math.floor(number(
source.highestPostNumber
))),
reaction,
excerpt,
...taxonomy,
searchText: [
title,
authorUsername,
`@${authorUsername}`,
reaction,
excerpt,
tab === "Reaction" ? `表情回应 回应 楼层 ${postNumber}` : tab === "Boost" ? `Boost 楼层 ${postNumber}` : `回复 楼层 ${postNumber}`,
taxonomy.categoryName,
...taxonomy.tags
].filter(Boolean).join(" ").toLocaleLowerCase()
});
}
function activityHistoryRemoteValue(value) {
return Object.freeze({
identity: value.identity,
tab: value.tab,
topicId: value.topicId,
postId: value.postId,
postNumber: value.postNumber,
title: value.title,
authorUsername: value.authorUsername,
avatarTemplate: value.avatarTemplate,
createdAt: value.createdAt,
reaction: value.reaction,
excerpt: value.excerpt,
categoryId: value.categoryId,
categoryName: value.categoryName,
tags: value.tags
});
}
function readerWebDavActivityHistoryRecordMatchesSchema(id, value) {
const normalized = activityHistoryValue(value);
return normalized !== null && normalized.identity === id && canonicalRecordFieldsMatch(
value,
activityHistoryRemoteValue(normalized),
[
"identity",
"tab",
"topicId",
"postId",
"postNumber",
"title",
"authorUsername",
"avatarTemplate",
"createdAt",
"reaction",
"excerpt",
"categoryId",
"categoryName",
"tags"
]
);
}
function mergeActivityHistory(local, remote) {
const left = activityHistoryValue(local), right = activityHistoryValue(remote);
if (!left) return remote;
if (!right) return local;
const leftAt = Date.parse(left.createdAt) || 0, rightAt = Date.parse(right.createdAt) || 0, recent = leftAt > rightAt ? left : rightAt > leftAt ? right : (0, import_reader_webdav_model.readerWebDavFingerprint)(activityHistoryRemoteValue(left)) >= (0, import_reader_webdav_model.readerWebDavFingerprint)(activityHistoryRemoteValue(right)) ? left : right, older = recent === left ? right : left;
return activityHistoryRemoteValue(Object.freeze({
...older,
...recent,
title: recent.title || older.title,
authorUsername: recent.authorUsername || older.authorUsername,
avatarTemplate: recent.avatarTemplate || older.avatarTemplate,
excerpt: recent.excerpt || older.excerpt,
reaction: recent.reaction || older.reaction,
categoryId: recent.categoryId ?? older.categoryId,
categoryName: recent.categoryName || older.categoryName,
tags: recent.tags.length ? recent.tags : older.tags
}));
}
function notificationHistoryValue(value) {
const source = record(value), group = text(source?.group), identity = text(source?.identity);
if (!source || !identity || !import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.includes(group)) return null;
const descriptor = (0, import_reader_notification_model.readerNotificationGroup)(group), actor = text(source.actor), summary = text(source.summary), excerpt = text(source.excerpt).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(), createdAt = Number.isFinite(Date.parse(text(source.createdAt))) ? text(source.createdAt) : (/* @__PURE__ */ new Date(0)).toISOString(), topicId = (0, import_identifiers.tryDiscourseTopicId)(record(source.target)?.topicId), postNumber = (0, import_identifiers.tryDiscoursePostNumber)(record(source.target)?.postNumber), target = topicId && postNumber ? Object.freeze({ topicId, postNumber }) : null, rawCount = Number(source.aggregateCount), aggregateCount = Number.isSafeInteger(rawCount) && rawCount > 1 ? rawCount : null, typeLabel = text(source.typeLabel) || descriptor.label, rawCategoryId = Number(source.categoryId), categoryId = Number.isSafeInteger(rawCategoryId) && rawCategoryId > 0 ? rawCategoryId : null, categoryName = text(source.categoryName), tags = Object.freeze([...new Map(
(Array.isArray(source.tags) ? source.tags : []).map((value2) => text(value2)).filter(Boolean).map((value2) => [value2.toLocaleLowerCase("zh-CN"), value2])
).values()]);
return Object.freeze({
identity,
group,
source: descriptor.source,
sourceNotificationId: null,
notificationTypeId: null,
highPriority: source.highPriority === !0,
typeName: text(source.typeName),
typeLabel,
aggregateCount,
icon: text(source.icon) || descriptor.icon,
actor,
avatarFallback: text(source.avatarFallback) || actor.slice(0, 1).toLocaleUpperCase() || "?",
avatarTemplate: text(source.avatarTemplate),
summary,
excerpt,
stateLabel: "",
createdAt,
read: null,
href: text(source.href),
target,
categoryId,
categoryName,
tags,
searchText: (0, import_reader_notification_model.notificationSearchText)([
actor,
summary,
excerpt,
typeLabel,
target?.topicId,
target?.postNumber,
categoryName,
...tags
])
});
}
function notificationHistoryRemoteValue(value) {
return Object.freeze({
identity: value.identity,
group: value.group,
highPriority: value.highPriority,
typeName: value.typeName,
typeLabel: value.typeLabel,
aggregateCount: value.aggregateCount,
icon: value.icon,
actor: value.actor,
avatarFallback: value.avatarFallback,
avatarTemplate: value.avatarTemplate,
summary: value.summary,
excerpt: value.excerpt,
createdAt: value.createdAt,
href: value.href,
target: value.target,
categoryId: value.categoryId,
categoryName: value.categoryName,
tags: value.tags
});
}
function readerWebDavNotificationHistoryRecordMatchesSchema(id, value) {
const normalized = notificationHistoryValue(value);
return normalized !== null && normalized.identity === id && canonicalRecordFieldsMatch(
value,
notificationHistoryRemoteValue(normalized),
[
"identity",
"group",
"highPriority",
"typeName",
"typeLabel",
"aggregateCount",
"icon",
"actor",
"avatarFallback",
"avatarTemplate",
"summary",
"excerpt",
"createdAt",
"href",
"target",
"categoryId",
"categoryName",
"tags"
]
);
}
function mergeNotificationHistory(local, remote) {
const left = notificationHistoryValue(local), right = notificationHistoryValue(remote);
if (!left) return remote;
if (!right) return local;
const leftAt = Date.parse(left.createdAt) || 0, rightAt = Date.parse(right.createdAt) || 0, recent = leftAt > rightAt ? left : rightAt > leftAt ? right : (0, import_reader_webdav_model.readerWebDavFingerprint)(notificationHistoryRemoteValue(left)) >= (0, import_reader_webdav_model.readerWebDavFingerprint)(notificationHistoryRemoteValue(right)) ? left : right, older = recent === left ? right : left;
return notificationHistoryRemoteValue(Object.freeze({
...older,
...recent,
actor: recent.actor || older.actor,
avatarTemplate: recent.avatarTemplate || older.avatarTemplate,
summary: recent.summary || older.summary,
excerpt: recent.excerpt || older.excerpt,
href: recent.href || older.href,
target: recent.target ?? older.target,
categoryId: recent.categoryId ?? older.categoryId,
categoryName: recent.categoryName || older.categoryName,
tags: recent.tags.length ? recent.tags : older.tags
}));
}
function mergeTopicContext(local, remote) {
const left = record(local), right = record(remote);
return left ? right ? number(left.at) >= number(right.at) ? local : remote : local : remote;
}
function connectMetricSample(value) {
const source = record(value);
if (!source) return null;
const sample = {
first: Number(source.first),
last: Number(source.last),
firstObservedAt: Number(source.firstObservedAt),
lastObservedAt: Number(source.lastObservedAt)
};
return Object.values(sample).every(Number.isFinite) ? Object.freeze(sample) : null;
}
function mergeConnectMetricSample(local, remote) {
const left = connectMetricSample(local), right = connectMetricSample(remote);
if (!left) return right;
if (!right) return left;
const tieWinner = (0, import_reader_webdav_model.readerWebDavFingerprint)(left) >= (0, import_reader_webdav_model.readerWebDavFingerprint)(right) ? left : right, first = left.firstObservedAt < right.firstObservedAt ? left : right.firstObservedAt < left.firstObservedAt ? right : tieWinner, last = left.lastObservedAt > right.lastObservedAt ? left : right.lastObservedAt > left.lastObservedAt ? right : tieWinner;
return Object.freeze({
first: first.first,
last: last.last,
firstObservedAt: first.firstObservedAt,
lastObservedAt: last.lastObservedAt
});
}
function mergeReaderWebDavConnectHistoryValues(local, remote) {
const left = record(local), right = record(remote);
if (!left) return remote;
if (!right) return local;
const leftDays = record(left.days) ?? {}, rightDays = record(right.days) ?? {}, days = /* @__PURE__ */ Object.create(null);
for (const day of /* @__PURE__ */ new Set([...Object.keys(leftDays), ...Object.keys(rightDays)])) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
const leftMetrics = record(leftDays[day]) ?? {}, rightMetrics = record(rightDays[day]) ?? {}, metrics = /* @__PURE__ */ Object.create(null);
for (const key of /* @__PURE__ */ new Set([
...Object.keys(leftMetrics),
...Object.keys(rightMetrics)
])) {
const sample = mergeConnectMetricSample(
leftMetrics[key],
rightMetrics[key]
);
sample && (metrics[key] = sample);
}
Object.keys(metrics).length && (days[day] = Object.freeze(metrics));
}
const confirmedReads = /* @__PURE__ */ Object.create(null), leftReads = record(left.confirmedReads) ?? {}, rightReads = record(right.confirmedReads) ?? {};
for (const fingerprint of /* @__PURE__ */ new Set([
...Object.keys(leftReads),
...Object.keys(rightReads)
])) {
if (!/^\d+:\d+$/.test(fingerprint)) continue;
const candidates = [leftReads[fingerprint], rightReads[fingerprint]].map(Number).filter(Number.isFinite);
candidates.length && (confirmedReads[fingerprint] = Math.min(...candidates));
}
const starts = [left.readTrackingStartedAt, right.readTrackingStartedAt].filter(finiteNonNegativeNumber);
return Object.freeze({
version: 1,
days: Object.freeze(days),
readTrackingStartedAt: starts.length ? Math.min(...starts) : null,
confirmedReads: Object.freeze(confirmedReads)
});
}
function readerWebDavConnectHistoryRecordMatchesSchema(id, value) {
const source = record(value), days = record(source?.days), confirmedReads = record(source?.confirmedReads);
if (id !== "current" || !source || source.version !== 1 || !days || !confirmedReads || !exactKeys(source, [
"version",
"days",
"readTrackingStartedAt",
"confirmedReads"
]) || !(source.readTrackingStartedAt === null || finiteNonNegativeNumber(source.readTrackingStartedAt))) return !1;
for (const [day, rawMetrics] of Object.entries(days)) {
const metrics = record(rawMetrics);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !metrics) return !1;
for (const [key, rawSample] of Object.entries(metrics)) {
const sample = record(rawSample);
if (!key || !sample || !exactKeys(sample, [
"first",
"last",
"firstObservedAt",
"lastObservedAt"
]) || !finiteNumber(sample.first) || !finiteNumber(sample.last) || !finiteNonNegativeNumber(sample.firstObservedAt) || !finiteNonNegativeNumber(sample.lastObservedAt) || sample.firstObservedAt > sample.lastObservedAt) return !1;
}
}
return Object.entries(confirmedReads).every(([fingerprint, confirmedAt]) => /^\d+:\d+$/.test(fingerprint) && finiteNonNegativeNumber(confirmedAt));
}
const TRANSLATION_SECTION_CACHE_ID_PREFIX = "reader-translation-section?", TRANSLATION_CACHE_RECORD_ID = "sections", TRANSLATION_CACHE_MAX_SECTIONS = 240, TRANSLATION_CACHE_MAX_PLAINTEXT_BYTES = 720 * 1024;
function translationCacheEntry(value) {
const source = record(value), id = text(source?.id), translation = String(source?.translation ?? "").trim(), storedAt = number(source?.storedAt, -1);
return !id.startsWith(TRANSLATION_SECTION_CACHE_ID_PREFIX) || id.length > 240 || !translation || storedAt < 0 ? null : Object.freeze({ id, translation, storedAt });
}
function translationCachePayload(value) {
const source = record(value), candidates = (Array.isArray(source?.sections) ? source.sections : []).map(translationCacheEntry).filter((entry) => entry !== null).sort((left, right) => right.storedAt - left.storedAt || left.id.localeCompare(right.id)), unique = /* @__PURE__ */ new Map();
for (const entry of candidates) {
const current = unique.get(entry.id);
(!current || entry.storedAt > current.storedAt) && unique.set(entry.id, entry);
}
const sections = [], encoder = new TextEncoder();
let payloadBytes = encoder.encode('{"version":1,"sections":[]}').byteLength;
for (const entry of unique.values()) {
if (sections.length >= TRANSLATION_CACHE_MAX_SECTIONS) break;
const entryBytes = encoder.encode(JSON.stringify(entry)).byteLength, nextBytes = payloadBytes + entryBytes + (sections.length ? 1 : 0);
nextBytes > TRANSLATION_CACHE_MAX_PLAINTEXT_BYTES || (sections.push(entry), payloadBytes = nextBytes);
}
return Object.freeze({
version: 1,
sections: Object.freeze(sections)
});
}
function mergeTranslationCache(local, remote) {
return translationCachePayload({
sections: [
...translationCachePayload(local).sections,
...translationCachePayload(remote).sections
]
});
}
function readerWebDavTranslationCacheRecordMatchesSchema(id, value) {
const source = record(value);
return id === TRANSLATION_CACHE_RECORD_ID && source?.version === 1 && Array.isArray(source.sections) && (0, import_reader_webdav_model.readerWebDavFingerprint)(source) === (0, import_reader_webdav_model.readerWebDavFingerprint)(translationCachePayload(source));
}
function translationRemoteProfileMatchesSchema(value, version) {
const source = record(value);
if (!source) return !1;
const keys = [
"baseUrl",
"model",
"prompt",
"temperature",
"reasoningEffort",
"requestsPerMinute",
"tokensPerMinute",
"animation",
...version >= 4 ? ["models"] : [],
...version >= 5 ? ["modelCatalog"] : []
];
return !(!exactKeys(source, keys) || typeof source.baseUrl != "string" || (0, import_reader_translation_config.normalizeReaderTranslationBaseUrl)(source.baseUrl) !== source.baseUrl || typeof source.model != "string" || source.model.trim() !== source.model || source.model.length > 160 || typeof source.prompt != "string" || source.prompt.trim() !== source.prompt || !source.prompt || source.prompt.length > 4e3 || !finiteNumber(source.temperature) || source.temperature < 0 || source.temperature > 1 || typeof source.reasoningEffort != "string" || source.reasoningEffort.trim() !== source.reasoningEffort || source.reasoningEffort.length > 64 || /[\u0000-\u001f\u007f]/.test(source.reasoningEffort) || !Number.isSafeInteger(source.requestsPerMinute) || Number(source.requestsPerMinute) < 0 || Number(source.requestsPerMinute) > 1e4 || !Number.isSafeInteger(source.tokensPerMinute) || Number(source.tokensPerMinute) < 0 || Number(source.tokensPerMinute) > 1e8 || typeof source.animation != "string" || (0, import_reader_translation_config.normalizeReaderTranslationAnimation)(source.animation) !== source.animation || version >= 4 && (!Array.isArray(source.models) || source.models.some((item) => typeof item != "string" || !item || item.trim() !== item)) || version >= 5 && (!Array.isArray(source.modelCatalog) || source.modelCatalog.some((item) => {
const normalized = (0, import_reader_translation_config.normalizeReaderAiModelCatalogEntry)(item);
return !normalized || (0, import_reader_webdav_model.readerWebDavFingerprint)(normalized) !== (0, import_reader_webdav_model.readerWebDavFingerprint)(item);
})));
}
function readerWebDavTranslationRemoteValueMatchesSchema(value) {
const source = record(value);
if (!source || ![3, 4, 5].includes(source.version)) return !1;
const version = source.version, keys = [
"version",
"activeBaseUrl",
"profiles",
"encryptedApiKeys",
...version >= 5 || Object.hasOwn(source, "animation") ? ["animation"] : []
];
return exactKeys(source, keys) && typeof source.activeBaseUrl == "string" && (0, import_reader_translation_config.normalizeReaderTranslationBaseUrl)(source.activeBaseUrl) === source.activeBaseUrl && Array.isArray(source.profiles) && source.profiles.length > 0 && source.profiles.every((profile) => translationRemoteProfileMatchesSchema(profile, version)) && (source.encryptedApiKeys === "" || (0, import_reader_webdav_secret_codec.readerWebDavEncryptedSecretMatchesSchema)(source.encryptedApiKeys)) && (!Object.hasOwn(source, "animation") || typeof source.animation == "string" && (0, import_reader_translation_config.normalizeReaderTranslationAnimation)(source.animation) === source.animation);
}
function encryptedTranslationKeyAssociatedData(context, recordId, baseUrls) {
return `awesome-linuxdo-reader-lite-webdav|translation-key|${context.scopeId}|${recordId}|${JSON.stringify(baseUrls)}|v2`;
}
async function encodeTranslationConfigRecords(records, context) {
const entries = await Promise.all(Object.entries(records).map(
async ([id, item]) => {
if (item.deleted) return [id, item];
const config = (0, import_reader_translation_config.normalizeReaderTranslationConfig)(item.value), baseUrls = config.profiles.map((profile) => profile.baseUrl), apiKeys = config.profiles.map((profile) => profile.apiKey), profiles = config.profiles.map((profile) => Object.freeze({
baseUrl: profile.baseUrl,
models: profile.models,
modelCatalog: profile.modelCatalog,
model: profile.model,
prompt: profile.prompt,
temperature: profile.temperature,
reasoningEffort: profile.reasoningEffort,
requestsPerMinute: profile.requestsPerMinute,
tokensPerMinute: profile.tokensPerMinute,
animation: profile.animation
})), value = Object.freeze({
version: 5,
activeBaseUrl: config.activeBaseUrl,
animation: config.animation,
profiles: Object.freeze(profiles),
encryptedApiKeys: apiKeys.some(Boolean) ? await (0, import_reader_webdav_secret_codec.encryptReaderWebDavSecret)(
apiKeys,
context.secret,
encryptedTranslationKeyAssociatedData(context, id, baseUrls)
) : ""
});
return [id, Object.freeze({ ...item, value })];
}
));
return Object.freeze(Object.fromEntries(entries));
}
async function decodeTranslationConfigRecords(records, context) {
const entries = await Promise.all(Object.entries(records).map(
async ([id, item]) => {
if (item.deleted) return [id, item];
const source = record(item.value);
if (!source || !readerWebDavTranslationRemoteValueMatchesSchema(source))
throw new Error("WebDAV 翻译服务集合格式无效");
const version = source.version, rawProfiles = source.profiles, baseUrls = rawProfiles.map((rawProfile) => String(record(rawProfile).baseUrl)), decryptedKeys = source.encryptedApiKeys ? await (0, import_reader_webdav_secret_codec.decryptReaderWebDavSecret)(
source.encryptedApiKeys,
context.secret,
encryptedTranslationKeyAssociatedData(context, id, baseUrls)
) : [];
if (!Array.isArray(decryptedKeys) || source.encryptedApiKeys !== "" && decryptedKeys.length !== rawProfiles.length)
throw new Error("WebDAV 翻译 API Key 集合格式无效");
const profiles = [];
for (const [index, rawProfile] of rawProfiles.entries()) {
const profile = record(rawProfile), baseUrl = baseUrls[index];
if (!profile || !baseUrl)
throw new Error("WebDAV 翻译服务项格式无效");
profiles.push({
baseUrl,
apiKey: String(decryptedKeys[index] ?? ""),
models: version >= 4 ? profile.models : [],
modelCatalog: version >= 5 ? profile.modelCatalog : [],
model: profile.model,
prompt: profile.prompt,
temperature: profile.temperature,
reasoningEffort: profile.reasoningEffort,
requestsPerMinute: profile.requestsPerMinute,
tokensPerMinute: profile.tokensPerMinute,
animation: profile.animation
});
}
const value = (0, import_reader_translation_config.normalizeReaderTranslationConfig)({
profiles,
activeBaseUrl: source.activeBaseUrl,
...Object.hasOwn(source, "animation") ? { animation: source.animation } : {}
});
if (value.profiles.length !== rawProfiles.length || value.activeBaseUrl !== source.activeBaseUrl || Object.hasOwn(source, "animation") && value.animation !== source.animation || rawProfiles.some((rawProfile, index) => {
const profile = record(rawProfile), normalized = value.profiles[index];
if (!normalized) return !0;
const canonical = Object.freeze({
baseUrl: normalized.baseUrl,
...version >= 4 ? { models: normalized.models } : {},
...version >= 5 ? { modelCatalog: normalized.modelCatalog } : {},
model: normalized.model,
prompt: normalized.prompt,
temperature: normalized.temperature,
reasoningEffort: normalized.reasoningEffort,
requestsPerMinute: normalized.requestsPerMinute,
tokensPerMinute: normalized.tokensPerMinute,
animation: normalized.animation
});
return (0, import_reader_webdav_model.readerWebDavFingerprint)(profile) !== (0, import_reader_webdav_model.readerWebDavFingerprint)(canonical);
})) throw new Error("WebDAV 翻译服务集合字段类型无效");
return [id, Object.freeze({ ...item, value })];
}
));
return Object.freeze(Object.fromEntries(entries));
}
function createReaderWebDavTranslationCategoryPort(repository) {
return categoryPort({
category: "translation",
initialStrategy: "remote",
validateRecord: (id) => id === "current",
capture: async () => [localRecord(
"current",
(await repository.load()).config
)],
mergeValues: (local) => local,
apply: (records) => repository.saveConfig((0, import_reader_translation_config.normalizeReaderTranslationConfig)(
records.find((entry) => entry.id === "current")?.value ?? (0, import_reader_translation_config.createReaderTranslationDefaultConfig)()
)),
decodeRemoteRecords: (records, context) => decodeTranslationConfigRecords(records, context),
encodeRemoteRecords: (records, context) => encodeTranslationConfigRecords(records, context)
});
}
function createReaderWebDavTranslationCacheCategoryPort(options) {
return categoryPort({
category: "translation-cache",
initialStrategy: "merge",
validateRecord: readerWebDavTranslationCacheRecordMatchesSchema,
capture: async () => {
const entries = await options.responses.entries({
kinds: [options.cache.kind],
tags: options.cache.tags
});
return [localRecord(
TRANSLATION_CACHE_RECORD_ID,
translationCachePayload({
sections: entries.map((entry) => ({
id: entry.id,
translation: typeof entry.value == "string" ? entry.value : "",
storedAt: entry.storedAt
}))
})
)];
},
mergeValues: mergeTranslationCache,
apply: async (records) => {
const payload = translationCachePayload(records.find((entry) => entry.id === TRANSLATION_CACHE_RECORD_ID)?.value);
await Promise.all(payload.sections.map((entry) => options.responses.restore({
id: entry.id,
kind: options.cache.kind,
tags: options.cache.tags,
freshForMs: options.cache.freshForMs,
retainForMs: options.cache.retainForMs,
persist: options.cache.persist
}, entry.translation, entry.storedAt)));
}
});
}
function createReaderWebDavNotificationHistoryCategoryPort(notifications) {
return (0, import_reader_webdav_history_cache_port.createReaderWebDavHistoryCacheCategoryPort)({
category: "notification-history",
validateRecord: readerWebDavNotificationHistoryRecordMatchesSchema,
capture: () => notifications.syncHistoryRecords().filter((entry) => import_reader_notification_model.READER_NOTIFICATION_AGGREGATE_GROUP_ORDER.includes(entry.group)).map((entry) => localRecord(entry.identity, notificationHistoryRemoteValue(entry))),
mergeValues: mergeNotificationHistory,
apply: (records) => notifications.applySyncedHistoryRecords(
(0, import_reader_notification_model.sortReaderNotifications)(records.map((entry) => notificationHistoryValue(entry.value)).filter((entry) => entry !== null))
)
});
}
function createReaderWebDavActivityHistoryCategoryPort(activity) {
return (0, import_reader_webdav_history_cache_port.createReaderWebDavHistoryCacheCategoryPort)({
category: "activity-history",
validateRecord: readerWebDavActivityHistoryRecordMatchesSchema,
capture: () => activity.activitySyncRecords().filter((entry) => ACTIVITY_TABS.includes(entry.tab)).map((entry) => localRecord(entry.identity, activityHistoryRemoteValue(entry))),
mergeValues: mergeActivityHistory,
apply: (records) => activity.applySyncedActivityRecords(
(0, import_reader_bookmark_model.sortReaderBookmarkRecords)(records.map((entry) => activityHistoryValue(entry.value)).filter((entry) => entry !== null))
)
});
}
function createReaderWebDavCategoryPorts(options) {
const ports = [
categoryPort({
category: "history",
initialStrategy: "merge",
validateRecord: readerWebDavHistoryRecordMatchesSchema,
/* 岁月史书依赖本机正文,不能同步到没有对应正文的另一设备。 */
capture: () => options.history.snapshot.entries.map((entry) => localRecord(String(entry.topicId), entry)),
mergeValues: mergeReaderWebDavHistoryValues,
apply: (records) => {
options.history.replaceExternal(records.map((entry) => historyValue(entry.value)).filter((entry) => entry !== null).sort((left, right) => right.viewedAt - left.viewedAt));
}
}),
categoryPort({
category: "preferences",
initialStrategy: "remote",
validateRecord: (id, value, records) => options.preferences.validate(id, value, records),
capture: () => Object.entries(options.preferences.read()).map(
([id, value]) => localRecord(id, value)
),
mergeValues: (local) => local,
apply: (records) => options.preferences.update(Object.fromEntries(
records.map((entry) => [entry.id, entry.value])
))
}),
categoryPort({
category: "topic-context",
initialStrategy: "merge",
validateRecord: readerWebDavTopicContextRecordMatchesSchema,
capture: () => {
const snapshot = options.topicContext.snapshot;
return Object.freeze([
...snapshot.fullPageGeometry ? [localRecord("geometry", snapshot.fullPageGeometry)] : [],
...Object.entries(snapshot.views).map(([id, value]) => localRecord(`view:${id}`, value))
]);
},
mergeValues: mergeTopicContext,
apply: (records) => options.topicContext.replaceExternal({
fullPageGeometry: records.find((entry) => entry.id === "geometry")?.value ?? null,
views: Object.fromEntries(records.filter((entry) => entry.id.startsWith("view:")).map((entry) => [entry.id.slice(5), entry.value]))
})
}),
categoryPort({
category: "custom-sites",
initialStrategy: "merge",
validateRecord: readerWebDavCustomSiteRecordMatchesSchema,
capture: async () => (await options.customSites.load()).map((host) => localRecord(host, host)),
mergeValues: (local) => local,
apply: (records) => options.customSites.replaceExternal(
records.map((entry) => entry.value)
)
})
];
return options.queue && ports.push(categoryPort({
category: "queue",
initialStrategy: "merge",
validateRecord: readerWebDavQueueRecordMatchesSchema,
capture: () => options.queue.syncEntries().map((entry) => localRecord(String(entry.topicId), entry)),
mergeValues: mergeQueue,
apply: (records) => options.queue.replaceExternal(records.map((entry) => queueValue(entry.value)).filter((entry) => entry !== null))
})), options.bookmarks && (ports.push(categoryPort({
category: "bookmarks",
initialStrategy: "merge",
validateRecord: readerWebDavBookmarkRecordMatchesSchema,
capture: async () => (await options.bookmarks.syncBookmarkRecords()).map((entry) => localRecord(entry.identity, bookmarkRemoteValue(entry))),
mergeValues: mergeBookmark,
apply: (records) => options.bookmarks.applySyncedBookmarkRecords(
records.map((entry) => bookmarkValue(entry.value)).filter((entry) => entry !== null)
)
})), ports.push(createReaderWebDavActivityHistoryCategoryPort(options.bookmarks))), options.notifications && ports.push(createReaderWebDavNotificationHistoryCategoryPort(
options.notifications
)), options.connectHistory && ports.push(categoryPort({
category: "connect-history",
initialStrategy: "merge",
validateRecord: readerWebDavConnectHistoryRecordMatchesSchema,
capture: () => [localRecord(
"current",
options.connectHistory.syncValue()
)],
mergeValues: mergeReaderWebDavConnectHistoryValues,
apply: (records) => options.connectHistory.replaceExternal(
records.find((entry) => entry.id === "current")?.value
)
})), options.translation && ports.push(createReaderWebDavTranslationCategoryPort(options.translation)), options.translationCache && ports.push(createReaderWebDavTranslationCacheCategoryPort(
options.translationCache
)), options.offlineTopics && ports.push((0, import_reader_webdav_offline_topic_port.createReaderWebDavOfflineTopicCategoryPort)(
options.offlineTopics
)), Object.freeze(ports);
}
}, "6feff4948f5e5b1ec90d525ea5d3461735fce9f7393cecdb8c60881b33a2f23d");
/* Source: lite/src/sync/reader-webdav-client.ts */
runtime.register("src/sync/reader-webdav-client.js", function(module, exports, require) {
var reader_webdav_client_exports = {};
__export(reader_webdav_client_exports, {
ReaderWebDavClient: () => ReaderWebDavClient,
ReaderWebDavError: () => ReaderWebDavError
});
module.exports = __toCommonJS(reader_webdav_client_exports);
var import_reader_webdav_model = require("./reader-webdav-model.js");
class ReaderWebDavError extends Error {
code;
status;
constructor(code, message, status = 0) {
super(message), this.name = "ReaderWebDavError", this.code = code, this.status = status;
}
}
function headerValue(headers, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return String(headers ?? "").match(
new RegExp(`^${escaped}:\\s*(.+)$`, "im")
)?.[1]?.trim() ?? "";
}
function encodedPath(path) {
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
function targetUrl(config, remotePath = config.remotePath) {
const path = (0, import_reader_webdav_model.normalizeReaderWebDavRemotePath)(remotePath);
if (!path) throw new ReaderWebDavError(
"unexpected",
"WebDAV 远端路径无效"
);
return new URL(encodedPath(path), config.endpoint);
}
function statusError(status, operation) {
return status === 401 ? new ReaderWebDavError(
"auth",
"WebDAV 认证失败,请检查用户名和应用密码",
status
) : status === 403 ? new ReaderWebDavError(
"forbidden",
`WebDAV 没有${operation}权限`,
status
) : status === 404 ? new ReaderWebDavError(
"not-found",
"WebDAV 目标不存在",
status
) : status === 409 || status === 412 ? new ReaderWebDavError(
"conflict",
"WebDAV 远端文件版本已变化,请重新读取后重试",
status
) : status >= 500 ? new ReaderWebDavError(
"server",
`WebDAV 服务暂时不可用(HTTP ${status})`,
status
) : new ReaderWebDavError(
"unexpected",
`WebDAV ${operation}失败(HTTP ${status || 0})`,
status
);
}
class ReaderWebDavClient {
#request;
#timeoutMs;
#maxDocumentBytes;
constructor(options) {
this.#request = options.request, this.#timeoutMs = Math.max(
3e3,
Math.min(6e4, Math.round(options.timeoutMs ?? 15e3))
), this.#maxDocumentBytes = Math.max(
16384,
Math.min(8 * 1024 * 1024, Math.round(
options.maxDocumentBytes ?? 2 * 1024 * 1024
))
);
}
async test(config, signal) {
const response = await this.#execute(
config,
"PROPFIND",
new URL(config.endpoint),
{ Depth: "0" },
signal
);
if (response.status !== 200 && response.status !== 207)
throw statusError(response.status, "连接检测");
}
async read(config, signal) {
const response = await this.#execute(
config,
"GET",
targetUrl(config),
{
Accept: "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache"
},
signal
);
if (response.status === 404 || response.status === 409) return null;
if (response.status < 200 || response.status >= 300)
throw statusError(response.status, "读取");
const text = String(response.responseText ?? "");
if (new TextEncoder().encode(text).byteLength > this.#maxDocumentBytes)
throw new ReaderWebDavError(
"unexpected",
"WebDAV 同步文件超过 2 MiB 安全上限"
);
const etag = headerValue(response.responseHeaders, "ETag");
if (!etag) throw new ReaderWebDavError(
"unexpected",
"WebDAV 读取成功但服务器未返回 ETag,无法安全同步"
);
return Object.freeze({ text, etag });
}
async write(config, text, etag, signal) {
if (new TextEncoder().encode(text).byteLength > this.#maxDocumentBytes)
throw new ReaderWebDavError(
"unexpected",
"WebDAV 同步文件超过 2 MiB 安全上限"
);
etag || await this.#ensureCollections(config, config.remotePath, signal);
const response = await this.#execute(
config,
"PUT",
targetUrl(config),
{
"Content-Type": "application/json; charset=utf-8",
...etag ? { "If-Match": etag } : { "If-None-Match": "*" }
},
signal,
text
);
if (![200, 201, 204].includes(response.status))
throw statusError(response.status, "写入");
return headerValue(response.responseHeaders, "ETag");
}
/**
* 读取主同步文件之外的独立对象。离线 Topic HTML 与历史清单不受主
* sync.json 的 2 MiB 安全上限约束;需要内容完整性校验的对象由上层负责。
*/
async readObject(config, remotePath, signal) {
const response = await this.#execute(
config,
"GET",
targetUrl(config, remotePath),
{
Accept: "text/html, application/json;q=0.9, */*;q=0.1",
"Cache-Control": "no-cache",
Pragma: "no-cache"
},
signal
);
if (response.status === 404 || response.status === 409) return null;
if (response.status < 200 || response.status >= 300)
throw statusError(response.status, "读取独立对象");
const etag = headerValue(response.responseHeaders, "ETag");
if (!etag) throw new ReaderWebDavError(
"unexpected",
"WebDAV 独立对象读取成功但服务器未返回 ETag"
);
return Object.freeze({
text: String(response.responseText ?? ""),
etag
});
}
/** 条件写入独立对象;内容对象用 null ETag 只允许首次创建。 */
async writeObject(config, remotePath, text, etag, contentType, signal) {
etag || await this.#ensureCollections(config, remotePath, signal);
const response = await this.#execute(
config,
"PUT",
targetUrl(config, remotePath),
{
"Content-Type": contentType,
...etag ? { "If-Match": etag } : { "If-None-Match": "*" }
},
signal,
text
);
if (![200, 201, 204].includes(response.status))
throw statusError(response.status, "写入独立对象");
return headerValue(response.responseHeaders, "ETag");
}
async #ensureCollections(config, remotePath, signal) {
const normalized = (0, import_reader_webdav_model.normalizeReaderWebDavRemotePath)(remotePath);
if (!normalized) throw new ReaderWebDavError(
"unexpected",
"WebDAV 独立对象路径无效"
);
const segments = normalized.split("/").slice(0, -1);
let relative = "";
for (const segment of segments) {
relative += `${encodeURIComponent(segment)}/`;
const response = await this.#execute(
config,
"MKCOL",
new URL(relative, config.endpoint),
{},
signal
);
if (![200, 201, 204, 405].includes(response.status))
throw statusError(response.status, "创建同步目录");
}
}
#execute(config, method, url, headers, signal, data) {
return signal.aborted ? Promise.reject(signal.reason) : new Promise((resolve, reject) => {
let settled = !1, handle;
const cleanup = () => signal.removeEventListener("abort", abort), fail = (cause) => {
settled || (settled = !0, cleanup(), reject(cause));
}, abort = () => {
if (!settled) {
settled = !0, cleanup();
try {
handle?.abort?.();
} finally {
reject(signal.reason);
}
}
};
signal.addEventListener("abort", abort, { once: !0 });
try {
handle = this.#request({
method,
url: url.href,
headers,
user: config.username,
password: config.password,
timeout: this.#timeoutMs,
responseType: "text",
...data === void 0 ? {} : { data },
onload: (response) => {
settled || (settled = !0, cleanup(), resolve(response));
},
onerror: () => fail(new ReaderWebDavError(
"network",
"WebDAV 网络连接失败"
)),
ontimeout: () => fail(new ReaderWebDavError(
"timeout",
"WebDAV 请求超时"
)),
onabort: () => {
signal.aborted ? abort() : fail(new ReaderWebDavError(
"network",
"WebDAV 请求已取消"
));
}
});
} catch (cause) {
settled = !0, cleanup(), reject(cause);
}
});
}
}
}, "347e396fab1aded6277faa4fc4e24214c7d0374edc82c20843ef99b5b43e4314");
/* Source: lite/src/sync/reader-webdav-config-repository.ts */
runtime.register("src/sync/reader-webdav-config-repository.js", function(module, exports, require) {
var reader_webdav_config_repository_exports = {};
__export(reader_webdav_config_repository_exports, {
READER_WEBDAV_CONFIG_STORAGE_KEY: () => READER_WEBDAV_CONFIG_STORAGE_KEY,
ReaderWebDavConfigRepository: () => ReaderWebDavConfigRepository
});
module.exports = __toCommonJS(reader_webdav_config_repository_exports);
var import_signal = require("../kernel/signal.js"), import_reader_webdav_model = require("./reader-webdav-model.js");
const READER_WEBDAV_CONFIG_STORAGE_KEY = "awesome-linuxdo-reader:webdav:v2";
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function normalizedBaselines(value) {
const scopes = record(value), result = /* @__PURE__ */ Object.create(null);
for (const [scopeId, rawBaseline] of Object.entries(scopes ?? {})) {
if (!scopeId || scopeId.length > 240) continue;
const source = record(rawBaseline), baseline = {};
for (const category of import_reader_webdav_model.READER_WEBDAV_CATEGORIES) {
const rawRecords = record(source?.[category]);
rawRecords && (baseline[category] = Object.freeze(Object.fromEntries(
Object.entries(rawRecords).filter(([id, state]) => !!id && typeof state == "string").map(([id, state]) => [id, state])
)));
}
result[scopeId] = Object.freeze(baseline);
}
return Object.freeze(result);
}
function normalizedStatus(value) {
const source = record(value), kind = ["idle", "syncing", "success", "error"].includes(String(source?.kind)) ? source.kind : "idle";
return Object.freeze({
kind: kind === "syncing" ? "idle" : kind,
message: String(source?.message ?? ""),
at: Math.max(0, Number(source?.at) || 0)
});
}
function sameConfig(left, right) {
return left.endpoint === right.endpoint && left.username === right.username && left.password === right.password && left.remotePath === right.remotePath && left.autoSyncEnabled === right.autoSyncEnabled && left.autoSyncIntervalMinutes === right.autoSyncIntervalMinutes && import_reader_webdav_model.READER_WEBDAV_CATEGORIES.every((category) => left.categories[category] === right.categories[category]);
}
function sameBaseline(left, right) {
return left ? import_reader_webdav_model.READER_WEBDAV_CATEGORIES.every((category) => {
const leftRecords = left[category], rightRecords = right[category];
if (leftRecords === rightRecords) return !0;
if (!leftRecords || !rightRecords) return !1;
const leftIds = Object.keys(leftRecords), rightIds = Object.keys(rightRecords);
return leftIds.length === rightIds.length && leftIds.every((id) => leftRecords[id] === rightRecords[id]);
}) : !1;
}
function defaultWriterId() {
const random = globalThis.crypto?.randomUUID?.();
return random ? `device:${random}` : `device:${Date.now()}`;
}
class ReaderWebDavConfigRepository {
changes = new import_signal.Signal();
#storage;
#storageKey;
#createWriterId;
#snapshot = Object.freeze({
loaded: !1,
config: (0, import_reader_webdav_model.createReaderWebDavDefaultConfig)(),
writerId: "",
baselines: Object.freeze({}),
status: Object.freeze({ kind: "idle", message: "", at: 0 })
});
#loadPromise = null;
#writeTail = Promise.resolve();
constructor(options) {
this.#storage = options.storage, this.#storageKey = options.storageKey ?? READER_WEBDAV_CONFIG_STORAGE_KEY, this.#createWriterId = options.createWriterId ?? defaultWriterId;
}
get snapshot() {
return this.#snapshot;
}
get storageKey() {
return this.#storageKey;
}
async load() {
if (this.#snapshot.loaded) return this.#snapshot;
if (this.#loadPromise) return this.#loadPromise;
this.#loadPromise = (async () => {
const source = record(await this.#storage.getValue(this.#storageKey)), snapshot = Object.freeze({
loaded: !0,
config: (0, import_reader_webdav_model.normalizeReaderWebDavConfig)(source?.config),
writerId: String(source?.writerId ?? "").trim() || this.#createWriterId(),
baselines: normalizedBaselines(source?.baselines),
status: normalizedStatus(source?.status)
});
return this.#commit(() => snapshot);
})();
try {
return await this.#loadPromise;
} finally {
this.#loadPromise = null;
}
}
reloadExternal() {
const transaction = this.#writeTail.then(async () => {
const source = record(await this.#storage.getValue(this.#storageKey)), snapshot = Object.freeze({
loaded: !0,
config: (0, import_reader_webdav_model.normalizeReaderWebDavConfig)(source?.config),
writerId: String(source?.writerId ?? "").trim() || this.#createWriterId(),
baselines: normalizedBaselines(source?.baselines),
status: normalizedStatus(source?.status)
});
return this.#snapshot = snapshot, this.changes.emit(snapshot), snapshot;
});
return this.#writeTail = transaction.then(
() => {
},
() => {
}
), transaction;
}
async saveConfig(value) {
await this.load();
const config = (0, import_reader_webdav_model.normalizeReaderWebDavConfig)(value);
return this.#commit((snapshot) => sameConfig(snapshot.config, config) ? snapshot : Object.freeze({
...snapshot,
config,
status: Object.freeze({
kind: "idle",
message: "WebDAV 设置已更新,尚未使用当前配置同步。",
at: 0
})
}));
}
async saveBaseline(scopeId, baseline) {
return await this.load(), this.#commit((snapshot) => sameBaseline(snapshot.baselines[scopeId], baseline) ? snapshot : Object.freeze({
...snapshot,
baselines: Object.freeze({
...snapshot.baselines,
[scopeId]: baseline
})
}));
}
/**
* 本机缓存清理只解除对应类别的三方合并基线,不触碰远端记录。
*
* 同一份本机数据可能先后连接多个 WebDAV 目标,因此必须从所有目标 scope
* 移除类别基线;下一次同步会按首次合并策略恢复仍存在的远端内容,而不会把
* 本机缓存缺失误判成用户主动删除。
*/
async forgetBaselineCategories(categories) {
await this.load();
const forgotten = new Set(categories);
return forgotten.size ? this.#commit((snapshot) => {
let changed = !1;
const baselines = {};
for (const [scopeId, baseline] of Object.entries(snapshot.baselines)) {
let scopeChanged = !1;
const next = {};
for (const category of import_reader_webdav_model.READER_WEBDAV_CATEGORIES) {
const records = baseline[category];
if (records !== void 0) {
if (forgotten.has(category)) {
changed = !0, scopeChanged = !0;
continue;
}
next[category] = records;
}
}
baselines[scopeId] = scopeChanged ? Object.freeze(next) : baseline;
}
return changed ? Object.freeze({
...snapshot,
baselines: Object.freeze(baselines)
}) : snapshot;
}) : this.#snapshot;
}
async saveStatus(status) {
return await this.load(), this.#commit((snapshot) => Object.freeze({
...snapshot,
status: Object.freeze({ ...status })
}));
}
#commit(update) {
const transaction = this.#writeTail.then(async () => {
const snapshot = update(this.#snapshot);
return snapshot === this.#snapshot || (await this.#storage.setValue(this.#storageKey, {
version: 2,
config: snapshot.config,
writerId: snapshot.writerId,
baselines: snapshot.baselines,
status: snapshot.status
}), this.#snapshot = snapshot, this.changes.emit(snapshot)), snapshot;
});
return this.#writeTail = transaction.then(
() => {
},
() => {
}
), transaction;
}
}
}, "839ea66a04fb71f7166bdeafe0fc480acff86e5ba7aea7bf196244b6280c79c1");
/* Source: lite/src/sync/reader-webdav-coordinator.ts */
runtime.register("src/sync/reader-webdav-coordinator.js", function(module, exports, require) {
var reader_webdav_coordinator_exports = {};
__export(reader_webdav_coordinator_exports, {
ReaderWebDavAutoSync: () => ReaderWebDavAutoSync,
ReaderWebDavCoordinator: () => ReaderWebDavCoordinator
});
module.exports = __toCommonJS(reader_webdav_coordinator_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_coordinated_request_client = require("../network/coordinated-request-client.js"), import_reader_webdav_client = require("./reader-webdav-client.js"), import_reader_webdav_model = require("./reader-webdav-model.js");
const CONFLICT_RETRY_DELAYS_MS = Object.freeze([250, 750]);
function errorMessage(cause) {
return cause instanceof Error && cause.message.trim() ? cause.message : "WebDAV 同步失败";
}
function localFingerprint(records) {
return (0, import_reader_webdav_model.readerWebDavFingerprint)([...records].map((entry) => ({ id: entry.id, value: entry.value })).sort((left, right) => left.id.localeCompare(right.id)));
}
function baselineStorageScopeId(config, runtimeScopeId) {
return `target:${(0, import_reader_webdav_model.readerWebDavFingerprint)({
endpoint: config.endpoint,
username: config.username,
remotePath: config.remotePath,
runtimeScopeId
})}`;
}
function withScope(document, scopeId, scope, writerId, now) {
return Object.freeze({
...document,
updatedAt: now,
writerId,
scopes: Object.freeze({
...document.scopes,
[scopeId]: scope
})
});
}
class ReaderWebDavCoordinator {
#client;
#repository;
#categories;
#hostname;
#username;
#now;
#retryDelay;
#active = null;
#activeConfig = null;
#localCacheMutationBarrier = null;
constructor(options) {
this.#client = options.client, this.#repository = options.repository, this.#categories = new Map(options.categories.map((port) => [
port.category,
port
])), this.#hostname = options.hostname, this.#username = options.username, this.#now = options.now ?? Date.now, this.#retryDelay = options.retryDelay ?? import_coordinated_request_client.abortableDelay;
}
async testConnection(signal = new AbortController().signal) {
const snapshot = await this.#repository.load(), issues = (0, import_reader_webdav_model.validateReaderWebDavConfig)(snapshot.config, {
requireCredentials: !0
});
if (issues.length) throw new Error(issues[0]);
await this.#client.test(snapshot.config, signal);
}
/**
* 在本机缓存清理期间暂停同步,并先解除对应类别的本地三方合并基线。
* 已经开始的同步会先完整结束;清理者释放 barrier 后,后续同步才可读取新本地状态。
*/
async acquireLocalCacheClear(categories) {
for (; this.#localCacheMutationBarrier; )
await this.#localCacheMutationBarrier;
let resolveBarrier = () => {
};
const barrier = new Promise((resolve) => {
resolveBarrier = resolve;
});
this.#localCacheMutationBarrier = barrier;
let released = !1;
const release = () => {
released || (released = !0, this.#localCacheMutationBarrier === barrier && (this.#localCacheMutationBarrier = null), resolveBarrier());
};
try {
return this.#active && await this.#active, await this.#repository.forgetBaselineCategories(categories), release;
} catch (cause) {
throw release(), cause;
}
}
syncNow(signal = new AbortController().signal) {
if (this.#localCacheMutationBarrier)
return this.#localCacheMutationBarrier.then(() => {
if (signal.aborted) throw signal.reason;
return this.syncNow(signal);
});
if (this.#active)
return this.#activeConfig === this.#repository.snapshot.config ? this.#active : this.#active.catch(() => {
}).then(() => {
if (signal.aborted) throw signal.reason;
return this.syncNow(signal);
});
this.#activeConfig = this.#repository.snapshot.config;
const active = this.#synchronize(signal).finally(() => {
this.#active === active && (this.#active = null, this.#activeConfig = null);
});
return this.#active = active, active;
}
async #synchronize(signal) {
const startedAt = this.#now();
let synchronizedConfig = null;
await this.#repository.saveStatus(Object.freeze({
kind: "syncing",
message: "正在读取并合并 WebDAV 数据…",
at: startedAt
}));
try {
const snapshot = await this.#repository.load();
synchronizedConfig = snapshot.config;
const issues = (0, import_reader_webdav_model.validateReaderWebDavConfig)(snapshot.config, {
requireCredentials: !0
});
if (issues.length) throw new Error(issues[0]);
const scopeId = (0, import_reader_webdav_model.readerWebDavRuntimeScopeId)(
this.#hostname(),
this.#username()
), baselineScopeId = baselineStorageScopeId(
snapshot.config,
scopeId
), requested = import_reader_webdav_model.READER_WEBDAV_CATEGORIES.filter(
(category) => snapshot.config.categories[category]
), unavailable = requested.filter(
(category) => !this.#categories.has(category)
);
if (unavailable.length)
throw new Error(
`所选同步内容当前不可用:${unavailable.map(
(category) => import_reader_webdav_model.READER_WEBDAV_CATEGORY_LABELS[category]
).join("、")}`
);
const selected = requested.map(
(category) => this.#categories.get(category)
);
if (!selected.length) throw new Error("所选同步内容当前不可用");
const regularSelected = selected.filter((port) => !port.synchronizeStandalone), standaloneSelected = selected.filter((port) => !!port.synchronizeStandalone), transformContext = Object.freeze({
secret: snapshot.config.password,
scopeId
});
let outcome = null, nextBaseline = snapshot.baselines[baselineScopeId] ?? Object.freeze({}), applyRecords = [];
regularSelected.length || (outcome = Object.freeze({
uploaded: 0,
imported: 0,
deleted: 0,
conflicts: 0,
categories: selected.length,
remoteCreated: !1,
at: this.#now()
}));
for (let attempt = 0; regularSelected.length && attempt < 3; attempt += 1) {
if (signal.aborted) throw signal.reason;
const remoteFile = await this.#client.read(snapshot.config, signal), document = remoteFile ? (0, import_reader_webdav_model.normalizeReaderWebDavDocument)(JSON.parse(remoteFile.text)) : (0, import_reader_webdav_model.createReaderWebDavDocument)(snapshot.writerId, this.#now()), storedRemoteScope = document.scopes[scopeId], remoteScope = storedRemoteScope ?? Object.freeze({
categories: Object.freeze({})
}), categories = { ...remoteScope.categories }, attemptBaseline = storedRemoteScope ? nextBaseline : Object.freeze({}), baseline = { ...attemptBaseline }, pendingApply = [];
let uploaded = 0, imported = 0, deleted = 0, conflicts = 0, changed = remoteFile === null && regularSelected.length > 0;
for (const port of regularSelected) {
const local = await port.capture();
if (port.validateRecord) {
for (const item of local)
if (!port.validateRecord(item.id, item.value, local))
throw new Error(
`本机 WebDAV ${port.category} 记录 ${item.id} 身份不一致`
);
}
const remoteCategory = remoteScope.categories[port.category], remoteRecords = remoteCategory?.records ?? {}, decodedRemoteRecords = port.decodeRemoteRecords ? await port.decodeRemoteRecords(
remoteRecords,
transformContext
) : remoteRecords, activeRemoteRecords = Object.freeze(
Object.entries(decodedRemoteRecords).filter(([, item]) => !item.deleted).map(([id, item]) => Object.freeze({
id,
value: item.value
}))
);
if (port.validateRecord) {
for (const item of activeRemoteRecords)
if (!port.validateRecord(
item.id,
item.value,
activeRemoteRecords
))
throw new Error(
`远端 WebDAV ${port.category} 记录 ${item.id} 身份不一致`
);
}
const reconciled = (0, import_reader_webdav_model.reconcileReaderWebDavRecords)({
local,
remote: decodedRemoteRecords,
...remoteCategory === void 0 || attemptBaseline[port.category] === void 0 ? {} : { baseline: attemptBaseline[port.category] },
writerId: snapshot.writerId,
now: this.#now(),
initialStrategy: port.initialStrategy,
mergeValues: port.mergeValues
}), encodedRecords = reconciled.changed && port.encodeRemoteRecords ? await port.encodeRemoteRecords(
reconciled.records,
transformContext
) : reconciled.changed ? reconciled.records : remoteRecords;
categories[port.category] = Object.freeze({
records: encodedRecords
}), baseline[port.category] = reconciled.baseline, pendingApply.push(Object.freeze({
port,
records: reconciled.active,
captured: local
})), changed ||= reconciled.changed, uploaded += reconciled.uploaded, imported += reconciled.imported, deleted += reconciled.deleted, conflicts += reconciled.conflicts;
}
const nextDocument = withScope(
document,
scopeId,
Object.freeze({ categories: Object.freeze(categories) }),
snapshot.writerId,
this.#now()
);
try {
changed && await this.#client.write(
snapshot.config,
JSON.stringify(nextDocument),
remoteFile?.etag ?? null,
signal
);
let localChangedDuringSync = !1;
for (const item of pendingApply) {
const current = await item.port.capture();
if (localFingerprint(current) !== localFingerprint(item.captured)) {
localChangedDuringSync = !0;
break;
}
}
if (localChangedDuringSync) {
if (attempt < 2) continue;
throw new Error(
"同步期间本地数据持续变化,已保留本机内容,请稍后重试"
);
}
nextBaseline = Object.freeze(baseline), applyRecords = Object.freeze(pendingApply), outcome = Object.freeze({
uploaded,
imported,
deleted,
conflicts,
categories: selected.length,
remoteCreated: remoteFile === null && changed,
at: this.#now()
});
break;
} catch (cause) {
if (cause instanceof import_reader_webdav_client.ReaderWebDavError && cause.code === "conflict") {
if (attempt < CONFLICT_RETRY_DELAYS_MS.length) {
await this.#retryDelay(
CONFLICT_RETRY_DELAYS_MS[attempt],
signal
);
continue;
}
throw new import_reader_webdav_client.ReaderWebDavError(
"conflict",
"WebDAV 远端版本持续变化,已保留本机数据;请稍后重试,并检查其他标签页或设备是否正在同步",
cause.status
);
}
throw cause;
}
}
if (!outcome) throw new Error("WebDAV 文件持续冲突,请稍后重试");
for (const item of applyRecords)
localFingerprint(item.records) !== localFingerprint(item.captured) && await item.port.apply(item.records);
regularSelected.length && await this.#repository.saveBaseline(baselineScopeId, nextBaseline);
let aggregate = outcome;
for (const port of standaloneSelected) {
const standalone = await port.synchronizeStandalone({
client: this.#client,
config: snapshot.config,
signal,
scopeId,
writerId: snapshot.writerId,
...nextBaseline[port.category] === void 0 ? {} : { baseline: nextBaseline[port.category] },
now: this.#now,
retryDelay: this.#retryDelay
});
nextBaseline = Object.freeze({
...nextBaseline,
[port.category]: standalone.baseline
}), await this.#repository.saveBaseline(baselineScopeId, nextBaseline), aggregate = Object.freeze({
...aggregate,
uploaded: aggregate.uploaded + standalone.uploaded,
imported: aggregate.imported + standalone.imported,
deleted: aggregate.deleted + standalone.deleted,
conflicts: aggregate.conflicts + standalone.conflicts,
remoteCreated: aggregate.remoteCreated || standalone.remoteCreated,
at: this.#now()
});
}
const message = `同步完成:上传 ${aggregate.uploaded},下载 ${aggregate.imported},删除 ${aggregate.deleted},冲突 ${aggregate.conflicts}`;
return this.#repository.snapshot.config === synchronizedConfig && await this.#repository.saveStatus(Object.freeze({
kind: "success",
message,
at: aggregate.at
})), aggregate;
} catch (cause) {
throw (synchronizedConfig === null || this.#repository.snapshot.config === synchronizedConfig) && await this.#repository.saveStatus(Object.freeze({
kind: "error",
message: errorMessage(cause),
at: this.#now()
})), cause;
}
}
}
class ReaderWebDavAutoSync {
scope;
#repository;
#coordinator;
#visibilityState;
#startupDelayMs;
#schedule;
#cancel;
#handle = null;
#signature = "";
#first = !0;
constructor(options) {
this.#repository = options.repository, this.#coordinator = options.coordinator, this.#visibilityState = options.visibilityState, this.#startupDelayMs = Math.max(1e3, options.startupDelayMs ?? 3e4), this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#repository.changes.subscribe(() => this.#refresh(), this.scope), this.scope.add(() => this.#clear()), this.#repository.load().then(() => this.#refresh()).catch(() => {
});
}
#refresh() {
if (this.scope.destroyed) return;
const config = this.#repository.snapshot.config, signature = `${config.autoSyncEnabled}:${config.autoSyncIntervalMinutes}`;
signature !== this.#signature && (this.#signature = signature, this.#clear(), config.autoSyncEnabled && (this.#arm(this.#first ? this.#startupDelayMs : config.autoSyncIntervalMinutes * 6e4), this.#first = !1));
}
#arm(delayMs) {
this.#handle = this.#schedule(() => {
this.#handle = null, this.#run();
}, delayMs);
}
async #run() {
if (!(this.scope.destroyed || !this.#repository.snapshot.config.autoSyncEnabled)) {
if (this.#visibilityState() === "visible")
try {
await this.#coordinator.syncNow();
} catch {
}
!this.scope.destroyed && this.#repository.snapshot.config.autoSyncEnabled && this.#arm(
this.#repository.snapshot.config.autoSyncIntervalMinutes * 6e4
);
}
}
#clear() {
this.#handle !== null && (this.#cancel(this.#handle), this.#handle = null);
}
}
}, "804a922d868dbe22d7f7fe07f6e06d164aab77a5a6ef730e78a14da0831b1d8c");
/* Source: lite/src/sync/reader-webdav-history-cache-port.ts */
runtime.register("src/sync/reader-webdav-history-cache-port.js", function(module, exports, require) {
var reader_webdav_history_cache_port_exports = {};
__export(reader_webdav_history_cache_port_exports, {
createReaderWebDavHistoryCacheCategoryPort: () => createReaderWebDavHistoryCacheCategoryPort
});
module.exports = __toCommonJS(reader_webdav_history_cache_port_exports);
var import_reader_webdav_client = require("./reader-webdav-client.js"), import_reader_webdav_model = require("./reader-webdav-model.js");
const HISTORY_CACHE_MANIFEST_FORMAT = "awesome-linuxdo-reader-lite-history-cache", HISTORY_CACHE_MANIFEST_VERSION = 1, CONFLICT_RETRY_DELAYS_MS = Object.freeze([250, 750]);
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function normalizedRecordId(value) {
const source = String(value ?? "").trim();
return !source || source.length > 240 || /[\u0000-\u001f]/.test(source) ? "" : source;
}
function manifestPath(remotePath, scopeId, category) {
const normalized = (0, import_reader_webdav_model.normalizeReaderWebDavRemotePath)(remotePath);
if (!normalized) throw new Error("WebDAV 远端路径无效");
const directory = normalized.split("/").slice(0, -1).join("/"), scope = (0, import_reader_webdav_model.readerWebDavFingerprint)(scopeId);
return `${directory}/history-cache/${scope}/${category}.json`;
}
function normalizeManifest(value, category) {
const source = record(value);
if (source?.format !== HISTORY_CACHE_MANIFEST_FORMAT || source.schemaVersion !== HISTORY_CACHE_MANIFEST_VERSION || source.category !== category) throw new Error(`WebDAV ${category} 清单格式或版本不受支持`);
const rawRecords = record(source.records);
if (!rawRecords) throw new Error(`WebDAV ${category} 清单缺少 records`);
if (typeof source.updatedAt != "number" || !Number.isFinite(source.updatedAt) || source.updatedAt < 0 || typeof source.writerId != "string") throw new Error(`WebDAV ${category} 清单字段类型无效`);
const records = /* @__PURE__ */ Object.create(null);
for (const [rawId, rawValue] of Object.entries(rawRecords)) {
const id = normalizedRecordId(rawId);
if (!id || id !== rawId)
throw new Error(`WebDAV ${category} 记录 ID 无效`);
records[id] = (0, import_reader_webdav_model.normalizeReaderWebDavRemoteRecord)(rawValue);
}
return Object.freeze({
format: HISTORY_CACHE_MANIFEST_FORMAT,
schemaVersion: HISTORY_CACHE_MANIFEST_VERSION,
category,
updatedAt: source.updatedAt,
writerId: source.writerId,
records: Object.freeze(records)
});
}
function recordsFingerprint(records) {
return (0, import_reader_webdav_model.readerWebDavFingerprint)([...records].map((entry) => ({ id: entry.id, value: entry.value })).sort((left, right) => left.id.localeCompare(right.id)));
}
async function synchronizeHistoryCache(options, context) {
const path = manifestPath(
context.config.remotePath,
context.scopeId,
options.category
);
for (let attempt = 0; attempt < 3; attempt += 1) {
if (context.signal.aborted) throw context.signal.reason;
const local = Object.freeze([...await options.capture()]), localState = recordsFingerprint(local), remoteFile = await context.client.readObject(
context.config,
path,
context.signal
), remote = remoteFile ? normalizeManifest(JSON.parse(remoteFile.text), options.category) : Object.freeze({
format: HISTORY_CACHE_MANIFEST_FORMAT,
schemaVersion: HISTORY_CACHE_MANIFEST_VERSION,
category: options.category,
updatedAt: 0,
writerId: "",
records: Object.freeze({})
});
for (const [id, item] of Object.entries(remote.records))
if (!item.deleted && !options.validateRecord(id, item.value))
throw new Error(
`WebDAV ${options.category} 记录 ${id} 身份不一致或格式无效`
);
for (const item of local)
if (!options.validateRecord(item.id, item.value))
throw new Error(`本机 WebDAV ${options.category} 记录 ${item.id} 格式无效`);
const reconciled = (0, import_reader_webdav_model.reconcileReaderWebDavRecords)({
local,
remote: remote.records,
writerId: context.writerId,
now: context.now(),
initialStrategy: "merge",
mergeValues: options.mergeValues
});
try {
if (reconciled.changed) {
const manifest = Object.freeze({
format: HISTORY_CACHE_MANIFEST_FORMAT,
schemaVersion: HISTORY_CACHE_MANIFEST_VERSION,
category: options.category,
updatedAt: context.now(),
writerId: context.writerId,
records: reconciled.records
});
await context.client.writeObject(
context.config,
path,
JSON.stringify(manifest),
remoteFile?.etag ?? null,
"application/json; charset=utf-8",
context.signal
);
}
const current = await options.capture();
if (recordsFingerprint(current) !== localState) {
if (attempt < 2) continue;
throw new Error(
"WebDAV 历史同步期间本地缓存持续变化,已保留本机内容,请稍后重试"
);
}
return recordsFingerprint(reconciled.active) !== localState && await options.apply(reconciled.active), Object.freeze({
// 该类别不传播删除,也不依赖三方基线。避免把每条历史指纹
// 再复制到 WebDAV 配置存储,历史增长不会拖大配置本身。
baseline: Object.freeze({}),
uploaded: reconciled.uploaded,
imported: reconciled.imported,
deleted: 0,
conflicts: reconciled.conflicts,
remoteCreated: remoteFile === null && reconciled.changed
});
} catch (cause) {
if (!(cause instanceof import_reader_webdav_client.ReaderWebDavError) || cause.code !== "conflict")
throw cause;
if (attempt < CONFLICT_RETRY_DELAYS_MS.length) {
await context.retryDelay(
CONFLICT_RETRY_DELAYS_MS[attempt],
context.signal
);
continue;
}
throw new import_reader_webdav_client.ReaderWebDavError(
"conflict",
`WebDAV ${options.category} 清单持续变化,已保留本机历史;请稍后重试,并检查其他标签页或设备是否正在同步`,
cause.status
);
}
}
throw new Error(`WebDAV ${options.category} 清单持续冲突,请稍后重试`);
}
function createReaderWebDavHistoryCacheCategoryPort(options) {
return Object.freeze({
category: options.category,
initialStrategy: "merge",
capture: options.capture,
mergeValues: options.mergeValues,
apply: options.apply,
synchronizeStandalone: (context) => synchronizeHistoryCache(options, context)
});
}
}, "b626c922282b3df80ec672c0dcfb2f4ce4de6c36a0e5cad31fb6bc11a4e4b274");
/* Source: lite/src/sync/reader-webdav-model.ts */
runtime.register("src/sync/reader-webdav-model.js", function(module, exports, require) {
var reader_webdav_model_exports = {};
__export(reader_webdav_model_exports, {
READER_WEBDAV_CATEGORIES: () => READER_WEBDAV_CATEGORIES,
READER_WEBDAV_CATEGORY_LABELS: () => READER_WEBDAV_CATEGORY_LABELS,
READER_WEBDAV_DEFAULT_REMOTE_PATH: () => READER_WEBDAV_DEFAULT_REMOTE_PATH,
READER_WEBDAV_FORMAT: () => READER_WEBDAV_FORMAT,
READER_WEBDAV_SCHEMA_VERSION: () => READER_WEBDAV_SCHEMA_VERSION,
createReaderWebDavCategorySelection: () => createReaderWebDavCategorySelection,
createReaderWebDavDefaultConfig: () => createReaderWebDavDefaultConfig,
createReaderWebDavDocument: () => createReaderWebDavDocument,
normalizeReaderWebDavConfig: () => normalizeReaderWebDavConfig,
normalizeReaderWebDavDocument: () => normalizeReaderWebDavDocument,
normalizeReaderWebDavRemotePath: () => normalizeReaderWebDavRemotePath,
normalizeReaderWebDavRemoteRecord: () => normalizeReaderWebDavRemoteRecord,
readerWebDavFingerprint: () => readerWebDavFingerprint,
readerWebDavRuntimeScopeId: () => readerWebDavRuntimeScopeId,
reconcileReaderWebDavRecords: () => reconcileReaderWebDavRecords,
validateReaderWebDavConfig: () => validateReaderWebDavConfig
});
module.exports = __toCommonJS(reader_webdav_model_exports);
const READER_WEBDAV_FORMAT = "awesome-linuxdo-reader-lite-webdav", READER_WEBDAV_SCHEMA_VERSION = 2, READER_WEBDAV_DEFAULT_REMOTE_PATH = "ALR-Lite/v2/sync.json", READER_WEBDAV_CATEGORIES = Object.freeze([
"history",
"bookmarks",
"notification-history",
"activity-history",
"preferences",
"queue",
"topic-context",
"custom-sites",
"connect-history",
"translation",
"translation-cache",
"offline-topics"
]), READER_WEBDAV_CATEGORY_LABELS = Object.freeze({
history: "浏览历史",
bookmarks: "收藏记录",
"notification-history": "通知历史缓存",
"activity-history": "回复、Boost 与表情回应历史",
preferences: "设置配置",
queue: "阅读队列",
"topic-context": "阅读位置与窗口状态",
"custom-sites": "自定义适用站点",
"connect-history": "Connect 本机观察历史",
translation: "AI 服务集合(Key 加密)",
"translation-cache": "已翻译 Section 缓存",
"offline-topics": "离线 Topic 下载(HTML 正文)"
}), AUTO_SYNC_INTERVALS = /* @__PURE__ */ new Set([15, 30, 60, 180, 360]), MISSING_STATE = "missing", DELETED_STATE = "deleted";
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function timestamp(value, label) {
if (typeof value != "number" || !Number.isFinite(value) || value < 0)
throw new Error(`${label} 必须是非负有限数值`);
return value;
}
function canonical(value) {
return value === null || typeof value != "object" ? JSON.stringify(value) ?? "null" : Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
}
function readerWebDavFingerprint(value) {
const source = canonical(value);
let left = 2166136261, right = 2654435769;
for (let index = 0; index < source.length; index += 1) {
const code = source.charCodeAt(index);
left = Math.imul(left ^ code, 16777619) >>> 0, right = Math.imul(right ^ code, 2246822507) >>> 0;
}
return `${left.toString(16).padStart(8, "0")}${right.toString(16).padStart(8, "0")}`;
}
function valueState(value) {
return `value:${readerWebDavFingerprint(value)}`;
}
function remoteState(value) {
return value ? value.deleted ? DELETED_STATE : valueState(value.value) : MISSING_STATE;
}
function normalizedRecordId(value) {
const source = String(value ?? "").trim();
return !source || source.length > 240 || /[\u0000-\u001f]/.test(source) ? "" : source;
}
function normalizeReaderWebDavRemotePath(value) {
const segments = String(value ?? "").trim().replace(/^\/+|\/+$/g, "").split("/").map((segment) => segment.trim());
return segments.length < 2 || segments.some((segment) => !segment || segment === "." || segment === ".." || segment.length > 80) ? "" : segments.join("/");
}
function normalizedEndpoint(value) {
try {
const url = new URL(String(value ?? "").trim());
return url.protocol !== "https:" || url.username || url.password || url.search || url.hash ? "" : (url.pathname = `${url.pathname.replace(/\/+$/g, "")}/`, url.href);
} catch {
return "";
}
}
function createReaderWebDavCategorySelection(value = null) {
const source = record(value);
return Object.freeze(Object.fromEntries(READER_WEBDAV_CATEGORIES.map(
(category) => [category, source ? source[category] === !0 : ["history", "bookmarks", "queue"].includes(category)]
)));
}
function createReaderWebDavDefaultConfig() {
return Object.freeze({
endpoint: "https://dav.jianguoyun.com/dav/",
username: "",
password: "",
remotePath: READER_WEBDAV_DEFAULT_REMOTE_PATH,
categories: createReaderWebDavCategorySelection(),
autoSyncEnabled: !1,
autoSyncIntervalMinutes: 60
});
}
function normalizeReaderWebDavConfig(value) {
const source = record(value);
if (!source) return createReaderWebDavDefaultConfig();
const interval = Number(source?.autoSyncIntervalMinutes);
return Object.freeze({
endpoint: normalizedEndpoint(source?.endpoint),
username: String(source?.username ?? "").trim(),
password: String(source?.password ?? ""),
remotePath: normalizeReaderWebDavRemotePath(source.remotePath),
categories: createReaderWebDavCategorySelection(source?.categories),
autoSyncEnabled: source?.autoSyncEnabled === !0,
autoSyncIntervalMinutes: AUTO_SYNC_INTERVALS.has(interval) ? interval : 60
});
}
function validateReaderWebDavConfig(value, options = {}) {
const issues = [];
return normalizedEndpoint(value.endpoint) || issues.push("WebDAV 地址必须是 HTTPS"), normalizeReaderWebDavRemotePath(value.remotePath) || issues.push("远端路径必须包含目录和文件名"), options.requireCredentials !== !1 && (value.username.trim() || issues.push("请填写 WebDAV 用户名"), value.password || issues.push("请填写 WebDAV 应用密码")), READER_WEBDAV_CATEGORIES.some((category) => value.categories[category]) || issues.push("至少选择一种同步内容"), Object.freeze(issues);
}
function normalizeReaderWebDavRemoteRecord(value) {
const source = record(value);
if (!source) throw new Error("WebDAV 远端记录必须是对象");
if (typeof source.deleted != "boolean")
throw new Error("WebDAV 远端记录 deleted 必须是布尔值");
if (typeof source.writerId != "string")
throw new Error("WebDAV 远端记录 writerId 必须是字符串");
const deleted = source.deleted;
if (!deleted && !Object.hasOwn(source, "value"))
throw new Error("WebDAV 活跃远端记录缺少 value");
return Object.freeze({
changedAt: timestamp(source.changedAt, "WebDAV 远端记录 changedAt"),
writerId: source.writerId,
deleted,
...deleted ? {} : { value: source.value }
});
}
function normalizeRemoteCategory(value) {
const source = record(value);
if (!source) throw new Error("WebDAV 远端分类必须是对象");
const rawRecords = record(source?.records);
if (!rawRecords) throw new Error("WebDAV 远端分类缺少 records");
const records = /* @__PURE__ */ Object.create(null);
for (const [rawId, rawValue] of Object.entries(rawRecords)) {
const id = normalizedRecordId(rawId);
if (!id || id !== rawId) throw new Error("WebDAV 远端记录 ID 无效");
records[id] = normalizeReaderWebDavRemoteRecord(rawValue);
}
return Object.freeze({ records: Object.freeze(records) });
}
function createReaderWebDavDocument(writerId, now = Date.now()) {
return Object.freeze({
format: READER_WEBDAV_FORMAT,
schemaVersion: 2,
updatedAt: now,
writerId,
scopes: Object.freeze({})
});
}
function normalizeReaderWebDavDocument(value) {
const source = record(value);
if (source?.format !== READER_WEBDAV_FORMAT || source.schemaVersion !== 2) throw new Error("远端同步文件格式或版本不受支持");
const rawScopes = record(source.scopes);
if (!rawScopes) throw new Error("远端同步文件缺少 scopes");
const scopes = /* @__PURE__ */ Object.create(null);
for (const [rawScopeId, rawScope] of Object.entries(rawScopes)) {
const scopeId = normalizedRecordId(rawScopeId), scopeSource = record(rawScope), rawCategories = record(scopeSource?.categories);
if (!scopeId || scopeId !== rawScopeId || !rawCategories)
throw new Error("WebDAV 远端 scope 格式无效");
const categories = /* @__PURE__ */ Object.create(null);
for (const [rawCategory, rawCategoryValue] of Object.entries(
rawCategories
)) {
const category = normalizedRecordId(rawCategory);
if (!category || category !== rawCategory)
throw new Error("WebDAV 远端分类 ID 无效");
categories[category] = normalizeRemoteCategory(rawCategoryValue);
}
scopes[scopeId] = Object.freeze({
categories: Object.freeze(categories)
});
}
if (typeof source.writerId != "string")
throw new Error("WebDAV 远端 writerId 必须是字符串");
return Object.freeze({
format: READER_WEBDAV_FORMAT,
schemaVersion: 2,
updatedAt: timestamp(source.updatedAt, "WebDAV 远端 updatedAt"),
writerId: source.writerId,
scopes: Object.freeze(scopes)
});
}
function localRecords(value) {
const result = /* @__PURE__ */ new Map();
for (const item of value) {
const id = normalizedRecordId(item.id);
id && result.set(id, Object.freeze({ id, value: item.value }));
}
return result;
}
function reconcileReaderWebDavRecords(options) {
const local = localRecords(options.local), next = Object.assign(
/* @__PURE__ */ Object.create(null),
options.remote
), ids = /* @__PURE__ */ new Set([
...local.keys(),
...Object.keys(options.remote),
...Object.keys(options.baseline ?? {})
]);
let uploaded = 0, imported = 0, deleted = 0, conflicts = 0;
for (const id of ids) {
const localItem = local.get(id), remoteItem = options.remote[id], baselineState = options.baseline?.[id], localState = localItem ? valueState(localItem.value) : baselineState === DELETED_STATE ? DELETED_STATE : MISSING_STATE, currentRemoteState = remoteState(remoteItem);
let chosen, mergedValue;
if (baselineState === void 0 ? remoteItem ? !localItem || remoteItem.deleted || options.initialStrategy === "remote" ? chosen = "remote" : (chosen = "merged", mergedValue = options.mergeValues(localItem.value, remoteItem.value)) : chosen = "local" : !remoteItem && localItem ? (chosen = "local", conflicts += 1) : localState !== baselineState ? currentRemoteState !== baselineState ? localState === currentRemoteState ? chosen = "remote" : localItem && remoteItem && !remoteItem.deleted ? (chosen = "merged", mergedValue = options.mergeValues(localItem.value, remoteItem.value), conflicts += 1) : (chosen = "remote", conflicts += 1) : chosen = "local" : chosen = "remote", chosen === "remote") {
currentRemoteState !== localState && (imported += 1), !remoteItem && baselineState !== void 0 && (next[id] = Object.freeze({
changedAt: options.now,
writerId: options.writerId,
deleted: !0
}), deleted += 1);
continue;
}
const value = chosen === "merged" ? mergedValue : localItem?.value;
if (value === void 0 && !localItem) {
next[id] = Object.freeze({
changedAt: options.now,
writerId: options.writerId,
deleted: !0
}), deleted += 1, uploaded += 1;
continue;
}
remoteState(remoteItem) !== valueState(value) && (next[id] = Object.freeze({
changedAt: options.now,
writerId: options.writerId,
deleted: !1,
value
}), uploaded += 1), chosen === "merged" && valueState(value) !== localState && (imported += 1);
}
const active = Object.freeze(Object.entries(next).filter(([, item]) => !item.deleted).map(([id, item]) => Object.freeze({ id, value: item.value })).sort((left, right) => left.id.localeCompare(right.id))), baseline = Object.freeze(Object.fromEntries(Object.entries(next).map(
([id, item]) => [id, remoteState(item)]
)));
return Object.freeze({
records: Object.freeze(next),
active,
baseline,
changed: canonical(next) !== canonical(options.remote),
uploaded,
imported,
deleted,
conflicts
});
}
function readerWebDavRuntimeScopeId(hostname, username) {
const host = String(hostname ?? "").trim().toLowerCase(), user = String(username ?? "").trim().toLowerCase();
if (!host) throw new Error("当前站点身份不可用");
if (!user) throw new Error("当前登录账号尚未就绪,请稍后重试");
return `site:${host}|account:${user}`;
}
}, "a812872096a68697e30587bb0fe712b783e666a73f0bafa267eca4ad8326d461");
/* Source: lite/src/sync/reader-webdav-offline-topic-port.ts */
runtime.register("src/sync/reader-webdav-offline-topic-port.js", function(module, exports, require) {
var reader_webdav_offline_topic_port_exports = {};
__export(reader_webdav_offline_topic_port_exports, {
createReaderWebDavOfflineTopicCategoryPort: () => createReaderWebDavOfflineTopicCategoryPort
});
module.exports = __toCommonJS(reader_webdav_offline_topic_port_exports);
var import_reader_webdav_client = require("./reader-webdav-client.js"), import_reader_webdav_model = require("./reader-webdav-model.js");
const OFFLINE_TOPIC_MANIFEST_FORMAT = "awesome-linuxdo-reader-lite-offline-topics", OFFLINE_TOPIC_MANIFEST_VERSION = 1, CONFLICT_RETRY_DELAYS_MS = Object.freeze([250, 750]);
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function timestamp(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 ? numeric : 0;
}
function topicId(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
function selectionMode(value) {
return value === "op" || value === "custom" ? value : "all";
}
function archiveStatus(value) {
const status = Number(value);
return status === 403 || status === 404 || status === 410 ? status : null;
}
function metadata(value) {
const source = record(value), id = topicId(source?.topicId);
if (!source || !id) return null;
const mode = selectionMode(source.selectionMode);
return Object.freeze({
topicId: id,
title: String(source.title || `Topic #${id}`),
selectionMode: mode,
selectionExpression: mode === "custom" ? String(source.selectionExpression ?? "") : "",
filename: String(
source.filename || `topic-${id}-lite-offline.html`
),
postCount: Math.max(0, Math.floor(Number(source.postCount) || 0)),
expectedPostCount: Math.max(
0,
Math.floor(Number(source.expectedPostCount) || 0)
),
complete: source.complete === !0,
archiveStatus: archiveStatus(source.archiveStatus),
createdAt: timestamp(source.createdAt),
finishedAt: timestamp(source.finishedAt),
localDownloadRequestedAt: timestamp(source.localDownloadRequestedAt)
});
}
function artifact(value) {
const source = record(value), valueMetadata = metadata(source), html = typeof source?.html == "string" ? source.html : "";
return !valueMetadata || !html ? null : Object.freeze({ ...valueMetadata, html });
}
function localRecord(value) {
return Object.freeze({ id: String(value.topicId), value });
}
function manifestDirectory(remotePath, scopeId) {
const normalized = (0, import_reader_webdav_model.normalizeReaderWebDavRemotePath)(remotePath);
if (!normalized) throw new Error("WebDAV 远端路径无效");
const directory = normalized.split("/").slice(0, -1).join("/"), scope = (0, import_reader_webdav_model.readerWebDavFingerprint)(scopeId);
return `${directory}/offline-topics/${scope}`;
}
function manifestPath(remotePath, scopeId) {
return `${manifestDirectory(remotePath, scopeId)}/manifest.json`;
}
function objectPath(remotePath, scopeId, artifactTopicId, sha2562) {
return `${manifestDirectory(remotePath, scopeId)}/objects/${artifactTopicId}-${sha2562}.html`;
}
async function sha256(value) {
const subtle = globalThis.crypto?.subtle;
if (!subtle) throw new Error("当前环境不支持离线 HTML SHA-256 校验");
const digest = await subtle.digest("SHA-256", new TextEncoder().encode(value));
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function byteLength(value) {
return new TextEncoder().encode(value).byteLength;
}
function normalizeManifest(value) {
const source = record(value);
if (source?.format !== OFFLINE_TOPIC_MANIFEST_FORMAT || source.schemaVersion !== OFFLINE_TOPIC_MANIFEST_VERSION) throw new Error("WebDAV 离线 Topic 清单格式或版本不受支持");
const rawRecords = record(source.records);
if (!rawRecords) throw new Error("WebDAV 离线 Topic 清单缺少 records");
if (typeof source.updatedAt != "number" || !Number.isFinite(source.updatedAt) || source.updatedAt < 0 || typeof source.writerId != "string") throw new Error("WebDAV 离线 Topic 清单字段类型无效");
const records = {};
for (const [id, rawValue] of Object.entries(rawRecords)) {
if (!topicId(id) || String(topicId(id)) !== id)
throw new Error("WebDAV 离线 Topic 清单记录 ID 无效");
records[id] = (0, import_reader_webdav_model.normalizeReaderWebDavRemoteRecord)(rawValue);
}
return Object.freeze({
format: OFFLINE_TOPIC_MANIFEST_FORMAT,
schemaVersion: OFFLINE_TOPIC_MANIFEST_VERSION,
updatedAt: source.updatedAt,
writerId: source.writerId,
records: Object.freeze(records)
});
}
function remoteValue(value, context) {
const source = record(value), valueMetadata = metadata(source), object = record(source?.object), digest = String(object?.sha256 ?? ""), bytes = Number(object?.bytes), path = (0, import_reader_webdav_model.normalizeReaderWebDavRemotePath)(object?.path);
if (source?.version !== 1 || !valueMetadata || !/^[a-f0-9]{64}$/.test(digest) || !Number.isSafeInteger(bytes) || bytes < 1 || !path || path !== objectPath(
context.config.remotePath,
context.scopeId,
valueMetadata.topicId,
digest
)) return null;
const normalized = Object.freeze({
...valueMetadata,
version: 1,
object: Object.freeze({ path, sha256: digest, bytes })
}), { archiveStatus: _archiveStatus, ...legacyNormalized } = normalized, canonical = Object.hasOwn(source, "archiveStatus") ? normalized : Object.freeze(legacyNormalized);
return (0, import_reader_webdav_model.readerWebDavFingerprint)(source) === (0, import_reader_webdav_model.readerWebDavFingerprint)(canonical) ? normalized : null;
}
async function captureArtifacts(store) {
const result = [];
for (const entry of await store.list()) {
const value = await store.read(entry.topicId);
value && result.push(localRecord(value));
}
return Object.freeze(result.sort((left, right) => Number(left.id) - Number(right.id)));
}
function recordsFingerprint(records) {
return (0, import_reader_webdav_model.readerWebDavFingerprint)([...records].sort((left, right) => left.id.localeCompare(right.id)));
}
async function decodeRemoteRecords(records, local, context) {
const localById = new Map(local.map((entry) => [entry.id, artifact(entry.value)])), entries = [];
for (const [id, item] of Object.entries(records)) {
if (item.deleted) {
entries.push([id, item]);
continue;
}
const reference = remoteValue(item.value, context);
if (!reference)
throw new Error(`WebDAV 离线 Topic #${id} 清单记录无效`);
if (String(reference.topicId) !== id)
throw new Error(`WebDAV 离线 Topic #${id} 清单记录身份不一致`);
const localArtifact = localById.get(id);
let html = "";
if (localArtifact && byteLength(localArtifact.html) === reference.object.bytes && await sha256(localArtifact.html) === reference.object.sha256)
html = localArtifact.html;
else {
const object = await context.client.readObject(
context.config,
reference.object.path,
context.signal
);
if (!object) throw new Error(
`WebDAV 离线 Topic #${id} HTML 对象不存在`
);
if (html = object.text, byteLength(html) !== reference.object.bytes || await sha256(html) !== reference.object.sha256) throw new Error(
`WebDAV 离线 Topic #${id} HTML 完整性校验失败`
);
}
const value = artifact({ ...reference, html });
if (!value) throw new Error(`WebDAV 离线 Topic #${id} 正文无效`);
entries.push([id, Object.freeze({ ...item, value })]);
}
return Object.freeze(Object.fromEntries(entries));
}
async function ensureObject(value, path, digest, bytes, context) {
try {
await context.client.writeObject(
context.config,
path,
value.html,
null,
"text/html; charset=utf-8",
context.signal
);
return;
} catch (cause) {
if (!(cause instanceof import_reader_webdav_client.ReaderWebDavError) || cause.code !== "conflict")
throw cause;
}
const existing = await context.client.readObject(
context.config,
path,
context.signal
);
if (!existing || byteLength(existing.text) !== bytes || await sha256(existing.text) !== digest) throw new Error(`WebDAV 离线 Topic #${value.topicId} 已有对象校验失败`);
}
async function encodeRemoteRecords(records, previous, context) {
const entries = [];
for (const [id, item] of Object.entries(records)) {
if (item.deleted) {
entries.push([id, item]);
continue;
}
const value = artifact(item.value);
if (!value) throw new Error(`本机离线 Topic #${id} 正文无效`);
const digest = await sha256(value.html), bytes = byteLength(value.html), path = objectPath(
context.config.remotePath,
context.scopeId,
value.topicId,
digest
), previousValue = previous[id]?.deleted ? null : remoteValue(previous[id]?.value, context);
(previousValue?.object.path !== path || previousValue.object.sha256 !== digest || previousValue.object.bytes !== bytes) && await ensureObject(value, path, digest, bytes, context);
const { html: _html, ...valueMetadata } = value;
entries.push([id, Object.freeze({
...item,
value: Object.freeze({
...valueMetadata,
version: 1,
object: Object.freeze({ path, sha256: digest, bytes })
})
})]);
}
return Object.freeze(Object.fromEntries(entries));
}
function mergeArtifacts(local, remote) {
const left = artifact(local), right = artifact(remote);
return left ? right ? left.finishedAt !== right.finishedAt ? left.finishedAt > right.finishedAt ? left : right : left.complete !== right.complete ? left.complete ? left : right : left.postCount !== right.postCount ? left.postCount > right.postCount ? left : right : (0, import_reader_webdav_model.readerWebDavFingerprint)(left) >= (0, import_reader_webdav_model.readerWebDavFingerprint)(right) ? left : right : local : remote;
}
async function applyArtifacts(store, records) {
const incoming = /* @__PURE__ */ new Map();
for (const entry of records) {
const value = artifact(entry.value);
value && incoming.set(value.topicId, value);
}
for (const [id, value] of incoming) {
const current = await store.read(id);
current && (0, import_reader_webdav_model.readerWebDavFingerprint)(current) === (0, import_reader_webdav_model.readerWebDavFingerprint)(value) || await store.write(value);
}
for (const entry of await store.list())
incoming.has(entry.topicId) || await store.remove(entry.topicId);
}
async function synchronizeStandalone(store, context) {
const path = manifestPath(context.config.remotePath, context.scopeId);
for (let attempt = 0; attempt < 3; attempt += 1) {
context.signal.throwIfAborted();
const captured = await captureArtifacts(store), capturedFingerprint = recordsFingerprint(captured), remoteFile = await context.client.readObject(
context.config,
path,
context.signal
), manifest = remoteFile ? normalizeManifest(JSON.parse(remoteFile.text)) : Object.freeze({
format: OFFLINE_TOPIC_MANIFEST_FORMAT,
schemaVersion: OFFLINE_TOPIC_MANIFEST_VERSION,
updatedAt: 0,
writerId: "",
records: Object.freeze({})
}), decoded = await decodeRemoteRecords(
manifest.records,
captured,
context
), reconciled = (0, import_reader_webdav_model.reconcileReaderWebDavRecords)({
local: captured,
remote: decoded,
...context.baseline === void 0 ? {} : { baseline: context.baseline },
writerId: context.writerId,
now: context.now(),
initialStrategy: "merge",
mergeValues: mergeArtifacts
}), encoded = reconciled.changed ? await encodeRemoteRecords(
reconciled.records,
manifest.records,
context
) : manifest.records;
try {
reconciled.changed && await context.client.writeObject(
context.config,
path,
JSON.stringify(Object.freeze({
format: OFFLINE_TOPIC_MANIFEST_FORMAT,
schemaVersion: OFFLINE_TOPIC_MANIFEST_VERSION,
updatedAt: context.now(),
writerId: context.writerId,
records: encoded
})),
remoteFile?.etag ?? null,
"application/json; charset=utf-8",
context.signal
);
const current = await captureArtifacts(store);
if (recordsFingerprint(current) !== capturedFingerprint) {
if (attempt < 2) continue;
throw new Error(
"离线 Topic 同步期间本地下载持续变化,已保留本机内容,请稍后重试"
);
}
return await applyArtifacts(store, reconciled.active), Object.freeze({
baseline: reconciled.baseline,
uploaded: reconciled.uploaded,
imported: reconciled.imported,
deleted: reconciled.deleted,
conflicts: reconciled.conflicts,
remoteCreated: remoteFile === null && reconciled.changed
});
} catch (cause) {
if (cause instanceof import_reader_webdav_client.ReaderWebDavError && cause.code === "conflict") {
if (attempt < CONFLICT_RETRY_DELAYS_MS.length) {
await context.retryDelay(
CONFLICT_RETRY_DELAYS_MS[attempt],
context.signal
);
continue;
}
throw new import_reader_webdav_client.ReaderWebDavError(
"conflict",
"WebDAV 离线 Topic 清单持续变化,已保留本机下载;请稍后重试,并检查其他标签页或设备是否正在同步",
cause.status
);
}
throw cause;
}
}
throw new Error("WebDAV 离线 Topic 清单持续冲突,请稍后重试");
}
function createReaderWebDavOfflineTopicCategoryPort(store) {
return Object.freeze({
category: "offline-topics",
initialStrategy: "merge",
capture: () => [],
mergeValues: (local) => local,
apply: () => {
},
synchronizeStandalone: (context) => synchronizeStandalone(store, context)
});
}
}, "3780054a84e84f3719406b93c4357e2faa560b5c17e1a0dd3d113feb34543a34");
/* Source: lite/src/sync/reader-webdav-secret-codec.ts */
runtime.register("src/sync/reader-webdav-secret-codec.js", function(module, exports, require) {
var reader_webdav_secret_codec_exports = {};
__export(reader_webdav_secret_codec_exports, {
decryptReaderWebDavSecret: () => decryptReaderWebDavSecret,
encryptReaderWebDavSecret: () => encryptReaderWebDavSecret,
readerWebDavEncryptedSecretMatchesSchema: () => readerWebDavEncryptedSecretMatchesSchema
});
module.exports = __toCommonJS(reader_webdav_secret_codec_exports);
const READER_WEBDAV_SECRET_FORMAT = "awesome-linuxdo-reader-lite-aes-gcm";
function record(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function cryptoPort() {
const crypto = globalThis.crypto;
if (!crypto?.subtle || !crypto.getRandomValues)
throw new Error("浏览器缺少 Web Crypto,无法加密 WebDAV 翻译设置");
return crypto;
}
function base64Url(bytes) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
}
function fromBase64Url(value, maximum) {
if (typeof value != "string")
throw new Error("WebDAV 加密载荷类型无效");
const source = value;
if (!source || !/^[A-Za-z0-9_-]+$/u.test(source) || source.length > Math.ceil(maximum * 4 / 3) + 4)
throw new Error("WebDAV 加密载荷长度无效");
const base64 = source.replaceAll("-", "+").replaceAll("_", "/"), padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="), binary = atob(padded);
if (binary.length > maximum) throw new Error("WebDAV 加密载荷超过安全上限");
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1)
bytes[index] = binary.charCodeAt(index);
return bytes;
}
function readerWebDavEncryptedSecretMatchesSchema(value) {
const source = record(value);
if (!source || Object.keys(source).length !== 8 || ![
"format",
"version",
"kdf",
"iterations",
"salt",
"cipher",
"iv",
"ciphertext"
].every((key) => Object.hasOwn(source, key)) || source.format !== READER_WEBDAV_SECRET_FORMAT || source.version !== 1 || source.kdf !== "PBKDF2-SHA-256" || source.cipher !== "AES-256-GCM" || typeof source.iterations != "number" || !Number.isSafeInteger(source.iterations) || source.iterations < 1e5 || source.iterations > 1e6) return !1;
try {
const salt = fromBase64Url(source.salt, 16), iv = fromBase64Url(source.iv, 12), ciphertext = fromBase64Url(
source.ciphertext,
1048576
);
return salt.length === 16 && iv.length === 12 && ciphertext.length >= 16 && base64Url(salt) === source.salt && base64Url(iv) === source.iv && base64Url(ciphertext) === source.ciphertext;
} catch {
return !1;
}
}
async function encryptionKey(secret, salt, iterations, usage) {
if (!secret) throw new Error("WebDAV 应用密码为空,无法加密翻译设置");
const crypto = cryptoPort(), material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
"PBKDF2",
!1,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
hash: "SHA-256",
salt,
iterations
},
material,
{ name: "AES-GCM", length: 256 },
!1,
[usage]
);
}
async function encryptReaderWebDavSecret(value, secret, associatedData) {
const crypto = cryptoPort(), salt = crypto.getRandomValues(new Uint8Array(16)), iv = crypto.getRandomValues(new Uint8Array(12)), key = await encryptionKey(secret, salt, 21e4, "encrypt"), ciphertext = new Uint8Array(await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
additionalData: new TextEncoder().encode(associatedData)
},
key,
new TextEncoder().encode(JSON.stringify(value))
));
if (ciphertext.length > 1048576)
throw new Error("翻译 API Key 加密后超过 WebDAV 安全上限");
return Object.freeze({
format: READER_WEBDAV_SECRET_FORMAT,
version: 1,
kdf: "PBKDF2-SHA-256",
iterations: 21e4,
salt: base64Url(salt),
cipher: "AES-256-GCM",
iv: base64Url(iv),
ciphertext: base64Url(ciphertext)
});
}
async function decryptReaderWebDavSecret(value, secret, associatedData) {
try {
if (!readerWebDavEncryptedSecretMatchesSchema(value))
throw new Error("unsupported envelope");
const source = value, iterations = source.iterations, salt = fromBase64Url(source.salt, 16), iv = fromBase64Url(source.iv, 12);
if (salt.length !== 16 || iv.length !== 12)
throw new Error("invalid nonce");
const ciphertext = fromBase64Url(
source.ciphertext,
1048576
), key = await encryptionKey(secret, salt, iterations, "decrypt"), plaintext = await cryptoPort().subtle.decrypt(
{
name: "AES-GCM",
iv,
additionalData: new TextEncoder().encode(associatedData)
},
key,
ciphertext
);
return JSON.parse(new TextDecoder().decode(plaintext));
} catch {
throw new Error(
"WebDAV 翻译 API Key 解密失败;请确认应用密码与加密时一致"
);
}
}
}, "e6fb2aa401af8addb0db5eca34d0e7fa22c3b8a25d4df2f1592660cd818a670a");
runtime.markLibrary("main-lite-platform");
})();