fix: .gitignore игнорировал src/core/history (паттерн history/ без якоря) — CI падал с MODULE_NOT_FOUND
All checks were successful
test / test (push) Successful in 11s
All checks were successful
test / test (push) Successful in 11s
Паттерн 'history/' в .gitignore матчил любой каталог с таким именем, включая служебный модуль src/core/history/. Привязаны /history/ и /data/ к корню репозитория; модуль истории добавлен в индекс.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,8 +4,10 @@ node_modules/
|
||||
.npmrc
|
||||
|
||||
# Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex)
|
||||
history/
|
||||
data/
|
||||
# Паттерны привязаны к корню репозитория: иначе "history/" игнорирует
|
||||
# и служебный модуль src/core/history/ (сборка/CI на Linux падали бы).
|
||||
/history/
|
||||
/data/
|
||||
.codex-jobs/
|
||||
|
||||
# Временные и тестовые файлы
|
||||
|
||||
225
src/core/history/history-service.js
Normal file
225
src/core/history/history-service.js
Normal file
@@ -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,
|
||||
};
|
||||
163
src/core/history/history-store.js
Normal file
163
src/core/history/history-store.js
Normal file
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user