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 # Project
IMAGE_TAG=latest IMAGE_TAG=v1.8.0
PROJECT_NAME=at-mintel PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20 PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582 GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "acquisition", "name": "acquisition",
"version": "1.7.12", "version": "1.8.0",
"type": "module", "type": "module",
"directus:extension": { "directus:extension": {
"type": "endpoint", "type": "endpoint",
@@ -24,4 +24,4 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
} }
} }

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "people-manager", "name": "people-manager",
"description": "Custom High-Fidelity People Management for Directus", "description": "Custom High-Fidelity People Management for Directus",
"icon": "person", "icon": "person",
"version": "1.7.12", "version": "1.8.0",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "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-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon> </v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-text-overflow :text="lead.company_name" /> <v-text-overflow :text="getCompanyName(lead)" />
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
</v-list> </v-list>
@@ -50,7 +50,7 @@
<template v-else> <template v-else>
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<h1 class="title">{{ selectedLead.company_name }}</h1> <h1 class="title">{{ getCompanyName(selectedLead) }}</h1>
<p class="subtitle"> <p class="subtitle">
<v-icon name="language" x-small /> <v-icon name="language" x-small />
<a :href="selectedLead.website_url" target="_blank" class="url-link"> <a :href="selectedLead.website_url" target="_blank" class="url-link">
@@ -156,7 +156,15 @@
<div class="drawer-content"> <div class="drawer-content">
<div class="form-section"> <div class="form-section">
<div class="field"> <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 /> <v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
</div> </div>
<div class="field"> <div class="field">
@@ -208,17 +216,26 @@ const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null); const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({ const newLead = ref({
company_name: '', company_name: '', // Legacy
company: null,
website_url: '', website_url: '',
contact_name: '', contact_name: '', // Legacy
contact_email: '', contact_email: '', // Legacy
contact_person: null, contact_person: null,
briefing: '', briefing: '',
status: 'new' status: 'new'
}); });
const companies = ref<any[]>([]);
const people = ref<any[]>([]); const people = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
const peopleOptions = computed(() => const peopleOptions = computed(() =>
people.value.map(p => ({ people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`, 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); const person = people.value.find(p => p.id === id);
return person ? `${person.first_name} ${person.last_name}` : 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)); const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
onMounted(fetchLeads); onMounted(fetchData);
async function fetchLeads() { async function fetchData() {
const [leadsResp, peopleResp] = await Promise.all([ const [leadsResp, peopleResp, companiesResp] = await Promise.all([
api.get('/items/leads', { params: { sort: '-date_created' } }), api.get('/items/leads', {
api.get('/items/people', { params: { sort: 'last_name' } }) 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; leads.value = leadsResp.data.data;
people.value = peopleResp.data.data; people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) { if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id; selectedLeadId.value = leads.value[0].id;
} }
} }
async function fetchLeads() {
await fetchData();
}
function selectLead(id: string) { function selectLead(id: string) {
selectedLeadId.value = id; selectedLeadId.value = id;
} }
@@ -318,7 +356,10 @@ function openPdf() {
} }
async function saveLead() { 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; savingLead.value = true;
try { try {
const payload = { const payload = {
@@ -332,6 +373,7 @@ async function saveLead() {
selectedLeadId.value = payload.id; selectedLeadId.value = payload.id;
newLead.value = { newLead.value = {
company_name: '', company_name: '',
company: null,
website_url: '', website_url: '',
contact_name: '', contact_name: '',
contact_email: '', contact_email: '',

View File

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

View File

@@ -1,5 +1,5 @@
import { defineEndpoint } from "@directus/extensions-sdk"; 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 { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
import { createElement } from "react"; import { createElement } from "react";
import * as path from "path"; 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) => { router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params; const { id } = req.params;
const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { 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 }); const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try { 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" }); if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email; let recipientEmail = lead.contact_email;
let companyName = lead.company_name; let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) { if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person); recipientEmail = lead.contact_person.email || recipientEmail;
if (person && person.email) {
recipientEmail = person.email; if (lead.contact_person.company) {
companyName = person.company || lead.company_name; 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 { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { 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 }); const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try { 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" }); if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email; let recipientEmail = lead.contact_email;
let companyName = lead.company_name; let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) { if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person); recipientEmail = lead.contact_person.email || recipientEmail;
if (person && person.email) {
recipientEmail = person.email; if (lead.contact_person.company) {
companyName = person.company || lead.company_name; 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", "up": "npm run build:extensions && docker compose up -d",
"down": "docker compose down", "down": "docker compose down",
"logs": "docker compose logs -f", "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" /> <v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div> </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" /> <v-divider v-if="isEditingEmployee" />
<div v-if="isEditingEmployee" class="field"> <div v-if="isEditingEmployee" class="field">
@@ -158,7 +167,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'; import { ref, onMounted, nextTick, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
const api = useApi(); const api = useApi();
@@ -183,6 +192,7 @@ const employeeForm = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
person: null,
temporary_password: '' temporary_password: ''
}); });
@@ -192,14 +202,22 @@ const tableHeaders = [
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true } { text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
]; ];
async function fetchCompanies() { const people = ref<any[]>([]);
const res = await api.get('/items/companies', {
params: { const peopleOptions = computed(() =>
fields: ['id', 'name'], people.value.map(p => ({
sort: 'name', text: `${p.first_name} ${p.last_name} (${p.email})`,
}, value: p.id
}); }))
companies.value = res.data.data; );
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) { async function selectCompany(company: any) {
@@ -209,7 +227,7 @@ async function selectCompany(company: any) {
const res = await api.get('/items/client_users', { const res = await api.get('/items/client_users', {
params: { params: {
filter: { company: { _eq: company.id } }, filter: { company: { _eq: company.id } },
fields: ['*'], fields: ['*', 'person.*'],
sort: 'first_name', sort: 'first_name',
}, },
}); });
@@ -273,6 +291,7 @@ async function openEditEmployee(item: any) {
first_name: item.first_name || '', first_name: item.first_name || '',
last_name: item.last_name || '', last_name: item.last_name || '',
email: item.email || '', email: item.email || '',
person: item.person?.id || item.person || null,
temporary_password: item.temporary_password || '' temporary_password: item.temporary_password || ''
}; };
isEditingEmployee.value = true; isEditingEmployee.value = true;
@@ -288,7 +307,8 @@ async function saveEmployee() {
await api.patch(`/items/client_users/${employeeForm.value.id}`, { await api.patch(`/items/client_users/${employeeForm.value.id}`, {
first_name: employeeForm.value.first_name, first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_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!' }; notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else { } else {
@@ -296,7 +316,8 @@ async function saveEmployee() {
first_name: employeeForm.value.first_name, first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name, last_name: employeeForm.value.last_name,
email: employeeForm.value.email, email: employeeForm.value.email,
company: selectedCompany.value.id company: selectedCompany.value.id,
person: employeeForm.value.person
}); });
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' }; notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
} }
@@ -343,7 +364,7 @@ function formatDate(dateStr: string) {
} }
onMounted(() => { onMounted(() => {
fetchCompanies(); fetchData();
}); });
</script> </script>

View File

@@ -78,7 +78,7 @@
<div class="card-text">{{ item.text }}</div> <div class="card-text">{{ item.text }}</div>
<footer class="card-footer"> <footer class="card-footer">
<div class="meta-tags"> <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 /> <v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div> </div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small /> <v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
@@ -142,7 +142,8 @@
<TransitionGroup name="thread-list"> <TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble"> <div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header"> <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> <span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header> </header>
<div class="reply-text">{{ reply.text }}</div> <div class="reply-text">{{ reply.text }}</div>
@@ -168,8 +169,12 @@
<v-card-title>Context</v-card-title> <v-card-title>Context</v-card-title>
<v-card-text class="meta-list"> <v-card-text class="meta-list">
<div class="meta-item"> <div class="meta-item">
<label><v-icon name="public" x-small /> Website</label> <label><v-icon name="business" x-small /> Organisation / Firma</label>
<strong>{{ selectedItem.project }}</strong> <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>
<div class="meta-item"> <div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label> <label><v-icon name="link" x-small /> Source Path</label>
@@ -238,13 +243,14 @@ const statusOptions = [
]; ];
const projects = computed(() => { 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(); return Array.from(projSet).sort();
}); });
const filteredItems = computed(() => { const filteredItems = computed(() => {
return items.value.filter(item => { 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 status = item.status || 'open';
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value; const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
return matchProject && matchStatus; return matchProject && matchStatus;
@@ -258,7 +264,8 @@ async function fetchData() {
const response = await api.get('/items/visual_feedback', { const response = await api.get('/items/visual_feedback', {
params: { params: {
sort: '-date_created,-id', sort: '-date_created,-id',
limit: 300 limit: 300,
fields: ['*', 'company.*', 'person.*']
} }
}); });
items.value = response.data.data; items.value = response.data.data;
@@ -278,7 +285,8 @@ async function selectItem(item) {
const response = await api.get('/items/visual_feedback_comments', { const response = await api.get('/items/visual_feedback_comments', {
params: { params: {
filter: { feedback_id: { _eq: item.id } }, filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id' sort: '-date_created,-id',
fields: ['*', 'person.*']
} }
}); });
comments.value = response.data.data; 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="details-grid">
<div class="detail-item"> <div class="detail-item">
<span class="label">Organisation</span> <span class="label">Organisation / Firma</span>
<p class="value">{{ selectedPerson.company || '---' }}</p> <p class="value">{{ getCompanyName(selectedPerson) }}</p>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="label">Telefon</span> <span class="label">Telefon</span>
@@ -98,8 +98,16 @@
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" /> <v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
</div> </div>
<div class="field"> <div class="field">
<span class="label">Organisation / Firma</span> <span class="label">Zentrale Firma</span>
<v-input v-model="form.company" placeholder="z.B. Mintel" /> <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>
<div class="field"> <div class="field">
<span class="label">Telefon</span> <span class="label">Telefon</span>
@@ -119,11 +127,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
const api = useApi(); const api = useApi();
const people = ref([]); const people = ref([]);
const companies = ref([]);
const selectedPerson = ref(null); const selectedPerson = ref(null);
const feedback = ref(null); const feedback = ref(null);
const saving = ref(false); const saving = ref(false);
@@ -135,18 +144,43 @@ const form = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
company: '', company: null,
company_name: '',
phone: '' 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 { try {
const response = await api.get('/items/people', { const [peopleResp, companiesResp] = await Promise.all([
params: { sort: 'last_name' } api.get('/items/people', {
}); params: {
people.ref = response.data.data; sort: 'last_name',
fields: '*.*'
}
}),
api.get('/items/companies', {
params: { sort: 'name' }
})
]);
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
} catch (error) { } 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() { function openCreateDrawer() {
isEditing.value = false; 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; drawerActive.value = true;
} }
function openEditDrawer() { function openEditDrawer() {
isEditing.value = true; 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; drawerActive.value = true;
} }
@@ -182,9 +242,9 @@ async function savePerson() {
feedback.value = { type: 'success', message: 'Person angelegt!' }; feedback.value = { type: 'success', message: 'Person angelegt!' };
} }
drawerActive.value = false; drawerActive.value = false;
await fetchPeople(); await fetchData();
if (isEditing.value) { if (isEditing.value) {
selectedPerson.value = form.value; selectedPerson.value = people.value.find(p => p.id === form.value.id);
} }
} catch (error) { } catch (error) {
feedback.value = { type: 'danger', message: error.message }; feedback.value = { type: 'danger', message: error.message };
@@ -200,13 +260,13 @@ async function deletePerson() {
await api.delete(`/items/people/${selectedPerson.value.id}`); await api.delete(`/items/people/${selectedPerson.value.id}`);
feedback.value = { type: 'success', message: 'Person gelöscht.' }; feedback.value = { type: 'success', message: 'Person gelöscht.' };
selectedPerson.value = null; selectedPerson.value = null;
await fetchPeople(); await fetchData();
} catch (error) { } catch (error) {
feedback.value = { type: 'danger', message: error.message }; feedback.value = { type: 'danger', message: error.message };
} }
} }
onMounted(fetchPeople); onMounted(fetchData);
</script> </script>
<style scoped> <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 case $ENV in
local) local)
PROJECT="infra-cms" 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) LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
if [ -z "$LOCAL_CONTAINER" ]; then if [ -z "$LOCAL_CONTAINER" ]; then
@@ -24,6 +27,9 @@ case $ENV in
exit 1 exit 1
fi fi
echo "🧹 Reconciling database metadata..."
./scripts/cms-reconcile.sh
echo "🚀 Applying schema to LOCAL $PROJECT..." echo "🚀 Applying schema to LOCAL $PROJECT..."
docker exec "$LOCAL_CONTAINER" npx directus schema apply -y /directus/schema/snapshot.yaml 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!"