Add README, customization snapshot, and snapshot/restore tooling

Provisioning now restores all GUI customizations on reset+reprovision:

- create_pod_espocrm.sh: deploy the version-controlled espocrm-custom/ tree
  (CTag entity, layouts, i18n, clientDefs, custom views, custom CSS) into the
  pod, then chown www-data and rebuild. Replaces the earlier inline CSS-only
  step. Adds a live-phase cache rebuild so customizations and the client
  cacheTimestamp are refreshed on every run.
- espocrm-custom/: snapshot of custom/ and client/custom/ (source of truth).
- snapshot_espocrm_custom.sh: refresh the snapshot from a running pod.
- readme.md: usage, first-time host setup, image-update and reset workflows.
- Include the task/instruction notes and plan.md for reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mkt
2026-06-06 16:51:02 +02:00
parent 79ea236da0
commit cebe829dcd
79 changed files with 2064 additions and 45 deletions

View File

@@ -25,6 +25,13 @@ BIND_DIR="$HOME/.local/share/$POD_NAME"
DB_DATA_DIR="$BIND_DIR/mariadb" # -> /var/lib/mysql
ESPO_DATA_DIR="$BIND_DIR/espocrm" # -> /var/www/html
# GUI customizations snapshot, version-controlled in this repo next to the script.
# Deployed into /var/www/html on every provisioning run so a reset+reprovision
# restores all customizations (CTag entity, layouts, i18n, clientDefs, custom
# views, custom CSS, ...). Refresh it after GUI changes with snapshot_espocrm_custom.sh.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CUSTOM_SRC="$SCRIPT_DIR/espocrm-custom" # contains custom/ and client/custom/
# systemd --user paths
USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
WEB_UNIT_FILE="$USER_SYSTEMD_DIR/container-${WEB_CTR}.service"
@@ -103,50 +110,22 @@ for i in $(seq 1 90); do
done
echo "EspoCRM responded with HTTP $code (rc=$?)"
# --- GUI customization: variable column widths for Markdown tables -------------
# Core CSS forces `table-layout: fixed; width: 100%` on `.complex-text table`,
# giving every column an equal width. Register a custom CSS file via cssList so
# those tables size columns to content (table-layout: auto). Created by this
# provisioning script (on the persisted bind-mount) so it survives re-runs; the
# rebuild step below refreshes the cached cssList metadata.
podman exec "$WEB_CTR" sh -lc 'mkdir -p /var/www/html/client/custom/css /var/www/html/custom/Espo/Custom/Resources/metadata/app'
podman exec -i "$WEB_CTR" sh -c 'cat > /var/www/html/client/custom/css/table-var-col.css' <<'CSSEOF'
/**
* Variable column widths for Markdown tables in text fields.
*
* EspoCRM core CSS forces `table-layout: fixed; width: 100%` on
* `.complex-text table`, which makes all columns equally wide.
* `auto` lets columns size to content instead.
*
* Registered via custom/Espo/Custom/Resources/metadata/app/client.json (cssList).
*/
.complex-text table {
table-layout: auto !important;
width: auto !important;
max-width: 100% !important;
}
CSSEOF
echo "Wrote client/custom/css/table-var-col.css (rc=$?)"
# Merge the CSS path into cssList (idempotent; preserves other keys/entries and
# keeps "__APPEND__" first). Uses the container's php so no host jq is required.
podman exec "$WEB_CTR" php -r '
$file = "/var/www/html/custom/Espo/Custom/Resources/metadata/app/client.json";
$css = "client/custom/css/table-var-col.css";
$data = is_file($file) ? json_decode(file_get_contents($file), true) : [];
if (!is_array($data)) { $data = []; }
$list = (isset($data["cssList"]) && is_array($data["cssList"])) ? $data["cssList"] : [];
$list = array_values(array_filter($list, function ($x) use ($css) { return $x !== "__APPEND__" && $x !== $css; }));
array_unshift($list, "__APPEND__");
$list[] = $css;
$data["cssList"] = $list;
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
'
echo "Registered table-var-col.css in cssList (rc=$?)"
# Match ownership of the other EspoCRM customization files (webserver user).
podman exec "$WEB_CTR" chown -R www-data:www-data /var/www/html/client/custom /var/www/html/custom/Espo/Custom/Resources/metadata
echo "Fixed ownership of customizations (rc=$?)"
# --- Deploy all GUI customizations from the repo snapshot ---------------------
# Copies the version-controlled custom/ and client/custom/ trees into the pod so
# a reset+reprovision restores everything: the CTag entity (controller, scopes,
# recordDefs, i18n), layouts, clientDefs (Tags +button removed, row-actions =
# Unlink only), the signature image-button view, and the table-var-col CSS.
# Extract as container-root (--no-same-owner), then chown to the webserver user.
# The rebuild step below refreshes the cached metadata (entityDefs, cssList, ...).
if [ -d "$CUSTOM_SRC" ]; then
tar -C "$CUSTOM_SRC" -cf - custom client/custom \
| podman exec -i "$WEB_CTR" tar -C /var/www/html -xf - --no-same-owner
echo "Deployed GUI customizations from $CUSTOM_SRC (rc=$?)"
podman exec "$WEB_CTR" chown -R www-data:www-data /var/www/html/custom /var/www/html/client/custom
echo "Fixed ownership of customizations (rc=$?)"
else
echo "WARNING: customization snapshot not found at $CUSTOM_SRC; skipping deploy."
fi
# Rebuild EspoCRM cache so customizations on the persisted bind-mount take effect
# and the client cacheTimestamp is bumped (forces browsers to reload custom views,

View File

@@ -0,0 +1,105 @@
### Goal
Restore the image button (`espoImage`) in the EspoCRM **signature** editor toolbar. The signature field uses the core view `views/preferences/fields/signature`, whose `setupToolbar()` overrides the toolbar to `[["style",[…]],["color",["color"]],["height",["height"]],["table",["espoLink"]],["misc",[<codeview>,"fullscreen"]]]` — i.e. it drops the `espoImage` button that the generic wysiwyg field has. We want the image button back **only for the signature field**, without changing anything else.
Do **not** touch other fields, ACLs, the generic wysiwyg editor (email body / templates already have the image button), or any data.
### Environment
- EspoCRM **9.3.8** (Apache variant) runs as a **rootless Podman pod** `espocrm_pod` on this server. Web container is expected to be `espocrm_ctr` (confirm with `podman ps`).
- App root inside the web container: `/var/www/html`, runs as user **`www-data`**.
- Customizations live on the persisted `/var/www/html` bind-mount, same locations as the S47 task:
- Backend metadata: `/var/www/html/custom/Espo/Custom/Resources/metadata/…`
- Frontend custom views: `/var/www/html/client/custom/src/…` (referenced with the `custom:` prefix).
### Mechanism (from 9.3.8 source — confirm against this instance before relying on it)
- The signature field view `client/src/views/preferences/fields/signature.js` extends the wysiwyg field and **overrides** `setupToolbar()`. Its `this.toolbar` contains a group `['table', ['espoLink']]` — the link button but **not** `espoImage`.
- The generic wysiwyg field `client/src/views/fields/wysiwyg.js` registers the upload callback in `enableWysiwygMode()`: `onImageUpload``uploadInlineAttachment(file)` → creates an `Attachment` (`role: 'Inline Attachment'`) and inserts `<img src="?entryPoint=attachment&id=…">`. This callback is present regardless of the toolbar, so as soon as the `espoImage` button is in the toolbar, its upload path produces an attachment.
- `espoImage` is a registered summernote plugin (`client/src/helpers/misc/summernote-custom.js`): its button invokes `espoImage.show` → modal `views/wysiwyg/modals/insert-image`, whose **upload** path runs through `insertImagesOrCallback` → the `onImageUpload` callback above. (The modal's **URL** path inserts a plain remote `<img>` instead — not what we want.)
Verify before relying on it:
```
podman exec espocrm_ctr sh -lc "grep -n 'setupToolbar\|espoLink\|espoImage\|table' /var/www/html/client/src/views/preferences/fields/signature.js"
podman exec espocrm_ctr sh -lc "grep -n 'onImageUpload\|uploadInlineAttachment\|entryPoint=attachment' /var/www/html/client/src/views/fields/wysiwyg.js"
```
You should see the reduced toolbar with `['table', ['espoLink']]` and the `onImageUpload`→attachment path. If the structure differs in this build, adjust the toolbar-patch below to match the real group names.
### Steps
1. **Confirm container.** `podman ps` → identify the web container (expected `espocrm_ctr`).
2. **Create the custom signature field view** on the persisted mount:
`/var/www/html/client/custom/src/views/preferences/fields/signature.js`
```js
define('custom:views/preferences/fields/signature',
['views/preferences/fields/signature'], (SignatureFieldView) => {
return class extends SignatureFieldView {
setupToolbar() {
// Build the reduced signature toolbar first …
super.setupToolbar();
// … then re-add the image button next to the link button.
this.toolbar = (this.toolbar || []).map(group => {
if (Array.isArray(group) && group[0] === 'table') {
const buttons = group[1] || [];
if (!buttons.includes('espoImage')) {
return ['table', [...buttons, 'espoImage']];
}
}
return group;
});
}
};
});
```
Create the directory if needed:
```
podman exec espocrm_ctr sh -lc "mkdir -p /var/www/html/client/custom/src/views/preferences/fields"
```
> If the `grep` in the verify step shows no group whose first element is `'table'`, fall back to simply appending a new group instead of the `.map(...)`: `this.toolbar.push(['insert', ['espoImage']]);` (place it before the final `['misc', …]` group if you want it left of code-view/fullscreen).
3. **Point the signature field at the custom view** via an entityDefs override. Merge into (create if absent):
`/var/www/html/custom/Espo/Custom/Resources/metadata/entityDefs/Preferences.json`
```json
{
"fields": {
"signature": {
"view": "custom:views/preferences/fields/signature"
}
}
}
```
**MERGE** — if the file already exists, preserve all other keys and only add `fields.signature.view`. Do not drop the field's `type` (it stays `wysiwyg`; metadata is merged on top of core). Produce valid JSON.
4. **Fix ownership** (as root in the container):
```
podman exec espocrm_ctr chown -R www-data:www-data /var/www/html/client/custom /var/www/html/custom/Espo/Custom/Resources/metadata
```
5. **Clear cache / rebuild** (as the web user):
```
podman exec -u www-data espocrm_ctr php command.php rebuild
```
(If `command.php` is missing in this build, use `bin/command rebuild`; or Administration → Rebuild in the UI as admin.)
6. **Verify (hard reload Ctrl+Shift+R first).** Open Benutzereinstellungen → the „E-Mail Signatur" editor. The toolbar must now show a **Bild/Image button** (picture icon) next to the link button. Click it → a „Bild einfügen" dialog opens with a **file-upload** option (and a URL field). Nothing else in EspoCRM should have changed.
### Rollback
1. Delete `/var/www/html/client/custom/src/views/preferences/fields/signature.js`.
2. Remove the `fields.signature.view` key from `…/metadata/entityDefs/Preferences.json` (or delete the file if it now only contains that key).
3. Rebuild (step 5) and hard-reload the browser.

View File

@@ -0,0 +1,14 @@
/**
* Variable column widths for Markdown tables in text fields.
*
* EspoCRM core CSS forces `table-layout: fixed; width: 100%` on
* `.complex-text table`, which makes all columns equally wide.
* `auto` lets columns size to content instead.
*
* Registered via custom/Espo/Custom/Resources/metadata/app/client.json (cssList).
*/
.complex-text table {
table-layout: auto !important;
width: auto !important;
max-width: 100% !important;
}

View File

@@ -0,0 +1 @@
dummy

View File

@@ -0,0 +1,24 @@
define('custom:views/preferences/fields/signature',
['views/preferences/fields/signature'], (SignatureFieldView) => {
return class extends SignatureFieldView {
setupToolbar() {
// Build the reduced signature toolbar first …
super.setupToolbar();
// … then re-add the image button next to the link button.
this.toolbar = (this.toolbar || []).map(group => {
if (Array.isArray(group) && group[0] === 'table') {
const buttons = group[1] || [];
if (!buttons.includes('espoImage')) {
return ['table', [...buttons, 'espoImage']];
}
}
return group;
});
}
};
});

View File

@@ -0,0 +1,28 @@
define('custom:views/record/row-actions/tags-unlink-only',
['views/record/row-actions/relationship'], (RelationshipRowActionsView) => {
return class extends RelationshipRowActionsView {
getActionList() {
const list = [];
// Keep only the "Unlink" action (German UI: "Link entfernen").
// View / Edit / Remove are intentionally omitted.
if (!this.options.unlinkDisabled) {
list.push({
action: 'unlinkRelated',
label: 'Unlink',
data: {
id: this.model.id,
},
groupIndex: 0,
});
}
// Preserve any explicitly configured extra row actions (none by default).
this.getAdditionalActionList().forEach(item => list.push(item));
return list;
}
};
});

View File

@@ -0,0 +1,2 @@
Order Deny,Allow
Deny from all

View File

@@ -0,0 +1,7 @@
<?php
namespace Espo\Custom\Controllers;
class CTag extends \Espo\Core\Templates\Controllers\Base
{
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "إنشاء {الكيانTypeTranslated}"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Създаване на Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Vytvořit Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Opret Tag "
}
}

View File

@@ -0,0 +1,10 @@
{
"fields": {
"cAgentur": "Agentur",
"cTags": "Tags"
},
"links": {
"cAgentur": "Agentur",
"cTags": "Tags"
}
}

View File

@@ -0,0 +1,15 @@
{
"labels": {
"Create CTag": "Tag erstellen"
},
"fields": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
},
"links": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
}
}

View File

@@ -0,0 +1,8 @@
{
"fields": {
"cTags": "Tags"
},
"links": {
"cTags": "Tags"
}
}

View File

@@ -0,0 +1,60 @@
{
"options": {
"stage": {
"Interessant": "Interessant",
"Abgelehnt": "Abgelehnt",
"Angefragt": "Angefragt",
"Agentur prüft": "Agentur prüft",
"Kunde prüft": "Kunde prüft",
"Identifiziert": "Identifiziert",
"Brief versendet": "Brief versendet",
"Anruf-Wiedervorlage": "Anruf-Wiedervorlage",
"Angerufen": "Angerufen",
"Ersttermin vereinbart": "Ersttermin vereinbart",
"Ersttermin durchgeführt": "Ersttermin durchgeführt",
"Angebot": "Angebot",
"Verhandlung": "Verhandlung",
"Unterschriftsreif": "Unterschriftsreif",
"Gewonnen": "Gewonnen",
"In Durchführung": "In Durchführung",
"Abgeschl. / Bestandsk.": "Abgeschl. / Bestandsk.",
"Verloren": "Verloren",
"Ghosted": "Ghosted"
},
"cLeadQuelle": {
"Inbound Agentur": "Inbound Agentur",
"Inbound Plattform-Lead": "Inbound Plattform-Lead",
"Aktive Plattform-Suche": "Aktive Plattform-Suche",
"Aktive Akquise": "Aktive Akquise",
"Empfehlung / Netzwerk": "Empfehlung / Netzwerk",
"Sonstiges": "Sonstiges"
},
"cVerlustgrund": {
"Agentur Absage": "Agentur Absage",
"Kunde Absage": "Kunde Absage",
"Keine Einigung": "Keine Einigung",
"Eigene Absage": "Eigene Absage",
"Sonstiges": "Sonstiges",
"Agentur Ghosted": "Agentur Ghosted",
"Kunde Ghosted": "Kunde Ghosted"
},
"cVerguetungsmodell": {
"Agentur Honorar": "Agentur Honorar",
"Direkt Festpreis": "Direkt Festpreis",
"Direkt Honorar": "Direkt Honorar",
"Direkt Nutzenbasiert": "Direkt Nutzenbasiert"
}
},
"fields": {
"cLeadQuelle": "Lead-Quelle",
"cVerlustgrund": "Verlustgrund",
"cProjektlink": "Projektlink",
"cVerguetungsmodell": "Vergütungsmodell",
"cAccount1": "Über Agentur",
"cTags": "Tags"
},
"links": {
"cAccount1": "Über Agentur",
"cTags": "Tags"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Δημιουργία Tag"
}
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,10 @@
{
"fields": {
"cAgentur": "Agentur",
"cTags": "Tags"
},
"links": {
"cAgentur": "Agentur",
"cTags": "Tags"
}
}

View File

@@ -0,0 +1,15 @@
{
"labels": {
"Create CTag": "Create Tag"
},
"fields": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
},
"links": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
}
}

View File

@@ -0,0 +1,8 @@
{
"fields": {
"cTags": "Tags"
},
"links": {
"cTags": "Tags"
}
}

View File

@@ -0,0 +1,8 @@
{
"scopeNames": {
"CTag": "Tag"
},
"scopeNamesPlural": {
"CTag": "Tags"
}
}

View File

@@ -0,0 +1,39 @@
{
"fields": {
"cLeadQuelle": "Lead-Quelle",
"cVerlustgrund": "Verlustgrund",
"cProjektlink": "Projektlink",
"cVerguetungsmodell": "Vergütungsmodell",
"cAccount1": "Über Agentur",
"cTags": "Tags"
},
"options": {
"cLeadQuelle": {
"Inbound Agentur": "Inbound Agentur",
"Inbound Plattform-Lead": "Inbound Plattform-Lead",
"Aktive Plattform-Suche": "Aktive Plattform-Suche",
"Aktive Akquise": "Aktive Akquise",
"Empfehlung / Netzwerk": "Empfehlung / Netzwerk",
"Sonstiges": "Sonstiges"
},
"cVerlustgrund": {
"Agentur Absage": "Agentur Absage",
"Kunde Absage": "Kunde Absage",
"Keine Einigung": "Keine Einigung",
"Eigene Absage": "Eigene Absage",
"Sonstiges": "Sonstiges",
"Agentur Ghosted": "Agentur Ghosted",
"Kunde Ghosted": "Kunde Ghosted"
},
"cVerguetungsmodell": {
"Agentur Honorar": "Agentur Honorar",
"Direkt Festpreis": "Direkt Festpreis",
"Direkt Honorar": "Direkt Honorar",
"Direkt Nutzenbasiert": "Direkt Nutzenbasiert"
}
},
"links": {
"cAccount1": "Über Agentur",
"cTags": "Tags"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Crear Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Crear Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "ایجاد Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Créer un Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Napravi Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "{EntityTypeTranslated} létrehozása"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Buat Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Crea Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Tag を作成する"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Sukurti Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Izveidot Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Opprett Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Creëer Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Utwórz Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Criar Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Criar Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Creare Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Создать Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Vytvoriť Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Ustvari Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Napravi Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Skapa Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "สร้าง Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Tag oluştur"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Створити Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "{entityTypetranslated} بنائیں"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "Tạo Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "创建 Tag"
}
}

View File

@@ -0,0 +1,5 @@
{
"labels": {
"Create CTag": "建立Tag"
}
}

View File

@@ -0,0 +1,45 @@
{
"activities": {
"disabled": true
},
"history": {
"disabled": true
},
"_delimiter_": {
"disabled": true
},
"_tabBreak_0": {
"index": 0,
"tabBreak": true,
"tabLabel": "$Stream"
},
"stream": {
"sticked": false,
"index": 1
},
"_tabBreak_1": {
"index": 2,
"tabBreak": true,
"tabLabel": "$Account"
},
"contacts": {
"index": 3
},
"opportunities": {
"index": 4
},
"documents": {
"index": 5
},
"_tabBreak_2": {
"index": 6,
"tabBreak": true,
"tabLabel": "$Support"
},
"cases": {
"index": 7
},
"cTags": {
"index": 8
}
}

View File

@@ -0,0 +1,14 @@
{
"_delimiter_": {
"disabled": true
},
"firmen": {
"index": 0
},
"kontakte": {
"index": 1
},
"verkaufschancen": {
"index": 2
}
}

View File

@@ -0,0 +1,27 @@
{
"activities": {
"disabled": true
},
"history": {
"disabled": true
},
"_delimiter_": {
"disabled": true
},
"stream": {
"sticked": false,
"index": 0
},
"opportunities": {
"index": 1
},
"cases": {
"index": 2
},
"targetLists": {
"index": 3
},
"cTags": {
"index": 4
}
}

View File

@@ -0,0 +1,21 @@
{
"activities": {
"disabled": true
},
"history": {
"disabled": true
},
"_delimiter_": {
"disabled": true
},
"stream": {
"sticked": false,
"index": 0
},
"documents": {
"index": 1
},
"cTags": {
"index": 2
}
}

View File

@@ -0,0 +1,89 @@
[
{
"rows": [
[
{
"name": "name"
}
],
[
{
"name": "stage"
},
{
"name": "cLeadQuelle"
}
],
[
{
"name": "cVerguetungsmodell"
},
{
"name": "amount"
}
],
[
{
"name": "closeDate"
},
{
"name": "cVerlustgrund"
}
]
],
"dynamicLogicVisible": null,
"style": "default",
"dynamicLogicStyled": null,
"tabBreak": false,
"hidden": false,
"noteText": null,
"customLabel": "Vorgang"
},
{
"rows": [
[
{
"name": "account"
},
{
"name": "cAccount1"
}
],
[
{
"name": "contacts"
},
false
]
],
"dynamicLogicVisible": null,
"style": "default",
"tabBreak": false,
"hidden": false,
"noteText": null,
"noteStyle": "info",
"customLabel": "Beteiligte"
},
{
"rows": [
[
{
"name": "description"
}
],
[
{
"name": "cProjektlink"
}
]
],
"dynamicLogicVisible": null,
"style": "default",
"tabBreak": false,
"tabLabel": null,
"hidden": false,
"noteText": null,
"noteStyle": "info",
"customLabel": "Projekt"
}
]

View File

@@ -0,0 +1,17 @@
[
{
"name": "name",
"link": true
},
{
"name": "account"
},
{
"name": "amount",
"align": "right",
"isLarge": true
},
{
"name": "cLeadQuelle"
}
]

View File

@@ -0,0 +1,40 @@
[
{
"name": "name",
"link": true
},
{
"name": "cAccount1",
"width": 13
},
{
"name": "account",
"width": 13
},
{
"name": "stage",
"width": 12
},
{
"name": "cLeadQuelle",
"width": 11
},
{
"name": "amount",
"width": 9,
"align": "right"
},
{
"name": "cVerguetungsmodell",
"width": 13
},
{
"name": "closeDate",
"width": 9,
"hidden": true
},
{
"name": "assignedUser",
"width": 9
}
]

View File

@@ -0,0 +1,6 @@
{
"cssList": [
"__APPEND__",
"client/custom/css/table-var-col.css"
]
}

View File

@@ -0,0 +1,20 @@
{
"relationshipPanels": {
"agentur": {
"layout": null,
"selectPrimaryFilterName": null
},
"cAgentur": {
"layout": null,
"selectPrimaryFilterName": null
},
"tags": {
"layout": null,
"selectPrimaryFilterName": null
},
"cTags": {
"create": false,
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"controller": "controllers/record",
"boolFilterList": [
"onlyMy"
],
"relationshipPanels": {
"verkaufschancen": {
"layout": null,
"selectPrimaryFilterName": null
},
"kontakte": {
"layout": null,
"selectPrimaryFilterName": null
},
"firmen": {
"layout": null,
"selectPrimaryFilterName": null
}
}
}

View File

@@ -0,0 +1,12 @@
{
"relationshipPanels": {
"tags": {
"layout": null,
"selectPrimaryFilterName": null
},
"cTags": {
"create": false,
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"relationshipPanels": {
"account1": {
"layout": null,
"selectPrimaryFilterName": null
},
"cAccount1": {
"layout": null,
"selectPrimaryFilterName": null
},
"verkaufschancen": {
"layout": null,
"selectPrimaryFilterName": null
},
"tags": {
"layout": null,
"selectPrimaryFilterName": null
},
"cTags": {
"create": false,
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
}
}
}

View File

@@ -0,0 +1,19 @@
{
"links": {
"cAgentur": {
"type": "hasMany",
"foreign": "cAccount1",
"entity": "Opportunity",
"audited": false,
"isCustom": true
},
"cTags": {
"type": "hasMany",
"relationName": "cAccountTag",
"foreign": "firmen",
"entity": "CTag",
"audited": false,
"isCustom": true
}
}
}

View File

@@ -0,0 +1,113 @@
{
"fields": {
"name": {
"type": "varchar",
"required": true,
"pattern": "$noBadCharacters"
},
"description": {
"type": "text"
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user"
},
"modifiedBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user"
},
"assignedUser": {
"type": "link",
"required": false,
"view": "views/fields/assigned-user"
},
"teams": {
"type": "linkMultiple",
"view": "views/fields/teams"
}
},
"links": {
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
},
"assignedUser": {
"type": "belongsTo",
"entity": "User"
},
"teams": {
"type": "hasMany",
"entity": "Team",
"relationName": "entityTeam",
"layoutRelationshipsDisabled": true
},
"verkaufschancen": {
"type": "hasMany",
"relationName": "cOpportunityTag",
"foreign": "cTags",
"entity": "Opportunity",
"audited": false,
"isCustom": true
},
"kontakte": {
"type": "hasMany",
"relationName": "cContactTag",
"foreign": "cTags",
"entity": "Contact",
"audited": false,
"isCustom": true
},
"firmen": {
"type": "hasMany",
"relationName": "cAccountTag",
"foreign": "cTags",
"entity": "Account",
"audited": false,
"isCustom": true
}
},
"collection": {
"orderBy": "createdAt",
"order": "desc"
},
"indexes": {
"name": {
"columns": [
"name",
"deleted"
]
},
"assignedUser": {
"columns": [
"assignedUserId",
"deleted"
]
},
"createdAt": {
"columns": [
"createdAt"
]
},
"createdAtId": {
"unique": true,
"columns": [
"createdAt",
"id"
]
}
}
}

View File

@@ -0,0 +1,12 @@
{
"links": {
"cTags": {
"type": "hasMany",
"relationName": "cContactTag",
"foreign": "kontakte",
"entity": "CTag",
"audited": false,
"isCustom": true
}
}
}

View File

@@ -0,0 +1,167 @@
{
"fields": {
"stage": {
"options": [
"Interessant",
"Abgelehnt",
"Angefragt",
"Agentur prüft",
"Kunde prüft",
"Identifiziert",
"Brief versendet",
"Anruf-Wiedervorlage",
"Angerufen",
"Ersttermin vereinbart",
"Ersttermin durchgeführt",
"Angebot",
"Verhandlung",
"Unterschriftsreif",
"Gewonnen",
"In Durchführung",
"Abgeschl. / Bestandsk.",
"Verloren",
"Ghosted"
],
"default": "Interessant",
"probabilityMap": {
"Interessant": 5,
"Abgelehnt": 0,
"Angefragt": 15,
"Agentur prüft": 20,
"Kunde prüft": 30,
"Identifiziert": 5,
"Brief versendet": 8,
"Anruf-Wiedervorlage": 10,
"Angerufen": 12,
"Ersttermin vereinbart": 20,
"Ersttermin durchgeführt": 30,
"Angebot": 40,
"Verhandlung": 60,
"Unterschriftsreif": 80,
"Gewonnen": 100,
"In Durchführung": 100,
"Abgeschl. / Bestandsk.": 100,
"Verloren": 0,
"Ghosted": 0
},
"style": {
"Interessant": null,
"Abgelehnt": "info",
"Angefragt": null,
"Agentur prüft": null,
"Kunde prüft": null,
"Identifiziert": null,
"Brief versendet": "warning",
"Anruf-Wiedervorlage": "warning",
"Angerufen": "warning",
"Ersttermin vereinbart": "warning",
"Ersttermin durchgeführt": "warning",
"Angebot": null,
"Verhandlung": null,
"Unterschriftsreif": null,
"Gewonnen": "success",
"In Durchführung": "primary",
"Abgeschl. / Bestandsk.": "info",
"Verloren": "danger",
"Ghosted": "info"
}
},
"cLeadQuelle": {
"type": "enum",
"required": true,
"options": [
"Inbound Agentur",
"Inbound Plattform-Lead",
"Aktive Plattform-Suche",
"Aktive Akquise",
"Empfehlung / Netzwerk",
"Sonstiges"
],
"style": {
"Inbound Agentur": null,
"Inbound Plattform-Lead": null,
"Aktive Plattform-Suche": null,
"Aktive Akquise": null,
"Empfehlung / Netzwerk": null,
"Sonstiges": null
},
"default": "Inbound Agentur",
"maxLength": 100,
"isCustom": true,
"displayAsLabel": true
},
"cVerlustgrund": {
"type": "enum",
"options": [
"Agentur Absage",
"Kunde Absage",
"Keine Einigung",
"Eigene Absage",
"Sonstiges",
"Agentur Ghosted",
"Kunde Ghosted"
],
"style": {
"Agentur Absage": null,
"Kunde Absage": null,
"Keine Einigung": null,
"Eigene Absage": null,
"Sonstiges": null,
"Agentur Ghosted": null,
"Kunde Ghosted": null
},
"default": "Kunde Absage",
"maxLength": 100,
"isCustom": true
},
"cProjektlink": {
"type": "url",
"isCustom": true
},
"cVerguetungsmodell": {
"type": "enum",
"options": [
"Agentur Honorar",
"Direkt Festpreis",
"Direkt Honorar",
"Direkt Nutzenbasiert"
],
"style": {
"Agentur Honorar": null,
"Direkt Festpreis": null,
"Direkt Honorar": null,
"Direkt Nutzenbasiert": null
},
"default": "Agentur Honorar",
"displayAsLabel": true,
"maxLength": 100,
"isCustom": true
},
"cAccount1": {
"type": "link"
},
"amount": {
"required": false
},
"closeDate": {
"required": false
}
},
"links": {
"cAccount1": {
"type": "belongsTo",
"foreign": "cAgentur",
"entity": "Account",
"audited": false,
"isCustom": true
},
"cTags": {
"type": "hasMany",
"relationName": "cOpportunityTag",
"foreign": "verkaufschancen",
"entity": "CTag",
"audited": false,
"isCustom": true
}
}
}

View File

@@ -0,0 +1,7 @@
{
"fields": {
"signature": {
"view": "custom:views/preferences/fields/signature"
}
}
}

View File

@@ -0,0 +1,47 @@
{
"fields": {
"cVerlustgrund": {
"visible": {
"conditionGroup": [
{
"type": "in",
"attribute": "stage",
"value": [
"Abgelehnt",
"Verloren",
"Ghosted"
]
}
]
},
"required": {
"conditionGroup": [
{
"type": "in",
"attribute": "stage",
"value": [
"Abgelehnt",
"Verloren",
"Ghosted"
]
}
]
}
},
"amount": {
"required": {
"conditionGroup": [
{
"type": "in",
"attribute": "stage",
"value": [
"Gewonnen",
"In Durchführung",
"Abgeschl. / Bestandsk."
]
}
]
}
}
}
}

View File

@@ -0,0 +1,3 @@
{
"duplicateWhereBuilderClassName": "Espo\\Classes\\DuplicateWhereBuilders\\General"
}

View File

@@ -0,0 +1,23 @@
{
"entity": true,
"layouts": true,
"tab": true,
"acl": true,
"aclPortal": true,
"aclPortalLevelList": [
"all",
"account",
"contact",
"own",
"no"
],
"customizable": true,
"importable": true,
"notifications": true,
"stream": false,
"disabled": false,
"type": "Base",
"module": "Custom",
"object": true,
"isCustom": true
}

View File

@@ -0,0 +1,2 @@
Order Deny,Allow
Deny from all

View File

@@ -0,0 +1,92 @@
# EspoCRM Customization: Variable Column Widths for Markdown Tables
Instructions for Claude Code. Goal: integrate a custom CSS file into the EspoCRM provisioning so that Markdown tables in text fields get content-based column widths instead of equal fixed widths. The EspoCRM installation is generated by a shell script into a Podman pod, so **both files below must be created by the provisioning script** — manual edits inside the pod would be lost on the next provisioning run.
## Problem and Root Cause
EspoCRM renders Markdown in text fields (e.g. Opportunity → `Beschreibung`) inside `.complex-text` containers. The core stylesheet contains this rule (verified live on this instance, EspoCRM 9.3.8, 2026-06-06):
```css
.alert table, .alert > .message table, .complex-text table,
.confirm-message table, .popover-content table {
table-layout: fixed;
width: 100%;
}
```
`table-layout: fixed` distributes the table width equally across all columns regardless of content. Measured effect on a 4-column table in a record detail view: every column 200 px wide, causing short columns (`Nr.`, `Bew.`) to waste space and the long `Anforderung` column to wrap into unnecessarily tall rows.
With `table-layout: auto`, columns size to their content. Verified by live style injection on the test record: column widths became 37 / 112 / 49 / 604 px — exactly the desired result.
## Solution: EspoCRM Custom CSS Mechanism
EspoCRM's official mechanism for custom stylesheets ([docs](https://docs.espocrm.com/development/custom-css/)): register the CSS file in the `cssList` metadata. Both involved directories (`client/custom/`, `custom/`) are upgrade-safe — EspoCRM upgrades do not touch them. The CSS filename is arbitrary; we use the declarative name `table-var-col.css`.
### File 1 (new): `<espocrm-root>/client/custom/css/table-var-col.css`
```css
/**
* Variable column widths for Markdown tables in text fields.
*
* EspoCRM core CSS forces `table-layout: fixed; width: 100%` on
* `.complex-text table`, which makes all columns equally wide.
* `auto` lets columns size to content instead.
*
* Registered via custom/Espo/Custom/Resources/metadata/app/client.json (cssList).
*/
.complex-text table {
table-layout: auto !important;
width: auto !important;
max-width: 100% !important;
}
```
Notes:
- The selector deliberately targets only `.complex-text` (text fields on record views), not the alert/popover tables of the core rule.
- `!important` guards against load-order variations; `cssList` entries are appended after the theme stylesheet, but this makes the override robust either way.
### File 2 (new or merge): `<espocrm-root>/custom/Espo/Custom/Resources/metadata/app/client.json`
```json
{
"cssList": [
"__APPEND__",
"client/custom/css/table-var-col.css"
]
}
```
**IMPORTANT — merge, do not overwrite:** This instance already has GUI customizations. If `client.json` already exists, append the path to the existing `cssList` array (keeping `"__APPEND__"` as its first element) and leave all other keys untouched. Check first.
## Integration into the Provisioning Script
1. Locate where the script already injects EspoCRM GUI customizations and follow the same convention (e.g. `podman cp`, volume mount, or heredoc).
2. Create both files after the EspoCRM installation step.
3. Match file ownership/permissions to the other EspoCRM application files (typically the webserver user inside the container, e.g. `www-data:www-data`). The CSS file must be readable by the webserver.
4. Clear the cache afterwards — `cssList` is metadata and is cached:
```bash
podman exec <container> php /var/www/html/clear_cache.php
# or, if the script already performs a rebuild:
podman exec <container> php /var/www/html/rebuild.php
```
(Adapt container name and EspoCRM root path to the script's actual values.)
## Testing
1. **Persistence test (the actual requirement):** re-run the provisioning script from scratch, then verify both files exist inside the pod:
`podman exec <container> ls -l /var/www/html/client/custom/css/table-var-col.css /var/www/html/custom/Espo/Custom/Resources/metadata/app/client.json`
2. Log in to the CRM, hard-reload (`Ctrl+F5`). In DevTools → Network, filter for `table-var-col` → the stylesheet must load with HTTP 200.
3. Open the test record (4-column Markdown table in `Beschreibung`):
`https://crm.creature-go.com/#Opportunity/view/6a23fc99e2391e7be`
4. Visual check: columns `Nr.`, `Einordnung`, `Bew.` at minimal width, `Anforderung` takes the remaining space; rows are one to two lines instead of uniformly tall.
5. Programmatic check in the DevTools console:
```js
getComputedStyle(document.querySelector('.complex-text table')).tableLayout
// expected: "auto" (without the fix: "fixed")
```
6. Regression check: open a record whose description contains no table and one with other Markdown (lists, headings) — rendering must be unchanged. Existing GUI customizations must still be active.

View File

@@ -0,0 +1,154 @@
# Task: Reduce the Tags relationship-panel row menu to "Unlink" only in EspoCRM
## Goal
This EspoCRM instance has a custom many-to-many relationship to a custom `Tag` entity, exposed as a **"Tags" bottom relationship panel** on three entity types: **Opportunity**, **Contact**, and **Account**. On each of these three entities the link is named **`cTags`**. (The inline **Create (+)** button on these panels was already removed in a previous task via `relationshipPanels.cTags.create = false` — leave that in place.)
On a record's Tags panel, each attached tag row has a small dropdown (triangle/▾) that currently offers **four** actions: **View**, **Edit**, **Unlink**, **Remove** (German UI: **Ansehen**, **Bearbeiten**, **Link entfernen**, **Löschen**).
We want each tag row's dropdown to offer **only "Unlink"** (German: **"Link entfernen"**). The actions **View**, **Edit**, and **Remove/Delete** must be gone. This must apply to **all** users, including regular (non-admin) users, on **all three** entities (Opportunity, Contact, Account).
Do **not** change anything else (no ACLs, no other panels/fields/entities, and do not touch the `Tag` entity itself — creating, viewing, editing and deleting tags from the **Tag** entity's own list/detail views must keep working normally).
## Environment
- EspoCRM (version **9.3.8**, Apache variant) runs as a **rootless Podman pod** named `espocrm_pod` on this server. The web container is expected to be `espocrm_ctr` (confirm with `podman ps`).
- EspoCRM application root inside the web container: `/var/www/html`. The app runs as user **`www-data`**.
- Customizations live under `/var/www/html/` and must be on the **persisted** bind-mount so they survive container recreation. Two locations are used here:
- Backend metadata: `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/` (this already holds `Opportunity.json`, `Contact.json`, `Account.json` from the +button task).
- Frontend custom view: `/var/www/html/client/custom/src/` (this is the official location for custom client-side views; it is part of the same persisted `/var/www/html` that already keeps your `custom/` metadata across restarts).
## Mechanism (verified against EspoCRM 9.3.8 source)
The per-row dropdown of a relationship panel is rendered by the view **`views/record/row-actions/relationship`**. Its `getActionList()` builds the menu like this (paraphrased from `client/src/views/record/row-actions/relationship.js`):
- **View** — pushed **unconditionally** (always present; there is **no** flag to disable it).
- **Edit** — pushed only if `this.options.acl.edit && !this.options.editDisabled`.
- **Unlink** — pushed only if `!this.options.unlinkDisabled`.
- **Remove** — pushed only if `this.options.acl.delete && !this.options.removeDisabled`.
The panel (`client/src/views/record/panels/relationship.js`) feeds these flags from the panel metadata and lets you swap the whole row-actions view:
```js
this.rowActionsView = this.defs.readOnly ? false : (this.defs.rowActionsView || this.rowActionsView);
// ...
rowActionsOptions: {
unlinkDisabled: unlinkDisabled,
editDisabled: this.defs.editDisabled,
removeDisabled: this.defs.removeDisabled,
},
```
Consequence:
- `editDisabled: true` and `removeDisabled: true` (in `clientDefs.<Entity>.relationshipPanels.cTags`) would hide **Edit** and **Remove** — but **not View**, because View is hard-coded.
- To leave **only Unlink**, we point the panel's **`rowActionsView`** (a supported `relationshipPanels.<link>` parameter) at a small **custom row-actions view** that returns only the Unlink action. This removes View, Edit and Remove in one go and is the clean, upgrade-safe approach.
Custom client views live under `client/custom/src/...` and are referenced with the **`custom:`** prefix (per the official EspoCRM docs, "Custom views"). No build step is required; a cache rebuild plus a hard browser reload picks them up.
### Confidence / self-verify before relying on it
The action name `unlinkRelated`, the four-action structure, and the `rowActionsView` parameter are taken from the 9.3.8 source. Before relying on them, confirm against this instance:
```
podman exec espocrm_ctr sh -lc "grep -n 'rowActionsView\|unlinkDisabled\|editDisabled\|removeDisabled' /var/www/html/client/src/views/record/panels/relationship.js"
podman exec espocrm_ctr sh -lc "grep -n 'quickView\|quickEdit\|unlinkRelated\|removeRelated' /var/www/html/client/src/views/record/row-actions/relationship.js"
```
You should see View pushed unconditionally, Unlink guarded by `unlinkDisabled`, and the panel reading `this.defs.rowActionsView`. If this version differs, adjust the custom view's `getActionList()` to match the real action name(s) for "Unlink".
## Steps
1. **Confirm container and link name.**
- `podman ps` → identify the web container (expected `espocrm_ctr`).
- Confirm each of `Opportunity`, `Contact`, `Account` has a many-to-many link named `cTags`:
```
podman exec espocrm_ctr sh -lc "grep -rn 'cTags' /var/www/html/custom/Espo/Custom/Resources/metadata/entityDefs/"
```
2. **Create the custom row-actions view.** Create the directory and file (inside the web container, on the persisted `/var/www/html`):
`/var/www/html/client/custom/src/views/record/row-actions/tags-unlink-only.js`
```js
define('custom:views/record/row-actions/tags-unlink-only',
['views/record/row-actions/relationship'], (RelationshipRowActionsView) => {
return class extends RelationshipRowActionsView {
getActionList() {
const list = [];
// Keep only the "Unlink" action (German UI: "Link entfernen").
// View / Edit / Remove are intentionally omitted.
if (!this.options.unlinkDisabled) {
list.push({
action: 'unlinkRelated',
label: 'Unlink',
data: {
id: this.model.id,
},
groupIndex: 0,
});
}
// Preserve any explicitly configured extra row actions (none by default).
this.getAdditionalActionList().forEach(item => list.push(item));
return list;
}
};
});
```
Make sure the directories exist, e.g.:
```
podman exec espocrm_ctr sh -lc "mkdir -p /var/www/html/client/custom/src/views/record/row-actions"
```
3. **Point the three Tags panels at the custom view.** For `Opportunity`, `Contact`, and `Account`, add the `rowActionsView` key to the existing `cTags` panel in:
`/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/<EntityType>.json`
The resulting `cTags` block must look like this (note: **keep** the existing `"create": false` from the previous task):
```json
{
"relationshipPanels": {
"cTags": {
"create": false,
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
}
}
}
```
- Target files:
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Opportunity.json`
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Contact.json`
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Account.json`
- **MERGE into the existing JSON.** Preserve all other keys; in particular keep `relationshipPanels.cTags.create = false`. Only add the single `rowActionsView` key inside `relationshipPanels.cTags`. Do not overwrite the file. Produce valid JSON.
4. **Fix ownership** so the web user owns the new/changed files (run as root inside the container):
```
podman exec espocrm_ctr chown -R www-data:www-data /var/www/html/client/custom /var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs
```
5. **Clear cache and rebuild** (run as the web user):
```
podman exec -u www-data espocrm_ctr php command.php rebuild
```
If `command.php` is not present in this version, use:
```
podman exec -u www-data espocrm_ctr bin/command rebuild
```
(Alternatively, an admin can run Administration → Rebuild in the web UI.)
6. **Verify in the browser** (do a hard reload, Ctrl+Shift+R, to drop the client cache; ideally test as a regular non-admin user, and also as admin):
- Open an **Opportunity**, a **Contact**, and an **Account** record. On the **Tags** panel, open the ▾ dropdown of an attached tag. It must now show **only "Link entfernen" (Unlink)**. **Ansehen / Bearbeiten / Löschen** (View / Edit / Remove) must be gone.
- **"Link entfernen"** must still work (it detaches the tag from the record but does not delete the Tag itself).
- The **Tag** entity's own list/detail views must still allow viewing, editing and deleting tags normally (this task does not touch the Tag entity or any ACL).
## Rollback
1. Remove the `rowActionsView` key from `relationshipPanels.cTags` in the three `clientDefs` files (leave `create: false` if you still want the +button hidden).
2. Optionally delete `/var/www/html/client/custom/src/views/record/row-actions/tags-unlink-only.js`.
3. Rebuild again (step 5) and hard-reload the browser.

View File

@@ -0,0 +1,82 @@
# Task: Disable the inline "Create" button on the Tags relationship panels in EspoCRM
## Goal
This EspoCRM instance has a custom many-to-many relationship to a custom `Tag` entity. It is exposed as a **"Tags" bottom relationship panel** on three entity types: **Opportunity**, **Contact**, and **Account**. On each of these three entities the link is named **`cTags`**.
Right now the Tags panel offers a **Create (+)** button plus a **three-dots (⋯) menu** that contains the actions **View list** and **Select**. We want to **remove only the inline Create (+) button** on these Tags panels, so that users can attach **existing** Tag records only (a controlled tag vocabulary), using the **⋯ → Select** action. Creating new tags must still be possible, but **only** via the `Tag` entity's own list view — never inline from a record's Tags panel. This must apply to **all** users, including regular (non-admin) users.
Do **not** change anything else (no other panels, fields, ACLs, or entities).
## Environment
- EspoCRM runs as a **rootless Podman pod** named `espocrm_pod` on this server. The web container is expected to be `espocrm_ctr` (confirm with `podman ps`).
- EspoCRM application root inside the web container: `/var/www/html`. The app runs as user **`www-data`**.
- Customizations live under `/var/www/html/custom/Espo/Custom/Resources/metadata/` and survive upgrades. This path must be on the **persisted** volume/bind-mount so the change is not lost when the container is recreated.
## Mechanism
EspoCRM reads relationship-panel options from `clientDefs.<EntityType>.relationshipPanels.<linkName>`. Setting `"create": false` for a panel hides its Create (+) button while keeping the ⋯ menu actions (View list / Select). EspoCRM **deep-merges** metadata, so we add only this single key per entity and do not redefine the whole panel.
Confidence note on the key name: it is almost certainly `create`. Before relying on it, verify against this version's frontend source, e.g.:
```
grep -rn "'create'\|\"create\"\|create:" /var/www/html/client/src/views/record/panels/relationship.js
grep -rn "relationshipPanels" /var/www/html/client/src/views/record/
```
Look at how the relationship panel decides whether to show the create action. If this version uses a different key (e.g. `createDisabled`), use the verified key instead and adjust the JSON below accordingly.
## Steps
1. **Confirm container and link name.**
- `podman ps` → identify the web container (expected `espocrm_ctr`).
- Confirm each of `Opportunity`, `Contact`, `Account` has a many-to-many link named `cTags` pointing to entity `Tag`, e.g.:
```
podman exec espocrm_ctr sh -lc "grep -rn 'cTags' /var/www/html/custom/Espo/Custom/Resources/metadata/entityDefs/"
```
2. **Add the override for each of the three entities.** For `Opportunity`, `Contact`, and `Account`, ensure the file
`/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/<EntityType>.json`
contains this key:
```json
{
"relationshipPanels": {
"cTags": {
"create": false
}
}
}
```
- Target files:
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Opportunity.json`
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Contact.json`
- `/var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs/Account.json`
- **If a file already exists, MERGE this key into the existing JSON** (preserve all other keys; if `relationshipPanels` already exists, add `cTags` to it). Do not overwrite.
- If a file or the directory does not exist, create it. Produce valid JSON.
3. **Fix ownership** so the web user owns the new/changed files (run as root inside the container):
```
podman exec espocrm_ctr chown -R www-data:www-data /var/www/html/custom/Espo/Custom/Resources/metadata/clientDefs
```
4. **Clear cache and rebuild** (run as the web user):
```
podman exec -u www-data espocrm_ctr php command.php rebuild
```
If `command.php` is not present in this version, use:
```
podman exec -u www-data espocrm_ctr bin/command rebuild
```
(Alternatively, an admin can run Administration → Rebuild in the web UI.)
5. **Verify in the browser** (ideally as a regular non-admin user, and also as admin):
- Open an Opportunity, a Contact, and an Account record. The **Tags** panel must now have **no Create (+) button**. The three-dots (⋯) menu must still offer **View list** and **Select**, so existing tags can still be attached via **⋯ → Select**.
- The **Tag** entity's own list view must still have its Create button (deliberate tag creation still works there).
- Attaching an existing tag via **⋯ → Select** must still work.
## Rollback
Remove the added `relationshipPanels.cTags` key from the three `clientDefs` files (or delete the files if they contained only this key), then rebuild again.

158
plan.md Normal file
View File

@@ -0,0 +1,158 @@
# Install EspoCRM on DesTEngSsv006 (rootless Podman pod)
Goal: create four shell scripts that run EspoCRM as a rootless Podman pod under the local user `mkt`.
A reference script, `create_pod_traefik.sh`, is attached. Follow its structure and conventions (the "House style" section below).
## Target environment (given — do not provision in the scripts)
- Host: DesTEngSsv006, Debian GNU/Linux 12 (bookworm), Kernel 6.1.0-49-amd64, x86-64.
- Podman 4.3.1, rootless. User `mkt` exists; subuid/subgid and `enable-linger` are configured. All scripts run as user `mkt`.
- Scripts live in `/home/mkt/bin` (a private git repo, not public, classified as a company secret). Plaintext passwords in the scripts are therefore deliberately acceptable.
- **Autostart:** Podman 4.3.1 has **no Quadlet** (introduced in 4.4). Use `podman generate systemd --files --new` (systemd `--user`) for autostart. Do not use Quadlet.
## House style (mandatory — follow `create_pod_traefik.sh`)
- Shebang `#!/bin/bash`, short comment header (who runs it, what it does).
- A block of environment variables at the top (pod name, container names, images, ports, IPs, `BIND_DIR`, systemd paths).
- Network option exactly as in the reference: `NET_OPTS='slirp4netns:allow_host_loopback=true,port_handler=slirp4netns'`.
- Data stored as bind mounts under `BIND_DIR="$HOME/.local/share/$POD_NAME"`, passed via `-v` in the `podman` command (no named volumes).
- Create the pod guarded with `if ! podman pod exists`; publish ports on the pod via `-p`.
- Create containers with `podman run -d --name ... --pod "$POD_NAME" -v ... "$IMAGE"`.
- Generate systemd units with `cd "$USER_SYSTEMD_DIR"; podman generate systemd --files --new --name "$POD_NAME"`.
- Then stop and remove the live pod, and `systemctl --user enable --now` the generated services (so the systemd-managed instance is what runs in the end).
- Status messages via `echo "... (rc=$?)"` as in the reference.
## Fixed parameters
- Pod name: `espocrm_pod`.
- Containers: `mariadb_ctr` (DB), `espocrm_ctr` (web), `espocrm_daemon_ctr` (scheduler/cron). **No WebSocket container** (no live updates).
- Images (pinned, no auto-updates):
- EspoCRM: `docker.io/espocrm/espocrm:9.3.8` (Apache variant, amd64).
- MariaDB: `docker.io/library/mariadb:11.4.12` (LTS, pinned patch version; separate from the EspoCRM image).
- Ports (loopback only):
- `127.0.0.1:8093` → EspoCRM web (container port 80).
- `127.0.0.1:8094` → MariaDB (container port 3306), for host-side DB access.
- MariaDB charset: utf8mb4, collation `utf8mb4_unicode_ci` (from the start, to avoid a later collation migration).
- `siteUrl`: `http://127.0.0.1:8093`.
- Admin user `admin`. DB name/user `espocrm`/`espocrm`. Passwords in plaintext in the scripts (private repo). Generate strong random passwords (e.g. `openssl rand -base64 24`) and set them as fixed variable values.
## Data storage (bind mounts)
```
BIND_DIR="$HOME/.local/share/espocrm_pod"
DB_DATA_DIR="$BIND_DIR/mariadb" # -> /var/lib/mysql
ESPO_DATA_DIR="$BIND_DIR/espocrm" # -> /var/www/html (app, data/upload, custom, config.php)
```
Mount the bind mounts plain, **without `:U` and without `:Z`**. Both official images run their entrypoint as root and chown their own data directories to the in-container service user before dropping privileges: MariaDB chowns `/var/lib/mysql` to `mysql`, EspoCRM chowns its writable resources (`data`, `custom`, `client/custom`, `install/config.php`) to `www-data`. In rootless Podman the container root maps to host user `mkt` and holds `CAP_CHOWN` inside the user namespace, so those chowns succeed on the bind-mounted host directories. No SELinux on Debian, so no `:Z`. This matches the other rootless pods on this host.
`espocrm_ctr` and `espocrm_daemon_ctr` share the same `ESPO_DATA_DIR` (both mount `/var/www/html`).
## Container configuration
### mariadb_ctr (start first)
Env variables:
```
MARIADB_ROOT_PASSWORD=<random>
MARIADB_DATABASE=espocrm
MARIADB_USER=espocrm
MARIADB_PASSWORD=<random>
```
Image arguments (utf8mb4) after the image name:
```
--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
```
### espocrm_ctr (web, start after the DB)
Env variables:
```
ESPOCRM_DATABASE_PLATFORM=Mysql
ESPOCRM_DATABASE_HOST=127.0.0.1
ESPOCRM_DATABASE_PORT=3306
ESPOCRM_DATABASE_NAME=espocrm
ESPOCRM_DATABASE_USER=espocrm
ESPOCRM_DATABASE_PASSWORD=<random, identical to MARIADB_PASSWORD>
ESPOCRM_ADMIN_USERNAME=admin
ESPOCRM_ADMIN_PASSWORD=<random>
ESPOCRM_SITE_URL=http://127.0.0.1:8093
ESPOCRM_CONFIG_USE_WEB_SOCKET=false
```
Note: containers in a pod share the network namespace, so the DB is reachable at `127.0.0.1:3306` (not via the host port 8094). Use `127.0.0.1` instead of `localhost` to force TCP rather than a socket.
### espocrm_daemon_ctr (scheduler, start last)
- Same image `docker.io/espocrm/espocrm:9.3.8`.
- Start with `--entrypoint docker-daemon.sh` (cron/scheduler loop).
- Mounts the same `ESPO_DATA_DIR` at `/var/www/html`.
- Reads its configuration from the `data/config.php` written by `espocrm_ctr` during install, so start it after the web container.
## Script 1: create_pod_espocrm.sh (create plus autostart)
Flow:
1. Env-var block (see above) including the pinned images, ports, `NET_OPTS`, `BIND_DIR`, `USER_SYSTEMD_DIR="$HOME/.config/systemd/user"`, and the plaintext passwords.
2. Create directories: `mkdir -p "$DB_DATA_DIR" "$ESPO_DATA_DIR" "$USER_SYSTEMD_DIR"`.
3. Create the pod (guarded with `if ! podman pod exists`):
```
podman pod create -n "$POD_NAME" --network "$NET_OPTS" \
-p 127.0.0.1:8093:80 \
-p 127.0.0.1:8094:3306
```
4. Start `mariadb_ctr` (`podman rm -f` first for idempotency), with env, `-v "$DB_DATA_DIR":/var/lib/mysql`, then the utf8mb4 image arguments.
5. Wait for the DB to be ready:
```
for i in $(seq 1 60); do
podman exec "$DB_CTR" mariadb-admin ping --silent >/dev/null 2>&1 && break
sleep 2
done
```
6. Start `espocrm_ctr` (`podman rm -f` first), with env, `-v "$ESPO_DATA_DIR":/var/www/html`.
7. Wait for EspoCRM install/response (HTTP 200/302 on `http://127.0.0.1:8093/`, max ~3 min).
8. Start `espocrm_daemon_ctr` (`podman rm -f` first), with `--entrypoint docker-daemon.sh`, `-v "$ESPO_DATA_DIR":/var/www/html`.
9. Generate systemd units: `cd "$USER_SYSTEMD_DIR"; podman generate systemd --files --new --name "$POD_NAME"` (produces `pod-espocrm_pod.service` plus `container-mariadb_ctr.service`, `container-espocrm_ctr.service`, `container-espocrm_daemon_ctr.service`).
10. **Enforce start order** (like the readiness injection in `create_pod_traefik.sh`): insert an `ExecStartPre` wait loop into `container-espocrm_ctr.service` and `container-espocrm_daemon_ctr.service` that waits for the dependency it needs (web waits for DB port `127.0.0.1:8094`; daemon waits for web port `127.0.0.1:8093`). Insert via `awk`, as in the reference script.
11. Stop and remove the live pod: `podman pod stop --time 15 "$POD_NAME"; podman pod rm -f --ignore "$POD_NAME"`.
12. Enable the services:
```
systemctl --user daemon-reload
systemctl --user enable --now pod-${POD_NAME}.service
systemctl --user enable --now container-mariadb_ctr.service
systemctl --user enable --now container-espocrm_ctr.service
systemctl --user enable --now container-espocrm_daemon_ctr.service
```
13. Print closing hints (status and log commands, as in the reference script).
## Script 2: stop_pod_espocrm.sh
- Stops the systemd-managed instance: `systemctl --user stop container-espocrm_daemon_ctr.service container-espocrm_ctr.service container-mariadb_ctr.service pod-${POD_NAME}.service` (in this order), with `echo` status messages.
- Do not disable (autostart stays in place), only stop.
## Script 3: reset_pod_espocrm.sh (dangerous — deletes all data)
- **Extra safety prompt**: only proceed after the exact confirmation phrase is entered, e.g.:
```
read -r -p "WARNING: this deletes the pod, containers, and ALL data under $BIND_DIR. To confirm, type 'RESET espocrm_pod': " C
[ "$C" = "RESET espocrm_pod" ] || { echo "Aborted."; exit 1; }
```
- Then: `systemctl --user disable --now` for the pod and all three containers; `podman pod rm -f --ignore "$POD_NAME"`; delete the generated unit files in `$USER_SYSTEMD_DIR`; `systemctl --user daemon-reload`; finally `rm -rf "$BIND_DIR"` (data gone).
- Print a hint that `create_pod_espocrm.sh` must be run again afterwards.
## Script 4: backup_espocrm.sh (no timer/cron)
- Backup target `BAK_DIR="$HOME/bak"` (`mkdir -p` at the start).
- Timestamp `TS=$(date +%Y%m%d-%H%M%S)`.
- DB dump via `podman exec` (so the backup script needs no password; it uses the container's own env):
```
podman exec "$DB_CTR" sh -c 'exec mariadb-dump --single-transaction --routines --triggers -uroot -p"$MARIADB_ROOT_PASSWORD" espocrm' | gzip > "$BAK_DIR/espocrm-db-$TS.sql.gz"
```
If `$MARIADB_ROOT_PASSWORD` is not available in the exec session, alternatively go host-side via the port: `mariadb-dump -h127.0.0.1 -P8094 -uroot -p<pw> ...` (password as a plaintext variable here too, same repo model). That is what port 8094 is for.
- File backup of the EspoCRM data (contains `config.php` with the crypt/passwordSalt key, `data/upload`, `custom`):
```
tar -czf "$BAK_DIR/espocrm-files-$TS.tar.gz" -C "$ESPO_DATA_DIR" .
```
- Closing message with the created file paths.
- No timer, no cron job (manual invocation).
## Sources for verifying env variable names and entrypoints
- Official image and tags: https://hub.docker.com/r/espocrm/espocrm
- Official Docker build repo (env variables, `docker-daemon.sh`): https://github.com/espocrm/espocrm-docker

276
readme.md
View File

@@ -1 +1,275 @@
user mkt folder /home/mkt/bin git repository
# EspoCRM on DesTEngSsv006 — rootless Podman pod
This repository contains the shell scripts that run **EspoCRM** as a **rootless
Podman pod** under the local user `mkt`, plus the version-controlled snapshot of
all GUI customizations. Everything is driven by a handful of `*.sh` scripts; you
do not edit anything inside the running container by hand.
> This is a **private** repository classified as a company secret. Plaintext
> passwords live directly in `create_pod_espocrm.sh` on purpose — keep the repo
> private.
---
## 1. What gets deployed
`create_pod_espocrm.sh` builds one pod with three containers:
| Container | Image | Role |
|-----------------------|-----------------------------------------|---------------------------------------|
| `mariadb_ctr` | `docker.io/library/mariadb:11.4.12` | Database (utf8mb4 / utf8mb4_unicode_ci) |
| `espocrm_ctr` | `docker.io/espocrm/espocrm:9.3.8` | Web app (Apache) |
| `espocrm_daemon_ctr` | `docker.io/espocrm/espocrm:9.3.8` | Scheduler / cron loop |
Pod name: **`espocrm_pod`**. Images are **pinned** (no auto-updates).
**Ports — loopback only** (reach them via an SSH tunnel or a local reverse proxy):
| Host address | → | Container | Purpose |
|-----------------------|----|-----------|-------------------------------|
| `127.0.0.1:8093` | → | `:80` | EspoCRM web UI |
| `127.0.0.1:8094` | → | `:3306` | MariaDB (host-side DB access) |
Web UI: **http://127.0.0.1:8093** · Admin user: **`admin`** (password = the
`ESPOCRM_ADMIN_PASSWORD` value in `create_pod_espocrm.sh`).
**Data lives in bind mounts on the host** (not in named volumes):
```
~/.local/share/espocrm_pod/mariadb -> /var/lib/mysql (database files)
~/.local/share/espocrm_pod/espocrm -> /var/www/html (app, uploads, config, customizations)
```
**Autostart:** Podman 4.3.1 has no Quadlet, so the scripts generate
`systemd --user` units (`podman generate systemd --files --new`) and enable them.
The systemd-managed instance is what actually runs after provisioning.
---
## 2. The scripts at a glance
| Script | What it does |
|------------------------------|-----------------------------------------------------------------------------|
| `create_pod_espocrm.sh` | Create/recreate the pod, deploy GUI customizations, rebuild, enable autostart. **Safe to re-run.** |
| `stop_pod_espocrm.sh` | Stop the running containers + pod (autostart stays enabled). |
| `backup_espocrm.sh` | Write a timestamped DB dump + a tarball of the app data dir to `~/bak`. |
| `restore_espocrm.sh` | Restore a backup set (DB + files). `--list` shows sets, `--yes` skips the prompt. |
| `snapshot_espocrm_custom.sh` | Refresh the `espocrm-custom/` snapshot from the running pod after GUI edits. |
| `reset_pod_espocrm.sh` | **DANGER:** delete the pod, units **and all data**. Requires typing a phrase.|
| `create_pod_traefik.sh` | Unrelated reference script (Traefik pod) — kept as the house-style template. |
All scripts are run **as user `mkt`**, from this directory:
```bash
cd ~/bin
./create_pod_espocrm.sh
```
---
## 3. First-time setup on a freshly installed host
The scripts assume the rootless-Podman groundwork is already in place. On a brand
new host, verify these **prerequisites** first (one-time, usually needs root):
1. **Podman installed** (4.3.x is what this was built for):
```bash
podman --version
```
2. **User `mkt` exists** and has **subuid/subgid ranges** (needed for rootless
user-namespace mapping):
```bash
grep '^mkt:' /etc/subuid /etc/subgid # each should print a line
# if missing (as root): usermod --add-subuids 524288-589823 --add-subgids 524288-589823 mkt
```
3. **Linger enabled** for `mkt`, so the user's systemd services start at boot and
keep running without an active login:
```bash
loginctl show-user mkt -p Linger # want: Linger=yes
# if not: sudo loginctl enable-linger mkt
```
4. **A user systemd session is reachable.** When working over plain SSH you may
need:
```bash
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user status # should respond
```
Then, as `mkt`:
```bash
cd ~/bin
./create_pod_espocrm.sh
```
On this **first run** the script will:
- pull the pinned images,
- run the EspoCRM installer against an empty database (creates the schema and the
`admin` user from the credentials in the script),
- deploy all GUI customizations from `espocrm-custom/`,
- run a cache rebuild,
- generate and enable the `systemd --user` services (autostart).
When it finishes, open **http://127.0.0.1:8093** and log in as `admin`.
> Reaching the UI from your workstation: it only listens on loopback. Use an SSH
> tunnel, e.g. `ssh -L 8093:127.0.0.1:8093 mkt@DesTEngSsv006`, then browse to
> `http://127.0.0.1:8093` locally.
---
## 4. Everyday operations
```bash
# Start (after a stop) — the systemd units stay enabled, so this also happens on boot:
systemctl --user start pod-espocrm_pod.service \
container-mariadb_ctr.service container-espocrm_ctr.service container-espocrm_daemon_ctr.service
# Stop everything (autostart remains enabled):
./stop_pod_espocrm.sh
# Status:
systemctl --user status pod-espocrm_pod.service
systemctl --user status container-espocrm_ctr.service
# Live logs:
journalctl --user -u container-espocrm_ctr.service -f
```
Re-running `./create_pod_espocrm.sh` at any time is safe: it recreates the
containers from the persisted data, redeploys customizations, and rebuilds.
---
## 5. Backups and restore
Backups are **manual** (no timer/cron). Each run writes two timestamped files to
`~/bak`: a gzipped SQL dump and a tarball of the app data dir (which includes
`config.php` with the crypt key, uploads, and customizations).
```bash
./backup_espocrm.sh # creates ~/bak/espocrm-db-<TS>.sql.gz and ...-files-<TS>.tar.gz
./restore_espocrm.sh --list # list available backup sets
./restore_espocrm.sh # restore the latest set (asks for confirmation)
./restore_espocrm.sh 20260606-1346 # restore a specific set by timestamp
```
`restore_espocrm.sh` checks the archives, drops & re-imports the database, and
replaces the app data dir, then rebuilds. **Take a backup before any risky
operation** (image update, reset, major change).
---
## 6. GUI customizations (important workflow)
All customizations made through the EspoCRM GUI (Entity Manager, Layout Manager,
Label Manager, custom fields/views, custom CSS, …) are stored in
**`espocrm-custom/`** in this repo and deployed by `create_pod_espocrm.sh`. This
snapshot is the **single source of truth** — a reset + reprovision restores it.
Current customizations captured here include the `CTag` tag entity and its
relationships, the Tags panels (Create `+` button removed, row menu reduced to
*Unlink*), the signature-editor image button, and variable-width Markdown tables.
**The golden rule:** after you change anything in the EspoCRM GUI, capture it:
```bash
./snapshot_espocrm_custom.sh # pull current customizations into espocrm-custom/
git add espocrm-custom && git commit -m "Update EspoCRM customizations snapshot"
```
If you skip this, the next `create_pod_espocrm.sh` run will **revert** your
un-captured GUI changes back to whatever is in `espocrm-custom/`.
> Note: customizations are *files*. Your actual records (tags, opportunities,
> contacts, …) are *data* and live only in the database — protect those with
> `backup_espocrm.sh`, not the snapshot.
---
## 7. Updating the container images
Images are pinned, so updates are deliberate. **Always back up first.**
```bash
./backup_espocrm.sh
```
### EspoCRM (e.g. 9.3.8 → a newer 9.x)
1. Edit the version in `create_pod_espocrm.sh` (the web and daemon share the same
`ESPO_IMAGE` variable, so one change covers both):
```bash
ESPO_IMAGE='docker.io/espocrm/espocrm:<new-version>'
```
2. Pull and reprovision:
```bash
podman pull docker.io/espocrm/espocrm:<new-version>
./create_pod_espocrm.sh
```
The EspoCRM container entrypoint **auto-runs its upgrade** when the image
version is newer than the installed version (it migrates the database and app
files on the bind mount). The script then redeploys customizations and
rebuilds.
3. Verify the UI and your customizations. If you tweaked anything in the GUI as
part of the upgrade, re-run `./snapshot_espocrm_custom.sh` and commit.
### MariaDB (e.g. 11.4.12 → a newer 11.4.x patch)
Patch/minor updates **within the same 11.4 LTS series** are data-compatible:
```bash
# edit DB_IMAGE in create_pod_espocrm.sh, then:
podman pull docker.io/library/mariadb:<new-11.4.x>
./create_pod_espocrm.sh
```
For a **major** MariaDB jump (e.g. 11.x → 12.x) do not just swap the tag —
back up, then dump/restore into a fresh data dir, and test. When in doubt, take
a backup and try it on a throwaway copy first.
### After any image change
```bash
podman ps # all three containers Up
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8093/ # expect 200
podman image prune # optionally drop the old images
```
Commit the bumped image tag(s) in `create_pod_espocrm.sh`.
---
## 8. Resetting / uninstalling
`reset_pod_espocrm.sh` **destroys the pod, the systemd units, and ALL data**
(database + uploads). It asks you to type `RESET espocrm_pod` to proceed.
```bash
./backup_espocrm.sh # do this first if you might want the data back
./reset_pod_espocrm.sh
./create_pod_espocrm.sh # fresh install; customizations are restored from espocrm-custom/
# (optional) restore your data:
./restore_espocrm.sh
```
After a reset, a fresh `create_pod_espocrm.sh` gives you a clean install with all
**customizations** back in place; **records** come back only via `restore`.
---
## 9. Repository layout
```
create_pod_espocrm.sh # provision + autostart + deploy customizations
stop_pod_espocrm.sh # stop the pod
backup_espocrm.sh # back up DB + files to ~/bak
restore_espocrm.sh # restore a backup set
snapshot_espocrm_custom.sh # refresh espocrm-custom/ from the running pod
reset_pod_espocrm.sh # DANGER: delete pod + units + all data
espocrm-custom/ # version-controlled GUI customizations (source of truth)
create_pod_traefik.sh # unrelated reference script (house-style template)
*.md # task/instruction notes and this readme
```

27
snapshot_espocrm_custom.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# To be run by user mkt. Refreshes the version-controlled GUI-customization
# snapshot ($CUSTOM_SRC) from the running EspoCRM pod. Run this after making
# customizations in the EspoCRM GUI (Entity Manager, Layout Manager, Label
# Manager, etc.) so create_pod_espocrm.sh can restore them on reset+reprovision.
# Commit the result to the repo afterwards.
# Environment variables
WEB_CTR='espocrm_ctr'
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CUSTOM_SRC="$SCRIPT_DIR/espocrm-custom" # contains custom/ and client/custom/
if ! podman exec "$WEB_CTR" true 2>/dev/null; then
echo "ERROR: container $WEB_CTR is not running. Start the pod first." >&2
exit 1
fi
# Pull /var/www/html/{custom,client/custom} into a clean snapshot dir.
rm -rf "$CUSTOM_SRC"
mkdir -p "$CUSTOM_SRC"
podman exec "$WEB_CTR" tar -C /var/www/html -cf - custom client/custom \
| tar -C "$CUSTOM_SRC" -xf - --no-same-owner
echo "Refreshed snapshot at $CUSTOM_SRC (rc=$?)"
echo "Files: $(find "$CUSTOM_SRC" -type f | wc -l)"
echo "Review and commit, e.g.: git -C \"$SCRIPT_DIR\" add espocrm-custom && git -C \"$SCRIPT_DIR\" commit -m 'Update EspoCRM customizations snapshot'"