From 6d5b8d5f053a1217559574d6ed64169cb77eff1f Mon Sep 17 00:00:00 2001 From: Coder Date: Thu, 27 Aug 2026 13:04:46 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20.gitignore=20=D0=B8=D0=B3=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20src/core/history?= =?UTF-8?q?=20(=D0=BF=D0=B0=D1=82=D1=82=D0=B5=D1=80=D0=BD=20history/=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D1=8F=D0=BA=D0=BE=D1=80=D1=8F)=20?= =?UTF-8?q?=E2=80=94=20CI=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=20=D1=81=20MODU?= =?UTF-8?q?LE=5FNOT=5FFOUND?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Паттерн 'history/' в .gitignore матчил любой каталог с таким именем, включая служебный модуль src/core/history/. Привязаны /history/ и /data/ к корню репозитория; модуль истории добавлен в индекс. --- .gitignore | 6 +- src/core/history/history-service.js | 225 ++++++++++++++++++++++++++++ src/core/history/history-store.js | 163 ++++++++++++++++++++ 3 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 src/core/history/history-service.js create mode 100644 src/core/history/history-store.js diff --git a/.gitignore b/.gitignore index 11b0f4c..a8a30fe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ node_modules/ .npmrc # Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex) -history/ -data/ +# Паттерны привязаны к корню репозитория: иначе "history/" игнорирует +# и служебный модуль src/core/history/ (сборка/CI на Linux падали бы). +/history/ +/data/ .codex-jobs/ # Временные и тестовые файлы diff --git a/src/core/history/history-service.js b/src/core/history/history-service.js new file mode 100644 index 0000000..2ee3ca2 --- /dev/null +++ b/src/core/history/history-service.js @@ -0,0 +1,225 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { getImageDimensions } = require("../images/dimensions"); +const { isHeic, convertHeicToJpeg } = require("../images/heic"); +const { sniffImageType } = require("../images/sniff"); +const { AppError, NotFoundError } = require("../../errors"); + +function contentTypeForExt(ext) { + const map = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", + }; + return map[String(ext || "").toLowerCase()] || "image/jpeg"; +} + +function extForMimetype(mimetype) { + const map = { + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/heic": "jpg", + "image/heif": "jpg", + }; + return map[String(mimetype || "").toLowerCase()] || "jpg"; +} + +class HistoryService { + /** + * @param {Object} options + * @param {import('./history-store').HistoryStore} options.store + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ store, logger = null }) { + this.store = store; + this.logger = logger; + } + + getHistory() { + return this.store.load(); + } + + getVersion(id) { + return this.store.get(id); + } + + async getVersionFile(id) { + const version = this.store.get(id); + if (!version) { + throw new NotFoundError("Версия не найдена в истории."); + } + + const filePath = this.store.filePath(version); + if (!fs.existsSync(filePath)) { + throw new NotFoundError("Файл изображения не найден на диске."); + } + + const buffer = fs.readFileSync(filePath); + const contentType = contentTypeForExt(version.ext); + return { version, filePath, buffer, contentType }; + } + + async createVersion({ + kind, + label, + prompt = null, + parentId = null, + referenceId = null, + buffer, + ext = "jpg", + width = null, + height = null, + }) { + if (!Buffer.isBuffer(buffer)) { + throw new AppError(400, "Буфер изображения обязателен для создания версии."); + } + + let finalWidth = width; + let finalHeight = height; + + if (!finalWidth || !finalHeight) { + const dims = getImageDimensions(buffer); + if (dims) { + finalWidth = dims.width; + finalHeight = dims.height; + } + } + + const id = crypto.randomUUID(); + const fileExt = ext ? ext.toLowerCase().replace(/^\./, "") : "jpg"; + + const version = { + id, + kind: kind || "original", + label: String(label || "Версия").trim(), + prompt: prompt ? String(prompt).trim() : null, + parentId: parentId || null, + referenceId: referenceId || null, + createdAt: new Date().toISOString(), + url: `/api/history/${id}/image.${fileExt}`, + width: finalWidth || null, + height: finalHeight || null, + ext: fileExt, + }; + + await this.store.append(version, buffer); + + if (this.logger) { + this.logger.info("Создана версия истории", { + versionId: version.id, + kind: version.kind, + ext: version.ext, + width: version.width, + height: version.height, + }); + } + + return version; + } + + async importPhoto({ buffer, mimetype, originalname }) { + if (!Buffer.isBuffer(buffer)) { + throw new AppError(400, "Файл не передан или повреждён."); + } + + let processedBuffer = buffer; + let ext = extForMimetype(mimetype); + + // Handle HEIC + const isHeicFile = isHeic({ mimetype, originalname }) || sniffImageType(buffer) === "heic"; + if (isHeicFile) { + processedBuffer = await convertHeicToJpeg(buffer); + ext = "jpg"; + } else { + const sniffed = sniffImageType(buffer); + if (sniffed) { + ext = sniffed === "jpeg" ? "jpg" : sniffed; + } + } + + const dims = getImageDimensions(processedBuffer); + const label = originalname ? path.basename(originalname, path.extname(originalname)) : "Оригинал"; + + return this.createVersion({ + kind: "original", + label: label || "Оригинал", + prompt: null, + parentId: null, + referenceId: null, + buffer: processedBuffer, + ext, + width: dims?.width || null, + height: dims?.height || null, + }); + } + + async removeCascade(id) { + return this.store.removeCascade(id); + } +} + +/** + * Миграция истории из старого каталога ./history в data/history при первом старте v2. + * @param {string} legacyDir - e.g. './history' + * @param {string} targetDir - e.g. './data/history' + * @param {import('../../logger').Logger} [logger] + */ +function migrateLegacyHistory(legacyDir, targetDir, logger = null) { + const legacyManifest = path.join(legacyDir, "history.json"); + const targetManifest = path.join(targetDir, "history.json"); + + // If source does not exist, nothing to migrate + if (!fs.existsSync(legacyManifest)) { + return; + } + + // If target already has manifest, migration was already done + if (fs.existsSync(targetManifest)) { + return; + } + + try { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + const files = fs.readdirSync(legacyDir); + let copied = 0; + + for (const file of files) { + const srcFile = path.join(legacyDir, file); + const dstFile = path.join(targetDir, file); + + const stat = fs.statSync(srcFile); + if (stat.isFile()) { + fs.copyFileSync(srcFile, dstFile); + copied++; + } + } + + if (logger) { + logger.info("Миграция истории из ./history в data/history завершена", { + filesCount: copied, + from: legacyDir, + to: targetDir, + }); + } + } catch (err) { + if (logger) { + logger.error("Ошибка при миграции истории версий", { error: err.message }); + } + } +} + +module.exports = { + HistoryService, + migrateLegacyHistory, + contentTypeForExt, + extForMimetype, +}; diff --git a/src/core/history/history-store.js b/src/core/history/history-store.js new file mode 100644 index 0000000..2056ff6 --- /dev/null +++ b/src/core/history/history-store.js @@ -0,0 +1,163 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +class HistoryStore { + /** + * @param {Object} options + * @param {string} options.dir - Путь к каталогу с историей (data/history) + * @param {number} [options.limit=50] - Максимальное число хранимых версий + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ dir, limit = 50, logger = null }) { + this.dir = dir; + this.limit = limit; + this.logger = logger; + this.manifestPath = path.join(this.dir, "history.json"); + this._writeChain = Promise.resolve(); + + // Ensure dir exists + if (!fs.existsSync(this.dir)) { + fs.mkdirSync(this.dir, { recursive: true }); + } + } + + load() { + try { + if (!fs.existsSync(this.manifestPath)) { + return []; + } + const raw = fs.readFileSync(this.manifestPath, "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (err) { + if (this.logger) { + this.logger.warn("Не удалось прочитать history.json, возврат пустого списка", { + error: err.message, + }); + } + return []; + } + } + + _saveManifestAtomic(versions) { + if (!fs.existsSync(this.dir)) { + fs.mkdirSync(this.dir, { recursive: true }); + } + + const tmpFile = path.join( + this.dir, + `history.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp` + ); + + const fd = fs.openSync(tmpFile, "w"); + try { + fs.writeFileSync(fd, JSON.stringify(versions, null, 2), "utf8"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + + fs.renameSync(tmpFile, this.manifestPath); + } + + filePath(version) { + const ext = version.ext || "jpg"; + return path.join(this.dir, `${version.id}.${ext}`); + } + + get(id) { + if (!id) return null; + const list = this.load(); + return list.find((v) => v.id === id) || null; + } + + async append(version, buffer = null) { + return new Promise((resolve, reject) => { + this._writeChain = this._writeChain + .then(() => { + if (buffer) { + const fPath = this.filePath(version); + fs.writeFileSync(fPath, buffer); + } + + const history = this.load(); + history.push(version); + + const trimmed = history.slice(-this.limit); + const keptIds = new Set(trimmed.map((entry) => entry.id)); + + for (const old of history) { + if (!keptIds.has(old.id)) { + try { + fs.rmSync(this.filePath(old), { force: true }); + } catch { + // File might not exist + } + } + } + + this._saveManifestAtomic(trimmed); + resolve(version); + }) + .catch(reject); + }); + } + + async removeCascade(id) { + return new Promise((resolve, reject) => { + this._writeChain = this._writeChain + .then(() => { + const history = this.load(); + const target = history.find((entry) => entry.id === id); + + if (!target) { + resolve({ removed: [], remaining: history }); + return; + } + + const toDelete = new Set([id]); + let grew = true; + + while (grew) { + grew = false; + for (const entry of history) { + if (entry.parentId && toDelete.has(entry.parentId) && !toDelete.has(entry.id)) { + toDelete.add(entry.id); + grew = true; + } + } + } + + const removed = history.filter((entry) => toDelete.has(entry.id)); + const remaining = history.filter((entry) => !toDelete.has(entry.id)); + + this._saveManifestAtomic(remaining); + + for (const entry of removed) { + try { + fs.rmSync(this.filePath(entry), { force: true }); + } catch { + // Ignore + } + } + + if (this.logger) { + this.logger.info("Удаление версии из истории", { + versionId: id, + removedCount: removed.length, + }); + } + + resolve({ removed, remaining }); + }) + .catch(reject); + }); + } +} + +module.exports = { + HistoryStore, +};