feat(cloner): add cloner-library and finalize pdf-library rename
This commit is contained in:
82
.agent/workflows/cms-workflow.md
Normal file
82
.agent/workflows/cms-workflow.md
Normal 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
2
.env
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
Qy-qP
|
ofRLH
|
||||||
File diff suppressed because one or more lines are too long
@@ -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: '',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
packages/cloner-library/build.mjs
Normal file
41
packages/cloner-library/build.mjs
Normal 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);
|
||||||
|
});
|
||||||
30
packages/cloner-library/package.json
Normal file
30
packages/cloner-library/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
93
packages/cloner-library/src/AssetManager.ts
Normal file
93
packages/cloner-library/src/AssetManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
184
packages/cloner-library/src/PageCloner.ts
Normal file
184
packages/cloner-library/src/PageCloner.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
packages/cloner-library/src/WebsiteCloner.ts
Normal file
123
packages/cloner-library/src/WebsiteCloner.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/cloner-library/src/index.ts
Normal file
3
packages/cloner-library/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./AssetManager.js";
|
||||||
|
export * from "./PageCloner.js";
|
||||||
|
export * from "./WebsiteCloner.js";
|
||||||
17
packages/cloner-library/tsconfig.json
Normal file
17
packages/cloner-library/tsconfig.json
Normal 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.
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1203
packages/cms-infra/schema/current.yaml
Normal file
1203
packages/cms-infra/schema/current.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
872
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
56
scripts/cms-reconcile.sh
Executable 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!"
|
||||||
Reference in New Issue
Block a user