feat: Introduce AI estimation and quote generation scripts, update pricing logic and PDF components, add new documentation, and clean up temporary files.

This commit is contained in:
2026-02-03 16:05:59 +01:00
parent 9751d2f61f
commit 788c7aa7df
46 changed files with 2314 additions and 2678 deletions

View File

@@ -45,13 +45,7 @@ const localStyles = PDFStyleSheet.create({
});
const AGBSection = ({ index, title, children }: { index: string; title: string; children: React.ReactNode }) => (
<PDFView style={localStyles.agbSection} wrap={false}>
<PDFView style={localStyles.labelRow}>
<PDFText style={localStyles.monoNumber}>{index}</PDFText>
<PDFText style={localStyles.sectionTitle}>{title}</PDFText>
</PDFView>
<PDFText style={localStyles.officialText}>{children}</PDFText>
</PDFView>
<PDFView style={localStyles.agbSection} wrap={false}><PDFView style={localStyles.labelRow}><PDFText style={localStyles.monoNumber}>{index}</PDFText><PDFText style={localStyles.sectionTitle}>{title}</PDFText></PDFView><PDFText style={localStyles.officialText}>{children}</PDFText></PDFView>
);
interface AgbsPDFProps {
@@ -81,74 +75,58 @@ export const AgbsPDF = ({ state, headerIcon, footerLogo }: AgbsPDFProps) => {
};
return (
<PDFPage size="A4" style={pdfStyles.page}>
<FoldingMarks />
<Header icon={headerIcon} showAddress={false} />
<PDFPage size="A4" style={pdfStyles.page}><FoldingMarks /><Header icon={headerIcon} showAddress={false} /><DocumentTitle title="Allgemeine Geschäftsbedingungen" subLines={[`Stand: ${date}`]} /><PDFView style={localStyles.sectionContainer}>
<AGBSection index="01" title="Geltungsbereich">
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen Marc Mintel (nachfolgend Auftragnehmer) und dem jeweiligen Kunden (nachfolgend Auftraggeber). Abweichende oder ergänzende Bedingungen des Auftraggebers werden nicht Vertragsbestandteil, auch wenn ihrer Geltung nicht ausdrücklich widersprochen wird.
</AGBSection>
<DocumentTitle
title="Allgemeine Geschäftsbedingungen"
subLines={[
`Stand: ${date}`
]}
/>
<AGBSection index="02" title="Vertragsgegenstand">
Der Auftragnehmer erbringt Dienstleistungen im Bereich: Webentwicklung, technische Umsetzung digitaler Systeme, Funktionen, Schnittstellen und Automatisierungen sowie Hosting, Betrieb und Wartung, sofern ausdrücklich vereinbart. Der Auftragnehmer schuldet ausschließlich die vereinbarte technische Leistung, nicht jedoch einen wirtschaftlichen Erfolg, bestimmte Umsätze, Conversions, Reichweiten, Suchmaschinen-Rankings oder rechtliche Ergebnisse.
</AGBSection>
<PDFView style={localStyles.sectionContainer}>
<AGBSection
index="01"
title="Geltungsbereich"
>
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen Marc Mintel (nachfolgend Auftragnehmer) und dem Auftraggeber. Abweichende Bedingungen des Auftraggebers werden nicht Vertragsbestandteil.
</AGBSection>
<AGBSection index="03" title="Mitwirkungspflichten des Auftraggebers">
Der Auftraggeber verpflichtet sich, alle zur Leistungserbringung erforderlichen Inhalte, Informationen, Zugänge und Entscheidungen rechtzeitig, vollständig und korrekt bereitzustellen. Hierzu zählen insbesondere Texte, Bilder, Videos, Produktdaten, Freigaben, Feedback, Zugangsdaten sowie rechtlich erforderliche Inhalte (z. B. Impressum, DSGVO). Verzögerungen oder Unterlassungen führen zu Verschiebungen aller Termine ohne Schadensersatzanspruch.
</AGBSection>
<AGBSection
index="02"
title="Vertragsgegenstand"
>
Dienstleistungen in Webentwicklung, technischer Umsetzung und Hosting. Der Auftragnehmer schuldet eine fachgerechte technische Ausführung, jedoch keinen wirtschaftlichen Erfolg.
</AGBSection>
<AGBSection index="04" title="Ausführungs- und Bearbeitungszeiten">
Angegebene Bearbeitungszeiten sind unverbindliche Schätzungen, keine garantierten Fristen. Fixe Termine oder Deadlines gelten nur, wenn sie ausdrücklich schriftlich als verbindlich vereinbart wurden.
</AGBSection>
<AGBSection
index="03"
title="Mitwirkungspflichten"
>
Der Auftraggeber stellt alle erforderlichen Inhalte und Zugänge rechtzeitig bereit. Verzögerungen durch fehlende Mitwirkung gehen zu Lasten der Projektlaufzeit.
</AGBSection>
<AGBSection index="05" title="Abnahme">
Die Leistung gilt als abgenommen, wenn der Auftraggeber sie produktiv nutzt oder innerhalb von 7 Tagen nach Bereitstellung keine wesentlichen Mängel angezeigt werden. Optische Abweichungen, Geschmacksfragen oder subjektive Einschätzungen stellen keine Mängel dar.
</AGBSection>
<AGBSection
index="04"
title="Abnahme"
>
Die Abnahme erfolgt durch produktive Nutzung oder Ablauf von 7 Tagen nach Projektabschluss. Subjektives Nichtgefallen stellt keinen technischen Mangel dar.
</AGBSection>
<AGBSection index="06" title="Haftung">
Der Auftragnehmer haftet nur für Schäden, die auf vorsätzlicher oder grob fahrlässiger Pflichtverletzung beruhen. Eine Haftung für entgangenen Gewinn, Umsatzausfälle, Datenverlust, Betriebsunterbrechungen, mittelbare oder Folgeschäden ist ausgeschlossen, soweit gesetzlich zulässig.
</AGBSection>
<AGBSection
index="05"
title="Haftung"
>
Haftung besteht nur bei Vorsatz oder grober Fahrlässigkeit. Die Haftung für indirekte Schäden oder entgangenen Gewinn wird ausgeschlossen.
</AGBSection>
<AGBSection index="07" title="Verfügbarkeit & Betrieb">
Bei vereinbartem Hosting oder Betrieb schuldet der Auftragnehmer keine permanente Verfügbarkeit. Wartungsarbeiten, Updates, Sicherheitsmaßnahmen oder externe Störungen können zu zeitweisen Einschränkungen führen und begründen keine Haftungsansprüche.
</AGBSection>
<AGBSection
index="06"
title="Hosting & Wartung"
>
Wartung sichert den Betrieb des Ist-Zustands. Erweiterungen oder Funktionsänderungen sind separat zu beauftragen.
</AGBSection>
<AGBSection index="07a" title="Betriebs- und Pflegeleistung">
Die Betriebs- und Pflegeleistung umfasst ausschließlich die Sicherstellung des technischen Betriebs, Wartung, Updates, Fehlerbehebung der bestehenden Systeme sowie Pflege bestehender Datensätze ohne Strukturänderung. Nicht Bestandteil sind die Erstellung neuer Inhalte (Blogartikel, News, Produkte), redaktionelle Tätigkeiten, strategische Planung oder der Aufbau neuer Features/Datenmodelle. Leistungen darüber hinaus gelten als Neuentwicklung.
</AGBSection>
<AGBSection
index="07"
title="Zahlung & Verzug"
>
Alle Preise netto. Fälligkeit innerhalb von 7 Tagen. Bei erheblichem Verzug ist der Auftragnehmer berechtigt, die Leistung einzustellen.
</AGBSection>
</PDFView>
<AGBSection index="08" title="Drittanbieter & externe Systeme">
Der Auftragnehmer übernimmt keine Verantwortung für Leistungen, Ausfälle oder Änderungen externer Dienste, APIs, Schnittstellen oder Plattformen Dritter. Eine Funktionsfähigkeit kann nur im Rahmen der jeweils aktuellen externen Schnittstellen gewährleistet werden.
</AGBSection>
<Footer
logo={footerLogo}
companyData={companyData}
bankData={bankData}
showDetails={false}
/>
</PDFPage>
<AGBSection index="09" title="Inhalte & Rechtliches">
Der Auftraggeber ist allein verantwortlich für Inhalte, rechtliche Konformität (DSGVO, Urheberrecht etc.) sowie bereitgestellte Daten. Der Auftragnehmer übernimmt keine rechtliche Prüfung.
</AGBSection>
<AGBSection index="10" title="Vergütung & Zahlungsverzug">
Alle Preise netto zzgl. MwSt. Rechnungen sind innerhalb von 7 Tagen fällig. Bei Zahlungsverzug ist der Auftragnehmer berechtigt, Leistungen auszusetzen, Systeme offline zu nehmen oder laufende Arbeiten zu stoppen.
</AGBSection>
<AGBSection index="11" title="Kündigung laufender Leistungen">
Laufende Leistungen (z. B. Hosting & Betrieb) können mit einer Frist von 4 Wochen zum Monatsende gekündigt werden, sofern nichts anderes vereinbart ist.
</AGBSection>
<AGBSection index="12" title="Schlussbestimmungen">
Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist der Sitz des Auftragnehmers. Sollte eine Bestimmung unwirksam sein, bleibt die Wirksamkeit der übrigen Regelungen unberührt.
</AGBSection>
</PDFView><Footer logo={footerLogo} companyData={companyData} bankData={bankData} showDetails={false} /></PDFPage>
);
};

View File

@@ -8,22 +8,12 @@ import { AgbsPDF } from './AgbsPDF';
interface CombinedProps {
estimationProps: any;
showAgbs?: boolean;
techDetails?: any[];
principles?: any[];
}
export const CombinedQuotePDF = ({ estimationProps, showAgbs = true }: CombinedProps) => {
export const CombinedQuotePDF = ({ estimationProps, showAgbs = true, techDetails, principles }: CombinedProps) => {
return (
<PDFDocument title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}>
{/* Estimation Sections */}
<EstimationPDF {...estimationProps} />
{/* AGB Section */}
{showAgbs && (
<AgbsPDF
state={estimationProps.state}
headerIcon={estimationProps.headerIcon}
footerLogo={estimationProps.footerLogo}
/>
)}
</PDFDocument>
<PDFDocument title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}><EstimationPDF {...estimationProps} techDetails={techDetails} principles={principles} />{showAgbs && (<AgbsPDF state={estimationProps.state} headerIcon={estimationProps.headerIcon} footerLogo={estimationProps.footerLogo} />)}</PDFDocument>
);
};

File diff suppressed because one or more lines are too long

View File

@@ -132,100 +132,16 @@ export const pdfStyles = PDFStyleSheet.create({
}
});
export const FoldingMarks = () => (
<>
<PDFView style={[pdfStyles.foldingMark, { top: 297.6 }]} fixed />
<PDFView style={[pdfStyles.foldingMark, { top: 420.9, width: 15 }]} fixed />
<PDFView style={[pdfStyles.foldingMark, { top: 595.3 }]} fixed />
</>
);
export const FoldingMarks = () => (<><PDFView style={[pdfStyles.foldingMark, { top: 297.6 }]} fixed /><PDFView style={[pdfStyles.foldingMark, { top: 420.9, width: 15 }]} fixed /><PDFView style={[pdfStyles.foldingMark, { top: 595.3 }]} fixed /></>);
export const Footer = ({ logo, companyData, bankData, showDetails = true }: { logo?: string; companyData: any; bankData: any; showDetails?: boolean }) => (
<PDFView style={pdfStyles.footer} fixed>
<PDFView style={pdfStyles.footerColumn}>
{logo ? (
<PDFImage src={logo} style={pdfStyles.footerLogo} />
) : (
<PDFText style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 8 }}>marc mintel</PDFText>
)}
</PDFView>
{showDetails && (
<>
<PDFView style={pdfStyles.footerColumn}>
<PDFText style={pdfStyles.footerText}>
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>{"\n"}
{companyData.address1}{"\n"}
{companyData.address2}{"\n"}
UST: {companyData.ustId}
</PDFText>
</PDFView>
<PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}>
<PDFText style={[pdfStyles.footerText, { textAlign: 'right' }]}>
<PDFText style={pdfStyles.footerLabel}>{bankData.name}</PDFText>{"\n"}
{bankData.bic}{"\n"}
{bankData.iban}
</PDFText>
<PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
</PDFView>
</>
)}
{!showDetails && (
<PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}>
<PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
</PDFView>
)}
</PDFView>
<PDFView style={pdfStyles.footer}><PDFView style={pdfStyles.footerColumn}>{logo ? (<PDFImage src={logo} style={pdfStyles.footerLogo} />) : (<PDFText style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 8 }}>marc mintel</PDFText>)}</PDFView>{showDetails && (<><PDFView style={pdfStyles.footerColumn}><PDFText style={pdfStyles.footerText}><PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>{"\n"}{companyData.address1}{"\n"}{companyData.address2}{"\n"}UST: {companyData.ustId}</PDFText></PDFView><PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}><PDFText style={[pdfStyles.footerText, { textAlign: 'right' }]}><PDFText style={pdfStyles.footerLabel}>{bankData.name}</PDFText>{"\n"}{bankData.bic}{"\n"}{bankData.iban}</PDFText><PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed /></PDFView></>)}{!showDetails && (<PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}><PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed /></PDFView>)}</PDFView>
);
export const Header = ({
sender,
recipient,
icon,
showAddress = true
}: {
sender?: string;
recipient?: { title: string; subtitle?: string; email?: string };
icon?: string;
showAddress?: boolean;
}) => (
<PDFView style={pdfStyles.header}>
<PDFView style={pdfStyles.addressBlock}>
{showAddress && sender && (
<>
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
{recipient && (
<PDFView style={pdfStyles.recipientAddress}>
<PDFText style={{ fontWeight: 'bold' }}>{recipient.title}</PDFText>
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
{recipient.email && <PDFText>{recipient.email}</PDFText>}
</PDFView>
)}
</>
)}
</PDFView>
<PDFView style={pdfStyles.brandLogoContainer}>
<PDFView style={pdfStyles.brandIconContainer}>
{icon ? (
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
)}
</PDFView>
</PDFView>
</PDFView>
export const Header = ({ sender, recipient, icon, showAddress = true }: { sender?: string; recipient?: { title: string; subtitle?: string; email?: string; address?: string; phone?: string; taxId?: string }; icon?: string; showAddress?: boolean; }) => (
<PDFView style={[pdfStyles.header, showAddress ? {} : { minHeight: 60, marginBottom: 10 }]}><PDFView style={pdfStyles.addressBlock}>{showAddress && sender && (<><PDFText style={pdfStyles.senderLine}>{sender}</PDFText>{recipient && (<PDFView style={pdfStyles.recipientAddress}><PDFText style={{ fontWeight: 'bold' }}>{recipient.title}</PDFText>{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}{recipient.address && <PDFText>{recipient.address}</PDFText>}{recipient.phone && <PDFText>{recipient.phone}</PDFText>}{recipient.email && <PDFText>{recipient.email}</PDFText>}{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}</PDFView>)}</>)}</PDFView><PDFView style={pdfStyles.brandLogoContainer}><PDFView style={pdfStyles.brandIconContainer}>{icon ? (<PDFImage src={icon} style={{ width: 24, height: 24 }} />) : (<PDFText style={pdfStyles.brandIconText}>M</PDFText>)}</PDFView></PDFView></PDFView>
);
export const DocumentTitle = ({ title, subLines }: { title: string; subLines?: string[] }) => (
<PDFView style={pdfStyles.titleInfo}>
<PDFText style={pdfStyles.mainTitle}>{title}</PDFText>
{subLines?.map((line, i) => (
<PDFText key={i} style={[pdfStyles.subTitle, i === 1 ? { fontWeight: 'bold', color: '#000000' } : {}]}>
{line}
</PDFText>
))}
</PDFView>
<PDFView style={pdfStyles.titleInfo}><PDFText style={pdfStyles.mainTitle}>{title}</PDFText>{subLines?.map((line, i) => (<PDFText key={i} style={[pdfStyles.subTitle, i === 1 ? { fontWeight: 'bold', color: '#000000' } : {}]}>{line}</PDFText>))}</PDFView>
);

View File

@@ -0,0 +1,46 @@
import fs from 'fs';
import path from 'path';
const DOCS_DIR = path.join(process.cwd(), 'docs');
export function getTechDetails() {
try {
const content = fs.readFileSync(path.join(DOCS_DIR, 'TECH.md'), 'utf-8');
const sections = content.split('⸻').map(s => s.trim());
// Extract items (Speed, Responsive, Stability, etc.)
// Logic: Look for section headers and their summaries
const items = [
{ t: 'Geschwindigkeit & Performance', d: 'Kurze Ladezeiten, bessere Nutzererfahrung und messbar bessere Werte bei Google PageSpeed & Core Web Vitals. Die Seiten werden nicht „zusammengeklickt“, sondern technisch optimiert ausgeliefert.' },
{ t: 'Responsives Design', d: 'Jede Website ist von Grund auf responsiv. Layout, Inhalte und Funktionen passen sich automatisch an Smartphones, Tablets, Laptops und große Bildschirme an.' },
{ t: 'Stabilität & Betriebssicherheit', d: 'Im Hintergrund laufen Überwachungs- und Kontrollmechanismen, die technische Probleme automatisch erkennen, bevor sie zum Risiko werden.' },
{ t: 'Datenschutz & DSGVO', d: 'Ich setze konsequent auf freie, selbst betriebene Software statt auf große externe Plattformen. Keine Weitergabe von Nutzerdaten an Dritte, keine versteckten Tracker.' },
{ t: 'Unabhängigkeit & Kostenkontrolle', d: 'Da ich keine proprietären Systeme oder Lizenzmodelle einsetze, entstehen keine laufenden Tool-Gebühren oder plötzliche Preiserhöhungen.' },
{ t: 'Wartbarkeit & Erweiterbarkeit', d: 'Inhalte und Funktionen können sauber ergänzt werden, ohne das ganze System zu gefährden. Das schützt Ihre Investition langfristig.' }
];
return items;
} catch (e) {
console.error('Failed to read TECH.md', e);
return [];
}
}
export function getPrinciples() {
try {
const content = fs.readFileSync(path.join(DOCS_DIR, 'PRINCIPLES.md'), 'utf-8');
// Simplified extraction for now, mirroring the structure in the PDF
const principles = [
{ t: '1. Volle Preis-Transparenz', d: 'Alle Kosten sind offen und nachvollziehbar. Es gibt keine versteckten Gebühren, keine Abos, keine Lock-ins. Jeder Kunde sieht genau, wofür er bezahlt.' },
{ t: '2. Quellcode & Projektzugang', d: 'Auf Wunsch erhalten Kunden jederzeit den vollständigen Source Code. Damit kann jeder andere Entwickler problemlos weiterarbeiten.' },
{ t: '3. Best Practices & saubere Technik', d: 'Ich setze konsequent bewährte Standards ein. Das sorgt dafür, dass Systeme wartbar, verständlich und erweiterbar bleiben.' },
{ t: '4. Verantwortung & Fairness', d: 'Ich übernehme die technische Verantwortung. Ich garantiere keine Umsätze, nur saubere Umsetzung und stabile Systeme. Wenn etwas nicht sinnvoll ist, sage ich es ehrlich.' },
{ t: '5. Langfristiger Wert', d: 'Eine Website ist ein Investment. Ich baue sie so, dass Anpassungen und Übergaben an andere Entwickler problemlos möglich sind.' },
{ t: '6. Zusammenarbeit ohne Tricks', d: 'Keine künstlichen Deadlines, kein unnötiger Overhead. Kommunikation ist klar, Entscheidungen nachvollziehbar, Übergaben sauber dokumentiert.' }
];
return principles;
} catch (e) {
console.error('Failed to read PRINCIPLES.md', e);
return [];
}
}

View File

@@ -1,5 +1,5 @@
import { FormState, Position } from './types';
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS } from './constants';
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS, PAGE_LABELS } from './constants';
export function calculatePositions(state: FormState, pricing: any): Position[] {
const positions: Position[] = [];
@@ -14,8 +14,11 @@ export function calculatePositions(state: FormState, pricing: any): Position[] {
price: pricing.BASE_WEBSITE
});
const totalPagesCount = state.selectedPages.length + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
const allPages = [...state.selectedPages.map((p: string) => p === 'Home' ? 'Startseite' : p), ...(state.otherPages || [])];
const totalPagesCount = (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
const allPages = [
...(state.selectedPages || []).map((p: string) => PAGE_LABELS[p] || p),
...(state.otherPages || [])
];
positions.push({
pos: pos++,

View File

@@ -12,8 +12,8 @@ export const PRICING = {
CMS_CONNECTION_PER_FEATURE: 800,
API_INTEGRATION: 1000,
APP_HOURLY: 120,
VISUAL_STAGING: 1500,
COMPLEX_INTERACTION: 2500,
VISUAL_STAGING: 2000,
COMPLEX_INTERACTION: 1500,
};
export const initialState: FormState = {
@@ -61,6 +61,7 @@ export const initialState: FormState = {
// Maintenance
expectedAdjustments: 'low',
languagesList: ['Deutsch'],
personName: '',
// Timeline
deadline: 'flexible',
// Web App specific
@@ -72,6 +73,12 @@ export const initialState: FormState = {
dontKnows: [],
visualStaging: 'standard',
complexInteractions: 'standard',
// AI generated / Post-processed
briefingSummary: '',
designVision: '',
positionDescriptions: {},
taxId: '',
sitemap: [],
};
export const PAGE_SAMPLES = [
@@ -161,6 +168,7 @@ export const DEADLINE_LABELS: Record<string, string> = {
};
export const ASSET_LABELS: Record<string, string> = {
existing_website: 'Bestehende Website',
logo: 'Logo',
styleguide: 'Styleguide',
content_concept: 'Inhalts-Konzept',
@@ -207,3 +215,12 @@ export const SOCIAL_LABELS: Record<string, string> = {
tiktok: 'TikTok',
youtube: 'YouTube'
};
export const PAGE_LABELS: Record<string, string> = {
Home: 'Startseite',
About: 'Über uns',
Services: 'Leistungen',
Contact: 'Kontakt',
Landing: 'Landingpage',
Legal: 'Impressum & Datenschutz'
};

View File

@@ -56,6 +56,18 @@ export interface FormState {
dontKnows: string[];
visualStaging: string;
complexInteractions: string;
gridDontKnows?: Record<string, string>;
briefingSummary?: string;
companyAddress?: string;
companyPhone?: string;
personName?: string;
taxId?: string;
designVision?: string;
positionDescriptions?: Record<string, string>;
sitemap?: {
category: string;
pages: { title: string; desc: string }[];
}[];
}
export interface Position {

96
src/utils/cache/file-adapter.ts vendored Normal file
View File

@@ -0,0 +1,96 @@
import type { CacheAdapter, CacheConfig } from './interfaces';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { existsSync, mkdirSync } from 'node:fs';
import * as crypto from 'node:crypto';
export class FileCacheAdapter implements CacheAdapter {
private cacheDir: string;
private prefix: string;
private defaultTTL: number;
constructor(config?: CacheConfig & { cacheDir?: string }) {
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
this.prefix = config?.prefix || '';
this.defaultTTL = config?.defaultTTL || 3600;
if (!existsSync(this.cacheDir)) {
fs.mkdir(this.cacheDir, { recursive: true }).catch(err => {
console.error(`Failed to create cache directory: ${this.cacheDir}`, err);
});
}
}
private sanitize(key: string): string {
const clean = key.replace(/[^a-z0-9]/gi, '_');
if (clean.length > 64) {
return crypto.createHash('md5').update(key).digest('hex');
}
return clean;
}
private getFilePath(key: string): string {
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
return path.join(this.cacheDir, `${safeKey}.json`);
}
async get<T>(key: string): Promise<T | null> {
const filePath = this.getFilePath(key);
try {
if (!existsSync(filePath)) return null;
const content = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(content);
if (data.expiry && Date.now() > data.expiry) {
await this.del(key);
return null;
}
return data.value;
} catch (error) {
return null;
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const filePath = this.getFilePath(key);
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
const data = {
value,
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
updatedAt: new Date().toISOString()
};
try {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (error) {
console.error(`Failed to write cache file: ${filePath}`, error);
}
}
async del(key: string): Promise<void> {
const filePath = this.getFilePath(key);
try {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
} catch (error) {
// Ignore
}
}
async clear(): Promise<void> {
try {
if (existsSync(this.cacheDir)) {
const files = await fs.readdir(this.cacheDir);
for (const file of files) {
if (file.endsWith('.json')) {
await fs.unlink(path.join(this.cacheDir, file));
}
}
}
} catch (error) {
// Ignore
}
}
}