305 lines
7.7 KiB
JavaScript
305 lines
7.7 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const FALLBACK_EMOJIS = ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'];
|
|
const DISPLAY_TIME_ZONE = 'Asia/Seoul';
|
|
|
|
function ensureDirectory(filePath) {
|
|
const directory = path.dirname(filePath);
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
}
|
|
|
|
function createDatabase(databasePath) {
|
|
ensureDirectory(databasePath);
|
|
|
|
const db = new Database(databasePath);
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
db.pragma('busy_timeout = 5000');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS state (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
last_check_at TEXT NOT NULL,
|
|
total_checks INTEGER NOT NULL DEFAULT 0,
|
|
trigger_executed_for_check_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
emoji TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
accepted INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS trigger_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
executed_at TEXT NOT NULL,
|
|
script_path TEXT NOT NULL,
|
|
success INTEGER NOT NULL DEFAULT 0,
|
|
output TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
|
`);
|
|
|
|
const existingState = db.prepare('SELECT id FROM state WHERE id = 1').get();
|
|
|
|
if (!existingState) {
|
|
const now = new Date().toISOString();
|
|
db.prepare(`
|
|
INSERT INTO state (id, last_check_at, total_checks, trigger_executed_for_check_at)
|
|
VALUES (1, ?, 0, NULL)
|
|
`).run(now);
|
|
}
|
|
|
|
return db;
|
|
}
|
|
|
|
function randomEmoji() {
|
|
return FALLBACK_EMOJIS[Math.floor(Math.random() * FALLBACK_EMOJIS.length)];
|
|
}
|
|
|
|
function getStateRow(db) {
|
|
return db.prepare('SELECT * FROM state WHERE id = 1').get();
|
|
}
|
|
|
|
function getRecentEvents(db, limit) {
|
|
return db
|
|
.prepare(`
|
|
SELECT emoji, created_at, accepted
|
|
FROM events
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
`)
|
|
.all(limit);
|
|
}
|
|
|
|
function daysBetween(fromDate, toDate) {
|
|
const start = new Date(fromDate);
|
|
const end = new Date(toDate);
|
|
const diffMs = end.getTime() - start.getTime();
|
|
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
|
}
|
|
|
|
function thresholdMilliseconds(config) {
|
|
if (config.threshold_seconds) {
|
|
return config.threshold_seconds * 1000;
|
|
}
|
|
|
|
return config.threshold_days * 24 * 60 * 60 * 1000;
|
|
}
|
|
|
|
function deadlineFor(lastCheckAt, config) {
|
|
return new Date(new Date(lastCheckAt).getTime() + thresholdMilliseconds(config));
|
|
}
|
|
|
|
function isExpired(state, config, now = new Date()) {
|
|
return now.getTime() >= deadlineFor(state.last_check_at, config).getTime();
|
|
}
|
|
|
|
function buildStatus(db, config) {
|
|
const state = getStateRow(db);
|
|
const now = new Date();
|
|
const lastCheckAt = new Date(state.last_check_at);
|
|
const elapsedDays = daysBetween(state.last_check_at, now.toISOString());
|
|
const deadlineAt = deadlineFor(lastCheckAt, config);
|
|
const remainingMs = Math.max(0, deadlineAt.getTime() - now.getTime());
|
|
const remainingDays = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
|
|
|
return {
|
|
last_check_at: state.last_check_at,
|
|
deadline_at: deadlineAt.toISOString(),
|
|
expired: remainingMs <= 0,
|
|
last_check_display: formatDateTime(lastCheckAt),
|
|
elapsed_days: elapsedDays,
|
|
remaining_days: remainingDays,
|
|
total_checks: state.total_checks,
|
|
threshold_days: config.threshold_days,
|
|
threshold_seconds: config.threshold_seconds,
|
|
cooldown_seconds: config.cooldown_seconds,
|
|
collection_path: config.collection_path,
|
|
recent_traces: getRecentEvents(db, config.recent_traces_limit).map((event) => ({
|
|
emoji: event.emoji,
|
|
time: formatTime(new Date(event.created_at)),
|
|
created_at: event.created_at,
|
|
accepted: Boolean(event.accepted),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function formatDateTime(date) {
|
|
const parts = getDateTimeParts(date);
|
|
const year = parts.year;
|
|
const month = parts.month;
|
|
const day = parts.day;
|
|
const hours = parts.hour;
|
|
const minutes = parts.minute;
|
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
}
|
|
|
|
function formatTime(date) {
|
|
const parts = getDateTimeParts(date);
|
|
const hours = parts.hour;
|
|
const minutes = parts.minute;
|
|
const seconds = parts.second;
|
|
return `${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
function getDateTimeParts(date) {
|
|
return Object.fromEntries(
|
|
new Intl.DateTimeFormat('ko-KR', {
|
|
timeZone: DISPLAY_TIME_ZONE,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
})
|
|
.formatToParts(date)
|
|
.filter((part) => part.type !== 'literal')
|
|
.map((part) => [part.type, part.value])
|
|
);
|
|
}
|
|
|
|
function recordCheck(db, config, requestedEmoji) {
|
|
const emoji =
|
|
typeof requestedEmoji === 'string' && requestedEmoji.trim()
|
|
? requestedEmoji.trim().slice(0, 16)
|
|
: randomEmoji();
|
|
|
|
const insertEvent = db.prepare(`
|
|
INSERT INTO events (emoji, created_at, accepted)
|
|
VALUES (?, ?, ?)
|
|
`);
|
|
|
|
const updateState = db.prepare(`
|
|
UPDATE state
|
|
SET last_check_at = ?,
|
|
total_checks = total_checks + 1,
|
|
trigger_executed_for_check_at = NULL
|
|
WHERE id = 1
|
|
`);
|
|
|
|
let accepted = false;
|
|
let expired = false;
|
|
|
|
const transaction = db.transaction(() => {
|
|
const now = new Date();
|
|
const nowIso = now.toISOString();
|
|
const state = getStateRow(db);
|
|
|
|
if (isExpired(state, config, now)) {
|
|
expired = true;
|
|
return;
|
|
}
|
|
|
|
const lastAcceptedAt = new Date(state.last_check_at);
|
|
const secondsSinceLastCheck = (now.getTime() - lastAcceptedAt.getTime()) / 1000;
|
|
accepted = secondsSinceLastCheck >= config.cooldown_seconds;
|
|
|
|
insertEvent.run(emoji, nowIso, accepted ? 1 : 0);
|
|
|
|
if (accepted) {
|
|
updateState.run(nowIso);
|
|
}
|
|
});
|
|
|
|
transaction();
|
|
|
|
if (expired) {
|
|
return {
|
|
accepted: false,
|
|
expired: true,
|
|
emoji,
|
|
message: '이미 시간이 지났어요.',
|
|
status: buildStatus(db, config),
|
|
};
|
|
}
|
|
|
|
const message = accepted
|
|
? `${emoji} 방금 확인했어요!`
|
|
: `${emoji} 잠시 후 다시 눌러주세요.`;
|
|
|
|
return {
|
|
accepted,
|
|
expired: false,
|
|
emoji,
|
|
message,
|
|
status: buildStatus(db, config),
|
|
};
|
|
}
|
|
|
|
function shouldRunTrigger(db, config) {
|
|
const state = getStateRow(db);
|
|
|
|
if (!isExpired(state, config)) {
|
|
return false;
|
|
}
|
|
|
|
if (state.trigger_executed_for_check_at === state.last_check_at) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function markTriggerExecuted(db, scriptPath, success, output) {
|
|
const nowIso = new Date().toISOString();
|
|
const state = getStateRow(db);
|
|
|
|
const insertRun = db.prepare(`
|
|
INSERT INTO trigger_runs (executed_at, script_path, success, output)
|
|
VALUES (?, ?, ?, ?)
|
|
`);
|
|
|
|
const updateState = db.prepare(`
|
|
UPDATE state
|
|
SET trigger_executed_for_check_at = ?
|
|
WHERE id = 1
|
|
`);
|
|
|
|
const transaction = db.transaction(() => {
|
|
insertRun.run(nowIso, scriptPath, success ? 1 : 0, output ?? '');
|
|
updateState.run(state.last_check_at);
|
|
});
|
|
|
|
transaction();
|
|
}
|
|
|
|
function resetKeeper(db) {
|
|
const nowIso = new Date().toISOString();
|
|
|
|
const resetState = db.prepare(`
|
|
UPDATE state
|
|
SET last_check_at = ?,
|
|
total_checks = 0,
|
|
trigger_executed_for_check_at = NULL
|
|
WHERE id = 1
|
|
`);
|
|
|
|
const deleteEvents = db.prepare('DELETE FROM events');
|
|
const deleteTriggerRuns = db.prepare('DELETE FROM trigger_runs');
|
|
|
|
const transaction = db.transaction(() => {
|
|
resetState.run(nowIso);
|
|
deleteEvents.run();
|
|
deleteTriggerRuns.run();
|
|
});
|
|
|
|
transaction();
|
|
}
|
|
|
|
module.exports = {
|
|
FALLBACK_EMOJIS,
|
|
createDatabase,
|
|
buildStatus,
|
|
recordCheck,
|
|
shouldRunTrigger,
|
|
markTriggerExecuted,
|
|
resetKeeper,
|
|
};
|