Files
aspekter/z51-pug-builder/vite.config.js
s.zotov 718821fdd6 Initial commit: ASPEKTER — визуальный конструктор email-рассылок
- z51-pug-builder: Svelte 5 SPA, визуальный редактор Pug-писем
- email-gen: Node.js рендерер Pug→HTML через email-templates + Juice
- email-gen-api: HTTP сервер рендеринга (порт 8787)
- coin-scout: сервис подбора монет из фидов
- Docker Compose для dev/prod
- Nginx конфиг с SSL для app.aspekter.ru
2026-04-13 11:36:39 +05:00

2381 lines
113 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineConfig } from 'vite'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { spawnSync } from 'child_process'
import { randomBytes, createHash, scryptSync, timingSafeEqual } from 'crypto'
import { svelte } from '@sveltejs/vite-plugin-svelte'
const apiPlugin = () => ({
name: 'z51-file-api',
configureServer(server) {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const dataDir = path.resolve(__dirname, 'data')
const emailGenRoot = path.resolve(__dirname, '..', 'email-gen')
const emailGenApiUrl = process.env.EMAIL_GEN_API_URL || ''
const configFile = path.resolve(dataDir, 'config.json')
const tokensFile = path.resolve(dataDir, 'google-tokens.json')
const getRedirectUri = (req) => {
if (process.env.APP_BASE_URL) return `${process.env.APP_BASE_URL.replace(/\/$/, '')}/oauth/callback`
const proto = req.headers['x-forwarded-proto'] || 'http'
return `${proto}://${req.headers.host}/oauth/callback`
}
const getValidAccessToken = async () => {
const tokens = readJson(tokensFile, {})
if (!tokens.refresh_token) return null
if (tokens.access_token && tokens.expiry_date && tokens.expiry_date > Date.now() + 60000) return tokens.access_token
const config = readJson(configFile, {})
if (!config.client_id || !config.client_secret) return null
try {
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: config.client_id, client_secret: config.client_secret, refresh_token: tokens.refresh_token, grant_type: 'refresh_token' }),
})
const data = await resp.json()
if (!data.access_token) return null
const updated = { ...tokens, access_token: data.access_token, expiry_date: Date.now() + (data.expires_in || 3600) * 1000 }
writeJson(tokensFile, updated)
return updated.access_token
} catch { return null }
}
const ensureDir = (dir) => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
}
const readJson = (file, fallback) => {
try {
if (!fs.existsSync(file)) return fallback
return JSON.parse(fs.readFileSync(file, 'utf-8'))
} catch {
return fallback
}
}
const writeJson = (file, data) => {
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8')
}
const sanitizeProjectSlug = (value) => String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
function applyNowrap(html) {
// Protect @{...} template expressions from nowrap processing
const templatePlaceholders = []
let protected_ = html.replace(/@\{[^}]*\}/g, (m) => {
templatePlaceholders.push(m)
return `\u200BTPL${templatePlaceholders.length - 1}\u200B`
})
function wrapShort(text) {
return text.replace(
/(?<![a-zA-Zа-яА-ЯёЁ])([a-zA-Zа-яА-ЯёЁ]{1,3})(?:\s|&nbsp;)+(\S+)/gu,
'\u200Bspan\u200Bnwr\u200B$1\u00A0$2\u200B/span\u200Bnwr\u200B'
)
}
const result = protected_.replace(
/(<span[^>]*class="[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi,
(_match, open, content, close) => {
const processed = content.replace(/>([^<]+)</g, (m, t) => '>' + wrapShort(t) + '<')
const firstText = processed.replace(/^([^<]+)/, (m) => wrapShort(m))
return open + firstText + close
}
)
return result
.replace(/\u200Bspan\u200Bnwr\u200B/g, '<span style="white-space:nowrap">')
.replace(/\u200B\/span\u200Bnwr\u200B/g, '</span>')
.replace(/\u200BTPL(\d+)\u200B/g, (_, i) => templatePlaceholders[parseInt(i)])
}
const getProjectDir = (name) => {
const dir = path.resolve(dataDir, name)
if (!dir.startsWith(dataDir + path.sep)) return null
return dir
}
const MAX_BODY_SIZE = 30 * 1024 * 1024
const readBody = (req) =>
new Promise((resolve) => {
let size = 0
let data = ''
req.on('data', (chunk) => {
size += chunk.length
if (size <= MAX_BODY_SIZE) data += chunk
})
req.on('end', () => {
if (size > MAX_BODY_SIZE) return resolve({})
try {
resolve(data ? JSON.parse(data) : {})
} catch {
resolve({})
}
})
})
// --- Image uploads ---
const uploadsDir = path.resolve(dataDir, 'uploads')
// Feed cache (persists across requests)
const feedCache = new Map()
const feedPending = new Map()
const FEED_CACHE_TTL = 3 * 60 * 60 * 1000
// Site availability cache: url → { ts, available } TTL 3h
const availabilityCache = new Map()
async function checkSiteAvailability(url) {
if (!url) return true
const cached = availabilityCache.get(url)
if (cached && Date.now() - cached.ts < FEED_CACHE_TTL) return cached.available
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(5000), headers: { 'User-Agent': 'ASPEKTER/1.0' } })
const html = await resp.text()
// Schema.org is unreliable on z51.ru — check actual button text instead
const available = !html.includes('>Ожидаем<') && !html.includes('schema.org/OutOfStock')
availabilityCache.set(url, { ts: Date.now(), available })
return available
} catch { return true } // on error assume available
}
// Pug render cache by hash (LRU, max 30 entries)
const renderCache = new Map()
const RENDER_CACHE_MAX = 30
function pugHash(slug, pug) {
return createHash('md5').update(slug + '\0' + pug).digest('hex')
}
function setRenderCache(key, html) {
if (renderCache.size >= RENDER_CACHE_MAX) {
const oldest = renderCache.keys().next().value
renderCache.delete(oldest)
}
renderCache.set(key, html)
}
// --- Auth system ---
const systemDir = path.resolve(dataDir, '_system')
ensureDir(systemDir)
const usersFile = path.resolve(systemDir, 'users.json')
const sessionsStore = new Map() // token -> { userId, expiresAt }
const SESSION_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days
// Периодическая очистка просроченных сессий (каждые 30 мин)
setInterval(() => {
const now = Date.now()
for (const [token, session] of sessionsStore) {
if (session.expiresAt < now) sessionsStore.delete(token)
}
}, 30 * 60 * 1000)
function hashPassword(password) {
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64).toString('hex')
return `${salt}:${hash}`
}
function verifyPassword(password, stored) {
const [salt, hash] = stored.split(':')
if (!salt || !hash) return false
const test = scryptSync(password, salt, 64).toString('hex')
try { return timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(test, 'hex')) } catch { return false }
}
function getUsers() { return readJson(usersFile, []) }
function saveUsers(users) { writeJson(usersFile, users) }
function createSession(userId) {
const token = randomBytes(32).toString('hex')
sessionsStore.set(token, { userId, expiresAt: Date.now() + SESSION_TTL })
return token
}
function getSession(token) {
const s = sessionsStore.get(token)
if (!s) return null
if (s.expiresAt < Date.now()) { sessionsStore.delete(token); return null }
return s
}
function getTokenFromReq(req) {
const cookie = req.headers.cookie || ''
const m = cookie.match(/(?:^|;\s*)z51_token=([^;]+)/)
return m ? m[1] : null
}
function setTokenCookie(res, token) {
res.setHeader('Set-Cookie', `z51_token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 3600}`)
}
function clearTokenCookie(res) {
res.setHeader('Set-Cookie', 'z51_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0')
}
// --- Audit logging ---
const auditLogsDir = path.resolve(systemDir, 'logs')
ensureDir(auditLogsDir)
function auditLog(req, action, details = {}) {
try {
const now = new Date()
const file = path.resolve(auditLogsDir, `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}.jsonl`)
const entry = {
ts: now.getTime(),
userId: req.user?.id || details._userId || null,
login: req.user?.login || details._login || null,
action,
project: details.project || null,
details: { ...details },
ip: (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket?.remoteAddress || '',
}
delete entry.details._userId
delete entry.details._login
delete entry.details.project
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
} catch (_) {}
}
// Cleanup old logs (>6 months) on startup
try {
const cutoff = new Date()
cutoff.setMonth(cutoff.getMonth() - 6)
const cutoffStr = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}`
for (const f of fs.readdirSync(auditLogsDir).filter(f => f.endsWith('.jsonl'))) {
if (f.replace('.jsonl', '') < cutoffStr) fs.unlinkSync(path.resolve(auditLogsDir, f))
}
} catch (_) {}
// Seed admin if no users exist
if (getUsers().length === 0) {
saveUsers([{
id: randomBytes(8).toString('hex'),
login: 'admin',
passwordHash: hashPassword('admin'),
name: 'Администратор',
role: 'admin',
projects: ['*'],
}])
}
// Auth endpoints (before auth middleware)
server.middlewares.use(async (req, res, next) => {
if (req.url === '/api/auth/login' && req.method === 'POST') {
const body = await readBody(req)
const { login, password } = body
if (!login || !password) return send(400, { error: 'missing_credentials' })
const users = getUsers()
const user = users.find(u => u.login === login)
if (!user || !verifyPassword(password, user.passwordHash)) {
auditLog(req, 'login_failed', { _login: login })
return send(401, { error: 'invalid_credentials' })
}
const token = createSession(user.id)
setTokenCookie(res, token)
auditLog(req, 'login', { _userId: user.id, _login: user.login })
return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects } })
}
if (req.url === '/api/auth/logout' && req.method === 'POST') {
const token = getTokenFromReq(req)
if (token) {
const session = getSession(token)
if (session) {
const user = getUsers().find(u => u.id === session.userId)
if (user) auditLog(req, 'logout', { _userId: user.id, _login: user.login })
}
sessionsStore.delete(token)
}
clearTokenCookie(res)
return send(200, { ok: true })
}
if (req.url === '/api/auth/me' && req.method === 'GET') {
const token = getTokenFromReq(req)
const session = token ? getSession(token) : null
if (!session) return send(401, { error: 'not_authenticated' })
const user = getUsers().find(u => u.id === session.userId)
if (!user) return send(401, { error: 'user_not_found' })
return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects, theme: user.theme || 'light' } })
}
if (req.url === '/api/auth/preferences' && req.method === 'PUT') {
const token = getTokenFromReq(req)
const session = token ? getSession(token) : null
if (!session) return send(401, { error: 'not_authenticated' })
const body = await readBody(req)
const users = getUsers()
const idx = users.findIndex(u => u.id === session.userId)
if (idx === -1) return send(404, { error: 'user_not_found' })
if (body.theme === 'dark' || body.theme === 'light') users[idx].theme = body.theme
saveUsers(users)
return send(200, { ok: true })
}
return next()
function send(status, payload) {
res.statusCode = status
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(payload))
}
})
// Auth middleware — protect all /api/ routes (except auth endpoints)
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/api/') || req.url.startsWith('/api/auth/')) return next()
const token = getTokenFromReq(req)
const session = token ? getSession(token) : null
if (!session) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
return res.end(JSON.stringify({ error: 'not_authenticated' }))
}
const user = getUsers().find(u => u.id === session.userId)
if (!user) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
return res.end(JSON.stringify({ error: 'user_not_found' }))
}
req.user = user
return next()
})
// Admin endpoints — user management
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/api/admin/')) return next()
if (req.user?.role !== 'admin') {
res.statusCode = 403
res.setHeader('Content-Type', 'application/json')
return res.end(JSON.stringify({ error: 'forbidden' }))
}
const send = (status, payload) => {
res.statusCode = status
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(payload))
}
if (req.url === '/api/admin/users' && req.method === 'GET') {
const users = getUsers().map(u => ({ id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects }))
return send(200, { users })
}
if (req.url === '/api/admin/users' && req.method === 'POST') {
const body = await readBody(req)
const { login, password, name, role, projects } = body
if (!login || !password) return send(400, { error: 'missing_fields' })
const users = getUsers()
if (users.some(u => u.login === login)) return send(409, { error: 'login_exists' })
const newUser = {
id: randomBytes(8).toString('hex'),
login,
passwordHash: hashPassword(password),
name: name || login,
role: role || 'user',
projects: projects || [],
}
users.push(newUser)
saveUsers(users)
return send(200, { user: { id: newUser.id, login: newUser.login, name: newUser.name, role: newUser.role, projects: newUser.projects } })
}
const userMatch = req.url.match(/^\/api\/admin\/users\/([^/]+)$/)
if (userMatch) {
const userId = decodeURIComponent(userMatch[1])
const users = getUsers()
const idx = users.findIndex(u => u.id === userId)
if (idx === -1) return send(404, { error: 'user_not_found' })
if (req.method === 'PUT') {
const body = await readBody(req)
if (body.name !== undefined) users[idx].name = body.name
if (body.role !== undefined) users[idx].role = body.role
if (body.projects !== undefined) users[idx].projects = body.projects
if (body.password) users[idx].passwordHash = hashPassword(body.password)
if (body.login && body.login !== users[idx].login) {
if (users.some((u, i) => i !== idx && u.login === body.login)) return send(409, { error: 'login_exists' })
users[idx].login = body.login
}
saveUsers(users)
const u = users[idx]
return send(200, { user: { id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects } })
}
if (req.method === 'DELETE') {
if (users[idx].id === req.user.id) return send(400, { error: 'cannot_delete_self' })
users.splice(idx, 1)
saveUsers(users)
return send(200, { ok: true })
}
}
// --- Audit logs ---
if (req.url.startsWith('/api/admin/logs') && req.method === 'GET') {
const params = new URLSearchParams(req.url.split('?')[1] || '')
const filterUser = params.get('user') || ''
const filterProject = params.get('project') || ''
const filterAction = params.get('action') || ''
const from = parseInt(params.get('from')) || 0
const to = parseInt(params.get('to')) || Date.now()
const page = Math.max(1, parseInt(params.get('page')) || 1)
const limit = Math.min(200, Math.max(10, parseInt(params.get('limit')) || 50))
// Determine which JSONL files to read
const fromDate = new Date(from || Date.now() - 30 * 24 * 60 * 60 * 1000)
const toDate = new Date(to)
const logFiles = []
try {
for (const f of fs.readdirSync(auditLogsDir).filter(f => f.endsWith('.jsonl')).sort()) {
const ym = f.replace('.jsonl', '')
const fileMonth = new Date(ym + '-01')
const fileEnd = new Date(fileMonth)
fileEnd.setMonth(fileEnd.getMonth() + 1)
if (fileEnd >= fromDate && fileMonth <= toDate) logFiles.push(f)
}
} catch (_) {}
const entries = []
for (const f of logFiles) {
try {
const lines = fs.readFileSync(path.resolve(auditLogsDir, f), 'utf8').split('\n').filter(Boolean)
for (const line of lines) {
try {
const entry = JSON.parse(line)
if (entry.ts < (from || 0) || entry.ts > to) continue
if (filterUser && entry.login !== filterUser) continue
if (filterProject && entry.project !== filterProject) continue
if (filterAction && entry.action !== filterAction) continue
entries.push(entry)
} catch (_) {}
}
} catch (_) {}
}
entries.sort((a, b) => b.ts - a.ts) // newest first
const total = entries.length
const offset = (page - 1) * limit
const paged = entries.slice(offset, offset + limit)
return send(200, { entries: paged, total, page, limit })
}
return send(404, { error: 'not_found' })
})
server.middlewares.use(async (req, res, next) => {
// Serve uploaded images at /uploads/...
if (req.url?.startsWith('/uploads/')) {
const filePath = path.resolve(uploadsDir, decodeURIComponent(req.url.replace('/uploads/', '')))
if (!filePath.startsWith(uploadsDir)) { res.statusCode = 403; return res.end('Forbidden') }
if (!fs.existsSync(filePath)) { res.statusCode = 404; return res.end('Not found') }
const ext = path.extname(filePath).toLowerCase()
const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' }
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
return fs.createReadStream(filePath).pipe(res)
}
// Serve local project images at /images/...
if (req.url?.startsWith('/images/')) {
const imagesBase = path.resolve(dataDir, 'images')
const filePath = path.resolve(imagesBase, decodeURIComponent(req.url.replace('/images/', '')))
if (!filePath.startsWith(imagesBase)) { res.statusCode = 403; return res.end('Forbidden') }
if (!fs.existsSync(filePath)) { res.statusCode = 404; return res.end('Not found') }
const ext = path.extname(filePath).toLowerCase()
const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' }
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
return fs.createReadStream(filePath).pipe(res)
}
return next()
})
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/oauth/callback')) return next()
const params = new URLSearchParams(req.url.replace('/oauth/callback?', '').replace('/oauth/callback', ''))
const code = params.get('code'), error = params.get('error')
const closeHtml = (result) =>
`<html><body><script>localStorage.setItem('z51-oauth-result',JSON.stringify(${JSON.stringify(result)}));window.close();</script></body></html>`
if (error || !code) {
res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8')
return res.end(closeHtml({ ok: false, error: error || 'no_code' }))
}
const config = readJson(configFile, {})
try {
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ code, client_id: config.client_id, client_secret: config.client_secret, redirect_uri: getRedirectUri(req), grant_type: 'authorization_code' }),
})
const tokens = await resp.json()
writeJson(tokensFile, {
access_token: tokens.access_token || '',
refresh_token: tokens.refresh_token || readJson(tokensFile, {}).refresh_token || '',
expiry_date: Date.now() + (tokens.expires_in || 3600) * 1000,
})
res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(closeHtml({ ok: true }))
} catch {
res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(closeHtml({ ok: false, error: 'token_exchange_failed' }))
}
})
server.middlewares.use(async (req, res, next) => {
if (!req.url.startsWith('/api/')) return next()
ensureDir(dataDir)
const send = (status, payload) => {
// Auto audit-log all successful mutations
if (status >= 200 && status < 300 && req.method !== 'GET') {
const url = req.url.split('?')[0]
const projMatch = url.match(/^\/api\/project\/([^/]+)\/(.+)$/)
const action = projMatch ? projMatch[2].replace(/\//g, '_') : url.replace(/^\/api\//, '').replace(/\//g, '_')
const project = projMatch ? decodeURIComponent(projMatch[1]) : null
auditLog(req, action, { project })
}
res.statusCode = status
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(payload))
}
const userCanAccessProject = (projectName) => {
const u = req.user
if (!u) return false
if (u.role === 'admin' || (u.projects && u.projects.includes('*'))) return true
return u.projects && u.projects.includes(projectName)
}
if (req.method === 'GET' && req.url === '/api/projects') {
const HIDDEN_DIRS = new Set(['uploads', 'images', 'node_modules', '.git', '_system', 'drafts'])
const projects = fs
.readdirSync(dataDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith('.') && !HIDDEN_DIRS.has(d.name))
.filter((d) => userCanAccessProject(d.name))
.map((d) => d.name)
return send(200, { projects })
}
if (req.url === '/api/last-project') {
const lastFile = path.resolve(dataDir, 'lastProject.json')
if (req.method === 'GET') {
const last = readJson(lastFile, { name: '' })
return send(200, { name: last.name || '' })
}
if (req.method === 'PUT') {
const body = await readBody(req)
const name = String(body.name || '').trim()
writeJson(lastFile, { name })
return send(200, { ok: true })
}
}
if (req.method === 'POST' && req.url === '/api/projects') {
const body = await readBody(req)
const name = String(body.name || '').trim()
if (!name) return send(400, { error: 'missing_name' })
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
ensureDir(dir)
const defaultBlock = path.resolve(__dirname, 'public', 'Block.pug')
const blockText = fs.existsSync(defaultBlock) ? fs.readFileSync(defaultBlock, 'utf-8') : ''
fs.writeFileSync(path.resolve(dir, 'block.pug'), blockText, 'utf-8')
writeJson(path.resolve(dir, 'settings.json'), { globalSpacing: 40, blocks: {} })
writeJson(path.resolve(dir, 'draft.json'), [])
writeJson(path.resolve(dir, 'presets.json'), [])
writeJson(path.resolve(dir, 'letters.json'), { list: [], currentId: '' })
ensureDir(path.resolve(dir, 'letters'))
writeJson(path.resolve(dir, 'notes.json'), { list: [], currentId: '' })
ensureDir(path.resolve(dir, 'notes'))
writeJson(path.resolve(dir, 'meta.json'), { sourceName: 'Block.pug' })
return send(200, { ok: true })
}
// Project access gate
const projectAccessMatch = req.url.match(/^\/api\/project\/([^/]+)/)
if (projectAccessMatch) {
const projName = decodeURIComponent(projectAccessMatch[1])
if (!userCanAccessProject(projName)) return send(403, { error: 'no_project_access' })
}
// Letters directory (shared across all users)
const getUserLettersDir = (projectDir) => {
const d = path.resolve(projectDir, 'letters')
ensureDir(d)
return d
}
const getUserLettersFile = (projectDir) => {
return path.resolve(projectDir, 'letters.json')
}
const lettersMatch = req.url.match(/^\/api\/project\/([^/]+)\/letters$/)
if (lettersMatch) {
const name = decodeURIComponent(lettersMatch[1])
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
ensureDir(dir)
const lettersFile = getUserLettersFile(dir)
if (req.method === 'GET') {
const letters = readJson(lettersFile, { list: [], currentId: '' })
return send(200, letters)
}
if (req.method === 'PUT') {
const body = await readBody(req)
const next = {
list: Array.isArray(body.list) ? body.list : [],
currentId: typeof body.currentId === 'string' ? body.currentId : '',
}
writeJson(lettersFile, next)
return send(200, { ok: true })
}
}
const historyMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter\/([^/]+)\/history$/)
if (historyMatch) {
const name = decodeURIComponent(historyMatch[1])
const id = decodeURIComponent(historyMatch[2])
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
const lettersDir = getUserLettersDir(dir)
const histFile = path.resolve(lettersDir, `${id}.history.json`)
if (req.method === 'GET') {
return send(200, { history: readJson(histFile, []) })
}
if (req.method === 'PUT') {
const body = await readBody(req)
const snapshot = body?.snapshot
if (!snapshot) return send(400, { error: 'missing_snapshot' })
const history = readJson(histFile, [])
history.unshift(snapshot)
if (history.length > 20) history.splice(20)
writeJson(histFile, history)
return send(200, { ok: true })
}
}
const letterMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter(?:\/([^/]+))?$/)
if (letterMatch) {
const name = decodeURIComponent(letterMatch[1])
const id = letterMatch[2] ? decodeURIComponent(letterMatch[2]) : ''
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
const lettersDir = getUserLettersDir(dir)
if (req.method === 'GET' && id) {
const file = path.resolve(lettersDir, `${id}.json`)
const letter = readJson(file, null)
return send(200, { letter })
}
if (req.method === 'DELETE' && id) {
const file = path.resolve(lettersDir, `${id}.json`)
if (fs.existsSync(file)) fs.unlinkSync(file)
return send(200, { ok: true })
}
if (req.method === 'PUT') {
const body = await readBody(req)
const letterId = String(body.id || '').trim()
if (!letterId) return send(400, { error: 'missing_id' })
const file = path.resolve(lettersDir, `${letterId}.json`)
const existing = readJson(file, null)
const userLogin = req.user?.login || 'unknown'
if (!existing) {
body.createdBy = userLogin
} else if (!body.createdBy) {
body.createdBy = existing.createdBy || userLogin
}
body.updatedBy = userLogin
writeJson(file, body || {})
// Update letters index with createdBy/updatedBy
try {
const lettersIndex = readJson(path.resolve(lettersDir, '..', 'letters.json'), { list: [] })
const li = (lettersIndex.list || []).find(l => l.id === letterId)
if (li) {
li.createdBy = body.createdBy
li.updatedBy = body.updatedBy
writeJson(path.resolve(lettersDir, '..', 'letters.json'), lettersIndex)
}
} catch (_) {}
return send(200, { ok: true })
}
}
const notesMatch = req.url.match(/^\/api\/project\/([^/]+)\/notes$/)
if (notesMatch) {
const name = decodeURIComponent(notesMatch[1])
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
ensureDir(dir)
const notesFile = path.resolve(dir, 'notes.json')
if (req.method === 'GET') {
const notes = readJson(notesFile, { list: [], currentId: '' })
return send(200, notes)
}
if (req.method === 'PUT') {
const body = await readBody(req)
const next = {
list: Array.isArray(body.list) ? body.list : [],
currentId: typeof body.currentId === 'string' ? body.currentId : '',
}
writeJson(notesFile, next)
return send(200, { ok: true })
}
}
const noteMatch = req.url.match(/^\/api\/project\/([^/]+)\/note(?:\/([^/]+))?$/)
if (noteMatch) {
const name = decodeURIComponent(noteMatch[1])
const id = noteMatch[2] ? decodeURIComponent(noteMatch[2]) : ''
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
const notesDir = path.resolve(dir, 'notes')
ensureDir(notesDir)
if (req.method === 'GET' && id) {
const file = path.resolve(notesDir, `${id}.json`)
const note = readJson(file, null)
return send(200, { note })
}
if (req.method === 'DELETE' && id) {
const file = path.resolve(notesDir, `${id}.json`)
if (fs.existsSync(file)) fs.unlinkSync(file)
return send(200, { ok: true })
}
if (req.method === 'PUT') {
const body = await readBody(req)
const noteId = String(body.id || '').trim()
if (!noteId) return send(400, { error: 'missing_id' })
const file = path.resolve(notesDir, `${noteId}.json`)
writeJson(file, body || {})
return send(200, { ok: true })
}
}
const renderMatch = req.url.match(/^\/api\/project\/([^/]+)\/render-email$/)
if (renderMatch && req.method === 'POST') {
const projectName = decodeURIComponent(renderMatch[1])
const body = await readBody(req)
const slug = sanitizeProjectSlug(body.projectSlug)
const pug = String(body.pug || '')
if (!slug) return send(400, { error: 'missing_project_slug', details: 'Укажи папку проекта в email-gen' })
if (!pug.trim()) return send(400, { error: 'missing_pug', details: 'PUG пустой, нечего генерировать' })
// Start feed loading in parallel with render (await before Mindbox processing)
const projDir = getProjectDir(projectName)
const projSettings = projDir ? readJson(path.resolve(projDir, 'settings.json'), {}) : {}
const feedUrl = projSettings.feedUrl || ''
const feedPromise = feedUrl ? getFeedProducts(feedUrl).catch(() => null) : null
// Check render cache by Pug hash
const hash = pugHash(slug, pug)
if (body.noCache) console.log('[render] noCache=true, skipping cache')
let rawHtml = body.noCache ? null : renderCache.get(hash)
if (!rawHtml) {
if (emailGenApiUrl) {
try {
const forward = await fetch(`${emailGenApiUrl.replace(/\/$/, '')}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectSlug: slug, pug }),
signal: AbortSignal.timeout(45000),
})
const payload = await forward.json().catch(() => ({}))
if (!forward.ok) {
return send(forward.status, {
error: payload.error || 'render_failed',
details: payload.details || 'Не удалось получить ответ от email-gen api',
})
}
rawHtml = payload.html || ''
} catch (error) {
return send(500, {
error: 'email_gen_api_unreachable',
details: error?.message || 'email-gen api недоступен',
})
}
} else {
// Local render fallback
if (!fs.existsSync(emailGenRoot)) return send(500, { error: 'email_gen_not_found', details: 'Папка email-gen не найдена рядом с проектом' })
const emailProjectDir = path.resolve(emailGenRoot, 'emails', slug)
if (!emailProjectDir.startsWith(path.resolve(emailGenRoot, 'emails'))) {
return send(400, { error: 'invalid_project_slug', details: 'Некорректное имя проекта email-gen' })
}
if (!fs.existsSync(emailProjectDir)) {
return send(404, { error: 'email_project_not_found', details: `Проект "${slug}" не найден в email-gen/emails` })
}
const lettersDir = path.resolve(emailProjectDir, 'letters')
ensureDir(lettersDir)
fs.writeFileSync(path.resolve(lettersDir, 'let.pug'), pug, 'utf-8')
const previewTemplateName = '__builder_preview__.pug'
const previewTemplatePath = path.resolve(emailProjectDir, previewTemplateName)
fs.writeFileSync(
previewTemplatePath,
['extends ./html.pug', '', 'block content', ' include ./letters/let.pug', ''].join('\n'),
'utf-8'
)
const renderScript = `
const path = require('path');
const fs = require('fs');
const Email = require('email-templates');
async function run() {
const project = process.argv[1];
const root = process.argv[2];
const email = new Email();
const html = await email.render({
path: project + '/__builder_preview__',
juiceResources: {
preserveImportant: true,
applyStyleTags: true,
removeStyleTags: true,
preserveMediaQueries: true,
webResources: {
relativeTo: path.resolve(root, 'emails', project)
}
},
}, { pretty: true });
const outDir = path.resolve(root, 'public');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const outPath = path.resolve(outDir, 'index.html');
fs.writeFileSync(outPath, String(html).replace('#MAILRU_PREHEADER_TAG#', '<vk-snippet-end/>'), 'utf-8');
}
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
});
`
const run = spawnSync(process.execPath, ['-e', renderScript, slug, emailGenRoot], {
cwd: emailGenRoot,
encoding: 'utf-8',
shell: false,
timeout: 120000,
})
if (fs.existsSync(previewTemplatePath)) fs.unlinkSync(previewTemplatePath)
if (run.error || run.status !== 0) {
return send(500, {
error: 'render_failed',
details: (run.stderr || run.stdout || run.error?.message || 'Ошибка генерации').trim(),
})
}
const previewFile = path.resolve(emailGenRoot, 'public', 'index.html')
if (!fs.existsSync(previewFile)) {
return send(500, { error: 'preview_not_found', details: 'email-gen не создал public/index.html' })
}
rawHtml = fs.readFileSync(previewFile, 'utf-8')
}
// Cache the rendered HTML
setRenderCache(hash, rawHtml)
} // end of if (!rawHtml) — cache miss
// Ensure feed is loaded before Mindbox processing
if (feedPromise) await feedPromise
// Process Mindbox tags server-side
const mindbox = await processMindboxTags(rawHtml, feedUrl)
// Process RetailCRM tags if configured
const retailcrmCfg = projSettings.retailcrmConfig
const previewHtml = retailcrmCfg?.url
? await processRetailCrmTags(mindbox.html, retailcrmCfg)
: mindbox.html
const finalHtml = applyNowrap(previewHtml)
const finalRawHtml = applyNowrap(rawHtml)
return send(200, {
html: finalRawHtml,
previewHtml: finalHtml,
unavailableProducts: mindbox.unavailable,
generatedAt: new Date().toISOString(),
})
}
if (req.url === '/api/config') {
if (req.method === 'GET') {
const c = readJson(configFile, {})
return send(200, {
client_id: c.client_id || '', hasSecret: Boolean(c.client_secret),
upload_base_url: c.upload_base_url || '',
})
}
if (req.method === 'PUT') {
const body = await readBody(req)
const existing = readJson(configFile, {})
writeJson(configFile, {
client_id: String(body.client_id ?? existing.client_id ?? '').trim(),
client_secret: String(body.client_secret || existing.client_secret || '').trim(),
upload_base_url: String(body.upload_base_url ?? existing.upload_base_url ?? '').trim(),
})
return send(200, { ok: true })
}
}
if (req.method === 'POST' && req.url === '/api/upload-image') {
const body = await readBody(req)
if (!body.imageData) return send(400, { error: 'Нет данных изображения' })
const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/)
if (!dataMatch) return send(400, { error: 'Неверный формат изображения' })
const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
const ext = TYPES[dataMatch[1]]
if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` })
const buffer = Buffer.from(dataMatch[2], 'base64')
if (buffer.length > 20 * 1024 * 1024) return send(400, { error: 'Файл слишком большой (макс. 20 МБ)' })
const slug = (body.projectName || 'default').replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_')
const key = `${slug}/${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`
const filePath = path.resolve(uploadsDir, key)
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, buffer)
const config = readJson(configFile, {})
const baseUrl = config.upload_base_url ? config.upload_base_url.replace(/\/$/, '') : ''
const url = baseUrl ? `${baseUrl}/uploads/${key}` : `/uploads/${key}`
return send(200, { url })
} catch (e) { return send(500, { error: e?.message || 'Ошибка сохранения файла' }) }
}
// --- Link checking endpoint ---
if (req.method === 'POST' && req.url === '/api/check-links') {
const body = await readBody(req)
const urls = Array.isArray(body.urls) ? body.urls.slice(0, 50) : []
if (!urls.length) return send(400, { error: 'no_urls' })
const results = await Promise.allSettled(
urls.map(async (url) => {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
const res = await fetch(url, {
method: 'HEAD', redirect: 'follow', signal: controller.signal,
headers: { 'User-Agent': 'ASPEKTER-LinkChecker/1.0' },
})
clearTimeout(timeout)
return { url, status: res.status, ok: res.ok, redirected: res.redirected, finalUrl: res.url }
} catch (e) {
return { url, status: 0, ok: false, error: e?.message || 'Ошибка соединения' }
}
})
)
return send(200, { results: results.map(r => r.status === 'fulfilled' ? r.value : { url: '', status: 0, ok: false, error: 'error' }) })
}
// --- Product feed endpoint (cached, lookup by IDs) ---
function getFeedProducts(feedUrl) {
const cached = feedCache.get(feedUrl)
if (cached && Date.now() - cached.ts < FEED_CACHE_TTL) return Promise.resolve(cached.products)
// Deduplicate in-flight requests, but expire stale pending after 2 min
if (feedPending.has(feedUrl)) {
const p = feedPending.get(feedUrl)
if (Date.now() - p._startedAt < 120000) return p
feedPending.delete(feedUrl)
}
const promise = _fetchFeedProducts(feedUrl)
.finally(() => feedPending.delete(feedUrl))
promise._startedAt = Date.now()
feedPending.set(feedUrl, promise)
return promise
}
async function _fetchFeedProducts(feedUrl) {
const resp = await fetch(feedUrl, {
signal: AbortSignal.timeout(90000),
headers: { 'User-Agent': 'ASPEKTER-FeedReader/1.0' },
})
if (!resp.ok) throw new Error(`Фид вернул ${resp.status}`)
// Handle windows-1251 encoding
const buf = await resp.arrayBuffer()
const contentType = resp.headers.get('content-type') || ''
let text
if (contentType.includes('1251') || contentType.includes('windows')) {
const decoder = new TextDecoder('windows-1251')
text = decoder.decode(buf)
} else {
// Try to detect from XML declaration
const preview = new TextDecoder('ascii').decode(buf.slice(0, 200))
const encMatch = preview.match(/encoding=["']([^"']+)["']/i)
const enc = encMatch ? encMatch[1] : 'utf-8'
try { text = new TextDecoder(enc).decode(buf) } catch { text = new TextDecoder('utf-8').decode(buf) }
}
const products = new Map()
const offerRegex = /<offer\s+id="([^"]*)"([^>]*)>([\s\S]*?)<\/offer>/gi
let m
while ((m = offerRegex.exec(text)) !== null) {
const id = m[1], attrs = m[2], block = m[3]
const availAttr = attrs.match(/available="([^"]*)"/i)
const available = availAttr ? availAttr[1].toLowerCase() === 'true' : true
const tag = (t) => { const x = block.match(new RegExp(`<${t}[^>]*>([\\s\\S]*?)<\\/${t}>`,'i')); return x ? x[1].trim() : '' }
const name = tag('name') || tag('title')
const seriesMatch = name.match(/[«""]([^»""]+)[»""]/)
products.set(id, {
id, available, name, price: tag('price'), oldPrice: tag('oldprice'),
image: tag('picture'), url: tag('url'), description: tag('description'),
categoryId: tag('categoryId'),
series: seriesMatch ? seriesMatch[1] : '',
denomination: tag('denomination'), year: tag('year'), dia: tag('dia'),
material: tag('material'), country: tag('country'), condition: tag('condition'),
weight: tag('Weight') || tag('weight'), assay: tag('assay'),
vendorCode: tag('vendorCode'), reverseImage: tag('reversePictureUrl'),
salePercent: tag('SalePercent') || tag('salepercent'),
})
}
feedCache.set(feedUrl, { ts: Date.now(), products })
return products
}
// --- Server-side Mindbox tag processing ---
function resolveProductProp(product, propPath) {
if (!product) return ''
const prop = propPath.toLowerCase()
if (prop === 'name') return product.name || ''
if (prop === 'url') return product.url || ''
if (prop === 'pictureurl' || prop === 'picture' || prop === 'imageurl') return product.image || ''
if (prop === 'price') return product.price || ''
if (prop === 'oldprice') return product.oldPrice || ''
if (prop === 'description') return product.description || ''
const cfMatch = propPath.match(/^(?:customfield|additionaldata)\.(\w+)$/i)
if (cfMatch) {
const cf = cfMatch[1].toLowerCase()
if (cf === 'denomination' || cf === 'nominal') return product.denomination || ''
if (cf === 'year' || cf === 'god') return product.year || ''
if (cf === 'dia' || cf === 'diameter' || cf === 'diametr') return product.dia || ''
if (cf === 'material') return product.material || ''
if (cf === 'country' || cf === 'strana') return product.country || ''
if (cf === 'condition' || cf === 'sohrannost' || cf === 'soxrannost') return product.condition || ''
if (cf === 'weight' || cf === 'ves') return product.weight || ''
if (cf === 'assay' || cf === 'proba') return product.assay || ''
if (cf === 'vendorcode' || cf === 'artikul') return product.vendorCode || ''
if (cf === 'reversepictureurl') return product.reverseImage || ''
if (cf === 'salepercent' || cf === 'sale') return product.salePercent || ''
return product[cfMatch[1]] || product[cf] || ''
}
return ''
}
async function processMindboxTags(html, feedUrl) {
const noResult = { html, unavailable: [] }
if (!feedUrl) return noResult
let work = html.replace(/&#39;/g, "'").replace(/&#x27;/g, "'")
// Remove @{for...}@{end for} blocks FIRST so recommendation IDs don't leak
work = work.replace(/@\{\s*for\s[^}]*\}[\s\S]*?@\{\s*end\s+for\s*\}/gi, '')
const idRegex = /GetByValue\(["'](\d+)["']\)/g
const ids = new Set()
let m
while ((m = idRegex.exec(work)) !== null) ids.add(m[1])
if (!ids.size) return noResult
let allProducts
try { allProducts = await getFeedProducts(feedUrl) } catch (e) { return noResult }
const products = {}
const unavailable = []
for (const id of ids) {
const p = allProducts.get(String(id))
if (p) {
products[id] = p
if (!p.available) unavailable.push({ id, name: p.name })
}
}
if (!Object.keys(products).length) return noResult
let result = work
result = result.replace(/@\{\s*if\s[^}]*\}/gi, '')
result = result.replace(/@\{\s*end\s+if\s*\}/gi, '')
result = result.replace(/\$\{\s*formatDecimal\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => {
const p = products[id]; if (!p) return ''
const val = resolveProductProp(p, propPath)
return val ? Number(val).toLocaleString('ru-RU') : ''
})
result = result.replace(/\$\{\s*ResizeImage\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => {
return resolveProductProp(products[id], propPath)
})
result = result.replace(/\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}/gi, (_, id, propPath) => {
return resolveProductProp(products[id], propPath)
})
result = result.replace(/-\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}%/gi, (_, id, propPath) => {
const val = resolveProductProp(products[id], propPath)
return val ? `-${val}%` : ''
})
result = result.replace(/\$\{\s*category\.\w+\s*\}/gi, '')
result = result.replace(/@\{[^}]*\}/g, '')
result = result.replace(/\$\{\s*[^}]*(?:Products\.|ResizeImage|formatDecimal)[^}]*\}/g, '')
// Add "Нет в наличии" overlay for unavailable products in preview
const badge = '<div style="position:absolute;top:4px;left:4px;background:#dc2626;color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;z-index:1;font-family:sans-serif;">Нет в наличии</div>'
for (const item of unavailable) {
const img = products[item.id]?.image
if (img) {
const imgEsc = img.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const imgRegex = new RegExp(`(<img[^>]*src=["']${imgEsc}["'][^>]*>)`, 'i')
result = result.replace(imgRegex, `<div style="position:relative;display:inline-block;">$1${badge}</div>`)
}
}
return { html: result, unavailable }
}
async function processRetailCrmTags(html, cfg) {
if (!cfg?.url || !cfg?.apiKey) return html
let work = html.replace(/&#39;/g, "'").replace(/&#x27;/g, "'")
// Extract externalIds from entity_by_id('Product', 'ID', ...)
const idRegex = /entity_by_id\(['"]Product['"],\s*['"](\d+)['"]/g
const ids = new Set()
let m
while ((m = idRegex.exec(work)) !== null) ids.add(m[1])
if (!ids.size) {
return work.replace(/\{%[^%]*%\}/g, '').replace(/\{\{[^}]*\}\}/g, '')
}
let products = {}
try { products = await fetchRetailCrmByIds(cfg, [...ids]) } catch (e) { /* ignore */ }
// Split work into segments by {% set productCrm = entity_by_id(...) %}
// Each segment belongs to one product context
const setTagRegex = /\{%\s*set\s+\w+\s*=\s*entity_by_id\(['"]Product['"],\s*['"](\d+)['"][^%]*%\}/gi
const segments = []
let lastIndex = 0, lastId = null, sm
while ((sm = setTagRegex.exec(work)) !== null) {
if (lastId !== null) segments.push({ id: lastId, text: work.slice(lastIndex, sm.index) })
else if (sm.index > 0) segments.push({ id: null, text: work.slice(0, sm.index) })
lastId = sm[1]
lastIndex = sm.index + sm[0].length
}
segments.push({ id: lastId, text: work.slice(lastIndex) })
const processSegment = (text, p) => {
// Remove {% for ... %} and {% endfor %} keeping inner content
text = text.replace(/\{%\s*for\s+[^%]*%\}/gi, '').replace(/\{%\s*endfor\s*%\}/gi, '')
if (!p) {
return text.replace(/\{%[^%]*%\}/g, '').replace(/\{\{[^}]*\}\}/g, '')
}
const price = p.price ? Number(p.price).toLocaleString('ru-RU') : ''
// Replace image URL
text = text.replace(/\{\{\s*productCrm\.imageUrl\s*\}\}/gi, p.image || '')
text = text.replace(/\{\{\s*productCrm\.mainImage\.url\s*\}\}/gi, p.image || '')
// Replace product URL
text = text.replace(/\{\{\s*productCrm\.url\s*\}\}/gi, p.url || '')
// Replace offer name
text = text.replace(/\{\{\s*offer\.name\s*\}\}/gi, p.name || '')
// Replace price
text = text.replace(/\{\{\s*offer\.offerPrice\([^)]*\)\.price\s*\}\}/gi, price)
text = text.replace(/\{\{\s*offer\.price\s*\}\}/gi, price)
// Remove remaining tags
text = text.replace(/\{%[^%]*%\}/g, '').replace(/\{\{[^}]*\}\}/g, '')
// Add "Нет в наличии" badge for unavailable products
if (!p.available && p.image) {
const badge = '<div style="position:absolute;top:4px;left:4px;background:#dc2626;color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;z-index:1;font-family:sans-serif;">Нет в наличии</div>'
const imgEsc = p.image.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
text = text.replace(new RegExp(`(<img[^>]*src=["']${imgEsc}["'][^>]*>)`, 'i'),
`<div style="position:relative;display:inline-block;">$1${badge}</div>`)
}
return text
}
return segments.map(({ id, text }) => processSegment(text, id ? products[id] : null)).join('')
}
// --- RetailCRM API helpers ---
function normalizeRetailCrmProduct(p) {
const offer = (p.offers || [])[0] || {}
return {
id: String(p.id),
name: p.name || '',
price: String(offer.price || p.minPrice || ''),
oldPrice: '',
image: p.imageUrl || (offer.images || [])[0] || '',
url: p.url || '',
description: p.description || '',
categoryId: String((p.groups || [])[0]?.externalId || ''),
series: '',
available: true, // will be overridden by site availability check
manufacturer: p.manufacturer || '',
}
}
async function fetchRetailCrmByIds(cfg, ids) {
const params = new URLSearchParams({ apiKey: cfg.apiKey, limit: 100 })
ids.forEach(id => params.append('filter[ids][]', id))
const resp = await fetch(`${cfg.url}/api/v5/store/products?${params}`, {
signal: AbortSignal.timeout(15000),
})
if (!resp.ok) throw new Error(`RetailCRM: ${resp.status}`)
const data = await resp.json()
if (!data.success) throw new Error(data.errorMsg || 'RetailCRM error')
const normalized = (data.products || []).map(normalizeRetailCrmProduct)
// Check site availability in parallel (cached 3h)
await Promise.all(normalized.map(async p => {
if (p.url) p.available = await checkSiteAvailability(p.url)
}))
const result = {}
for (const p of normalized) result[p.id] = p
return result
}
async function searchRetailCrm(cfg, query, url) {
const params = new URLSearchParams({ apiKey: cfg.apiKey, limit: 20 })
if (url) {
params.set('filter[url]', url)
} else if (query) {
params.set('filter[name]', query)
}
const resp = await fetch(`${cfg.url}/api/v5/store/products?${params}`, {
signal: AbortSignal.timeout(15000),
})
if (!resp.ok) throw new Error(`RetailCRM: ${resp.status}`)
const data = await resp.json()
if (!data.success) throw new Error(data.errorMsg || 'RetailCRM error')
// If URL search returned nothing, fall back to name search
if (url && !(data.products || []).length && query) {
return searchRetailCrm(cfg, query, null)
}
return (data.products || []).map(normalizeRetailCrmProduct)
}
// --- Feed refresh (clear cache + re-fetch, with diff) ---
const feedRefreshMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-refresh$/)
if (feedRefreshMatch && req.method === 'POST') {
const projectName = decodeURIComponent(feedRefreshMatch[1])
const projDir = getProjectDir(projectName)
if (!projDir) return send(400, { error: 'invalid_project_name' })
const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
const retailcrm = projSettings.retailcrmConfig
const feedUrl = projSettings.feedUrl
if (retailcrm?.url && retailcrm?.apiKey) {
// RetailCRM: just test connection
try {
const params = new URLSearchParams({ apiKey: retailcrm.apiKey, limit: 20 })
const resp = await fetch(`${retailcrm.url}/api/v5/store/products?${params}`, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
if (!data.success) return send(500, { error: data.errorMsg || 'RetailCRM error' })
return send(200, { count: data.pagination?.totalCount || 0, added: 0, removed: 0 })
} catch (e) { return send(500, { error: e?.message }) }
}
if (!feedUrl) return send(400, { error: 'Фид не настроен' })
// Remember old IDs before refresh
const oldCached = feedCache.get(feedUrl)
const oldIds = oldCached ? new Set(oldCached.products.keys()) : null
feedCache.delete(feedUrl)
feedPending.delete(feedUrl)
try {
const products = await getFeedProducts(feedUrl)
const newIds = new Set(products.keys())
const diff = { count: products.size }
if (oldIds) {
const added = [...newIds].filter(id => !oldIds.has(id))
const removed = [...oldIds].filter(id => !newIds.has(id))
diff.added = added.length
diff.removed = removed.length
diff.addedProducts = added.slice(0, 10).map(id => {
const p = products.get(id)
return { id, name: p?.name || '' }
})
}
return send(200, diff)
} catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) }
}
const feedLookupMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-lookup$/)
if (feedLookupMatch && req.method === 'POST') {
const projectName = decodeURIComponent(feedLookupMatch[1])
const projDir = getProjectDir(projectName)
if (!projDir) return send(400, { error: 'invalid_project_name' })
const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
const body = await readBody(req)
const ids = Array.isArray(body.ids) ? body.ids : []
const retailcrm = projSettings.retailcrmConfig
if (retailcrm?.url && retailcrm?.apiKey) {
try {
const result = await fetchRetailCrmByIds(retailcrm, ids)
return send(200, { products: result })
} catch (e) { return send(500, { error: e?.message }) }
}
const feedUrl = projSettings.feedUrl
if (!feedUrl) return send(400, { error: 'Фид не настроен' })
try {
const allProducts = await getFeedProducts(feedUrl)
const result = {}
for (const id of ids) {
const p = allProducts.get(String(id))
if (p) result[id] = p
}
return send(200, { products: result })
} catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) }
}
// --- Feed suggest replacements ---
const feedSuggestMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-suggest$/)
if (feedSuggestMatch && req.method === 'POST') {
const projectName = decodeURIComponent(feedSuggestMatch[1])
const projDir = getProjectDir(projectName)
if (!projDir) return send(400, { error: 'invalid_project_name' })
const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
const retailcrm = projSettings.retailcrmConfig
if (retailcrm?.url && retailcrm?.apiKey) {
const body = await readBody(req)
const search = String(body.search || body.productId || '').trim()
const productUrl = String(body.url || '').trim()
try {
const products = await searchRetailCrm(retailcrm, search, productUrl || null)
const excludeIds = new Set((Array.isArray(body.excludeIds) ? body.excludeIds : []).map(String))
const suggestions = products.filter(p => !excludeIds.has(p.id)).map(p => ({
id: p.id, name: p.name, price: p.price, oldPrice: p.oldPrice,
image: p.image, url: p.url, series: p.series, categoryId: p.categoryId,
}))
return send(200, { source: { id: search, name: search }, suggestions })
} catch (e) { return send(500, { error: e?.message }) }
}
const feedUrl = projSettings.feedUrl
if (!feedUrl) return send(400, { error: 'Фид не настроен' })
const body = await readBody(req)
const productId = String(body.productId || '')
const excludeIds = new Set((Array.isArray(body.excludeIds) ? body.excludeIds : []).map(String))
const search = String(body.search || '').toLowerCase().trim()
if (!productId) return send(400, { error: 'missing productId' })
try {
const allProducts = await getFeedProducts(feedUrl)
const source = allProducts.get(productId)
if (!source) return send(404, { error: 'Товар не найден в фиде' })
const candidates = []
for (const [id, p] of allProducts) {
if (id === productId || excludeIds.has(id) || !p.available) continue
// If search query provided, filter by name/id match
if (search && !(p.name || '').toLowerCase().includes(search) && !id.includes(search)) continue
let score = 0
if (!search) {
// Auto-suggest: score by category + series + name similarity
if (source.categoryId && p.categoryId === source.categoryId) score += 10
if (source.series && p.series) {
if (p.series === source.series) {
score += 30
} else {
// Partial series match: compare base series (before " — ")
const srcBase = source.series.split(/\s*—\s*/)[0].trim().toLowerCase()
const pBase = p.series.split(/\s*—\s*/)[0].trim().toLowerCase()
if (srcBase.length >= 3 && srcBase === pBase) score += 25
}
}
// Name word overlap: count significant shared words (4+ chars)
if (score <= 10) {
const srcWords = new Set((source.name || '').toLowerCase().split(/[\s,.()\-«»"]+/).filter(w => w.length >= 4))
const pWords = (p.name || '').toLowerCase().split(/[\s,.()\-«»"]+/).filter(w => w.length >= 4)
const overlap = pWords.filter(w => srcWords.has(w)).length
if (overlap >= 2) score += overlap * 3
}
if (score === 0) continue
}
candidates.push({ ...p, score })
}
candidates.sort((a, b) => b.score - a.score || (Number(b.price) || 0) - (Number(a.price) || 0))
const suggestions = candidates.slice(0, 20).map(p => ({
id: p.id, name: p.name, price: p.price, oldPrice: p.oldPrice,
image: p.image, url: p.url, series: p.series, categoryId: p.categoryId,
}))
return send(200, { source: { id: source.id, name: source.name }, suggestions })
} catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) }
}
// --- Auto-assemble product blocks ---
const autoAssembleMatch = req.url.match(/^\/api\/project\/([^/]+)\/auto-assemble$/)
if (autoAssembleMatch && req.method === 'POST') {
const projectName = decodeURIComponent(autoAssembleMatch[1])
const projDir = getProjectDir(projectName)
if (!projDir) return send(400, { error: 'invalid_project_name' })
try {
const body = await readBody(req)
const type = body.type || 'new' // 'new' | 'sale'
const layout = Array.isArray(body.layout) ? body.layout.filter(n => n >= 1 && n <= 3) : null
const target = layout ? layout.reduce((s, n) => s + n, 0) : Math.min(Math.max(parseInt(body.target) || 10, 3), 24)
const forceExcludeIds = new Set(Array.isArray(body.excludeIds) ? body.excludeIds.map(String) : [])
const anchorIds = Array.isArray(body.anchorIds) ? body.anchorIds.map(String) : null
const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
const mixinRules = projSettings.mixinRules || []
const productMixins = [...new Set(mixinRules.filter(r => r.type === 'mixin-ids').map(r => r.mixin))]
if (!productMixins.length) return send(400, { error: 'Нет миксинов товаров в настройках проекта' })
// Map slot count → mixin name
const getMixinSlots = (name) => {
if (/3/.test(name)) return 3
if (/2/.test(name)) return 2
return 1
}
const slotMap = {}
for (const m of productMixins) {
const s = getMixinSlots(m)
if (!slotMap[s]) slotMap[s] = m
}
const slotKeys = Object.keys(slotMap).map(Number)
const maxSlots = slotKeys.length > 0 ? Math.max(...slotKeys) : 3
const getMixinForCount = (count) => {
for (let c = count; c >= 1; c--) {
if (slotMap[c]) return slotMap[c]
}
return productMixins[0]
}
const feedUrl = projSettings.feedUrl
if (!feedUrl) return send(400, { error: 'feedUrl не настроен для проекта' })
const feedMap = await getFeedProducts(feedUrl)
if (!feedMap || feedMap.size === 0) return send(400, { error: 'Фид пустой или недоступен' })
const allProducts = Array.from(feedMap.values())
// Material detection (needed early for excludeMaterials filter)
const getMat = (p) => {
const s = ((p.material || '') + ' ' + (p.name || '')).toLowerCase()
if (/золот|gold|\bau\b/.test(s)) return 'gold'
if (/серебр|silver|\bag\b/.test(s)) return 'silver'
if (/платин|platinum|\bpt\b/.test(s)) return 'platinum'
if (/биметалл|bimetal/.test(s)) return 'bimetal'
if (/медь|медн|copper|бронза|bronze|латунь/.test(s)) return 'copper'
return 'base'
}
// Category sets for type filtering
const BANKNOTE_CATS = new Set(['8','31','32','33','34','35','36','37','38','59','60','61','84','85','86'])
const COIN_CATS = new Set(['1','18','19','20','22','23','24','25','26','27','97','98','99'])
const COPY_CATS = new Set(['7','28','77','78','79','80'])
// Accessories / non-collectibles — always excluded
const EXCLUDE_CATS = new Set([
'9','10','12','16','17', // Награды, Иконы, Аксессуары, Наборы, Значки
'39','40','41','42','43', // Награды (подкатегории)
'44','45','46','47','48','49','50','51', // Альбомы, Листы, Холдеры, Капсулы, Коробки, Литература
'54','55','56','57','58', // Открытки, прочее
'64','65','66','67','68','69','70','71','72','73','74','75','76', // Значки подкатегории
'81','82','83', // Копии наград
'53', // Знаки, значки
'103','108','109','110','111', // Акции, Марки
'128','137', // Дорожные чеки, Лотерейные билеты (AT)
'139','143','144','146','155', // Лотерейные (KB), Конверты, Открытки
])
// Determine product type by categoryId + name
const getProductType = (p) => {
const cid = String(p.categoryId || '')
if (BANKNOTE_CATS.has(cid)) return 'banknote'
if (COPY_CATS.has(cid)) return 'copy'
if (COIN_CATS.has(cid)) return 'coin'
// Fallback: check name for non-coins
const n = (p.name || '').toLowerCase()
if (/банкнот|купюр/.test(n)) return 'banknote'
if (/копия|реплика/.test(n)) return 'copy'
if (/почтов\S*\s*марк|stamp|конверт|открытк|лотерейн|акция\s+на\s|облигаци|плакет|нашивк|вексел/.test(n)) return 'other'
if (/билет\S*\s+(ммм|банк|лотер)/i.test(n)) return 'other'
if (/сертификат|дорожный чек|дорожн\S*\s*чек|чек\S*\s*банк|ваучер|талон|облигац|вексел|расписк|лотере[яи]|купон/.test(n)) return 'banknote'
if (/^знак\s|^значок|нашивк|^медаль\s|^жетон\s|^плакет/.test(n)) return 'other'
// Coins always have material/weight; paper doesn't
if (!(p.material || '').trim() && !(p.weight || '').trim()) return 'other'
return 'coin'
}
const priceMin = parseFloat(body.priceMin) || 0
const priceMax = parseFloat(body.priceMax) || 0
const productType = body.productType || 'coin'
const excludeMaterials = new Set(Array.isArray(body.excludeMaterials) ? body.excludeMaterials : [])
let candidates = allProducts.filter(p => {
if (!p.available || !p.id) return false
if (forceExcludeIds.has(String(p.id))) return false
if (EXCLUDE_CATS.has(String(p.categoryId || ''))) return false
if (productType !== 'any' && getProductType(p) !== productType) return false
const price = parseFloat(p.price) || 0
if (priceMin > 0 && price < priceMin) return false
if (priceMax > 0 && price > priceMax) return false
// Exclude precious metals if requested
if (excludeMaterials.size > 0) {
const mat = getMat(p)
if (excludeMaterials.has(mat)) return false
}
return true
})
if (type === 'sale') {
candidates = candidates.filter(p => parseFloat(p.salePercent) > 0)
candidates.sort((a, b) => parseFloat(b.salePercent) - parseFloat(a.salePercent))
} else {
// 'new': sort by numeric ID descending (higher ID = newer)
candidates.sort((a, b) => (parseInt(b.id) || 0) - (parseInt(a.id) || 0))
// Exclude items with permanent discounts (salePercent > 10%)
candidates = candidates.filter(p => {
const sp = parseFloat(p.salePercent)
return !sp || sp <= 10
})
}
// Limit to top candidates by primary criterion
if (!layout) {
const topN = Math.min(candidates.length, target * 10)
candidates = candidates.slice(0, topN)
}
// ── Helpers ──────────────────────────────────────────────────────────
// Uniqueness key: strip year + mint mark so "1 рейхспфенниг 1937 А" == "1 рейхспфенниг 1939 J"
const getProductKey = (p) => {
const rawName = (p.name || '').trim()
// Prefer explicit series from field
if (p.series) return p.series.toLowerCase().trim()
// Extract series from «...» or "..." quotes
const quotedMatch = rawName.match(/[«""]([^»""]{3,})[»""]/)
if (quotedMatch) return quotedMatch[1].toLowerCase().trim()
// Extract name after dash separator: "1 доллар 1879 года S США — Доллар Моргана"
const dashMatch = rawName.match(/[—–-]\s*([А-ЯЁA-Z][а-яёa-z А-ЯЁA-Z]{3,})$/)
if (dashMatch) return dashMatch[1].toLowerCase().trim()
// Fallback: strip year + mint marks
let name = rawName
.replace(/\b\d{4}\b/g, '')
.replace(/\b[A-ZА-ЯЁ]{1,4}\b/g, '')
.replace(/копия/gi, '')
.replace(/\s+/g, ' ').trim()
return name.slice(0, 25).toLowerCase()
}
// Era bucket based on year
const getEra = (year) => {
const y = parseInt(year)
if (!y || isNaN(y)) return 'unknown'
if (y < 1800) return 'ancient' // до 1800 — антика
if (y < 1900) return 'c19' // XIX век
if (y < 1941) return 'early20' // начало XX
if (y < 1946) return 'wwii' // ВОВ
if (y < 1992) return 'soviet' // СССР/соцлагерь
if (y < 2011) return 'post91' // 1992-2010
return 'modern' // 2011+
}
// Price bracket
const getPriceBucket = (price) => {
const p = parseFloat(price) || 0
if (p < 400) return 'cheap'
if (p < 2000) return 'mid'
if (p < 10000) return 'exp'
return 'premium'
}
// Diameter bucket: визуальное соответствие монет в ряду
const getDiaBucket = (dia) => {
const d = parseFloat(dia) || 0
if (d === 0) return 'unknown'
if (d < 25) return 'small' // мелкие: копейки, сены, пфенниги
if (d < 36) return 'medium' // средние: большинство оборотных монет
return 'large' // крупные: талеры, 1oz silver, памятные
}
// Подтип для островного серебра: animals vs popculture vs other
const getIslandSubtype = (p) => {
const s = ((p.series || '') + ' ' + (p.name || '')).toLowerCase()
if (/медвед|лев|волк|тигр|орёл|орел|черепах|сова|пеликан|акул|дельфин|конь|лошад|слон|пантер|пума|гиен|ягуар|бизон|кенгур|рыб|краб/.test(s)) return 'wildlife'
if (/dc comics|marvel|star wars|звёздные войны|звездные войны|looney|disney|форсаж|гарри поттер|властелин|бэтмен|флэш|супермен|человек-паук|iron man|transformers/.test(s)) return 'popculture'
if (/дракон|единорог|феникс|griffin|griffon|грифон|мифолог/.test(s)) return 'fantasy'
return 'other'
}
// Macro-region
const getRegion = (country) => {
const c = (country || '').toLowerCase()
if (/россия|рсфср/.test(c)) return 'russia'
if (/российская империя|русская финляндия/.test(c)) return 'rus_empire'
if (/ссср/.test(c)) return 'ussr'
if (/япония/.test(c)) return 'japan'
if (/китай/.test(c)) return 'china'
if (/германия|пруссия|австрия|швейцария|франция|италия|испания|великобритания|нидерланды|бельгия|швеция|дания|норвегия|польша|финляндия/.test(c)) return 'europe'
if (/сша|канада/.test(c)) return 'america_n'
if (/мексика|гватемала|сальвадор|бразилия|аргентина|чили|перу|колумбия|боливия|куба/.test(c)) return 'america_l'
if (/турция|османская|египет|иран|афганистан/.test(c)) return 'near_east'
if (/индия|пакистан/.test(c)) return 'south_asia'
if (/ниуэ|самоа|тувалу|токелау|барбадос|кука|палау|фиджи|кирибати|науру|тонга|вануату/.test(c)) return 'island_mint'
return 'other'
}
// Style key: coins with same key go in one block
const getStyleKey = (p) => {
const era = getEra(p.year)
const mat = getMat(p)
const bucket = getPriceBucket(p.price)
const region = getRegion(p.country)
const dia = getDiaBucket(p.dia)
// Серия = высший приоритет
if (p.series) return `series_${p.series.toLowerCase().trim()}_${bucket}`
// Россия/СССР/Империя серебро → группа по эпохе + цена + диаметр
if ((region === 'russia' || region === 'ussr' || region === 'rus_empire') && mat === 'silver') {
return `silver_${region}_${era}_${bucket}_${dia}`
}
// Островные монетные дворы: разбиваем по подтипу (wildlife / popculture / fantasy / other) + диаметр
if (region === 'island_mint' && mat === 'silver' && era === 'modern') {
const sub = getIslandSubtype(p)
return `island_silver_${sub}_${bucket}_${dia}`
}
// Канада/США современное коллекционное серебро (1oz) — тоже в подтип
if (region === 'america_n' && mat === 'silver' && era === 'modern' && dia === 'large') {
const sub = getIslandSubtype(p)
return `island_silver_${sub}_${bucket}_${dia}`
}
// Старые европейские дорогие монеты → по эпохе + диаметр
if (region === 'europe' && mat === 'silver' && (era === 'c19' || era === 'early20' || era === 'ancient') && (bucket === 'exp' || bucket === 'premium')) {
return `europe_old_silver_${era}_${bucket}_${dia}`
}
// Остальное: регион + эпоха + материал + цена + диаметр
return `${region}_${era}_${mat}_${bucket}_${dia}`
}
// ── Group candidates by style key ─────────────────────────────────────
const styleGroups = {}
for (const p of candidates) {
const key = getStyleKey(p)
if (!styleGroups[key]) styleGroups[key] = []
styleGroups[key].push(p)
}
// Sort style groups: largest first (most candidates = most likely to fill blocks)
const sortedGroups = Object.entries(styleGroups)
.sort((a, b) => b[1].length - a[1].length)
// Fisher-Yates shuffle helper
const fisherYates = (arr) => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
// ── Build blocks within each style group ─────────────────────────────
const allRawBlocks = []
for (const [styleKey, items] of sortedGroups) {
// Shuffle within each group → each regeneration picks different coins
// But if group is series-based, keep series-mates together (sort by series first)
let remaining
if (styleKey.startsWith('series_')) {
// Series group: sort by series then shuffle within each series
const bySeries = {}
for (const p of items) {
const s = p.series || '_'
if (!bySeries[s]) bySeries[s] = []
bySeries[s].push(p)
}
remaining = Object.values(bySeries).flatMap(arr => fisherYates(arr))
} else {
remaining = fisherYates(items.slice())
}
while (remaining.length > 0) {
const blockItems = []
const usedKeys = new Set()
const usedPrices = []
const anchorSeries = remaining[0]?.series || null
const anchorPrice = parseFloat(remaining[0]?.price) || 0
// Build candidate scan order: same-price items first (within ±5%), then rest
const samePrice = remaining.filter(p => {
const pp = parseFloat(p.price) || 0
return anchorPrice > 0 && Math.abs(pp - anchorPrice) / anchorPrice <= 0.05
})
const otherPrice = remaining.filter(p => {
const pp = parseFloat(p.price) || 0
return !(anchorPrice > 0 && Math.abs(pp - anchorPrice) / anchorPrice <= 0.05)
})
const scanOrder = [...samePrice, ...otherPrice]
for (let i = 0; i < scanOrder.length && blockItems.length < maxSlots; i++) {
const p = scanOrder[i]
const key = getProductKey(p)
const price = parseFloat(p.price) || 0
if (usedKeys.has(key)) continue
// Prefer same-series: skip different series if same-series items still available
if (anchorSeries && blockItems.length < maxSlots - 1 && p.series && p.series !== anchorSeries) {
const sameSeries = scanOrder.slice(i + 1).filter(x => x.series === anchorSeries && !usedKeys.has(getProductKey(x))).length
if (sameSeries > 0) continue
}
// Price variance max 3x within block
if (usedPrices.length > 0) {
const prices = [...usedPrices, price].filter(x => x > 0)
if (Math.max(...prices) / Math.max(Math.min(...prices), 1) > 3) continue
}
blockItems.push(p)
usedKeys.add(key)
if (price > 0) usedPrices.push(price)
}
if (!blockItems.length) break
const slotCount = Math.min(blockItems.length, maxSlots)
const label = blockItems[0].country || styleKey
allRawBlocks.push({ styleKey, country: label, items: blockItems.slice(0, slotCount) })
const used = new Set(blockItems.map(p => p.id))
remaining.splice(0, remaining.length, ...remaining.filter(p => !used.has(p.id)))
}
}
// ── Build result blocks ────────────────────────────────────────────
const result = []
const usedIds = new Set()
let totalItems = 0
// ── Similarity score: how well do two coins pair ──
const similarity = (a, b) => {
let score = 0
const aSeries = (a.series || '').toLowerCase().trim()
const bSeries = (b.series || '').toLowerCase().trim()
if (aSeries && bSeries && aSeries === bSeries) score += 50
const aDenom = (a.denomination || '').toLowerCase().trim()
const bDenom = (b.denomination || '').toLowerCase().trim()
if (aDenom && bDenom && aDenom === bDenom) score += 20
if ((a.country || '') === (b.country || '')) score += 30
if (getEra(a.year) === getEra(b.year)) score += 15
if (getMat(a) === getMat(b)) score += 15
if (getDiaBucket(a.dia) === getDiaBucket(b.dia)) score += 10
const aPrice = parseFloat(a.price) || 0
const bPrice = parseFloat(b.price) || 0
if (aPrice > 0 && bPrice > 0) {
const ratio = Math.max(aPrice, bPrice) / Math.max(Math.min(aPrice, bPrice), 1)
if (ratio <= 2) score += 10
else if (ratio <= 3) score += 5
}
return score
}
// ── Conflict check: too similar to show together ──
const stripName = (p) => (p.name || '').replace(/\b\d{4}\b/g, '').replace(/\b[A-ZА-ЯЁ]{1,2}\b/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
const hasConflict = (a, b) => {
if (getProductKey(a) === getProductKey(b)) return true
if (stripName(a) === stripName(b)) return true
const aDenom = (a.denomination || '').trim(), bDenom = (b.denomination || '').trim()
const aYear = (a.year || '').trim(), bYear = (b.year || '').trim()
if (aDenom && aDenom === bDenom && aYear && aYear === bYear) return true
return false
}
// ── Scan letter history for recently used IDs ──
const idLastUsed = {}
try {
const lettersDir = path.resolve(projDir, 'letters')
const letterFiles = fs.existsSync(lettersDir)
? fs.readdirSync(lettersDir).filter(f => f.endsWith('.json') && !f.endsWith('.history.json') && f !== 'letters.json')
: []
const cutoff = Date.now() - 25 * 24 * 60 * 60 * 1000
for (const fname of letterFiles) {
try {
const letter = JSON.parse(fs.readFileSync(path.resolve(lettersDir, fname), 'utf8'))
const letterDate = new Date(letter.updatedAt || letter.date || 0).getTime()
if (letterDate < cutoff) continue
for (const block of (letter.blocks || [])) {
const content = block.content || ''
for (const m of content.matchAll(/["'](\d{3,8})["']/g)) {
const pid = m[1]
if (!idLastUsed[pid] || letterDate > idLastUsed[pid]) idLastUsed[pid] = letterDate
}
}
} catch (_) {}
}
} catch (_) {}
const nowTs = Date.now()
const getUsedDaysAgo = (id) => {
const ts = idLastUsed[String(id)]
return ts != null ? Math.floor((nowTs - ts) / (24 * 60 * 60 * 1000)) : null
}
// ── Single coin replacement mode ──
if (anchorIds && anchorIds.length > 0) {
const idToProduct = {}
for (const p of allProducts) idToProduct[p.id] = p
const anchors = anchorIds.map(id => idToProduct[id]).filter(Boolean)
if (anchors.length > 0) {
// Collect anchor properties for hard filters
const anchorCountries = new Set(anchors.map(a => (a.country || '').trim()).filter(Boolean))
const anchorRegions = new Set(anchors.map(a => getRegion(a.country)))
const anchorSeries = new Set(anchors.map(a => (a.series || '').toLowerCase().trim()).filter(Boolean))
const anchorMats = new Set(anchors.map(a => getMat(a)))
const anchorPrices = anchors.map(a => parseFloat(a.price) || 0).filter(p => p > 0)
const avgAnchorPrice = anchorPrices.length ? anchorPrices.reduce((s, p) => s + p, 0) / anchorPrices.length : 0
const scoreAll = candidates
.filter(p => !forceExcludeIds.has(String(p.id)))
.map(p => {
const avgScore = anchors.reduce((s, a) => s + similarity(a, p), 0) / anchors.length
const conflict = anchors.some(a => hasConflict(a, p))
return { p, score: conflict ? -1 : avgScore }
})
.filter(s => s.score >= 0)
.sort((a, b) => b.score - a.score)
// Tier 1: same series (strongest match)
let pool = scoreAll.filter(s => {
const ps = (s.p.series || '').toLowerCase().trim()
return ps && anchorSeries.has(ps)
})
// Tier 2: same country + same material + similar price (±2x)
if (pool.length < 3) {
pool = scoreAll.filter(s => {
if (!anchorCountries.has((s.p.country || '').trim())) return false
if (!anchorMats.has(getMat(s.p))) return false
const pp = parseFloat(s.p.price) || 0
if (avgAnchorPrice > 0 && pp > 0) {
const ratio = Math.max(pp, avgAnchorPrice) / Math.min(pp, avgAnchorPrice)
if (ratio > 2) return false
}
return true
})
}
// Tier 3: same country (any material)
if (pool.length < 3) {
pool = scoreAll.filter(s => anchorCountries.has((s.p.country || '').trim()))
}
// Tier 4: same region + same material
if (pool.length < 3) {
pool = scoreAll.filter(s => {
return anchorRegions.has(getRegion(s.p.country)) && anchorMats.has(getMat(s.p))
})
}
// Tier 5: same region
if (pool.length < 3) {
pool = scoreAll.filter(s => anchorRegions.has(getRegion(s.p.country)))
}
// Last resort: top-scored overall (but only if score >= 30)
if (pool.length === 0) {
pool = scoreAll.filter(s => s.score >= 30)
}
// Pick random from top-5 of the chosen pool
const top = pool.slice(0, Math.min(5, pool.length))
const pick = top.length > 0 ? top[Math.floor(Math.random() * top.length)].p : null
if (pick) {
const p = pick
return send(200, { replacement: { id: p.id, name: p.name||'', price: p.price||'', oldPrice: p.oldPrice||'', image: p.image||'', salePercent: p.salePercent||'', usedDaysAgo: getUsedDaysAgo(p.id) } })
}
return send(200, { replacement: null })
}
}
if (layout) {
// Layout mode: country grouping + scoring within group + diversity
// Sort candidates by freshness (newest first)
candidates.sort((a, b) => (parseInt(b.id) || 0) - (parseInt(a.id) || 0))
// Deduplicate by product key
const seenKeys = new Set()
candidates = candidates.filter(p => {
const key = getProductKey(p)
if (seenKeys.has(key)) return false
seenKeys.add(key)
return true
})
// ── Group by country ──
const byCountry = {}
for (const p of candidates) {
const c = (p.country || 'Другое').trim()
if (!byCountry[c]) byCountry[c] = []
byCountry[c].push(p)
}
// Sort countries by item count desc
const countryGroups = Object.entries(byCountry)
.sort((a, b) => b[1].length - a[1].length)
.map(([country, items]) => ({ country, items, region: getRegion(items[0]?.country) }))
// ── Pick best N coins from a group using anchor + scoring + diversity ──
const pickFromGroup = (items, size) => {
const available = items.filter(p => !usedIds.has(p.id))
if (available.length < size) return null
// Anchor = random from top-5 freshest (for variety on regenerate)
const anchorPool = available.slice(0, Math.min(5, available.length))
const anchor = anchorPool[Math.floor(Math.random() * anchorPool.length)]
if (size === 1) return [anchor]
// Score others against anchor
const scored = available.filter(p => p !== anchor)
.map(p => ({ p, score: similarity(anchor, p) }))
.sort((a, b) => b.score - a.score)
const picked = [anchor]
for (const { p } of scored) {
if (picked.length >= size) break
if (picked.some(existing => hasConflict(existing, p))) continue
picked.push(p)
}
return picked.length >= size ? picked : null
}
// ── Build region groups as fallback ──
const byRegion = {}
for (const g of countryGroups) {
if (!byRegion[g.region]) byRegion[g.region] = []
byRegion[g.region].push(...g.items)
}
const regionGroups = Object.entries(byRegion)
.sort((a, b) => b[1].length - a[1].length)
.map(([region, items]) => ({ region, items }))
// ── Fill layout slots ──
let countryIdx = Math.floor(Math.random() * countryGroups.length)
for (const slotSize of layout) {
let picked = null
// 1) Try country groups (round-robin)
for (let attempt = 0; attempt < countryGroups.length; attempt++) {
const gi = (countryIdx + attempt) % countryGroups.length
picked = pickFromGroup(countryGroups[gi].items, slotSize)
if (picked) {
countryIdx = (gi + 1) % countryGroups.length
break
}
}
// 2) Try region groups
if (!picked) {
for (const rg of regionGroups) {
picked = pickFromGroup(rg.items, slotSize)
if (picked) break
}
}
// 3) Last resort: just take freshest available
if (!picked) {
picked = candidates.filter(p => !usedIds.has(p.id)).slice(0, slotSize)
}
if (!picked.length) break
picked.forEach(p => usedIds.add(p.id))
result.push({
mixin: getMixinForCount(picked.length),
ids: picked.map(p => p.id),
country: picked[0]?.country || '',
styleKey: '',
})
totalItems += picked.length
}
} else {
// Original auto mode: interleave style groups
const blocksByStyle = {}
for (const b of allRawBlocks) {
if (!blocksByStyle[b.styleKey]) blocksByStyle[b.styleKey] = []
blocksByStyle[b.styleKey].push(b)
}
const styleQueues = Object.values(blocksByStyle).sort((a, b) => b.length - a.length)
while (totalItems < target) {
let added = false
for (const queue of styleQueues) {
if (totalItems >= target) break
if (!queue.length) continue
const block = queue.shift()
const ids = block.items.map(p => p.id).filter(id => !usedIds.has(id))
if (!ids.length) continue
ids.forEach(id => usedIds.add(id))
const label = block.country || block.styleKey
result.push({ mixin: getMixinForCount(ids.length), ids, country: label, styleKey: block.styleKey })
totalItems += ids.length
added = true
}
if (!added) break
}
}
// Enrich with product details for preview
const idToProduct = {}
for (const p of allProducts) idToProduct[p.id] = p
const blocksOut = result.map(b => ({
mixin: b.mixin,
ids: b.ids,
country: b.country,
styleKey: b.styleKey || '',
products: b.ids.map(id => {
const p = idToProduct[id] || {}
return { id, name: p.name || '', price: p.price || '', oldPrice: p.oldPrice || '', image: p.image || '', salePercent: p.salePercent || '', usedDaysAgo: getUsedDaysAgo(id) }
}),
}))
return send(200, { blocks: blocksOut, total: totalItems })
} catch (e) { return send(500, { error: e?.message || 'Ошибка автоподбора' }) }
}
// --- FTP/SFTP endpoints ---
const ftpMatch = req.url.match(/^\/api\/project\/([^/]+)\/ftp\/(test|upload|list)$/)
if (ftpMatch && req.method === 'POST') {
const projectName = decodeURIComponent(ftpMatch[1])
const ftpAction = ftpMatch[2]
const projDir = getProjectDir(projectName)
if (!projDir) return send(400, { error: 'invalid_project_name' })
const projSettings = readJson(path.resolve(projDir, 'settings.json'), {})
const imageStorage = projSettings.imageStorage || 'ftp'
const localImageBaseUrl = process.env.LOCAL_IMAGE_BASE_URL || ''
// ── Local image storage ──
if (imageStorage === 'local') {
const imagesDir = path.resolve(dataDir, 'images', projectName)
if (ftpAction === 'test') {
return send(200, { ok: true, message: `Локальное хранение: ${imagesDir}` })
}
if (ftpAction === 'upload') {
const body = await readBody(req)
if (!body.imageData) return send(400, { error: 'Нет данных изображения' })
const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/)
if (!dataMatch) return send(400, { error: 'Неверный формат' })
const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
const ext = TYPES[dataMatch[1]]
if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` })
const buffer = Buffer.from(dataMatch[2], 'base64')
const folder = String(body.folder || '').trim()
const fileName = String(body.fileName || '').trim()
if (!folder) return send(400, { error: 'Не указана папка' })
if (!fileName) return send(400, { error: 'Не указано имя файла' })
const safeName = fileName.replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_') || 'file'
const localDir = path.resolve(imagesDir, folder)
const localFile = path.resolve(localDir, `${safeName}.${ext}`)
try {
fs.mkdirSync(localDir, { recursive: true })
fs.writeFileSync(localFile, buffer)
const publicUrl = `${localImageBaseUrl}/${projectName}/${folder}/${safeName}.${ext}`
return send(200, { url: publicUrl })
} catch (e) {
return send(500, { error: 'Ошибка сохранения: ' + (e?.message || '') })
}
}
if (ftpAction === 'list') {
const body = await readBody(req)
const folder = String(body.folder || '').trim()
if (!folder) return send(400, { error: 'Не указана папка' })
const localDir = path.resolve(imagesDir, folder)
try {
if (!fs.existsSync(localDir)) return send(200, { files: [] })
const entries = fs.readdirSync(localDir).filter(f => /\.(png|jpe?g|gif|webp)$/i.test(f))
const files = entries.map(name => ({
name,
url: `${localImageBaseUrl}/${projectName}/${folder}/${name}`,
}))
return send(200, { files })
} catch (e) {
return send(500, { error: e?.message || 'Ошибка чтения' })
}
}
return send(400, { error: 'Неизвестное действие' })
}
const fc = projSettings.ftpConfig || {}
if (!fc.host) return send(400, { error: 'FTP не настроен для этого проекта' })
const connectFtp = async () => {
const { Client } = await import('basic-ftp')
const client = new Client()
client.ftp.verbose = false
client.ftp.timeout = 15000
await client.access({
host: fc.host,
port: parseInt(fc.port) || 21,
user: fc.user,
password: fc.password,
secure: false,
})
return client
}
const connectSftp = async () => {
const SftpClient = (await import('ssh2-sftp-client')).default
const sftp = new SftpClient()
await sftp.connect({
host: fc.host,
port: parseInt(fc.port) || 22,
username: fc.user,
password: fc.password,
readyTimeout: 15000,
})
return sftp
}
if (ftpAction === 'test') {
try {
if (fc.protocol === 'sftp') {
const sftp = await connectSftp()
const exists = await sftp.exists(fc.remotePath || '/')
await sftp.end()
return send(200, { ok: true, message: `Подключено. Путь ${fc.remotePath || '/'} ${exists ? 'существует' : 'не найден (будет создан)'}` })
} else {
const client = await connectFtp()
await client.ensureDir(fc.remotePath || '/')
client.close()
return send(200, { ok: true, message: 'Подключено. Путь доступен.' })
}
} catch (e) {
return send(500, { error: 'connection_failed', details: e?.message || 'Не удалось подключиться' })
}
}
if (ftpAction === 'upload') {
const body = await readBody(req)
if (!body.imageData) return send(400, { error: 'Нет данных изображения' })
const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/)
if (!dataMatch) return send(400, { error: 'Неверный формат' })
const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
const ext = TYPES[dataMatch[1]]
if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` })
const buffer = Buffer.from(dataMatch[2], 'base64')
const folder = String(body.folder || '').trim()
const fileName = String(body.fileName || '').trim()
if (!folder) return send(400, { error: 'Не указана папка (дата письма)' })
if (!fileName) return send(400, { error: 'Не указано имя файла' })
const safeName = fileName.replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_') || 'file'
const remoteDir = `${fc.remotePath}/${folder}`
const remoteFile = `${remoteDir}/${safeName}.${ext}`
const steps = []
try {
if (fc.protocol === 'sftp') {
steps.push('sftp: connecting')
const sftp = await connectSftp()
steps.push('sftp: mkdir ' + remoteDir)
await sftp.mkdir(remoteDir, true)
steps.push('sftp: uploading ' + remoteFile)
await sftp.put(buffer, remoteFile)
steps.push('sftp: done')
await sftp.end()
} else {
const client = await connectFtp()
await client.ensureDir(remoteDir)
const tmpFile = path.resolve(dataDir, `.ftp-tmp-${Date.now()}.${ext}`)
try {
fs.writeFileSync(tmpFile, buffer)
await client.uploadFrom(tmpFile, `${safeName}.${ext}`)
} finally {
try { fs.unlinkSync(tmpFile) } catch {}
client.close()
}
}
const publicUrl = `${fc.baseUrl}/${folder}/${safeName}.${ext}`
return send(200, { url: publicUrl, steps })
} catch (e) {
return send(500, { error: 'upload_failed', details: e?.message || 'Ошибка загрузки', steps })
}
}
if (ftpAction === 'list') {
const body = await readBody(req)
const folder = String(body.folder || '').trim()
if (!folder) return send(400, { error: 'Не указана папка' })
const remoteDir = `${fc.remotePath}/${folder}`
try {
let files = []
if (fc.protocol === 'sftp') {
const sftp = await connectSftp()
const exists = await sftp.exists(remoteDir)
if (exists) {
const listing = await sftp.list(remoteDir)
files = listing
.filter(f => f.type === '-' && /\.(png|jpe?g|gif|webp)$/i.test(f.name))
.map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` }))
}
await sftp.end()
} else {
const client = await connectFtp()
try {
const listing = await client.list(remoteDir)
files = listing
.filter(f => f.type === 1 && /\.(png|jpe?g|gif|webp)$/i.test(f.name))
.map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` }))
} catch { /* folder doesn't exist yet */ }
client.close()
}
return send(200, { files, folder })
} catch (e) {
return send(500, { error: 'list_failed', details: e?.message || 'Ошибка получения списка' })
}
}
}
if (req.method === 'GET' && req.url === '/api/sheets/auth-status') {
const config = readJson(configFile, {})
const tokens = readJson(tokensFile, {})
return send(200, { configured: Boolean(config.client_id && config.client_secret), authorized: Boolean(tokens.refresh_token) })
}
if (req.method === 'GET' && req.url === '/api/sheets/auth') {
const config = readJson(configFile, {})
if (!config.client_id || !config.client_secret) return send(400, { error: 'not_configured' })
const oauthParams = new URLSearchParams({
client_id: config.client_id,
redirect_uri: getRedirectUri(req),
response_type: 'code',
scope: 'https://www.googleapis.com/auth/spreadsheets',
access_type: 'offline',
prompt: 'consent',
})
res.statusCode = 302
res.setHeader('Location', `https://accounts.google.com/o/oauth2/v2/auth?${oauthParams}`)
return res.end()
}
const sheetsReadMatch = req.url?.match(/^\/api\/sheets\/read\?(.+)$/)
if (sheetsReadMatch && req.method === 'GET') {
const p = new URLSearchParams(sheetsReadMatch[1])
const spreadsheetId = p.get('spreadsheetId') || '', sheetName = p.get('sheetName') || ''
if (!spreadsheetId || !sheetName) return send(400, { error: 'missing_params' })
const accessToken = await getValidAccessToken()
if (!accessToken) return send(401, { error: 'not_authorized' })
const range = encodeURIComponent(`${sheetName}!A:Z`)
try {
const resp = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?ranges=${range}&fields=sheets.data.rowData.values(formattedValue,hyperlink,textFormatRuns.format.link,chipRuns.chip.richLinkProperties)`,
{ headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(15000) },
)
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
return send(resp.status, { error: err?.error?.message || 'sheets_error' })
}
const data = await resp.json()
const sheet = data.sheets?.[0]?.data?.[0]
const rowData = sheet?.rowData || []
const values = rowData.map(r => (r.values || []).map(c => c?.formattedValue || ''))
const hyperlinks = rowData.map(r => (r.values || []).map(c => {
if (c?.hyperlink) return c.hyperlink
const runs = c?.textFormatRuns
if (runs) { for (const run of runs) { if (run?.format?.link?.uri) return run.format.link.uri } }
const chips = c?.chipRuns
if (chips) { for (const cr of chips) { if (cr?.chip?.richLinkProperties?.uri) return cr.chip.richLinkProperties.uri } }
return ''
}))
return send(200, { values, hyperlinks })
} catch (err) {
return send(500, { error: 'fetch_failed', details: err?.message || 'Не удалось обратиться к Google Sheets API' })
}
}
if (req.method === 'POST' && req.url === '/api/sheets/write') {
const body = await readBody(req)
const { spreadsheetId, sheetName, col, row, value } = body
if (!spreadsheetId || !sheetName || !col || !row) return send(400, { error: 'missing_params' })
const accessToken = await getValidAccessToken()
if (!accessToken) return send(401, { error: 'not_authorized' })
const range = encodeURIComponent(`${sheetName}!${col}${row}`)
try {
const resp = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}?valueInputOption=USER_ENTERED`, {
method: 'PUT',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ values: [[value]] }),
signal: AbortSignal.timeout(15000),
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
return send(resp.status, { error: err?.error?.message || 'write_error' })
}
return send(200, { ok: true })
} catch (err) {
return send(500, { error: 'fetch_failed', details: err?.message })
}
}
// --- Stats ---
const statsMatch = req.url.match(/^\/api\/project\/([^/]+)\/stats$/)
if (statsMatch) {
const name = decodeURIComponent(statsMatch[1])
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
const statsPath = path.resolve(dir, 'stats.json')
if (req.method === 'GET') {
return send(200, { entries: readJson(statsPath, []) })
}
if (req.method === 'POST') {
const body = await readBody(req)
const entry = body?.entry
if (!entry?.letterId) return send(400, { error: 'missing letterId' })
entry.userId = req.user?.id || ''
entry.userName = req.user?.name || ''
const entries = readJson(statsPath, [])
const idx = entries.findIndex(e => e.letterId === entry.letterId && e.userId === entry.userId)
if (idx >= 0) {
entries[idx] = { ...entries[idx], ...entry }
} else {
entries.push(entry)
}
fs.writeFileSync(statsPath, JSON.stringify(entries, null, 2))
return send(200, { ok: true })
}
}
const allStatsMatch = req.method === 'GET' && req.url === '/api/stats'
if (allStatsMatch) {
const HIDDEN_DIRS = new Set(['uploads', 'images', 'node_modules', '.git', '_system', 'drafts'])
const projects = fs.readdirSync(dataDir, { withFileTypes: true })
.filter(d => d.isDirectory() && !d.name.startsWith('.') && !HIDDEN_DIRS.has(d.name))
.map(d => d.name)
const all = {}
for (const p of projects) {
const sp = path.resolve(dataDir, p, 'stats.json')
const entries = readJson(sp, [])
if (entries.length) all[p] = entries
}
return send(200, { stats: all })
}
const match = req.url.match(/^\/api\/project\/([^/]+)(\/(block|settings|draft|presets))?$/)
if (!match) return send(404, { error: 'not_found' })
const name = decodeURIComponent(match[1])
const action = match[3]
const dir = getProjectDir(name)
if (!dir) return send(400, { error: 'invalid_project_name' })
ensureDir(dir)
if (req.method === 'GET' && !action) {
const blockPath = path.resolve(dir, 'block.pug')
const meta = readJson(path.resolve(dir, 'meta.json'), { sourceName: 'Block.pug' })
const settings = readJson(path.resolve(dir, 'settings.json'), { globalSpacing: 40, blocks: {} })
// Маскируем FTP-пароль
if (settings.ftpConfig?.password) {
settings.ftpConfig = { ...settings.ftpConfig, password: '', hasPassword: true }
}
const userDraftFile = path.resolve(dir, 'drafts', `${req.user?.id || '_default'}.json`)
const draft = readJson(userDraftFile, [])
const presets = readJson(path.resolve(dir, 'presets.json'), [])
const letters = readJson(getUserLettersFile(dir), { list: [], currentId: '' })
const notes = readJson(path.resolve(dir, 'notes.json'), { list: [], currentId: '' })
const blockText = fs.existsSync(blockPath) ? fs.readFileSync(blockPath, 'utf-8') : ''
return send(200, { name, meta, settings, draft, presets, letters, notes, blockText })
}
if (req.method === 'PUT' && action) {
const body = await readBody(req)
if (action === 'block') {
fs.writeFileSync(path.resolve(dir, 'block.pug'), body.blockText || '', 'utf-8')
writeJson(path.resolve(dir, 'meta.json'), { sourceName: body.sourceName || 'Block.pug' })
return send(200, { ok: true })
}
if (action === 'block-custom') {
fs.writeFileSync(path.resolve(dir, 'block-custom.pug'), body.content || '', 'utf-8')
return send(200, { ok: true })
}
if (action === 'settings') {
const incoming = body || { globalSpacing: 40, blocks: {} }
// Сохраняем существующий FTP-пароль если не передан новый
if (incoming.ftpConfig && !incoming.ftpConfig.password) {
const existing = readJson(path.resolve(dir, 'settings.json'), {})
if (existing.ftpConfig?.password) {
incoming.ftpConfig.password = existing.ftpConfig.password
}
}
writeJson(path.resolve(dir, 'settings.json'), incoming)
return send(200, { ok: true })
}
if (action === 'draft') {
const draftsDir = path.resolve(dir, 'drafts')
ensureDir(draftsDir)
writeJson(path.resolve(draftsDir, `${req.user?.id || '_default'}.json`), body || [])
return send(200, { ok: true })
}
if (action === 'presets') {
writeJson(path.resolve(dir, 'presets.json'), body || [])
return send(200, { ok: true })
}
}
return send(405, { error: 'method_not_allowed' })
})
},
})
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte(), apiPlugin()],
server: {
allowedHosts: ['app.aspekter.ru', 'aspekter.ru', '.aspekter.ru'],
watch: {
usePolling: true,
interval: 300,
},
proxy: {
'/typograf': {
target: 'http://typograf.artlebedev.ru',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/typograf/, ''),
},
'/speller': {
target: 'https://speller.yandex.net',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/speller/, ''),
},
},
},
})