refactor: optimize project structure, migrate to SQLite, and add new features
fix:
- Fix TypeScript type errors in api-wrapper.ts (ApiResponse type)
- Fix backward compatibility in database.ts getSettings() for missing fields
- Fix default value for weekNavigationEnabled (changed from true to false)
- Fix API routes error handling with unified wrapper
- Fix duplicate toggle switch code in admin.tsx (6 instances)
- Fix inconsistent authentication check in API routes (unified with withAuth)
- Fix error message text in loading-context.tsx (improved user experience)
add:
- Add database.ts: SQLite database layer with better-sqlite3 for persistent storage
* Groups management (CRUD operations)
* Settings management with caching
* Admin password hashing with bcrypt
* Automatic database initialization and migration
- Add api-wrapper.ts utility for unified API route handling
* withAuth wrapper for protected routes
* withMethods wrapper for public routes
* Consistent error handling and method validation
- Add validation.ts utility with centralized validation functions
* validateCourse - course validation (1-5)
* validateGroupId - group ID format validation
* validatePassword - password strength validation
- Add showAddGroupButton setting to control visibility of 'Add Group' button on homepage
- Add toggle switch component in admin.tsx for reusable UI (replaces 6 duplicate instances)
- Add CourseSelect component in admin.tsx for reusable course selection
- Add DialogFooterButtons component in admin.tsx for reusable dialog footer
- Add unified loadData function in admin.tsx to reduce code duplication
- Add change-password.ts API endpoint for admin password management
- Add logs.ts API endpoint for viewing error logs in admin panel
- Add logErrorToFile function in logger.ts for persistent error logging
- Add comprehensive error logging in schedule.ts (parsing, fetch, timeout, network errors)
- Add comprehensive project structure documentation in README.md
- Add architecture and code organization section in README.md
- Add database information section in README.md
- Add SQLite and bcrypt to tech stack documentation
- Add better-sqlite3 and bcrypt dependencies to package.json
- Add .gitignore rules for error.log and database files (data/, *.db, *.db-shm, *.db-wal)
refactor:
- Refactor admin.tsx: extract reusable components (toggle, select, dialog footer)
- Refactor API routes to use withAuth wrapper for consistent authentication
- Refactor API routes to use validation utilities instead of inline validation
- Refactor groups.ts and groups.json: move to old/data/ directory (deprecated, now using SQLite)
- Refactor settings-loader.ts: migrate from JSON to SQLite database
- Refactor groups-loader.ts: migrate from JSON to SQLite database
- Refactor database.ts: improve backward compatibility for settings migration
- Refactor admin.tsx: unify data loading functions (loadGroupsList, loadSettingsList)
- Refactor index.tsx: add showAddGroupButton prop and conditional rendering
- Refactor API routes: consistent error handling and method validation
- Refactor README.md: update tech stack, project structure, and admin panel documentation
- Refactor auth.ts: improve session management and cookie handling
- Refactor schedule.ts: improve error handling with detailed logging and error types
- Refactor logger.ts: add file-based error logging functionality
- Refactor loading-context.tsx: improve error message clarity
remove:
- Remove hello.ts test API endpoint
- Remove groups.ts and groups.json (moved to old/data/, replaced by SQLite)
update:
- Update .gitignore to exclude old data files, database files, and error logs
- Update package.json: add better-sqlite3, bcrypt and their type definitions
- Update README.md with new features, architecture, and database information
- Update all API routes to use new wrapper system
- Update admin panel with new settings and improved UI
- Update sitemap.xml with cache usage comment
This commit is contained in:
389
src/shared/data/database.ts
Normal file
389
src/shared/data/database.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import bcrypt from 'bcrypt'
|
||||
import type { GroupInfo, GroupsData } from './groups-loader'
|
||||
import type { AppSettings } from './settings-loader'
|
||||
|
||||
// Путь к файлу базы данных
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'schedule-app.db')
|
||||
const DEFAULT_PASSWORD = 'ksadmin'
|
||||
|
||||
// Создаем директорию data, если её нет
|
||||
const dbDir = path.dirname(DB_PATH)
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Инициализация базы данных
|
||||
let db: Database.Database | null = null
|
||||
|
||||
function getDatabase(): Database.Database {
|
||||
if (db) {
|
||||
return db
|
||||
}
|
||||
|
||||
db = new Database(DB_PATH)
|
||||
|
||||
// Применяем современные настройки SQLite
|
||||
db.pragma('journal_mode = WAL') // Write-Ahead Logging для лучшей производительности
|
||||
db.pragma('synchronous = NORMAL') // Баланс между производительностью и надежностью
|
||||
db.pragma('foreign_keys = ON') // Включение проверки внешних ключей
|
||||
db.pragma('busy_timeout = 5000') // Таймаут для ожидания блокировок (5 секунд)
|
||||
db.pragma('temp_store = MEMORY') // Хранение временных данных в памяти
|
||||
db.pragma('mmap_size = 268435456') // Memory-mapped I/O (256MB)
|
||||
db.pragma('cache_size = -64000') // Размер кеша в страницах (64MB)
|
||||
|
||||
// Создаем таблицы, если их нет
|
||||
initializeTables()
|
||||
|
||||
// Выполняем миграцию данных из JSON, если БД пустая
|
||||
migrateFromJSON()
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
function initializeTables(): void {
|
||||
const database = getDatabase()
|
||||
|
||||
// Таблица групп
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
parseId INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
course INTEGER NOT NULL CHECK(course >= 1 AND course <= 5)
|
||||
)
|
||||
`)
|
||||
|
||||
// Таблица настроек
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Таблица админ пароля
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin_password (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
password_hash TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
// ==================== Функции для работы с группами ====================
|
||||
|
||||
export function getAllGroups(): GroupsData {
|
||||
const database = getDatabase()
|
||||
const rows = database.prepare('SELECT id, parseId, name, course FROM groups').all() as Array<{
|
||||
id: string
|
||||
parseId: number
|
||||
name: string
|
||||
course: number
|
||||
}>
|
||||
|
||||
const groups: GroupsData = {}
|
||||
for (const row of rows) {
|
||||
groups[row.id] = {
|
||||
parseId: row.parseId,
|
||||
name: row.name,
|
||||
course: row.course
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
export function getGroup(id: string): GroupInfo | null {
|
||||
const database = getDatabase()
|
||||
const row = database.prepare('SELECT parseId, name, course FROM groups WHERE id = ?').get(id) as {
|
||||
parseId: number
|
||||
name: string
|
||||
course: number
|
||||
} | undefined
|
||||
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
parseId: row.parseId,
|
||||
name: row.name,
|
||||
course: row.course
|
||||
}
|
||||
}
|
||||
|
||||
export function createGroup(id: string, group: GroupInfo): void {
|
||||
const database = getDatabase()
|
||||
database
|
||||
.prepare('INSERT INTO groups (id, parseId, name, course) VALUES (?, ?, ?, ?)')
|
||||
.run(id, group.parseId, group.name, group.course)
|
||||
}
|
||||
|
||||
export function updateGroup(id: string, group: Partial<GroupInfo>): void {
|
||||
const database = getDatabase()
|
||||
const existing = getGroup(id)
|
||||
if (!existing) {
|
||||
throw new Error(`Group with id ${id} not found`)
|
||||
}
|
||||
|
||||
const updated: GroupInfo = {
|
||||
parseId: group.parseId !== undefined ? group.parseId : existing.parseId,
|
||||
name: group.name !== undefined ? group.name : existing.name,
|
||||
course: group.course !== undefined ? group.course : existing.course
|
||||
}
|
||||
|
||||
database
|
||||
.prepare('UPDATE groups SET parseId = ?, name = ?, course = ? WHERE id = ?')
|
||||
.run(updated.parseId, updated.name, updated.course, id)
|
||||
}
|
||||
|
||||
export function deleteGroup(id: string): void {
|
||||
const database = getDatabase()
|
||||
database.prepare('DELETE FROM groups WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
// ==================== Функции для работы с настройками ====================
|
||||
|
||||
export function getSettings(): AppSettings {
|
||||
const database = getDatabase()
|
||||
const row = database.prepare('SELECT value FROM settings WHERE key = ?').get('app') as {
|
||||
value: string
|
||||
} | undefined
|
||||
|
||||
if (!row) {
|
||||
// Возвращаем настройки по умолчанию
|
||||
const defaultSettings: AppSettings = {
|
||||
weekNavigationEnabled: false,
|
||||
showAddGroupButton: true,
|
||||
debug: {
|
||||
forceCache: false,
|
||||
forceEmpty: false,
|
||||
forceError: false,
|
||||
forceTimeout: false,
|
||||
showCacheInfo: false
|
||||
}
|
||||
}
|
||||
return defaultSettings
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = JSON.parse(row.value) as Partial<AppSettings>
|
||||
// Всегда добавляем дефолтные debug настройки (они не хранятся в БД)
|
||||
// И добавляем отсутствующие поля для обратной совместимости
|
||||
return {
|
||||
weekNavigationEnabled: settings.weekNavigationEnabled ?? false,
|
||||
showAddGroupButton: settings.showAddGroupButton ?? true,
|
||||
...settings,
|
||||
debug: {
|
||||
forceCache: false,
|
||||
forceEmpty: false,
|
||||
forceError: false,
|
||||
forceTimeout: false,
|
||||
showCacheInfo: false
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing settings from database:', error)
|
||||
const defaultSettings: AppSettings = {
|
||||
weekNavigationEnabled: false,
|
||||
showAddGroupButton: true,
|
||||
debug: {
|
||||
forceCache: false,
|
||||
forceEmpty: false,
|
||||
forceError: false,
|
||||
forceTimeout: false,
|
||||
showCacheInfo: false
|
||||
}
|
||||
}
|
||||
return defaultSettings
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSettings(settings: AppSettings): void {
|
||||
const database = getDatabase()
|
||||
const defaultSettings: AppSettings = {
|
||||
weekNavigationEnabled: false,
|
||||
showAddGroupButton: true,
|
||||
debug: {
|
||||
forceCache: false,
|
||||
forceEmpty: false,
|
||||
forceError: false,
|
||||
forceTimeout: false,
|
||||
showCacheInfo: false
|
||||
}
|
||||
}
|
||||
|
||||
// Исключаем debug из настроек перед сохранением в БД
|
||||
const { debug, ...settingsWithoutDebug } = settings
|
||||
const mergedSettings: AppSettings = {
|
||||
...defaultSettings,
|
||||
...settingsWithoutDebug
|
||||
// debug намеренно не сохраняется в БД
|
||||
}
|
||||
|
||||
database
|
||||
.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('app', JSON.stringify(mergedSettings))
|
||||
}
|
||||
|
||||
// ==================== Функции для работы с паролем ====================
|
||||
|
||||
export function getPasswordHash(): string | null {
|
||||
const database = getDatabase()
|
||||
const row = database.prepare('SELECT password_hash FROM admin_password WHERE id = 1').get() as {
|
||||
password_hash: string
|
||||
} | undefined
|
||||
|
||||
return row?.password_hash || null
|
||||
}
|
||||
|
||||
export function setPasswordHash(hash: string): void {
|
||||
const database = getDatabase()
|
||||
database.prepare('INSERT OR REPLACE INTO admin_password (id, password_hash) VALUES (1, ?)').run(hash)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string): Promise<boolean> {
|
||||
const hash = getPasswordHash()
|
||||
if (!hash) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await bcrypt.compare(password, hash)
|
||||
} catch (error) {
|
||||
console.error('Error verifying password:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword: string, newPassword: string): Promise<boolean> {
|
||||
// Проверяем старый пароль
|
||||
const isValid = await verifyPassword(oldPassword)
|
||||
if (!isValid) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Хэшируем новый пароль
|
||||
const saltRounds = 10
|
||||
const newHash = await bcrypt.hash(newPassword, saltRounds)
|
||||
|
||||
// Сохраняем новый хэш
|
||||
setPasswordHash(newHash)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function isDefaultPassword(): Promise<boolean> {
|
||||
const hash = getPasswordHash()
|
||||
if (!hash) {
|
||||
return true // Если пароля нет, считаем что используется дефолтный
|
||||
}
|
||||
|
||||
// Проверяем, соответствует ли хэш дефолтному паролю
|
||||
return await bcrypt.compare(DEFAULT_PASSWORD, hash)
|
||||
}
|
||||
|
||||
// ==================== Миграция данных из JSON ====================
|
||||
|
||||
function migrateFromJSON(): void {
|
||||
const database = getDatabase()
|
||||
|
||||
// Проверяем, есть ли уже данные в БД
|
||||
const groupsCount = database.prepare('SELECT COUNT(*) as count FROM groups').get() as { count: number }
|
||||
const settingsCount = database.prepare('SELECT COUNT(*) as count FROM settings').get() as { count: number }
|
||||
const passwordExists = database.prepare('SELECT COUNT(*) as count FROM admin_password WHERE id = 1').get() as {
|
||||
count: number
|
||||
}
|
||||
|
||||
// Мигрируем группы из JSON, если БД пустая
|
||||
if (groupsCount.count === 0) {
|
||||
try {
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'src/shared/data/groups.json'),
|
||||
path.join(process.cwd(), '.next/standalone/src/shared/data/groups.json'),
|
||||
path.join(process.cwd(), 'groups.json')
|
||||
]
|
||||
|
||||
for (const filePath of possiblePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8')
|
||||
const rawGroups = JSON.parse(fileContents) as GroupsData | { [key: string]: [number, string] | GroupInfo }
|
||||
|
||||
// Мигрируем данные
|
||||
const insertStmt = database.prepare('INSERT INTO groups (id, parseId, name, course) VALUES (?, ?, ?, ?)')
|
||||
const transaction = database.transaction((groups: GroupsData) => {
|
||||
for (const [id, data] of Object.entries(groups)) {
|
||||
let group: GroupInfo
|
||||
if (Array.isArray(data) && data.length === 2 && typeof data[0] === 'number' && typeof data[1] === 'string') {
|
||||
// Старый формат [parseId, name]
|
||||
group = {
|
||||
parseId: data[0],
|
||||
name: data[1],
|
||||
course: 1
|
||||
}
|
||||
} else if (typeof data === 'object' && 'parseId' in data && 'name' in data) {
|
||||
group = data as GroupInfo
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
insertStmt.run(id, group.parseId, group.name, group.course)
|
||||
}
|
||||
})
|
||||
|
||||
transaction(rawGroups as GroupsData)
|
||||
console.log('Groups migrated from JSON to database')
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error migrating groups from JSON:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Мигрируем настройки из JSON, если БД пустая
|
||||
if (settingsCount.count === 0) {
|
||||
try {
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'src/shared/data/settings.json'),
|
||||
path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'),
|
||||
path.join(process.cwd(), 'settings.json')
|
||||
]
|
||||
|
||||
for (const filePath of possiblePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8')
|
||||
const settings = JSON.parse(fileContents) as AppSettings
|
||||
updateSettings(settings)
|
||||
console.log('Settings migrated from JSON to database')
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error migrating settings from JSON:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализируем дефолтный пароль, если его нет
|
||||
if (passwordExists.count === 0) {
|
||||
const saltRounds = 10
|
||||
try {
|
||||
// Используем синхронную версию для инициализации при старте
|
||||
const hash = bcrypt.hashSync(DEFAULT_PASSWORD, saltRounds)
|
||||
setPasswordHash(hash)
|
||||
console.log('Default password "ksadmin" initialized')
|
||||
} catch (err) {
|
||||
console.error('Error hashing default password:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем функцию для закрытия соединения (полезно для тестов)
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getAllGroups as getAllGroupsFromDB, createGroup, updateGroup, deleteGroup, getGroup } from './database'
|
||||
|
||||
export type GroupInfo = {
|
||||
parseId: number
|
||||
@@ -9,120 +8,74 @@ export type GroupInfo = {
|
||||
|
||||
export type GroupsData = { [group: string]: GroupInfo }
|
||||
|
||||
// Старый формат для миграции
|
||||
type OldGroupsData = { [group: string]: [number, string] | GroupInfo }
|
||||
|
||||
let cachedGroups: GroupsData | null = null
|
||||
let cacheTimestamp: number = 0
|
||||
const CACHE_TTL_MS = 1000 * 60 // 1 минута
|
||||
|
||||
/**
|
||||
* Мигрирует старый формат данных в новый
|
||||
* Загружает группы из базы данных
|
||||
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
|
||||
*/
|
||||
function migrateGroups(oldGroups: OldGroupsData): GroupsData {
|
||||
const migrated: GroupsData = {}
|
||||
export function loadGroups(forceRefresh: boolean = false): GroupsData {
|
||||
const now = Date.now()
|
||||
const isCacheValid = cachedGroups !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
|
||||
|
||||
for (const [id, data] of Object.entries(oldGroups)) {
|
||||
// Проверяем, является ли это старым форматом [parseId, name]
|
||||
if (Array.isArray(data) && data.length === 2 && typeof data[0] === 'number' && typeof data[1] === 'string') {
|
||||
// Старый формат - мигрируем
|
||||
migrated[id] = {
|
||||
parseId: data[0],
|
||||
name: data[1],
|
||||
course: 1 // По умолчанию курс 1
|
||||
}
|
||||
} else if (typeof data === 'object' && 'parseId' in data && 'name' in data) {
|
||||
// Уже новый формат
|
||||
migrated[id] = data as GroupInfo
|
||||
}
|
||||
}
|
||||
|
||||
return migrated
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает группы из JSON файла
|
||||
* Использует кеш для оптимизации в production
|
||||
* Автоматически мигрирует старый формат в новый
|
||||
*/
|
||||
export function loadGroups(): GroupsData {
|
||||
if (cachedGroups) {
|
||||
if (isCacheValid && cachedGroups !== null) {
|
||||
return cachedGroups
|
||||
}
|
||||
|
||||
// В production Next.js может использовать другую структуру директорий
|
||||
// Пробуем несколько путей
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'src/shared/data/groups.json'),
|
||||
path.join(process.cwd(), '.next/standalone/src/shared/data/groups.json'),
|
||||
path.join(process.cwd(), 'groups.json'),
|
||||
]
|
||||
|
||||
for (const filePath of possiblePaths) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8')
|
||||
const rawGroups = JSON.parse(fileContents) as OldGroupsData
|
||||
|
||||
// Проверяем, нужна ли миграция
|
||||
const needsMigration = Object.values(rawGroups).some(
|
||||
data => Array.isArray(data) && data.length === 2
|
||||
)
|
||||
|
||||
let groups: GroupsData
|
||||
if (needsMigration) {
|
||||
// Мигрируем старый формат
|
||||
groups = migrateGroups(rawGroups)
|
||||
// Сохраняем мигрированные данные
|
||||
const mainPath = path.join(process.cwd(), 'src/shared/data/groups.json')
|
||||
if (filePath === mainPath) {
|
||||
// Сохраняем только если это основной файл
|
||||
fs.writeFileSync(mainPath, JSON.stringify(groups, null, 2), 'utf8')
|
||||
console.log('Groups data migrated to new format')
|
||||
}
|
||||
} else {
|
||||
groups = rawGroups as GroupsData
|
||||
}
|
||||
|
||||
cachedGroups = groups
|
||||
return groups
|
||||
}
|
||||
} catch (error) {
|
||||
// Пробуем следующий путь
|
||||
continue
|
||||
}
|
||||
try {
|
||||
cachedGroups = getAllGroupsFromDB()
|
||||
cacheTimestamp = now
|
||||
return cachedGroups
|
||||
} catch (error) {
|
||||
console.error('Error loading groups from database:', error)
|
||||
// Fallback к пустому объекту
|
||||
return {}
|
||||
}
|
||||
|
||||
console.error('Error loading groups.json: file not found in any of the expected locations')
|
||||
// Fallback к пустому объекту
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет группы в JSON файл
|
||||
* Сохраняет группы в базу данных
|
||||
*/
|
||||
export function saveGroups(groups: GroupsData): void {
|
||||
// Всегда сохраняем в основной путь
|
||||
const filePath = path.join(process.cwd(), 'src/shared/data/groups.json')
|
||||
|
||||
try {
|
||||
// Создаем директорию, если её нет
|
||||
const dir = path.dirname(filePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
const existingGroups = getAllGroupsFromDB()
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(groups, null, 2), 'utf8')
|
||||
// Сбрасываем кеш
|
||||
// Определяем, какие группы нужно добавить, обновить или удалить
|
||||
const existingIds = new Set(Object.keys(existingGroups))
|
||||
const newIds = new Set(Object.keys(groups))
|
||||
|
||||
// Добавляем или обновляем группы
|
||||
for (const [id, group] of Object.entries(groups)) {
|
||||
if (existingIds.has(id)) {
|
||||
updateGroup(id, group)
|
||||
} else {
|
||||
createGroup(id, group)
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем группы, которых больше нет
|
||||
for (const id of existingIds) {
|
||||
if (!newIds.has(id)) {
|
||||
deleteGroup(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Сбрасываем кеш и timestamp
|
||||
cachedGroups = null
|
||||
cacheTimestamp = 0
|
||||
} catch (error) {
|
||||
console.error('Error saving groups.json:', error)
|
||||
console.error('Error saving groups to database:', error)
|
||||
throw new Error('Failed to save groups')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает кеш групп (полезно после обновления файла)
|
||||
* Сбрасывает кеш групп (полезно после обновления)
|
||||
*/
|
||||
export function clearGroupsCache(): void {
|
||||
cachedGroups = null
|
||||
cacheTimestamp = 0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"ib4k": {
|
||||
"parseId": 138,
|
||||
"name": "ИБ-4к",
|
||||
"course": 4
|
||||
},
|
||||
"ib5": {
|
||||
"parseId": 144,
|
||||
"name": "ИБ-5",
|
||||
"course": 3
|
||||
},
|
||||
"ib6": {
|
||||
"parseId": 145,
|
||||
"name": "ИБ-6",
|
||||
"course": 3
|
||||
},
|
||||
"ib7k": {
|
||||
"parseId": 172,
|
||||
"name": "ИБ-7к",
|
||||
"course": 3
|
||||
},
|
||||
"ib3": {
|
||||
"parseId": 123,
|
||||
"name": "ИБ-3",
|
||||
"course": 4
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Загружаем группы из JSON файла только на сервере
|
||||
// На клиенте будет пустой объект, группы должны передаваться через props
|
||||
let groups: { [group: string]: [number, string] } = {}
|
||||
|
||||
// Используем условный require только на сервере для избежания включения fs в клиентскую сборку
|
||||
if (typeof window === 'undefined') {
|
||||
// Серверная сторона
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const groupsLoader = require('./groups-loader')
|
||||
groups = groupsLoader.loadGroups()
|
||||
} catch (error) {
|
||||
console.error('Error loading groups:', error)
|
||||
groups = {}
|
||||
}
|
||||
}
|
||||
|
||||
export { groups }
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getSettings as getSettingsFromDB, updateSettings as updateSettingsInDB } from './database'
|
||||
|
||||
export type AppSettings = {
|
||||
weekNavigationEnabled: boolean
|
||||
showAddGroupButton: boolean
|
||||
debug?: {
|
||||
forceCache?: boolean
|
||||
forceEmpty?: boolean
|
||||
@@ -13,190 +13,64 @@ export type AppSettings = {
|
||||
}
|
||||
|
||||
let cachedSettings: AppSettings | null = null
|
||||
let cachedSettingsPath: string | null = null
|
||||
let cachedSettingsMtime: number | null = null
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
weekNavigationEnabled: true,
|
||||
debug: {
|
||||
forceCache: false,
|
||||
forceEmpty: false,
|
||||
forceError: false,
|
||||
forceTimeout: false,
|
||||
showCacheInfo: false
|
||||
}
|
||||
}
|
||||
let cacheTimestamp: number = 0
|
||||
const CACHE_TTL_MS = 1000 * 60 // 1 минута
|
||||
|
||||
/**
|
||||
* Загружает настройки из JSON файла
|
||||
* Проверяет время модификации файла для инвалидации кеша
|
||||
* Загружает настройки из базы данных
|
||||
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
|
||||
*/
|
||||
export function loadSettings(): AppSettings {
|
||||
// В production Next.js может использовать другую структуру директорий
|
||||
// Пробуем несколько путей
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'src/shared/data/settings.json'),
|
||||
path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'),
|
||||
path.join(process.cwd(), 'settings.json'),
|
||||
]
|
||||
export function loadSettings(forceRefresh: boolean = false): AppSettings {
|
||||
const now = Date.now()
|
||||
const isCacheValid = cachedSettings !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
|
||||
|
||||
// Ищем существующий файл
|
||||
let foundPath: string | null = null
|
||||
for (const filePath of possiblePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
foundPath = filePath
|
||||
break
|
||||
}
|
||||
if (isCacheValid && cachedSettings !== null) {
|
||||
return cachedSettings
|
||||
}
|
||||
|
||||
// Если файл найден, проверяем, изменился ли он
|
||||
if (foundPath) {
|
||||
try {
|
||||
const stats = fs.statSync(foundPath)
|
||||
const mtime = stats.mtimeMs
|
||||
|
||||
// Если файл изменился или путь изменился, сбрасываем кеш
|
||||
if (cachedSettings && (cachedSettingsPath !== foundPath || cachedSettingsMtime !== mtime)) {
|
||||
cachedSettings = null
|
||||
cachedSettingsPath = null
|
||||
cachedSettingsMtime = null
|
||||
}
|
||||
|
||||
// Если кеш валиден, возвращаем его
|
||||
if (cachedSettings && cachedSettingsPath === foundPath && cachedSettingsMtime === mtime) {
|
||||
return cachedSettings
|
||||
}
|
||||
|
||||
// Загружаем файл заново
|
||||
const fileContents = fs.readFileSync(foundPath, 'utf8')
|
||||
const settings = JSON.parse(fileContents) as AppSettings
|
||||
|
||||
// Убеждаемся, что все обязательные поля присутствуют
|
||||
const mergedSettings: AppSettings = {
|
||||
...defaultSettings,
|
||||
...settings,
|
||||
debug: {
|
||||
...defaultSettings.debug,
|
||||
...settings.debug
|
||||
}
|
||||
}
|
||||
|
||||
cachedSettings = mergedSettings
|
||||
cachedSettingsPath = foundPath
|
||||
cachedSettingsMtime = mtime
|
||||
|
||||
return mergedSettings
|
||||
} catch (error) {
|
||||
console.error('Error reading settings.json:', error)
|
||||
// Продолжаем дальше, чтобы создать файл с настройками по умолчанию
|
||||
}
|
||||
}
|
||||
|
||||
// Если файл не найден, создаем его с настройками по умолчанию
|
||||
const mainPath = path.join(process.cwd(), 'src/shared/data/settings.json')
|
||||
|
||||
try {
|
||||
// Создаем директорию, если её нет
|
||||
const dir = path.dirname(mainPath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(mainPath, JSON.stringify(defaultSettings, null, 2), 'utf8')
|
||||
|
||||
const stats = fs.statSync(mainPath)
|
||||
cachedSettings = defaultSettings
|
||||
cachedSettingsPath = mainPath
|
||||
cachedSettingsMtime = stats.mtimeMs
|
||||
|
||||
return defaultSettings
|
||||
cachedSettings = getSettingsFromDB()
|
||||
cacheTimestamp = now
|
||||
return cachedSettings
|
||||
} catch (error) {
|
||||
console.error('Error creating settings.json:', error)
|
||||
console.error('Error loading settings from database:', error)
|
||||
// Возвращаем настройки по умолчанию
|
||||
const defaultSettings: AppSettings = {
|
||||
weekNavigationEnabled: false,
|
||||
showAddGroupButton: true,
|
||||
debug: {
|
||||
forceCache: false,
|
||||
forceEmpty: false,
|
||||
forceError: false,
|
||||
forceTimeout: false,
|
||||
showCacheInfo: false
|
||||
}
|
||||
}
|
||||
return defaultSettings
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет настройки в JSON файл
|
||||
* Сохраняет настройки в базу данных
|
||||
*/
|
||||
export function saveSettings(settings: AppSettings): void {
|
||||
// Сначала пытаемся найти существующий файл
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'src/shared/data/settings.json'),
|
||||
path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'),
|
||||
path.join(process.cwd(), 'settings.json'),
|
||||
]
|
||||
|
||||
// Объединяем с настройками по умолчанию для сохранения всех полей
|
||||
const mergedSettings: AppSettings = {
|
||||
...defaultSettings,
|
||||
...settings,
|
||||
debug: {
|
||||
...defaultSettings.debug,
|
||||
...settings.debug
|
||||
}
|
||||
}
|
||||
|
||||
// Ищем существующий файл
|
||||
let targetPath: string | null = null
|
||||
for (const filePath of possiblePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
targetPath = filePath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Если файл не найден, используем основной путь
|
||||
if (!targetPath) {
|
||||
targetPath = path.join(process.cwd(), 'src/shared/data/settings.json')
|
||||
}
|
||||
|
||||
try {
|
||||
// Создаем директорию, если её нет
|
||||
const dir = path.dirname(targetPath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
// Сохраняем файл
|
||||
fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
||||
|
||||
// Обновляем кеш с новыми метаданными
|
||||
try {
|
||||
const stats = fs.statSync(targetPath)
|
||||
cachedSettings = mergedSettings
|
||||
cachedSettingsPath = targetPath
|
||||
cachedSettingsMtime = stats.mtimeMs
|
||||
} catch (error) {
|
||||
// Если не удалось получить stats, просто обновляем кеш
|
||||
cachedSettings = mergedSettings
|
||||
cachedSettingsPath = targetPath
|
||||
cachedSettingsMtime = null
|
||||
}
|
||||
|
||||
// Также сохраняем в другие возможные пути для совместимости (если они существуют)
|
||||
for (const filePath of possiblePaths) {
|
||||
if (filePath !== targetPath && fs.existsSync(path.dirname(filePath))) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
// Игнорируем ошибки при сохранении в дополнительные пути
|
||||
}
|
||||
}
|
||||
}
|
||||
updateSettingsInDB(settings)
|
||||
// Сбрасываем кеш и timestamp
|
||||
cachedSettings = null
|
||||
cacheTimestamp = 0
|
||||
} catch (error) {
|
||||
console.error('Error saving settings.json:', error)
|
||||
console.error('Error saving settings to database:', error)
|
||||
throw new Error('Failed to save settings')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает кеш настроек (полезно после обновления файла)
|
||||
* Сбрасывает кеш настроек (полезно после обновления)
|
||||
*/
|
||||
export function clearSettingsCache(): void {
|
||||
cachedSettings = null
|
||||
cachedSettingsPath = null
|
||||
cachedSettingsMtime = null
|
||||
cacheTimestamp = 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user