koishi-plugin-statistical-ranking
Version:
统计命令使用和成员发言记录,支持分命令/群组/用户统计,支持统计发言排行,支持输出图片
1,149 lines (1,142 loc) • 88.1 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
Config: () => Config,
apply: () => apply,
inject: () => inject,
name: () => name,
usage: () => usage
});
module.exports = __toCommonJS(index_exports);
var import_koishi2 = require("koishi");
// src/utils.ts
var fs = __toESM(require("fs"));
var path = __toESM(require("path"));
var Utils = {
/**
* 按显示宽度截断字符串
* @param {string} str - 输入字符串
* @param {number} maxWidth - 最大显示宽度
* @returns {string} 截断后的字符串
*/
truncateByDisplayWidth(str, maxWidth) {
if (!str) return str;
let width = 0, result = "";
for (const char of Array.from(str)) {
const charWidth = /[\u3000-\u9fff\uff01-\uff60\u2E80-\u2FDF\u3040-\u30FF\u2600-\u26FF\u2700-\u27BF]/.test(char) ? 2 : 1;
if (width + charWidth > maxWidth) break;
width += charWidth;
result += char;
}
return result;
},
/**
* 清理字符串,移除不可见字符和特殊字符,限制长度
* @param {string} input - 输入字符串
* @returns {string} 清理后的字符串
*/
sanitizeString(input) {
if (input == null) return "";
return String(input).replace(/[\x00-\x1F\x7F\u200B-\u200F\u2028-\u202F\uFEFF]/g, "").replace(/(.)\1{5,}/g, "$1$1$1…").replace(/[<>`$()[\]{};'"\\\=]/g, "").replace(/\s+/g, " ").trim().slice(0, 64);
},
/**
* 格式化时间为"多久前"的形式
* @param {Date} date - 日期对象
* @returns {string} 格式化后的时间字符串
*/
formatTimeAgo(date) {
if (!date?.getTime?.()) return "未知时间";
const diff = Date.now() - date.getTime();
if (Math.abs(diff) < 3e3) return diff < 0 ? "一会后" : "一会前";
const units = [
[31536e6, "年"],
[2592e6, "月"],
[864e5, "天"],
[36e5, "时"],
[6e4, "分"],
[1e3, "秒"]
];
const absDiff = Math.abs(diff), suffix = diff < 0 ? "后" : "前";
for (let i = 0; i < units.length; i++) {
const [primaryDiv, primaryUnit] = units[i];
if (absDiff < primaryDiv) continue;
const primaryVal = Math.floor(absDiff / primaryDiv);
if (i < units.length - 1) {
const [secondaryDiv, secondaryUnit] = units[i + 1];
const remainder = absDiff % primaryDiv;
const secondaryVal = Math.floor(remainder / secondaryDiv);
if (secondaryVal > 0) return `${primaryVal}${primaryUnit}${secondaryVal}${secondaryUnit}${suffix}`;
}
return `${primaryVal}${primaryUnit}${suffix}`;
}
return `一会${suffix}`;
},
/**
* 格式化日期时间为年月日和24小时制
* @param {Date} date - 日期对象
* @returns {string} 格式化后的日期时间字符串
*/
formatDateTime(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
},
/**
* 获取数据目录
* @param {string} [subdir='statistical-ranking'] 子目录名称
* @returns {string} 数据目录的绝对路径
*/
getDataDirectory(subdir = "statistical-ranking") {
const dataDir = path.join(process.cwd(), "data", subdir);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
return dataDir;
},
/**
* 获取平台用户ID,尝试从绑定数据中查找
* @param {any} session - 会话对象
* @returns {Promise<string>} 平台用户ID
*/
async getPlatformId(session) {
if (!session?.userId || !session?.platform || !session?.app?.database) return session?.userId;
try {
const [binding] = await session.app.database.get("binding", {
aid: session.userId,
platform: session.platform
});
return binding?.pid ?? session.userId;
} catch {
return session.userId;
}
},
/**
* 获取会话信息,包括平台、群组、用户等信息
* @param {any} session - 会话对象
* @returns {Promise<{platform: string, guildId: string, userId: string, userName: string, guildName: string} | null>}
* 会话信息对象,获取失败时返回null
*/
async getSessionInfo(session) {
if (!session) return null;
const platform = session.platform;
const guildId = session.guildId || session.groupId || session.channelId;
const userId = await this.getPlatformId(session);
const bot = session.bot;
let userName = "", guildName = "";
userName = session.username;
if (!userName && bot?.getGuildMember) {
const member = await bot.getGuildMember(guildId, userId).catch(() => null);
userName = member?.username;
}
if (guildId !== "private" && bot?.getGuild) {
const guild = await bot.getGuild(guildId).catch(() => null);
guildName = guild?.name;
}
return {
platform,
guildId,
userId,
userName: this.sanitizeString(userName),
guildName: this.sanitizeString(guildName)
};
},
/**
* 检查目标是否匹配规则列表中的任何规则
* @param {string[]} list - 规则列表
* @param {{platform: string, guildId: string, userId: string, command?: string}} target - 目标对象
* @returns {boolean} 是否匹配
*/
matchRuleList(list, target) {
return list.some((rule) => {
if (target.command && rule === target.command) return true;
const [rulePlatform = "", ruleGuild = "", ruleUser = ""] = rule.split(":");
return rulePlatform && target.platform === rulePlatform || ruleGuild && target.guildId === ruleGuild || ruleUser && target.userId === ruleUser || rule.endsWith(":") && rule.startsWith(target.platform + ":");
});
},
/**
* 构建条件描述
* @param {Object} options - 包含可能的条件的对象
* @returns {string[]} 条件描述数组
*/
buildConditions(options) {
return Object.entries({
user: ["用户", options.user],
guild: ["群组", options.guild],
platform: ["平台", options.platform],
command: ["命令", options.command]
}).filter(([_, [__, value]]) => value).map(([_, [label, value]]) => `${label}${value}`);
},
/**
* 标准化统计记录
* @param {any} record 待处理的记录
* @param {Object} options 选项
* @returns {any} 标准化后的记录
*/
normalizeRecord(record, options = {}) {
const result = { ...record };
if (options.sanitizeNames) {
if (result.userName) result.userName = this.sanitizeString(result.userName);
if (result.guildName) result.guildName = this.sanitizeString(result.guildName);
}
if (result.lastTime && !(result.lastTime instanceof Date)) result.lastTime = new Date(result.lastTime);
if (result.count && typeof result.count !== "number") result.count = parseInt(String(result.count)) || 1;
return result;
},
/**
* 生成统计数据映射表
* @param {Array<any>} records 记录数组
* @param {string} keyField 用作键的字段名
* @param {function} [keyFormatter] 键格式化函数
* @returns {Map<string, {count: number, lastTime: Date, displayName?: string}>}
*/
generateStatsMap(records, keyField, keyFormatter) {
const dataMap = /* @__PURE__ */ new Map();
records.forEach((record) => {
const recordKey = record[keyField];
if (!recordKey) return;
const formattedKey = keyFormatter ? keyFormatter(recordKey) : recordKey;
let displayName = formattedKey;
if (keyField === "userId" && record.userName) displayName = record.userName;
else if (keyField === "guildId" && record.guildName) displayName = record.guildName;
const current = dataMap.get(formattedKey) || { count: 0, lastTime: record.lastTime, displayName };
current.count += record.count;
if (record.lastTime > current.lastTime) current.lastTime = record.lastTime;
dataMap.set(formattedKey, current);
});
return dataMap;
},
/**
* 过滤统计记录
* @param {Array<any>} records 记录数组
* @param {Object} options 过滤选项
* @returns {Array<any>} 过滤后的记录
*/
filterStatRecords(records, options = {}) {
const { keyField = "command", displayWhitelist = [], displayBlacklist = [], disableCommandMerge = false } = options;
let filteredRecords = records;
if (keyField === "command" && !disableCommandMerge) filteredRecords = records.filter((r) => r.command !== "_message");
if (displayWhitelist.length || displayBlacklist.length) {
filteredRecords = filteredRecords.filter((record) => {
const key = record[keyField];
if (!key) return false;
if (displayWhitelist.length) return displayWhitelist.some((pattern) => key.includes(pattern));
return !displayBlacklist.some((pattern) => key.includes(pattern));
});
}
return filteredRecords;
},
/**
* 通用数据排序函数
* @param {Array<any>} data 数据数组
* @param {string} sortBy 排序字段: 'count' | 'key' | 'time'
* @param {string} keyField 键字段名
* @returns {Array<any>} 排序后的数组
*/
sortData(data, sortBy = "count", keyField = "key") {
return [...data].sort((a, b) => {
if (sortBy === "count") return b.count - a.count;
if (sortBy === "time" && a.lastTime && b.lastTime) return new Date(b.lastTime).getTime() - new Date(a.lastTime).getTime();
return a[keyField].localeCompare(b[keyField]);
});
},
/**
* 处理名称显示
* @param {string} name 原始名称
* @param {string} id ID标识
* @param {boolean} truncateId 是否截断ID
* @returns {string} 格式化后的名称
*/
formatDisplayName(name2, id, truncateId = false) {
if (!name2) return id;
const cleanName = this.sanitizeString(name2);
if (!cleanName || /^[\s*□]+$/.test(cleanName)) return id;
if (truncateId || cleanName === id || cleanName.includes(id)) return cleanName;
return `${cleanName} (${id})`;
},
/**
* 通用数组分页
* @param {Array<any>} data 数据数组
* @returns {Array<Array<any>>} 分页后的数据
*/
paginateArray(data) {
if (!data.length || data.length <= 200) return [data];
const totalRows = data.length;
const normalPageCount = Math.ceil(totalRows / 200);
const lastPageRows = totalRows - (normalPageCount - 1) * 200;
const actualPageCount = lastPageRows < 50 && normalPageCount > 1 ? normalPageCount - 1 : normalPageCount;
if (actualPageCount <= 1) return [data];
const mainPageSize = Math.ceil(totalRows / actualPageCount);
const pages = [];
let currentIdx = 0;
for (let i = 0; i < actualPageCount; i++) {
const pageSize = i === actualPageCount - 1 ? totalRows - currentIdx : mainPageSize;
pages.push(data.slice(currentIdx, currentIdx + pageSize));
currentIdx += pageSize;
}
return pages;
}
};
// src/database.ts
var database = {
/**
* 初始化统计数据库表结构
* @param ctx - Koishi 上下文
* @description 创建并定义 analytics.stat 表的结构
*/
initialize(ctx) {
ctx.model.extend("analytics.stat", {
id: "unsigned",
platform: { type: "string", length: 60 },
guildId: { type: "string", length: 150 },
userId: { type: "string", length: 150 },
command: { type: "string", length: 150 },
guildName: { type: "string", nullable: true },
userName: { type: "string", nullable: true },
count: "unsigned",
lastTime: "timestamp"
}, { primary: "id", autoInc: true, unique: [["platform", "guildId", "userId", "command"]] });
},
/**
* 初始化排行榜数据库表结构
* @param ctx - Koishi 上下文
* @description 创建并定义 analytics.rank 表的结构
*/
initializeRankTable(ctx) {
ctx.model.extend("analytics.rank", {
id: "unsigned",
stat: "unsigned",
timestamp: "timestamp",
count: "unsigned"
}, { primary: "id", autoInc: true, unique: [["stat", "timestamp"]] });
},
/**
* 保存统计记录
* @param ctx - Koishi 上下文
* @param data - 需要保存的记录数据
* @description 更新或插入统计记录
*/
async saveRecord(ctx, data) {
data.command ??= "_message";
if (data.guildId?.includes("private")) return;
try {
const query = { platform: data.platform, guildId: data.guildId, userId: data.userId, command: data.command };
const normalizedData = Utils.normalizeRecord(data, { sanitizeNames: true });
const [userName, guildName] = [normalizedData.userName, normalizedData.guildName];
const [existing] = await ctx.database.get("analytics.stat", query);
if (existing) {
const updateData = { count: existing.count + 1, lastTime: /* @__PURE__ */ new Date() };
if (userName !== void 0) updateData.userName = userName;
if (guildName !== void 0) updateData.guildName = guildName;
await ctx.database.set("analytics.stat", query, updateData);
} else {
await ctx.database.create("analytics.stat", { ...query, userName, guildName, count: 1, lastTime: /* @__PURE__ */ new Date() });
}
} catch (e) {
ctx.logger.error("保存记录失败:", e, data);
}
},
/**
* 注册清除命令
* @param {Context} ctx Koishi 上下文
* @param {any} parent 父命令对象
*/
registerClearCommand(ctx, parent) {
parent.subcommand(".clear", "清除统计数据", { authority: 4 }).option("user", "-u [user:string] 指定用户").option("platform", "-p [platform:string] 指定平台").option("guild", "-g [guild:string] 指定群组").option("command", "-c [command:string] 指定命令").option("below", "-b [count:number] 少于指定次数").option("time", "-t [days:number] 指定天数之前").option("rank", "-r 只删除排行数据").option("drop", "-d 不重建数据表").action(async ({ options }) => {
const cleanOptions = {
userId: options.user,
platform: options.platform,
guildId: options.guild,
command: options.command
};
if (options.rank && ctx.database.tables["analytics.rank"]) {
await ctx.database.drop("analytics.rank");
if (!options.drop) {
await this.initializeRankTable(ctx);
}
return options.drop ? "已删除所有排行记录(未重建表)" : "已删除所有排行记录";
}
if (!options.below && !options.time && !Object.values(cleanOptions).some(Boolean)) {
ctx.logger.info("正在删除所有记录" + (options.drop ? "" : "并重建数据表") + "...");
await ctx.database.drop("analytics.stat");
if (ctx.database.tables["analytics.rank"]) {
await ctx.database.drop("analytics.rank");
if (!options.drop) {
await this.initializeRankTable(ctx);
}
}
if (!options.drop) {
await this.initialize(ctx);
}
ctx.logger.info("已删除所有记录" + (options.drop ? "(未重建表)" : ""));
return "已删除所有记录" + (options.drop ? "(未重建表)" : "");
}
let [userName, guildName] = ["", ""];
if (options.user) {
const userRecords = await ctx.database.get("analytics.stat", { userId: options.user });
userName = userRecords.find((r) => r.userName)?.userName;
}
if (options.guild) {
const guildRecords = await ctx.database.get("analytics.stat", { guildId: options.guild });
guildName = guildRecords.find((r) => r.guildName)?.guildName;
}
const query = Object.fromEntries(Object.entries(cleanOptions).filter(([_, v]) => Boolean(v)));
if (options.below > 0) query.count = { $lt: options.below };
if (options.time > 0) {
const cutoffDate = /* @__PURE__ */ new Date();
cutoffDate.setDate(cutoffDate.getDate() - options.time);
query.lastTime = { $lt: cutoffDate };
}
const recordsToDelete = await ctx.database.get("analytics.stat", query, ["id"]);
const deleteCount = recordsToDelete.length;
if (ctx.database.tables["analytics.rank"] && deleteCount > 0) {
await ctx.database.remove("analytics.rank", { stat: { $in: recordsToDelete.map((r) => r.id) } });
}
await ctx.database.remove("analytics.stat", query);
const conditions = Utils.buildConditions({
user: options.user ? userName || options.user : null,
guild: options.guild ? guildName || options.guild : null,
platform: options.platform,
command: options.command
});
const thresholdConditions = [
options.below > 0 && `少于${options.below}次`,
options.time > 0 && `在${options.time}天前`
].filter(Boolean);
let message = "已删除" + (conditions.length ? `${conditions.join("、")}的` : "所有");
if (thresholdConditions.length) message += `${thresholdConditions.join("且")}的`;
message += `记录(共${deleteCount}条)`;
return message;
});
}
};
// src/io.ts
var fs2 = __toESM(require("fs"));
var path2 = __toESM(require("path"));
var io = {
rankInstance: null,
/**
* 导出统计数据到文件
* @param {Context} ctx Koishi 上下文
* @param {string} filename 文件名(不含扩展名)
* @param {Object} options 导出选项
* @param {string} [options.userId] 筛选特定用户ID
* @param {string} [options.platform] 筛选特定平台
* @param {string} [options.guildId] 筛选特定群组ID
* @param {string} [options.command] 筛选特定命令
* @param {number} [options.batchSize] 批处理大小,默认为200条/批
* @returns {Promise<{count: number, batches: number, files: Array<{count: number, path: string, filename: string, batch: number, totalBatches: number}>}>} 导出结果
* @throws {Error} 导出失败时抛出错误
*/
async exportToFile(ctx, filename, options) {
const query = Object.fromEntries(Object.entries({ ...options, batchSize: void 0 }).filter(([_, v]) => Boolean(v)));
const records = await ctx.database.get("analytics.stat", query);
if (!records.length) throw new Error("历史数据为空");
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:T.]/g, "-").substring(0, 19);
const batchSize = options.batchSize || 200, totalRecords = records.length;
const batches = Math.ceil(totalRecords / batchSize), exportFiles = [];
const statDir = Utils.getDataDirectory();
for (let batch = 0; batch < batches; batch++) {
const start = batch * batchSize, end = Math.min((batch + 1) * batchSize, totalRecords);
const batchRecords = records.slice(start, end);
const outputFilename = batches === 1 ? `${filename}-${timestamp}.json` : `${filename}-${timestamp}-${batches}-${batch + 1}.json`;
const filePath = path2.join(statDir, outputFilename);
fs2.writeFileSync(filePath, JSON.stringify(batchRecords.map(({ id, ...rest }) => rest), null, 2), "utf-8");
exportFiles.push({
count: batchRecords.length,
path: filePath,
filename: outputFilename,
batch: batch + 1,
totalBatches: batches
});
}
return { count: totalRecords, batches, files: exportFiles };
},
/**
* 列出可导入的统计数据文件
* @returns {Promise<{files: string[], fileInfo: Record<string, any>}>} 文件列表和详细信息
*/
async listImportFiles() {
const statDir = Utils.getDataDirectory();
const files = await fs2.promises.readdir(statDir);
const statFiles = files.filter((file) => file.endsWith(".json") && (file.includes("stat") || file.includes("analytics")));
if (!statFiles.length) return { files: [], fileInfo: {} };
const fileInfo = {}, batchGroups = /* @__PURE__ */ new Map();
for (const file of statFiles) {
const stats = await fs2.promises.stat(path2.join(statDir, file));
const batchMatch = file.match(/(.*)-(\d+)-(\d+)\.json$/), isBatch = !!batchMatch;
fileInfo[file] = {
mtime: stats.mtime.toLocaleString(),
timestamp: stats.mtime.getTime(),
isBatch,
batchInfo: isBatch ? { base: batchMatch[1], total: parseInt(batchMatch[2]), current: parseInt(batchMatch[3]) } : void 0
};
if (isBatch) {
const [, base, total] = batchMatch, key = `${base}-total${total}`;
if (!batchGroups.has(key)) batchGroups.set(key, []);
batchGroups.get(key).push(file);
}
}
const batchGroupFiles = [];
for (const [, files2] of batchGroups.entries()) {
if (files2.length <= 1) continue;
const firstFile = files2[0], groupInfo = firstFile.match(/(.*)-(\d+)-(\d+)\.json$/);
if (!groupInfo) continue;
const [, base, total] = groupInfo, groupName = `${base}(N=${total})`;
fileInfo[groupName] = {
mtime: new Date(Math.max(...files2.map((f) => fileInfo[f].timestamp))).toLocaleString(),
timestamp: Math.max(...files2.map((f) => fileInfo[f].timestamp)),
isBatch: true,
isGroup: true,
batchInfo: {
base,
total: parseInt(total),
files: files2.sort((a, b) => {
const [aMatch, bMatch] = [a.match(/-(\d+)-(\d+)/), b.match(/-(\d+)-(\d+)/)];
return aMatch && bMatch && aMatch[1] === bMatch[1] ? parseInt(aMatch[2]) - parseInt(bMatch[2]) : 0;
})
}
};
batchGroupFiles.push(groupName);
}
const sortedFiles = [...batchGroupFiles, ...statFiles].sort((a, b) => {
const aInfo = fileInfo[a], bInfo = fileInfo[b];
return aInfo.isGroup !== bInfo.isGroup ? aInfo.isGroup ? -1 : 1 : bInfo.timestamp - aInfo.timestamp;
});
return { files: sortedFiles, fileInfo };
},
/**
* 从文件导入统计数据
* @param {Context} ctx Koishi 上下文
* @param {string} filename 文件名或文件组标识
* @param {boolean} [overwrite=false] 是否覆盖现有数据
* @returns {Promise<string>} 导入结果消息
* @throws {Error} 导入失败时抛出错误
*/
async importFromFile(ctx, filename, overwrite = false) {
const dataDir = Utils.getDataDirectory();
let files = [];
if (/^\d+-\d+$/.test(filename)) {
const [groupIdx, fileIdx] = filename.split("-").map(Number);
const { files: filesList, fileInfo } = await this.listImportFiles();
if (groupIdx < 1 || groupIdx > filesList.length || !filesList[groupIdx - 1].includes("(N=") || !fileInfo[filesList[groupIdx - 1]]?.batchInfo?.files || fileIdx < 1 || fileIdx > fileInfo[filesList[groupIdx - 1]]?.batchInfo?.files.length) {
throw new Error(`文件序号无效`);
}
const targetFile = fileInfo[filesList[groupIdx - 1]].batchInfo.files[fileIdx - 1];
if (fs2.existsSync(path2.join(dataDir, targetFile))) files.push(targetFile);
} else if (filename.includes("(N=")) {
const [, baseFilename, totalBatches] = filename.match(/(.*)\(N=(\d+)\)$/);
for (let i = 1; i <= parseInt(totalBatches); i++) {
const batchFile = `${baseFilename}-${totalBatches}-${i}.json`;
if (fs2.existsSync(path2.join(dataDir, batchFile))) files.push(batchFile);
}
} else {
const fileToCheck = filename.endsWith(".json") ? filename : `${filename}.json`;
if (fs2.existsSync(path2.join(dataDir, fileToCheck))) files.push(fileToCheck);
}
if (overwrite) await ctx.database.remove("analytics.stat", {});
let totalStats = { imported: 0, errors: 0, invalidRecords: 0 };
for (let i = 0; i < files.length; i++) {
const content = await fs2.promises.readFile(path2.join(dataDir, files[i]), "utf-8");
const { validRecords, invalidRecords } = this.parseJSON(content);
const result = await this.importRecords(ctx, validRecords);
totalStats.imported += result.imported;
totalStats.errors += result.errors;
totalStats.invalidRecords += invalidRecords;
}
const totalAttempted = totalStats.imported + totalStats.errors;
if (this.rankInstance && totalStats.imported > 0) {
try {
await this.rankInstance.generateRankSnapshot();
} catch (error) {
ctx.logger.error("更新排行失败:", error);
}
}
return files.length === 1 ? `导入成功(${totalStats.imported}/${totalAttempted}条)` : `批量导入成功(${totalStats.imported}/${totalAttempted}条)`;
},
/**
* 从 analytics 插件导入历史数据
* @param {Context} ctx Koishi 上下文
* @param {boolean} [overwrite=false] 是否覆盖现有数据
* @returns {Promise<string>} 导入结果消息
* @throws {Error} 导入失败时抛出错误
*/
async importLegacyData(ctx, overwrite = false) {
if (!ctx.database.tables["analytics.command"]) throw new Error("无历史数据表");
const [records, bindings] = await Promise.all([
ctx.database.get("analytics.command", {}),
ctx.database.get("binding", {})
]);
if (!records.length) throw new Error("历史数据为空");
if (overwrite) await ctx.database.remove("analytics.stat", {});
const userIdMap = new Map(bindings.filter((b) => b.aid).map((b) => [b.aid.toString(), { pid: b.pid, platform: b.platform }]));
const mergedRecords = /* @__PURE__ */ new Map();
records.forEach((cmd) => {
const binding = userIdMap.get(cmd.userId?.toString());
if (!binding || !cmd.channelId) return;
const commandValue = cmd.name || "_message";
const key = `${binding.platform}:${cmd.channelId}:${binding.pid}:${commandValue}`;
const timestamp = new Date(cmd.date * 864e5 + (cmd.hour || 0) * 36e5);
if (isNaN(timestamp.getTime())) return;
const curr = mergedRecords.get(key) || {
platform: binding.platform,
guildId: cmd.channelId,
userId: binding.pid,
command: commandValue,
count: 0,
lastTime: timestamp,
userName: "",
guildName: ""
};
curr.count += cmd.count || 1;
curr.lastTime = new Date(Math.max(curr.lastTime.getTime(), timestamp.getTime()));
mergedRecords.set(key, curr);
});
const result = await this.importRecords(ctx, Array.from(mergedRecords.values()));
if (this.rankInstance && result.imported > 0) {
try {
await this.rankInstance.generateRankSnapshot();
} catch (error) {
ctx.logger.error("更新排行失败:", error);
}
}
return `导入成功(${result.imported}/${result.imported + result.errors}条)`;
},
/**
* 解析JSON格式的统计数据
* @param {string} content JSON格式的字符串内容
* @returns {{validRecords: Array<StatRecord>, totalRecords: number, invalidRecords: number}} 解析结果,包括有效记录、总记录数和无效记录数
* @throws {Error} 解析失败时抛出错误
*/
parseJSON(content) {
try {
const data = JSON.parse(content);
let invalidRecords = 0, validRecords = [];
for (const record of data) {
if (!record.platform || !record.guildId || !record.userId || !record.command) {
invalidRecords++;
continue;
}
const { id, ...rest } = record;
validRecords.push(Utils.normalizeRecord({
...rest,
platform: rest.platform,
guildId: rest.guildId,
userId: rest.userId,
userName: rest.userName,
guildName: rest.guildName,
command: rest.command,
count: parseInt(String(rest.count)) || 1,
lastTime: rest.lastTime ? new Date(rest.lastTime) : /* @__PURE__ */ new Date()
}, { sanitizeNames: true }));
}
return { validRecords, totalRecords: data.length, invalidRecords };
} catch (error) {
throw new Error(error.message);
}
},
/**
* 将统计记录导入到数据库
* @param {Context} ctx Koishi 上下文
* @param {Array<StatRecord>} records 要导入的统计记录数组
* @returns {Promise<{imported: number, errors: number}>} 导入结果,包括成功导入数和错误数
*/
async importRecords(ctx, records) {
let imported = 0, errors = 0;
const batchSize = 100;
for (let i = 0; i < records.length; i += batchSize) {
const batch = records.slice(i, i + batchSize);
await Promise.all(batch.map(async (record) => {
const query = {
platform: record.platform,
guildId: record.guildId,
userId: record.userId,
command: record.command
};
try {
const [existing] = await ctx.database.get("analytics.stat", query);
if (existing) {
const existingUserName = existing.userName?.trim();
const recordUserName = Utils.sanitizeString(record.userName);
const newUserName = existingUserName && recordUserName ? record.lastTime > existing.lastTime ? recordUserName : existingUserName : existingUserName || recordUserName;
const existingGuildName = existing.guildName?.trim();
const recordGuildName = Utils.sanitizeString(record.guildName);
const newGuildName = existingGuildName && recordGuildName ? record.lastTime > existing.lastTime ? recordGuildName : existingGuildName : existingGuildName || recordGuildName;
await ctx.database.set("analytics.stat", query, {
count: existing.count + (record.count || 1),
lastTime: record.lastTime > existing.lastTime ? record.lastTime : existing.lastTime,
userName: newUserName,
guildName: newGuildName
});
} else {
await ctx.database.create("analytics.stat", {
...query,
count: record.count || 1,
lastTime: record.lastTime || /* @__PURE__ */ new Date(),
userName: Utils.sanitizeString(record.userName),
guildName: Utils.sanitizeString(record.guildName)
});
}
imported++;
} catch (e) {
errors++;
}
}));
}
return { imported, errors };
},
/**
* 注册导入导出命令
* @param {Context} ctx Koishi 上下文
* @param {any} parent 父命令对象
* @param {Rank} [rank] 排行榜实例,用于导入后更新排行
*/
registerCommands(ctx, parent, rank) {
if (rank) this.rankInstance = rank;
parent.subcommand(".export", "导出统计数据", { authority: 4 }).option("user", "-u [user:string] 指定用户").option("platform", "-p [platform:string] 指定平台").option("guild", "-g [guild:string] 指定群组").option("command", "-c [command:string] 指定命令").action(async ({ options, session }) => {
try {
if (Object.values(options).some(Boolean)) await session.send("正在导出...");
const result = await this.exportToFile(ctx, "stat", {
userId: options.user,
platform: options.platform,
guildId: options.guild,
command: options.command
});
return result.batches === 1 ? `导出成功(${result.count}条):
- ${result.files[0].filename}` : `导出成功(${result.count}条):
${result.files.map((f) => `- ${f.filename}`).join("\n")}`;
} catch (e) {
return `导出失败:${e.message}`;
}
});
parent.subcommand(".import [selector:number]", "导入统计数据", { authority: 4 }).option("force", "-f 覆盖现有数据").option("database", "-d 从历史数据库导入").action(async ({ session, options, args }) => {
try {
if (options.database) {
session.send("正在导入历史记录...");
try {
return await this.importLegacyData(ctx, options.force);
} catch (e) {
return e.message;
}
}
const { files, fileInfo } = await this.listImportFiles();
if (!files.length) return "未找到历史记录文件";
const selector = args[0];
if (selector) {
if (selector > 0 && selector <= files.length) {
const targetFile = files[selector - 1];
await session.send(`正在${options.force ? "覆盖" : ""}导入文件:
- ${targetFile}`);
return await this.importFromFile(ctx, targetFile, options.force);
}
return "请输入正确的序号";
}
const fileList = files.map((file, index) => {
const info = fileInfo[file] || {};
let prefix = "📄";
if (file.includes("(N=")) prefix = "📦";
else if (info.isBatch) prefix = "📎";
return `${index + 1}.${prefix}${file}`;
}).join("\n");
return `使用 import [序号]导入对应文件:
${fileList}`;
} catch (e) {
return `导入失败:${e.message}`;
}
});
}
};
// src/stat.ts
var statProcessor = {
/**
* 处理统计记录并格式化显示
* @param {StatRecord[]} records - 统计记录数组
* @param {keyof StatRecord} aggregateKey - 聚合键
* @param {StatProcessOptions} [options={}] - 处理选项
* @returns {Promise<{items: string[], page: number, totalPages: number, totalItems: number, title: string}>}
* 处理后的结果,包含格式化项目、分页信息和标题
*/
async processStatRecords(records, aggregateKey, options = {}) {
const {
sortBy = "count",
limit,
disableCommandMerge = false,
truncateId = false,
displayBlacklist = [],
displayWhitelist = [],
page = 1,
pageSize = 15,
title = "",
skipPaging = false
} = options;
const filteredRecords = Utils.filterStatRecords(records, {
keyField: aggregateKey,
displayWhitelist,
displayBlacklist,
disableCommandMerge
});
const keyFormatter = aggregateKey === "command" && !disableCommandMerge ? (k) => k?.split(".")[0] : void 0;
const statsMap = Utils.generateStatsMap(filteredRecords, aggregateKey, keyFormatter);
let entries = Array.from(statsMap.entries()).sort((a, b) => {
if (sortBy === "count") return b[1].count - a[1].count;
if (sortBy === "time") return new Date(b[1].lastTime).getTime() - new Date(a[1].lastTime).getTime();
return a[0].localeCompare(b[0]);
});
const totalItems = entries.length;
let pagedEntries = entries, currentPage = 1, totalPages = 1;
if (!skipPaging) {
const effectiveLimit = limit ? Math.min(totalItems, limit) : totalItems;
totalPages = Math.ceil(effectiveLimit / pageSize) || 1;
currentPage = Math.min(Math.max(1, page), totalPages);
const startIdx = (currentPage - 1) * pageSize;
const endIdx = limit ? Math.min(startIdx + pageSize, limit, totalItems) : Math.min(startIdx + pageSize, totalItems);
pagedEntries = entries.slice(startIdx, endIdx);
}
const formattedTitle = !skipPaging && totalPages > 1 ? `${title.endsWith(" ——") ? title.slice(0, -3) : title}(第${currentPage}/${totalPages}页)——` : title;
const countWidth = 6, timeWidth = 10, nameWidth = 18;
const items = pagedEntries.map(([key, { count, lastTime }]) => {
const displayName = Utils.formatDisplayName(statsMap.get(key)?.displayName || key, key, truncateId);
const truncatedName = Utils.truncateByDisplayWidth(displayName, nameWidth);
const countStr = count.toString() + (aggregateKey === "command" ? "次" : "条");
const truncatedCount = Utils.truncateByDisplayWidth(countStr, countWidth);
const timeAgo = Utils.formatTimeAgo(lastTime);
const truncatedTime = Utils.truncateByDisplayWidth(timeAgo, timeWidth);
const namePadding = " ".repeat(Math.max(0, nameWidth - (truncatedName ? Array.from(truncatedName).reduce((w, c) => w + (/[\u3000-\u9fff\uff01-\uff60\u2E80-\u2FDF\u3040-\u30FF\u2600-\u26FF\u2700-\u27BF]/.test(c) ? 2 : 1), 0) : 0)));
const countPadding = " ".repeat(Math.max(0, countWidth - (truncatedCount ? Array.from(truncatedCount).reduce((w, c) => w + (/[\u3000-\u9fff\uff01-\uff60\u2E80-\u2FDF\u3040-\u30FF\u2600-\u26FF\u2700-\u27BF]/.test(c) ? 2 : 1), 0) : 0)));
return `${truncatedName}${namePadding} ${countPadding}${truncatedCount} ${truncatedTime}`;
});
return { items, page: currentPage, totalPages, totalItems, title: formattedTitle };
},
/**
* 处理统计查询并返回结果
* @param {Context} ctx - Koishi上下文
* @param {QueryOptions} options - 查询选项
* @param {'command' | 'user' | 'guild'} type - 查询类型
* @returns {Promise<string | {records: StatRecord[], title: string}>} 查询结果或错误信息
*/
async handleStatQuery(ctx, options, type) {
const query = {};
const typeMap = { command: "命令", user: "发言", guild: "群组" };
if (options.user) query.userId = options.user;
if (options.guild) query.guildId = options.guild;
if (options.platform) query.platform = options.platform;
if (type === "user") query.command = "_message";
else if (type === "command") query.command = options.command ?? { $neq: "_message" };
else if (options.command) query.command = options.command;
const records = await ctx.database.get("analytics.stat", query);
if (!records?.length) return "暂无数据";
const userName = options.user && records.find((r) => r.userId === options.user && r.userName)?.userName;
const guildName = options.guild && records.find((r) => r.guildId === options.guild && r.guildName)?.guildName;
const conditions = Utils.buildConditions({
user: options.user ? userName || options.user : null,
guild: options.guild ? guildName || options.guild : null,
platform: options.platform,
command: options.command
});
let title = "";
if (conditions.length) title = `${conditions.join("、")}的${typeMap[type]}统计 ——`;
else if (options.guild && type !== "guild") title = `${guildName || options.guild}的${typeMap[type]}统计 ——`;
else title = `全局${typeMap[type]}统计 ——`;
return { records, title };
},
/**
* 格式化并返回指定类型的列表
* @param {StatRecord[]} records - 统计记录数组
* @param {keyof StatRecord} key - 要获取的键名
* @param {string} title - 列表标题
* @returns {string|null} 格式化后的列表字符串,无内容则返回null
*/
formatList: /* @__PURE__ */ __name((records, key, title) => {
const uniqueKeys = [...new Set(records.map((r) => r[key]).filter(Boolean))];
if (!uniqueKeys.length) return null;
if (key === "command") {
const commands = uniqueKeys.filter((cmd) => cmd !== "_message");
return commands.length ? `${title} ——
${commands.join(",")}` : null;
} else if (key === "userId" || key === "guildId") {
const items = uniqueKeys.map((id) => {
const record = records.find((r) => r[key] === id);
const name2 = key === "userId" ? record?.userName : record?.guildName;
return name2 ? `${name2} (${id})` : id;
});
return items.length ? `${title} ——
${items.join(",")}` : null;
}
return `${title} ——
${uniqueKeys.join(",")}`;
}, "formatList"),
/**
* 注册列表查看子命令
* @param {Context} ctx - Koishi上下文
* @param {any} parent - 父命令对象
*/
registerListCommand(ctx, parent) {
parent.subcommand(".list", "查看类型列表", { authority: 3 }).option("user", "-u 显示用户列表").option("guild", "-g 显示群组列表").action(async ({ options }) => {
const records = await ctx.database.get("analytics.stat", {});
if (!records?.length) return "暂无数据";
const hasParams = options.user || options.guild;
const parts = [];
if (!hasParams) {
parts.push(this.formatList(records, "platform", "平台列表"));
parts.push(this.formatList(records, "command", "命令列表"));
}
if (options.user) parts.push(this.formatList(records, "userId", "用户列表"));
if (options.guild) parts.push(this.formatList(records, "guildId", "群组列表"));
return parts.filter(Boolean).join("\n");
});
}
};
// src/render.ts
var Renderer = class {
static {
__name(this, "Renderer");
}
ctx;
constructor(ctx) {
this.ctx = ctx;
}
/**
* 将HTML内容转换为图片
* @param {string} html - 要渲染的HTML内容
* @param {Object} options - 渲染选项
* @param {number} [options.width] - 图片宽度
* @returns {Promise<Buffer>} 图片Buffer数据
*/
async htmlToImage(html) {
let page = null;
try {
page = await this.ctx.puppeteer.page();
await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
await page.setDefaultNavigationTimeout(3e4);
await page.setDefaultTimeout(3e4);
await page.setContent(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { margin: 0; padding: 0; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: transparent; color: rgba(0, 0, 0, 0.87); font-size: 14px; line-height: 1.4; -webkit-font-smoothing: antialiased; }
table { width: 100%; table-layout: auto; border-collapse: separate; border-spacing: 0; overflow: hidden; }
h2, h3 { margin: 0; letter-spacing: 0.5px; font-weight: 500; }
.material-card { border-radius: 10px; overflow: hidden; background-color: #fff; box-shadow: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12); margin: 4px; padding: 12px; }
.stat-chip { padding: 0 10px; height: 28px; display: inline-flex; align-items: center; border-radius: 14px; font-size: 14px; line-height: 28px; background-color: rgba(0, 0, 0, 0.06); color: rgba(0, 0, 0, 0.87); white-space: nowrap; }
.stat-table th { font-weight: 500; color: white; padding: 8px 12px; position: sticky; top: 0; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.stat-table td { padding: 6px 12px; border-bottom: 1px solid rgba(0, 0, 0, 0.04); position: relative; }
.highlight-row td { background-color: rgba(33, 150, 243, 0.03); font-weight: 500; }
.table-container { border-radius: 8px; overflow: hidden; border: 1px solid rgba(0, 0, 0, 0.06); }
</style>
</head>
<body>${html}</body>
</html>
`, { waitUntil: "networkidle0" });
const dimensions = await page.evaluate(() => {
const contentWidth = Math.max(
document.body.scrollWidth,
document.body.offsetWidth,
document.documentElement.clientWidth,
document.documentElement.scrollWidth,
document.documentElement.offsetWidth
);
const contentHeight = document.body.scrollHeight;
return { width: contentWidth, height: contentHeight };
});
await page.setViewport({ width: dimensions.width, height: dimensions.height, deviceScaleFactor: 2 });
await page.evaluate(() => {
const imgPromises = Array.from(document.querySelectorAll("img")).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener("load", resolve);
img.addEventListener("error", resolve);
}));
return Promise.all(imgPromises);
});
return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
} catch (error) {
this.ctx.logger.error("图片渲染出错:", error);
throw new Error(`图片渲染出错: ${error.message || "未知错误"}`);
} finally {
if (page) await page.close().catch(() => {
});
}
}
/**
* 将统计记录转换为图表数据
* @param {StatRecord[]} records - 统计记录数组
* @param {keyof StatRecord} key - 统计键名
* @param {StatProcessOptions} options - 处理选项
* @returns {Array<{name: string, value: number, time: string, rawTime: Date}>} 转换后的图表数据
*/
recordsToChartData(records, key, options = {}) {
const {
sortBy = "count",
disableCommandMerge = false,
truncateId = false,
displayBlacklist = [],
displayWhitelist = []
} = options;
const filteredRecords = Utils.filterStatRecords(records, {
keyField: key,
displayWhitelist,
displayBlacklist,
disableCommandMerge
});
const keyFormatter = key === "command" && !disableCommandMerge ? (k) => k?.split(".")[0] : void 0;
const dataMap = Utils.generateStatsMap(filteredRecords, key, keyFormatter);
let chartData = Array.from(dataMap.entries()).map(([key2, data]) => ({
name: Utils.formatDisplayName(data.displayName, key2, truncateId),
value: data.count,
time: Utils.formatTimeAgo(data.lastTime),
rawTime: data.lastTime
}));
chartData.sort((a, b) => {
if (sortBy === "count") return b.value - a.value;
if (sortBy === "time") return b.rawTime.getTime() - a.rawTime.getTime();
return a.name.localeCompare(b.name);
});
return chartData;
}
/**
* 生成统计数据的图片
* @param {StatRecord[]} records - 统计记录数组
* @param {keyof StatRecord} key - 统计键名
* @param {string} title - 图表标题
* @param {StatProcessOptions} options - 处理选项
* @returns {Promise<Buffer[]>} 生成的图片Buffer数组
*/
async generateStatImage(records, key, title, options = {}) {
const chartData = this.recordsToChartData(records, key, options);
const headerColor = key === "userId" ? "#9C27B0" : key === "guildId" ? "#4CAF50" : "#2196F3";
const pages = Utils.paginateArray(chartData);
const results = [];
const currentTime = Utils.formatDateTime(/* @__PURE__ */ new Date());
const totalItems = chartData.length;
const totalCount = chartData.reduce((sum, item) => sum + item.value, 0);
for (let i = 0; i < pages.length; i++) {
const pageData = pages[i];
const pageTitle = pages.length > 1 ? `${title} (${i + 1}/${pages.length})` : title;
const html = `
<div class="material-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid rgba(0,0,0,0.08); flex-wrap:nowrap;">
<div style="display:flex; gap:8px; flex-shrink:0; margin-right:12px;">
<div class="stat-chip">
<span style="color:rgba(0,0,0,0.6);">总计: </span>
<span style="font-weight:500; margin-left:3px;">${totalItems}</span>
</div>
<div class="stat-chip">
<span style="color:rgba(0,0,0,0.6);">总${key === "command" ? "次数" : "条数"}: </span>
<span style="font-weight:500; margin-left:3px;">${totalCount}</span>
</div>
</div>
<h2 style="margin:0; font-size:18px; text-align:center; flex-grow:1; font-weight:500;">${pageTitle}</h2>
<div class="stat-chip" style="color:rgba(0,0,0,0.6); margin-left:12px;">${currentTime}</div>
</div>
${this.generateTableHTML(pageData, key, headerColor)}
</div>
`;
results.push(await this.htmlToImage(html));
}
return results;
}
/**
* 生成综合统计图,将用户的所有统计信息整合到一张图中
* @param {Array<{records: StatRecord[], title: string, key: keyof StatRecord, options?: StatProcessOptions}>} datasets - 多个数据集
* @param {string} mainTitle - 主标题
* @returns {Promise<Buffer[]>} 生成的图片Buffer数组
*/
async generateCombinedStatImage(datasets, mainTitle) {
const processedDatasets = datasets.map((dataset) => {
if (!dataset.records.length) return { chartData: [], key: dataset.key, title: dataset.title, headerColor: "", totalItems: 0, totalCount: 0 };
const chartData = this.recordsToChartData(dataset.records, dataset.key, dataset.options);
const headerColor = dataset.key === "userId" ? "#9C27B0" : dataset.key === "guildId" ? "#4CAF50" : "#2196F3";
const totalItems = chartData.length;
const totalCount = chartData.reduce((sum, item) => sum + item.value, 0);
return { chartData, key: dataset.key, title: dataset.title, headerColor, totalItems, totalCount };
}).filter((d) => d.chartData.length > 0);
if (processedDatasets.length === 0)
return [await this.htmlToImage(`<div style="padding:24px; text-align:center;">没有数据</div>`)];
let totalRows = processedDatasets.reduce((sum, dataset) => sum + dataset.chartData.length, 0);
const currentTime = Utils.formatDateTime(/* @__PURE__ */ new Date());
if (totalRows <= 200) {
const tablesHTML = processedDatasets.map((dataset, index) => {
const isLastDataset = index === processedDatasets.length - 1;
return `
<div style="margin-bottom:${isLastDataset ? "0" : "16px"};">
<div style="display:flex; align-items:center; margin:8px 0; flex-wrap:nowrap;">
<div style="display:flex; gap:8px; flex-shrink:0; margin-right:12px;">
<div class="stat-chip">
<span style="color:rgba(0,0,0,0.6);">总计: </span>
<span style="font-weight:500; margin-left:3px;">${dataset.totalItems}</span>
</div>
<div class="stat-chip">
<span style="color:rgba(0,0,0,0.6);">${dataset.key === "command" ? "次数" : "条数"}: </span>
<span style="font-weight:500; margin-left:3px;">${dataset.totalCount}</span>
</div>
</div>
<h3 style="margin:0; font-size:16px; text-align:center; flex-grow:1; font-weight:500;">${dataset.title}</h3>
<div style="flex-shrink:0; margin-left:10px; width:1px;"></div>
</div>
${this.generateTableHTML(dataset.chartData, dataset.key, dataset.headerColor)}
</div>
`;
}).join("");
const html = `
<div class="material-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid rgba(0,0,0,0.08); flex-wrap:nowrap;">
<div style="min-width:10px; flex-shrink:0;"></div>
<h2 style="margin:0; font-size:18px; text-align:center; flex-grow:1; font-weight:500; color:rgba(0, 0, 0, 0.87);">${mainTitle}</h2>
<div class="stat-chip" style="color:rgba(0,0,0,0.6);">${currentTime}</div>
</div>
${tablesHTML}
</div>
`;
return [await this.htmlToImage(html)];
}
const pages = [];
let currentPage = [];
let currentPageRows = 0;
for (const dataset of processedDatasets) {
if (currentPageRows + dataset.chartData.length > 200 && currentPage.length > 0) {
if (dataset.chartData.length >= 50 || currentPage.length === 0) {
pages.push({ datasets: [...currentPage] });
currentPage = [dataset];
currentPageRows = dataset.chartData.length;
} else {
currentPage.push(dataset);
currentPageRows += dataset.chartData.length;
}
} else {
currentPage.push(dataset);
currentPageRows += dataset.chartData.length;
}
}
if (currentPage.length > 0) {
pages.push({ datasets: currentPage });
}