|
import fs from 'node:fs'; |
|
import path from 'node:path'; |
|
import crypto from 'node:crypto'; |
|
|
|
import express from 'express'; |
|
import writeFileAtomic from 'write-file-atomic'; |
|
|
|
const readFile = fs.promises.readFile; |
|
const readdir = fs.promises.readdir; |
|
|
|
import { getAllUserHandles, getUserDirectories } from '../users.js'; |
|
|
|
const STATS_FILE = 'stats.json'; |
|
|
|
|
|
|
|
|
|
const STATS = new Map(); |
|
|
|
|
|
|
|
const TIMESTAMPS = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function timestampToMoment(timestamp) { |
|
if (!timestamp) { |
|
return 0; |
|
} |
|
|
|
if (typeof timestamp === 'number') { |
|
return timestamp; |
|
} |
|
|
|
const pattern1 = |
|
/(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/; |
|
const replacement1 = ( |
|
match, |
|
year, |
|
month, |
|
day, |
|
hour, |
|
minute, |
|
second, |
|
millisecond, |
|
) => { |
|
return `${year}-${month.padStart(2, '0')}-${day.padStart( |
|
2, |
|
'0', |
|
)}T${hour.padStart(2, '0')}:${minute.padStart( |
|
2, |
|
'0', |
|
)}:${second.padStart(2, '0')}.${millisecond.padStart(3, '0')}Z`; |
|
}; |
|
const isoTimestamp1 = timestamp.replace(pattern1, replacement1); |
|
if (!isNaN(Number(new Date(isoTimestamp1)))) { |
|
return new Date(isoTimestamp1).getTime(); |
|
} |
|
|
|
const pattern2 = /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i; |
|
const replacement2 = (match, month, day, year, hour, minute, meridiem) => { |
|
const monthNames = [ |
|
'January', |
|
'February', |
|
'March', |
|
'April', |
|
'May', |
|
'June', |
|
'July', |
|
'August', |
|
'September', |
|
'October', |
|
'November', |
|
'December', |
|
]; |
|
const monthNum = monthNames.indexOf(month) + 1; |
|
const hour24 = |
|
meridiem.toLowerCase() === 'pm' |
|
? (parseInt(hour, 10) % 12) + 12 |
|
: parseInt(hour, 10) % 12; |
|
return `${year}-${monthNum.toString().padStart(2, '0')}-${day.padStart( |
|
2, |
|
'0', |
|
)}T${hour24.toString().padStart(2, '0')}:${minute.padStart( |
|
2, |
|
'0', |
|
)}:00Z`; |
|
}; |
|
const isoTimestamp2 = timestamp.replace(pattern2, replacement2); |
|
if (!isNaN(Number(new Date(isoTimestamp2)))) { |
|
return new Date(isoTimestamp2).getTime(); |
|
} |
|
|
|
return 0; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function collectAndCreateStats(chatsPath, charactersPath) { |
|
const files = await readdir(charactersPath); |
|
|
|
const pngFiles = files.filter((file) => file.endsWith('.png')); |
|
|
|
let processingPromises = pngFiles.map((file) => |
|
calculateStats(chatsPath, file), |
|
); |
|
const statsArr = await Promise.all(processingPromises); |
|
|
|
let finalStats = {}; |
|
for (let stat of statsArr) { |
|
finalStats = { ...finalStats, ...stat }; |
|
} |
|
|
|
finalStats.timestamp = Date.now(); |
|
return finalStats; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function recreateStats(handle, chatsPath, charactersPath) { |
|
console.info('Collecting and creating stats for user:', handle); |
|
const stats = await collectAndCreateStats(chatsPath, charactersPath); |
|
STATS.set(handle, stats); |
|
await saveStatsToFile(); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
export async function init() { |
|
try { |
|
const userHandles = await getAllUserHandles(); |
|
for (const handle of userHandles) { |
|
const directories = getUserDirectories(handle); |
|
try { |
|
const statsFilePath = path.join(directories.root, STATS_FILE); |
|
const statsFileContent = await readFile(statsFilePath, 'utf-8'); |
|
STATS.set(handle, JSON.parse(statsFileContent)); |
|
} catch (err) { |
|
|
|
if (err.code === 'ENOENT' || err instanceof SyntaxError) { |
|
await recreateStats(handle, directories.chats, directories.characters); |
|
} else { |
|
throw err; |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Failed to initialize stats:', err); |
|
} |
|
|
|
setInterval(saveStatsToFile, 5 * 60 * 1000); |
|
} |
|
|
|
|
|
|
|
async function saveStatsToFile() { |
|
const userHandles = await getAllUserHandles(); |
|
for (const handle of userHandles) { |
|
if (!STATS.has(handle)) { |
|
continue; |
|
} |
|
const charStats = STATS.get(handle); |
|
const lastSaveTimestamp = TIMESTAMPS.get(handle) || 0; |
|
if (charStats.timestamp > lastSaveTimestamp) { |
|
try { |
|
const directories = getUserDirectories(handle); |
|
const statsFilePath = path.join(directories.root, STATS_FILE); |
|
await writeFileAtomic(statsFilePath, JSON.stringify(charStats)); |
|
TIMESTAMPS.set(handle, Date.now()); |
|
} catch (error) { |
|
console.error('Failed to save stats to file.', error); |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
export async function onExit() { |
|
try { |
|
await saveStatsToFile(); |
|
} catch (err) { |
|
console.error('Failed to write stats to file:', err); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function readAndParseFile(filepath) { |
|
try { |
|
let file = fs.readFileSync(filepath, 'utf8'); |
|
let lines = file.split('\n'); |
|
return lines; |
|
} catch (error) { |
|
console.error(`Error reading file at ${filepath}: ${error}`); |
|
return []; |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function calculateGenTime(gen_started, gen_finished) { |
|
let startDate = new Date(gen_started); |
|
let endDate = new Date(gen_finished); |
|
return Number(endDate) - Number(startDate); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function countWordsInString(str) { |
|
const match = str.match(/\b\w+\b/g); |
|
return match ? match.length : 0; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const calculateStats = (chatsPath, item) => { |
|
const chatDir = path.join(chatsPath, item.replace('.png', '')); |
|
const stats = { |
|
total_gen_time: 0, |
|
user_word_count: 0, |
|
non_user_word_count: 0, |
|
user_msg_count: 0, |
|
non_user_msg_count: 0, |
|
total_swipe_count: 0, |
|
chat_size: 0, |
|
date_last_chat: 0, |
|
date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(), |
|
}; |
|
let uniqueGenStartTimes = new Set(); |
|
|
|
if (fs.existsSync(chatDir)) { |
|
const chats = fs.readdirSync(chatDir); |
|
if (Array.isArray(chats) && chats.length) { |
|
for (const chat of chats) { |
|
const result = calculateTotalGenTimeAndWordCount( |
|
chatDir, |
|
chat, |
|
uniqueGenStartTimes, |
|
); |
|
stats.total_gen_time += result.totalGenTime || 0; |
|
stats.user_word_count += result.userWordCount || 0; |
|
stats.non_user_word_count += result.nonUserWordCount || 0; |
|
stats.user_msg_count += result.userMsgCount || 0; |
|
stats.non_user_msg_count += result.nonUserMsgCount || 0; |
|
stats.total_swipe_count += result.totalSwipeCount || 0; |
|
|
|
const chatStat = fs.statSync(path.join(chatDir, chat)); |
|
stats.chat_size += chatStat.size; |
|
stats.date_last_chat = Math.max( |
|
stats.date_last_chat, |
|
Math.floor(chatStat.mtimeMs), |
|
); |
|
stats.date_first_chat = Math.min( |
|
stats.date_first_chat, |
|
result.firstChatTime, |
|
); |
|
} |
|
} |
|
} |
|
|
|
return { [item]: stats }; |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
function setCharStats(handle, stats) { |
|
stats.timestamp = Date.now(); |
|
STATS.set(handle, stats); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function calculateTotalGenTimeAndWordCount( |
|
chatDir, |
|
chat, |
|
uniqueGenStartTimes, |
|
) { |
|
let filepath = path.join(chatDir, chat); |
|
let lines = readAndParseFile(filepath); |
|
|
|
let totalGenTime = 0; |
|
let userWordCount = 0; |
|
let nonUserWordCount = 0; |
|
let nonUserMsgCount = 0; |
|
let userMsgCount = 0; |
|
let totalSwipeCount = 0; |
|
let firstChatTime = new Date('9999-12-31T23:59:59.999Z').getTime(); |
|
|
|
for (let line of lines) { |
|
if (line.length) { |
|
try { |
|
let json = JSON.parse(line); |
|
if (json.mes) { |
|
let hash = crypto |
|
.createHash('sha256') |
|
.update(json.mes) |
|
.digest('hex'); |
|
if (uniqueGenStartTimes.has(hash)) { |
|
continue; |
|
} |
|
if (hash) { |
|
uniqueGenStartTimes.add(hash); |
|
} |
|
} |
|
|
|
if (json.gen_started && json.gen_finished) { |
|
let genTime = calculateGenTime( |
|
json.gen_started, |
|
json.gen_finished, |
|
); |
|
totalGenTime += genTime; |
|
|
|
if (json.swipes && !json.swipe_info) { |
|
|
|
totalGenTime += genTime * json.swipes.length; |
|
} |
|
} |
|
|
|
if (json.mes) { |
|
let wordCount = countWordsInString(json.mes); |
|
json.is_user |
|
? (userWordCount += wordCount) |
|
: (nonUserWordCount += wordCount); |
|
json.is_user ? userMsgCount++ : nonUserMsgCount++; |
|
} |
|
|
|
if (json.swipes && json.swipes.length > 1) { |
|
totalSwipeCount += json.swipes.length - 1; |
|
for (let i = 1; i < json.swipes.length; i++) { |
|
|
|
let swipeText = json.swipes[i]; |
|
|
|
let wordCount = countWordsInString(swipeText); |
|
json.is_user |
|
? (userWordCount += wordCount) |
|
: (nonUserWordCount += wordCount); |
|
json.is_user ? userMsgCount++ : nonUserMsgCount++; |
|
} |
|
} |
|
|
|
if (json.swipe_info && json.swipe_info.length > 1) { |
|
for (let i = 1; i < json.swipe_info.length; i++) { |
|
|
|
let swipe = json.swipe_info[i]; |
|
if (swipe.gen_started && swipe.gen_finished) { |
|
totalGenTime += calculateGenTime( |
|
swipe.gen_started, |
|
swipe.gen_finished, |
|
); |
|
} |
|
} |
|
} |
|
|
|
|
|
if (json.is_user) { |
|
|
|
firstChatTime = Math.min(timestampToMoment(json.send_date), firstChatTime); |
|
} |
|
} catch (error) { |
|
console.error(`Error parsing line ${line}: ${error}`); |
|
} |
|
} |
|
} |
|
return { |
|
totalGenTime, |
|
userWordCount, |
|
nonUserWordCount, |
|
userMsgCount, |
|
nonUserMsgCount, |
|
totalSwipeCount, |
|
firstChatTime, |
|
}; |
|
} |
|
|
|
export const router = express.Router(); |
|
|
|
|
|
|
|
|
|
router.post('/get', function (request, response) { |
|
const stats = STATS.get(request.user.profile.handle) || {}; |
|
response.send(stats); |
|
}); |
|
|
|
|
|
|
|
|
|
router.post('/recreate', async function (request, response) { |
|
try { |
|
await recreateStats(request.user.profile.handle, request.user.directories.chats, request.user.directories.characters); |
|
return response.sendStatus(200); |
|
} catch (error) { |
|
console.error(error); |
|
return response.sendStatus(500); |
|
} |
|
}); |
|
|
|
|
|
|
|
|
|
router.post('/update', function (request, response) { |
|
if (!request.body) return response.sendStatus(400); |
|
setCharStats(request.user.profile.handle, request.body); |
|
return response.sendStatus(200); |
|
}); |
|
|