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
|
.npmrc
|
||||||
|
|
||||||
# Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex)
|
# Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex)
|
||||||
history/
|
# Паттерны привязаны к корню репозитория: иначе "history/" игнорирует
|
||||||
data/
|
# и служебный модуль src/core/history/ (сборка/CI на Linux падали бы).
|
||||||
|
/history/
|
||||||
|
/data/
|
||||||
.codex-jobs/
|
.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