feat(cloner): add cloner-library and finalize pdf-library rename

This commit is contained in:
2026-02-12 21:59:48 +01:00
parent 57ec4d7544
commit 422e4fccba
33 changed files with 5909 additions and 177 deletions

View File

@@ -0,0 +1,82 @@
---
description: How to manage and deploy Directus CMS infrastructure changes.
---
# Directus CMS Infrastructure Workflow
This workflow ensures "Industrial Grade" consistency and stability across local, testing, and production environments for the `at-mintel` Directus CMS.
## 1. Local Development Lifecycle
### Starting the CMS
To start the local Directus instance with extensions:
```bash
cd packages/cms-infra
npm run up
```
### Modifying Schema
1. **Directus UI**: Make your changes directly in the local Directus Admin UI (Collections, Fields, Relations).
2. **Take Snapshot**:
```bash
cd packages/cms-infra
npm run snapshot:local
```
This updates `packages/cms-infra/schema/snapshot.yaml`.
3. **Commit**: Commit the updated `snapshot.yaml`.
## 2. Deploying Schema Changes
### To Local Environment (Reconciliation)
If you pull changes from Git and need to apply them to your local database:
```bash
cd packages/cms-infra
npm run schema:apply:local
```
> [!IMPORTANT]
> This command automatically runs `scripts/cms-reconcile.sh` to prevent "Field already exists" errors by registering database columns in Directus metadata first.
### To Production (Infra)
To deploy the local snapshot to the production server:
```bash
cd packages/cms-infra
npm run schema:apply:infra
```
This script:
1. Syncs built extensions via rsync.
2. Injects the `snapshot.yaml` into the remote container.
3. Runs `directus schema apply`.
4. Restarts Directus to clear the schema cache.
## 3. Data Synchronization
### Pulling from Production
To update your local environment with production data and assets:
```bash
cd packages/cms-infra
npm run sync:pull
```
### Pushing to Production
> [!CAUTION]
> This will overwrite production data. Use with extreme care.
```bash
cd packages/cms-infra
npm run sync:push
```
## 4. Extension Management
When modifying extensions in `packages/*-manager`:
1. Extensions are automatically built and synced when running `npm run up`.
2. To sync manually without restarting the stack:
```bash
cd packages/cms-infra
npm run build:extensions
```
## 5. Troubleshooting "Field already exists"
If `schema:apply` fails with "Field already exists", run:
```bash
./scripts/cms-reconcile.sh
```
This script ensures the database state matches Directus's internal field registry (`directus_fields`).

2
.env
View File

@@ -1,5 +1,5 @@
# Project
IMAGE_TAG=latest
IMAGE_TAG=v1.8.0
PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.7.12",
"version": "1.8.0",
"type": "module",
"keywords": [
"directus",

View File

@@ -1,6 +1,6 @@
{
"name": "acquisition",
"version": "1.7.12",
"version": "1.8.0",
"type": "module",
"directus:extension": {
"type": "endpoint",

View File

@@ -2,7 +2,7 @@
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.7.12",
"version": "1.8.0",
"type": "module",
"keywords": [
"directus",

View File

@@ -2,7 +2,7 @@
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.7.12",
"version": "1.8.0",
"type": "module",
"keywords": [
"directus",

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "people-manager",
"description": "Custom High-Fidelity People Management for Directus",
"icon": "person",
"version": "1.7.12",
"version": "1.8.0",
"type": "module",
"keywords": [
"directus",

View File

@@ -1 +1 @@
Qy-qP
ofRLH

File diff suppressed because one or more lines are too long

View File

@@ -23,7 +23,7 @@
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="lead.company_name" />
<v-text-overflow :text="getCompanyName(lead)" />
</v-list-item-content>
</v-list-item>
</v-list>
@@ -50,7 +50,7 @@
<template v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ selectedLead.company_name }}</h1>
<h1 class="title">{{ getCompanyName(selectedLead) }}</h1>
<p class="subtitle">
<v-icon name="language" x-small />
<a :href="selectedLead.website_url" target="_blank" class="url-link">
@@ -156,7 +156,15 @@
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firma</span>
<span class="label">Organisation / Firma (Zentral)</span>
<v-select
v-model="newLead.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Organisation / Firma (Legacy / Neu)</span>
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
@@ -208,17 +216,26 @@ const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({
company_name: '',
company_name: '', // Legacy
company: null,
website_url: '',
contact_name: '',
contact_email: '',
contact_name: '', // Legacy
contact_email: '', // Legacy
contact_person: null,
briefing: '',
status: 'new'
});
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`,
@@ -226,7 +243,16 @@ const peopleOptions = computed(() =>
}))
);
function getPersonName(id: string) {
function getCompanyName(lead: any) {
if (lead.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || lead.company_name);
}
return lead.company_name;
}
function getPersonName(id: string | any) {
if (!id) return '';
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
const person = people.value.find(p => p.id === id);
return person ? `${person.first_name} ${person.last_name}` : id;
}
@@ -238,20 +264,32 @@ function goToPerson(id: string) {
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
onMounted(fetchLeads);
onMounted(fetchData);
async function fetchLeads() {
const [leadsResp, peopleResp] = await Promise.all([
api.get('/items/leads', { params: { sort: '-date_created' } }),
api.get('/items/people', { params: { sort: 'last_name' } })
async function fetchData() {
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
api.get('/items/leads', {
params: {
sort: '-date_created',
fields: '*.*'
}
}),
api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } })
]);
leads.value = leadsResp.data.data;
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id;
}
}
async function fetchLeads() {
await fetchData();
}
function selectLead(id: string) {
selectedLeadId.value = id;
}
@@ -318,7 +356,10 @@ function openPdf() {
}
async function saveLead() {
if (!newLead.value.company_name) return;
if (!newLead.value.company_name && !newLead.value.company) {
notice.value = { type: 'danger', message: 'Firma oder Firmenname erforderlich.' };
return;
}
savingLead.value = true;
try {
const payload = {
@@ -332,6 +373,7 @@ async function saveLead() {
selectedLeadId.value = payload.id;
newLead.value = {
company_name: '',
company: null,
website_url: '',
contact_name: '',
contact_email: '',

View File

@@ -14,7 +14,7 @@
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/acquisition": "workspace:*",
"@mintel/pdf": "workspace:*",
"@mintel/mail": "workspace:*",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"

View File

@@ -1,5 +1,5 @@
import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/acquisition";
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
import { createElement } from "react";
import * as path from "path";
@@ -39,22 +39,25 @@ export default defineEndpoint((router, { services, env }) => {
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company_name;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
}
}
@@ -119,20 +122,22 @@ export default defineEndpoint((router, { services, env }) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email;
let companyName = lead.company_name;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
}
}

View File

@@ -0,0 +1,41 @@
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const entryPoints = [
resolve(__dirname, 'src/index.ts')
];
try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch (e) { }
console.log(`Building entry point...`);
build({
entryPoints: entryPoints,
bundle: true,
platform: 'node',
target: 'node18',
outdir: resolve(__dirname, 'dist'),
format: 'esm',
loader: {
'.ts': 'ts',
'.js': 'js',
},
external: ["playwright", "crawlee", "axios", "cheerio", "fs", "path", "os", "http", "https", "url", "stream", "util", "child_process"],
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
if (e.errors) {
console.error("Build failed with errors:");
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
} else {
console.error("Build failed:", e);
}
process.exit(1);
});

View File

@@ -0,0 +1,30 @@
{
"name": "@mintel/cloner",
"version": "1.8.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.6.3",
"@types/node": "^22.0.0"
},
"dependencies": {
"playwright": "^1.40.0",
"crawlee": "^3.7.0",
"axios": "^1.6.0",
"cheerio": "^1.0.0-rc.12"
}
}

View File

@@ -0,0 +1,93 @@
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
export interface AssetMap {
[originalUrl: string]: string;
}
export class AssetManager {
private userAgent: string;
constructor(userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") {
this.userAgent = userAgent;
}
public sanitizePath(rawPath: string): string {
return rawPath
.split("/")
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
}
public async downloadFile(url: string, assetsDir: string): Promise<string | null> {
if (url.startsWith("//")) url = `https:${url}`;
if (!url.startsWith("http")) return null;
try {
const u = new URL(url);
const relPath = this.sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
if (fs.existsSync(dest)) return `./assets/${relPath}`;
const res = await axios.get(url, {
responseType: "arraybuffer",
headers: { "User-Agent": this.userAgent },
timeout: 15000,
validateStatus: () => true,
});
if (res.status !== 200) return null;
if (!fs.existsSync(path.dirname(dest)))
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null;
}
}
public async processCssRecursively(
cssContent: string,
cssUrl: string,
assetsDir: string,
urlMap: AssetMap,
depth = 0,
): Promise<string> {
if (depth > 5) return cssContent;
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
let match;
let newContent = cssContent;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
continue;
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await this.downloadFile(absUrl, assetsDir);
if (local) {
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
const rel = path.relative(
path.dirname(this.sanitizePath(cssPath)),
this.sanitizePath(assetPath),
);
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch {
// Ignore
}
}
return newContent;
}
}

View File

@@ -0,0 +1,184 @@
import { chromium, Browser, BrowserContext, Page } from "playwright";
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { AssetManager, AssetMap } from "./AssetManager.js";
export interface PageClonerOptions {
outputDir: string;
userAgent?: string;
}
export class PageCloner {
private options: PageClonerOptions;
private assetManager: AssetManager;
private userAgent: string;
constructor(options: PageClonerOptions) {
this.options = options;
this.userAgent = options.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
this.assetManager = new AssetManager(this.userAgent);
}
public async clone(targetUrl: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domainSlug = urlObj.hostname.replace("www.", "");
const domainDir = path.resolve(this.options.outputDir, domainSlug);
const assetsDir = path.join(domainDir, "assets");
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
if (!pageSlug) pageSlug = "index";
const htmlFilename = `${pageSlug}.html`;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: this.userAgent,
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const urlMap: AssetMap = {};
const foundAssets = new Set<string>();
page.on("response", (response) => {
if (response.status() === 200) {
const url = response.url();
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
foundAssets.add(url);
}
}
});
try {
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
// Scroll Wave
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
});
});
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
await page.waitForTimeout(3000);
// Sanitization
await page.evaluate(() => {
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
if (["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(el.tagName)) return;
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (assetPattern.test(val) || name.includes("src") || name.includes("image")) {
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE") (el as HTMLSourceElement).srcset = val;
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") (el as HTMLMediaElement).src = val;
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none") htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
});
if (document.body) {
document.body.style.setProperty("opacity", "1", "important");
document.body.style.setProperty("visibility", "visible", "important");
}
});
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
let content = await page.content();
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
/url\(["']?([^"'\)]+)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
}
}
for (const url of foundAssets) {
const local = await this.assetManager.downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, { headers: { "User-Agent": this.userAgent } });
const processedCss = await this.assetManager.processCssRecursively(data, url, assetsDir, urlMap);
const relPath = this.assetManager.sanitizePath(new URL(url).hostname + new URL(url).pathname);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch { }
}
}
}
let finalContent = content;
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
}
const commonDirs = ["/wp-content/", "/wp-includes/", "/assets/", "/static/", "/images/"];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`).split(`'${dir}`).join(`'${localDir}`).split(`(${dir}`).join(`(${localDir}`);
}
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`, "gi");
finalContent = finalContent.replace(domainPattern, () => "./");
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, scriptContent) => {
const lower = scriptContent.toLowerCase();
return (lower.includes("google-analytics") || lower.includes("gtag") || lower.includes("fbq") || lower.includes("lazy") || lower.includes("tracker")) ? "" : match;
});
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
}
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
return finalPath;
} finally {
await browser.close();
}
}
}

View File

@@ -0,0 +1,123 @@
import { PlaywrightCrawler, RequestQueue } from 'crawlee';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { execSync } from 'node:child_process';
export interface WebsiteClonerOptions {
baseOutputDir: string;
maxRequestsPerCrawl?: number;
maxConcurrency?: number;
}
export class WebsiteCloner {
private options: WebsiteClonerOptions;
constructor(options: WebsiteClonerOptions) {
this.options = {
maxRequestsPerCrawl: 100,
maxConcurrency: 3,
...options
};
}
public async clone(targetUrl: string, outputDirName?: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
if (fs.existsSync(baseOutputDir)) {
fs.rmSync(baseOutputDir, { recursive: true, force: true });
}
fs.mkdirSync(baseOutputDir, { recursive: true });
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
console.log(`📂 Output: ${baseOutputDir}`);
const requestQueue = await RequestQueue.open();
await requestQueue.addRequest({ url: targetUrl });
const crawler = new PlaywrightCrawler({
requestQueue,
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
maxConcurrency: this.options.maxConcurrency,
async requestHandler({ request, enqueueLinks, log }) {
const url = request.url;
log.info(`Capturing ${url}...`);
const u = new URL(url);
let relPath = u.pathname;
if (relPath === '/' || relPath === '') relPath = '/index.html';
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html';
if (relPath.startsWith('/')) relPath = relPath.substring(1);
const fullPath = path.join(baseOutputDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
try {
// Note: This assumes single-file-cli is available in the environment
execSync(`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`, {
stdio: 'inherit'
});
} catch (e) {
log.error(`Failed to capture ${url} with SingleFile`);
}
await enqueueLinks({
strategy: 'same-domain',
transformRequestFunction: (req) => {
if (/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(req.url)) return false;
return req;
}
});
},
});
await crawler.run();
console.log('🔗 Rewriting internal links for offline navigation...');
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html'));
for (const file of allFiles) {
let content = fs.readFileSync(file, 'utf8');
const fileRelToRoot = path.relative(baseOutputDir, file);
content = content.replace(/href="([^"]+)"/g, (match, href) => {
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) {
try {
const linkUrl = new URL(href, targetUrl);
if (linkUrl.hostname === domain) {
let linkPath = linkUrl.pathname;
if (linkPath === '/' || linkPath === '') linkPath = '/index.html';
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html';
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1);
const relativeLink = path.relative(path.dirname(fileRelToRoot), linkPath);
return `href="${relativeLink}"`;
}
} catch (e) { }
}
return match;
});
fs.writeFileSync(file, content);
}
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
return baseOutputDir;
}
private getFiles(dir: string, fileList: string[] = []) {
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) {
this.getFiles(name, fileList);
} else {
fileList.push(name);
}
}
return fileList;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./AssetManager.js";
export * from "./PageCloner.js";
export * from "./WebsiteCloner.js";

View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": false
},
"include": [
"src/**/*"
]
}

Binary file not shown.

View File

@@ -7,6 +7,11 @@
"up": "npm run build:extensions && docker compose up -d",
"down": "docker compose down",
"logs": "docker compose logs -f",
"build:extensions": "../../scripts/sync-extensions.sh"
"build:extensions": "../../scripts/sync-extensions.sh",
"schema:apply:local": "../../scripts/cms-apply.sh local",
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
"snapshot:local": "../../scripts/cms-snapshot.sh",
"sync:push": "../../scripts/sync-directus.sh push infra",
"sync:pull": "../../scripts/sync-directus.sh pull infra"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,15 @@
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span>
<v-select
v-model="employeeForm.person"
:items="peopleOptions"
placeholder="Person aus dem People Manager auswählen..."
/>
</div>
<v-divider v-if="isEditingEmployee" />
<div v-if="isEditingEmployee" class="field">
@@ -158,7 +167,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue';
import { ref, onMounted, nextTick, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
@@ -183,6 +192,7 @@ const employeeForm = ref({
first_name: '',
last_name: '',
email: '',
person: null,
temporary_password: ''
});
@@ -192,14 +202,22 @@ const tableHeaders = [
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
];
async function fetchCompanies() {
const res = await api.get('/items/companies', {
params: {
fields: ['id', 'name'],
sort: 'name',
},
});
companies.value = res.data.data;
const people = ref<any[]>([]);
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name} (${p.email})`,
value: p.id
}))
);
async function fetchData() {
const [companiesResp, peopleResp] = await Promise.all([
api.get('/items/companies', { params: { sort: 'name', fields: ['id', 'name'] } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
companies.value = companiesResp.data.data;
people.value = peopleResp.data.data;
}
async function selectCompany(company: any) {
@@ -209,7 +227,7 @@ async function selectCompany(company: any) {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: company.id } },
fields: ['*'],
fields: ['*', 'person.*'],
sort: 'first_name',
},
});
@@ -273,6 +291,7 @@ async function openEditEmployee(item: any) {
first_name: item.first_name || '',
last_name: item.last_name || '',
email: item.email || '',
person: item.person?.id || item.person || null,
temporary_password: item.temporary_password || ''
};
isEditingEmployee.value = true;
@@ -288,7 +307,8 @@ async function saveEmployee() {
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email
email: employeeForm.value.email,
person: employeeForm.value.person
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
@@ -296,7 +316,8 @@ async function saveEmployee() {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id
company: selectedCompany.value.id,
person: employeeForm.value.person
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
@@ -343,7 +364,7 @@ function formatDate(dateStr: string) {
}
onMounted(() => {
fetchCompanies();
fetchData();
});
</script>

View File

@@ -78,7 +78,7 @@
<div class="card-text">{{ item.text }}</div>
<footer class="card-footer">
<div class="meta-tags">
<v-chip x-small outline>{{ item.project }}</v-chip>
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
@@ -142,7 +142,8 @@
<TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header">
<span class="reply-user">{{ reply.user_name }}</span>
<span class="reply-user">{{ reply.user_name || 'System' }}</span>
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header>
<div class="reply-text">{{ reply.text }}</div>
@@ -168,8 +169,12 @@
<v-card-title>Context</v-card-title>
<v-card-text class="meta-list">
<div class="meta-item">
<label><v-icon name="public" x-small /> Website</label>
<strong>{{ selectedItem.project }}</strong>
<label><v-icon name="business" x-small /> Organisation / Firma</label>
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
</div>
<div v-if="selectedItem.person" class="meta-item">
<label><v-icon name="person" x-small /> Zentrale Person</label>
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
</div>
<div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label>
@@ -238,13 +243,14 @@ const statusOptions = [
];
const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
return Array.from(projSet).sort();
});
const filteredItems = computed(() => {
return items.value.filter(item => {
const matchProject = currentProject.value === 'all' || item.project === currentProject.value;
const projectName = item.company?.name || item.project;
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
const status = item.status || 'open';
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
return matchProject && matchStatus;
@@ -258,7 +264,8 @@ async function fetchData() {
const response = await api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300
limit: 300,
fields: ['*', 'company.*', 'person.*']
}
});
items.value = response.data.data;
@@ -278,7 +285,8 @@ async function selectItem(item) {
const response = await api.get('/items/visual_feedback_comments', {
params: {
filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id'
sort: '-date_created,-id',
fields: ['*', 'person.*']
}
});
comments.value = response.data.data;

File diff suppressed because one or more lines are too long

View File

@@ -64,8 +64,8 @@
<div class="details-grid">
<div class="detail-item">
<span class="label">Organisation</span>
<p class="value">{{ selectedPerson.company || '---' }}</p>
<span class="label">Organisation / Firma</span>
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
</div>
<div class="detail-item">
<span class="label">Telefon</span>
@@ -98,8 +98,16 @@
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
</div>
<div class="field">
<span class="label">Organisation / Firma</span>
<v-input v-model="form.company" placeholder="z.B. Mintel" />
<span class="label">Zentrale Firma</span>
<v-select
v-model="form.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Firma (Legacy / Neu)</span>
<v-input v-model="form.company_name" placeholder="z.B. Mintel" />
</div>
<div class="field">
<span class="label">Telefon</span>
@@ -119,11 +127,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const people = ref([]);
const companies = ref([]);
const selectedPerson = ref(null);
const feedback = ref(null);
const saving = ref(false);
@@ -135,18 +144,43 @@ const form = ref({
first_name: '',
last_name: '',
email: '',
company: '',
company: null,
company_name: '',
phone: ''
});
async function fetchPeople() {
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
function getCompanyName(person: any) {
if (!person) return '---';
if (person.company) {
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || person.company_name);
}
return person.company_name || '---';
}
async function fetchData() {
try {
const response = await api.get('/items/people', {
params: { sort: 'last_name' }
});
people.ref = response.data.data;
const [peopleResp, companiesResp] = await Promise.all([
api.get('/items/people', {
params: {
sort: 'last_name',
fields: '*.*'
}
}),
api.get('/items/companies', {
params: { sort: 'name' }
})
]);
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
} catch (error) {
console.error('Failed to fetch people:', error);
console.error('Failed to fetch data:', error);
}
}
@@ -156,13 +190,39 @@ function selectPerson(person) {
function openCreateDrawer() {
isEditing.value = false;
form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' };
form.value = {
id: null,
first_name: '',
last_name: '',
email: '',
company: null,
company_name: '',
phone: ''
};
drawerActive.value = true;
}
function openEditDrawer() {
isEditing.value = true;
form.value = { ...selectedPerson.value };
const person = selectedPerson.value;
let companyId = null;
let companyName = person.company_name || '';
if (person.company) {
if (typeof person.company === 'object') {
companyId = person.company.id;
} else if (person.company.length === 36) { // Assume UUID
companyId = person.company;
} else {
companyName = person.company;
}
}
form.value = {
...person,
company: companyId,
company_name: companyName
};
drawerActive.value = true;
}
@@ -182,9 +242,9 @@ async function savePerson() {
feedback.value = { type: 'success', message: 'Person angelegt!' };
}
drawerActive.value = false;
await fetchPeople();
await fetchData();
if (isEditing.value) {
selectedPerson.value = form.value;
selectedPerson.value = people.value.find(p => p.id === form.value.id);
}
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
@@ -200,13 +260,13 @@ async function deletePerson() {
await api.delete(`/items/people/${selectedPerson.value.id}`);
feedback.value = { type: 'success', message: 'Person gelöscht.' };
selectedPerson.value = null;
await fetchPeople();
await fetchData();
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
}
}
onMounted(fetchPeople);
onMounted(fetchData);
</script>
<style scoped>

872
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,10 @@ fi
case $ENV in
local)
PROJECT="infra-cms"
CMD_PREFIX="docker-compose -f packages/cms-infra/docker-compose.yml"
# Derive monorepo root
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$( dirname "$SCRIPT_DIR" )"
CMD_PREFIX="docker compose -f $ROOT_DIR/packages/cms-infra/docker-compose.yml"
LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
if [ -z "$LOCAL_CONTAINER" ]; then
@@ -24,6 +27,9 @@ case $ENV in
exit 1
fi
echo "🧹 Reconciling database metadata..."
./scripts/cms-reconcile.sh
echo "🚀 Applying schema to LOCAL $PROJECT..."
docker exec "$LOCAL_CONTAINER" npx directus schema apply -y /directus/schema/snapshot.yaml
;;

56
scripts/cms-reconcile.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
# Configuration
# Derive monorepo root
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$( dirname "$SCRIPT_DIR" )"
DB_PATH="$ROOT_DIR/packages/cms-infra/database/data.db"
if [ ! -f "$DB_PATH" ]; then
echo "❌ Database not found at $DB_PATH"
exit 1
fi
reconcile_table() {
local TABLE=$1
echo "🔍 Reconciling table: $TABLE"
# 1. Get all columns from SQLite
COLUMNS=$(sqlite3 "$DB_PATH" "PRAGMA table_info($TABLE);" | cut -d'|' -f2)
for COL in $COLUMNS; do
# Skip system columns if needed, but usually it's safer to just check if they exist in Directus
# 2. Check if field exists in directus_fields
EXISTS=$(sqlite3 "$DB_PATH" "SELECT count(*) FROM directus_fields WHERE collection = '$TABLE' AND field = '$COL';")
if [ "$EXISTS" -eq 0 ]; then
echo " Registering missing field: $TABLE.$COL"
# Determine a basic interface based on column name or type (very simplified)
INTERFACE="input"
case $COL in
*id) INTERFACE="numeric" ;;
*text) INTERFACE="input-multiline" ;;
company|person|user_created|user_updated|feedback_id) INTERFACE="select-dropdown-m2o" ;;
date_created|date_updated) INTERFACE="datetime" ;;
screenshot|logo) INTERFACE="file" ;;
status|type) INTERFACE="select-dropdown" ;;
esac
sqlite3 "$DB_PATH" "INSERT INTO directus_fields (collection, field, interface) VALUES ('$TABLE', '$COL', '$INTERFACE');"
else
echo "✅ Field already registered: $TABLE.$COL"
fi
done
}
# Run for known problematic tables
reconcile_table "visual_feedback"
reconcile_table "visual_feedback_comments"
reconcile_table "people"
reconcile_table "leads"
reconcile_table "client_users"
reconcile_table "companies"
echo "✨ SQL Reconciliation complete!"