// ==UserScript==
// @name 骨碌碌本地作品检索
// @namespace https://www.gululu.world/
// @version 0.2.0
// @description 为骨碌碌文字世界提供全库本地同步、组合筛选、排序、随机推送和历史记录筛选。
// @author Gululu Search Contributors
// @match https://www.gululu.world/
// @match https://create.gululu.world/*
// @connect backend.gululu.world
// @connect image.gululu.world
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @run-at document-idle
// @license MIT
// ==/UserScript==
(() => {
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
// src/database.js
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function transactionDone(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error("\u6570\u636E\u5E93\u4E8B\u52A1\u5DF2\u4E2D\u6B62"));
});
}
function openDatabase() {
if (databasePromise) return databasePromise;
databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
const works = database.createObjectStore(WORKS_STORE, { keyPath: "id" });
works.createIndex("updateTimestamp", "updateTimestamp");
works.createIndex("authorId", "authorId");
database.createObjectStore(META_STORE, { keyPath: "key" });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return databasePromise;
}
async function putWorks(works) {
if (!works.length) return;
const database = await openDatabase();
const transaction = database.transaction(WORKS_STORE, "readwrite");
const store = transaction.objectStore(WORKS_STORE);
for (const work of works) store.put(work);
await transactionDone(transaction);
}
async function putWorksPreservingTags(works) {
if (!works.length) return;
const database = await openDatabase();
const readTransaction = database.transaction(WORKS_STORE, "readonly");
const readStore = readTransaction.objectStore(WORKS_STORE);
const existing = await Promise.all(works.map((work) => requestResult(readStore.get(work.id))));
const merged = works.map((work, index) => {
const cached = existing[index];
if (!cached?.tagsLoadedAt && !cached?.indexedTags?.length) return work;
return {
...work,
tags: cached.tags ?? [],
tagsLoadedAt: cached.tagsLoadedAt,
indexedTags: cached.indexedTags ?? []
};
});
await putWorks(merged);
}
async function applyCompleteTagIndex(tagName2, matchingIds) {
const tag = String(tagName2 ?? "").trim();
if (!tag) return;
const database = await openDatabase();
const transaction = database.transaction(WORKS_STORE, "readwrite");
const done = transactionDone(transaction);
const store = transaction.objectStore(WORKS_STORE);
await new Promise((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return resolve();
const work = cursor.value;
const indexedTags = new Set(Array.isArray(work.indexedTags) ? work.indexedTags : []);
const tags = new Set(Array.isArray(work.tags) ? work.tags : []);
const matches = matchingIds.has(String(work.id));
const changed = matches ? !indexedTags.has(tag) || !tags.has(tag) : indexedTags.has(tag) || tags.has(tag);
if (matches) {
indexedTags.add(tag);
tags.add(tag);
} else {
indexedTags.delete(tag);
tags.delete(tag);
}
if (changed) cursor.update({ ...work, indexedTags: [...indexedTags], tags: [...tags] });
cursor.continue();
};
request.onerror = () => reject(request.error);
});
await done;
}
async function getAllWorks() {
const database = await openDatabase();
const transaction = database.transaction(WORKS_STORE, "readonly");
return requestResult(transaction.objectStore(WORKS_STORE).getAll());
}
async function countWorks() {
const database = await openDatabase();
const transaction = database.transaction(WORKS_STORE, "readonly");
return requestResult(transaction.objectStore(WORKS_STORE).count());
}
async function getMeta(key) {
const database = await openDatabase();
const transaction = database.transaction(META_STORE, "readonly");
const record = await requestResult(transaction.objectStore(META_STORE).get(key));
return record?.value;
}
async function setMeta(key, value) {
const database = await openDatabase();
const transaction = database.transaction(META_STORE, "readwrite");
transaction.objectStore(META_STORE).put({ key, value });
await transactionDone(transaction);
}
async function removeWorksNotSeenInRun(runId) {
const database = await openDatabase();
const transaction = database.transaction(WORKS_STORE, "readwrite");
const done = transactionDone(transaction);
const store = transaction.objectStore(WORKS_STORE);
await new Promise((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return resolve();
if (cursor.value.lastFullSyncRun !== runId) cursor.delete();
cursor.continue();
};
request.onerror = () => reject(request.error);
});
await done;
}
var DATABASE_NAME, DATABASE_VERSION, WORKS_STORE, META_STORE, databasePromise;
var init_database = __esm({
"src/database.js"() {
DATABASE_NAME = "gululu-enhanced-recommendations";
DATABASE_VERSION = 1;
WORKS_STORE = "works";
META_STORE = "meta";
}
});
// src/conditions.js
function evaluateNode(node, matcher) {
if (node?.type === "term") return matcher(node.value);
if (node?.type !== "group" || !node.children?.length) return false;
return node.operator === "AND" ? node.children.every((child) => evaluateNode(child, matcher)) : node.children.some((child) => evaluateNode(child, matcher));
}
function evaluateForest(nodes, matcher) {
return !nodes?.length || nodes.some((node) => evaluateNode(node, matcher));
}
function evaluateConditionNode(node, matcher) {
if (node?.type === "term") return matcher(node);
if (node?.type !== "group" || !node.children?.length) return false;
return node.operator === "AND" ? node.children.every((child) => evaluateConditionNode(child, matcher)) : node.children.some((child) => evaluateConditionNode(child, matcher));
}
function evaluateConditionForest(nodes, matcher) {
return !nodes?.length || nodes.some((node) => evaluateConditionNode(node, matcher));
}
function collectConditionTerms(nodes) {
const visit = (node) => node?.type === "term" ? [node] : node?.children?.flatMap(visit) ?? [];
return (nodes ?? []).flatMap(visit);
}
var init_conditions = __esm({
"src/conditions.js"() {
}
});
// src/search.js
function optionalNumber(value) {
if (value === "" || value == null) return null;
const result = Number(value);
return Number.isFinite(result) ? result : null;
}
function numericValue(work, field) {
const result = Number(work[field]);
if (Number.isFinite(result) && result !== 0) return result;
if (field === "createTimestamp" && work.createTime) {
const timestamp = Date.parse(String(work.createTime).replace(" ", "T"));
if (Number.isFinite(timestamp)) return timestamp;
}
if (field === "updateTimestamp" && work.updateTime) {
const timestamp = Date.parse(String(work.updateTime).replace(" ", "T"));
if (Number.isFinite(timestamp)) return timestamp;
}
return Number.isFinite(result) ? result : 0;
}
function rangeFor(criteria, field) {
const suffix = `${field[0].toUpperCase()}${field.slice(1)}`;
return [optionalNumber(criteria[`min${suffix}`]), optionalNumber(criteria[`max${suffix}`])];
}
function matchesRange(work, field, criteria) {
const [minimum, maximum] = rangeFor(criteria, field);
if (minimum != null && maximum != null && minimum > maximum) return false;
const value = numericValue(work, field);
return (minimum == null || value >= minimum) && (maximum == null || value <= maximum);
}
function compare(left, right, field) {
if (["title", "authorName"].includes(field)) {
return String(left[field] ?? "").localeCompare(String(right[field] ?? ""), "zh-CN");
}
return numericValue(left, field) - numericValue(right, field);
}
function matchesTimeFilter(work, criteria, prefix, field, now) {
const timestamp = numericValue(work, field);
const mode = criteria[`${prefix}TimeMode`] ?? "recent";
if (mode === "recent") {
const amount = optionalNumber(criteria[`${prefix}RecentValue`]);
if (amount == null) return true;
const unit = UNIT_MILLISECONDS[criteria[`${prefix}RecentUnit`]] ?? UNIT_MILLISECONDS.days;
return timestamp >= now - amount * unit && timestamp <= now;
}
const startRaw = criteria[`${prefix}Start`];
const endRaw = criteria[`${prefix}End`];
const start2 = startRaw ? Date.parse(startRaw) : null;
const end = endRaw ? Date.parse(endRaw) : null;
if (start2 != null && end != null && start2 > end) return false;
return (start2 == null || timestamp >= start2) && (end == null || timestamp <= end);
}
function seededRandom(seed) {
let value = Number(seed) >>> 0;
return () => {
value += 1831565813;
let result = value;
result = Math.imul(result ^ result >>> 15, result | 1);
result ^= result + Math.imul(result ^ result >>> 7, result | 61);
return ((result ^ result >>> 14) >>> 0) / 4294967296;
};
}
function shuffle(items, seed) {
const result = [...items];
const random = seededRandom(seed);
for (let index = result.length - 1; index > 0; index -= 1) {
const target = Math.floor(random() * (index + 1));
[result[index], result[target]] = [result[target], result[index]];
}
return result;
}
function searchWorks(works, customCriteria = {}, context = {}) {
const criteria = { ...DEFAULT_SEARCH, ...customCriteria };
const query = String(criteria.query ?? "").trim().toLocaleLowerCase();
const authorQuery = String(criteria.authorQuery ?? "").trim().toLocaleLowerCase();
const sortBy = SORT_FIELDS.includes(criteria.sortBy) ? criteria.sortBy : DEFAULT_SEARCH.sortBy;
const direction = criteria.sortDirection === "asc" ? 1 : -1;
const pageSize = Math.min(100, Math.max(1, Number(criteria.pageSize) || DEFAULT_SEARCH.pageSize));
const now = Number.isFinite(context.now) ? context.now : Date.now();
const matchesTypedTerm = (work, term) => {
const value = String(term?.value ?? "").toLocaleLowerCase();
if (!value) return false;
const field = term?.field ?? "text";
if (field === "tag") return context.tagTermIds?.get(String(term.value).trim())?.has(String(work.id)) ?? false;
if (field === "title") return String(work.title ?? "").toLocaleLowerCase().includes(value);
if (field === "author") return String(work.authorName ?? "").toLocaleLowerCase().includes(value);
if (field === "summary") return String(work.summary ?? "").toLocaleLowerCase().includes(value);
const haystack = `${work.title ?? ""}
${work.authorName ?? ""}
${work.summary ?? ""}`.toLocaleLowerCase();
return haystack.includes(value);
};
const matches = works.filter((work) => {
if (criteria.type !== "all" && work.type !== criteria.type) return false;
if (context.tagIds && !context.tagIds.has(String(work.id))) return false;
if (context.excludeTagIds?.has(String(work.id))) return false;
if (criteria.collectionMode === "include" && !context.collectionIds?.has(String(work.id))) return false;
if (criteria.collectionMode === "exclude" && context.collectionIds?.has(String(work.id))) return false;
if (criteria.historyMode === "include" && !context.historyIds?.has(String(work.id))) return false;
if (criteria.historyMode === "exclude" && context.historyIds?.has(String(work.id))) return false;
if (!matchesTimeFilter(work, criteria, "publish", "createTimestamp", now)) return false;
if (!matchesTimeFilter(work, criteria, "update", "updateTimestamp", now)) return false;
if (authorQuery && !String(work.authorName ?? "").toLocaleLowerCase().includes(authorQuery)) return false;
const author = String(work.authorName ?? "").toLocaleLowerCase();
if (!evaluateForest(criteria.authorConditions, (term) => author.includes(String(term).toLocaleLowerCase()))) return false;
if (criteria.excludeAuthorConditions?.length && evaluateForest(criteria.excludeAuthorConditions, (term) => author.includes(String(term).toLocaleLowerCase()))) return false;
if (query) {
const haystack = `${work.title ?? ""}
${work.authorName ?? ""}
${work.summary ?? ""}`.toLocaleLowerCase();
if (!haystack.includes(query)) return false;
}
if (!evaluateForest(criteria.keywordConditions, (term) => {
const haystack = `${work.title ?? ""}
${work.authorName ?? ""}
${work.summary ?? ""}`.toLocaleLowerCase();
return haystack.includes(String(term).toLocaleLowerCase());
})) return false;
if (criteria.excludeKeywordConditions?.length && evaluateForest(criteria.excludeKeywordConditions, (term) => {
const haystack = `${work.title ?? ""}
${work.authorName ?? ""}
${work.summary ?? ""}`.toLocaleLowerCase();
return haystack.includes(String(term).toLocaleLowerCase());
})) return false;
if (!evaluateConditionForest(criteria.filterConditions, (term) => matchesTypedTerm(work, term))) return false;
if (criteria.excludeFilterConditions?.length && evaluateConditionForest(criteria.excludeFilterConditions, (term) => matchesTypedTerm(work, term))) return false;
return NUMERIC_FIELDS.every((field) => matchesRange(work, field, criteria));
});
const ordered = criteria.mode === "random" ? shuffle(matches, criteria.randomSeed) : matches.sort(
(left, right) => direction * (sortBy === "lastReadTimestamp" ? (context.historyReadTimes?.get(String(left.id)) ?? 0) - (context.historyReadTimes?.get(String(right.id)) ?? 0) : compare(left, right, sortBy)) || String(left.id).localeCompare(String(right.id))
);
const totalPages = Math.max(1, Math.ceil(ordered.length / pageSize));
const page = Math.min(totalPages, Math.max(1, Number(criteria.page) || 1));
const start2 = (page - 1) * pageSize;
return { total: ordered.length, totalPages, page, pageSize, items: ordered.slice(start2, start2 + pageSize) };
}
var NUMERIC_FIELDS, SORT_FIELDS, DEFAULT_SEARCH, UNIT_MILLISECONDS;
var init_search = __esm({
"src/search.js"() {
init_conditions();
NUMERIC_FIELDS = Object.freeze([
"words",
"readNum",
"commentNum",
"floorNum"
]);
SORT_FIELDS = Object.freeze([
"updateTimestamp",
"createTimestamp",
"lastReadTimestamp",
...NUMERIC_FIELDS,
"title",
"authorName"
]);
DEFAULT_SEARCH = Object.freeze({
query: "",
keywordConditions: [],
excludeKeywordConditions: [],
authorQuery: "",
authorConditions: [],
excludeAuthorConditions: [],
tag: "",
tagConditions: [],
excludeTagConditions: [],
filterConditions: [],
excludeFilterConditions: [],
type: "all",
minWords: null,
maxWords: null,
minReadNum: null,
maxReadNum: null,
minCommentNum: null,
maxCommentNum: null,
minFloorNum: null,
maxFloorNum: null,
sortBy: "updateTimestamp",
sortDirection: "desc",
mode: "sort",
randomSeed: 1,
page: 1,
pageSize: 20,
collectionMode: "any",
historyMode: "any",
publishTimeMode: "recent",
publishRecentValue: "",
publishRecentUnit: "days",
publishStart: "",
publishEnd: "",
updateTimeMode: "recent",
updateRecentValue: "",
updateRecentUnit: "days",
updateStart: "",
updateEnd: ""
});
UNIT_MILLISECONDS = Object.freeze({
hours: 36e5,
days: 864e5,
weeks: 7 * 864e5,
months: 30 * 864e5
});
}
});
// src/recommender.js
function list(value) {
return Array.isArray(value) ? value : [];
}
function text(value) {
return value == null ? "" : String(value).trim();
}
function number(value) {
const result = Number(value);
return Number.isFinite(result) ? result : 0;
}
function tagName(tag) {
return text(typeof tag === "string" ? tag : tag?.name ?? tag?.tagName);
}
function normalizeCandidate(raw, source = "unknown") {
const id = text(raw.opusId ?? raw.bookId ?? raw.id);
const author = raw.author && typeof raw.author === "object" ? raw.author : {};
return {
id,
title: text(raw.opusName ?? raw.title ?? raw.name) || "\u672A\u547D\u540D\u4F5C\u54C1",
authorId: text(raw.userId ?? raw.authorId ?? author.userId ?? author.id),
authorName: text(raw.nickName ?? raw.authorName ?? author.nickName ?? author.name) || "\u672A\u77E5\u4F5C\u8005",
tags: list(raw.tags ?? raw.tagList).map(tagName).filter(Boolean),
updateTime: text(raw.updateTime ?? raw.updatedAt ?? raw.createTime),
updateTimestamp: number(raw.updateTimestamp),
createTime: text(raw.createTime),
cover: text(raw.opusCoverPic ?? raw.coverPic ?? raw.cover ?? raw.firstImg),
summary: text(raw.oneLineText ?? raw.introduction ?? raw.description ?? raw.text),
readNum: number(raw.readNum),
commentNum: number(raw.commentNum),
collectNum: number(raw.collectNum),
goodNum: number(raw.goodNum),
words: number(raw.words),
floorNum: number(raw.floorNum),
type: text(raw.type) || "book",
enable: number(raw.enable),
source,
url: id ? `https://www.gululu.world/book/${encodeURIComponent(id)}` : ""
};
}
var DEFAULT_SETTINGS;
var init_recommender = __esm({
"src/recommender.js"() {
DEFAULT_SETTINGS = Object.freeze({
preferredTags: [],
blockedTags: [],
preferredAuthors: [],
blockedAuthors: [],
seenPenalty: 35,
maxPerAuthor: 2,
resultLimit: 20,
minimumInteractions: 10
});
}
});
// src/sync-policy.js
function parseSiteTime(value) {
if (!value) return NaN;
const timestamp = Date.parse(String(value).trim().replace(" ", "T"));
return Number.isFinite(timestamp) ? timestamp : NaN;
}
function calculateCutoff(lastSuccessfulSync, overlapMinutes = DEFAULT_OVERLAP_MINUTES) {
return Math.max(0, Number(lastSuccessfulSync) - overlapMinutes * 6e4);
}
function pageTimeRange(items) {
const timestamps = items.map((item) => parseSiteTime(item.updateTime)).filter(Number.isFinite);
return {
min: timestamps.length ? Math.min(...timestamps) : NaN,
max: timestamps.length ? Math.max(...timestamps) : NaN,
validCount: timestamps.length
};
}
function updateStalePageCount(currentCount, range, cutoff) {
if (!range.validCount || !Number.isFinite(range.max)) return 0;
return range.max < cutoff ? currentCount + 1 : 0;
}
function shouldContinueFullSync({ received }) {
return received > 0;
}
var DEFAULT_OVERLAP_MINUTES, REQUIRED_STALE_PAGES;
var init_sync_policy = __esm({
"src/sync-policy.js"() {
DEFAULT_OVERLAP_MINUTES = 30;
REQUIRED_STALE_PAGES = 3;
}
});
// src/synchronizer.js
async function fetchPage(page, signal) {
const query = new URLSearchParams({
pageNum: String(page),
pageSize: String(PAGE_SIZE),
isAsc: "asc",
sort: "1"
});
let lastError;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) {
try {
const response = await fetch(`${API_URL}?${query}`, {
headers: { platform: "1" },
cache: "no-store",
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (payload.code !== 200) throw new Error(payload.msg || `\u4E1A\u52A1\u72B6\u6001 ${payload.code}`);
return {
rows: Array.isArray(payload.rows) ? payload.rows : [],
total: Number.isFinite(Number(payload.total)) ? Number(payload.total) : null
};
} catch (error) {
if (error.name === "AbortError") throw error;
lastError = error;
if (attempt < MAX_RETRIES) await wait(1e3 * 2 ** (attempt - 1), signal);
}
}
throw lastError;
}
function storedWork(raw, mode, runId, syncedAt) {
const work = normalizeCandidate(raw, "\u672C\u5730\u7D22\u5F15");
return {
...work,
updateTimestamp: parseSiteTime(raw.updateTime),
createTimestamp: parseSiteTime(raw.createTime),
syncedAt,
lastFullSyncRun: mode === "full" ? runId : void 0
};
}
async function getSyncState() {
return {
count: await countWorks(),
lastSuccessfulSync: await getMeta("lastSuccessfulSync") ?? null,
lastFullSync: await getMeta("lastFullSync") ?? null,
status: await getMeta("syncStatus") ?? { state: "idle" }
};
}
async function synchronize({ mode, signal, onProgress = () => {
} }) {
if (!["full", "incremental"].includes(mode)) throw new Error("\u672A\u77E5\u540C\u6B65\u6A21\u5F0F");
const previousWatermark = await getMeta("lastSuccessfulSync");
if (mode === "incremental" && !previousWatermark) throw new Error("\u8BF7\u5148\u5B8C\u6210\u4E00\u6B21\u5168\u91CF\u540C\u6B65");
const startedAt = Date.now();
const runId = `${startedAt}-${Math.random().toString(36).slice(2)}`;
const cutoff = mode === "incremental" ? calculateCutoff(previousWatermark) : 0;
let page = 1;
let downloaded = 0;
let stored = 0;
let stalePages = 0;
let expectedTotal = null;
const initialStatus = { state: "running", mode, page, downloaded, stored, startedAt, cutoff };
await setMeta("syncStatus", initialStatus);
onProgress(initialStatus);
try {
while (true) {
if (signal?.aborted) throw new DOMException("\u540C\u6B65\u5DF2\u53D6\u6D88", "AbortError");
const { rows, total } = await fetchPage(page, signal);
if (total != null) expectedTotal = total;
downloaded += rows.length;
const range = pageTimeRange(rows);
const selected = mode === "full" ? rows : rows.filter((row) => {
const timestamp = parseSiteTime(row.updateTime);
return !Number.isFinite(timestamp) || timestamp >= cutoff;
});
const syncedAt = Date.now();
await putWorksPreservingTags(selected.map((row) => storedWork(row, mode, runId, syncedAt)));
stored += selected.length;
const progress = {
state: "running",
mode,
page,
downloaded,
stored,
startedAt,
cutoff,
expectedTotal,
pageReceived: rows.length,
pageMinTime: range.min,
pageMaxTime: range.max
};
await setMeta("syncStatus", progress);
onProgress(progress);
if (!rows.length) break;
if (mode === "full") {
if (!shouldContinueFullSync({ page, pageSize: PAGE_SIZE, total: expectedTotal, received: rows.length })) break;
} else {
stalePages = updateStalePageCount(stalePages, range, cutoff);
if (stalePages >= REQUIRED_STALE_PAGES) break;
}
page += 1;
await wait(REQUEST_INTERVAL_MS, signal);
}
if (mode === "full") {
await removeWorksNotSeenInRun(runId);
await setMeta("lastFullSync", startedAt);
}
await setMeta("lastSuccessfulSync", startedAt);
const finalStatus = {
state: "complete",
mode,
page,
downloaded,
stored,
indexed: await countWorks(),
startedAt,
completedAt: Date.now(),
expectedTotal
};
await setMeta("syncStatus", finalStatus);
onProgress(finalStatus);
return finalStatus;
} catch (error) {
const finalStatus = {
state: error.name === "AbortError" ? "cancelled" : "failed",
mode,
page,
downloaded,
stored,
startedAt,
endedAt: Date.now(),
error: error.message
};
await setMeta("syncStatus", finalStatus);
onProgress(finalStatus);
throw error;
}
}
var API_URL, PAGE_SIZE, REQUEST_INTERVAL_MS, MAX_RETRIES, wait;
var init_synchronizer = __esm({
"src/synchronizer.js"() {
init_recommender();
init_database();
init_sync_policy();
API_URL = "https://backend.gululu.world/opus/list-sort";
PAGE_SIZE = 1e3;
REQUEST_INTERVAL_MS = 1200;
MAX_RETRIES = 3;
wait = (milliseconds, signal) => new Promise((resolve, reject) => {
const timer = setTimeout(resolve, milliseconds);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("\u540C\u6B65\u5DF2\u53D6\u6D88", "AbortError"));
}, { once: true });
});
}
});
// src/user-data.js
function extractWorkIds(payload, ids = /* @__PURE__ */ new Set()) {
if (Array.isArray(payload)) {
for (const item of payload) extractWorkIds(item, ids);
} else if (payload && typeof payload === "object") {
if (payload.opusId != null) ids.add(String(payload.opusId));
else if (payload.bookId != null && Number(payload.bookId) > 0) ids.add(String(payload.bookId));
else if (payload.historyId != null && Number(payload.historyId) > 0 && Number(payload.type) === 1) {
ids.add(String(payload.historyId));
}
for (const [key, value] of Object.entries(payload)) {
if (!["opusId", "bookId", "historyId"].includes(key) && (Array.isArray(value) || value && typeof value === "object")) {
extractWorkIds(value, ids);
}
}
}
return ids;
}
function parseReadTime(value) {
if (value == null || value === "") return 0;
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric < 1e10 ? numeric * 1e3 : numeric;
const parsed = Date.parse(String(value).replace(" ", "T"));
return Number.isFinite(parsed) ? parsed : 0;
}
function extractHistoryData(payload, result = { ids: /* @__PURE__ */ new Set(), readTimes: /* @__PURE__ */ new Map() }) {
if (Array.isArray(payload)) {
for (const item of payload) extractHistoryData(item, result);
} else if (payload && typeof payload === "object") {
if (payload.historyId != null && Number(payload.historyId) > 0 && Number(payload.type) === 1) {
const id = String(payload.historyId);
result.ids.add(id);
const timestamp = parseReadTime(payload.readTime);
if (timestamp > (result.readTimes.get(id) ?? 0)) result.readTimes.set(id, timestamp);
}
for (const [key, value] of Object.entries(payload)) {
if (key !== "historyId" && (Array.isArray(value) || value && typeof value === "object")) {
extractHistoryData(value, result);
}
}
}
return result;
}
var init_user_data = __esm({
"src/user-data.js"() {
}
});
// src/background.js
var background_exports = {};
async function loadTagIds(tagName2) {
const tag = String(tagName2 ?? "").trim();
if (!tag) return null;
const cached = tagCache.get(tag);
if (cached && Date.now() - cached.createdAt < 10 * 6e4) return cached.ids;
const ids = /* @__PURE__ */ new Set();
const pageSize = 1e3;
for (let page = 1; page <= 200; page += 1) {
const query = new URLSearchParams({ tagName: tag, pageNum: String(page), pageSize: String(pageSize) });
const response = await fetch(`https://backend.gululu.world/search/opus-tag?${query}`, {
headers: { platform: "1" },
cache: "no-store"
});
if (!response.ok) throw new Error(`\u6807\u7B7E\u63A5\u53E3 HTTP ${response.status}`);
const rows = await response.json();
if (!Array.isArray(rows)) throw new Error("\u6807\u7B7E\u63A5\u53E3\u8FD4\u56DE\u683C\u5F0F\u5F02\u5E38");
if (!rows.length) break;
const before = ids.size;
for (const row of rows) if (row.opusId != null) ids.add(String(row.opusId));
if (ids.size === before) break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
await applyCompleteTagIndex(tag, ids);
tagCache.set(tag, { ids, createdAt: Date.now() });
return ids;
}
function union(sets) {
return new Set(sets.flatMap((set) => [...set]));
}
function intersection(sets) {
if (!sets.length) return /* @__PURE__ */ new Set();
return new Set([...sets[0]].filter((id) => sets.slice(1).every((set) => set.has(id))));
}
async function resolveTagNode(node) {
if (node?.type === "term") return loadTagIds(node.value);
if (node?.type !== "group" || !node.children?.length) return /* @__PURE__ */ new Set();
const children = await Promise.all(node.children.map(resolveTagNode));
return node.operator === "AND" ? intersection(children) : union(children);
}
async function resolveTagForest(criteria) {
const nodes = Array.isArray(criteria.tagConditions) ? criteria.tagConditions : [];
if (!nodes.length && criteria.tag) return loadTagIds(criteria.tag);
if (!nodes.length) return null;
return union(await Promise.all(nodes.map(resolveTagNode)));
}
async function resolveTypedTagTerms(criteria) {
const terms = [
...collectConditionTerms(criteria.filterConditions),
...collectConditionTerms(criteria.excludeFilterConditions)
].filter((term) => term.field === "tag" && String(term.value ?? "").trim());
const names = [...new Set(terms.map((term) => String(term.value).trim()))];
return new Map(await Promise.all(names.map(async (name) => [name, await loadTagIds(name)])));
}
async function fetchUserIds(path, token, paginated = false) {
if (!token) throw new Error("\u8BE5\u7B5B\u9009\u9700\u8981\u5148\u767B\u5F55\u9AA8\u788C\u788C");
const ids = /* @__PURE__ */ new Set();
const maxPages = paginated ? 20 : 1;
for (let page = 1; page <= maxPages; page += 1) {
const suffix = paginated ? `?pageNum=${page}&pageSize=1000` : "";
const response = await fetch(`https://backend.gululu.world${path}${suffix}`, {
headers: { platform: "1", Authorization: `Bearer ${token}` },
cache: "no-store"
});
if (!response.ok) throw new Error(`\u7528\u6237\u6570\u636E\u63A5\u53E3 HTTP ${response.status}`);
const payload = await response.json();
if (payload?.code === 401) throw new Error("\u767B\u5F55\u72B6\u6001\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u9AA8\u788C\u788C");
const before = ids.size;
extractWorkIds(payload, ids);
const rows = Array.isArray(payload) ? payload : payload?.rows ?? payload?.data;
if (!paginated || !Array.isArray(rows) || rows.length < 1e3 || ids.size === before) break;
}
return ids;
}
async function fetchHistory(token) {
if (!token) throw new Error("\u8BE5\u7B5B\u9009\u9700\u8981\u5148\u767B\u5F55\u9AA8\u788C\u788C");
const response = await fetch("https://backend.gululu.world/history/get", {
headers: { platform: "1", Authorization: `Bearer ${token}` },
cache: "no-store"
});
if (!response.ok) throw new Error(`\u7528\u6237\u6570\u636E\u63A5\u53E3 HTTP ${response.status}`);
const payload = await response.json();
if (payload?.code === 401) throw new Error("\u767B\u5F55\u72B6\u6001\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u9AA8\u788C\u788C");
return extractHistoryData(payload);
}
async function loadWorkTags(work) {
if (work.tagsLoadedAt) return work;
const response = await fetch(`https://backend.gululu.world/reader/opus/detail/${encodeURIComponent(work.id)}`, {
headers: { platform: "1" },
cache: "no-store"
});
if (!response.ok) throw new Error(`\u4F5C\u54C1\u6807\u7B7E\u63A5\u53E3 HTTP ${response.status}`);
const payload = await response.json();
if (payload?.code !== 200) throw new Error(payload?.msg || `\u4F5C\u54C1\u6807\u7B7E\u4E1A\u52A1\u72B6\u6001 ${payload?.code}`);
const tags = Array.isArray(payload?.data?.tags) ? payload.data.tags.map((tag) => String(tag?.tagName ?? tag?.name ?? "").trim()).filter(Boolean) : [];
return { ...work, tags, tagsLoadedAt: Date.now() };
}
async function enrichVisibleTags(items) {
const enriched = [...items];
let cursor = 0;
const workers = Array.from({ length: Math.min(4, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++;
try {
enriched[index] = await loadWorkTags(items[index]);
} catch {
enriched[index] = items[index];
}
}
});
await Promise.all(workers);
const changed = enriched.filter((item, index) => item !== items[index]);
if (changed.length) await putWorks(changed);
return enriched;
}
async function searchLocalWorks(criteria, token) {
const needsCollection = criteria.collectionMode && criteria.collectionMode !== "any";
const needsHistory = criteria.historyMode && criteria.historyMode !== "any";
const [candidates, tagIds, excludeTagIds, tagTermIds, collectionIds, historyData] = await Promise.all([
getAllWorks(),
resolveTagForest(criteria),
resolveTagForest({ tagConditions: criteria.excludeTagConditions }),
resolveTypedTagTerms(criteria),
needsCollection ? fetchUserIds("/bookshelf/get-book", token, true) : null,
needsHistory ? fetchHistory(token) : null
]);
const result = searchWorks(candidates, criteria, {
tagIds,
excludeTagIds,
tagTermIds,
collectionIds,
historyIds: historyData?.ids,
historyReadTimes: historyData?.readTimes
});
result.items = await enrichVisibleTags(result.items);
return {
...result,
candidateCount: candidates.length
};
}
var activeSync, tagCache;
var init_background = __esm({
"src/background.js"() {
init_database();
init_search();
init_synchronizer();
init_user_data();
init_conditions();
activeSync = null;
tagCache = /* @__PURE__ */ new Map();
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
let action;
if (message?.type === "GULULU_SEARCH") {
action = searchLocalWorks(message.criteria ?? {}, message.token);
} else if (message?.type === "GULULU_SYNC_STATE") {
action = getSyncState().then((state2) => {
if (state2.status.state === "running" && !activeSync) {
state2.status = { ...state2.status, state: "failed", error: "\u4E0A\u6B21\u540C\u6B65\u610F\u5916\u4E2D\u65AD\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB" };
}
return state2;
});
} else {
return false;
}
action.then((data) => sendResponse({ ok: true, ...data })).catch((error) => sendResponse({ ok: false, error: error.message }));
return true;
});
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "gululu-sync") return;
port.onMessage.addListener((message) => {
if (message?.type === "CANCEL_SYNC") {
activeSync?.controller.abort();
return;
}
if (message?.type !== "START_SYNC") return;
if (activeSync) {
port.postMessage({ state: "failed", error: "\u5DF2\u6709\u540C\u6B65\u4EFB\u52A1\u6B63\u5728\u8FD0\u884C" });
return;
}
const controller = new AbortController();
activeSync = { controller, port };
synchronize({
mode: message.mode,
signal: controller.signal,
onProgress: (progress) => port.postMessage(progress)
}).catch(() => {
}).finally(() => {
activeSync = null;
});
});
port.onDisconnect.addListener(() => {
if (activeSync?.port === port) activeSync.controller.abort();
});
});
}
});
// src/content.js
var content_exports = {};
function element(tag, className, text2) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text2 != null) node.textContent = text2;
return node;
}
function createInput(label, key, { type = "number", placeholder = "\u4E0D\u9650" } = {}) {
const wrapper = element("label", "ger-field");
wrapper.append(element("span", "ger-label", label));
const input = element("input", "ger-input");
input.type = type;
input.name = key;
input.dataset.criteriaKey = key;
input.value = state.criteria[key] ?? "";
input.placeholder = placeholder;
if (type === "number") input.min = "0";
wrapper.append(input);
return wrapper;
}
function createSelect(label, key, options) {
const wrapper = element("label", "ger-field");
wrapper.append(element("span", "ger-label", label));
const select = element("select", "ger-input");
select.name = key;
select.dataset.criteriaKey = key;
for (const [value, text2] of options) {
const option = element("option", "", text2);
option.value = value;
option.selected = String(state.criteria[key]) === value;
select.append(option);
}
wrapper.append(select);
return wrapper;
}
function createRange(label, minimumKey, maximumKey) {
const group = element("fieldset", "ger-range");
group.append(element("legend", "ger-label", label));
const inputs = element("div", "ger-range-inputs");
inputs.append(createInput("\u6700\u5C0F", minimumKey), createInput("\u6700\u5927", maximumKey));
group.append(inputs);
return group;
}
function createTimeFilter(label, prefix) {
const section = element("fieldset", "ger-time-filter");
section.append(element("legend", "ger-label", label));
const modeField = createSelect("\u68C0\u7D22\u65B9\u5F0F", `${prefix}TimeMode`, [["recent", "\u6700\u8FD1\u4E00\u6BB5\u65F6\u95F4"], ["range", "\u8D77\u6B62\u65F6\u95F4"]]);
const recent = element("div", "ger-time-mode");
recent.append(
createInput("\u65F6\u95F4\u957F\u5EA6", `${prefix}RecentValue`, { placeholder: "\u7559\u7A7A\u8868\u793A\u4E0D\u9650" }),
createSelect("\u5355\u4F4D", `${prefix}RecentUnit`, [["hours", "\u5C0F\u65F6"], ["days", "\u5929"], ["weeks", "\u5468"], ["months", "\u6708\uFF0830\u5929\uFF09"]])
);
const range = element("div", "ger-time-mode");
range.append(
createInput("\u5F00\u59CB\u65F6\u95F4", `${prefix}Start`, { type: "datetime-local", placeholder: "" }),
createInput("\u7ED3\u675F\u65F6\u95F4", `${prefix}End`, { type: "datetime-local", placeholder: "" })
);
const select = modeField.querySelector("select");
const updateVisibility = () => {
const isRecent = select.value === "recent";
recent.hidden = !isRecent;
range.hidden = isRecent;
};
select.addEventListener("change", updateVisibility);
section.append(modeField, recent, range);
updateVisibility();
return section;
}
function readCriteria(form) {
const data = new FormData(form);
const criteria = { ...state.criteria };
for (const key of Object.keys(criteria)) {
const value = data.get(key);
if (value != null) criteria[key] = value;
}
criteria.type = "all";
criteria.pageSize = Math.min(100, Math.max(1, Number(criteria.pageSize) || 20));
return criteria;
}
function refreshActiveCriteria(form) {
for (const control of form.querySelectorAll("[data-criteria-key]")) {
const key = control.dataset.criteriaKey;
const value = String(control.value ?? "");
const defaultValue = String(DEFAULT_SEARCH2[key] ?? "");
control.classList.toggle("is-active", value !== "" && value !== defaultValue);
}
for (const group of form.querySelectorAll(".ger-range, .ger-time-filter")) {
group.classList.toggle("is-active", Boolean(group.querySelector(".ger-input.is-active")));
}
for (const group of form.querySelectorAll(".ger-filter-group")) {
const hasActiveControl = Boolean(group.querySelector(".ger-input.is-active"));
const hasCondition = Boolean(group.querySelector(".ger-condition-chip"));
group.classList.toggle("is-active", hasActiveControl || hasCondition);
}
}
function conditionLabel(node) {
if (node?.type === "term") {
const labels = { text: "\u4EFB\u610F\u6587\u672C", title: "\u6807\u9898", author: "\u4F5C\u8005", summary: "\u7B80\u4ECB", tag: "Tag" };
return node.field ? `${labels[node.field] ?? node.field}\uFF1A${node.value}` : node.value;
}
return `(${(node?.children ?? []).map(conditionLabel).join(` ${node.operator} `)})`;
}
function mergeConditions(nodes, indices, operator) {
const selected = [...indices].sort((a, b) => a - b);
if (selected.length < 2) return nodes;
const selectedSet = new Set(selected);
const group = { type: "group", operator, children: selected.map((index) => nodes[index]) };
return nodes.flatMap((node, index) => index === selected[0] ? [group] : selectedSet.has(index) ? [] : [node]);
}
function createConditionEditor(label, key, placeholder, { warning = "", typed = false } = {}) {
const section = element("section", "ger-condition-editor");
if (key.startsWith("exclude")) section.classList.add("is-negative");
const heading = element("div", "ger-condition-heading");
heading.append(element("span", "ger-label", label));
if (warning) {
const badge = element("span", "ger-warning-badge", warning);
badge.title = "\u8BE5\u529F\u80FD\u4F9D\u8D56\u7AD9\u70B9\u6807\u7B7E\u63A5\u53E3\uFF0C\u4E0D\u80FD\u4FDD\u8BC1\u7ED3\u679C\u7EDD\u5BF9\u5B8C\u6574";
heading.append(badge);
}
section.append(heading);
const chips = element("div", "ger-condition-chips");
const inputRow = element("div", "ger-condition-input-row");
let fieldSelect = null;
if (typed) {
fieldSelect = element("select", "ger-input ger-condition-field");
for (const [value, text2] of [["text", "\u4EFB\u610F\u6587\u672C"], ["title", "\u6807\u9898"], ["author", "\u4F5C\u8005"], ["summary", "\u7B80\u4ECB"], ["tag", "Tag"]]) {
const option = element("option", "", text2);
option.value = value;
fieldSelect.append(option);
}
inputRow.append(fieldSelect);
}
const input = element("input", "ger-input");
input.type = "search";
input.placeholder = placeholder;
const hint = element("span", "ger-condition-hint", "\u56DE\u8F66\u6DFB\u52A0\uFF1B\u672A\u5408\u5E76\u9879\u9ED8\u8BA4 OR");
inputRow.append(input, hint);
const controls = element("div", "ger-condition-controls");
const mergeAnd = element("button", "ger-mini-button", "AND \u5408\u5E76");
const mergeOr = element("button", "ger-mini-button", "OR \u5408\u5E76");
mergeAnd.type = mergeOr.type = "button";
const selected = /* @__PURE__ */ new Set();
function render() {
chips.replaceChildren();
const nodes = Array.isArray(state.criteria[key]) ? state.criteria[key] : [];
nodes.forEach((node, index) => {
const chip = element("div", `ger-condition-chip${selected.has(index) ? " is-selected" : ""}`);
const select = element("button", "ger-chip-label", conditionLabel(node));
select.type = "button";
select.setAttribute("aria-pressed", String(selected.has(index)));
select.title = "\u70B9\u51FB\u9009\u62E9\uFF0C\u4EE5\u4FBF\u8FDB\u884C AND/OR \u5408\u5E76";
select.addEventListener("click", () => {
selected.has(index) ? selected.delete(index) : selected.add(index);
render();
});
chip.append(select);
if (node.type === "group") {
const split = element("button", "ger-chip-action", "\u62C6");
split.type = "button";
split.title = "\u62C6\u5206\u7EC4\u5408";
split.addEventListener("click", () => {
state.criteria[key] = [...nodes.slice(0, index), ...node.children, ...nodes.slice(index + 1)];
selected.clear();
render();
});
chip.append(split);
}
const remove = element("button", "ger-chip-action", "\xD7");
remove.type = "button";
remove.title = "\u5220\u9664\u6761\u4EF6";
remove.addEventListener("click", () => {
state.criteria[key] = nodes.filter((_, position) => position !== index);
selected.clear();
render();
});
chip.append(remove);
chips.append(chip);
});
mergeAnd.disabled = mergeOr.disabled = selected.size < 2;
controls.hidden = nodes.length < 2;
const form = section.closest("form");
if (form) refreshActiveCriteria(form);
}
function merge(operator) {
state.criteria[key] = mergeConditions(state.criteria[key] ?? [], selected, operator);
selected.clear();
render();
}
mergeAnd.addEventListener("click", () => merge("AND"));
mergeOr.addEventListener("click", () => merge("OR"));
controls.append(mergeAnd, mergeOr);
input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
const value = input.value.trim();
if (!value) return;
state.criteria[key] = [...state.criteria[key] ?? [], {
type: "term",
field: typed ? fieldSelect.value : void 0,
value
}];
input.value = "";
render();
});
section.append(chips, inputRow, controls);
render();
return section;
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function metricTone(value, thresholds) {
const number2 = Number(value || 0);
if (number2 <= thresholds[0]) return "muted";
if (number2 <= thresholds[1]) return "green";
if (number2 <= thresholds[2]) return "red";
return "orange";
}
function metricChip(label, value, thresholds) {
const chip = element("span", `ger-metric is-${metricTone(value, thresholds)}`);
chip.title = `${label} ${formatNumber(value)}`;
chip.append(element("span", "ger-metric-label", label));
chip.append(element("span", "ger-metric-value", formatNumber(value)));
return chip;
}
function renderMetrics(item) {
const metrics = element("div", "ger-metrics");
metrics.append(
metricChip("\u5B57\u6570", item.words, [9999, 99999, 999999]),
metricChip("\u9605\u8BFB", item.readNum, [999, 9999, 99999]),
metricChip("\u697C\u5C42", item.floorNum, [9, 99, 999]),
metricChip("\u8BC4\u8BBA", item.commentNum, [9, 99, 999])
);
return metrics;
}
function renderCard(item) {
const card = element("article", "ger-card");
if (item.cover) {
const image = element("img", "ger-cover");
image.src = item.cover;
image.alt = "";
image.loading = "lazy";
card.append(image);
}
const body = element("div", "ger-card-body");
const title = element("a", "ger-title", item.title);
title.href = item.url;
title.target = "_blank";
title.rel = "noopener noreferrer";
body.append(title);
body.append(element("div", "ger-meta", `${item.authorName} \xB7 ${item.type === "game" ? "\u4E92\u52A8\u4F5C\u54C1" : "\u4F5C\u54C1"}`));
body.append(element("div", "ger-meta", `\u53D1\u5E03 ${item.createTime || "\u672A\u77E5"} \xB7 \u66F4\u65B0 ${item.updateTime || "\u672A\u77E5"}`));
if (item.summary) body.append(element("p", "ger-summary", item.summary));
body.append(renderMetrics(item));
if (Array.isArray(item.tags) && item.tags.length) {
const tags = element("div", "ger-tags");
tags.setAttribute("aria-label", "\u4F5C\u54C1\u6807\u7B7E");
for (const tag of item.tags) tags.append(element("span", "ger-tag", tag));
body.append(tags);
}
card.append(body);
return card;
}
function formatTime(timestamp) {
return timestamp ? new Date(timestamp).toLocaleString("zh-CN") : "\u5C1A\u672A\u5B8C\u6210\u540C\u6B65";
}
function renderSyncState(panel, syncState) {
const text2 = panel.querySelector(".ger-sync-status");
const incremental = panel.querySelector('[data-sync-mode="incremental"]');
const cancel = panel.querySelector(".ger-sync-cancel");
const running = syncState.state === "running";
incremental.disabled = running || !syncState.lastSuccessfulSync;
panel.querySelector('[data-sync-mode="full"]').disabled = running;
cancel.hidden = !running;
if (running) {
text2.textContent = `${syncState.mode === "full" ? "\u5168\u91CF" : "\u589E\u91CF"}\u540C\u6B65\u4E2D\uFF1A\u7B2C ${syncState.page} \u9875\uFF0C\u4E0B\u8F7D ${syncState.downloaded} \u6761\uFF0C\u5199\u5165 ${syncState.stored} \u6761`;
} else {
text2.textContent = `\u672C\u5730\u7D22\u5F15 ${syncState.count ?? syncState.indexed ?? 0} \u6761\uFF1B\u4E0A\u6B21\u6210\u529F\uFF1A${formatTime(syncState.lastSuccessfulSync)}`;
if (syncState.state === "failed") text2.textContent += `\uFF1B\u4E0A\u6B21\u5931\u8D25\uFF1A${syncState.error}`;
if (syncState.state === "cancelled") text2.textContent += "\uFF1B\u4E0A\u6B21\u540C\u6B65\u5DF2\u53D6\u6D88";
}
}
async function loadSyncState(panel) {
const response = await sendMessage({ type: "GULULU_SYNC_STATE" });
if (!response?.ok) throw new Error(response?.error || "\u65E0\u6CD5\u8BFB\u53D6\u540C\u6B65\u72B6\u6001");
renderSyncState(panel, { ...response.status, ...response });
}
function startSync(panel, mode) {
if (syncPort) return;
syncPort = chrome.runtime.connect({ name: "gululu-sync" });
syncPort.onMessage.addListener(async (progress) => {
renderSyncState(panel, progress);
if (["complete", "failed", "cancelled"].includes(progress.state)) {
syncPort.disconnect();
syncPort = null;
await loadSyncState(panel).catch(() => {
});
if (progress.state === "complete") runSearch(panel);
}
});
syncPort.onDisconnect.addListener(() => {
syncPort = null;
});
syncPort.postMessage({ type: "START_SYNC", mode });
}
function createSyncPanel(panel) {
const section = element("section", "ger-sync");
const copy = element("div");
copy.append(element("h3", "ger-sync-heading", "\u672C\u5730\u5019\u9009\u6C60"));
copy.append(element("div", "ger-sync-status", "\u6B63\u5728\u8BFB\u53D6\u672C\u5730\u7D22\u5F15\u2026"));
const actions = element("div", "ger-sync-actions");
const full = element("button", "ger-button", "\u5168\u91CF\u540C\u6B65");
full.type = "button";
full.dataset.syncMode = "full";
full.addEventListener("click", () => startSync(panel, "full"));
const incremental = element("button", "ger-button ger-primary", "\u540C\u6B65\u5230\u6700\u65B0");
incremental.type = "button";
incremental.dataset.syncMode = "incremental";
incremental.disabled = true;
incremental.addEventListener("click", () => startSync(panel, "incremental"));
const cancel = element("button", "ger-button ger-sync-cancel", "\u505C\u6B62");
cancel.type = "button";
cancel.hidden = true;
cancel.addEventListener("click", () => syncPort?.postMessage({ type: "CANCEL_SYNC" }));
actions.append(full, incremental, cancel);
section.append(copy, actions);
return section;
}
async function runSearch(panel) {
if (state.loading) return;
state.loading = true;
const status = panel.querySelector(".ger-status");
const results = panel.querySelector(".ger-results");
const loadingOverlay = panel.querySelector(".ger-loading-overlay");
loadingOverlay.hidden = false;
panel.setAttribute("aria-busy", "true");
status.textContent = "\u6B63\u5728\u68C0\u7D22\u672C\u5730\u7D22\u5F15\u2026";
try {
const needsLogin = state.criteria.collectionMode !== "any" || state.criteria.historyMode !== "any";
const token = needsLogin ? readCookie("token") : "";
const response = await sendMessage({ type: "GULULU_SEARCH", criteria: state.criteria, token });
if (!response?.ok) throw new Error(response?.error || "\u672A\u77E5\u9519\u8BEF");
if (!response.candidateCount) {
status.textContent = "\u672C\u5730\u5019\u9009\u6C60\u4E3A\u7A7A\uFF0C\u8BF7\u5148\u70B9\u51FB\u201C\u5168\u91CF\u540C\u6B65\u201D\u3002";
} else {
status.textContent = `\u672C\u5730\u5171 ${response.candidateCount} \u6761\uFF1B\u5339\u914D ${response.total} \u6761\uFF1B\u7B2C ${response.page}/${response.totalPages} \u9875`;
}
results.replaceChildren();
panel.querySelector(".ger-pagination").replaceChildren();
for (const item of response.items) results.append(renderCard(item));
if (response.candidateCount && !response.items.length) results.append(element("div", "ger-empty", "\u6CA1\u6709\u4F5C\u54C1\u7B26\u5408\u5F53\u524D\u6761\u4EF6\u3002"));
renderPagination(panel, response);
} catch (error) {
status.textContent = `\u68C0\u7D22\u5931\u8D25\uFF1A${error.message}`;
} finally {
state.loading = false;
loadingOverlay.hidden = true;
panel.setAttribute("aria-busy", "false");
}
}
function readCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const entry = document.cookie.split("; ").find((part) => part.startsWith(prefix));
return entry ? decodeURIComponent(entry.slice(prefix.length)) : "";
}
function renderPagination(panel, response) {
const pager = panel.querySelector(".ger-pagination");
pager.replaceChildren();
if (!response.total || response.totalPages <= 1) return;
const previous = element("button", "ger-button", "\u4E0A\u4E00\u9875");
previous.type = "button";
previous.disabled = response.page <= 1;
previous.addEventListener("click", () => {
state.criteria.page = response.page - 1;
runSearch(panel);
});
const label = element("span", "ger-page-label", `${response.page} / ${response.totalPages}`);
const next = element("button", "ger-button", "\u4E0B\u4E00\u9875");
next.type = "button";
next.disabled = response.page >= response.totalPages;
next.addEventListener("click", () => {
state.criteria.page = response.page + 1;
runSearch(panel);
});
pager.append(previous, label, next);
}
function createFilterGroup(title, children, { open = true, negative = false } = {}) {
const details = element("details", "ger-filter-group");
details.open = open;
if (negative) details.classList.add("is-negative");
details.append(element("summary", "ger-filter-group-title", title));
const content = element("div", "ger-filter-group-content");
content.append(...children);
details.append(content);
return details;
}
function createSearchForm(panel) {
const form = element("form", "ger-form");
const historyField = createSelect("\u5386\u53F2\u8BB0\u5F55\u7B5B\u9009", "historyMode", [["any", "\u4E0D\u9650"], ["include", "\u53EA\u770B\u8BB0\u5F55\u5185\u4F5C\u54C1"], ["exclude", "\u4E0D\u770B\u8BB0\u5F55\u5185\u4F5C\u54C1"]]);
const sortField = createSelect("\u6392\u5E8F\u6307\u6807", "sortBy", [
["updateTimestamp", "\u66F4\u65B0\u65F6\u95F4"],
["createTimestamp", "\u521B\u5EFA\u65F6\u95F4"],
["lastReadTimestamp", "\u6700\u540E\u6D4F\u89C8\u65F6\u95F4"],
["words", "\u5B57\u6570"],
["readNum", "\u9605\u8BFB\u6570"],
["commentNum", "\u8BC4\u8BBA\u6570"],
["floorNum", "\u697C\u5C42\u6570"],
["title", "\u6807\u9898"],
["authorName", "\u4F5C\u8005"]
]);
form.append(
createFilterGroup("\u5173\u952E\u8BCD\u7B5B\u9009", [
createConditionEditor("\u8DE8\u5B57\u6BB5\u6761\u4EF6", "filterConditions", "\u9009\u62E9\u7C7B\u578B\uFF0C\u8F93\u5165\u5185\u5BB9\u540E\u6309\u56DE\u8F66", { warning: "Tag \u53EF\u80FD\u6F0F\u68C0", typed: true })
]),
createFilterGroup("\u5173\u952E\u8BCD\u8D1F\u5411\u7B5B\u9009", [
createConditionEditor("\u8DE8\u5B57\u6BB5\u6392\u9664\u6761\u4EF6", "excludeFilterConditions", "\u9009\u62E9\u7C7B\u578B\uFF0C\u8F93\u5165\u6392\u9664\u5185\u5BB9\u540E\u6309\u56DE\u8F66", { warning: "Tag \u53EF\u80FD\u6F0F\u68C0", typed: true })
], { open: false, negative: true }),
createFilterGroup("\u5185\u5BB9\u7B5B\u9009", [
createSelect("\u6536\u85CF\u7B5B\u9009", "collectionMode", [["any", "\u4E0D\u9650"], ["include", "\u53EA\u770B\u6536\u85CF"], ["exclude", "\u4E0D\u770B\u6536\u85CF"]]),
historyField
]),
createFilterGroup("\u6570\u636E\u7B5B\u9009", [
createTimeFilter("\u53D1\u5E03\u65F6\u95F4", "publish"),
createTimeFilter("\u6700\u540E\u66F4\u65B0\u65F6\u95F4", "update"),
createRange("\u5B57\u6570", "minWords", "maxWords"),
createRange("\u9605\u8BFB\u6570", "minReadNum", "maxReadNum"),
createRange("\u8BC4\u8BBA\u6570", "minCommentNum", "maxCommentNum"),
createRange("\u697C\u5C42\u6570", "minFloorNum", "maxFloorNum"),
createSelect("\u6D4F\u89C8\u6A21\u5F0F", "mode", [["sort", "\u6392\u5E8F\u6A21\u5F0F"], ["random", "\u6761\u4EF6\u5185\u968F\u673A\u63A8\u9001"]]),
sortField,
createSelect("\u6392\u5E8F\u65B9\u5411", "sortDirection", [["desc", "\u4ECE\u9AD8\u5230\u4F4E / \u4ECE\u65B0\u5230\u65E7"], ["asc", "\u4ECE\u4F4E\u5230\u9AD8 / \u4ECE\u65E7\u5230\u65B0"]]),
createSelect("\u6BCF\u9875\u6570\u91CF", "pageSize", [["10", "10 \u6761"], ["20", "20 \u6761"], ["50", "50 \u6761"], ["100", "100 \u6761"]])
])
);
const actions = element("div", "ger-actions");
const submit = element("button", "ger-button ger-primary", "\u6267\u884C\u68C0\u7D22");
submit.type = "submit";
const reset = element("button", "ger-button", "\u91CD\u7F6E\u6761\u4EF6");
reset.type = "button";
reset.addEventListener("click", async () => {
state.criteria = { ...DEFAULT_SEARCH2 };
await chrome.storage.local.set({ [STORAGE_KEY]: state.criteria });
const replacement = createSearchForm(panel);
form.replaceWith(replacement);
runSearch(panel);
});
actions.append(submit, reset);
form.append(actions);
form.addEventListener("submit", async (event) => {
event.preventDefault();
state.criteria = readCriteria(form);
state.criteria.page = 1;
if (state.criteria.mode === "random") state.criteria.randomSeed = Date.now();
await chrome.storage.local.set({ [STORAGE_KEY]: state.criteria });
runSearch(panel);
});
form.addEventListener("input", () => refreshActiveCriteria(form));
form.addEventListener("change", () => refreshActiveCriteria(form));
const historySelect = historyField.querySelector("select");
const sortSelect = sortField.querySelector("select");
const lastReadOption = sortSelect.querySelector('option[value="lastReadTimestamp"]');
const updateHistorySort = () => {
const enabled = historySelect.value === "include";
lastReadOption.hidden = !enabled;
if (!enabled && sortSelect.value === "lastReadTimestamp") sortSelect.value = DEFAULT_SEARCH2.sortBy;
refreshActiveCriteria(form);
};
historySelect.addEventListener("change", updateHistorySort);
updateHistorySort();
refreshActiveCriteria(form);
return form;
}
function createPanel() {
const panel = element("section", "ger-panel");
panel.id = "gululu-enhanced-recommendations";
const header = element("div", "ger-header");
const heading = element("div");
heading.append(element("h2", "ger-heading", "\u672C\u5730\u4F5C\u54C1\u68C0\u7D22"));
heading.append(element("p", "ger-subtitle", "\u4F7F\u7528\u660E\u786E\u7684\u7B5B\u9009\u6761\u4EF6\u548C\u4F20\u7EDF\u6392\u5E8F\uFF0C\u4E0D\u4F7F\u7528\u79EF\u5206\u63A8\u8350\u3002"));
const searchButton = element("button", "ger-button ger-primary", "\u6267\u884C\u68C0\u7D22");
searchButton.type = "button";
searchButton.addEventListener("click", () => panel.querySelector(".ger-form").requestSubmit());
const collapseButton = element("button", "ger-button ger-collapse-button", "\u6536\u8D77\u9762\u677F");
collapseButton.type = "button";
collapseButton.setAttribute("aria-expanded", "true");
header.append(heading, searchButton, collapseButton);
const details = element("details", "ger-settings");
details.append(element("summary", "ger-settings-title", "\u68C0\u7D22\u4E0E\u6392\u5E8F\u6761\u4EF6"));
details.append(createSearchForm(panel));
const body = element("div", "ger-panel-body");
body.append(createSyncPanel(panel), details, element("div", "ger-status", "\u51C6\u5907\u68C0\u7D22\u2026"), element("div", "ger-results"), element("nav", "ger-pagination"), createSearchAboutPanel());
const loadingOverlay = element("div", "ger-loading-overlay");
loadingOverlay.hidden = true;
loadingOverlay.setAttribute("role", "status");
loadingOverlay.setAttribute("aria-live", "polite");
loadingOverlay.append(element("span", "ger-loading-spinner"), element("span", "ger-loading-text", "\u52A0\u8F7D\u4E2D"));
collapseButton.addEventListener("click", () => {
const collapsed = panel.classList.toggle("is-collapsed");
collapseButton.textContent = collapsed ? "\u5C55\u5F00\u9762\u677F" : "\u6536\u8D77\u9762\u677F";
collapseButton.setAttribute("aria-expanded", String(!collapsed));
});
panel.append(header, body, loadingOverlay);
return panel;
}
function createAboutLink(label, url, className) {
const link = element("a", className, label);
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.title = `\u6253\u5F00\u4F5C\u54C1\u300A${label}\u300B`;
return link;
}
function createSearchAboutPanel() {
const details = element("details", "ger-about");
details.append(element("summary", "ger-about-title", "\u5173\u4E8E\u6211\u4EEC"));
const content = element("div", "ger-about-content");
content.append(element("p", "", "\u8FD9\u4E2A\u811A\u672C\u7531 Canoe \u4E3A\u9AA8\u788C\u788C\u4F5C\u54C1\u68C0\u7D22\u4E0E\u521B\u4F5C\u6D41\u7A0B\u505A\u589E\u5F3A\u3002"));
const info = element("p", "ger-about-meta", `\u7528\u6237\u540D\uFF1A${CREATOR_ABOUT_AUTHOR.name}\u3000\u9AA8\u788C\u788C ID\uFF1A${CREATOR_ABOUT_AUTHOR.gululuId}`);
content.append(info);
const works = element("div", "ger-about-works");
works.append(element("span", "ger-about-works-label", "\u7ED9\u6211\u7684\u4EE3\u8868\u4F5C\u5F15\u4E2A\u6D41\uFF1A"));
for (const work of CREATOR_ABOUT_AUTHOR.works) {
works.append(createAboutLink(work.title, work.url, "ger-about-link"));
}
content.append(works);
details.append(content);
return details;
}
function cdbRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function cdbDone(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error("\u7D20\u6750\u5E93\u4E8B\u52A1\u5DF2\u4E2D\u6B62"));
});
}
function openCreatorDatabase() {
if (creatorDatabasePromise) return creatorDatabasePromise;
creatorDatabasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(CREATOR_DB_NAME, CREATOR_DB_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(CREATOR_IMAGE_STORE)) {
const images = database.createObjectStore(CREATOR_IMAGE_STORE, { keyPath: "key" });
images.createIndex("groupId", "groupId");
images.createIndex("lastUsedAt", "lastUsedAt");
}
if (!database.objectStoreNames.contains(CREATOR_TEMPLATE_STORE)) {
const templates = database.createObjectStore(CREATOR_TEMPLATE_STORE, { keyPath: "id" });
templates.createIndex("updatedAt", "updatedAt");
}
if (!database.objectStoreNames.contains(CREATOR_ALBUM_STORE)) {
const albums = database.createObjectStore(CREATOR_ALBUM_STORE, { keyPath: "id" });
albums.createIndex("updatedAt", "updatedAt");
albums.createIndex("type", "type");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return creatorDatabasePromise;
}
async function getCreatorStoreAll(storeName) {
const database = await openCreatorDatabase();
const transaction = database.transaction(storeName, "readonly");
return cdbRequest(transaction.objectStore(storeName).getAll());
}
async function getCreatorRecord(storeName, key) {
const database = await openCreatorDatabase();
const transaction = database.transaction(storeName, "readonly");
return cdbRequest(transaction.objectStore(storeName).get(key));
}
async function putCreatorRecords(storeName, records) {
if (!records.length) return;
const database = await openCreatorDatabase();
const transaction = database.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
for (const record of records) store.put(record);
await cdbDone(transaction);
}
async function deleteCreatorRecord(storeName, key) {
const database = await openCreatorDatabase();
const transaction = database.transaction(storeName, "readwrite");
transaction.objectStore(storeName).delete(key);
await cdbDone(transaction);
}
async function clearCreatorDatabaseStores() {
const database = await openCreatorDatabase();
const stores = [CREATOR_IMAGE_STORE, CREATOR_TEMPLATE_STORE, CREATOR_ALBUM_STORE].filter((storeName) => database.objectStoreNames.contains(storeName));
if (!stores.length) return;
const transaction = database.transaction(stores, "readwrite");
for (const storeName of stores) transaction.objectStore(storeName).clear();
await cdbDone(transaction);
}
function resetCreatorWorkbenchRuntimeState() {
Object.assign(creatorWorkbenchState, {
tab: "images",
query: "",
albumQuery: "",
imageMode: "all",
albumBuildMode: false,
albumBuildSelectedKeys: [],
selectedAlbumId: CREATOR_RECENT_ALBUM_ID,
selectedSubAlbumId: CREATOR_SUB_ALBUM_ALL_ID,
selectedAlbumFolderId: CREATOR_FOLDER_ALL_ID,
albumSidebarWidth: 166,
uiScale: DEFAULT_CREATOR_UI_SCALE,
imageInsertMode: DEFAULT_CREATOR_IMAGE_INSERT_MODE,
selectedTemplateFolderId: CREATOR_TEMPLATE_RECENT_FOLDER_ID,
templateFolders: [],
images: [],
imageByKey: /* @__PURE__ */ new Map(),
albums: [],
templates: [],
loading: false
});
}
async function clearCreatorWorkbenchLocalData() {
const confirmed = window.confirm("\u786E\u5B9A\u6E05\u9664\u7D20\u6750\u53F0\u672C\u5730\u6570\u636E\u5417\uFF1F\u8FD9\u4F1A\u5220\u9664\u672C\u5730\u56FE\u7247\u7D22\u5F15\u3001\u76F8\u518C\u3001\u590D\u5408\u6A21\u677F\u3001\u526A\u8D34\u677F\u5BFC\u5165\u8BB0\u5F55\u548C\u754C\u9762\u8BBE\u7F6E\uFF1B\u4E0D\u4F1A\u5220\u9664\u670D\u52A1\u5668\u56FE\u5E93\u3001\u6B63\u6587\u5185\u5BB9\u6216\u767B\u5F55\u72B6\u6001\u3002");
if (!confirmed) return;
creatorWorkbenchState.loading = true;
renderCreatorWorkbench();
await clearCreatorDatabaseStores();
await chrome.storage.local.remove(CREATOR_UI_KEY);
resetCreatorWorkbenchRuntimeState();
const panel = document.getElementById("gululu-creator-workbench");
if (panel) {
panel.style.left = "24px";
panel.style.top = "90px";
panel.style.width = "520px";
panel.style.height = "620px";
panel.style.right = "auto";
applyCreatorScale(panel, DEFAULT_CREATOR_UI_SCALE);
}
await loadCreatorWorkbenchData();
setCreatorStatus("\u5DF2\u6E05\u9664\u7D20\u6750\u53F0\u672C\u5730\u6570\u636E");
}
function uniqueCreatorKeys(keys) {
return [...new Set((keys ?? []).filter(Boolean).map(String))];
}
function creatorAlbumRecord(id, name, extra = {}) {
const now = Date.now();
return {
id,
name,
imageKeys: [],
children: [],
createdAt: now,
updatedAt: now,
...extra
};
}
function creatorGroupAlbumId(imageOrGroup, source = imageOrGroup?.source) {
const groupId = imageOrGroup?.groupId ?? imageOrGroup?.id;
return `${source || "group"}:${groupId ?? imageOrGroup?.groupName ?? imageOrGroup?.name ?? "unknown"}`;
}
function defaultCreatorAlbums() {
return [
creatorAlbumRecord(CREATOR_RECENT_ALBUM_ID, "\u6700\u8FD1\u4F7F\u7528", { type: "system", pinned: true })
];
}
async function ensureCreatorDefaultAlbums() {
const albums = await getCreatorStoreAll(CREATOR_ALBUM_STORE);
const existing = new Set(albums.map((album) => album.id));
const missing = defaultCreatorAlbums().filter((album) => !existing.has(album.id));
if (missing.length) await putCreatorRecords(CREATOR_ALBUM_STORE, missing);
}
async function addImageKeysToCreatorAlbum(albumId, imageKeys, albumDefaults = {}) {
const keys = uniqueCreatorKeys(imageKeys);
if (!keys.length) return;
const old = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
const now = Date.now();
const album = {
...old ?? creatorAlbumRecord(albumId, albumDefaults.name || "\u672A\u547D\u540D\u76F8\u518C", albumDefaults),
...albumDefaults,
id: albumId,
imageKeys: uniqueCreatorKeys([...old?.imageKeys ?? [], ...keys]),
updatedAt: now
};
if (!album.createdAt) album.createdAt = now;
await putCreatorRecords(CREATOR_ALBUM_STORE, [album]);
}
async function replaceCreatorAlbumKeys(albumId, imageKeys, albumDefaults = {}) {
const old = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
const now = Date.now();
await putCreatorRecords(CREATOR_ALBUM_STORE, [{
...old ?? creatorAlbumRecord(albumId, albumDefaults.name || "\u672A\u547D\u540D\u76F8\u518C", albumDefaults),
...albumDefaults,
id: albumId,
imageKeys: uniqueCreatorKeys(imageKeys),
updatedAt: now,
createdAt: old?.createdAt ?? now
}]);
}
function creatorAlbumBuildSelectedSet() {
return new Set(uniqueCreatorKeys(creatorWorkbenchState.albumBuildSelectedKeys));
}
function toggleCreatorAlbumBuildImage(imageKey) {
const selected = creatorAlbumBuildSelectedSet();
if (selected.has(imageKey)) selected.delete(imageKey);
else selected.add(imageKey);
creatorWorkbenchState.albumBuildSelectedKeys = [...selected];
renderCreatorWorkbench();
}
function clearCreatorAlbumBuildSelection() {
creatorWorkbenchState.albumBuildSelectedKeys = [];
renderCreatorWorkbench();
}
function cancelCreatorAlbumBuildMode() {
creatorWorkbenchState.albumBuildMode = false;
creatorWorkbenchState.albumBuildSelectedKeys = [];
renderCreatorWorkbench();
}
async function createCreatorAlbumFromSelection() {
const imageKeys = uniqueCreatorKeys(creatorWorkbenchState.albumBuildSelectedKeys);
if (!imageKeys.length) {
setCreatorStatus("\u8BF7\u5148\u9009\u62E9\u8981\u653E\u5165\u65B0\u76F8\u518C\u7684\u56FE\u7247");
return;
}
const name = prompt("\u65B0\u76F8\u518C\u540D\u79F0", `\u65B0\u76F8\u518C ${(/* @__PURE__ */ new Date()).toLocaleString("zh-CN")}`);
const trimmed = String(name ?? "").trim();
if (!trimmed) return;
const now = Date.now();
const folder = creatorAlbumFolderList().find((item) => item.id === creatorWorkbenchState.selectedAlbumFolderId);
const shouldAttachFolder = folder && ![CREATOR_FOLDER_ALL_ID, CREATOR_FOLDER_UNFILED_ID, CREATOR_FOLDER_RECENT_DELETED_ID].includes(folder.id);
const album = creatorAlbumRecord(`local:custom:${now}_${Math.random().toString(36).slice(2)}`, trimmed, {
type: "local",
source: "custom",
imageKeys,
sortOrder: creatorAlbumList().filter((item) => item.type !== "virtual").length,
folderId: shouldAttachFolder ? folder.id : "",
folderName: shouldAttachFolder ? folder.name : "",
folderIds: shouldAttachFolder ? [folder.id] : [],
createdFromSelection: true
});
await putCreatorRecords(CREATOR_ALBUM_STORE, [album]);
creatorWorkbenchState.albumBuildMode = false;
creatorWorkbenchState.albumBuildSelectedKeys = [];
creatorWorkbenchState.selectedAlbumId = album.id;
creatorWorkbenchState.selectedSubAlbumId = CREATOR_SUB_ALBUM_ALL_ID;
creatorWorkbenchState.selectedAlbumFolderId = shouldAttachFolder ? folder.id : CREATOR_FOLDER_ALL_ID;
creatorWorkbenchState.imageMode = "all";
setCreatorStatus(`\u5DF2\u521B\u5EFA\u76F8\u518C\u300C${trimmed}\u300D\uFF0C\u5305\u542B ${imageKeys.length} \u5F20\u56FE\u7247`);
await loadCreatorWorkbenchData();
}
async function removeImageKeyFromCreatorAlbum(albumId, imageKey) {
const album = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
if (!album) return;
await putCreatorRecords(CREATOR_ALBUM_STORE, [{
...album,
imageKeys: uniqueCreatorKeys(album.imageKeys).filter((key) => key !== imageKey),
children: (album.children ?? []).map((child) => ({
...child,
imageKeys: uniqueCreatorKeys(child.imageKeys).filter((key) => key !== imageKey)
})),
updatedAt: Date.now()
}]);
}
async function removeImageKeyEverywhere(imageKey) {
const albums = await getCreatorStoreAll(CREATOR_ALBUM_STORE);
await putCreatorRecords(CREATOR_ALBUM_STORE, albums.map((album) => ({
...album,
imageKeys: uniqueCreatorKeys(album.imageKeys).filter((key) => key !== imageKey),
children: (album.children ?? []).map((child) => ({
...child,
imageKeys: uniqueCreatorKeys(child.imageKeys).filter((key) => key !== imageKey)
})),
updatedAt: Date.now()
})));
}
function normalizedCreatorAlbumChildren(album) {
return (album?.children ?? []).map((child, index) => ({
id: child.id || `sub_${Date.now()}_${index}`,
name: child.name || "\u672A\u547D\u540D\u4E8C\u7EA7\u76F8\u518C",
imageKeys: uniqueCreatorKeys(child.imageKeys),
createdAt: child.createdAt ?? Date.now(),
updatedAt: child.updatedAt ?? Date.now(),
sortOrder: Number.isFinite(Number(child.sortOrder)) ? Number(child.sortOrder) : index
})).sort((a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0) || String(a.name).localeCompare(String(b.name), "zh-CN"));
}
function selectedCreatorSubAlbum(album = selectedCreatorAlbum()) {
if (album.id === CREATOR_ALL_ALBUM_ID || album.id === CREATOR_SEARCH_ALBUM_ID) return null;
return normalizedCreatorAlbumChildren(album).find((child) => child.id === creatorWorkbenchState.selectedSubAlbumId) ?? null;
}
async function addImageKeyToCreatorSubAlbum(albumId, subAlbumId, imageKey) {
const album = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
if (!album) return;
const children = normalizedCreatorAlbumChildren(album);
const index = children.findIndex((child) => child.id === subAlbumId);
if (index < 0) return;
children[index] = {
...children[index],
imageKeys: uniqueCreatorKeys([...children[index].imageKeys ?? [], imageKey]),
updatedAt: Date.now()
};
await putCreatorRecords(CREATOR_ALBUM_STORE, [{ ...album, children, updatedAt: Date.now() }]);
}
async function persistCreatorAlbumOrder(albumIds) {
const albums = await getCreatorStoreAll(CREATOR_ALBUM_STORE);
const order = new Map(albumIds.map((id, index) => [id, index]));
await putCreatorRecords(CREATOR_ALBUM_STORE, albums.map((album, index) => ({
...album,
sortOrder: order.has(album.id) ? order.get(album.id) : albumIds.length + index,
updatedAt: Date.now()
})));
await loadCreatorWorkbenchData();
}
async function moveCreatorAlbumToFront(albumId) {
const ordered = creatorAlbumList().filter((album) => album.type !== "virtual").map((album) => album.id);
await persistCreatorAlbumOrder([albumId, ...ordered.filter((id) => id !== albumId)]);
}
async function reorderCreatorAlbum(dragId, targetId) {
if (!dragId || !targetId || dragId === targetId) return;
const ordered = creatorAlbumList().filter((album) => album.type !== "virtual").map((album) => album.id);
const from = ordered.indexOf(dragId);
const to = ordered.indexOf(targetId);
if (from < 0 || to < 0) return;
const [moved] = ordered.splice(from, 1);
ordered.splice(to, 0, moved);
await persistCreatorAlbumOrder(ordered);
}
function creatorAlbumFolderRecords() {
return creatorWorkbenchState.albums.filter((album) => album.type === "folder").sort((a, b) => {
const ao = Number(a.sortOrder);
const bo = Number(b.sortOrder);
if (Number.isFinite(ao) && Number.isFinite(bo) && ao !== bo) return ao - bo;
return String(a.name).localeCompare(String(b.name), "zh-CN");
});
}
function creatorAlbumFolderList() {
return [
{ id: CREATOR_FOLDER_ALL_ID, name: "\u5168\u90E8", type: "virtual-folder" },
{ id: CREATOR_FOLDER_UNFILED_ID, name: "\u672A\u5206\u7C7B", type: "virtual-folder" },
{ id: CREATOR_FOLDER_RECENT_DELETED_ID, name: "\u56DE\u6536\u7AD9", type: "virtual-folder", danger: true },
...creatorAlbumFolderRecords()
];
}
function creatorAlbumFolderIds(album) {
return uniqueCreatorKeys([
...Array.isArray(album?.folderIds) ? album.folderIds : [],
album?.folderId
]).filter((id) => id && id !== CREATOR_FOLDER_ALL_ID && id !== CREATOR_FOLDER_UNFILED_ID);
}
function creatorAlbumIsRecentlyDeleted(album) {
return creatorAlbumFolderIds(album).includes(CREATOR_FOLDER_RECENT_DELETED_ID);
}
async function createCreatorAlbumFolder(name) {
const trimmed = String(name ?? "").trim();
if (!trimmed) return null;
const old = creatorWorkbenchState.albums.find((album) => album.type === "folder" && album.name === trimmed);
if (old) return old;
const now = Date.now();
const folder = creatorAlbumRecord(`folder:${now}_${Math.random().toString(36).slice(2)}`, trimmed, {
type: "folder",
sortOrder: creatorAlbumFolderRecords().length
});
await putCreatorRecords(CREATOR_ALBUM_STORE, [folder]);
return folder;
}
async function assignCreatorAlbumToFolder(albumId, folder) {
const album = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
if (!album || album.type === "folder") return;
const folderId = folder?.id === CREATOR_FOLDER_UNFILED_ID ? "" : folder?.id;
await putCreatorRecords(CREATOR_ALBUM_STORE, [{
...album,
folderId: folderId || "",
folderName: folderId ? folder.name : "",
folderIds: folderId ? [folderId] : [],
updatedAt: Date.now()
}]);
await loadCreatorWorkbenchData();
}
async function copyCreatorAlbumToFolder(albumId, folder) {
const album = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
if (!album || album.type === "folder") return;
if (!folder?.id || folder.id === CREATOR_FOLDER_ALL_ID || folder.id === CREATOR_FOLDER_UNFILED_ID) return;
const folderIds = uniqueCreatorKeys([...creatorAlbumFolderIds(album), folder.id]);
await putCreatorRecords(CREATOR_ALBUM_STORE, [{
...album,
folderId: album.folderId || folder.id,
folderName: album.folderName || folder.name,
folderIds,
updatedAt: Date.now()
}]);
await loadCreatorWorkbenchData();
}
async function moveCreatorAlbumToRecentDeleted(albumId, { confirmMove = true } = {}) {
const album = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
if (!album || album.type === "folder") return;
if (confirmMove && !confirm(`\u79FB\u9664\u76F8\u518C\u300C${album.name}\u300D\u5417\uFF1F
\u5B83\u4F1A\u88AB\u79FB\u52A8\u5230\u201C\u56DE\u6536\u7AD9\u201D\uFF0C\u4E0D\u4F1A\u5220\u9664\u56FE\u7247\uFF0C\u4E5F\u4E0D\u4F1A\u5220\u9664\u670D\u52A1\u5668\u56FE\u5E93\u3002`)) return;
await putCreatorRecords(CREATOR_ALBUM_STORE, [{
...album,
folderId: CREATOR_FOLDER_RECENT_DELETED_ID,
folderName: "\u56DE\u6536\u7AD9",
folderIds: [CREATOR_FOLDER_RECENT_DELETED_ID],
deletedAt: Date.now(),
updatedAt: Date.now()
}]);
if (creatorWorkbenchState.selectedAlbumId === album.id) {
creatorWorkbenchState.selectedAlbumFolderId = CREATOR_FOLDER_ALL_ID;
creatorWorkbenchState.selectedAlbumId = CREATOR_RECENT_ALBUM_ID;
}
setCreatorStatus(`\u5DF2\u628A\u76F8\u518C\u300C${album.name}\u300D\u79FB\u5165\u56DE\u6536\u7AD9`);
await loadCreatorWorkbenchData();
}
async function confirmCreatorAlbumFolderDrop(albumId, folder) {
const album = await getCreatorRecord(CREATOR_ALBUM_STORE, albumId);
if (!album || album.type === "folder" || !folder || folder.id === CREATOR_FOLDER_ALL_ID) return;
if (folder.id === CREATOR_FOLDER_RECENT_DELETED_ID) {
await moveCreatorAlbumToRecentDeleted(album.id, { confirmMove: false });
return;
}
if (folder.id === CREATOR_FOLDER_UNFILED_ID) {
if (!confirm(`\u628A\u4E00\u7EA7\u76F8\u518C\u300C${album.name}\u300D\u79FB\u52A8\u5230\u300C\u672A\u5206\u7C7B\u300D\u5417\uFF1F
\u8FD9\u4F1A\u628A\u5B83\u4ECE\u6240\u6709\u4E8C\u7EA7\u6587\u4EF6\u5939\u4E2D\u79FB\u51FA\u3002`)) return;
creatorWorkbenchState.selectedAlbumFolderId = CREATOR_FOLDER_UNFILED_ID;
await assignCreatorAlbumToFolder(album.id, folder);
setCreatorStatus(`\u5DF2\u628A\u4E00\u7EA7\u76F8\u518C\u300C${album.name}\u300D\u79FB\u52A8\u5230\u300C\u672A\u5206\u7C7B\u300D`);
return;
}
if (creatorAlbumIsRecentlyDeleted(album)) {
if (!confirm(`\u628A\u76F8\u518C\u300C${album.name}\u300D\u4ECE\u56DE\u6536\u7AD9\u6062\u590D\u5230\u300C${folder.name}\u300D\u5417\uFF1F`)) return;
creatorWorkbenchState.selectedAlbumFolderId = folder.id;
await assignCreatorAlbumToFolder(album.id, folder);
setCreatorStatus(`\u5DF2\u628A\u76F8\u518C\u300C${album.name}\u300D\u6062\u590D\u5230\u300C${folder.name}\u300D`);
return;
}
const currentFolderIds = creatorAlbumFolderIds(album);
const alreadyInTarget = currentFolderIds.includes(folder.id);
if (alreadyInTarget && currentFolderIds.length === 1) {
setCreatorStatus(`\u4E00\u7EA7\u76F8\u518C\u300C${album.name}\u300D\u5DF2\u7ECF\u5728\u300C${folder.name}\u300D\u4E2D`);
return;
}
const moveMessage = alreadyInTarget ? `\u4E00\u7EA7\u76F8\u518C\u300C${album.name}\u300D\u5DF2\u7ECF\u5305\u542B\u5728\u300C${folder.name}\u300D\u4E2D\u3002
\u8981\u6539\u4E3A\u53EA\u4FDD\u7559\u5728\u8FD9\u4E2A\u4E8C\u7EA7\u6587\u4EF6\u5939\u91CC\u5417\uFF1F
\u786E\u5B9A = \u79FB\u52A8\u5E76\u79FB\u51FA\u5176\u5B83\u4E8C\u7EA7\u6587\u4EF6\u5939
\u53D6\u6D88 = \u4E0D\u5904\u7406` : `\u628A\u4E00\u7EA7\u76F8\u518C\u300C${album.name}\u300D\u653E\u5165\u4E8C\u7EA7\u6587\u4EF6\u5939\u300C${folder.name}\u300D\u5417\uFF1F
\u786E\u5B9A = \u79FB\u52A8\uFF08\u53EA\u4FDD\u7559\u5728\u76EE\u6807\u6587\u4EF6\u5939\uFF09
\u53D6\u6D88 = \u7EE7\u7EED\u9009\u62E9\u662F\u5426\u590D\u5236`;
if (confirm(moveMessage)) {
creatorWorkbenchState.selectedAlbumFolderId = folder.id;
await assignCreatorAlbumToFolder(album.id, folder);
setCreatorStatus(`\u5DF2\u628A\u4E00\u7EA7\u76F8\u518C\u300C${album.name}\u300D\u79FB\u52A8\u5230\u300C${folder.name}\u300D`);
return;
}
if (alreadyInTarget) return;
if (!confirm(`\u6539\u4E3A\u590D\u5236\u5230\u300C${folder.name}\u300D\u5417\uFF1F
\u590D\u5236\u540E\uFF0C\u539F\u4E8C\u7EA7\u6587\u4EF6\u5939\u91CC\u4ECD\u4F1A\u4FDD\u7559\u8FD9\u4E2A\u4E00\u7EA7\u76F8\u518C\uFF1B\u4E24\u5904\u5F15\u7528\u540C\u4E00\u4E2A\u76F8\u518C\uFF0C\u4E0D\u4F1A\u590D\u5236\u56FE\u7247\u3002`)) return;
creatorWorkbenchState.selectedAlbumFolderId = folder.id;
await copyCreatorAlbumToFolder(album.id, folder);
setCreatorStatus(`\u5DF2\u628A\u4E00\u7EA7\u76F8\u518C\u300C${album.name}\u300D\u590D\u5236\u5230\u300C${folder.name}\u300D`);
}
function getCreatorPageWindow() {
try {
if (typeof unsafeWindow !== "undefined" && unsafeWindow?.document) return unsafeWindow;
} catch {
}
return window;
}
function normalizeCreatorImageInsertMode(value) {
return value === "avatar" ? "avatar" : "image";
}
function creatorImageAvatarAttr() {
return normalizeCreatorImageInsertMode(creatorWorkbenchState.imageInsertMode) === "avatar" ? "true" : "false";
}
function getCreatorToken() {
const pageWindow = getCreatorPageWindow();
const candidates = ["wxdl-admin-token", "token"];
const stores = [];
for (const owner of [pageWindow, window, globalThis]) {
for (const name of ["localStorage", "sessionStorage"]) {
try {
const store = owner?.[name];
if (store && !stores.includes(store)) stores.push(store);
} catch {
}
}
}
for (const storage of stores) {
for (const key of candidates) {
const found = extractTokenLikeValue(storage.getItem(key));
if (found) return found;
}
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index) || "";
if (!/(token|auth|authorization)/i.test(key)) continue;
const raw = storage.getItem(key);
const found = extractTokenLikeValue(raw);
if (found) return found;
}
}
const cookieText = [document.cookie, (() => {
try {
return pageWindow?.document?.cookie || "";
} catch {
return "";
}
})()].join("; ");
const cookieToken = cookieText.split("; ").map((part) => part.split("=")).find(([key]) => /(token|auth|authorization)/i.test(decodeURIComponent(key || "")))?.[1];
if (cookieToken) return decodeURIComponent(cookieToken);
return "";
}
function extractTokenLikeValue(raw) {
if (!raw) return "";
const visit = (value, depth = 0) => {
if (depth > 4 || value == null) return "";
if (typeof value === "string") {
const trimmed = value.trim();
if (/^Bearer\s+/i.test(trimmed)) return trimmed.replace(/^Bearer\s+/i, "");
if (/^[A-Za-z0-9._-]{24,}$/.test(trimmed)) return trimmed;
try {
return visit(JSON.parse(trimmed), depth + 1);
} catch {
return "";
}
}
if (typeof value === "object") {
for (const key of ["token", "accessToken", "access_token", "authorization", "value", "data"]) {
const found = visit(value[key], depth + 1);
if (found) return found;
}
}
return "";
};
return visit(raw);
}
async function creatorApi(path, { method = "GET", body, formData = false } = {}) {
const token = getCreatorToken();
if (!token) throw new Error("\u811A\u672C\u672A\u80FD\u8BFB\u53D6\u9875\u9762\u767B\u5F55\u4EE4\u724C\uFF1B\u8BF7\u66F4\u65B0\u5230\u6700\u65B0\u7248\u811A\u672C\u540E\u5237\u65B0\u521B\u4F5C\u7AEF\u9875\u9762");
const headers = { platform: "5", Authorization: `Bearer ${token}` };
if (!formData && body) headers["Content-Type"] = "application/json";
const response = await fetch(`https://backend.gululu.world${path}`, {
method,
headers,
body: formData ? body : body ? JSON.stringify(body) : void 0,
cache: "no-store"
});
if (!response.ok) {
let detail = "";
try {
detail = (await response.text()).replace(/\s+/g, " ").trim().slice(0, 180);
} catch {
detail = "";
}
throw new Error(`\u63A5\u53E3 ${path} HTTP ${response.status}${detail ? `\uFF1A${detail}` : ""}`);
}
const payload = await response.json();
if (![0, 200, 201, 202, 203, 204].includes(Number(payload?.code ?? 200))) {
throw new Error(payload?.msg || `\u63A5\u53E3\u4E1A\u52A1\u72B6\u6001 ${payload?.code}`);
}
return payload;
}
function payloadRows(payload) {
const data = payload?.data;
return payload?.rows ?? data?.rows ?? data?.data ?? data?.records ?? data ?? [];
}
function payloadTotal(payload) {
const data = payload?.data;
return Number(payload?.total ?? data?.total ?? 0) || 0;
}
function payloadData(payload) {
return payload?.data?.data ?? payload?.data ?? payload;
}
function isValidCreatorImageId(value) {
if (value == null) return false;
const text2 = String(value).trim();
return Boolean(text2) && text2 !== "0" && text2 !== "null" && text2 !== "undefined";
}
function creatorImageIdValue(row) {
const value = row?.picId ?? row?.id ?? row?.groupPicId ?? row?.imageId ?? row?.pictureId ?? row?.fileId ?? null;
return isValidCreatorImageId(value) ? value : null;
}
function creatorUploadResponseSummary(value) {
try {
return JSON.stringify(value, (key, item) => {
if (item instanceof Blob || item instanceof File) return `[${item.constructor.name} ${item.size}]`;
return item;
}).slice(0, 360);
} catch {
return String(value).slice(0, 360);
}
}
function normalizeCreatorUploadUrl(value) {
const text2 = String(value ?? "").trim();
if (!text2 || text2 === "null" || text2 === "undefined") return "";
if (/^(https?:|data:|blob:)/i.test(text2)) return text2;
if (text2.startsWith("//")) return `${location.protocol}${text2}`;
if (text2.startsWith("/")) {
try {
return new URL(text2, location.origin).href;
} catch {
return text2;
}
}
return "";
}
function findCreatorUploadValue(value, keys, { url = false, depth = 0 } = {}) {
if (value == null || depth > 7) return "";
if (typeof value === "string") return url ? normalizeCreatorUploadUrl(value) : cleanCreatorImageName(value);
if (typeof value === "number" || typeof value === "bigint") return url ? "" : cleanCreatorImageName(String(value));
if (typeof value !== "object") return "";
if (Array.isArray(value)) {
for (const item of value) {
const found = findCreatorUploadValue(item, keys, { url, depth: depth + 1 });
if (found) return found;
}
return "";
}
const lowerKeySet = new Set(keys.map((key) => key.toLowerCase()));
for (const [key, item] of Object.entries(value)) {
if (!lowerKeySet.has(key.toLowerCase())) continue;
const found = findCreatorUploadValue(item, keys, { url, depth: depth + 1 });
if (found) return found;
}
for (const item of Object.values(value)) {
const found = findCreatorUploadValue(item, keys, { url, depth: depth + 1 });
if (found) return found;
}
return "";
}
function normalizeCreatorUploadedPic(raw, { fallbackName = "", fallbackUrl = "" } = {}) {
const data = payloadData(raw);
const picUrl = findCreatorUploadValue(data, [
"picUrl",
"url",
"fileUrl",
"fileURL",
"imageUrl",
"imageURL",
"imgUrl",
"src",
"path",
"ossUrl",
"cosUrl",
"link",
"downloadUrl",
"previewUrl",
"originalUrl",
"resourceUrl",
"fullUrl"
], { url: true }) || normalizeCreatorUploadUrl(fallbackUrl);
const picId = findCreatorUploadValue(data, [
"picId",
"picID",
"pic_id",
"id",
"groupPicId",
"groupPicID",
"group_pic_id",
"imageId",
"imageID",
"image_id",
"pictureId",
"pictureID",
"picture_id",
"fileId",
"fileID",
"file_id",
"resourceId",
"resourceID",
"resource_id",
"attachmentId",
"attachmentID",
"attachment_id"
]);
const picName = findCreatorUploadValue(data, [
"picName",
"name",
"groupPicName",
"displayName",
"fileName",
"filename",
"originalName",
"originName",
"title"
]) || fallbackName || creatorImageNameFromUrl(picUrl);
return {
...typeof data === "object" && data != null ? data : {},
picUrl,
url: picUrl,
picId: isValidCreatorImageId(picId) ? picId : null,
id: isValidCreatorImageId(picId) ? picId : null,
picName,
name: picName,
__rawUploadData: data
};
}
function materialKey(source, row) {
const id = creatorImageIdValue(row);
const url = row.picUrl ?? row.url;
return `${source}:${id ?? url ?? row.picName ?? row.name}`;
}
function cleanCreatorImageName(value) {
const text2 = String(value ?? "").trim();
if (!text2 || text2 === "null" || text2 === "undefined") return "";
return text2;
}
function middleEllipsisText(value, head = 8, tail = 6) {
const text2 = String(value ?? "").trim();
const chars = [...text2];
if (chars.length <= head + tail + 1) return text2;
return `${chars.slice(0, head).join("")}\u2026${chars.slice(-tail).join("")}`;
}
function creatorImageNameFromUrl(url) {
if (!url) return "";
try {
const parsed = new URL(url, location.href);
return decodeURIComponent(parsed.pathname.split("/").filter(Boolean).pop() || "");
} catch {
return String(url).split("?")[0].split("/").pop() || "";
}
}
function extractCreatorImageName(row, picUrl = row?.picUrl ?? row?.url ?? "") {
const candidates = [
row?.name,
row?.groupPicName,
row?.displayName,
row?.title,
row?.["aria-label"],
row?.alias,
row?.alt,
row?.picName,
row?.fileName,
row?.filename,
row?.originalName,
row?.originName,
row?.originalFileName,
row?.pic?.name,
row?.pic?.picName,
row?.picture?.name,
row?.picture?.picName,
row?.file?.name
].map(cleanCreatorImageName).filter(Boolean);
return candidates[0] || creatorImageNameFromUrl(picUrl) || "\u672A\u547D\u540D\u56FE\u7247";
}
function normalizeCreatorImage(row, group, source) {
const picUrl = row.picUrl ?? row.url ?? "";
const picName = extractCreatorImageName(row, picUrl);
return {
key: materialKey(source, row),
picId: creatorImageIdValue(row),
picName,
picUrl,
groupId: group.groupId ?? group.id,
groupName: group.groupName ?? group.name ?? "",
groupType: group.groupType,
source,
tags: [],
pinned: false,
useCount: 0,
lastUsedAt: 0,
syncedAt: Date.now()
};
}
function normalizedCreatorUrlFingerprint(url) {
const text2 = String(url ?? "").trim();
if (!text2) return "";
try {
const parsed = new URL(text2, location.href);
parsed.hash = "";
return parsed.href;
} catch {
return text2.split("#")[0];
}
}
function sameCreatorImageUrl(left, right) {
const a = normalizedCreatorUrlFingerprint(left);
const b = normalizedCreatorUrlFingerprint(right);
if (!a || !b) return false;
if (a === b) return true;
try {
const leftUrl = new URL(a, location.href);
const rightUrl = new URL(b, location.href);
return leftUrl.pathname === rightUrl.pathname && leftUrl.search === rightUrl.search;
} catch {
return false;
}
}
function matchUploadedCreatorImage(images, uploaded, name) {
const picId = creatorImageIdValue(uploaded);
const picUrl = uploaded.picUrl ?? uploaded.url ?? "";
const expectedName = cleanCreatorImageName(uploaded.picName ?? uploaded.name ?? name);
return images.find((image) => {
if (picId != null && String(creatorImageIdValue(image) ?? "") === String(picId)) return true;
if (picUrl && sameCreatorImageUrl(image.picUrl ?? image.url, picUrl)) return true;
if (expectedName && cleanCreatorImageName(image.picName ?? image.name) === expectedName) return true;
return false;
});
}
async function fetchSharedLibraryRoot() {
const payload = await creatorApi("/sharedlibrary/group/origin");
const data = payloadData(payload);
if (!data?.id) throw new Error("\u65B0\u7248\u56FE\u5E93\u6839\u76EE\u5F55\u63A5\u53E3\u6CA1\u6709\u8FD4\u56DE id");
return data;
}
async function fetchSharedLibraryGroups() {
const root = await fetchSharedLibraryRoot();
const groups = [];
const size = 1e3;
for (let current = 1; current <= 20; current += 1) {
const query = new URLSearchParams({ parentId: String(root.id), current: String(current), size: String(size) });
const payload = await creatorApi(`/sharedlibrary/group/page-group?${query}`);
const rows = payloadRows(payload);
if (!Array.isArray(rows) || !rows.length) break;
groups.push(...rows.map((group) => ({ ...group, groupType: group.groupType ?? "sharedlibrary" })));
const total = payloadTotal(payload);
if (total && groups.length >= total) break;
if (rows.length < size) break;
}
return groups;
}
async function fetchSharedLibraryImagesInGroup(group) {
const images = [];
const size = 1e3;
for (let current = 1; current <= 200; current += 1) {
const query = new URLSearchParams({ parentId: String(group.id), current: String(current), size: String(size) });
const payload = await creatorApi(`/sharedlibrary/group/page-pic?${query}`);
const rows = payloadRows(payload);
if (!Array.isArray(rows) || !rows.length) break;
images.push(...rows.map((row) => normalizeCreatorImage(row, group, "sharedlibrary")).filter((item) => item.picUrl));
const total = payloadTotal(payload);
if (total && images.length >= total) break;
if (rows.length < size) break;
}
return images;
}
async function ensureClipboardImportGroup() {
const groups = await fetchSharedLibraryGroups();
const existing = groups.find((group) => String(group.name ?? "").trim() === CLIPBOARD_IMPORT_GROUP_NAME);
if (existing?.id) return existing;
const root = await fetchSharedLibraryRoot();
await creatorApi("/sharedlibrary/group", {
method: "POST",
body: { name: CLIPBOARD_IMPORT_GROUP_NAME, parentId: root.id, storePic: true }
});
const refreshed = await fetchSharedLibraryGroups();
const created = refreshed.find((group) => String(group.name ?? "").trim() === CLIPBOARD_IMPORT_GROUP_NAME);
if (!created?.id) throw new Error("\u5DF2\u8BF7\u6C42\u521B\u5EFA\u526A\u8D34\u677F\u56FE\u5E93\uFF0C\u4F46\u6CA1\u6709\u8BFB\u5230\u65B0\u56FE\u5E93 id");
return created;
}
function clipboardFileName(index, extension = "png") {
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
return `clipboard-${stamp}-${index + 1}.${extension}`;
}
function extensionFromMime(type) {
if (type.includes("webp")) return "webp";
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
if (type.includes("gif")) return "gif";
return "png";
}
function uniqueImageUrlsFromHtml(html) {
if (!html) return [];
const doc = new DOMParser().parseFromString(html, "text/html");
return [...new Set([...doc.querySelectorAll("img[src]")].map((img) => img.src).filter(Boolean))];
}
function looksLikeImageUrl(value) {
try {
const url = new URL(String(value).trim());
return /^https?:$/.test(url.protocol) && /\.(png|jpe?g|gif|webp|bmp|avif)(\?.*)?$/i.test(url.href);
} catch {
return false;
}
}
function clipboardSourcesFromDataTransfer(dataTransfer) {
const sources = [];
const items = [...dataTransfer?.items ?? []];
items.forEach((item, index) => {
if (!item.type?.startsWith("image/")) return;
const file = item.getAsFile();
if (!file) return;
sources.push({
type: "blob",
blob: file,
name: file.name || clipboardFileName(index, extensionFromMime(file.type))
});
});
for (const url of uniqueImageUrlsFromHtml(dataTransfer?.getData("text/html"))) {
sources.push({ type: "url", url, name: url.split("/").pop()?.split("?")[0] || clipboardFileName(sources.length, "webp") });
}
const text2 = dataTransfer?.getData("text/plain")?.trim();
if (looksLikeImageUrl(text2)) sources.push({ type: "url", url: text2, name: text2.split("/").pop()?.split("?")[0] || clipboardFileName(sources.length, "webp") });
return sources;
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
function dataUrlToBlob(dataUrl) {
const [header, data] = String(dataUrl).split(",");
const mime = header.match(/data:([^;]+)/)?.[1] || "application/octet-stream";
const binary = atob(data || "");
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return new Blob([bytes], { type: mime });
}
async function digestBlob(blob) {
if (!crypto.subtle) return `${blob.size}:${blob.type}:${Date.now()}:${Math.random()}`;
const hash = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function clipboardSourcesFromNavigator() {
if (!navigator.clipboard?.read) throw new Error("\u5F53\u524D\u6D4F\u89C8\u5668\u4E0D\u5141\u8BB8\u811A\u672C\u76F4\u63A5\u8BFB\u53D6\u526A\u8D34\u677F\uFF0C\u8BF7\u7528 Ctrl+V / Cmd+V \u7C98\u8D34\u5BFC\u5165");
const clipboardItems = await navigator.clipboard.read();
const sources = [];
for (const item of clipboardItems) {
for (const type of item.types) {
if (type.startsWith("image/")) {
const blob = await item.getType(type);
sources.push({ type: "blob", blob, name: clipboardFileName(sources.length, extensionFromMime(type)) });
} else if (type === "text/html") {
const html = await (await item.getType(type)).text();
for (const url of uniqueImageUrlsFromHtml(html)) sources.push({ type: "url", url, name: url.split("/").pop()?.split("?")[0] || clipboardFileName(sources.length, "webp") });
} else if (type === "text/plain") {
const text2 = (await (await item.getType(type)).text()).trim();
if (looksLikeImageUrl(text2)) sources.push({ type: "url", url: text2, name: text2.split("/").pop()?.split("?")[0] || clipboardFileName(sources.length, "webp") });
}
}
}
return sources;
}
async function addUploadedSharedImageToGroup(data, group, name, { fallbackUrl = "" } = {}) {
let uploaded = normalizeCreatorUploadedPic(data, { fallbackName: name, fallbackUrl });
let picId = creatorImageIdValue(uploaded);
let groupAttachWarning = "";
if (picId != null) {
try {
await creatorApi("/sharedlibrary/group/pic/add-list", {
method: "POST",
body: { groupId: group.id, groupPicList: [{ name, picId }] }
});
} catch (error) {
groupAttachWarning = error.message;
}
}
if (!uploaded.picUrl || picId == null) {
try {
const images = await fetchSharedLibraryImagesInGroup(group);
const matched = matchUploadedCreatorImage(images, uploaded, name);
if (matched) {
uploaded = { ...uploaded, ...matched, picUrl: matched.picUrl, url: matched.picUrl };
picId = creatorImageIdValue(uploaded);
}
} catch {
}
}
const picUrl = uploaded.picUrl;
if (!picUrl || picId == null) {
const warning = groupAttachWarning ? `\uFF1B\u52A0\u5165\u56FE\u5E93\u5206\u7EC4\u5931\u8D25\uFF1A${groupAttachWarning}` : "";
throw new Error(`\u4E0A\u4F20\u540E\u7684\u56FE\u7247\u63A5\u53E3\u6CA1\u6709\u8FD4\u56DE\u53EF\u7528\u56FE\u7247\u5730\u5740\u6216\u6709\u6548\u56FE\u7247 ID${warning}\uFF1B\u54CD\u5E94\uFF1A${creatorUploadResponseSummary(data)}`);
}
return {
...normalizeCreatorImage({ ...uploaded, picId, id: picId, name, picUrl }, group, "sharedlibrary"),
alias: name,
tags: ["\u526A\u8D34\u677F"],
pinned: true,
useCount: 0,
lastUsedAt: Date.now(),
uploadWarning: groupAttachWarning
};
}
function safeUploadFileName(name, blob, fallbackPrefix = "clipboard-image") {
const extension = extensionFromMime(blob?.type || "image/png");
const rawName = String(name || "").split(/[\\/]/).pop()?.split("?")[0]?.trim() || `${fallbackPrefix}.${extension}`;
const withoutUnsafe = rawName.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_").slice(0, 120);
return /\.[a-z0-9]{2,6}$/i.test(withoutUnsafe) ? withoutUnsafe : `${withoutUnsafe}.${extension}`;
}
function uploadFormForBlob(blob, name) {
const form = new FormData();
form.append("file", blob, safeUploadFileName(name, blob));
return form;
}
async function pictureShareRecordFromUpload(payload, group, name) {
let uploaded = normalizeCreatorUploadedPic(payload, { fallbackName: name });
let picId = creatorImageIdValue(uploaded);
let picUrl = uploaded.picUrl;
const groupId = group?.groupId ?? group?.id;
if ((!picUrl || picId == null) && groupId != null) {
try {
const images = await fetchCreatorImagesInGroup({ ...group, groupId });
const matched = matchUploadedCreatorImage(images, uploaded, name);
if (matched) {
uploaded = { ...uploaded, ...matched, picUrl: matched.picUrl, url: matched.picUrl };
picId = creatorImageIdValue(uploaded);
picUrl = uploaded.picUrl;
}
} catch {
}
}
if (!picUrl || picId == null) throw new Error(`\u65E7\u4E0A\u4F20\u63A5\u53E3\u6CA1\u6709\u8FD4\u56DE\u53EF\u7528\u56FE\u7247\u5730\u5740\u6216\u6709\u6548\u56FE\u7247 ID\uFF1B\u54CD\u5E94\uFF1A${creatorUploadResponseSummary(payload)}`);
return {
...normalizeCreatorImage({ ...uploaded, picId, id: picId, name, picUrl }, {
groupId: groupId ?? "clipboard-upload",
id: groupId ?? "clipboard-upload",
groupName: group?.groupName ?? group?.name ?? CLIPBOARD_IMPORT_GROUP_NAME,
name: group?.groupName ?? group?.name ?? CLIPBOARD_IMPORT_GROUP_NAME,
groupType: group?.groupType ?? "clipboard-upload"
}, "picture-share"),
alias: name,
tags: ["\u526A\u8D34\u677F"],
pinned: true,
useCount: 0,
lastUsedAt: Date.now(),
uploadWarning: "\u5DF2\u901A\u8FC7\u65E7\u56FE\u7247\u4E0A\u4F20\u63A5\u53E3\u515C\u5E95\uFF0C\u672A\u5FC5\u4F1A\u51FA\u73B0\u5728\u521B\u4F5C\u7AEF\u56FE\u5E93\u5206\u7EC4\u5185"
};
}
async function firstCreatorFallbackUploadGroup() {
const errors = [];
for (const groupType of [1, 3, 2]) {
try {
const groups = await fetchCreatorGroups(groupType);
const group = groups.find((item) => item?.groupId != null || item?.id != null);
if (group) return { ...group, groupType };
} catch (error) {
errors.push(`${groupType}: ${error.message}`);
}
}
if (errors.length) throw new Error(errors.join("\uFF1B"));
return null;
}
async function uploadClipboardBlobWithFallbacks(source, group) {
const attempts = [];
const originalName = safeUploadFileName(source.name, source.blob);
const variants = [{ label: "\u539F\u59CB\u683C\u5F0F", blob: source.blob, name: originalName }];
if (source.blob.type !== "image/png") {
try {
const pngBlob = await pngBlobForClipboard(source.blob);
variants.push({ label: "PNG \u8F6C\u6362", blob: pngBlob, name: originalName.replace(/\.[a-z0-9]{2,6}$/i, ".png") });
} catch (error) {
attempts.push(`PNG \u8F6C\u6362\uFF1A${error.message}`);
}
}
for (const variant of variants) {
try {
const payload = await creatorApi("/sharedlibrary/pic/upload-file", {
method: "POST",
body: uploadFormForBlob(variant.blob, variant.name),
formData: true
});
return await addUploadedSharedImageToGroup(payload, group, variant.name);
} catch (error) {
attempts.push(`\u65B0\u56FE\u5E93\u4E0A\u4F20 ${variant.label}\uFF1A${error.message}`);
}
}
const oldUploadGroups = [{ label: "\u65E0\u5206\u7EC4", group: null }];
try {
const oldGroup = await firstCreatorFallbackUploadGroup();
if (oldGroup) oldUploadGroups.push({ label: oldGroup.groupName || oldGroup.name || "\u5DF2\u6709\u65E7\u56FE\u5E93\u5206\u7EC4", group: oldGroup });
} catch (error) {
attempts.push(`\u8BFB\u53D6\u65E7\u56FE\u5E93\u5206\u7EC4\uFF1A${error.message}`);
}
for (const uploadGroup of oldUploadGroups) {
for (const variant of variants) {
try {
const form = uploadFormForBlob(variant.blob, variant.name);
const groupId = uploadGroup.group?.groupId ?? uploadGroup.group?.id;
if (groupId != null) form.append("groupId", String(groupId));
const payload = await creatorApi("/pic/add-by-file", {
method: "POST",
body: form,
formData: true
});
return await pictureShareRecordFromUpload(payload, uploadGroup.group || group, variant.name);
} catch (error) {
attempts.push(`\u65E7\u63A5\u53E3\u4E0A\u4F20 ${variant.label}/${uploadGroup.label}\uFF1A${error.message}`);
}
}
}
throw new Error(`\u672C\u5730\u56FE\u7247\u4E0A\u4F20\u5931\u8D25\u3002\u5DF2\u5C1D\u8BD5\uFF1A${attempts.join("\uFF1B")}`);
}
async function uploadClipboardSource(source, group) {
if (source.type === "url") {
const attempts = [];
try {
const payload = await creatorApi("/sharedlibrary/pic/upload-url", { method: "POST", body: { picUrl: source.url } });
return addUploadedSharedImageToGroup(payload, group, source.name, { fallbackUrl: source.url });
} catch (error) {
attempts.push(`URL \u4E0A\u4F20\uFF1A${error.message}`);
}
try {
const blob = await blobFromImageUrl(source.url);
return await uploadClipboardBlobWithFallbacks({ type: "blob", blob, name: source.name || creatorImageNameFromUrl(source.url) }, group);
} catch (error) {
attempts.push(`URL \u8F6C\u672C\u5730\u56FE\u7247\u540E\u4E0A\u4F20\uFF1A${error.message}`);
}
throw new Error(`\u56FE\u7247\u5730\u5740\u4E0A\u4F20\u5931\u8D25\u3002\u5DF2\u5C1D\u8BD5\uFF1A${attempts.join("\uFF1B")}`);
}
return uploadClipboardBlobWithFallbacks(source, group);
}
async function localRecordFromClipboardSource(source, index) {
if (source.type === "url") {
const key2 = `clipboard-url:${source.url}`;
const old2 = await getCreatorRecord(CREATOR_IMAGE_STORE, key2);
return {
...old2 ?? {},
key: key2,
picId: old2?.picId ?? null,
picName: old2?.picName ?? source.name,
picUrl: old2?.picUrl ?? source.url,
originalUrl: source.url,
groupId: old2?.groupId ?? "clipboard-local",
groupName: old2?.groupName ?? CLIPBOARD_IMPORT_GROUP_NAME,
groupType: "clipboard-local",
source: old2?.source ?? "clipboard-local-url",
tags: old2?.tags ?? ["\u526A\u8D34\u677F"],
alias: old2?.alias ?? source.name,
pinned: old2?.pinned ?? true,
useCount: Number(old2?.useCount ?? 0),
lastUsedAt: Date.now(),
localOnly: !old2?.picId,
syncedAt: Date.now()
};
}
const hash = await digestBlob(source.blob);
const key = `clipboard-blob:${hash}`;
const old = await getCreatorRecord(CREATOR_IMAGE_STORE, key);
const dataUrl = old?.localDataUrl ?? old?.picUrl ?? await blobToDataUrl(source.blob);
return {
...old ?? {},
key,
picId: old?.picId ?? null,
picName: old?.picName ?? (source.name || clipboardFileName(index, extensionFromMime(source.blob.type))),
picUrl: old?.picUrl ?? dataUrl,
localDataUrl: dataUrl,
groupId: old?.groupId ?? "clipboard-local",
groupName: old?.groupName ?? CLIPBOARD_IMPORT_GROUP_NAME,
groupType: "clipboard-local",
source: old?.source ?? "clipboard-local-blob",
tags: old?.tags ?? ["\u526A\u8D34\u677F"],
alias: old?.alias ?? source.name,
pinned: old?.pinned ?? true,
useCount: Number(old?.useCount ?? 0),
lastUsedAt: Date.now(),
localOnly: !old?.picId,
syncedAt: Date.now()
};
}
async function uploadLocalClipboardImage(image) {
if (isValidCreatorImageId(image.picId ?? image.id) && !image.localOnly) return image;
const group = await ensureClipboardImportGroup();
let uploaded;
if (image.originalUrl) {
uploaded = await uploadClipboardSource({ type: "url", url: image.originalUrl, name: image.picName || image.alias || "clipboard-image" }, group);
} else if (image.localDataUrl || String(image.picUrl ?? "").startsWith("data:")) {
const dataUrl = image.localDataUrl || image.picUrl;
uploaded = await uploadClipboardSource({
type: "blob",
blob: dataUrlToBlob(dataUrl),
name: image.picName || image.alias || "clipboard-image.png"
}, group);
} else if (image.picUrl) {
uploaded = await uploadClipboardSource({ type: "url", url: image.picUrl, name: image.picName || image.alias || "clipboard-image" }, group);
} else {
throw new Error("\u8FD9\u6761\u672C\u5730\u7D20\u6750\u6CA1\u6709\u53EF\u4E0A\u4F20\u7684\u56FE\u7247\u6570\u636E");
}
const merged = {
...image,
...uploaded,
key: image.key,
alias: image.alias || uploaded.alias,
tags: [.../* @__PURE__ */ new Set([...image.tags ?? [], ...uploaded.tags ?? []])],
localOnly: false,
uploadedAt: Date.now(),
localDataUrl: image.localDataUrl,
originalUrl: image.originalUrl
};
await putCreatorRecords(CREATOR_IMAGE_STORE, [merged]);
await loadCreatorWorkbenchData();
return merged;
}
async function importClipboardSources(sources) {
const unique = [];
const seen = /* @__PURE__ */ new Set();
for (const source of sources) {
const key = source.type === "url" ? source.url : `${source.name}:${source.blob.size}:${source.blob.type}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(source);
}
if (!unique.length) throw new Error("\u526A\u8D34\u677F\u91CC\u6CA1\u6709\u53EF\u5BFC\u5165\u7684\u56FE\u7247");
const records = [];
for (let index = 0; index < unique.length; index += 1) {
setCreatorStatus(`\u6B63\u5728\u4FDD\u5B58\u526A\u8D34\u677F\u56FE\u7247 ${index + 1}/${unique.length} \u5230\u672C\u5730...`);
records.push(await localRecordFromClipboardSource(unique[index], index));
}
await putCreatorRecords(CREATOR_IMAGE_STORE, records);
await addImageKeysToCreatorAlbum(CREATOR_CLIPBOARD_ALBUM_ID, records.map((record) => record.key), {
name: CLIPBOARD_IMPORT_GROUP_NAME,
type: "local"
});
creatorWorkbenchState.tab = "images";
creatorWorkbenchState.selectedAlbumId = CREATOR_CLIPBOARD_ALBUM_ID;
creatorWorkbenchState.imageMode = "all";
await loadCreatorWorkbenchData();
return records;
}
async function importClipboardByButton() {
const records = await importClipboardSources(await clipboardSourcesFromNavigator());
setCreatorStatus(`\u5DF2\u4ECE\u526A\u8D34\u677F\u5BFC\u5165 ${records.length} \u5F20\u56FE\u7247\u5230\u672C\u5730\u7D20\u6750\u5E93`);
}
function shouldLetCreatorEditorHandlePaste(event) {
if (editorFromNode(event.target)) return true;
if (editorFromNode(document.activeElement)) return true;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
return Boolean(editorFromNode(selection.anchorNode) || editorFromNode(selection.focusNode));
}
function installCreatorPasteListener() {
if (creatorPasteListenerInstalled) return;
creatorPasteListenerInstalled = true;
document.addEventListener("paste", (event) => {
if (event.__gululuCreatorEditorInsert) return;
if (shouldLetCreatorEditorHandlePaste(event)) return;
const panel = document.getElementById("gululu-creator-workbench");
if (!panel || panel.classList.contains("is-minimized")) return;
const sources = clipboardSourcesFromDataTransfer(event.clipboardData);
if (!sources.length) return;
event.preventDefault();
event.stopPropagation();
importClipboardSources(sources).then((records) => setCreatorStatus(`\u5DF2\u4ECE\u526A\u8D34\u677F\u5BFC\u5165 ${records.length} \u5F20\u56FE\u7247\u5230\u672C\u5730\u7D20\u6750\u5E93`)).catch((error) => setCreatorStatus(`\u526A\u8D34\u677F\u5BFC\u5165\u5931\u8D25\uFF1A${error.message}`));
}, true);
}
async function fetchCreatorGroups(groupType) {
const groups = [];
const pageSize = 100;
for (let pageNum = 1; pageNum <= 50; pageNum += 1) {
const query = new URLSearchParams({ groupType: String(groupType), pageSize: String(pageSize), pageNum: String(pageNum) });
const payload = await creatorApi(`/picGroup/my-pic-group?${query}`);
const rows = payloadRows(payload);
if (!Array.isArray(rows) || !rows.length) break;
groups.push(...rows);
const total = payloadTotal(payload);
if (total && groups.length >= total) break;
if (rows.length < pageSize) break;
}
return groups;
}
async function fetchCreatorImagesInGroup(group) {
const images = [];
const pageSize = 500;
for (let pageNum = 1; pageNum <= 200; pageNum += 1) {
const query = new URLSearchParams({ groupId: String(group.groupId), pageSize: String(pageSize), pageNum: String(pageNum) });
const payload = await creatorApi(`/pic/list?${query}`);
const rows = payloadRows(payload);
if (!Array.isArray(rows) || !rows.length) break;
images.push(...rows.map((row) => normalizeCreatorImage(row, group, "picture-share")).filter((item) => item.picUrl));
const total = payloadTotal(payload);
if (total && images.length >= total) break;
if (rows.length < pageSize) break;
}
return images;
}
async function syncCreatorGallery({ onProgress = () => {
} } = {}) {
creatorWorkbenchState.loading = true;
renderCreatorWorkbench();
try {
const errors = [];
const sharedGroups = [];
try {
onProgress("\u6B63\u5728\u8BFB\u53D6\u7F16\u8F91\u5668\u65B0\u7248\u56FE\u5E93...");
sharedGroups.push(...await fetchSharedLibraryGroups());
} catch (error) {
errors.push(`\u65B0\u7248\u56FE\u5E93\uFF1A${error.message}`);
}
const groupTypes = [1, 3, 2];
const groups = [];
for (const groupType of groupTypes) {
try {
onProgress(`\u6B63\u5728\u8BFB\u53D6\u65E7\u56FE\u5E93\u5206\u533A ${groupType}...`);
const loaded = await fetchCreatorGroups(groupType);
groups.push(...loaded.map((group) => ({ ...group, groupType })));
} catch (error) {
errors.push(`\u65E7\u56FE\u5E93\u5206\u533A ${groupType}\uFF1A${error.message}`);
}
}
const merged = [];
const syncedAlbums = [];
let done = 0;
for (const group of sharedGroups) {
done += 1;
onProgress(`\u8BFB\u53D6\u65B0\u7248\u56FE\u5E93 ${done}/${sharedGroups.length + groups.length}\uFF1A${group.name ?? group.id}`);
try {
const images = await fetchSharedLibraryImagesInGroup(group);
syncedAlbums.push({
id: creatorGroupAlbumId(group, "sharedlibrary"),
name: group.name ?? group.groupName ?? "\u65B0\u7248\u56FE\u5E93",
source: "sharedlibrary",
groupId: group.id ?? group.groupId ?? null,
groupType: group.groupType ?? "sharedlibrary",
imageKeys: images.map((image) => image.key)
});
for (const image of images) {
const old = await getCreatorRecord(CREATOR_IMAGE_STORE, image.key);
merged.push({
...image,
tags: old?.tags ?? [],
pinned: Boolean(old?.pinned),
useCount: Number(old?.useCount ?? 0),
lastUsedAt: Number(old?.lastUsedAt ?? 0),
alias: old?.alias ?? "",
note: old?.note ?? "",
resizedFrom: old?.resizedFrom
});
}
} catch (error) {
errors.push(`\u65B0\u7248\u56FE\u5E93 ${group.name ?? group.id}\uFF1A${error.message}`);
}
}
for (const group of groups) {
done += 1;
onProgress(`\u8BFB\u53D6\u65E7\u56FE\u5E93 ${done}/${sharedGroups.length + groups.length}\uFF1A${group.groupName ?? group.name ?? group.groupId}`);
if (!Number(group.picNum ?? group.picCount ?? 0)) continue;
try {
const images = await fetchCreatorImagesInGroup(group);
syncedAlbums.push({
id: creatorGroupAlbumId(group, "picture-share"),
name: group.groupName ?? group.name ?? "\u65E7\u7248\u56FE\u5E93",
source: "picture-share",
groupId: group.groupId ?? group.id ?? null,
groupType: group.groupType,
imageKeys: images.map((image) => image.key)
});
for (const image of images) {
const old = await getCreatorRecord(CREATOR_IMAGE_STORE, image.key);
merged.push({
...image,
tags: old?.tags ?? [],
pinned: Boolean(old?.pinned),
useCount: Number(old?.useCount ?? 0),
lastUsedAt: Number(old?.lastUsedAt ?? 0),
alias: old?.alias ?? "",
note: old?.note ?? "",
resizedFrom: old?.resizedFrom
});
}
} catch (error) {
errors.push(`\u65E7\u56FE\u5E93 ${group.groupName ?? group.groupId}\uFF1A${error.message}`);
}
}
if (!merged.length && errors.length) throw new Error(errors.join("\uFF1B"));
await putCreatorRecords(CREATOR_IMAGE_STORE, merged);
await ensureCreatorDefaultAlbums();
for (const album of syncedAlbums) {
await replaceCreatorAlbumKeys(album.id, album.imageKeys, {
name: album.name,
type: "source",
source: album.source,
groupId: album.groupId,
groupType: album.groupType
});
}
await loadCreatorWorkbenchData();
return { groups: sharedGroups.length + groups.length, images: merged.length, warnings: errors };
} finally {
creatorWorkbenchState.loading = false;
renderCreatorWorkbench();
}
}
function editorFromNode(node) {
const elementNode = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
return elementNode?.closest?.(".ProseMirror[contenteditable='true'], [contenteditable='true'].ProseMirror") ?? null;
}
function rememberCreatorEditorSelection() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const editor = editorFromNode(selection.anchorNode) || editorFromNode(selection.focusNode);
if (!editor?.isContentEditable) return;
lastCreatorEditor = editor;
lastCreatorEditorRange = selection.getRangeAt(0).cloneRange();
}
function currentCreatorEditor() {
const activeEditor = editorFromNode(document.activeElement);
if (activeEditor?.isContentEditable) return activeEditor;
if (lastCreatorEditor?.isConnected && lastCreatorEditor.isContentEditable) return lastCreatorEditor;
return document.querySelector(".ProseMirror[contenteditable='true'], [contenteditable='true'].ProseMirror, [contenteditable='true']");
}
function restoreCreatorEditorSelection() {
const editor = currentCreatorEditor();
if (!editor) throw new Error("\u6CA1\u6709\u627E\u5230\u6B63\u6587\u7F16\u8F91\u533A\u57DF\uFF0C\u8BF7\u5148\u5728\u6B63\u6587\u91CC\u70B9\u4E00\u4E0B\u8981\u63D2\u5165\u7684\u4F4D\u7F6E");
editor.focus();
const selection = window.getSelection();
if (!selection) return editor;
selection.removeAllRanges();
if (lastCreatorEditorRange && editor.contains(lastCreatorEditorRange.commonAncestorContainer)) {
selection.addRange(lastCreatorEditorRange);
} else {
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.addRange(range);
lastCreatorEditorRange = range.cloneRange();
lastCreatorEditor = editor;
}
return editor;
}
function installCreatorEditorSelectionTracker() {
if (creatorSelectionTrackerInstalled) return;
creatorSelectionTrackerInstalled = true;
document.addEventListener("selectionchange", rememberCreatorEditorSelection);
document.addEventListener("keyup", rememberCreatorEditorSelection, true);
document.addEventListener("mouseup", rememberCreatorEditorSelection, true);
document.addEventListener("focusin", rememberCreatorEditorSelection, true);
}
function createEditorPasteEvent(html) {
let clipboardData = null;
try {
clipboardData = new DataTransfer();
clipboardData.setData("text/html", html);
clipboardData.setData("text/plain", html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
} catch {
clipboardData = null;
}
let event;
try {
event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData });
} catch {
event = new Event("paste", { bubbles: true, cancelable: true });
}
if (clipboardData && !event.clipboardData) {
try {
Object.defineProperty(event, "clipboardData", { value: clipboardData });
} catch {
}
}
try {
Object.defineProperty(event, "__gululuCreatorEditorInsert", { value: true });
} catch {
event.__gululuCreatorEditorInsert = true;
}
return event;
}
function createEditorImagePasteEvent(blob, name, html = "") {
let clipboardData = null;
try {
clipboardData = new DataTransfer();
const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/png";
const file = new File([blob], safeUploadFileName(name, blob), { type });
clipboardData.items.add(file);
if (html) clipboardData.setData("text/html", html);
clipboardData.setData("text/plain", name || file.name || "image");
} catch {
clipboardData = null;
}
let event;
try {
event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData });
} catch {
event = new Event("paste", { bubbles: true, cancelable: true });
}
if (clipboardData && !event.clipboardData) {
try {
Object.defineProperty(event, "clipboardData", { value: clipboardData });
} catch {
}
}
try {
Object.defineProperty(event, "__gululuCreatorEditorInsert", { value: true });
} catch {
event.__gululuCreatorEditorInsert = true;
}
return { event, clipboardData };
}
function insertHtmlWithRange(html) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
range.deleteContents();
const fragment = range.createContextualFragment(html);
const tail = fragment.lastChild;
range.insertNode(fragment);
if (tail) {
const next = document.createRange();
next.setStartAfter(tail);
next.collapse(true);
selection.removeAllRanges();
selection.addRange(next);
}
return true;
}
function creatorEditorHasImage(editor, image) {
if (!editor) return false;
const expectedId = image.picId ?? image.id ?? null;
const expectedSrc = String(image.picUrl ?? "");
return [...editor.querySelectorAll("img")].some((img) => {
const src = img.currentSrc || img.src || img.getAttribute("src") || "";
if (expectedSrc && src === expectedSrc) return true;
const imageId = parseCreatorImageIdFromElement(img);
return expectedId != null && String(imageId ?? "") === String(expectedId);
});
}
function creatorEditorImageCount(editor) {
return editor ? editor.querySelectorAll("img").length : 0;
}
function waitCreatorFrames(count = 2) {
return new Promise((resolve) => {
const step = (left) => {
if (left <= 0) resolve();
else requestAnimationFrame(() => step(left - 1));
};
step(count);
});
}
function waitCreatorDelay(ms = 120) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function verifyCreatorImageInserted(image, beforeCount = null) {
await waitCreatorFrames(4);
await waitCreatorDelay(120);
const editor = currentCreatorEditor();
if (creatorEditorHasImage(editor, image)) return true;
return beforeCount != null && creatorEditorImageCount(editor) > beforeCount;
}
function findCreatorUploadedPasteImage(editor, beforeCount, objectUrl, beforeElements = null) {
if (!editor) return false;
const images = [...editor.querySelectorAll("img")];
const freshImages = beforeElements ? images.filter((img) => !beforeElements.has(img)) : [];
const candidates = freshImages.length ? freshImages : beforeCount == null ? images : images.slice(Math.max(0, beforeCount));
return candidates.find((img) => {
const imageId = parseCreatorImageIdFromElement(img);
if (isValidCreatorImageId(imageId)) return true;
const src = img.currentSrc || img.src || img.getAttribute("src") || "";
if (!src || src === objectUrl || src.startsWith("blob:") || src.startsWith("data:")) return false;
return /^https?:\/\//i.test(src) || src.includes("image.gululu.world");
}) ?? null;
}
async function waitForCreatorNativePasteUpload(beforeCount, objectUrl, beforeElements = null, { timeoutMs = 12e3, intervalMs = 300 } = {}) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const editor = currentCreatorEditor();
const image = findCreatorUploadedPasteImage(editor, beforeCount, objectUrl, beforeElements);
if (image) return image;
await waitCreatorDelay(intervalMs);
}
return null;
}
async function tryInsertCreatorImageWithPaste(html, image, beforeCount = null) {
const editor = restoreCreatorEditorSelection();
const pasteEvent = createEditorPasteEvent(html);
editor.dispatchEvent(pasteEvent);
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertHTML", data: "" }));
rememberCreatorEditorSelection();
return verifyCreatorImageInserted(image, beforeCount);
}
async function blobForCreatorNativePaste(image) {
if (image.localDataUrl) return dataUrlToBlob(image.localDataUrl);
if (String(image.picUrl ?? "").startsWith("data:")) return dataUrlToBlob(image.picUrl);
if (image.originalUrl) return blobFromImageUrl(image.originalUrl);
if (image.picUrl) return blobFromImageUrl(image.picUrl);
throw new Error("\u6CA1\u6709\u53EF\u7528\u4E8E\u6A21\u62DF\u7C98\u8D34\u7684\u56FE\u7247\u6570\u636E");
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
})[char]);
}
function creatorImageRecordFromEditorImage(base, img) {
const src = img?.currentSrc || img?.src || img?.getAttribute?.("src") || base.picUrl || "";
const imageId = parseCreatorImageIdFromElement(img);
const validId = creatorImageIdValue({ picId: imageId });
const fallbackName = base.alias || base.picName || fileNameFromImageUrl(src);
const pageName = extractCreatorImageName({
name: img?.getAttribute?.("alt"),
alt: img?.alt,
title: img?.title,
"aria-label": img?.getAttribute?.("aria-label")
}, src);
const now = Date.now();
return {
...base,
key: base.key || `editor-paste:${validId ?? src}`,
picId: validId ?? null,
id: validId ?? null,
picName: cleanCreatorImageName(base.picName) || pageName || fallbackName,
picUrl: src || base.picUrl,
url: src || base.picUrl,
groupId: base.groupId ?? "editor-paste",
groupName: base.groupName ?? "\u7C98\u8D34\u4E0A\u4F20",
groupType: base.groupType ?? "local",
source: validId ? "editor-paste" : base.source ?? "editor-paste",
alias: base.alias || "",
tags: base.tags ?? ["\u526A\u8D34\u677F"],
localOnly: !validId,
uploadedAt: validId ? now : base.uploadedAt,
syncedAt: now
};
}
async function updateCreatorImageFromNativePaste(base, img) {
const record = creatorImageRecordFromEditorImage(base, img);
await putCreatorRecords(CREATOR_IMAGE_STORE, [record]);
await addImageKeysToCreatorAlbum(CREATOR_RECENT_ALBUM_ID, [record.key], {
name: "\u6700\u8FD1\u4F7F\u7528",
type: "system",
pinned: true
});
await loadCreatorWorkbenchData();
return record;
}
async function insertCreatorImageWithNativePaste(image, beforeCount = null) {
let objectUrl = "";
try {
const editor = restoreCreatorEditorSelection();
const beforeElements = /* @__PURE__ */ new Set([...editor.querySelectorAll("img")]);
const blob = await blobForCreatorNativePaste(image);
objectUrl = URL.createObjectURL(blob);
const html = ``;
const { event, clipboardData } = createEditorImagePasteEvent(blob, image.picName || image.alias || "image.png", html);
if (!clipboardData?.files?.length && !clipboardData?.items?.length) {
creatorLastInsertDiagnostic = "\u6A21\u62DF\u56FE\u7247\u7C98\u8D34\u5931\u8D25\uFF1A\u6D4F\u89C8\u5668\u4E0D\u5141\u8BB8\u6784\u9020\u526A\u8D34\u677F\u56FE\u7247\u6570\u636E";
return false;
}
editor.dispatchEvent(event);
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: "" }));
rememberCreatorEditorSelection();
const insertedImage = await waitForCreatorNativePasteUpload(beforeCount, objectUrl, beforeElements);
if (!insertedImage) creatorLastInsertDiagnostic = "\u5DF2\u6A21\u62DF\u539F\u7AD9\u56FE\u7247\u7C98\u8D34\uFF0C\u4F46\u7B49\u5F85\u4E0A\u4F20\u540E\u4ECD\u672A\u68C0\u6D4B\u5230\u670D\u52A1\u5668\u56FE\u7247\u6216\u6709\u6548 imageId";
return insertedImage;
} catch (error) {
creatorLastInsertDiagnostic = `\u6A21\u62DF\u539F\u7AD9\u56FE\u7247\u7C98\u8D34\u5931\u8D25\uFF1A${error.message}`;
return null;
} finally {
if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl), 15e3);
}
}
function insertHtmlIntoEditor(html) {
const editor = restoreCreatorEditorSelection();
let inserted = false;
try {
inserted = document.execCommand("insertHTML", false, html);
} catch {
inserted = false;
}
if (!inserted) inserted = insertHtmlWithRange(html);
if (!inserted) throw new Error("\u63D2\u5165\u5931\u8D25\uFF1A\u65E0\u6CD5\u5199\u5165\u6B63\u6587\u7F16\u8F91\u533A\u57DF\uFF0C\u8BF7\u5148\u5728\u6B63\u6587\u91CC\u70B9\u4E00\u4E0B\u63D2\u5165\u4F4D\u7F6E");
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertHTML", data: "" }));
rememberCreatorEditorSelection();
}
function findProseMirrorEditorView(editor) {
const seen = /* @__PURE__ */ new WeakSet();
const isObject = (value) => value && (typeof value === "object" || typeof value === "function");
const looksLikeView = (value) => isObject(value) && value.state?.schema?.nodes && typeof value.dispatch === "function" && isObject(value.dom);
const visit = (value, depth = 0) => {
if (!isObject(value) || seen.has(value) || depth > 5) return null;
seen.add(value);
if (looksLikeView(value)) return value;
for (const key of ["view", "editorView", "pmView", "props", "parent", "root", "docView"]) {
try {
const found = visit(value[key], depth + 1);
if (found) return found;
} catch {
}
}
if (depth < 2) {
for (const key of Object.getOwnPropertyNames(value).slice(0, 80)) {
if (/^(ownerDocument|document|window|parentNode|childNodes|children)$/i.test(key)) continue;
try {
const found = visit(value[key], depth + 1);
if (found) return found;
} catch {
}
}
}
return null;
};
return visit(editor?.pmViewDesc) || visit(editor);
}
function findTiptapEditor(editor) {
const seen = /* @__PURE__ */ new WeakSet();
const isObject = (value) => value && (typeof value === "object" || typeof value === "function");
const looksLikeTiptap = (value) => isObject(value) && typeof value.chain === "function" && typeof value.commands?.focus === "function" && typeof value.commands?.insertContent === "function";
let inspected = 0;
const visit = (value, depth = 0) => {
if (!isObject(value) || seen.has(value) || inspected > 25e3) return null;
inspected += 1;
if (looksLikeTiptap(value)) return value;
seen.add(value);
if (depth > 14) return null;
const keys = [];
try {
keys.push(...Object.keys(value));
if (depth < 3) keys.push(...Object.getOwnPropertyNames(value).slice(0, 80));
} catch {
return null;
}
for (const key of [...new Set(keys)]) {
if (/^(ownerDocument|document|window|parentNode|childNodes|children|body|head)$/i.test(key)) continue;
try {
const found = visit(value[key], depth + 1);
if (found) return found;
} catch {
}
}
return null;
};
const candidates = [];
let node = editor;
while (node) {
candidates.push(node);
node = node.parentElement;
}
candidates.push(document.querySelector("#root"), document.body);
for (const candidate of candidates) {
const found = visit(candidate);
if (found) {
findTiptapEditor.lastDiagnostic = `\u9694\u79BB\u73AF\u5883\u627E\u5230 Tiptap editor\uFF0C\u68C0\u67E5\u5BF9\u8C61 ${inspected} \u4E2A`;
return found;
}
}
findTiptapEditor.lastDiagnostic = `\u9694\u79BB\u73AF\u5883\u672A\u627E\u5230 Tiptap editor\uFF0C\u68C0\u67E5\u5BF9\u8C61 ${inspected} \u4E2A`;
return null;
}
function ensureCreatorPageBridge() {
if (creatorPageBridgeReadyPromise) return creatorPageBridgeReadyPromise;
if (creatorPageBridgeInstalled) return Promise.resolve(true);
const pageWindow = getCreatorPageWindow();
if (pageWindow && pageWindow !== window) {
try {
if (!pageWindow.__gululuCreatorPageBridgeInstalled) {
pageWindow.__gululuCreatorPageBridgeInstalled = true;
const isObject = (value) => value && (typeof value === "object" || typeof value === "function");
const looksLikeTiptap = (value) => isObject(value) && typeof value.chain === "function" && typeof value.commands?.focus === "function" && typeof value.commands?.insertContent === "function";
const findPageTiptapEditor = () => {
const seen = /* @__PURE__ */ new WeakSet();
let inspected = 0;
const visit = (value, depth = 0) => {
if (!isObject(value) || seen.has(value) || inspected > 8e4) return null;
inspected += 1;
if (looksLikeTiptap(value)) return { editor: value, inspected };
seen.add(value);
if (depth > 18) return null;
let keys = [];
try {
keys = Object.keys(value);
if (depth < 6) keys = keys.concat(Object.getOwnPropertyNames(value).slice(0, 160));
} catch {
return null;
}
for (const key of [...new Set(keys)]) {
if (/^(ownerDocument|document|window|parentNode|childNodes|children|body|head)$/i.test(key)) continue;
try {
const found = visit(value[key], depth + 1);
if (found) return found;
} catch {
}
}
return null;
};
const pageDocument = pageWindow.document || document;
const candidates = [];
let node = pageDocument.querySelector(".ProseMirror[contenteditable='true'], [contenteditable='true'].ProseMirror, [contenteditable='true']");
while (node) {
candidates.push(node);
node = node.parentElement;
}
candidates.push(pageDocument.querySelector("#root"), pageDocument.querySelector("[data-reactroot]"), pageDocument.body);
for (const candidate of candidates) {
const found = visit(candidate);
if (found) return found;
}
return { editor: null, inspected };
};
pageWindow.addEventListener("gululu-creator-insert-image-request", (event) => {
const detail = event.detail || {};
const requestId = detail.requestId;
const image = detail.image || {};
const EventCtor = pageWindow.CustomEvent || CustomEvent;
const respond = (payload) => pageWindow.dispatchEvent(new EventCtor("gululu-creator-insert-image-response", { detail: { requestId, ...payload } }));
try {
const found = findPageTiptapEditor();
const editor = found.editor;
if (!editor) {
respond({ ok: false, error: "\u9875\u9762\u6865\u63A5\u672A\u627E\u5230 Tiptap editor", diagnostic: `unsafeWindow inspected ${found.inspected}` });
return;
}
const imageId = image.imageId;
const avatar = image.avatar === "true" || image.avatar === true ? "true" : "false";
const result = editor.chain().focus().insertContent({
type: "image",
attrs: {
avatar,
src: image.picUrl,
alt: image.alt || "",
imageId,
imageid: imageId == null ? null : JSON.stringify(imageId),
picId: imageId
}
}).createParagraphNear().run();
if (!result) {
respond({ ok: false, error: "Tiptap \u63D2\u5165\u547D\u4EE4\u8FD4\u56DE false", diagnostic: `unsafeWindow inspected ${found.inspected}` });
return;
}
respond({ ok: true, diagnostic: `unsafeWindow inspected ${found.inspected}` });
} catch (error) {
respond({ ok: false, error: error?.message || String(error), diagnostic: "unsafeWindow bridge exception" });
}
});
}
creatorPageBridgeInstalled = true;
creatorPageBridgeReadyPromise = Promise.resolve(true);
return creatorPageBridgeReadyPromise;
} catch (error) {
creatorLastInsertDiagnostic = `unsafeWindow bridge install failed: ${error?.message || error}`;
}
}
creatorPageBridgeInstalled = true;
const bridgeUrl = (() => {
try {
return chrome?.runtime?.getURL?.("src/page-bridge.js") || "";
} catch {
return "";
}
})();
if (!bridgeUrl) {
creatorLastInsertDiagnostic = "\u9875\u9762\u6865\u63A5\u4E0D\u53EF\u7528\uFF1A\u5F53\u524D\u811A\u672C\u73AF\u5883\u6CA1\u6709 unsafeWindow\uFF0C\u4E5F\u6CA1\u6709\u53EF\u6CE8\u5165\u7684\u5916\u90E8\u6865\u63A5\u811A\u672C";
creatorPageBridgeReadyPromise = Promise.resolve(false);
return creatorPageBridgeReadyPromise;
}
if (document.getElementById("gululu-creator-page-bridge")) {
creatorPageBridgeReadyPromise = Promise.resolve(true);
return creatorPageBridgeReadyPromise;
}
const script = document.createElement("script");
script.id = "gululu-creator-page-bridge";
script.src = bridgeUrl;
script.async = false;
creatorPageBridgeReadyPromise = new Promise((resolve) => {
script.onload = () => {
script.remove();
resolve(true);
};
script.onerror = () => {
creatorLastInsertDiagnostic = "\u9875\u9762\u6865\u63A5\u5916\u90E8\u811A\u672C\u52A0\u8F7D\u5931\u8D25";
script.remove();
resolve(false);
};
});
document.documentElement.append(script);
return creatorPageBridgeReadyPromise;
}
async function insertCreatorImageWithPageBridge(image, beforeCount = null) {
const bridgeReady = await ensureCreatorPageBridge();
if (!bridgeReady) return false;
const pageWindow = getCreatorPageWindow();
const eventTarget = pageWindow || window;
const EventCtor = pageWindow?.CustomEvent || CustomEvent;
const imageId = image.picId ?? image.id ?? null;
if (imageId == null) {
creatorLastInsertDiagnostic = "\u9875\u9762\u6865\u63A5\u8DF3\u8FC7\uFF1A\u6CA1\u6709\u56FE\u7247 ID";
return false;
}
const requestId = `gci_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const response = await new Promise((resolve) => {
const timer = setTimeout(() => {
eventTarget.removeEventListener("gululu-creator-insert-image-response", onResponse);
resolve({ ok: false, error: "\u9875\u9762\u6865\u63A5\u54CD\u5E94\u8D85\u65F6", diagnostic: "timeout" });
}, 1600);
const onResponse = (event) => {
if (event.detail?.requestId !== requestId) return;
clearTimeout(timer);
eventTarget.removeEventListener("gululu-creator-insert-image-response", onResponse);
resolve(event.detail);
};
eventTarget.addEventListener("gululu-creator-insert-image-response", onResponse);
eventTarget.dispatchEvent(new EventCtor("gululu-creator-insert-image-request", {
detail: {
requestId,
image: {
picUrl: image.picUrl,
alt: image.alias || image.picName || "",
imageId,
avatar: creatorImageAvatarAttr()
}
}
}));
});
creatorLastInsertDiagnostic = response.diagnostic || "";
if (!response.ok) {
creatorLastInsertDiagnostic = `${response.error || "\u9875\u9762\u6865\u63A5\u63D2\u5165\u5931\u8D25"}${response.diagnostic ? `\uFF08${response.diagnostic}\uFF09` : ""}`;
return false;
}
const verified = await verifyCreatorImageInserted(image, beforeCount);
if (!verified) creatorLastInsertDiagnostic = `\u9875\u9762\u6865\u63A5\u547D\u4EE4\u8FD4\u56DE\u6210\u529F\uFF0C\u4F46\u7F16\u8F91\u5668\u4E2D\u672A\u68C0\u6D4B\u5230\u65B0\u589E\u56FE\u7247\uFF08${response.diagnostic || "\u65E0\u8BCA\u65AD"}\uFF09`;
return verified;
}
async function insertCreatorImageWithTiptapEditor(image, beforeCount = null) {
const editorElement = restoreCreatorEditorSelection();
const tiptap = findTiptapEditor(editorElement);
if (!tiptap) {
creatorLastInsertDiagnostic = findTiptapEditor.lastDiagnostic || "\u9694\u79BB\u73AF\u5883\u672A\u627E\u5230 Tiptap editor";
return false;
}
const imageId = image.picId ?? image.id ?? null;
if (imageId == null) {
creatorLastInsertDiagnostic = "\u9694\u79BB\u73AF\u5883 Tiptap \u8DF3\u8FC7\uFF1A\u6CA1\u6709\u56FE\u7247 ID";
return false;
}
try {
const chain = tiptap.chain().focus();
if (typeof chain.setTextSelection === "function" && lastCreatorEditorRange && editorElement.contains(lastCreatorEditorRange.commonAncestorContainer)) {
}
const result = chain.insertContent({
type: "image",
attrs: {
avatar: creatorImageAvatarAttr(),
src: image.picUrl,
alt: image.alias || image.picName || "",
imageId,
imageid: JSON.stringify(imageId),
picId: imageId
}
}).createParagraphNear().run();
if (!result) {
creatorLastInsertDiagnostic = "\u9694\u79BB\u73AF\u5883 Tiptap \u63D2\u5165\u547D\u4EE4\u8FD4\u56DE false";
return false;
}
rememberCreatorEditorSelection();
const verified = await verifyCreatorImageInserted(image, beforeCount);
if (!verified) creatorLastInsertDiagnostic = "\u9694\u79BB\u73AF\u5883 Tiptap \u547D\u4EE4\u6210\u529F\uFF0C\u4F46\u7F16\u8F91\u5668\u4E2D\u672A\u68C0\u6D4B\u5230\u65B0\u589E\u56FE\u7247";
return verified;
} catch (error) {
creatorLastInsertDiagnostic = `\u9694\u79BB\u73AF\u5883 Tiptap \u5F02\u5E38\uFF1A${error.message}`;
return false;
}
}
async function insertCreatorImageWithEditorView(image, beforeCount = null) {
const editor = restoreCreatorEditorSelection();
const view = findProseMirrorEditorView(editor);
const imageType = view?.state?.schema?.nodes?.image;
if (!view || !imageType) return false;
const imageId = image.picId ?? image.id ?? null;
const attrs = {
src: image.picUrl,
alt: image.alias || image.picName || "",
avatar: creatorImageAvatarAttr(),
imageId,
imageid: imageId == null ? null : JSON.stringify(imageId),
picId: imageId
};
let node;
try {
node = imageType.createAndFill ? imageType.createAndFill(attrs) : imageType.create(attrs);
if (!node) return false;
let tr = view.state.tr.replaceSelectionWith(node).scrollIntoView();
const paragraphType = view.state.schema.nodes.paragraph;
if (paragraphType && node.isBlock) {
try {
tr = tr.insert(tr.selection.to, paragraphType.create());
} catch {
}
}
view.dispatch(tr);
view.focus?.();
rememberCreatorEditorSelection();
return verifyCreatorImageInserted(image, beforeCount);
} catch {
return false;
}
}
function creatorImageHtmlForEditor(image) {
const paragraph = document.createElement("p");
const img = document.createElement("img");
img.src = image.picUrl || "";
img.alt = image.alias || image.picName || "";
img.setAttribute("avatar", creatorImageAvatarAttr());
const imageId = image.picId ?? image.id ?? null;
if (imageId != null) img.setAttribute("imageid", JSON.stringify(imageId));
paragraph.append(img);
const wrapper = document.createElement("div");
wrapper.append(paragraph);
return wrapper.innerHTML;
}
async function markImageUsed(image) {
const current = await getCreatorRecord(CREATOR_IMAGE_STORE, image.key);
await putCreatorRecords(CREATOR_IMAGE_STORE, [{
...current ?? image,
useCount: Number(current?.useCount ?? image.useCount ?? 0) + 1,
lastUsedAt: Date.now()
}]);
await addImageKeysToCreatorAlbum(CREATOR_RECENT_ALBUM_ID, [image.key], {
name: "\u6700\u8FD1\u4F7F\u7528",
type: "system",
pinned: true
});
await loadCreatorWorkbenchData();
}
function parseCreatorImageIdFromElement(img) {
const raw = img.getAttribute("imageid") ?? img.getAttribute("imageId") ?? img.getAttribute("data-image-id") ?? img.getAttribute("data-pic-id");
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function fileNameFromImageUrl(url) {
try {
const parsed = new URL(url, location.href);
return decodeURIComponent(parsed.pathname.split("/").filter(Boolean).pop() || "\u9875\u9762\u56FE\u7247");
} catch {
return String(url || "").split("/").pop() || "\u9875\u9762\u56FE\u7247";
}
}
function findKnownCreatorImageByElement(img, knownImages) {
const src = img.currentSrc || img.src || img.getAttribute("src") || "";
const imageId = parseCreatorImageIdFromElement(img);
if (imageId != null) {
const idText = String(imageId);
const matched = knownImages.find((image) => String(image.picId ?? image.id ?? "") === idText);
if (matched) return matched;
}
return knownImages.find((image) => image.picUrl === src || image.originalUrl === src) ?? null;
}
async function syncCurrentPageImagesToRecentAlbum() {
const editor = currentCreatorEditor() || document.querySelector(".ProseMirror[contenteditable='true'], [contenteditable='true'].ProseMirror");
if (!editor) throw new Error("\u6CA1\u6709\u627E\u5230\u6B63\u6587\u7F16\u8F91\u533A\u57DF\uFF0C\u65E0\u6CD5\u540C\u6B65\u5F53\u524D\u9875\u9762\u56FE\u7247");
const imageElements = [...editor.querySelectorAll("img")].filter((img) => img.currentSrc || img.src || img.getAttribute("src"));
if (!imageElements.length) throw new Error("\u5F53\u524D\u6B63\u6587\u91CC\u6CA1\u6709\u627E\u5230\u56FE\u7247");
const knownImages = await getCreatorStoreAll(CREATOR_IMAGE_STORE);
const now = Date.now();
const records = [];
const keys = [];
for (const img of imageElements) {
const src = img.currentSrc || img.src || img.getAttribute("src");
const imageId = parseCreatorImageIdFromElement(img);
const known = findKnownCreatorImageByElement(img, knownImages) ?? records.find((record2) => record2.picUrl === src || imageId != null && String(record2.picId ?? "") === String(imageId));
const pageName = extractCreatorImageName({
name: img.getAttribute("alt"),
alt: img.alt,
title: img.title,
"aria-label": img.getAttribute("aria-label")
}, src);
const oldName = cleanCreatorImageName(known?.picName);
const fallbackName = fileNameFromImageUrl(src);
const shouldUsePageName = pageName && pageName !== fallbackName && pageName !== "\u672A\u547D\u540D\u56FE\u7247";
const record = {
...known ?? {},
key: known?.key ?? `editor-page:${imageId ?? src}`,
picId: known?.picId ?? imageId ?? null,
picName: shouldUsePageName ? pageName : oldName || pageName || fallbackName,
picUrl: known?.picUrl ?? src,
groupId: known?.groupId ?? "current-page",
groupName: known?.groupName ?? "\u5F53\u524D\u9875\u9762",
groupType: known?.groupType ?? "local",
source: known?.source ?? "editor-page",
tags: known?.tags ?? ["\u5F53\u524D\u9875\u9762"],
alias: known?.alias || (shouldUsePageName ? pageName : ""),
pinned: Boolean(known?.pinned),
useCount: Number(known?.useCount ?? 0),
lastUsedAt: now,
syncedAt: now
};
records.push(record);
keys.push(record.key);
}
const uniqueRecords = [...new Map(records.map((record) => [record.key, record])).values()];
await putCreatorRecords(CREATOR_IMAGE_STORE, uniqueRecords);
await addImageKeysToCreatorAlbum(CREATOR_PAGE_ALBUM_ID, keys, { name: "\u5F53\u524D\u9875\u9762", type: "local" });
await addImageKeysToCreatorAlbum(CREATOR_RECENT_ALBUM_ID, keys, { name: "\u6700\u8FD1\u4F7F\u7528", type: "system", pinned: true });
creatorWorkbenchState.tab = "images";
creatorWorkbenchState.selectedAlbumId = CREATOR_PAGE_ALBUM_ID;
creatorWorkbenchState.imageMode = "all";
await loadCreatorWorkbenchData();
return uniqueRecords.length;
}
async function insertCreatorImage(image) {
let target = image;
const beforeCount = creatorEditorImageCount(currentCreatorEditor());
creatorLastInsertDiagnostic = "";
const insertDiagnostics = [];
if (!isValidCreatorImageId(target.picId ?? target.id) || target.localOnly || String(target.picUrl ?? "").startsWith("data:")) {
setCreatorStatus("\u6B63\u5728\u8C03\u7528\u539F\u7AD9\u7C98\u8D34\u4E0A\u4F20...");
const pasted = await insertCreatorImageWithNativePaste(target, beforeCount);
if (pasted) {
const updatedTarget = await updateCreatorImageFromNativePaste(target, pasted);
await markImageUsed(updatedTarget);
return;
}
if (creatorLastInsertDiagnostic) insertDiagnostics.push(creatorLastInsertDiagnostic);
try {
setCreatorStatus("\u539F\u7AD9\u7C98\u8D34\u4E0A\u4F20\u672A\u751F\u6548\uFF0C\u6B63\u5728\u5C1D\u8BD5\u63A5\u53E3\u4E0A\u4F20...");
target = await uploadLocalClipboardImage(image);
} catch (error) {
const detail = [...new Set([...insertDiagnostics, error.message].filter(Boolean))].join("\uFF1B");
throw new Error(`\u81EA\u52A8\u4E0A\u4F20\u5931\u8D25\uFF0C\u6682\u4E0D\u63D2\u5165\uFF1A${detail}`);
}
}
const imageId = creatorImageIdValue(target);
if (imageId == null) {
const detail = [...new Set(insertDiagnostics.filter(Boolean))].join("\uFF1B");
throw new Error(`\u81EA\u52A8\u4E0A\u4F20\u540E\u4ECD\u6CA1\u6709\u62FF\u5230\u56FE\u7247 ID\uFF0C\u6682\u4E0D\u63D2\u5165${detail ? `\u3002\u8BCA\u65AD\uFF1A${detail}` : ""}`);
}
const html = `