Add Portal Settings Web Part with styles, localization, and tests

- Implemented the PortalSettings Web Part with a new manifest and TypeScript file.
- Created SCSS styles for the Web Part, defining various UI elements and responsive design.
- Added localization support for English and German languages.
- Developed tests for Provider Registry and static assets validation.
- Included PowerShell script for project validation.
This commit is contained in:
Torsten Brendgen
2026-07-21 22:43:35 +02:00
parent 62fef65c12
commit 10f7463094
49 changed files with 2411 additions and 462 deletions

220
README.md
View File

@@ -1,67 +1,201 @@
# PortalSettings
# PortalSettings V3
PortalSettings wird einmal im Root Web einer Site Collection installiert. Die Seite erkennt registrierte
Einstellungsanbieter automatisch und zeigt nur Tabs fuer tatsaechlich vorhandene Solutions.
PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server Subscription Edition. Es wird einmal im Root Web einer Site Collection installiert und verwaltet die zentralen beziehungsweise lokalen Einstellungen erkannter Portal-Erweiterungen.
Aktuell verwaltet sie:
## Version und Voraussetzungen
- Custom Branding
- Mega Menu
- ExpiryIndicator
| Eigenschaft | Wert |
|---|---|
| Version | 3.0.0 |
| SharePoint Framework | 1.4.1 |
| Build-Node | 8.17.0 |
| npm | 6.x |
| Gulp | lokal 3.9.1 |
| Komponente | Client-Side WebPart |
Fuer Mega Menu wird das Termset ueber einen durchsuchbaren Picker aus dem Default Site Collection Term Store ausgewaehlt. PortalSettings speichert Termset-ID, Namen, Cache-Dauer und Debug-Modus einmal zentral im Property Bag des Root Webs. Beim Speichern werden identische Runtime-Bindungen rekursiv auf das Root Web und alle vorhandenen Unterwebs synchronisiert. Individuelle Stylesheets werden ausschliesslich ueber Custom Branding eingebunden.
Die frühere provisionierte `PortalSettings.aspx` und der leere Application Customizer wurden entfernt. Die Oberfläche wird vollständig vom WebPart erzeugt und verwendet kein `innerHTML`.
## ExpiryIndicator 2.1
## Unterstützte Solutions
- Site-Collection-Standard im Property Bag des Root Webs,
- Inventar aller durch das Aktivierungsskript registrierten Bindungen in Root Web und Subwebs,
- Popup zum Anbinden vorhandener Datumsfelder ohne PowerShell,
- Vererbung des zentralen Standards durch alle Bindungen,
- weiterhin moegliche lokale Ausnahmen in `ClientSideComponentProperties` des gebundenen Feldes,
- keine zusaetzliche Konfigurationsliste,
- Unterstuetzung alter Einzelregeln und verschachtelter V2-Regeln mit `and`/`or`,
- Validierung und Formatierung der vollstaendigen JSON-Konfiguration vor dem Speichern.
### MegaMenu
Die Prioritaet lautet: lokale Feldausnahme, Subweb-Override, Site-Collection-Standard, eingebauter Standard.
PortalSettings bleibt fuer den Betrieb des ExpiryIndicators optional.
- Erkennung der zentralen `SPSite.UserCustomAction`
- Auswahl des Navigationstermsets aus dem Default Site Collection Term Store
- Mega-Menu- oder Flyout-Modus
- Cache-Dauer und Cache-Version
- Debug-Modus
- schemaerhaltendes Speichern unbekannter Properties
## Erkennung
### CustomBranding
- Custom Branding wird anhand seiner aktiven Application-Customizer-UserCustomAction erkannt.
- Mega Menu wird anhand seiner zentralen Root-Web-Konfiguration oder einer aktiven Runtime-Bindung erkannt.
- ExpiryIndicator registriert einen Solution-Descriptor und sein Bindungsinventar im Property Bag des Root Webs.
- zentrale `SPSite.UserCustomAction`
- Aktivierung und Debug-Modus
- CSS-Dateien inklusive `media`
- Allowlist externer CSS-Hosts
- Header- und Footer-Elemente
- Sicherheitsprüfung von Tags, Event-Attributen, URLs, Tiefe und Elementanzahl
- Kompatibilität mit dem Legacy-Root-Array `elements`
Custom Branding kann beim Speichern auf eine site-scoped UserCustomAction angehoben werden. Mega Menu verwendet wegen der On-Premises-Loader-Eigenheiten bewusst web-scoped Runtime-Bindungen. Die fachliche Konfiguration bleibt dennoch einmalig und zentral: PortalSettings speichert sie im Root Web und verteilt sie auf alle vorhandenen Webs. Fuer spaeter neu angelegte Unterwebs wird die Konfiguration einfach erneut gespeichert, wodurch die Synchronisierung idempotent nachgezogen wird.
### ExpiryIndicator
Neue eigene Solutions werden zentral im Provider-Register in `PortalSettings.aspx` hinterlegt. Ein Provider
definiert Kennung, Anzeigename, Component-ID, Panel und Erkennungsart. So bleibt PortalSettings die gemeinsame
Admin-Oberflaeche, ohne dass nicht installierte Solutions leere Tabs erzeugen.
- Site-Collection-Standard aus dem Root-Web-Property-Bag
- Übersicht registrierter Listen- und Bibliotheksbindungen
- Web-, Listen- und Datumsfeldauswahl ohne Eingabe technischer IDs
- Anbinden eines vorhandenen Datumsfeldes
- zentrale Laufzeiten, Farben und AND-/OR-Regeln
- lokale Ausnahmen pro Bindung
- Umschalten einer Bindung auf Vererbung
- Validierung von internen Feldnamen, Laufzeiten, Farbregeln und Bedingungsgruppen
## Architektur
```text
PortalSettingsWebPart
├── ProviderRegistry
│ ├── MegaMenuSettingsProvider
│ ├── CustomBrandingSettingsProvider
│ └── ExpiryIndicatorSettingsProvider
├── DynamicFormRenderer
│ ├── Sections und Überschriften
│ ├── Hinweise und Trennlinien
│ ├── Text, Zahl, Toggle und Choice Cards
│ ├── Termset-Picker
│ ├── JSON- und Listen-Editoren
│ ├── Bindungsinventar
│ └── Zusammenfassung und JSON-Vorschau
└── Storage
├── SPSite.UserCustomActions
├── Root-Web-Property-Bag
└── Field.ClientSideComponentProperties
```
Provider beschreiben fachliche Felder und Layout deklarativ. Der Renderer kennt keine MegaMenu-, Branding- oder ExpiryIndicator-Sonderformulare. Neue Standardfelder benötigen deshalb keine Änderung am Renderer.
Spezielle Infrastruktur wie Taxonomy oder Field-Customizer-Bindungen wird über geprüfte Services angebunden. Provider dürfen kein freies HTML, JavaScript oder CSS liefern.
## Layout-Elemente
Ein Provider kann folgende Elementtypen verwenden:
- `heading`, `text`, `notice`, `divider`, `group`
- `textField`, `number`, `boolean`
- `choice`, `choiceCards`
- `termSet`, `stringList`, `json`
- `bindingInventory`
- `propertySummary`, `jsonPreview`
Sections unterstützen ein-, zwei- und dreispaltige Layouts, optionale Beschreibungstexte sowie einklappbare Expertenbereiche. Elemente können über `visibleWhen` abhängig von anderen Konfigurationswerten erscheinen.
Das Styling verwendet ausschließlich SharePoint-Themeslots wie `themePrimary`, `neutralPrimary`, `neutralLight` und `white`. Responsive Layout, Tastaturfokus, Forced Colors und reduzierte Bewegung sind berücksichtigt.
## Provider-Vertrag
Ein Provider implementiert:
```ts
interface ISettingsProvider<TConfig> {
key: string;
displayName: string;
description: string;
schemaVersion: number;
minimumPortalSettingsVersion: string;
componentIds: string[];
storage: IProviderStorageDefinition;
sections: ISettingsSection[];
createDefault(): TConfig;
normalize(value: any): TConfig;
validate(value: TConfig): string[];
}
```
Neue Provider werden unter `src/providers` angelegt und einmal in `ProviderRegistry.ts` registriert. Ein Provider definiert selbst:
- Erkennungs-IDs
- Speicherart
- Konfigurationsversion
- Sections und Layout
- Felder und Hilfetexte
- Normalisierung
- Validierung
- Standardwerte
## Schutz vor Datenverlust
PortalSettings normalisiert gelesene Werte für die Anzeige, überschreibt beim Speichern aber nicht blind das komplette Objekt. Bearbeitete Werte werden rekursiv in die ursprüngliche Konfiguration gemergt. Unbekannte zukünftige Properties bleiben dadurch erhalten.
JSON-Editoren ändern das Modell nur, wenn der aktuelle Inhalt gültiges JSON ist. Unsichere CustomBranding-Strukturen und ungültige ExpiryIndicator-Regeln werden vor dem Speichern abgelehnt.
## Berechtigungen
Das WebPart zeigt die Verwaltungsoberfläche nur Site-Collection-Administratoren. Die eigentliche Sicherheitsgrenze bleiben die SharePoint-Berechtigungen der REST-Endpunkte.
Für ExpiryIndicator-Bindungen werden zusätzlich Berechtigungen zum Verwalten der betreffenden Liste und Felder benötigt.
## Build
Die Solution verwendet die SPFx-1.4.1-Toolchain und wird reproduzierbar mit Node.js 8.17.0 gebaut:
```powershell
npm install
npm test
npm run package
```
Das Paket liegt danach unter `sharepoint/solution/portal-settings.sppkg`.
Das Paket entsteht unter:
## Installation und Upgrade
```text
sharepoint/solution/portal-settings.sppkg
```
1. `portal-settings.sppkg` im App Catalog durch Version `2.2.0.0` ersetzen.
2. PortalSettings im Root Web der Site Collection installieren beziehungsweise aktualisieren.
3. `expiry-indicator.sppkg` durch Version `2.1.0.0` ersetzen und die App aktualisieren.
4. `/SitePages/PortalSettings.aspx` mit geleertem Browsercache laden.
5. **ExpiryIndicator anbinden** auswaehlen und Web-URL, Listentitel und internen Namen des vorhandenen
Datumsfeldes eingeben. Optional werden Classic ScriptLink und Ribbon-Befehl mitregistriert.
`npm run package` führt die Provider- und statischen Sicherheitsprüfungen vor dem Ship-Build aus.
Das PowerShell-Skript bleibt fuer automatisierte Rollouts und Massenbereitstellungen erhalten, ist fuer eine
manuelle Bindung ueber PortalSettings aber nicht mehr erforderlich.
## Installation
Der ExpiryIndicator-Tab erscheint erst, wenn mindestens eine Bindung registriert ist, ein zentraler Standard
existiert oder der Solution-Descriptor im Root Web vorhanden ist.
1. `portal-settings.sppkg` im App Catalog durch Version `3.0.0.0` ersetzen.
2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren.
3. Eine moderne Seite erstellen.
4. Das WebPart **Portal Settings** einmal auf der Seite hinzufügen.
5. Die Seite veröffentlichen und nur für die gewünschte Administratorgruppe verlinken.
Zum Anbinden werden mindestens die Berechtigungen **Listen verwalten** und zum Registrieren der optionalen
Classic-Webaktion **Web verwalten** benoetigt. Die ExpiryIndicator-App muss zuvor im Root Web installiert sein.
Optional kann eine Seite per PnP PowerShell erzeugt werden:
```powershell
.\deployment\Add-PortalSettingsPage.ps1 `
-SiteUrl 'http://clshp001/sites/target' `
-PageName 'PortalSettingsV3' `
-Publish
```
Die Standardseite heißt absichtlich `PortalSettingsV3.aspx`, damit die alte V2-Seite während der Abnahme erhalten bleiben kann.
## Migration von V2
V3 liest die vorhandenen Konfigurationsorte direkt weiter:
- MegaMenu und CustomBranding aus zentralen UserCustomActions
- ExpiryIndicator-Defaults, Descriptor und Bindungsinventar aus dem Root Web
- lokale ExpiryIndicator-Ausnahmen aus dem gebundenen Feld
Es ist keine Datenmigration in eine neue Liste erforderlich. Empfohlenes Vorgehen:
1. V3-Paket installieren.
2. `PortalSettingsV3.aspx` parallel zur alten Seite bereitstellen.
3. Alle drei Provider laden und Konfigurationen testweise speichern.
4. Root Web und Subwebs prüfen.
5. Erst danach die alte `PortalSettings.aspx` löschen oder archivieren.
## Diagnose
Wenn kein Tab erscheint, muss zunächst die zentrale Registrierung der jeweiligen Solution vorhanden sein. PortalSettings erkennt keine bloß im App Catalog hochgeladene, aber nicht für die Site Collection registrierte Runtime.
Browserkonsole:
```javascript
performance.getEntriesByType('resource')
.map(function (entry) { return entry.name; })
.filter(function (url) { return url.toLowerCase().indexOf('portal-settings') >= 0; });
```
Die sichtbare Statusmeldung enthält REST-Statuscode und SharePoint-Fehlertext, wenn Lesen oder Speichern fehlschlägt.

80
ToDo.md Normal file
View File

@@ -0,0 +1,80 @@
# PortalSettings V3 Umsetzungsstand
Stand: 21.07.2026
Branch: `dev-3.0`
Zielversion: 3.0.0
## Architektur
- [x] Branch `dev-3.0` angelegt.
- [x] Monolithische ASPX-Seite entfernt.
- [x] Leeren Application Customizer entfernt.
- [x] V3 als SPFx-WebPart aufgebaut.
- [x] Provider-Vertrag, Registry und versionierte Schemas eingeführt.
- [x] Provider-Erkennung von UI und Speicherlogik getrennt.
- [x] Keine dynamische Ausführung fremder Provider-Scripts.
- [x] Unbekannte Properties bleiben beim Speichern erhalten.
## Dynamische Oberfläche
- [x] Sections mit Titel, Beschreibung und Layout.
- [x] Überschriften, Text, Hinweise, Divider und Gruppen.
- [x] Text-, Zahlen-, Boolean- und Auswahlfelder.
- [x] Choice Cards für visuelle Modusauswahl.
- [x] String-Listen- und JSON-Editoren.
- [x] Termset-Picker.
- [x] Bindungsinventar.
- [x] Property-Zusammenfassung und einklappbare JSON-Vorschau.
- [x] Bedingte Sichtbarkeit über `visibleWhen`.
- [x] Responsive 12-Spalten-Darstellung.
- [x] SharePoint-Themeslots statt fester Portal-Farben.
- [x] Tastaturfokus, Forced Colors und Reduced Motion berücksichtigt.
## Provider
- [x] MegaMenu vollständig auf Provider-Schema umgestellt.
- [x] Termset, Modus, Cache, Cache-Version und Debug abgedeckt.
- [x] CustomBranding vollständig auf Schema 2 abgebildet.
- [x] Branding-Sicherheitsprüfung vor dem Speichern.
- [x] ExpiryIndicator-Site-Collection-Standard abgebildet.
- [x] Expiry-Bindungsinventar dargestellt.
- [x] Web-, Listen- und Datumsfeld-Picker umgesetzt.
- [x] Neue Field-Customizer-Bindung aus dem WebPart möglich.
- [x] Lokale Expiry-Ausnahme bearbeiten.
- [x] Bindung auf Site-Standard-Vererbung stellen.
- [x] Legacy- und verschachtelte AND-/OR-Regeln validieren.
## Build und Bereinigung
- [x] Versionen auf 3.0.0 angehoben.
- [x] Node-Engine auf SPFx-1.4.1-Buildumgebung begrenzt.
- [x] Alte Feature-, Upgrade-, Copy- und Azure-Assets entfernt.
- [x] Altes Gulp-Copy-Task entfernt.
- [x] Serve-Konfiguration auf WebPart Workbench umgestellt.
- [x] Tests laufen vor der Paketierung.
- [x] PnP-Provisionierungsskript für eine moderne V3-Seite ergänzt.
- [x] README vollständig neu erstellt.
## Automatisierte Prüfungen
- [x] Provider-Registry und alle drei Provider geprüft.
- [x] Normalisierung und Erhalt unbekannter Properties geprüft.
- [x] Legacy-Branding-Migration geprüft.
- [x] Branding-XSS-Negativfälle geprüft.
- [x] Expiry-Regel-Negativfälle geprüft.
- [x] Objektpfade, Deep Clone und schemaerhaltendes Merge geprüft.
- [x] Paket-, Manifest- und Bundle-Struktur geprüft.
- [x] Verbot von `innerHTML` statisch geprüft.
- [x] SharePoint-Themeslots statisch geprüft.
## Zielserver-Abnahme
- [ ] Ship-Paket mit Node.js 8.17.0 erstellen.
- [ ] App im Root Web aktualisieren.
- [ ] Moderne `PortalSettingsV3.aspx` provisionieren.
- [ ] MegaMenu-Termsets laden und speichern.
- [ ] CustomBranding laden und schemaerhaltend speichern.
- [ ] ExpiryIndicator-Standard speichern.
- [ ] Expiry-Bindung hinzufügen, lokal konfigurieren und wieder auf Vererbung stellen.
- [ ] Bedienung mit Tastatur, 200-Prozent-Zoom und Windows-Hochkontrast prüfen.
- [ ] Alte V2-Seite nach erfolgreicher Parallelabnahme archivieren oder entfernen.

View File

@@ -2,17 +2,17 @@
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"portal-settings-application-customizer": {
"portal-settings-web-part": {
"components": [
{
"entrypoint": "./lib/extensions/portalSettings/PortalSettingsApplicationCustomizer.js",
"manifest": "./src/extensions/portalSettings/PortalSettingsApplicationCustomizer.manifest.json"
"entrypoint": "./lib/webparts/portalSettings/PortalSettingsWebPart.js",
"manifest": "./src/webparts/portalSettings/PortalSettingsWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"PortalSettingsApplicationCustomizerStrings": "lib/extensions/portalSettings/loc/{locale}.js"
"PortalSettingsWebPartStrings": "lib/webparts/portalSettings/loc/{locale}.js"
}
}

View File

@@ -1,4 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@@ -1,7 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./release/assets/",
"account": "[account]",
"container": "portal-settings",
"accessKey": "[access-key]"
}

View File

@@ -3,28 +3,9 @@
"solution": {
"name": "portal-settings-client-side-solution",
"id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286",
"version": "2.2.0.0",
"version": "3.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": false,
"features": [
{
"title": "Portal Settings",
"description": "Provisioniert die zentrale Settings-Seite fuer aktive Portal-Erweiterungen.",
"id": "57bd9daf-48f9-49e2-aebd-11b15a0b5e93",
"version": "2.2.0.0",
"assets": {
"elementManifests": [
"elements-v2.xml"
],
"elementFiles": [
"PortalSettings.aspx"
],
"upgradeActions": [
"upgrade-actions-v2.xml"
]
}
}
]
"skipFeatureDeployment": false
},
"paths": {
"zippedPackage": "solution/portal-settings.sppkg"

View File

@@ -4,13 +4,7 @@
"https": true,
"serveConfigurations": {
"default": {
"pageUrl": "http://clshp001/SitePages/PortalSettings.aspx",
"customActions": {
"cd6b9e8d-07b9-4468-b230-6bb35acd9a5f": {
"location": "ClientSideExtension.ApplicationCustomizer",
"properties": {}
}
}
"pageUrl": "http://clshp001/_layouts/15/workbench.aspx"
}
}
}

View File

@@ -0,0 +1,55 @@
param(
[Parameter(Mandatory = $true)]
[string]$SiteUrl,
[string]$PageName = 'PortalSettingsV3',
[switch]$Publish
)
$ErrorActionPreference = 'Stop'
$componentId = 'c41d05ec-0682-4af0-b807-649806ed56b3'
if (-not (Get-Command Connect-PnPOnline -ErrorAction SilentlyContinue)) {
throw 'PnP PowerShell wurde nicht gefunden. Bitte das zur SharePoint-SE-Umgebung passende PnP-Modul laden.'
}
Connect-PnPOnline -Url $SiteUrl -CurrentCredentials
$pageFileName = if ($PageName.EndsWith('.aspx', [StringComparison]::OrdinalIgnoreCase)) { $PageName } else { $PageName + '.aspx' }
$pageIdentity = $pageFileName.Substring(0, $pageFileName.Length - 5)
$page = Get-PnPClientSidePage -Identity $pageFileName -ErrorAction SilentlyContinue
if (-not $page) {
Write-Host "Erstelle moderne Seite $pageFileName ..." -ForegroundColor Yellow
$page = Add-PnPClientSidePage -Name $pageIdentity -LayoutType Article
}
else {
Write-Host "Verwende vorhandene moderne Seite $pageFileName." -ForegroundColor Yellow
}
$existingControl = @($page.Controls | Where-Object {
$_.WebPartId -and $_.WebPartId.ToString().Equals($componentId, [StringComparison]::OrdinalIgnoreCase)
}) | Select-Object -First 1
if (-not $existingControl) {
if (-not $page.Sections -or $page.Sections.Count -eq 0) {
Add-PnPClientSidePageSection -Page $page -SectionTemplate OneColumn -Order 1 | Out-Null
}
$component = Get-PnPAvailableClientSideComponents -Page $page | Where-Object {
$_.Id.ToString().Equals($componentId, [StringComparison]::OrdinalIgnoreCase)
} | Select-Object -First 1
if (-not $component) {
throw 'Das Portal Settings V3 WebPart ist nicht verfügbar. Ist die App im Root Web installiert beziehungsweise aktualisiert?'
}
Add-PnPClientSideWebPart -Page $page -Component $component -Section 1 -Column 1 -Order 1 | Out-Null
Write-Host 'Portal Settings WebPart wurde hinzugefügt.' -ForegroundColor Green
}
else {
Write-Host 'Portal Settings WebPart ist bereits vorhanden.' -ForegroundColor Green
}
$page.Save()
if ($Publish) { $page.Publish() }
Write-Host ("Fertig: " + $SiteUrl.TrimEnd('/') + '/SitePages/' + $pageFileName) -ForegroundColor Green

View File

@@ -2,20 +2,4 @@
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
// Custom Task: Kopiere Feature- und Settings-Assets in den Output-Ordner
const copyAssets = build.subTask('copy-settings-assets', function (gulp, buildOptions, done) {
return gulp.src([
'src/assets/*.aspx',
'src/assets/*.html',
'src/assets/*.css',
'src/assets/*.xml'
])
.pipe(gulp.dest('sharepoint/assets'));
});
// Task vor dem Bundle ausfuehren
build.rig.addPreBuildTask(copyAssets);
build.initialize(require('gulp'));

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "portal-settings",
"version": "2.2.0",
"version": "3.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,24 +1,23 @@
{
"name": "portal-settings",
"version": "2.2.0",
"version": "3.0.0",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
"node": ">=6.9.0 <9.0.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"package": "gulp clean && gulp bundle --ship && gulp package-solution --ship",
"test": "gulp test"
"package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship",
"test": "tsc --noEmit -p . && tsc -p tests/tsconfig.json && node temp/tests/tests/ProviderRegistry.test.js && node tests/validate-static-assets.js && powershell -NoProfile -ExecutionPolicy Bypass -File ./tests/validate-project.ps1"
},
"dependencies": {
"@microsoft/sp-core-library": "~1.4.0",
"@microsoft/decorators": "~1.4.0",
"@microsoft/sp-http": "~1.4.1",
"@microsoft/sp-webpart-base": "~1.4.1",
"@microsoft/sp-office-ui-fabric-core": "~1.4.1",
"@types/webpack-env": "1.13.1",
"@types/es6-promise": "0.0.33",
"@microsoft/sp-dialog": "~1.4.0",
"@microsoft/sp-application-base": "~1.4.0"
"@types/es6-promise": "0.0.33"
},
"devDependencies": {
"@microsoft/sp-build-web": "~1.4.1",

View File

@@ -1,112 +0,0 @@
<%@ Page language="C#" MasterPageFile="~masterurl/default.master" Inherits="Microsoft.SharePoint.WebPartPages.WebPartPage, Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register Tagprefix="Utilities" Namespace="Microsoft.SharePoint.Utilities" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register Tagprefix="WebPartPages" Namespace="Microsoft.SharePoint.WebPartPages" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<asp:Content ContentPlaceHolderID="PlaceHolderAdditionalPageHead" runat="server">
<SharePoint:ScriptLink language="javascript" name="sp.js" runat="server" Defer="true" LoadAfterUI="true" Localizable="false" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
:root{--ps-bg:#f4f7fb;--ps-surface:#fff;--ps-border:#d8e1ec;--ps-ink:#16263d;--ps-muted:#607086;--ps-accent:#0f6cbd;--ps-soft:#eaf2fb;--ps-success:#107c10;--ps-warning:#a15c00;--ps-danger:#b42318;--ps-shadow:0 16px 34px rgba(20,35,59,.08);--ps-radius:18px}
.ps-page{background:linear-gradient(180deg,#f9fbfe 0%,var(--ps-bg) 100%);padding:28px 0 40px}.ps-shell{width:min(1240px,calc(100% - 32px));margin:0 auto}.ps-hero{background:linear-gradient(135deg,#123660 0%,#0f6cbd 60%,#58a3e0 100%);color:#fff;border-radius:24px;padding:28px 30px;box-shadow:var(--ps-shadow)}.ps-hero h1{margin:0 0 10px;font-size:34px;line-height:1.1}.ps-hero p{margin:0;max-width:860px;line-height:1.6;color:rgba(255,255,255,.92)}.ps-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.16em;opacity:.85;margin-bottom:12px}.ps-status-host{min-height:56px;margin-bottom:18px}.ps-status{padding:14px 16px;border-radius:12px;border:1px solid transparent;background:#fff;box-shadow:0 10px 24px rgba(20,35,59,.06)}.ps-status-info{color:var(--ps-accent);background:#f3f8fe;border-color:rgba(15,108,189,.2)}.ps-status-success{color:var(--ps-success);background:#eff8ef;border-color:rgba(16,124,16,.2)}.ps-status-warning{color:var(--ps-warning);background:#fff6eb;border-color:rgba(161,92,0,.2)}.ps-status-error{color:var(--ps-danger);background:#fff1f0;border-color:rgba(180,35,24,.2)}.ps-metrics,.ps-grid{display:grid;gap:16px;margin-top:18px}.ps-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}.ps-grid{grid-template-columns:repeat(12,minmax(0,1fr))}.ps-card,.ps-panel,.ps-metric,.ps-empty{background:var(--ps-surface);border:1px solid var(--ps-border);border-radius:var(--ps-radius);box-shadow:0 10px 24px rgba(20,35,59,.05)}.ps-metric{padding:16px 18px}.ps-metric label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--ps-muted);margin-bottom:8px}.ps-metric strong{display:block;font-size:18px;line-height:1.45;word-break:break-word}.ps-tabs{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}.ps-tab,.ps-btn{height:42px;padding:0 18px;border-radius:999px;border:1px solid var(--ps-border);background:#fff;color:var(--ps-ink);font-size:14px;font-weight:600;cursor:pointer}.ps-tab.is-active{border-color:var(--ps-accent);background:var(--ps-soft);color:var(--ps-accent)}.ps-empty{margin-top:18px;padding:24px;color:var(--ps-muted);line-height:1.7}.ps-panel{margin-top:18px;padding:22px}.ps-panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:18px}.ps-panel-head h2,.ps-card h3{margin:0 0 8px}.ps-panel-head p,.ps-card p,.ps-note,.ps-help{margin:0;color:var(--ps-muted);line-height:1.6}.ps-chip{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:28px;padding:0 10px;border-radius:999px;background:var(--ps-soft);color:var(--ps-accent);font-size:12px;font-weight:700}.ps-card{padding:20px}.ps-span-4{grid-column:span 4}.ps-span-6{grid-column:span 6}.ps-span-8{grid-column:span 8}.ps-span-12{grid-column:span 12}.ps-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:14px 0}.ps-meta div{padding:12px;border:1px solid var(--ps-border);border-radius:12px;background:#f8fbff}.ps-meta label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--ps-muted);margin-bottom:6px}.ps-meta strong{display:block;font-size:14px;line-height:1.55;word-break:break-word}.ps-label{display:block;margin:0 0 8px;font-size:13px;font-weight:600}.ps-input,.ps-textarea,.ps-preview{width:100%;box-sizing:border-box;border:1px solid var(--ps-border);border-radius:12px;background:#fbfdff;color:var(--ps-ink)}.ps-input{height:44px;padding:0 14px;font-size:14px}.ps-textarea,.ps-preview{min-height:260px;padding:14px;font:13px/1.6 Consolas,'Courier New',monospace;white-space:pre-wrap;overflow:auto}.ps-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center}.ps-list{display:grid;gap:10px;margin-top:14px}.ps-item,.ps-list-empty{border:1px solid var(--ps-border);border-radius:12px;background:#fbfdff}.ps-item{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:14px}.ps-list-empty{padding:18px;text-align:center;color:var(--ps-muted)}.ps-path{font:13px/1.6 Consolas,'Courier New',monospace;word-break:break-all}.ps-actions{display:flex;gap:10px;flex-wrap:wrap}.ps-actions-bar{position:sticky;bottom:16px;z-index:4;display:flex;justify-content:space-between;align-items:center;gap:14px;margin-top:18px;padding:16px 18px;border:1px solid rgba(15,108,189,.14);border-radius:20px;background:rgba(255,255,255,.92);box-shadow:0 16px 32px rgba(20,35,59,.12)}.ps-btn-primary{background:linear-gradient(135deg,var(--ps-accent) 0%,#1b86e0 100%);border-color:var(--ps-accent);color:#fff}.ps-btn-quiet{background:#f7faff}.ps-btn-danger{background:#fff6f5;border-color:rgba(180,35,24,.18);color:var(--ps-danger)}.ps-check{display:flex;align-items:center;gap:10px;min-height:44px}.ps-check input{width:18px;height:18px}.ps-note{margin-top:14px;padding:14px 16px;border-radius:12px;background:#f8fbff;border:1px solid var(--ps-border)}
.ps-hero-actions{margin-top:18px}.ps-modal-backdrop{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(10,25,45,.58)}.ps-modal-backdrop[hidden]{display:none}.ps-modal{width:min(620px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:24px;border-radius:20px;background:#fff;box-shadow:0 28px 70px rgba(0,0,0,.28)}.ps-modal h2{margin:0 0 8px}.ps-modal p{margin:0 0 18px;color:var(--ps-muted);line-height:1.6}.ps-modal .ps-actions{justify-content:flex-end;margin-top:22px}
@media (max-width:1080px){.ps-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.ps-span-4,.ps-span-6,.ps-span-8{grid-column:span 12}.ps-meta{grid-template-columns:minmax(0,1fr)}}@media (max-width:720px){.ps-shell{width:calc(100% - 20px)}.ps-metrics{grid-template-columns:minmax(0,1fr)}.ps-inline,.ps-actions-bar{display:grid;grid-template-columns:minmax(0,1fr)}.ps-actions .ps-btn,.ps-inline .ps-btn,.ps-tab{width:100%}.ps-hero h1{font-size:28px}}
</style>
</asp:Content>
<asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server">
<div class="ps-page"><div class="ps-shell"><div id="statusMessage" class="ps-status-host"></div><section class="ps-hero"><div class="ps-eyebrow">Portal Settings</div><h1>Zentrale Verwaltung fuer aktive Portal-Erweiterungen</h1><p>Diese Seite erkennt installierte Einstellungsanbieter automatisch und zeigt nur deren Tabs. Konfigurationen werden in UserCustomActions, Feld-Properties oder im Property Bag des Root Webs gespeichert.</p><div class="ps-hero-actions"><button type="button" class="ps-btn" onclick="openExpiryBindingDialog()">ExpiryIndicator anbinden</button></div></section><section class="ps-metrics"><div class="ps-metric"><label>Aktive Erweiterungen</label><strong id="activeSolutionCount">0</strong></div><div class="ps-metric"><label>Aktiver Tab</label><strong id="activeTabValue">-</strong></div><div class="ps-metric"><label>Site Collection</label><strong id="siteUrlValue">-</strong></div><div class="ps-metric"><label>Erkannte Erweiterungen</label><strong id="detectedSolutionsValue">Keine</strong></div></section><div id="tabsHost" class="ps-tabs" role="tablist" aria-label="Portal Settings Tabs"></div><section id="emptyState" class="ps-empty" hidden>Keine unterstuetzten Erweiterungen wurden aktiv gefunden. Aktuell werden <strong>Custom Branding</strong>, <strong>Mega Menu</strong> und <strong>ExpiryIndicator</strong> unterstuetzt.</section>
<section id="panel-branding" class="ps-panel" hidden><div class="ps-panel-head"><div><h2>Custom Branding</h2><p>Pflegt die ClientSideComponentProperties des aktiven Custom Branding Application Customizers.</p></div><span id="brandingChip" class="ps-chip">0</span></div><div class="ps-grid"><div class="ps-card ps-span-4"><h3>CSS-Dateien</h3><p>Lokale SiteAssets-Pfade oder externe Stylesheets.</p><div class="ps-meta"><div><label>Component Id</label><strong id="brandingComponentId">-</strong></div><div><label>Registrierung</label><strong id="brandingLocation">-</strong></div></div><label class="ps-label" for="brandingCssPath">Neue CSS-Datei</label><div class="ps-inline"><input type="text" id="brandingCssPath" class="ps-input" placeholder="/sites/root/SiteAssets/custom-branding.css oder https://cdn.example.com/style.css" /><button type="button" class="ps-btn ps-btn-primary" onclick="addBrandingCssFile()">Hinzufuegen</button></div><div id="brandingCssList" class="ps-list"></div></div><div class="ps-card ps-span-4"><h3>Placeholder Top</h3><p>JSON-Array fuer den oberen Placeholder.</p><label class="ps-label" for="brandingTopJson">Top JSON</label><textarea id="brandingTopJson" class="ps-textarea" spellcheck="false"></textarea></div><div class="ps-card ps-span-4"><h3>Placeholder Bottom</h3><p>JSON-Array fuer den unteren Placeholder.</p><label class="ps-label" for="brandingBottomJson">Bottom JSON</label><textarea id="brandingBottomJson" class="ps-textarea" spellcheck="false"></textarea></div><div class="ps-card ps-span-12"><h3>ClientSideComponentProperties Preview</h3><p>Dieses JSON wird direkt in die aktive UserCustomAction fuer Custom Branding geschrieben.</p><pre id="brandingPreview" class="ps-preview"></pre><div class="ps-note"><strong>Schema:</strong> <code>{ cssfiles, placeholdertop: { elements: [] }, placeholderbottom: { elements: [] } }</code></div></div></div><div class="ps-actions-bar"><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="reloadSolutions('branding')">Neu laden</button><button type="button" class="ps-btn ps-btn-quiet" onclick="validateBrandingEditors()">JSON validieren</button><button type="button" class="ps-btn ps-btn-quiet" onclick="formatBrandingEditors()">JSON formatieren</button></div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-primary" onclick="applyBrandingConfig()">Konfiguration anwenden</button></div></div></section>
<section id="panel-megamenu" class="ps-panel" hidden><div class="ps-panel-head"><div><h2>Mega Menu</h2><p>Pflegt die ClientSideComponentProperties des aktiven Mega Menu Application Customizers.</p></div><span id="megaMenuChip" class="ps-chip">OK</span></div><div class="ps-grid"><div class="ps-card ps-span-6"><h3>Navigation</h3><p>Waehlt das Termset aus dem Default Site Collection Term Store und pflegt Cache sowie Debug-Verhalten. Individuelle Styles werden ueber Custom Branding eingebunden.</p><div class="ps-meta"><div><label>Component Id</label><strong id="megaMenuComponentId">-</strong></div><div><label>Registrierung</label><strong id="megaMenuLocation">-</strong></div></div><input type="hidden" id="megaMenuTermSetId" /><input type="hidden" id="megaMenuTermSetName" /><label class="ps-label">Ausgewaehltes Termset</label><div class="ps-item"><div><strong id="megaMenuSelectedTermSetName">Kein Termset ausgewaehlt</strong><div id="megaMenuSelectedTermSetMeta" class="ps-path">-</div></div><div class="ps-actions"><button type="button" class="ps-btn" onclick="openMegaMenuTermSetDialog()">Auswaehlen</button><button type="button" class="ps-btn ps-btn-quiet" onclick="clearMegaMenuTermSet()">Leeren</button></div></div><label class="ps-label" for="megaMenuCacheMinutes" style="margin-top:16px;">Cache-Dauer in Minuten</label><input type="number" id="megaMenuCacheMinutes" class="ps-input" min="1" max="1440" value="15" /><label class="ps-label" style="margin-top:16px;">Debug-Modus</label><div class="ps-check"><input type="checkbox" id="megaMenuDebug" /><label for="megaMenuDebug" style="margin:0;font-weight:600;">Debug-Ausgaben in der Browser-Konsole aktivieren</label></div></div><div class="ps-card ps-span-6"><h3>ClientSideComponentProperties Preview</h3><p>Dieses JSON wird direkt in die aktive UserCustomAction fuer Mega Menu geschrieben.</p><pre id="megaMenuPreview" class="ps-preview"></pre><div class="ps-note"><strong>Schema:</strong> <code>{ termSetId, termSetName, cacheMinutes, debug }</code></div></div></div><div class="ps-actions-bar"><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="reloadSolutions('megamenu')">Neu laden</button></div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-primary" onclick="applyMegaMenuConfig()">Konfiguration anwenden</button></div></div></section>
<section id="panel-expiryindicator" class="ps-panel" hidden><div class="ps-panel-head"><div><h2>ExpiryIndicator 2.1</h2><p>Zentrale Standards fuer die Site Collection und lokale Ausnahmen fuer einzelne Listen oder Bibliotheken.</p></div><span id="expiryIndicatorChip" class="ps-chip">V2.1</span></div><div class="ps-grid"><div class="ps-card ps-span-4"><h3>Site-Collection-Standard</h3><p>Gilt im Root Web und in allen Subwebs, sofern eine Bindung auf Vererbung steht.</p><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-quiet" onclick="loadExpirySiteDefaults()">Standard laden</button><button type="button" class="ps-btn ps-btn-primary" onclick="saveExpirySiteDefaults()">Standard speichern</button></div><div class="ps-note"><strong>Speicherort:</strong> Property Bag des Root Webs. Es wird keine Konfigurationsliste verwendet.</div></div><div class="ps-card ps-span-8"><h3>Registrierte Bindungen</h3><p>Listen und Bibliotheken, die per PortalSettings oder Aktivierungsskript registriert wurden.</p><div id="expiryBindingsList" class="ps-list"><div class="ps-list-empty">Keine Bindungen registriert.</div></div><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-quiet" onclick="openExpiryBindingDialog()">Bindung hinzufuegen</button><button type="button" class="ps-btn ps-btn-quiet" onclick="setAllExpiryBindingsToInheritance()">Alle auf Vererbung stellen</button></div></div><div class="ps-card ps-span-4"><h3>Ausgewaehltes Ziel</h3><p>Web, Liste und interner Name des gebundenen Ablaufdatumsfeldes.</p><label class="ps-label" for="expiryWebUrl" style="margin-top:16px;">Web URL</label><input type="text" id="expiryWebUrl" class="ps-input" /><label class="ps-label" for="expiryListTitle" style="margin-top:16px;">Listentitel</label><input type="text" id="expiryListTitle" class="ps-input" placeholder="Dokumente" /><label class="ps-label" for="expiryFieldName" style="margin-top:16px;">Interner Feldname</label><input type="text" id="expiryFieldName" class="ps-input" placeholder="ExpiryDate" /><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-primary" onclick="loadExpiryIndicatorConfig()">Lokale Konfiguration laden</button><button type="button" class="ps-btn ps-btn-quiet" onclick="setSelectedExpiryBindingToInheritance()">Standard erben</button></div></div><div class="ps-card ps-span-8"><h3>Konfigurationseditor</h3><p>Unterstuetzt V1-Regeln sowie verschachtelte V2-<code>condition</code>-Gruppen mit <code>and</code>/<code>or</code>.</p><textarea id="expiryIndicatorJson" class="ps-textarea" style="min-height:430px" spellcheck="false"></textarea><div class="ps-note"><strong>Prioritaet:</strong> lokale Feldkonfiguration &gt; Subweb-Override &gt; Site-Collection-Standard &gt; eingebauter Standard.</div></div></div><div class="ps-actions-bar"><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="validateExpiryIndicatorConfig()">JSON validieren</button><button type="button" class="ps-btn ps-btn-quiet" onclick="formatExpiryIndicatorConfig()">JSON formatieren</button></div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-primary" onclick="applyExpiryIndicatorConfig()">Als lokale Ausnahme speichern</button></div></div></section>
<div id="expiryBindingDialog" class="ps-modal-backdrop" hidden><div class="ps-modal" role="dialog" aria-modal="true" aria-labelledby="expiryBindingDialogTitle"><h2 id="expiryBindingDialogTitle">ExpiryIndicator anbinden</h2><p>Verbindet ein bereits vorhandenes Datumsfeld mit dem Field Customizer und registriert die Liste zentral.</p><div id="bindExpiryStatus"></div><label class="ps-label" for="bindExpiryWebUrl">Web URL</label><input type="text" id="bindExpiryWebUrl" class="ps-input" /><label class="ps-label" for="bindExpiryListTitle" style="margin-top:16px;">Listentitel</label><input type="text" id="bindExpiryListTitle" class="ps-input" placeholder="Documents" /><label class="ps-label" for="bindExpiryFieldName" style="margin-top:16px;">Interner Name des vorhandenen Datumsfeldes</label><input type="text" id="bindExpiryFieldName" class="ps-input" placeholder="ExpiryDate" /><div class="ps-check" style="margin-top:14px;"><input type="checkbox" id="bindExpiryClassic" checked="checked" /><label for="bindExpiryClassic" style="margin:0;font-weight:600;">Classic ScriptLink und Ribbon-Befehl registrieren</label></div><div class="ps-note">Die Solution legt keine Spalte an. Bei bereits vorhandener lokaler Konfiguration bleiben die Component Properties erhalten.</div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="closeExpiryBindingDialog()">Abbrechen</button><button type="button" id="bindExpirySubmit" class="ps-btn ps-btn-primary" onclick="bindExpiryIndicatorFromDialog()">Bindung anlegen</button></div></div></div>
<div id="megaMenuTermSetDialog" class="ps-modal-backdrop" hidden><div class="ps-modal" role="dialog" aria-modal="true" aria-labelledby="megaMenuTermSetDialogTitle"><h2 id="megaMenuTermSetDialogTitle">Termset auswaehlen</h2><p>Termsets aus dem Default Site Collection Term Store.</p><div id="megaMenuTermSetStatus"></div><label class="ps-label" for="megaMenuTermSetSearch">Suche</label><input type="text" id="megaMenuTermSetSearch" class="ps-input" placeholder="Gruppe oder Termset" oninput="renderMegaMenuTermSets()" /><div id="megaMenuTermSetList" style="max-height:420px;overflow:auto;margin-top:16px;"></div><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-quiet" onclick="closeMegaMenuTermSetDialog()">Abbrechen</button></div></div></div>
<script type="text/javascript">
var CUSTOM_SOLUTION_TAG='MSFT-Custom-Solution';var EXPIRY_FIELD_COMPONENT_ID='a555f4fc-d6a6-4421-8189-457449d9bbde';var EXPIRY_DESCRIPTOR_PROPERTY='PortalSettings.Solutions.ExpiryIndicator';var EXPIRY_DEFAULTS_PROPERTY='PortalSettings.ExpiryIndicator.Defaults';var EXPIRY_BINDINGS_PROPERTY='PortalSettings.ExpiryIndicator.Bindings';var SUPPORTED_SOLUTIONS={branding:{key:'branding',label:'Custom Branding',componentId:'035ba968-6488-4d42-86b3-0470ffcc95b9',panelId:'panel-branding',descriptionTag:'MSFT-Custom-Solution:CustomBranding',providerType:'applicationCustomizer'},megamenu:{key:'megamenu',label:'Mega Menu',componentId:'abc3361f-bb2d-491f-aba3-cd51c19a299b',panelId:'panel-megamenu',descriptionTag:'MSFT-Custom-Solution:MegaMenu',providerType:'applicationCustomizer'},expiryindicator:{key:'expiryindicator',label:'ExpiryIndicator',componentId:EXPIRY_FIELD_COMPONENT_ID,panelId:'panel-expiryindicator',providerType:'rootPropertyBag'}};
var state={booted:false,activeKey:'',solutions:{},megaMenuTermSets:[],megaMenuTermSetsLoaded:false};
document.addEventListener('DOMContentLoaded',function(){bootPage();});
function bootPage(){if(state.booted){return;}if(window.SP&&SP.ClientContext){state.booted=true;bindPage();reloadSolutions();return;}window.setTimeout(bootPage,150);}
function bindPage(){document.getElementById('siteUrlValue').textContent=getSiteUrl();document.getElementById('expiryWebUrl').value=getWebUrl();document.getElementById('expiryIndicatorJson').value=JSON.stringify(defaultExpiryIndicatorConfig('ExpiryDate'),null,2);document.getElementById('brandingCssPath').addEventListener('keypress',function(event){if(event.key==='Enter'){event.preventDefault();addBrandingCssFile();}});document.getElementById('brandingTopJson').addEventListener('input',updateBrandingPreview);document.getElementById('brandingBottomJson').addEventListener('input',updateBrandingPreview);document.getElementById('megaMenuTermSetId').addEventListener('input',updateMegaMenuPreview);document.getElementById('megaMenuTermSetName').addEventListener('input',updateMegaMenuPreview);document.getElementById('megaMenuCacheMinutes').addEventListener('input',updateMegaMenuPreview);document.getElementById('megaMenuDebug').addEventListener('change',updateMegaMenuPreview);}
function reloadSolutions(preferredKey){showStatus('Lade aktive Einstellungsanbieter...','info',true);state.solutions={};loadSolutionsSequentially(Object.keys(SUPPORTED_SOLUTIONS),0,preferredKey||state.activeKey,[],[],function(labels,errors,preferred){var keys=getAvailableSolutionKeys();if(!keys.length){state.activeKey='';renderTabs();renderPanels();updateOverview();showStatus('Keine unterstuetzten Erweiterungen wurden aktiv registriert gefunden.','warning',true);return;}state.activeKey=preferred&&state.solutions[preferred]?preferred:keys[0];hydratePanels();renderTabs();renderPanels();updateOverview();var message='Aktive Erweiterungen geladen: '+labels.join(', ')+'.';if(errors.length){showStatus(message+' Hinweise: '+errors.join(' | '),'warning',true);return;}showStatus(message,'success');});}
function loadSolutionsSequentially(keys,index,preferred,labels,errors,done){if(index>=keys.length){done(labels,errors,preferred);return;}var key=keys[index];var definition=SUPPORTED_SOLUTIONS[key];if(definition.providerType==='rootPropertyBag'){readExpiryProviderInfo(function(info){if(info){state.solutions[key]={definition:definition,info:info,config:info.siteDefaults};labels.push(definition.label);}loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);},function(message){errors.push(definition.label+': '+message);loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);});return;}readCustomizerActionInfo(definition,function(info){if(info&&info.action){state.solutions[key]={definition:definition,info:info,config:parseSolutionConfig(key,info.action.ClientSideComponentProperties)};labels.push(definition.label);}loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);},function(message){errors.push(definition.label+': '+message);loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);});}
function hydratePanels(){if(state.solutions.branding){populateBrandingPanel(state.solutions.branding);}if(state.solutions.megamenu){populateMegaMenuPanel(state.solutions.megamenu);}if(state.solutions.expiryindicator){renderExpiryBindings();}}
function renderTabs(){var host=document.getElementById('tabsHost');var keys=getAvailableSolutionKeys();if(!keys.length){host.innerHTML='';host.hidden=true;document.getElementById('emptyState').hidden=false;return;}host.hidden=false;document.getElementById('emptyState').hidden=true;var html='';for(var i=0;i<keys.length;i++){var key=keys[i];var def=SUPPORTED_SOLUTIONS[key];var isActive=state.activeKey===key;html+='<button type="button" class="ps-tab'+(isActive?' is-active':'')+'" role="tab" aria-selected="'+(isActive?'true':'false')+'" aria-controls="'+def.panelId+'" onclick="switchTab(\''+key+'\')">'+escapeHtml(def.label)+'</button>'; }host.innerHTML=html;}
function renderPanels(){document.getElementById('panel-branding').hidden=!(state.activeKey==='branding'&&state.solutions.branding);document.getElementById('panel-megamenu').hidden=!(state.activeKey==='megamenu'&&state.solutions.megamenu);document.getElementById('panel-expiryindicator').hidden=!(state.activeKey==='expiryindicator'&&state.solutions.expiryindicator);}
function switchTab(key){if(!state.solutions[key]){return;}state.activeKey=key;renderTabs();renderPanels();updateOverview();}
function updateOverview(){var keys=getAvailableSolutionKeys();document.getElementById('activeSolutionCount').textContent=String(keys.length);document.getElementById('activeTabValue').textContent=state.activeKey&&SUPPORTED_SOLUTIONS[state.activeKey]?SUPPORTED_SOLUTIONS[state.activeKey].label:'-';document.getElementById('detectedSolutionsValue').textContent=keys.length?keys.map(function(key){return SUPPORTED_SOLUTIONS[key].label;}).join(', '):'Keine';}
function populateBrandingPanel(solution){document.getElementById('brandingComponentId').textContent=solution.definition.componentId;document.getElementById('brandingLocation').textContent=getActionLocationText(solution.info);document.getElementById('brandingCssPath').value='';document.getElementById('brandingTopJson').value=JSON.stringify(solution.config.placeholdertop.elements,null,2);document.getElementById('brandingBottomJson').value=JSON.stringify(solution.config.placeholderbottom.elements,null,2);renderBrandingCssFiles();updateBrandingPreview();}
function renderBrandingCssFiles(){var host=document.getElementById('brandingCssList');var solution=state.solutions.branding;var cssFiles=solution?solution.config.cssfiles:[];if(!cssFiles||!cssFiles.length){host.innerHTML='<div class="ps-list-empty">Keine CSS-Dateien konfiguriert.</div>';document.getElementById('brandingChip').textContent='0';return;}var html='';for(var i=0;i<cssFiles.length;i++){html+='<div class="ps-item"><div class="ps-path">'+escapeHtml(cssFiles[i].path)+'</div><button type="button" class="ps-btn ps-btn-danger" onclick="removeBrandingCssFile('+i+')">Entfernen</button></div>'; }host.innerHTML=html;document.getElementById('brandingChip').textContent=String(cssFiles.length);}
function addBrandingCssFile(){var solution=state.solutions.branding;if(!solution){showStatus('Custom Branding ist aktuell nicht aktiv konfiguriert.','warning');return;}var input=document.getElementById('brandingCssPath');var path=input.value.trim();if(!path){showStatus('Bitte geben Sie einen CSS-Pfad ein.','warning');return;}var exists=solution.config.cssfiles.some(function(item){return item.path===path;});if(exists){showStatus('Diese CSS-Datei ist bereits vorhanden.','warning');return;}solution.config.cssfiles.push({path:path});input.value='';renderBrandingCssFiles();updateBrandingPreview();showStatus('CSS-Datei hinzugefuegt.','success');}
function removeBrandingCssFile(index){var solution=state.solutions.branding;if(!solution){return;}solution.config.cssfiles.splice(index,1);renderBrandingCssFiles();updateBrandingPreview();showStatus('CSS-Datei entfernt.','success');}
function buildBrandingConfig(){var solution=state.solutions.branding;if(!solution){throw new Error('Custom Branding ist aktuell nicht aktiv konfiguriert.');}return{cssfiles:solution.config.cssfiles.slice(),placeholdertop:{elements:parseJsonArray('brandingTopJson','Placeholder Top')},placeholderbottom:{elements:parseJsonArray('brandingBottomJson','Placeholder Bottom')}};}
function validateBrandingEditors(){try{state.solutions.branding.config=normalizeBrandingConfig(buildBrandingConfig());updateBrandingPreview();showStatus('Custom Branding JSON ist gueltig.','success');}catch(error){showStatus(error.message,'error');}}
function formatBrandingEditors(){try{state.solutions.branding.config=normalizeBrandingConfig(buildBrandingConfig());document.getElementById('brandingTopJson').value=JSON.stringify(state.solutions.branding.config.placeholdertop.elements,null,2);document.getElementById('brandingBottomJson').value=JSON.stringify(state.solutions.branding.config.placeholderbottom.elements,null,2);updateBrandingPreview();showStatus('Custom Branding JSON wurde formatiert.','success');}catch(error){showStatus(error.message,'error');}}
function updateBrandingPreview(){var preview=document.getElementById('brandingPreview');try{var config=buildBrandingConfig();preview.textContent=JSON.stringify(config,null,2);document.getElementById('brandingChip').textContent=String(config.cssfiles.length);}catch(error){preview.textContent='Fehler: '+error.message;}}
function applyBrandingConfig(){try{var config=buildBrandingConfig();showStatus('Aktualisiere Custom Branding...','info',true);updateSolutionAction('branding',config,function(){state.solutions.branding.config=normalizeBrandingConfig(config);populateBrandingPanel(state.solutions.branding);showStatus('Custom Branding wurde erfolgreich aktualisiert.','success');},function(message){showStatus('Custom Branding konnte nicht aktualisiert werden: '+message,'error',true);});}catch(error){showStatus(error.message,'error');}}
function populateMegaMenuPanel(solution){document.getElementById('megaMenuComponentId').textContent=solution.definition.componentId;document.getElementById('megaMenuLocation').textContent=getActionLocationText(solution.info);document.getElementById('megaMenuTermSetId').value=solution.config.termSetId||'';document.getElementById('megaMenuTermSetName').value=solution.config.termSetName||'';document.getElementById('megaMenuCacheMinutes').value=String(solution.config.cacheMinutes||15);document.getElementById('megaMenuDebug').checked=solution.config.debug===true;updateMegaMenuSelectedTermSet();updateMegaMenuPreview();}
function buildMegaMenuConfig(){var solution=state.solutions.megamenu;if(!solution){throw new Error('Mega Menu ist aktuell nicht aktiv konfiguriert.');}var termSetId=document.getElementById('megaMenuTermSetId').value.trim();var termSetName=document.getElementById('megaMenuTermSetName').value.trim();var cacheMinutes=Number(document.getElementById('megaMenuCacheMinutes').value);if(termSetId&&!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(termSetId)){throw new Error('Die Term Set ID ist keine gueltige GUID.');}if(!termSetId&&!termSetName){throw new Error('Bitte eine Term Set ID oder einen Term Set Name angeben.');}if(!isFinite(cacheMinutes)||cacheMinutes<1||cacheMinutes>1440){throw new Error('Die Cache-Dauer muss zwischen 1 und 1440 Minuten liegen.');}return{termSetId:termSetId,termSetName:termSetName,cacheMinutes:Math.floor(cacheMinutes),debug:document.getElementById('megaMenuDebug').checked===true};}
function updateMegaMenuPreview(){var preview=document.getElementById('megaMenuPreview');try{var config=buildMegaMenuConfig();preview.textContent=JSON.stringify(config,null,2);document.getElementById('megaMenuChip').textContent=config.debug?'DBG':'OK';}catch(error){preview.textContent='Fehler: '+error.message;document.getElementById('megaMenuChip').textContent='!';}}
function applyMegaMenuConfig(){try{var config=buildMegaMenuConfig();showStatus('Aktualisiere Mega Menu...','info',true);updateSolutionAction('megamenu',config,function(){state.solutions.megamenu.config=normalizeMegaMenuConfig(config);populateMegaMenuPanel(state.solutions.megamenu);showStatus('Mega Menu wurde erfolgreich aktualisiert.','success');},function(message){showStatus('Mega Menu konnte nicht aktualisiert werden: '+message,'error',true);});}catch(error){showStatus(error.message,'error');}}
function updateMegaMenuSelectedTermSet(){var id=document.getElementById('megaMenuTermSetId').value.trim();var name=document.getElementById('megaMenuTermSetName').value.trim();document.getElementById('megaMenuSelectedTermSetName').textContent=name||'Kein Termset ausgewaehlt';document.getElementById('megaMenuSelectedTermSetMeta').textContent=id||'-';}
function clearMegaMenuTermSet(){document.getElementById('megaMenuTermSetId').value='';document.getElementById('megaMenuTermSetName').value='';updateMegaMenuSelectedTermSet();updateMegaMenuPreview();}
function openMegaMenuTermSetDialog(){document.getElementById('megaMenuTermSetDialog').hidden=false;document.getElementById('megaMenuTermSetSearch').value='';document.getElementById('megaMenuTermSetList').innerHTML='';setMegaMenuTermSetStatus('Lade Termsets...','info');if(state.megaMenuTermSetsLoaded){setMegaMenuTermSetStatus('','info');renderMegaMenuTermSets();window.setTimeout(function(){document.getElementById('megaMenuTermSetSearch').focus();},0);return;}loadMegaMenuTermSets();}
function closeMegaMenuTermSetDialog(){document.getElementById('megaMenuTermSetDialog').hidden=true;}
function setMegaMenuTermSetStatus(message,type){var host=document.getElementById('megaMenuTermSetStatus');host.innerHTML=message?'<div class="ps-status ps-status-'+type+'">'+escapeHtml(message)+'</div>':'';}
function loadMegaMenuTermSets(){var requestXml='<Request AddExpandoFieldTypeSuffix="true" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName="PortalSettings" xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009"><Actions><ObjectPath Id="2" ObjectPathId="1" /><ObjectIdentityQuery Id="3" ObjectPathId="1" /><ObjectPath Id="5" ObjectPathId="4" /><ObjectIdentityQuery Id="6" ObjectPathId="4" /><Query Id="7" ObjectPathId="4"><Query SelectAllProperties="false"><Properties><Property Name="Id" ScalarProperty="true" /><Property Name="Name" ScalarProperty="true" /><Property Name="Groups"><Query SelectAllProperties="false"><Properties /></Query><ChildItemQuery SelectAllProperties="false"><Properties><Property Name="Name" ScalarProperty="true" /><Property Name="Id" ScalarProperty="true" /><Property Name="IsSystemGroup" ScalarProperty="true" /><Property Name="TermSets"><Query SelectAllProperties="false"><Properties /></Query><ChildItemQuery SelectAllProperties="false"><Properties><Property Name="Name" ScalarProperty="true" /><Property Name="Id" ScalarProperty="true" /><Property Name="Description" ScalarProperty="true" /></Properties></ChildItemQuery></Property></Properties></ChildItemQuery></Property></Properties></Query></Query></Actions><ObjectPaths><StaticMethod Id="1" Name="GetTaxonomySession" TypeId="{981cbc68-9edc-4f8d-872f-71146fcbb84f}" /><Method Id="4" ParentId="1" Name="GetDefaultSiteCollectionTermStore" /></ObjectPaths></Request>';try{var request=new XMLHttpRequest();request.open('POST',getSiteUrl().replace(/\/$/,'')+'/_vti_bin/client.svc/ProcessQuery',true);request.setRequestHeader('Accept','application/json');request.setRequestHeader('Content-Type','application/xml');var digest=getRequestDigest();if(digest){request.setRequestHeader('X-RequestDigest',digest);}request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status<200||request.status>=300){setMegaMenuTermSetStatus('Termsets konnten nicht geladen werden: '+buildRequestErrorMessage(request),'error');return;}try{var payload=JSON.parse(request.responseText||'[]');var errorInfo=payload&&payload.length?payload[0]:null;if(errorInfo&&errorInfo.ErrorInfo){throw new Error(errorInfo.ErrorInfo.ErrorMessage||'Taxonomy-Abfrage fehlgeschlagen.');}state.megaMenuTermSets=parseMegaMenuTermSets(payload);state.megaMenuTermSetsLoaded=true;setMegaMenuTermSetStatus(state.megaMenuTermSets.length?'':'Keine lesbaren Termsets gefunden.',state.megaMenuTermSets.length?'info':'warning');renderMegaMenuTermSets();window.setTimeout(function(){document.getElementById('megaMenuTermSetSearch').focus();},0);}catch(error){setMegaMenuTermSetStatus('Termsets konnten nicht gelesen werden: '+(error.message||String(error)),'error');}};request.onerror=function(){setMegaMenuTermSetStatus('Netzwerkfehler beim Laden der Termsets.','error');};request.send(requestXml);}catch(error){setMegaMenuTermSetStatus(error.message||String(error),'error');}}
function parseMegaMenuTermSets(payload){var result=[];if(!Array.isArray(payload)){return result;}var store=null;for(var i=0;i<payload.length;i++){if(payload[i]&&payload[i]._ObjectType_==='SP.Taxonomy.TermStore'){store=payload[i];break;}}var groups=store&&store.Groups&&Array.isArray(store.Groups._Child_Items_)?store.Groups._Child_Items_:[];for(var g=0;g<groups.length;g++){var group=groups[g];if(!group||group.IsSystemGroup===true){continue;}var sets=group.TermSets&&Array.isArray(group.TermSets._Child_Items_)?group.TermSets._Child_Items_:[];for(var s=0;s<sets.length;s++){var termSet=sets[s];if(!termSet){continue;}var id=cleanTaxonomyGuid(termSet.Id);if(id){result.push({id:id,name:termSet.Name||id,groupName:group.Name||'Ohne Gruppe',description:termSet.Description||''});}}}result.sort(function(left,right){var a=(left.groupName+' '+left.name).toLowerCase();var b=(right.groupName+' '+right.name).toLowerCase();return a<b?-1:(a>b?1:0);});return result;}
function cleanTaxonomyGuid(value){return String(value||'').replace('/Guid(','').replace(')/','').replace(')','').replace(/[{}]/g,'').toLowerCase();}
function renderMegaMenuTermSets(){var host=document.getElementById('megaMenuTermSetList');var search=document.getElementById('megaMenuTermSetSearch').value.trim().toLowerCase();var html='';for(var i=0;i<state.megaMenuTermSets.length;i++){var item=state.megaMenuTermSets[i];var haystack=(item.groupName+' '+item.name+' '+item.description+' '+item.id).toLowerCase();if(search&&haystack.indexOf(search)===-1){continue;}html+='<div class="ps-item"><div><strong>'+escapeHtml(item.name)+'</strong><div class="ps-path">'+escapeHtml(item.groupName)+' | '+escapeHtml(item.id)+'</div>'+(item.description?'<div>'+escapeHtml(item.description)+'</div>':'')+'</div><button type="button" class="ps-btn" onclick="selectMegaMenuTermSet('+i+')">Auswaehlen</button></div>'; }host.innerHTML=html||'<div class="ps-list-empty">Keine passenden Termsets gefunden.</div>';}
function selectMegaMenuTermSet(index){var item=state.megaMenuTermSets[index];if(!item){return;}document.getElementById('megaMenuTermSetId').value=item.id;document.getElementById('megaMenuTermSetName').value=item.name;updateMegaMenuSelectedTermSet();updateMegaMenuPreview();closeMegaMenuTermSetDialog();showStatus('Termset ausgewaehlt: '+item.name+'.','success');}
function defaultExpiryIndicatorConfig(expiryField){return{baseField:'Created',expiryField:expiryField||'ExpiryDate',default:{lifeTime:{value:2,unit:'years'},columnRule:[{daysUntilExpiry:0,operator:'lessOrEqual',backgroundColor:'#a4262c',textColor:'#ffffff',label:'Abgelaufen'},{daysUntilExpiry:30,operator:'lessOrEqual',backgroundColor:'#ffaa44',textColor:'#000000',label:'Laeuft bald ab'},{daysUntilExpiry:90,operator:'lessOrEqual',backgroundColor:'#fff4ce',textColor:'#000000',label:'Beobachten'}]},rules:[],nullText:'Kein Ablaufdatum',confirmExtension:true};}
function openExpiryBindingDialog(){document.getElementById('bindExpiryWebUrl').value=getWebUrl();document.getElementById('bindExpiryListTitle').value='';document.getElementById('bindExpiryFieldName').value='ExpiryDate';document.getElementById('bindExpiryStatus').innerHTML='';document.getElementById('bindExpirySubmit').disabled=false;document.getElementById('expiryBindingDialog').hidden=false;window.setTimeout(function(){document.getElementById('bindExpiryListTitle').focus();},0);}
function closeExpiryBindingDialog(){document.getElementById('expiryBindingDialog').hidden=true;}
function getExpiryBindingInput(){var webUrl=document.getElementById('bindExpiryWebUrl').value.trim().replace(/\/$/,'');var listTitle=document.getElementById('bindExpiryListTitle').value.trim();var fieldName=document.getElementById('bindExpiryFieldName').value.trim();if(!webUrl){throw new Error('Bitte eine Web URL angeben.');}if(!listTitle){throw new Error('Bitte einen Listentitel angeben.');}if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)){throw new Error('Der interne Feldname ist ungueltig.');}return{webUrl:webUrl,listTitle:listTitle,fieldName:fieldName,classic:document.getElementById('bindExpiryClassic').checked===true};}
function bindingListUrl(target){return target.webUrl+"/_api/web/lists/getbytitle('"+target.listTitle.replace(/'/g,"''")+"')";}
function bindExpiryIndicatorFromDialog(){var target;try{target=getExpiryBindingInput();}catch(error){showStatus(error.message,'error',true);return;}var button=document.getElementById('bindExpirySubmit');button.disabled=true;showStatus('Pruefe Liste und Ablaufdatumsfeld...','info',true);var listUrl=bindingListUrl(target);getJson(listUrl+'?$select=Id,Title,BaseType',function(listPayload){var list=listPayload.d||listPayload;getJson(listUrl+"/fields/getbyinternalnameortitle('"+target.fieldName+"')?$select=Id,InternalName,TypeAsString,ClientSideComponentId,ClientSideComponentProperties",function(fieldPayload){var field=fieldPayload.d||fieldPayload;var currentComponent=normalizeGuid(field.ClientSideComponentId);if(currentComponent&&currentComponent!==normalizeGuid(EXPIRY_FIELD_COMPONENT_ID)){button.disabled=false;showStatus('Das Feld ist bereits mit einem anderen Field Customizer verbunden. Die Bindung wurde nicht geaendert.','error',true);return;}if(field.TypeAsString&&field.TypeAsString!=='DateTime'){button.disabled=false;showStatus('Das angegebene Feld ist kein DateTime-Feld.','error',true);return;}var properties=field.ClientSideComponentProperties;if(!properties||!String(properties).trim()){properties=JSON.stringify({schemaVersion:2,expiryField:field.InternalName,inheritSiteDefaults:true});}var payload={__metadata:{type:field.__metadata&&field.__metadata.type?field.__metadata.type:'SP.Field'},ClientSideComponentId:EXPIRY_FIELD_COMPONENT_ID,ClientSideComponentProperties:properties};postJsonMerge(listUrl+"/fields(guid'"+normalizeGuid(field.Id)+"')",payload,function(){var info={target:target,list:list,field:field,listUrl:listUrl};function finish(classicWarning){registerExpiryBinding(info,function(){button.disabled=false;closeExpiryBindingDialog();reloadSolutions('expiryindicator');if(classicWarning){showStatus('Die moderne Bindung wurde angelegt. Classic konnte nicht vollstaendig registriert werden: '+classicWarning,'warning',true);}else{showStatus('ExpiryIndicator wurde erfolgreich angebunden und zentral registriert.','success');}},function(message){button.disabled=false;showStatus('Das Feld wurde gebunden, aber das zentrale Inventar konnte nicht aktualisiert werden: '+message,'warning',true);});}if(target.classic){ensureExpiryClassicRegistration(info,function(warning){finish(warning);});}else{finish('');}},function(message){button.disabled=false;showStatus('Das Feld konnte nicht mit dem ExpiryIndicator verbunden werden: '+message,'error',true);});},function(message){button.disabled=false;showStatus('Das Ablaufdatumsfeld konnte nicht gelesen werden: '+message,'error',true);});},function(message){button.disabled=false;showStatus('Die Liste oder Bibliothek konnte nicht gelesen werden: '+message,'error',true);});}
function registerExpiryBinding(info,onSuccess,onError){getJson(getSiteUrl().replace(/\/$/,'')+'/_api/web/AllProperties',function(payload){var properties=payload.d||payload||{};var bindings=normalizeExpiryBindings(propertyBagJson(properties,EXPIRY_BINDINGS_PROPERTY,[]));var webUrl=info.target.webUrl.replace(/\/$/,'');var listId=normalizeGuid(info.list.Id);bindings=bindings.filter(function(item){return !(String(item.webUrl||'').replace(/\/$/,'').toLowerCase()===webUrl.toLowerCase()&&normalizeGuid(item.listId)===listId);});bindings.push({webUrl:webUrl,listId:listId,listTitle:info.list.Title,fieldInternalName:info.field.InternalName,fieldId:normalizeGuid(info.field.Id)});var descriptor={key:'ExpiryIndicator',displayName:'Expiry Indicator',version:'2.1.1',provider:'expiryIndicator',componentIds:[EXPIRY_FIELD_COMPONENT_ID,'cd58f5d9-ffc8-4df7-a910-3054842d7abf','95691218-bcb1-4fad-adda-02bb12014d15'],settingsScope:['siteCollection','web','list']};setRootPropertyBagValue(EXPIRY_DESCRIPTOR_PROPERTY,JSON.stringify(descriptor),function(){setRootPropertyBagValue(EXPIRY_BINDINGS_PROPERTY,JSON.stringify(bindings),onSuccess,onError);},onError);},onError);}
function ensureExpiryClassicRegistration(info,onComplete){var webActionsUrl=info.target.webUrl+'/_api/web/UserCustomActions';getJson(webActionsUrl,function(webPayload){var webActions=getResultArray(webPayload);var hasScript=webActions.some(function(action){return action.Name==='ExpiryIndicator.Classic.ScriptLink';});function ensureRibbon(){var listActionsUrl=info.listUrl+'/UserCustomActions';getJson(listActionsUrl,function(listPayload){var listActions=getResultArray(listPayload);var ribbonAction=null;for(var i=0;i<listActions.length;i++){if(listActions[i].Name==='ExpiryIndicator.Classic.Ribbon'){ribbonAction=listActions[i];break;}}var ribbonLocation=Number(info.list.BaseType)===1?'Ribbon.Documents.Manage.Controls._children':'Ribbon.ListItem.Actions.Controls._children';var ribbonXml='<CommandUIExtension xmlns="http://schemas.microsoft.com/sharepoint/"><CommandUIDefinitions><CommandUIDefinition Location="'+ribbonLocation+'"><Button Id="ExpiryIndicator.Classic.ExtendOneYear.Button" Command="ExpiryIndicator.Classic.ExtendOneYear" Sequence="90" LabelText="Ablaufdatum +1 Jahr" Description="Verlaengert das Ablaufdatum der ausgewaehlten Elemente um ein Kalenderjahr." TemplateAlias="o1" /></CommandUIDefinition></CommandUIDefinitions><CommandUIHandlers><CommandUIHandler Command="ExpiryIndicator.Classic.ExtendOneYear" CommandAction="javascript:ExpiryIndicatorClassic.extendSelected();" EnabledScript="javascript:ExpiryIndicatorClassic.canExtend();" /></CommandUIHandlers></CommandUIExtension>';var payload={__metadata:{type:'SP.UserCustomAction'},Name:'ExpiryIndicator.Classic.Ribbon',Title:'ExpiryIndicator Classic ribbon command',Location:'CommandUI.Ribbon',CommandUIExtension:ribbonXml,Sequence:651};if(ribbonAction){if(String(ribbonAction.CommandUIExtension||'').indexOf(ribbonLocation)>=0){onComplete('');return;}postJsonMerge(listActionsUrl+"(guid'"+normalizeGuid(ribbonAction.Id)+"')",payload,function(){onComplete('');},onComplete);return;}postJson(listActionsUrl,payload,function(){onComplete('');},onComplete);},onComplete);}if(hasScript){ensureRibbon();return;}postJson(webActionsUrl,{__metadata:{type:'SP.UserCustomAction'},Name:'ExpiryIndicator.Classic.ScriptLink',Title:'ExpiryIndicator Classic runtime',Location:'ScriptLink',ScriptSrc:'~sitecollection/SiteAssets/ExpiryIndicator/ExpiryIndicatorClassic.js',Sequence:650},ensureRibbon,onComplete);},onComplete);}
function normalizePropertyBagKey(value){return String(value||'').replace(/_x002e_/gi,'.').toLowerCase();}
function propertyBagJson(properties,key,fallback){if(!properties){return fallback;}var value;var expected=normalizePropertyBagKey(key);Object.keys(properties).some(function(name){if(normalizePropertyBagKey(name)===expected){value=properties[name];return true;}return false;});if(typeof value==='undefined'||value===null||value===''){return fallback;}if(typeof value==='object'){return value;}try{return JSON.parse(value);}catch(ignore){return fallback;}}
function normalizeExpiryBindings(value){var result=[];function visit(item){if(Array.isArray(item)){item.forEach(visit);return;}if(!item||typeof item!=='object'){return;}if(item.webUrl&&item.listId&&item.fieldInternalName){result.push(item);}}visit(value);return result;}
function readExpiryProviderInfo(onSuccess,onError){getJson(getSiteUrl().replace(/\/$/,'')+'/_api/web/AllProperties',function(payload){var properties=payload.d||payload||{};var descriptor=propertyBagJson(properties,EXPIRY_DESCRIPTOR_PROPERTY,null);var bindings=normalizeExpiryBindings(propertyBagJson(properties,EXPIRY_BINDINGS_PROPERTY,[]));var siteDefaults=propertyBagJson(properties,EXPIRY_DEFAULTS_PROPERTY,null);if(!descriptor&&!siteDefaults&&!bindings.length){onSuccess(null);return;}onSuccess({descriptor:descriptor,bindings:bindings,siteDefaults:siteDefaults||defaultExpiryIndicatorConfig('ExpiryDate'),rootProperties:properties,selected:null});},onError);}
function renderExpiryBindings(){var solution=state.solutions.expiryindicator;var host=document.getElementById('expiryBindingsList');if(!solution||!solution.info||!solution.info.bindings.length){host.innerHTML='<div class="ps-list-empty">Keine Bindungen registriert. Aktivieren Sie ExpiryIndicator 2.1 erneut an den gewuenschten Listen.</div>';return;}var html='';for(var i=0;i<solution.info.bindings.length;i++){var binding=solution.info.bindings[i];html+='<div class="ps-item"><div><strong>'+escapeHtml(binding.listTitle||binding.listId)+'</strong><div class="ps-path">'+escapeHtml(binding.webUrl)+' | '+escapeHtml(binding.fieldInternalName)+'</div></div><button type="button" class="ps-btn ps-btn-quiet" onclick="selectExpiryBinding('+i+')">Auswaehlen</button></div>';}host.innerHTML=html;document.getElementById('expiryIndicatorChip').textContent=String(solution.info.bindings.length);}
function selectExpiryBinding(index){var solution=state.solutions.expiryindicator;if(!solution||!solution.info||!solution.info.bindings[index]){return;}var binding=solution.info.bindings[index];document.getElementById('expiryWebUrl').value=binding.webUrl||getWebUrl();document.getElementById('expiryListTitle').value=binding.listTitle||'';document.getElementById('expiryFieldName').value=binding.fieldInternalName||'ExpiryDate';loadExpiryIndicatorConfig();}
function loadExpirySiteDefaults(){var solution=state.solutions.expiryindicator;if(!solution||!solution.info){return;}var config=solution.info.siteDefaults||defaultExpiryIndicatorConfig('ExpiryDate');document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);showStatus('Site-Collection-Standard wurde in den Editor geladen.','success');}
function saveExpirySiteDefaults(){var config;try{config=parseExpiryIndicatorEditor();}catch(error){showStatus(error.message,'error',true);return;}showStatus('Speichere Site-Collection-Standard...','info',true);setRootPropertyBagValue(EXPIRY_DEFAULTS_PROPERTY,JSON.stringify(config),function(){state.solutions.expiryindicator.info.siteDefaults=config;state.solutions.expiryindicator.config=config;showStatus('Site-Collection-Standard wurde gespeichert. Vererbende Bindungen verwenden ihn beim naechsten Laden.','success');},function(message){showStatus('Site-Collection-Standard konnte nicht gespeichert werden: '+message,'error',true);});}
function setRootPropertyBagValue(key,value,onSuccess,onError){try{var context=new SP.ClientContext(getSiteUrl());var web=context.get_web();var properties=web.get_allProperties();context.load(properties);context.executeQueryAsync(function(){try{properties.set_item(key,value);web.update();context.executeQueryAsync(onSuccess,function(sender,args){onError(args.get_message());});}catch(error){onError(error.message||String(error));}},function(sender,args){onError(args.get_message());});}catch(error){onError(error.message||String(error));}}
function inheritancePayload(fieldName){return{__metadata:{type:'SP.Field'},ClientSideComponentProperties:JSON.stringify({schemaVersion:2,expiryField:fieldName,inheritSiteDefaults:true})};}
function setSelectedExpiryBindingToInheritance(){var target;try{target=getExpiryTarget();}catch(error){showStatus(error.message,'error');return;}showStatus('Aktiviere Vererbung fuer die ausgewaehlte Bindung...','info',true);postJsonMerge(expiryFieldUrl(target),inheritancePayload(target.fieldName),function(){if(state.solutions.expiryindicator.info){state.solutions.expiryindicator.info.selected=null;}loadExpirySiteDefaults();showStatus('Die Bindung erbt jetzt den Site-Collection-Standard.','success');},function(message){showStatus('Vererbung konnte nicht aktiviert werden: '+message,'error',true);});}
function expiryBindingFieldUrl(binding){return String(binding.webUrl||'').replace(/\/$/,'')+"/_api/web/lists(guid'"+normalizeGuid(binding.listId)+"')/fields/getbyinternalnameortitle('"+String(binding.fieldInternalName||'ExpiryDate').replace(/'/g,"''")+"')";}
function setAllExpiryBindingsToInheritance(){var solution=state.solutions.expiryindicator;var bindings=solution&&solution.info?solution.info.bindings:[];if(!bindings.length){showStatus('Es sind keine Bindungen registriert.','warning');return;}if(!window.confirm('Lokale Konfigurationen von '+bindings.length+' Bindung(en) durch Vererbung ersetzen?')){return;}showStatus('Aktiviere Vererbung fuer '+bindings.length+' Bindung(en)...','info',true);var errors=[];function next(index){if(index>=bindings.length){if(errors.length){showStatus('Vererbung wurde mit Hinweisen abgeschlossen: '+errors.join(' | '),'warning',true);}else{showStatus('Alle registrierten Bindungen erben jetzt den Site-Collection-Standard.','success');}return;}var binding=bindings[index];postJsonMerge(expiryBindingFieldUrl(binding),inheritancePayload(binding.fieldInternalName),function(){next(index+1);},function(message){errors.push((binding.listTitle||binding.listId)+': '+message);next(index+1);});}next(0);}
function getExpiryTarget(){var webUrl=document.getElementById('expiryWebUrl').value.trim().replace(/\/$/,'');var listTitle=document.getElementById('expiryListTitle').value.trim();var fieldName=document.getElementById('expiryFieldName').value.trim();if(!webUrl){throw new Error('Bitte eine Web URL angeben.');}if(!listTitle){throw new Error('Bitte einen Listentitel angeben.');}if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)){throw new Error('Der interne Feldname ist ungueltig.');}return{webUrl:webUrl,listTitle:listTitle,fieldName:fieldName};}
function expiryFieldUrl(target){return target.webUrl+"/_api/web/lists/getbytitle('"+target.listTitle.replace(/'/g,"''")+"')/fields/getbyinternalnameortitle('"+target.fieldName+"')";}
function loadExpiryIndicatorConfig(){var target;try{target=getExpiryTarget();}catch(error){showStatus(error.message,'error');return;}showStatus('Lade ExpiryIndicator-Konfiguration...','info',true);getJson(expiryFieldUrl(target)+'?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties',function(payload){var field=payload.d||payload;if(normalizeGuid(field.ClientSideComponentId)!==normalizeGuid(EXPIRY_FIELD_COMPONENT_ID)){showStatus('Das Feld ist nicht mit dem ExpiryIndicator Field Customizer verbunden.','error',true);return;}var config=defaultExpiryIndicatorConfig(field.InternalName);var inherited=false;if(field.ClientSideComponentProperties&&field.ClientSideComponentProperties.trim()){try{var stored=JSON.parse(field.ClientSideComponentProperties);if(stored.inheritSiteDefaults===true){inherited=true;config=JSON.parse(JSON.stringify(state.solutions.expiryindicator.info.siteDefaults||config));}else{config=stored;}}catch(error){showStatus('Die gespeicherten Component Properties enthalten ungueltiges JSON: '+error.message,'error',true);return;}}config.expiryField=field.InternalName;state.solutions.expiryindicator.info.selected={target:target,field:field,updateUrl:expiryFieldUrl(target),actionType:field.__metadata&&field.__metadata.type?field.__metadata.type:'SP.Field'};state.solutions.expiryindicator.config=config;document.getElementById('expiryFieldName').value=field.InternalName;document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);document.getElementById('expiryIndicatorChip').textContent=inherited?'ERBT':'LOKAL';showStatus(inherited?'Die Bindung erbt den Site-Collection-Standard.':'Lokale ExpiryIndicator-Konfiguration wurde geladen.','success');},function(message){showStatus('ExpiryIndicator-Konfiguration konnte nicht geladen werden: '+message,'error',true);});}
function parseExpiryIndicatorEditor(){var raw=document.getElementById('expiryIndicatorJson').value.trim();if(!raw){throw new Error('Die ExpiryIndicator-Konfiguration darf nicht leer sein.');}var config;try{config=JSON.parse(raw);}catch(error){throw new Error('Ungueltiges JSON: '+error.message);}var errors=validateExpiryIndicatorObject(config);if(errors.length){throw new Error(errors.join(' | '));}return config;}
function validateExpiryIndicatorObject(config){var errors=[];function safeName(value){return/^[A-Za-z_][A-Za-z0-9_]*$/.test(value||'');}function validDuration(value,path){if(!value||typeof value.value!=='number'||!isFinite(value.value)||['days','months','years'].indexOf(value.unit)<0){errors.push(path+' muss value und unit days/months/years enthalten.');}}function validColorRules(value,path){if(!Array.isArray(value)){errors.push(path+' muss ein Array sein.');return;}for(var i=0;i<value.length;i++){var item=value[i];if(!item||typeof item.daysUntilExpiry!=='number'||['lessThan','lessOrEqual','equal','greaterOrEqual','greaterThan'].indexOf(item.operator)<0||typeof item.backgroundColor!=='string'||typeof item.textColor!=='string'){errors.push(path+'['+i+'] ist ungueltig.');}}}function validGroup(group,path,depth){if(depth>10){errors.push(path+' ist tiefer als 10 Ebenen.');return;}if(!group||(group.operator!=='and'&&group.operator!=='or')){errors.push(path+'.operator muss and oder or sein.');}if(!group||!Array.isArray(group.conditions)||!group.conditions.length){errors.push(path+'.conditions muss mindestens einen Eintrag enthalten.');return;}for(var i=0;i<group.conditions.length;i++){var condition=group.conditions[i];var child=path+'.conditions['+i+']';if(condition&&typeof condition.operator!=='undefined'){validGroup(condition,child,depth+1);}else if(!condition||!safeName(condition.columnName)||typeof condition.columnValue==='undefined'){errors.push(child+' benoetigt columnName und columnValue.');}}}if(!config||typeof config!=='object'){return['Konfiguration muss ein Objekt sein.'];}if(!safeName(config.baseField)){errors.push('baseField ist ungueltig.');}if(!safeName(config.expiryField)){errors.push('expiryField ist ungueltig.');}if(!config.default){errors.push('default fehlt.');}else{validDuration(config.default.lifeTime,'default.lifeTime');validColorRules(config.default.columnRule,'default.columnRule');}if(!Array.isArray(config.rules)){errors.push('rules muss ein Array sein.');}else{for(var r=0;r<config.rules.length;r++){var rule=config.rules[r];var path='rules['+r+']';if(rule&&rule.condition){validGroup(rule.condition,path+'.condition',0);}else if(!rule||!safeName(rule.columnName)||typeof rule.columnValue==='undefined'){errors.push(path+' benoetigt condition oder columnName/columnValue.');}if(rule){validDuration(rule.lifeTime,path+'.lifeTime');validColorRules(rule.columnRule,path+'.columnRule');}}}return errors;}
function validateExpiryIndicatorConfig(){try{parseExpiryIndicatorEditor();showStatus('ExpiryIndicator V2 JSON ist gueltig.','success');document.getElementById('expiryIndicatorChip').textContent='OK';}catch(error){showStatus(error.message,'error',true);document.getElementById('expiryIndicatorChip').textContent='!';}}
function formatExpiryIndicatorConfig(){try{var config=parseExpiryIndicatorEditor();document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);showStatus('ExpiryIndicator JSON wurde formatiert.','success');}catch(error){showStatus(error.message,'error',true);}}
function applyExpiryIndicatorConfig(){var solution=state.solutions.expiryindicator;var selected=solution&&solution.info?solution.info.selected:null;if(!selected){showStatus('Bitte zuerst eine lokale ExpiryIndicator-Konfiguration laden.','warning',true);return;}var config;try{config=parseExpiryIndicatorEditor();}catch(error){showStatus(error.message,'error',true);return;}config.expiryField=selected.field.InternalName;var payload={__metadata:{type:selected.actionType},ClientSideComponentProperties:JSON.stringify(config)};showStatus('Aktualisiere lokale ExpiryIndicator-Ausnahme...','info',true);postJsonMerge(selected.updateUrl,payload,function(){solution.config=config;document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);document.getElementById('expiryIndicatorChip').textContent='LOKAL';showStatus('Lokale ExpiryIndicator-Konfiguration wurde erfolgreich aktualisiert.','success');},function(message){showStatus('ExpiryIndicator konnte nicht aktualisiert werden: '+message,'error',true);});}
function parseSolutionConfig(key,rawValue){var parsed={};if(typeof rawValue==='string'&&rawValue.trim().length>0){try{parsed=JSON.parse(rawValue);}catch(error){parsed={};}}if(key==='branding'){return normalizeBrandingConfig(parsed);}if(key==='megamenu'){return normalizeMegaMenuConfig(parsed);}return parsed||{};}
function normalizeBrandingConfig(config){var normalized={cssfiles:[],placeholdertop:{elements:[]},placeholderbottom:{elements:[]}};if(config&&Array.isArray(config.cssfiles)){normalized.cssfiles=config.cssfiles.filter(function(item){return item&&typeof item.path==='string'&&item.path.trim().length>0;}).map(function(item){return{path:item.path.trim()};});}if(config&&config.placeholdertop&&Array.isArray(config.placeholdertop.elements)){normalized.placeholdertop.elements=config.placeholdertop.elements;}else if(config&&Array.isArray(config.elements)){normalized.placeholdertop.elements=config.elements;}if(config&&config.placeholderbottom&&Array.isArray(config.placeholderbottom.elements)){normalized.placeholderbottom.elements=config.placeholderbottom.elements;}return normalized;}
function normalizeMegaMenuConfig(config){var cacheMinutes=config?Number(config.cacheMinutes):15;return{termSetId:config&&typeof config.termSetId==='string'?config.termSetId.trim():'',termSetName:config&&typeof config.termSetName==='string'?config.termSetName.trim():'',cacheMinutes:isFinite(cacheMinutes)?Math.min(Math.max(Math.floor(cacheMinutes),1),1440):15,debug:!!(config&&config.debug===true)};}
function parseJsonArray(textareaId,label){var textarea=document.getElementById(textareaId);var rawValue=textarea.value.trim();if(!rawValue){return [];}var parsedValue;try{parsedValue=JSON.parse(rawValue);}catch(error){throw new Error(label+' enthaelt kein gueltiges JSON: '+error.message);}if(!Array.isArray(parsedValue)){throw new Error(label+' muss ein JSON-Array sein.');}return parsedValue;}
function updateSolutionAction(key,config,onSuccess,onError){var solution=state.solutions[key];if(!solution||!solution.info||!solution.info.action){onError('Die passende UserCustomAction wurde nicht gefunden.');return;}if(solution.info.scopeLabel!=='Site Collection'){promoteSolutionActionToSiteCollection(key,config,onSuccess,onError);return;}var actionId=solution.info.action.Id;if(!actionId){onError('Die UserCustomAction hat keine gueltige Id.');return;}var updateUrl=solution.info.updateBaseUrl+'(guid\''+actionId+'\')';var payload={__metadata:{type:solution.info.actionType||'SP.UserCustomAction'},ClientSideComponentProperties:JSON.stringify(config)};postJsonMerge(updateUrl,payload,onSuccess,onError);}
function promoteSolutionActionToSiteCollection(key,config,onSuccess,onError){var solution=state.solutions[key];var definition=solution.definition;var oldInfo=solution.info;var oldAction=oldInfo.action;var siteActionsUrl=getSiteUrl().replace(/\/$/,'')+'/_api/site/UserCustomActions';var payload={__metadata:{type:'SP.UserCustomAction'},Name:oldAction.Name||definition.key,Title:oldAction.Title||definition.label,Description:definition.descriptionTag||CUSTOM_SOLUTION_TAG,Location:'ClientSideExtension.ApplicationCustomizer',ClientSideComponentId:definition.componentId,ClientSideComponentProperties:JSON.stringify(config)};postJsonResult(siteActionsUrl,payload,function(createdPayload){var createdAction=createdPayload&&createdPayload.d?createdPayload.d:createdPayload;var oldActionId=oldAction.Id;function finish(){solution.info={action:createdAction&&createdAction.Id?createdAction:{Id:createdAction&&createdAction.Id?createdAction.Id:'',Title:payload.Title,Name:payload.Name,Description:payload.Description,Location:payload.Location,ClientSideComponentId:payload.ClientSideComponentId,ClientSideComponentProperties:payload.ClientSideComponentProperties},updateBaseUrl:siteActionsUrl,actionType:'SP.UserCustomAction',scopeLabel:'Site Collection'};onSuccess();}if(!oldActionId||!oldInfo.updateBaseUrl){finish();return;}var oldUrl=oldInfo.updateBaseUrl+'(guid\''+oldActionId+'\')';postJsonDelete(oldUrl,finish,function(message){if(window.console&&console.warn){console.warn('Die site-scoped Registrierung wurde erstellt, der alte Web-Eintrag konnte aber nicht entfernt werden: '+message);}finish();});},function(message){onError('Die site-scoped UserCustomAction konnte nicht erstellt werden: '+message);});}
function readCustomizerActionInfo(definition,onSuccess,onError){var baseSiteUrl=getSiteUrl().replace(/\/$/,'');var endpoints=[{scopeLabel:'Site Collection',readUrl:baseSiteUrl+'/_api/site/UserCustomActions',updateBaseUrl:baseSiteUrl+'/_api/site/UserCustomActions'},{scopeLabel:'Web',readUrl:baseSiteUrl+'/_api/site/rootweb/UserCustomActions',updateBaseUrl:baseSiteUrl+'/_api/site/rootweb/UserCustomActions'}];readCustomizerActionInfoFromEndpoints(definition,endpoints,0,[],onSuccess,onError);}
function readCustomizerActionInfoFromEndpoints(definition,endpoints,index,errors,onSuccess,onError){if(index>=endpoints.length){if(errors.length){onError(errors.join(' | '));return;}onSuccess(null);return;}var endpoint=endpoints[index];getJson(endpoint.readUrl,function(payload){try{var actions=getResultArray(payload);var action=findActiveCustomizerAction(actions,definition);if(action){onSuccess({action:action,updateBaseUrl:endpoint.updateBaseUrl,actionType:action.__metadata&&action.__metadata.type?action.__metadata.type:'SP.UserCustomAction',scopeLabel:endpoint.scopeLabel});return;}readCustomizerActionInfoFromEndpoints(definition,endpoints,index+1,errors,onSuccess,onError);}catch(error){errors.push(error.message||String(error));readCustomizerActionInfoFromEndpoints(definition,endpoints,index+1,errors,onSuccess,onError);}},function(message){errors.push(message);readCustomizerActionInfoFromEndpoints(definition,endpoints,index+1,errors,onSuccess,onError);});}
function findActiveCustomizerAction(actions,definition){if(!Array.isArray(actions)||!definition){return null;}var expected=normalizeGuid(definition.componentId);var tagged=null;var applicationCustomizer=null;var componentFallback=null;for(var i=0;i<actions.length;i++){var action=actions[i];if(normalizeGuid(action&&action.ClientSideComponentId)!==expected){continue;}if(!componentFallback){componentFallback=action;}var isApplicationCustomizer=typeof action.Location==='string'&&action.Location.indexOf('ClientSideExtension.ApplicationCustomizer')!==-1;if(isApplicationCustomizer&&!applicationCustomizer){applicationCustomizer=action;}if(matchesSolutionTag(action,definition)){if(isApplicationCustomizer){return action;}if(!tagged){tagged=action;}}}return tagged||applicationCustomizer||componentFallback;}function matchesSolutionTag(action,definition){var description=action&&typeof action.Description==='string'?action.Description:'';if(description.indexOf(CUSTOM_SOLUTION_TAG)===-1){return false;}if(definition&&typeof definition.descriptionTag==='string'&&definition.descriptionTag){return description.indexOf(definition.descriptionTag)!==-1||description===CUSTOM_SOLUTION_TAG;}return true;}
function getJson(url,onSuccess,onError){try{var request=new XMLHttpRequest();request.open('GET',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){try{onSuccess(JSON.parse(request.responseText||'{}'));}catch(error){onError('Antwort konnte nicht gelesen werden: '+(error.message||String(error)));}return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Lesen von '+url);};request.send();}catch(error){onError(error.message||String(error));}}
function postJson(url,payload,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden. Laden Sie die Seite neu und versuchen Sie es erneut.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('Content-Type','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){onSuccess();return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Erstellen unter '+url);};request.send(JSON.stringify(payload));}catch(error){onError(error.message||String(error));}}
function postJsonMerge(url,payload,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden. Laden Sie die Seite neu und versuchen Sie es erneut.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('Content-Type','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.setRequestHeader('IF-MATCH','*');request.setRequestHeader('X-HTTP-Method','MERGE');request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){onSuccess();return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Aktualisieren von '+url);};request.send(JSON.stringify(payload));}catch(error){onError(error.message||String(error));}}
function postJsonResult(url,payload,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden. Laden Sie die Seite neu und versuchen Sie es erneut.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('Content-Type','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){try{onSuccess(request.responseText?JSON.parse(request.responseText):{});}catch(error){onError('Erstellte UserCustomAction konnte nicht gelesen werden: '+(error.message||String(error)));}return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Erstellen unter '+url);};request.send(JSON.stringify(payload));}catch(error){onError(error.message||String(error));}}
function postJsonDelete(url,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.setRequestHeader('IF-MATCH','*');request.setRequestHeader('X-HTTP-Method','DELETE');request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){onSuccess();return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Entfernen von '+url);};request.send();}catch(error){onError(error.message||String(error));}}
function getRequestDigest(){var digestField=document.getElementById('__REQUESTDIGEST');return digestField&&digestField.value?digestField.value:'';}
function getResultArray(payload){if(payload&&payload.d&&Array.isArray(payload.d.results)){return payload.d.results;}if(payload&&Array.isArray(payload.value)){return payload.value;}return [];}
function normalizeGuid(value){if(typeof value!=='string'){return '';}var normalized=value.replace(/[{}]/g,'').toLowerCase();return normalized==='00000000-0000-0000-0000-000000000000'?'':normalized;}
function buildRequestErrorMessage(request){var message=String(request.status||0);if(request.statusText){message+=' '+request.statusText;}if(request.responseText){try{var payload=JSON.parse(request.responseText);if(payload&&payload.error&&payload.error.message){message+=': '+(payload.error.message.value||payload.error.message);}}catch(ignoreError){}}return message;}
function getAvailableSolutionKeys(){return Object.keys(SUPPORTED_SOLUTIONS).filter(function(key){return !!state.solutions[key];});}
function getActionLocationText(info){if(!info||!info.action){return '-';}var scopeLabel=info.scopeLabel?info.scopeLabel+' | ':'';return scopeLabel+(info.action.Location||'ClientSideExtension.ApplicationCustomizer');}
function getWebUrl(){if(window._spPageContextInfo&&_spPageContextInfo.webAbsoluteUrl){return _spPageContextInfo.webAbsoluteUrl;}return window.location.origin||'';}
function getSiteUrl(){if(window._spPageContextInfo&&_spPageContextInfo.siteAbsoluteUrl){return _spPageContextInfo.siteAbsoluteUrl;}return getWebUrl();}
function showStatus(message,type,keepVisible){var html='<div class="ps-status ps-status-'+type+'">'+escapeHtml(message)+'</div>';var host=document.getElementById('statusMessage');host.innerHTML=html;var dialog=document.getElementById('expiryBindingDialog');var dialogHost=document.getElementById('bindExpiryStatus');if(dialog&&dialog.hidden===false&&dialogHost){dialogHost.innerHTML=html;}if(!keepVisible){window.clearTimeout(showStatus._timer);showStatus._timer=window.setTimeout(function(){host.innerHTML='';if(dialogHost){dialogHost.innerHTML='';}},4200);}}
function escapeHtml(text){var div=document.createElement('div');div.textContent=text===null||text===undefined?'':String(text);return div.innerHTML;}
</script></div></div>
</asp:Content>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="PortalSettingsPageV11" Url="SitePages" RootWebOnly="TRUE">
<File
Path="PortalSettings.aspx"
Url="PortalSettings.aspx"
Type="GhostableInLibrary"
IgnoreIfAlreadyExists="FALSE"
ReplaceContent="TRUE" />
</Module>
</Elements>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="PortalSettingsPageV2" Url="SitePages" RootWebOnly="TRUE">
<File
Path="PortalSettings.aspx"
Url="PortalSettings.aspx"
Type="GhostableInLibrary"
IgnoreIfAlreadyExists="FALSE"
ReplaceContent="TRUE" />
</Module>
</Elements>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="PortalSettingsPage" Url="SitePages" RootWebOnly="TRUE">
<File
Path="PortalSettings.aspx"
Url="PortalSettings.aspx"
Type="GhostableInLibrary"
IgnoreIfAlreadyExists="FALSE" />
</Module>
</Elements>

View File

@@ -1,3 +0,0 @@
<ApplyElementManifests>
<ElementManifest Location="57bd9daf-48f9-49e2-aebd-11b15a0b5e93\elements-v1.1.xml" />
</ApplyElementManifests>

View File

@@ -1,3 +0,0 @@
<ApplyElementManifests>
<ElementManifest Location="57bd9daf-48f9-49e2-aebd-11b15a0b5e93\elements-v2.xml" />
</ApplyElementManifests>

View File

@@ -1,117 +0,0 @@
<%@ Page language="C#" MasterPageFile="~masterurl/default.master" Inherits="Microsoft.SharePoint.WebPartPages.WebPartPage, Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register Tagprefix="Utilities" Namespace="Microsoft.SharePoint.Utilities" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register Tagprefix="WebPartPages" Namespace="Microsoft.SharePoint.WebPartPages" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<asp:Content ContentPlaceHolderID="PlaceHolderAdditionalPageHead" runat="server">
<SharePoint:ScriptLink language="javascript" name="sp.js" runat="server" Defer="true" LoadAfterUI="true" Localizable="false" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
:root{--ps-bg:#f4f7fb;--ps-surface:#fff;--ps-border:#d8e1ec;--ps-ink:#16263d;--ps-muted:#607086;--ps-accent:#0f6cbd;--ps-soft:#eaf2fb;--ps-success:#107c10;--ps-warning:#a15c00;--ps-danger:#b42318;--ps-shadow:0 16px 34px rgba(20,35,59,.08);--ps-radius:18px}
.ps-page{background:linear-gradient(180deg,#f9fbfe 0%,var(--ps-bg) 100%);padding:28px 0 40px}.ps-shell{width:min(1240px,calc(100% - 32px));margin:0 auto}.ps-hero{background:linear-gradient(135deg,#123660 0%,#0f6cbd 60%,#58a3e0 100%);color:#fff;border-radius:24px;padding:28px 30px;box-shadow:var(--ps-shadow)}.ps-hero h1{margin:0 0 10px;font-size:34px;line-height:1.1}.ps-hero p{margin:0;max-width:860px;line-height:1.6;color:rgba(255,255,255,.92)}.ps-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.16em;opacity:.85;margin-bottom:12px}.ps-status-host{min-height:56px;margin-bottom:18px}.ps-status{padding:14px 16px;border-radius:12px;border:1px solid transparent;background:#fff;box-shadow:0 10px 24px rgba(20,35,59,.06)}.ps-status-info{color:var(--ps-accent);background:#f3f8fe;border-color:rgba(15,108,189,.2)}.ps-status-success{color:var(--ps-success);background:#eff8ef;border-color:rgba(16,124,16,.2)}.ps-status-warning{color:var(--ps-warning);background:#fff6eb;border-color:rgba(161,92,0,.2)}.ps-status-error{color:var(--ps-danger);background:#fff1f0;border-color:rgba(180,35,24,.2)}.ps-metrics,.ps-grid{display:grid;gap:16px;margin-top:18px}.ps-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}.ps-grid{grid-template-columns:repeat(12,minmax(0,1fr))}.ps-card,.ps-panel,.ps-metric,.ps-empty{background:var(--ps-surface);border:1px solid var(--ps-border);border-radius:var(--ps-radius);box-shadow:0 10px 24px rgba(20,35,59,.05)}.ps-metric{padding:16px 18px}.ps-metric label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--ps-muted);margin-bottom:8px}.ps-metric strong{display:block;font-size:18px;line-height:1.45;word-break:break-word}.ps-tabs{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}.ps-tab,.ps-btn{height:42px;padding:0 18px;border-radius:999px;border:1px solid var(--ps-border);background:#fff;color:var(--ps-ink);font-size:14px;font-weight:600;cursor:pointer}.ps-tab.is-active{border-color:var(--ps-accent);background:var(--ps-soft);color:var(--ps-accent)}.ps-empty{margin-top:18px;padding:24px;color:var(--ps-muted);line-height:1.7}.ps-panel{margin-top:18px;padding:22px}.ps-panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:18px}.ps-panel-head h2,.ps-card h3{margin:0 0 8px}.ps-panel-head p,.ps-card p,.ps-note,.ps-help{margin:0;color:var(--ps-muted);line-height:1.6}.ps-chip{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:28px;padding:0 10px;border-radius:999px;background:var(--ps-soft);color:var(--ps-accent);font-size:12px;font-weight:700}.ps-card{padding:20px}.ps-span-4{grid-column:span 4}.ps-span-6{grid-column:span 6}.ps-span-8{grid-column:span 8}.ps-span-12{grid-column:span 12}.ps-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:14px 0}.ps-meta div{padding:12px;border:1px solid var(--ps-border);border-radius:12px;background:#f8fbff}.ps-meta label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--ps-muted);margin-bottom:6px}.ps-meta strong{display:block;font-size:14px;line-height:1.55;word-break:break-word}.ps-label{display:block;margin:0 0 8px;font-size:13px;font-weight:600}.ps-input,.ps-textarea,.ps-preview{width:100%;box-sizing:border-box;border:1px solid var(--ps-border);border-radius:12px;background:#fbfdff;color:var(--ps-ink)}.ps-input{height:44px;padding:0 14px;font-size:14px}.ps-textarea,.ps-preview{min-height:260px;padding:14px;font:13px/1.6 Consolas,'Courier New',monospace;white-space:pre-wrap;overflow:auto}.ps-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center}.ps-list{display:grid;gap:10px;margin-top:14px}.ps-item,.ps-list-empty{border:1px solid var(--ps-border);border-radius:12px;background:#fbfdff}.ps-item{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:14px}.ps-list-empty{padding:18px;text-align:center;color:var(--ps-muted)}.ps-path{font:13px/1.6 Consolas,'Courier New',monospace;word-break:break-all}.ps-actions{display:flex;gap:10px;flex-wrap:wrap}.ps-actions-bar{position:sticky;bottom:16px;z-index:4;display:flex;justify-content:space-between;align-items:center;gap:14px;margin-top:18px;padding:16px 18px;border:1px solid rgba(15,108,189,.14);border-radius:20px;background:rgba(255,255,255,.92);box-shadow:0 16px 32px rgba(20,35,59,.12)}.ps-btn-primary{background:linear-gradient(135deg,var(--ps-accent) 0%,#1b86e0 100%);border-color:var(--ps-accent);color:#fff}.ps-btn-quiet{background:#f7faff}.ps-btn-danger{background:#fff6f5;border-color:rgba(180,35,24,.18);color:var(--ps-danger)}.ps-check{display:flex;align-items:center;gap:10px;min-height:44px}.ps-check input{width:18px;height:18px}.ps-note{margin-top:14px;padding:14px 16px;border-radius:12px;background:#f8fbff;border:1px solid var(--ps-border)}
.ps-hero-actions{margin-top:18px}.ps-modal-backdrop{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(10,25,45,.58)}.ps-modal-backdrop[hidden]{display:none}.ps-modal{width:min(620px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:24px;border-radius:20px;background:#fff;box-shadow:0 28px 70px rgba(0,0,0,.28)}.ps-modal h2{margin:0 0 8px}.ps-modal p{margin:0 0 18px;color:var(--ps-muted);line-height:1.6}.ps-modal .ps-actions{justify-content:flex-end;margin-top:22px}
@media (max-width:1080px){.ps-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.ps-span-4,.ps-span-6,.ps-span-8{grid-column:span 12}.ps-meta{grid-template-columns:minmax(0,1fr)}}@media (max-width:720px){.ps-shell{width:calc(100% - 20px)}.ps-metrics{grid-template-columns:minmax(0,1fr)}.ps-inline,.ps-actions-bar{display:grid;grid-template-columns:minmax(0,1fr)}.ps-actions .ps-btn,.ps-inline .ps-btn,.ps-tab{width:100%}.ps-hero h1{font-size:28px}}
</style>
</asp:Content>
<asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server">
<div class="ps-page"><div class="ps-shell"><div id="statusMessage" class="ps-status-host"></div><section class="ps-hero"><div class="ps-eyebrow">Portal Settings</div><h1>Zentrale Verwaltung fuer aktive Portal-Erweiterungen</h1><p>Diese Seite erkennt installierte Einstellungsanbieter automatisch und zeigt nur deren Tabs. Konfigurationen werden in UserCustomActions, Feld-Properties oder im Property Bag des Root Webs gespeichert.</p><div class="ps-hero-actions"><button type="button" class="ps-btn" onclick="openExpiryBindingDialog()">ExpiryIndicator anbinden</button></div></section><section class="ps-metrics"><div class="ps-metric"><label>Aktive Erweiterungen</label><strong id="activeSolutionCount">0</strong></div><div class="ps-metric"><label>Aktiver Tab</label><strong id="activeTabValue">-</strong></div><div class="ps-metric"><label>Site Collection</label><strong id="siteUrlValue">-</strong></div><div class="ps-metric"><label>Erkannte Erweiterungen</label><strong id="detectedSolutionsValue">Keine</strong></div></section><div id="tabsHost" class="ps-tabs" role="tablist" aria-label="Portal Settings Tabs"></div><section id="emptyState" class="ps-empty" hidden>Keine unterstuetzten Erweiterungen wurden aktiv gefunden. Aktuell werden <strong>Custom Branding</strong>, <strong>Mega Menu</strong> und <strong>ExpiryIndicator</strong> unterstuetzt.</section>
<section id="panel-branding" class="ps-panel" hidden><div class="ps-panel-head"><div><h2>Custom Branding</h2><p>Pflegt die ClientSideComponentProperties des aktiven Custom Branding Application Customizers.</p></div><span id="brandingChip" class="ps-chip">0</span></div><div class="ps-grid"><div class="ps-card ps-span-4"><h3>CSS-Dateien</h3><p>Lokale SiteAssets-Pfade oder externe Stylesheets.</p><div class="ps-meta"><div><label>Component Id</label><strong id="brandingComponentId">-</strong></div><div><label>Registrierung</label><strong id="brandingLocation">-</strong></div></div><label class="ps-label" for="brandingCssPath">Neue CSS-Datei</label><div class="ps-inline"><input type="text" id="brandingCssPath" class="ps-input" placeholder="/sites/root/SiteAssets/custom-branding.css oder https://cdn.example.com/style.css" /><button type="button" class="ps-btn ps-btn-primary" onclick="addBrandingCssFile()">Hinzufuegen</button></div><div id="brandingCssList" class="ps-list"></div></div><div class="ps-card ps-span-4"><h3>Placeholder Top</h3><p>JSON-Array fuer den oberen Placeholder.</p><label class="ps-label" for="brandingTopJson">Top JSON</label><textarea id="brandingTopJson" class="ps-textarea" spellcheck="false"></textarea></div><div class="ps-card ps-span-4"><h3>Placeholder Bottom</h3><p>JSON-Array fuer den unteren Placeholder.</p><label class="ps-label" for="brandingBottomJson">Bottom JSON</label><textarea id="brandingBottomJson" class="ps-textarea" spellcheck="false"></textarea></div><div class="ps-card ps-span-12"><h3>ClientSideComponentProperties Preview</h3><p>Dieses JSON wird direkt in die aktive UserCustomAction fuer Custom Branding geschrieben.</p><pre id="brandingPreview" class="ps-preview"></pre><div class="ps-note"><strong>Schema:</strong> <code>{ cssfiles, placeholdertop: { elements: [] }, placeholderbottom: { elements: [] } }</code></div></div></div><div class="ps-actions-bar"><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="reloadSolutions('branding')">Neu laden</button><button type="button" class="ps-btn ps-btn-quiet" onclick="validateBrandingEditors()">JSON validieren</button><button type="button" class="ps-btn ps-btn-quiet" onclick="formatBrandingEditors()">JSON formatieren</button></div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-primary" onclick="applyBrandingConfig()">Konfiguration anwenden</button></div></div></section>
<section id="panel-megamenu" class="ps-panel" hidden><div class="ps-panel-head"><div><h2>Mega Menu</h2><p>Eine zentrale Konfiguration fuer die gesamte Site Collection. PortalSettings synchronisiert die Runtime-Bindung auf Root Web und alle Unterwebs.</p></div><span id="megaMenuChip" class="ps-chip">OK</span></div><div class="ps-grid"><div class="ps-card ps-span-6"><h3>Navigation</h3><p>Waehlt das Termset aus dem Default Site Collection Term Store und pflegt Cache sowie Debug-Verhalten. Individuelle Styles werden ueber Custom Branding eingebunden.</p><div class="ps-meta"><div><label>Component Id</label><strong id="megaMenuComponentId">-</strong></div><div><label>Konfigurationsbereich</label><strong id="megaMenuLocation">Site Collection</strong></div></div><input type="hidden" id="megaMenuTermSetId" /><input type="hidden" id="megaMenuTermSetName" /><label class="ps-label">Ausgewaehltes Termset</label><div class="ps-item"><div><strong id="megaMenuSelectedTermSetName">Kein Termset ausgewaehlt</strong><div id="megaMenuSelectedTermSetMeta" class="ps-path">-</div></div><div class="ps-actions"><button type="button" class="ps-btn" onclick="openMegaMenuTermSetDialog()">Auswaehlen</button><button type="button" class="ps-btn ps-btn-quiet" onclick="clearMegaMenuTermSet()">Leeren</button></div></div><label class="ps-label" for="megaMenuCacheMinutes" style="margin-top:16px;">Cache-Dauer in Minuten</label><input type="number" id="megaMenuCacheMinutes" class="ps-input" min="1" max="1440" value="15" /><label class="ps-label" style="margin-top:16px;">Debug-Modus</label><div class="ps-check"><input type="checkbox" id="megaMenuDebug" /><label for="megaMenuDebug" style="margin:0;font-weight:600;">Debug-Ausgaben in der Browser-Konsole aktivieren</label></div><div class="ps-note"><strong>Speicherort:</strong> Property Bag des Root Webs. Beim Speichern werden alle vorhandenen Unterwebs rekursiv synchronisiert.</div></div><div class="ps-card ps-span-6"><h3>Zentrale Konfiguration</h3><p>Dieses JSON wird einmal zentral gespeichert und identisch in die web-scoped Runtime-Bindungen geschrieben.</p><pre id="megaMenuPreview" class="ps-preview"></pre><div class="ps-note"><strong>Schema:</strong> <code>{ termSetId, termSetName, cacheMinutes, debug }</code></div></div></div><div class="ps-actions-bar"><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="reloadSolutions('megamenu')">Neu laden</button></div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-primary" onclick="applyMegaMenuConfig()">Speichern und alle Webs synchronisieren</button></div></div></section>
<section id="panel-expiryindicator" class="ps-panel" hidden><div class="ps-panel-head"><div><h2>ExpiryIndicator 2.1</h2><p>Zentrale Standards fuer die Site Collection und lokale Ausnahmen fuer einzelne Listen oder Bibliotheken.</p></div><span id="expiryIndicatorChip" class="ps-chip">V2.1</span></div><div class="ps-grid"><div class="ps-card ps-span-4"><h3>Site-Collection-Standard</h3><p>Gilt im Root Web und in allen Subwebs, sofern eine Bindung auf Vererbung steht.</p><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-quiet" onclick="loadExpirySiteDefaults()">Standard laden</button><button type="button" class="ps-btn ps-btn-primary" onclick="saveExpirySiteDefaults()">Standard speichern</button></div><div class="ps-note"><strong>Speicherort:</strong> Property Bag des Root Webs. Es wird keine Konfigurationsliste verwendet.</div></div><div class="ps-card ps-span-8"><h3>Registrierte Bindungen</h3><p>Listen und Bibliotheken, die per PortalSettings oder Aktivierungsskript registriert wurden.</p><div id="expiryBindingsList" class="ps-list"><div class="ps-list-empty">Keine Bindungen registriert.</div></div><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-quiet" onclick="openExpiryBindingDialog()">Bindung hinzufuegen</button><button type="button" class="ps-btn ps-btn-quiet" onclick="setAllExpiryBindingsToInheritance()">Alle auf Vererbung stellen</button></div></div><div class="ps-card ps-span-4"><h3>Ausgewaehltes Ziel</h3><p>Web, Liste und interner Name des gebundenen Ablaufdatumsfeldes.</p><label class="ps-label" for="expiryWebUrl" style="margin-top:16px;">Web URL</label><input type="text" id="expiryWebUrl" class="ps-input" /><label class="ps-label" for="expiryListTitle" style="margin-top:16px;">Listentitel</label><input type="text" id="expiryListTitle" class="ps-input" placeholder="Dokumente" /><label class="ps-label" for="expiryFieldName" style="margin-top:16px;">Interner Feldname</label><input type="text" id="expiryFieldName" class="ps-input" placeholder="ExpiryDate" /><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-primary" onclick="loadExpiryIndicatorConfig()">Lokale Konfiguration laden</button><button type="button" class="ps-btn ps-btn-quiet" onclick="setSelectedExpiryBindingToInheritance()">Standard erben</button></div></div><div class="ps-card ps-span-8"><h3>Konfigurationseditor</h3><p>Unterstuetzt V1-Regeln sowie verschachtelte V2-<code>condition</code>-Gruppen mit <code>and</code>/<code>or</code>.</p><textarea id="expiryIndicatorJson" class="ps-textarea" style="min-height:430px" spellcheck="false"></textarea><div class="ps-note"><strong>Prioritaet:</strong> lokale Feldkonfiguration &gt; Subweb-Override &gt; Site-Collection-Standard &gt; eingebauter Standard.</div></div></div><div class="ps-actions-bar"><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="validateExpiryIndicatorConfig()">JSON validieren</button><button type="button" class="ps-btn ps-btn-quiet" onclick="formatExpiryIndicatorConfig()">JSON formatieren</button></div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-primary" onclick="applyExpiryIndicatorConfig()">Als lokale Ausnahme speichern</button></div></div></section>
<div id="expiryBindingDialog" class="ps-modal-backdrop" hidden><div class="ps-modal" role="dialog" aria-modal="true" aria-labelledby="expiryBindingDialogTitle"><h2 id="expiryBindingDialogTitle">ExpiryIndicator anbinden</h2><p>Verbindet ein bereits vorhandenes Datumsfeld mit dem Field Customizer und registriert die Liste zentral.</p><div id="bindExpiryStatus"></div><label class="ps-label" for="bindExpiryWebUrl">Web URL</label><input type="text" id="bindExpiryWebUrl" class="ps-input" /><label class="ps-label" for="bindExpiryListTitle" style="margin-top:16px;">Listentitel</label><input type="text" id="bindExpiryListTitle" class="ps-input" placeholder="Documents" /><label class="ps-label" for="bindExpiryFieldName" style="margin-top:16px;">Interner Name des vorhandenen Datumsfeldes</label><input type="text" id="bindExpiryFieldName" class="ps-input" placeholder="ExpiryDate" /><div class="ps-check" style="margin-top:14px;"><input type="checkbox" id="bindExpiryClassic" checked="checked" /><label for="bindExpiryClassic" style="margin:0;font-weight:600;">Classic ScriptLink und Ribbon-Befehl registrieren</label></div><div class="ps-note">Die Solution legt keine Spalte an. Bei bereits vorhandener lokaler Konfiguration bleiben die Component Properties erhalten.</div><div class="ps-actions"><button type="button" class="ps-btn ps-btn-quiet" onclick="closeExpiryBindingDialog()">Abbrechen</button><button type="button" id="bindExpirySubmit" class="ps-btn ps-btn-primary" onclick="bindExpiryIndicatorFromDialog()">Bindung anlegen</button></div></div></div>
<div id="megaMenuTermSetDialog" class="ps-modal-backdrop" hidden><div class="ps-modal" role="dialog" aria-modal="true" aria-labelledby="megaMenuTermSetDialogTitle"><h2 id="megaMenuTermSetDialogTitle">Termset auswaehlen</h2><p>Termsets aus dem Default Site Collection Term Store.</p><div id="megaMenuTermSetStatus"></div><label class="ps-label" for="megaMenuTermSetSearch">Suche</label><input type="text" id="megaMenuTermSetSearch" class="ps-input" placeholder="Gruppe oder Termset" oninput="renderMegaMenuTermSets()" /><div id="megaMenuTermSetList" style="max-height:420px;overflow:auto;margin-top:16px;"></div><div class="ps-actions" style="margin-top:16px;"><button type="button" class="ps-btn ps-btn-quiet" onclick="closeMegaMenuTermSetDialog()">Abbrechen</button></div></div></div>
<script type="text/javascript">
var CUSTOM_SOLUTION_TAG='MSFT-Custom-Solution';var EXPIRY_FIELD_COMPONENT_ID='a555f4fc-d6a6-4421-8189-457449d9bbde';var EXPIRY_DESCRIPTOR_PROPERTY='PortalSettings.Solutions.ExpiryIndicator';var EXPIRY_DEFAULTS_PROPERTY='PortalSettings.ExpiryIndicator.Defaults';var EXPIRY_BINDINGS_PROPERTY='PortalSettings.ExpiryIndicator.Bindings';var MEGAMENU_COMPONENT_ID='c0abbffb-355d-4d4e-bd38-9e15fb811506';var MEGAMENU_LEGACY_COMPONENT_ID='abc3361f-bb2d-491f-aba3-cd51c19a299b';var MEGAMENU_CONFIGURATION_PROPERTY='PortalSettings.MegaMenu.Configuration';var SUPPORTED_SOLUTIONS={branding:{key:'branding',label:'Custom Branding',componentId:'035ba968-6488-4d42-86b3-0470ffcc95b9',panelId:'panel-branding',descriptionTag:'MSFT-Custom-Solution:CustomBranding',providerType:'applicationCustomizer'},megamenu:{key:'megamenu',label:'Mega Menu',componentId:MEGAMENU_COMPONENT_ID,panelId:'panel-megamenu',descriptionTag:'MSFT-Custom-Solution:MegaMenu',providerType:'siteCollectionMegaMenu'},expiryindicator:{key:'expiryindicator',label:'ExpiryIndicator',componentId:EXPIRY_FIELD_COMPONENT_ID,panelId:'panel-expiryindicator',providerType:'rootPropertyBag'}};
var state={booted:false,activeKey:'',solutions:{},megaMenuTermSets:[],megaMenuTermSetsLoaded:false};
document.addEventListener('DOMContentLoaded',function(){bootPage();});
function bootPage(){if(state.booted){return;}if(window.SP&&SP.ClientContext){state.booted=true;bindPage();reloadSolutions();return;}window.setTimeout(bootPage,150);}
function bindPage(){document.getElementById('siteUrlValue').textContent=getSiteUrl();document.getElementById('expiryWebUrl').value=getWebUrl();document.getElementById('expiryIndicatorJson').value=JSON.stringify(defaultExpiryIndicatorConfig('ExpiryDate'),null,2);document.getElementById('brandingCssPath').addEventListener('keypress',function(event){if(event.key==='Enter'){event.preventDefault();addBrandingCssFile();}});document.getElementById('brandingTopJson').addEventListener('input',updateBrandingPreview);document.getElementById('brandingBottomJson').addEventListener('input',updateBrandingPreview);document.getElementById('megaMenuTermSetId').addEventListener('input',updateMegaMenuPreview);document.getElementById('megaMenuTermSetName').addEventListener('input',updateMegaMenuPreview);document.getElementById('megaMenuCacheMinutes').addEventListener('input',updateMegaMenuPreview);document.getElementById('megaMenuDebug').addEventListener('change',updateMegaMenuPreview);}
function reloadSolutions(preferredKey){showStatus('Lade aktive Einstellungsanbieter...','info',true);state.solutions={};loadSolutionsSequentially(Object.keys(SUPPORTED_SOLUTIONS),0,preferredKey||state.activeKey,[],[],function(labels,errors,preferred){var keys=getAvailableSolutionKeys();if(!keys.length){state.activeKey='';renderTabs();renderPanels();updateOverview();showStatus('Keine unterstuetzten Erweiterungen wurden aktiv registriert gefunden.','warning',true);return;}state.activeKey=preferred&&state.solutions[preferred]?preferred:keys[0];hydratePanels();renderTabs();renderPanels();updateOverview();var message='Aktive Erweiterungen geladen: '+labels.join(', ')+'.';if(errors.length){showStatus(message+' Hinweise: '+errors.join(' | '),'warning',true);return;}showStatus(message,'success');});}
function loadSolutionsSequentially(keys,index,preferred,labels,errors,done){if(index>=keys.length){done(labels,errors,preferred);return;}var key=keys[index];var definition=SUPPORTED_SOLUTIONS[key];if(definition.providerType==='rootPropertyBag'){readExpiryProviderInfo(function(info){if(info){state.solutions[key]={definition:definition,info:info,config:info.siteDefaults};labels.push(definition.label);}loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);},function(message){errors.push(definition.label+': '+message);loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);});return;}if(definition.providerType==='siteCollectionMegaMenu'){readMegaMenuProviderInfo(definition,function(info){if(info){state.solutions[key]={definition:definition,info:info,config:normalizeMegaMenuConfig(info.centralConfig||{})};labels.push(definition.label);}loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);},function(message){errors.push(definition.label+': '+message);loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);});return;}readCustomizerActionInfo(definition,function(info){if(info&&info.action){state.solutions[key]={definition:definition,info:info,config:parseSolutionConfig(key,info.action.ClientSideComponentProperties)};labels.push(definition.label);}loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);},function(message){errors.push(definition.label+': '+message);loadSolutionsSequentially(keys,index+1,preferred,labels,errors,done);});}
function hydratePanels(){if(state.solutions.branding){populateBrandingPanel(state.solutions.branding);}if(state.solutions.megamenu){populateMegaMenuPanel(state.solutions.megamenu);}if(state.solutions.expiryindicator){renderExpiryBindings();}}
function renderTabs(){var host=document.getElementById('tabsHost');var keys=getAvailableSolutionKeys();if(!keys.length){host.innerHTML='';host.hidden=true;document.getElementById('emptyState').hidden=false;return;}host.hidden=false;document.getElementById('emptyState').hidden=true;var html='';for(var i=0;i<keys.length;i++){var key=keys[i];var def=SUPPORTED_SOLUTIONS[key];var isActive=state.activeKey===key;html+='<button type="button" class="ps-tab'+(isActive?' is-active':'')+'" role="tab" aria-selected="'+(isActive?'true':'false')+'" aria-controls="'+def.panelId+'" onclick="switchTab(\''+key+'\')">'+escapeHtml(def.label)+'</button>'; }host.innerHTML=html;}
function renderPanels(){document.getElementById('panel-branding').hidden=!(state.activeKey==='branding'&&state.solutions.branding);document.getElementById('panel-megamenu').hidden=!(state.activeKey==='megamenu'&&state.solutions.megamenu);document.getElementById('panel-expiryindicator').hidden=!(state.activeKey==='expiryindicator'&&state.solutions.expiryindicator);}
function switchTab(key){if(!state.solutions[key]){return;}state.activeKey=key;renderTabs();renderPanels();updateOverview();}
function updateOverview(){var keys=getAvailableSolutionKeys();document.getElementById('activeSolutionCount').textContent=String(keys.length);document.getElementById('activeTabValue').textContent=state.activeKey&&SUPPORTED_SOLUTIONS[state.activeKey]?SUPPORTED_SOLUTIONS[state.activeKey].label:'-';document.getElementById('detectedSolutionsValue').textContent=keys.length?keys.map(function(key){return SUPPORTED_SOLUTIONS[key].label;}).join(', '):'Keine';}
function populateBrandingPanel(solution){document.getElementById('brandingComponentId').textContent=solution.definition.componentId;document.getElementById('brandingLocation').textContent=getActionLocationText(solution.info);document.getElementById('brandingCssPath').value='';document.getElementById('brandingTopJson').value=JSON.stringify(solution.config.placeholdertop.elements,null,2);document.getElementById('brandingBottomJson').value=JSON.stringify(solution.config.placeholderbottom.elements,null,2);renderBrandingCssFiles();updateBrandingPreview();}
function renderBrandingCssFiles(){var host=document.getElementById('brandingCssList');var solution=state.solutions.branding;var cssFiles=solution?solution.config.cssfiles:[];if(!cssFiles||!cssFiles.length){host.innerHTML='<div class="ps-list-empty">Keine CSS-Dateien konfiguriert.</div>';document.getElementById('brandingChip').textContent='0';return;}var html='';for(var i=0;i<cssFiles.length;i++){html+='<div class="ps-item"><div class="ps-path">'+escapeHtml(cssFiles[i].path)+'</div><button type="button" class="ps-btn ps-btn-danger" onclick="removeBrandingCssFile('+i+')">Entfernen</button></div>'; }host.innerHTML=html;document.getElementById('brandingChip').textContent=String(cssFiles.length);}
function addBrandingCssFile(){var solution=state.solutions.branding;if(!solution){showStatus('Custom Branding ist aktuell nicht aktiv konfiguriert.','warning');return;}var input=document.getElementById('brandingCssPath');var path=input.value.trim();if(!path){showStatus('Bitte geben Sie einen CSS-Pfad ein.','warning');return;}var exists=solution.config.cssfiles.some(function(item){return item.path===path;});if(exists){showStatus('Diese CSS-Datei ist bereits vorhanden.','warning');return;}solution.config.cssfiles.push({path:path});input.value='';renderBrandingCssFiles();updateBrandingPreview();showStatus('CSS-Datei hinzugefuegt.','success');}
function removeBrandingCssFile(index){var solution=state.solutions.branding;if(!solution){return;}solution.config.cssfiles.splice(index,1);renderBrandingCssFiles();updateBrandingPreview();showStatus('CSS-Datei entfernt.','success');}
function buildBrandingConfig(){var solution=state.solutions.branding;if(!solution){throw new Error('Custom Branding ist aktuell nicht aktiv konfiguriert.');}return{cssfiles:solution.config.cssfiles.slice(),placeholdertop:{elements:parseJsonArray('brandingTopJson','Placeholder Top')},placeholderbottom:{elements:parseJsonArray('brandingBottomJson','Placeholder Bottom')}};}
function validateBrandingEditors(){try{state.solutions.branding.config=normalizeBrandingConfig(buildBrandingConfig());updateBrandingPreview();showStatus('Custom Branding JSON ist gueltig.','success');}catch(error){showStatus(error.message,'error');}}
function formatBrandingEditors(){try{state.solutions.branding.config=normalizeBrandingConfig(buildBrandingConfig());document.getElementById('brandingTopJson').value=JSON.stringify(state.solutions.branding.config.placeholdertop.elements,null,2);document.getElementById('brandingBottomJson').value=JSON.stringify(state.solutions.branding.config.placeholderbottom.elements,null,2);updateBrandingPreview();showStatus('Custom Branding JSON wurde formatiert.','success');}catch(error){showStatus(error.message,'error');}}
function updateBrandingPreview(){var preview=document.getElementById('brandingPreview');try{var config=buildBrandingConfig();preview.textContent=JSON.stringify(config,null,2);document.getElementById('brandingChip').textContent=String(config.cssfiles.length);}catch(error){preview.textContent='Fehler: '+error.message;}}
function applyBrandingConfig(){try{var config=buildBrandingConfig();showStatus('Aktualisiere Custom Branding...','info',true);updateSolutionAction('branding',config,function(){state.solutions.branding.config=normalizeBrandingConfig(config);populateBrandingPanel(state.solutions.branding);showStatus('Custom Branding wurde erfolgreich aktualisiert.','success');},function(message){showStatus('Custom Branding konnte nicht aktualisiert werden: '+message,'error',true);});}catch(error){showStatus(error.message,'error');}}
function populateMegaMenuPanel(solution){document.getElementById('megaMenuComponentId').textContent=solution.definition.componentId;document.getElementById('megaMenuLocation').textContent='Site Collection | '+String(solution.info.webCount||0)+' Web-Bindungen';document.getElementById('megaMenuTermSetId').value=solution.config.termSetId||'';document.getElementById('megaMenuTermSetName').value=solution.config.termSetName||'';document.getElementById('megaMenuCacheMinutes').value=String(solution.config.cacheMinutes||15);document.getElementById('megaMenuDebug').checked=solution.config.debug===true;updateMegaMenuSelectedTermSet();updateMegaMenuPreview();}
function buildMegaMenuConfig(){var solution=state.solutions.megamenu;if(!solution){throw new Error('Mega Menu ist aktuell nicht aktiv konfiguriert.');}var termSetId=document.getElementById('megaMenuTermSetId').value.trim();var termSetName=document.getElementById('megaMenuTermSetName').value.trim();var cacheMinutes=Number(document.getElementById('megaMenuCacheMinutes').value);if(termSetId&&!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(termSetId)){throw new Error('Die Term Set ID ist keine gueltige GUID.');}if(!termSetId&&!termSetName){throw new Error('Bitte eine Term Set ID oder einen Term Set Name angeben.');}if(!isFinite(cacheMinutes)||cacheMinutes<1||cacheMinutes>1440){throw new Error('Die Cache-Dauer muss zwischen 1 und 1440 Minuten liegen.');}return{termSetId:termSetId,termSetName:termSetName,cacheMinutes:Math.floor(cacheMinutes),debug:document.getElementById('megaMenuDebug').checked===true};}
function updateMegaMenuPreview(){var preview=document.getElementById('megaMenuPreview');try{var config=buildMegaMenuConfig();preview.textContent=JSON.stringify(config,null,2);document.getElementById('megaMenuChip').textContent=config.debug?'DBG':'OK';}catch(error){preview.textContent='Fehler: '+error.message;document.getElementById('megaMenuChip').textContent='!';}}
function applyMegaMenuConfig(){try{var config=buildMegaMenuConfig();showStatus('Speichere zentrale MegaMenu-Konfiguration und ermittle alle Unterwebs...','info',true);setRootPropertyBagValue(MEGAMENU_CONFIGURATION_PROPERTY,JSON.stringify(config),function(){synchronizeMegaMenuWebs(config,function(result){showStatus('Mega Menu wurde zentral gespeichert und auf '+result.webCount+' Webs synchronisiert.','success',true);reloadSolutions('megamenu');},function(message){showStatus('Die zentrale Konfiguration wurde gespeichert, die Web-Bindungen konnten aber nicht vollstaendig synchronisiert werden: '+message,'error',true);});},function(message){showStatus('Mega Menu konnte nicht zentral gespeichert werden: '+message,'error',true);});}catch(error){showStatus(error.message,'error');}}
function readMegaMenuProviderInfo(definition,onSuccess,onError){var rootUrl=getSiteUrl().replace(/\/$/,'');getJson(rootUrl+'/_api/web/AllProperties',function(propertyPayload){var properties=propertyPayload.d||propertyPayload||{};var centralConfig=propertyBagJson(properties,MEGAMENU_CONFIGURATION_PROPERTY,null);getAllWebUrls(rootUrl,function(webUrls){readCustomizerActionInfo(definition,function(actionInfo){if(!centralConfig&&actionInfo&&actionInfo.action){centralConfig=parseSolutionConfig('megamenu',actionInfo.action.ClientSideComponentProperties);}if(!centralConfig&&!actionInfo){onSuccess(null);return;}onSuccess({action:actionInfo?actionInfo.action:null,updateBaseUrl:actionInfo?actionInfo.updateBaseUrl:'',actionType:'SP.UserCustomAction',scopeLabel:'Site Collection configuration',centralConfig:centralConfig||normalizeMegaMenuConfig({}),webCount:webUrls.length,webUrls:webUrls});},onError);},onError);},onError);}
function getAllWebUrls(rootUrl,onSuccess,onError){var urls=[];var visited={};function visit(webUrl,done){var normalized=String(webUrl||'').replace(/\/$/,'');var key=normalized.toLowerCase();if(!normalized||visited[key]){done();return;}visited[key]=true;urls.push(normalized);getJson(normalized+'/_api/web/webs?$select=Url',function(payload){var children=getResultArray(payload);var index=0;function next(){if(index>=children.length){done();return;}var child=children[index++];visit(child.Url,next);}next();},onError);}visit(rootUrl,function(){onSuccess(urls);});}
function synchronizeMegaMenuWebs(config,onSuccess,onError){getAllWebUrls(getSiteUrl().replace(/\/$/,''),function(webUrls){var index=0;var failures=[];function next(){if(index>=webUrls.length){if(failures.length){onError(failures.join(' | '));return;}onSuccess({webCount:webUrls.length});return;}var webUrl=webUrls[index++];showStatus('Synchronisiere MegaMenu-Bindung '+index+' von '+webUrls.length+': '+webUrl,'info',true);synchronizeMegaMenuWeb(webUrl,config,next,function(message){failures.push(webUrl+': '+message);next();});}next();},onError);}
function synchronizeMegaMenuWeb(webUrl,config,onSuccess,onError){var actionsUrl=webUrl.replace(/\/$/,'')+'/_api/web/UserCustomActions';getJson(actionsUrl,function(payload){var actions=getResultArray(payload);var current=null;var obsolete=[];for(var i=0;i<actions.length;i++){var componentId=normalizeGuid(actions[i].ClientSideComponentId);if(componentId===normalizeGuid(MEGAMENU_COMPONENT_ID)&&!current){current=actions[i];continue;}if(componentId===normalizeGuid(MEGAMENU_COMPONENT_ID)||componentId===normalizeGuid(MEGAMENU_LEGACY_COMPONENT_ID)){obsolete.push(actions[i]);}}function removeNext(){if(!obsolete.length){upsert();return;}var action=obsolete.shift();postJsonDelete(actionsUrl+"(guid'"+normalizeGuid(action.Id)+"')",removeNext,onError);}function upsert(){var serialized=JSON.stringify(config);if(current&&current.Id){postJsonMerge(actionsUrl+"(guid'"+normalizeGuid(current.Id)+"')",{__metadata:{type:current.__metadata&&current.__metadata.type?current.__metadata.type:'SP.UserCustomAction'},Name:current.Name||createGuidInBraces(),Title:'MegaMenu Application Customizer',Description:'MSFT-Custom-Solution:MegaMenu',Location:'ClientSideExtension.ApplicationCustomizer',ClientSideComponentId:MEGAMENU_COMPONENT_ID,ClientSideComponentProperties:serialized,Sequence:100},onSuccess,onError);return;}postJson(actionsUrl,{__metadata:{type:'SP.UserCustomAction'},Name:createGuidInBraces(),Title:'MegaMenu Application Customizer',Description:'MSFT-Custom-Solution:MegaMenu',Location:'ClientSideExtension.ApplicationCustomizer',ClientSideComponentId:MEGAMENU_COMPONENT_ID,ClientSideComponentProperties:serialized,Sequence:100},onSuccess,onError);}removeNext();},onError);}
function createGuidInBraces(){function part(length){var value='';while(value.length<length){value+=Math.floor(Math.random()*16).toString(16);}return value;}return'{'+part(8)+'-'+part(4)+'-4'+part(3)+'-'+((8+Math.floor(Math.random()*4)).toString(16))+part(3)+'-'+part(12)+'}';}
function updateMegaMenuSelectedTermSet(){var id=document.getElementById('megaMenuTermSetId').value.trim();var name=document.getElementById('megaMenuTermSetName').value.trim();document.getElementById('megaMenuSelectedTermSetName').textContent=name||'Kein Termset ausgewaehlt';document.getElementById('megaMenuSelectedTermSetMeta').textContent=id||'-';}
function clearMegaMenuTermSet(){document.getElementById('megaMenuTermSetId').value='';document.getElementById('megaMenuTermSetName').value='';updateMegaMenuSelectedTermSet();updateMegaMenuPreview();}
function openMegaMenuTermSetDialog(){document.getElementById('megaMenuTermSetDialog').hidden=false;document.getElementById('megaMenuTermSetSearch').value='';document.getElementById('megaMenuTermSetList').innerHTML='';setMegaMenuTermSetStatus('Lade Termsets...','info');if(state.megaMenuTermSetsLoaded){setMegaMenuTermSetStatus('','info');renderMegaMenuTermSets();window.setTimeout(function(){document.getElementById('megaMenuTermSetSearch').focus();},0);return;}loadMegaMenuTermSets();}
function closeMegaMenuTermSetDialog(){document.getElementById('megaMenuTermSetDialog').hidden=true;}
function setMegaMenuTermSetStatus(message,type){var host=document.getElementById('megaMenuTermSetStatus');host.innerHTML=message?'<div class="ps-status ps-status-'+type+'">'+escapeHtml(message)+'</div>':'';}
function loadMegaMenuTermSets(){var requestXml='<Request AddExpandoFieldTypeSuffix="true" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName="PortalSettings" xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009"><Actions><ObjectPath Id="2" ObjectPathId="1" /><ObjectIdentityQuery Id="3" ObjectPathId="1" /><ObjectPath Id="5" ObjectPathId="4" /><ObjectIdentityQuery Id="6" ObjectPathId="4" /><Query Id="7" ObjectPathId="4"><Query SelectAllProperties="false"><Properties><Property Name="Id" ScalarProperty="true" /><Property Name="Name" ScalarProperty="true" /><Property Name="Groups"><Query SelectAllProperties="false"><Properties /></Query><ChildItemQuery SelectAllProperties="false"><Properties><Property Name="Name" ScalarProperty="true" /><Property Name="Id" ScalarProperty="true" /><Property Name="IsSystemGroup" ScalarProperty="true" /><Property Name="TermSets"><Query SelectAllProperties="false"><Properties /></Query><ChildItemQuery SelectAllProperties="false"><Properties><Property Name="Name" ScalarProperty="true" /><Property Name="Id" ScalarProperty="true" /><Property Name="Description" ScalarProperty="true" /></Properties></ChildItemQuery></Property></Properties></ChildItemQuery></Property></Properties></Query></Query></Actions><ObjectPaths><StaticMethod Id="1" Name="GetTaxonomySession" TypeId="{981cbc68-9edc-4f8d-872f-71146fcbb84f}" /><Method Id="4" ParentId="1" Name="GetDefaultSiteCollectionTermStore" /></ObjectPaths></Request>';try{var request=new XMLHttpRequest();request.open('POST',getSiteUrl().replace(/\/$/,'')+'/_vti_bin/client.svc/ProcessQuery',true);request.setRequestHeader('Accept','application/json');request.setRequestHeader('Content-Type','application/xml');var digest=getRequestDigest();if(digest){request.setRequestHeader('X-RequestDigest',digest);}request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status<200||request.status>=300){setMegaMenuTermSetStatus('Termsets konnten nicht geladen werden: '+buildRequestErrorMessage(request),'error');return;}try{var payload=JSON.parse(request.responseText||'[]');var errorInfo=payload&&payload.length?payload[0]:null;if(errorInfo&&errorInfo.ErrorInfo){throw new Error(errorInfo.ErrorInfo.ErrorMessage||'Taxonomy-Abfrage fehlgeschlagen.');}state.megaMenuTermSets=parseMegaMenuTermSets(payload);state.megaMenuTermSetsLoaded=true;setMegaMenuTermSetStatus(state.megaMenuTermSets.length?'':'Keine lesbaren Termsets gefunden.',state.megaMenuTermSets.length?'info':'warning');renderMegaMenuTermSets();window.setTimeout(function(){document.getElementById('megaMenuTermSetSearch').focus();},0);}catch(error){setMegaMenuTermSetStatus('Termsets konnten nicht gelesen werden: '+(error.message||String(error)),'error');}};request.onerror=function(){setMegaMenuTermSetStatus('Netzwerkfehler beim Laden der Termsets.','error');};request.send(requestXml);}catch(error){setMegaMenuTermSetStatus(error.message||String(error),'error');}}
function parseMegaMenuTermSets(payload){var result=[];if(!Array.isArray(payload)){return result;}var store=null;for(var i=0;i<payload.length;i++){if(payload[i]&&payload[i]._ObjectType_==='SP.Taxonomy.TermStore'){store=payload[i];break;}}var groups=store&&store.Groups&&Array.isArray(store.Groups._Child_Items_)?store.Groups._Child_Items_:[];for(var g=0;g<groups.length;g++){var group=groups[g];if(!group||group.IsSystemGroup===true){continue;}var sets=group.TermSets&&Array.isArray(group.TermSets._Child_Items_)?group.TermSets._Child_Items_:[];for(var s=0;s<sets.length;s++){var termSet=sets[s];if(!termSet){continue;}var id=cleanTaxonomyGuid(termSet.Id);if(id){result.push({id:id,name:termSet.Name||id,groupName:group.Name||'Ohne Gruppe',description:termSet.Description||''});}}}result.sort(function(left,right){var a=(left.groupName+' '+left.name).toLowerCase();var b=(right.groupName+' '+right.name).toLowerCase();return a<b?-1:(a>b?1:0);});return result;}
function cleanTaxonomyGuid(value){return String(value||'').replace('/Guid(','').replace(')/','').replace(')','').replace(/[{}]/g,'').toLowerCase();}
function renderMegaMenuTermSets(){var host=document.getElementById('megaMenuTermSetList');var search=document.getElementById('megaMenuTermSetSearch').value.trim().toLowerCase();var html='';for(var i=0;i<state.megaMenuTermSets.length;i++){var item=state.megaMenuTermSets[i];var haystack=(item.groupName+' '+item.name+' '+item.description+' '+item.id).toLowerCase();if(search&&haystack.indexOf(search)===-1){continue;}html+='<div class="ps-item"><div><strong>'+escapeHtml(item.name)+'</strong><div class="ps-path">'+escapeHtml(item.groupName)+' | '+escapeHtml(item.id)+'</div>'+(item.description?'<div>'+escapeHtml(item.description)+'</div>':'')+'</div><button type="button" class="ps-btn" onclick="selectMegaMenuTermSet('+i+')">Auswaehlen</button></div>'; }host.innerHTML=html||'<div class="ps-list-empty">Keine passenden Termsets gefunden.</div>';}
function selectMegaMenuTermSet(index){var item=state.megaMenuTermSets[index];if(!item){return;}document.getElementById('megaMenuTermSetId').value=item.id;document.getElementById('megaMenuTermSetName').value=item.name;updateMegaMenuSelectedTermSet();updateMegaMenuPreview();closeMegaMenuTermSetDialog();showStatus('Termset ausgewaehlt: '+item.name+'.','success');}
function defaultExpiryIndicatorConfig(expiryField){return{baseField:'Created',expiryField:expiryField||'ExpiryDate',default:{lifeTime:{value:2,unit:'years'},columnRule:[{daysUntilExpiry:0,operator:'lessOrEqual',backgroundColor:'#a4262c',textColor:'#ffffff',label:'Abgelaufen'},{daysUntilExpiry:30,operator:'lessOrEqual',backgroundColor:'#ffaa44',textColor:'#000000',label:'Laeuft bald ab'},{daysUntilExpiry:90,operator:'lessOrEqual',backgroundColor:'#fff4ce',textColor:'#000000',label:'Beobachten'}]},rules:[],nullText:'Kein Ablaufdatum',confirmExtension:true};}
function openExpiryBindingDialog(){document.getElementById('bindExpiryWebUrl').value=getWebUrl();document.getElementById('bindExpiryListTitle').value='';document.getElementById('bindExpiryFieldName').value='ExpiryDate';document.getElementById('bindExpiryStatus').innerHTML='';document.getElementById('bindExpirySubmit').disabled=false;document.getElementById('expiryBindingDialog').hidden=false;window.setTimeout(function(){document.getElementById('bindExpiryListTitle').focus();},0);}
function closeExpiryBindingDialog(){document.getElementById('expiryBindingDialog').hidden=true;}
function getExpiryBindingInput(){var webUrl=document.getElementById('bindExpiryWebUrl').value.trim().replace(/\/$/,'');var listTitle=document.getElementById('bindExpiryListTitle').value.trim();var fieldName=document.getElementById('bindExpiryFieldName').value.trim();if(!webUrl){throw new Error('Bitte eine Web URL angeben.');}if(!listTitle){throw new Error('Bitte einen Listentitel angeben.');}if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)){throw new Error('Der interne Feldname ist ungueltig.');}return{webUrl:webUrl,listTitle:listTitle,fieldName:fieldName,classic:document.getElementById('bindExpiryClassic').checked===true};}
function bindingListUrl(target){return target.webUrl+"/_api/web/lists/getbytitle('"+target.listTitle.replace(/'/g,"''")+"')";}
function bindExpiryIndicatorFromDialog(){var target;try{target=getExpiryBindingInput();}catch(error){showStatus(error.message,'error',true);return;}var button=document.getElementById('bindExpirySubmit');button.disabled=true;showStatus('Pruefe Liste und Ablaufdatumsfeld...','info',true);var listUrl=bindingListUrl(target);getJson(listUrl+'?$select=Id,Title,BaseType',function(listPayload){var list=listPayload.d||listPayload;getJson(listUrl+"/fields/getbyinternalnameortitle('"+target.fieldName+"')?$select=Id,InternalName,TypeAsString,ClientSideComponentId,ClientSideComponentProperties",function(fieldPayload){var field=fieldPayload.d||fieldPayload;var currentComponent=normalizeGuid(field.ClientSideComponentId);if(currentComponent&&currentComponent!==normalizeGuid(EXPIRY_FIELD_COMPONENT_ID)){button.disabled=false;showStatus('Das Feld ist bereits mit einem anderen Field Customizer verbunden. Die Bindung wurde nicht geaendert.','error',true);return;}if(field.TypeAsString&&field.TypeAsString!=='DateTime'){button.disabled=false;showStatus('Das angegebene Feld ist kein DateTime-Feld.','error',true);return;}var properties=field.ClientSideComponentProperties;if(!properties||!String(properties).trim()){properties=JSON.stringify({schemaVersion:2,expiryField:field.InternalName,inheritSiteDefaults:true});}var payload={__metadata:{type:field.__metadata&&field.__metadata.type?field.__metadata.type:'SP.Field'},ClientSideComponentId:EXPIRY_FIELD_COMPONENT_ID,ClientSideComponentProperties:properties};postJsonMerge(listUrl+"/fields(guid'"+normalizeGuid(field.Id)+"')",payload,function(){var info={target:target,list:list,field:field,listUrl:listUrl};function finish(classicWarning){registerExpiryBinding(info,function(){button.disabled=false;closeExpiryBindingDialog();reloadSolutions('expiryindicator');if(classicWarning){showStatus('Die moderne Bindung wurde angelegt. Classic konnte nicht vollstaendig registriert werden: '+classicWarning,'warning',true);}else{showStatus('ExpiryIndicator wurde erfolgreich angebunden und zentral registriert.','success');}},function(message){button.disabled=false;showStatus('Das Feld wurde gebunden, aber das zentrale Inventar konnte nicht aktualisiert werden: '+message,'warning',true);});}if(target.classic){ensureExpiryClassicRegistration(info,function(warning){finish(warning);});}else{finish('');}},function(message){button.disabled=false;showStatus('Das Feld konnte nicht mit dem ExpiryIndicator verbunden werden: '+message,'error',true);});},function(message){button.disabled=false;showStatus('Das Ablaufdatumsfeld konnte nicht gelesen werden: '+message,'error',true);});},function(message){button.disabled=false;showStatus('Die Liste oder Bibliothek konnte nicht gelesen werden: '+message,'error',true);});}
function registerExpiryBinding(info,onSuccess,onError){getJson(getSiteUrl().replace(/\/$/,'')+'/_api/web/AllProperties',function(payload){var properties=payload.d||payload||{};var bindings=normalizeExpiryBindings(propertyBagJson(properties,EXPIRY_BINDINGS_PROPERTY,[]));var webUrl=info.target.webUrl.replace(/\/$/,'');var listId=normalizeGuid(info.list.Id);bindings=bindings.filter(function(item){return !(String(item.webUrl||'').replace(/\/$/,'').toLowerCase()===webUrl.toLowerCase()&&normalizeGuid(item.listId)===listId);});bindings.push({webUrl:webUrl,listId:listId,listTitle:info.list.Title,fieldInternalName:info.field.InternalName,fieldId:normalizeGuid(info.field.Id)});var descriptor={key:'ExpiryIndicator',displayName:'Expiry Indicator',version:'2.1.1',provider:'expiryIndicator',componentIds:[EXPIRY_FIELD_COMPONENT_ID,'cd58f5d9-ffc8-4df7-a910-3054842d7abf','95691218-bcb1-4fad-adda-02bb12014d15'],settingsScope:['siteCollection','web','list']};setRootPropertyBagValue(EXPIRY_DESCRIPTOR_PROPERTY,JSON.stringify(descriptor),function(){setRootPropertyBagValue(EXPIRY_BINDINGS_PROPERTY,JSON.stringify(bindings),onSuccess,onError);},onError);},onError);}
function ensureExpiryClassicRegistration(info,onComplete){var webActionsUrl=info.target.webUrl+'/_api/web/UserCustomActions';getJson(webActionsUrl,function(webPayload){var webActions=getResultArray(webPayload);var hasScript=webActions.some(function(action){return action.Name==='ExpiryIndicator.Classic.ScriptLink';});function ensureRibbon(){var listActionsUrl=info.listUrl+'/UserCustomActions';getJson(listActionsUrl,function(listPayload){var listActions=getResultArray(listPayload);var ribbonAction=null;for(var i=0;i<listActions.length;i++){if(listActions[i].Name==='ExpiryIndicator.Classic.Ribbon'){ribbonAction=listActions[i];break;}}var ribbonLocation=Number(info.list.BaseType)===1?'Ribbon.Documents.Manage.Controls._children':'Ribbon.ListItem.Actions.Controls._children';var ribbonXml='<CommandUIExtension xmlns="http://schemas.microsoft.com/sharepoint/"><CommandUIDefinitions><CommandUIDefinition Location="'+ribbonLocation+'"><Button Id="ExpiryIndicator.Classic.ExtendOneYear.Button" Command="ExpiryIndicator.Classic.ExtendOneYear" Sequence="90" LabelText="Ablaufdatum +1 Jahr" Description="Verlaengert das Ablaufdatum der ausgewaehlten Elemente um ein Kalenderjahr." TemplateAlias="o1" /></CommandUIDefinition></CommandUIDefinitions><CommandUIHandlers><CommandUIHandler Command="ExpiryIndicator.Classic.ExtendOneYear" CommandAction="javascript:ExpiryIndicatorClassic.extendSelected();" EnabledScript="javascript:ExpiryIndicatorClassic.canExtend();" /></CommandUIHandlers></CommandUIExtension>';var payload={__metadata:{type:'SP.UserCustomAction'},Name:'ExpiryIndicator.Classic.Ribbon',Title:'ExpiryIndicator Classic ribbon command',Location:'CommandUI.Ribbon',CommandUIExtension:ribbonXml,Sequence:651};if(ribbonAction){if(String(ribbonAction.CommandUIExtension||'').indexOf(ribbonLocation)>=0){onComplete('');return;}postJsonMerge(listActionsUrl+"(guid'"+normalizeGuid(ribbonAction.Id)+"')",payload,function(){onComplete('');},onComplete);return;}postJson(listActionsUrl,payload,function(){onComplete('');},onComplete);},onComplete);}if(hasScript){ensureRibbon();return;}postJson(webActionsUrl,{__metadata:{type:'SP.UserCustomAction'},Name:'ExpiryIndicator.Classic.ScriptLink',Title:'ExpiryIndicator Classic runtime',Location:'ScriptLink',ScriptSrc:'~sitecollection/SiteAssets/ExpiryIndicator/ExpiryIndicatorClassic.js',Sequence:650},ensureRibbon,onComplete);},onComplete);}
function normalizePropertyBagKey(value){return String(value||'').replace(/_x002e_/gi,'.').toLowerCase();}
function propertyBagJson(properties,key,fallback){if(!properties){return fallback;}var value;var expected=normalizePropertyBagKey(key);Object.keys(properties).some(function(name){if(normalizePropertyBagKey(name)===expected){value=properties[name];return true;}return false;});if(typeof value==='undefined'||value===null||value===''){return fallback;}if(typeof value==='object'){return value;}try{return JSON.parse(value);}catch(ignore){return fallback;}}
function normalizeExpiryBindings(value){var result=[];function visit(item){if(Array.isArray(item)){item.forEach(visit);return;}if(!item||typeof item!=='object'){return;}if(item.webUrl&&item.listId&&item.fieldInternalName){result.push(item);}}visit(value);return result;}
function readExpiryProviderInfo(onSuccess,onError){getJson(getSiteUrl().replace(/\/$/,'')+'/_api/web/AllProperties',function(payload){var properties=payload.d||payload||{};var descriptor=propertyBagJson(properties,EXPIRY_DESCRIPTOR_PROPERTY,null);var bindings=normalizeExpiryBindings(propertyBagJson(properties,EXPIRY_BINDINGS_PROPERTY,[]));var siteDefaults=propertyBagJson(properties,EXPIRY_DEFAULTS_PROPERTY,null);if(!descriptor&&!siteDefaults&&!bindings.length){onSuccess(null);return;}onSuccess({descriptor:descriptor,bindings:bindings,siteDefaults:siteDefaults||defaultExpiryIndicatorConfig('ExpiryDate'),rootProperties:properties,selected:null});},onError);}
function renderExpiryBindings(){var solution=state.solutions.expiryindicator;var host=document.getElementById('expiryBindingsList');if(!solution||!solution.info||!solution.info.bindings.length){host.innerHTML='<div class="ps-list-empty">Keine Bindungen registriert. Aktivieren Sie ExpiryIndicator 2.1 erneut an den gewuenschten Listen.</div>';return;}var html='';for(var i=0;i<solution.info.bindings.length;i++){var binding=solution.info.bindings[i];html+='<div class="ps-item"><div><strong>'+escapeHtml(binding.listTitle||binding.listId)+'</strong><div class="ps-path">'+escapeHtml(binding.webUrl)+' | '+escapeHtml(binding.fieldInternalName)+'</div></div><button type="button" class="ps-btn ps-btn-quiet" onclick="selectExpiryBinding('+i+')">Auswaehlen</button></div>';}host.innerHTML=html;document.getElementById('expiryIndicatorChip').textContent=String(solution.info.bindings.length);}
function selectExpiryBinding(index){var solution=state.solutions.expiryindicator;if(!solution||!solution.info||!solution.info.bindings[index]){return;}var binding=solution.info.bindings[index];document.getElementById('expiryWebUrl').value=binding.webUrl||getWebUrl();document.getElementById('expiryListTitle').value=binding.listTitle||'';document.getElementById('expiryFieldName').value=binding.fieldInternalName||'ExpiryDate';loadExpiryIndicatorConfig();}
function loadExpirySiteDefaults(){var solution=state.solutions.expiryindicator;if(!solution||!solution.info){return;}var config=solution.info.siteDefaults||defaultExpiryIndicatorConfig('ExpiryDate');document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);showStatus('Site-Collection-Standard wurde in den Editor geladen.','success');}
function saveExpirySiteDefaults(){var config;try{config=parseExpiryIndicatorEditor();}catch(error){showStatus(error.message,'error',true);return;}showStatus('Speichere Site-Collection-Standard...','info',true);setRootPropertyBagValue(EXPIRY_DEFAULTS_PROPERTY,JSON.stringify(config),function(){state.solutions.expiryindicator.info.siteDefaults=config;state.solutions.expiryindicator.config=config;showStatus('Site-Collection-Standard wurde gespeichert. Vererbende Bindungen verwenden ihn beim naechsten Laden.','success');},function(message){showStatus('Site-Collection-Standard konnte nicht gespeichert werden: '+message,'error',true);});}
function setRootPropertyBagValue(key,value,onSuccess,onError){try{var context=new SP.ClientContext(getSiteUrl());var web=context.get_web();var properties=web.get_allProperties();context.load(properties);context.executeQueryAsync(function(){try{properties.set_item(key,value);web.update();context.executeQueryAsync(onSuccess,function(sender,args){onError(args.get_message());});}catch(error){onError(error.message||String(error));}},function(sender,args){onError(args.get_message());});}catch(error){onError(error.message||String(error));}}
function inheritancePayload(fieldName){return{__metadata:{type:'SP.Field'},ClientSideComponentProperties:JSON.stringify({schemaVersion:2,expiryField:fieldName,inheritSiteDefaults:true})};}
function setSelectedExpiryBindingToInheritance(){var target;try{target=getExpiryTarget();}catch(error){showStatus(error.message,'error');return;}showStatus('Aktiviere Vererbung fuer die ausgewaehlte Bindung...','info',true);postJsonMerge(expiryFieldUrl(target),inheritancePayload(target.fieldName),function(){if(state.solutions.expiryindicator.info){state.solutions.expiryindicator.info.selected=null;}loadExpirySiteDefaults();showStatus('Die Bindung erbt jetzt den Site-Collection-Standard.','success');},function(message){showStatus('Vererbung konnte nicht aktiviert werden: '+message,'error',true);});}
function expiryBindingFieldUrl(binding){return String(binding.webUrl||'').replace(/\/$/,'')+"/_api/web/lists(guid'"+normalizeGuid(binding.listId)+"')/fields/getbyinternalnameortitle('"+String(binding.fieldInternalName||'ExpiryDate').replace(/'/g,"''")+"')";}
function setAllExpiryBindingsToInheritance(){var solution=state.solutions.expiryindicator;var bindings=solution&&solution.info?solution.info.bindings:[];if(!bindings.length){showStatus('Es sind keine Bindungen registriert.','warning');return;}if(!window.confirm('Lokale Konfigurationen von '+bindings.length+' Bindung(en) durch Vererbung ersetzen?')){return;}showStatus('Aktiviere Vererbung fuer '+bindings.length+' Bindung(en)...','info',true);var errors=[];function next(index){if(index>=bindings.length){if(errors.length){showStatus('Vererbung wurde mit Hinweisen abgeschlossen: '+errors.join(' | '),'warning',true);}else{showStatus('Alle registrierten Bindungen erben jetzt den Site-Collection-Standard.','success');}return;}var binding=bindings[index];postJsonMerge(expiryBindingFieldUrl(binding),inheritancePayload(binding.fieldInternalName),function(){next(index+1);},function(message){errors.push((binding.listTitle||binding.listId)+': '+message);next(index+1);});}next(0);}
function getExpiryTarget(){var webUrl=document.getElementById('expiryWebUrl').value.trim().replace(/\/$/,'');var listTitle=document.getElementById('expiryListTitle').value.trim();var fieldName=document.getElementById('expiryFieldName').value.trim();if(!webUrl){throw new Error('Bitte eine Web URL angeben.');}if(!listTitle){throw new Error('Bitte einen Listentitel angeben.');}if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)){throw new Error('Der interne Feldname ist ungueltig.');}return{webUrl:webUrl,listTitle:listTitle,fieldName:fieldName};}
function expiryFieldUrl(target){return target.webUrl+"/_api/web/lists/getbytitle('"+target.listTitle.replace(/'/g,"''")+"')/fields/getbyinternalnameortitle('"+target.fieldName+"')";}
function loadExpiryIndicatorConfig(){var target;try{target=getExpiryTarget();}catch(error){showStatus(error.message,'error');return;}showStatus('Lade ExpiryIndicator-Konfiguration...','info',true);getJson(expiryFieldUrl(target)+'?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties',function(payload){var field=payload.d||payload;if(normalizeGuid(field.ClientSideComponentId)!==normalizeGuid(EXPIRY_FIELD_COMPONENT_ID)){showStatus('Das Feld ist nicht mit dem ExpiryIndicator Field Customizer verbunden.','error',true);return;}var config=defaultExpiryIndicatorConfig(field.InternalName);var inherited=false;if(field.ClientSideComponentProperties&&field.ClientSideComponentProperties.trim()){try{var stored=JSON.parse(field.ClientSideComponentProperties);if(stored.inheritSiteDefaults===true){inherited=true;config=JSON.parse(JSON.stringify(state.solutions.expiryindicator.info.siteDefaults||config));}else{config=stored;}}catch(error){showStatus('Die gespeicherten Component Properties enthalten ungueltiges JSON: '+error.message,'error',true);return;}}config.expiryField=field.InternalName;state.solutions.expiryindicator.info.selected={target:target,field:field,updateUrl:expiryFieldUrl(target),actionType:field.__metadata&&field.__metadata.type?field.__metadata.type:'SP.Field'};state.solutions.expiryindicator.config=config;document.getElementById('expiryFieldName').value=field.InternalName;document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);document.getElementById('expiryIndicatorChip').textContent=inherited?'ERBT':'LOKAL';showStatus(inherited?'Die Bindung erbt den Site-Collection-Standard.':'Lokale ExpiryIndicator-Konfiguration wurde geladen.','success');},function(message){showStatus('ExpiryIndicator-Konfiguration konnte nicht geladen werden: '+message,'error',true);});}
function parseExpiryIndicatorEditor(){var raw=document.getElementById('expiryIndicatorJson').value.trim();if(!raw){throw new Error('Die ExpiryIndicator-Konfiguration darf nicht leer sein.');}var config;try{config=JSON.parse(raw);}catch(error){throw new Error('Ungueltiges JSON: '+error.message);}var errors=validateExpiryIndicatorObject(config);if(errors.length){throw new Error(errors.join(' | '));}return config;}
function validateExpiryIndicatorObject(config){var errors=[];function safeName(value){return/^[A-Za-z_][A-Za-z0-9_]*$/.test(value||'');}function validDuration(value,path){if(!value||typeof value.value!=='number'||!isFinite(value.value)||['days','months','years'].indexOf(value.unit)<0){errors.push(path+' muss value und unit days/months/years enthalten.');}}function validColorRules(value,path){if(!Array.isArray(value)){errors.push(path+' muss ein Array sein.');return;}for(var i=0;i<value.length;i++){var item=value[i];if(!item||typeof item.daysUntilExpiry!=='number'||['lessThan','lessOrEqual','equal','greaterOrEqual','greaterThan'].indexOf(item.operator)<0||typeof item.backgroundColor!=='string'||typeof item.textColor!=='string'){errors.push(path+'['+i+'] ist ungueltig.');}}}function validGroup(group,path,depth){if(depth>10){errors.push(path+' ist tiefer als 10 Ebenen.');return;}if(!group||(group.operator!=='and'&&group.operator!=='or')){errors.push(path+'.operator muss and oder or sein.');}if(!group||!Array.isArray(group.conditions)||!group.conditions.length){errors.push(path+'.conditions muss mindestens einen Eintrag enthalten.');return;}for(var i=0;i<group.conditions.length;i++){var condition=group.conditions[i];var child=path+'.conditions['+i+']';if(condition&&typeof condition.operator!=='undefined'){validGroup(condition,child,depth+1);}else if(!condition||!safeName(condition.columnName)||typeof condition.columnValue==='undefined'){errors.push(child+' benoetigt columnName und columnValue.');}}}if(!config||typeof config!=='object'){return['Konfiguration muss ein Objekt sein.'];}if(!safeName(config.baseField)){errors.push('baseField ist ungueltig.');}if(!safeName(config.expiryField)){errors.push('expiryField ist ungueltig.');}if(!config.default){errors.push('default fehlt.');}else{validDuration(config.default.lifeTime,'default.lifeTime');validColorRules(config.default.columnRule,'default.columnRule');}if(!Array.isArray(config.rules)){errors.push('rules muss ein Array sein.');}else{for(var r=0;r<config.rules.length;r++){var rule=config.rules[r];var path='rules['+r+']';if(rule&&rule.condition){validGroup(rule.condition,path+'.condition',0);}else if(!rule||!safeName(rule.columnName)||typeof rule.columnValue==='undefined'){errors.push(path+' benoetigt condition oder columnName/columnValue.');}if(rule){validDuration(rule.lifeTime,path+'.lifeTime');validColorRules(rule.columnRule,path+'.columnRule');}}}return errors;}
function validateExpiryIndicatorConfig(){try{parseExpiryIndicatorEditor();showStatus('ExpiryIndicator V2 JSON ist gueltig.','success');document.getElementById('expiryIndicatorChip').textContent='OK';}catch(error){showStatus(error.message,'error',true);document.getElementById('expiryIndicatorChip').textContent='!';}}
function formatExpiryIndicatorConfig(){try{var config=parseExpiryIndicatorEditor();document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);showStatus('ExpiryIndicator JSON wurde formatiert.','success');}catch(error){showStatus(error.message,'error',true);}}
function applyExpiryIndicatorConfig(){var solution=state.solutions.expiryindicator;var selected=solution&&solution.info?solution.info.selected:null;if(!selected){showStatus('Bitte zuerst eine lokale ExpiryIndicator-Konfiguration laden.','warning',true);return;}var config;try{config=parseExpiryIndicatorEditor();}catch(error){showStatus(error.message,'error',true);return;}config.expiryField=selected.field.InternalName;var payload={__metadata:{type:selected.actionType},ClientSideComponentProperties:JSON.stringify(config)};showStatus('Aktualisiere lokale ExpiryIndicator-Ausnahme...','info',true);postJsonMerge(selected.updateUrl,payload,function(){solution.config=config;document.getElementById('expiryIndicatorJson').value=JSON.stringify(config,null,2);document.getElementById('expiryIndicatorChip').textContent='LOKAL';showStatus('Lokale ExpiryIndicator-Konfiguration wurde erfolgreich aktualisiert.','success');},function(message){showStatus('ExpiryIndicator konnte nicht aktualisiert werden: '+message,'error',true);});}
function parseSolutionConfig(key,rawValue){var parsed={};if(typeof rawValue==='string'&&rawValue.trim().length>0){try{parsed=JSON.parse(rawValue);}catch(error){parsed={};}}if(key==='branding'){return normalizeBrandingConfig(parsed);}if(key==='megamenu'){return normalizeMegaMenuConfig(parsed);}return parsed||{};}
function normalizeBrandingConfig(config){var normalized={cssfiles:[],placeholdertop:{elements:[]},placeholderbottom:{elements:[]}};if(config&&Array.isArray(config.cssfiles)){normalized.cssfiles=config.cssfiles.filter(function(item){return item&&typeof item.path==='string'&&item.path.trim().length>0;}).map(function(item){return{path:item.path.trim()};});}if(config&&config.placeholdertop&&Array.isArray(config.placeholdertop.elements)){normalized.placeholdertop.elements=config.placeholdertop.elements;}else if(config&&Array.isArray(config.elements)){normalized.placeholdertop.elements=config.elements;}if(config&&config.placeholderbottom&&Array.isArray(config.placeholderbottom.elements)){normalized.placeholderbottom.elements=config.placeholderbottom.elements;}return normalized;}
function normalizeMegaMenuConfig(config){var cacheMinutes=config?Number(config.cacheMinutes):15;return{termSetId:config&&typeof config.termSetId==='string'?config.termSetId.trim():'',termSetName:config&&typeof config.termSetName==='string'?config.termSetName.trim():'',cacheMinutes:isFinite(cacheMinutes)?Math.min(Math.max(Math.floor(cacheMinutes),1),1440):15,debug:!!(config&&config.debug===true)};}
function parseJsonArray(textareaId,label){var textarea=document.getElementById(textareaId);var rawValue=textarea.value.trim();if(!rawValue){return [];}var parsedValue;try{parsedValue=JSON.parse(rawValue);}catch(error){throw new Error(label+' enthaelt kein gueltiges JSON: '+error.message);}if(!Array.isArray(parsedValue)){throw new Error(label+' muss ein JSON-Array sein.');}return parsedValue;}
function updateSolutionAction(key,config,onSuccess,onError){var solution=state.solutions[key];if(!solution||!solution.info||!solution.info.action){onError('Die passende UserCustomAction wurde nicht gefunden.');return;}if(solution.info.scopeLabel!=='Site Collection'){promoteSolutionActionToSiteCollection(key,config,onSuccess,onError);return;}var actionId=solution.info.action.Id;if(!actionId){onError('Die UserCustomAction hat keine gueltige Id.');return;}var updateUrl=solution.info.updateBaseUrl+'(guid\''+actionId+'\')';var payload={__metadata:{type:solution.info.actionType||'SP.UserCustomAction'},ClientSideComponentProperties:JSON.stringify(config)};postJsonMerge(updateUrl,payload,onSuccess,onError);}
function promoteSolutionActionToSiteCollection(key,config,onSuccess,onError){var solution=state.solutions[key];var definition=solution.definition;var oldInfo=solution.info;var oldAction=oldInfo.action;var siteActionsUrl=getSiteUrl().replace(/\/$/,'')+'/_api/site/UserCustomActions';var payload={__metadata:{type:'SP.UserCustomAction'},Name:oldAction.Name||definition.key,Title:oldAction.Title||definition.label,Description:definition.descriptionTag||CUSTOM_SOLUTION_TAG,Location:'ClientSideExtension.ApplicationCustomizer',ClientSideComponentId:definition.componentId,ClientSideComponentProperties:JSON.stringify(config)};postJsonResult(siteActionsUrl,payload,function(createdPayload){var createdAction=createdPayload&&createdPayload.d?createdPayload.d:createdPayload;var oldActionId=oldAction.Id;function finish(){solution.info={action:createdAction&&createdAction.Id?createdAction:{Id:createdAction&&createdAction.Id?createdAction.Id:'',Title:payload.Title,Name:payload.Name,Description:payload.Description,Location:payload.Location,ClientSideComponentId:payload.ClientSideComponentId,ClientSideComponentProperties:payload.ClientSideComponentProperties},updateBaseUrl:siteActionsUrl,actionType:'SP.UserCustomAction',scopeLabel:'Site Collection'};onSuccess();}if(!oldActionId||!oldInfo.updateBaseUrl){finish();return;}var oldUrl=oldInfo.updateBaseUrl+'(guid\''+oldActionId+'\')';postJsonDelete(oldUrl,finish,function(message){if(window.console&&console.warn){console.warn('Die site-scoped Registrierung wurde erstellt, der alte Web-Eintrag konnte aber nicht entfernt werden: '+message);}finish();});},function(message){onError('Die site-scoped UserCustomAction konnte nicht erstellt werden: '+message);});}
function readCustomizerActionInfo(definition,onSuccess,onError){var baseSiteUrl=getSiteUrl().replace(/\/$/,'');var endpoints=[{scopeLabel:'Site Collection',readUrl:baseSiteUrl+'/_api/site/UserCustomActions',updateBaseUrl:baseSiteUrl+'/_api/site/UserCustomActions'},{scopeLabel:'Web',readUrl:baseSiteUrl+'/_api/site/rootweb/UserCustomActions',updateBaseUrl:baseSiteUrl+'/_api/site/rootweb/UserCustomActions'}];readCustomizerActionInfoFromEndpoints(definition,endpoints,0,[],onSuccess,onError);}
function readCustomizerActionInfoFromEndpoints(definition,endpoints,index,errors,onSuccess,onError){if(index>=endpoints.length){if(errors.length){onError(errors.join(' | '));return;}onSuccess(null);return;}var endpoint=endpoints[index];getJson(endpoint.readUrl,function(payload){try{var actions=getResultArray(payload);var action=findActiveCustomizerAction(actions,definition);if(action){onSuccess({action:action,updateBaseUrl:endpoint.updateBaseUrl,actionType:action.__metadata&&action.__metadata.type?action.__metadata.type:'SP.UserCustomAction',scopeLabel:endpoint.scopeLabel});return;}readCustomizerActionInfoFromEndpoints(definition,endpoints,index+1,errors,onSuccess,onError);}catch(error){errors.push(error.message||String(error));readCustomizerActionInfoFromEndpoints(definition,endpoints,index+1,errors,onSuccess,onError);}},function(message){errors.push(message);readCustomizerActionInfoFromEndpoints(definition,endpoints,index+1,errors,onSuccess,onError);});}
function findActiveCustomizerAction(actions,definition){if(!Array.isArray(actions)||!definition){return null;}var expected=normalizeGuid(definition.componentId);var tagged=null;var applicationCustomizer=null;var componentFallback=null;for(var i=0;i<actions.length;i++){var action=actions[i];if(normalizeGuid(action&&action.ClientSideComponentId)!==expected){continue;}if(!componentFallback){componentFallback=action;}var isApplicationCustomizer=typeof action.Location==='string'&&action.Location.indexOf('ClientSideExtension.ApplicationCustomizer')!==-1;if(isApplicationCustomizer&&!applicationCustomizer){applicationCustomizer=action;}if(matchesSolutionTag(action,definition)){if(isApplicationCustomizer){return action;}if(!tagged){tagged=action;}}}return tagged||applicationCustomizer||componentFallback;}function matchesSolutionTag(action,definition){var description=action&&typeof action.Description==='string'?action.Description:'';if(description.indexOf(CUSTOM_SOLUTION_TAG)===-1){return false;}if(definition&&typeof definition.descriptionTag==='string'&&definition.descriptionTag){return description.indexOf(definition.descriptionTag)!==-1||description===CUSTOM_SOLUTION_TAG;}return true;}
function getJson(url,onSuccess,onError){try{var request=new XMLHttpRequest();request.open('GET',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){try{onSuccess(JSON.parse(request.responseText||'{}'));}catch(error){onError('Antwort konnte nicht gelesen werden: '+(error.message||String(error)));}return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Lesen von '+url);};request.send();}catch(error){onError(error.message||String(error));}}
function postJson(url,payload,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden. Laden Sie die Seite neu und versuchen Sie es erneut.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('Content-Type','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){onSuccess();return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Erstellen unter '+url);};request.send(JSON.stringify(payload));}catch(error){onError(error.message||String(error));}}
function postJsonMerge(url,payload,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden. Laden Sie die Seite neu und versuchen Sie es erneut.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('Content-Type','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.setRequestHeader('IF-MATCH','*');request.setRequestHeader('X-HTTP-Method','MERGE');request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){onSuccess();return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Aktualisieren von '+url);};request.send(JSON.stringify(payload));}catch(error){onError(error.message||String(error));}}
function postJsonResult(url,payload,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden. Laden Sie die Seite neu und versuchen Sie es erneut.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('Content-Type','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){try{onSuccess(request.responseText?JSON.parse(request.responseText):{});}catch(error){onError('Erstellte UserCustomAction konnte nicht gelesen werden: '+(error.message||String(error)));}return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Erstellen unter '+url);};request.send(JSON.stringify(payload));}catch(error){onError(error.message||String(error));}}
function postJsonDelete(url,onSuccess,onError){try{var digest=getRequestDigest();if(!digest){onError('Kein Request Digest gefunden.');return;}var request=new XMLHttpRequest();request.open('POST',url,true);request.setRequestHeader('Accept','application/json;odata=verbose');request.setRequestHeader('X-RequestDigest',digest);request.setRequestHeader('IF-MATCH','*');request.setRequestHeader('X-HTTP-Method','DELETE');request.onreadystatechange=function(){if(request.readyState!==4){return;}if(request.status>=200&&request.status<300){onSuccess();return;}onError(buildRequestErrorMessage(request));};request.onerror=function(){onError('Netzwerkfehler beim Entfernen von '+url);};request.send();}catch(error){onError(error.message||String(error));}}
function getRequestDigest(){var digestField=document.getElementById('__REQUESTDIGEST');return digestField&&digestField.value?digestField.value:'';}
function getResultArray(payload){if(payload&&payload.d&&Array.isArray(payload.d.results)){return payload.d.results;}if(payload&&Array.isArray(payload.value)){return payload.value;}return [];}
function normalizeGuid(value){if(typeof value!=='string'){return '';}var normalized=value.replace(/[{}]/g,'').toLowerCase();return normalized==='00000000-0000-0000-0000-000000000000'?'':normalized;}
function buildRequestErrorMessage(request){var message=String(request.status||0);if(request.statusText){message+=' '+request.statusText;}if(request.responseText){try{var payload=JSON.parse(request.responseText);if(payload&&payload.error&&payload.error.message){message+=': '+(payload.error.message.value||payload.error.message);}}catch(ignoreError){}}return message;}
function getAvailableSolutionKeys(){return Object.keys(SUPPORTED_SOLUTIONS).filter(function(key){return !!state.solutions[key];});}
function getActionLocationText(info){if(!info||!info.action){return '-';}var scopeLabel=info.scopeLabel?info.scopeLabel+' | ':'';return scopeLabel+(info.action.Location||'ClientSideExtension.ApplicationCustomizer');}
function getWebUrl(){if(window._spPageContextInfo&&_spPageContextInfo.webAbsoluteUrl){return _spPageContextInfo.webAbsoluteUrl;}return window.location.origin||'';}
function getSiteUrl(){if(window._spPageContextInfo&&_spPageContextInfo.siteAbsoluteUrl){return _spPageContextInfo.siteAbsoluteUrl;}return getWebUrl();}
function showStatus(message,type,keepVisible){var html='<div class="ps-status ps-status-'+type+'">'+escapeHtml(message)+'</div>';var host=document.getElementById('statusMessage');host.innerHTML=html;var dialog=document.getElementById('expiryBindingDialog');var dialogHost=document.getElementById('bindExpiryStatus');if(dialog&&dialog.hidden===false&&dialogHost){dialogHost.innerHTML=html;}if(!keepVisible){window.clearTimeout(showStatus._timer);showStatus._timer=window.setTimeout(function(){host.innerHTML='';if(dialogHost){dialogHost.innerHTML='';}},4200);}}
function escapeHtml(text){var div=document.createElement('div');div.textContent=text===null||text===undefined?'':String(text);return div.innerHTML;}
</script></div></div>
</asp:Content>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="PortalSettingsPageV11" Url="SitePages" RootWebOnly="TRUE">
<File
Path="PortalSettings.aspx"
Url="PortalSettings.aspx"
Type="GhostableInLibrary"
IgnoreIfAlreadyExists="FALSE"
ReplaceContent="TRUE" />
</Module>
</Elements>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="PortalSettingsPageV2" Url="SitePages" RootWebOnly="TRUE">
<File
Path="PortalSettings.aspx"
Url="PortalSettings.aspx"
Type="GhostableInLibrary"
IgnoreIfAlreadyExists="FALSE"
ReplaceContent="TRUE" />
</Module>
</Elements>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="PortalSettingsPage" Url="SitePages" RootWebOnly="TRUE">
<File
Path="PortalSettings.aspx"
Url="PortalSettings.aspx"
Type="GhostableInLibrary"
IgnoreIfAlreadyExists="FALSE" />
</Module>
</Elements>

View File

@@ -1,3 +0,0 @@
<ApplyElementManifests>
<ElementManifest Location="57bd9daf-48f9-49e2-aebd-11b15a0b5e93\elements-v1.1.xml" />
</ApplyElementManifests>

View File

@@ -1,3 +0,0 @@
<ApplyElementManifests>
<ElementManifest Location="57bd9daf-48f9-49e2-aebd-11b15a0b5e93\elements-v2.xml" />
</ApplyElementManifests>

46
src/core/ObjectPath.ts Normal file
View File

@@ -0,0 +1,46 @@
// tslint:disable:no-any
export function getPathValue(source: any, path: string): any {
if (!path) { return source; }
const parts: string[] = path.split('.');
let current: any = source;
for (let i: number = 0; i < parts.length; i++) {
if (current === undefined || current === null) { return undefined; }
current = current[parts[i]];
}
return current;
}
export function setPathValue(target: any, path: string, value: any): void {
const parts: string[] = path.split('.');
let current: any = target;
for (let i: number = 0; i < parts.length - 1; i++) {
if (!current[parts[i]] || typeof current[parts[i]] !== 'object') {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
}
export function cloneValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
export function mergePreservingUnknown(original: any, edited: any): any {
if (!isObject(original) || !isObject(edited)) { return cloneValue(edited); }
const result: any = cloneValue(original);
Object.keys(edited).forEach((key: string): void => {
result[key] = isObject(result[key]) && isObject(edited[key])
? mergePreservingUnknown(result[key], edited[key])
: cloneValue(edited[key]);
});
return result;
}
export function isEmptyValue(value: any): boolean {
return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0);
}
function isObject(value: any): boolean {
return !!value && typeof value === 'object' && !Array.isArray(value);
}

View File

@@ -0,0 +1,108 @@
export type SettingsStorageKind = 'siteUserCustomAction' | 'rootPropertyBag';
export type SettingsLayout = 'singleColumn' | 'twoColumns' | 'threeColumns';
export type SettingsAppearance = 'default' | 'subtle' | 'emphasized' | 'info' | 'success' | 'warning' | 'danger';
export type SettingsElementType =
'heading' | 'text' | 'notice' | 'divider' | 'group' | 'propertySummary' | 'jsonPreview' |
'textField' | 'number' | 'boolean' | 'choice' | 'choiceCards' | 'termSet' | 'stringList' |
'json' | 'bindingInventory';
export interface ISettingsOption {
key: string;
text: string;
description?: string;
iconName?: string;
}
export interface IVisibilityCondition {
field: string;
operator: 'equals' | 'notEquals' | 'isEmpty' | 'isNotEmpty' | 'contains' | 'greaterThan' | 'lessThan';
value?: any; // tslint:disable-line:no-any
}
export interface ISettingsElement {
type: SettingsElementType;
key?: string;
path?: string;
companionNamePath?: string;
text?: string;
title?: string;
label?: string;
description?: string;
placeholder?: string;
level?: 2 | 3 | 4;
appearance?: SettingsAppearance;
columnSpan?: 1 | 2 | 3 | 4 | 6 | 8 | 12;
required?: boolean;
readOnly?: boolean;
minimum?: number;
maximum?: number;
step?: number;
suffix?: string;
options?: ISettingsOption[];
elements?: ISettingsElement[];
visibleWhen?: IVisibilityCondition;
collapsedByDefault?: boolean;
}
export interface ISettingsSection {
key: string;
title: string;
description?: string;
iconName?: string;
layout?: SettingsLayout;
appearance?: SettingsAppearance;
collapsible?: boolean;
collapsedByDefault?: boolean;
elements: ISettingsElement[];
}
export interface IProviderStorageDefinition {
kind: SettingsStorageKind;
propertyKey?: string;
}
export interface ISettingsProvider<TConfig> {
key: string;
displayName: string;
description: string;
iconName?: string;
schemaVersion: number;
minimumPortalSettingsVersion: string;
componentIds: string[];
storage: IProviderStorageDefinition;
sections: ISettingsSection[];
createDefault(): TConfig;
normalize(value: any): TConfig; // tslint:disable-line:no-any
validate(value: TConfig): string[];
}
export interface IUserCustomActionInfo {
id: string;
title: string;
name: string;
location: string;
componentId: string;
properties: string;
entityType: string;
}
export interface IProviderLoadContext {
action?: IUserCustomActionInfo;
propertyBag?: { [key: string]: any }; // tslint:disable-line:no-any
bindings?: any[]; // tslint:disable-line:no-any
}
export interface ILoadedSettingsProvider {
provider: ISettingsProvider<any>; // tslint:disable-line:no-any
config: any; // tslint:disable-line:no-any
originalConfig: any; // tslint:disable-line:no-any
context: IProviderLoadContext;
dirty: boolean;
}
export interface ITermSetInfo {
id: string;
name: string;
groupName: string;
description: string;
}

View File

@@ -1,10 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/component-manifest.schema.json",
"id": "cd6b9e8d-07b9-4468-b230-6bb35acd9a5f",
"alias": "PortalSettingsApplicationCustomizer",
"componentType": "Extension",
"extensionType": "ApplicationCustomizer",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false
}

View File

@@ -1,20 +0,0 @@
import { override } from '@microsoft/decorators';
import { Log } from '@microsoft/sp-core-library';
import { BaseApplicationCustomizer } from '@microsoft/sp-application-base';
import * as strings from 'PortalSettingsApplicationCustomizerStrings';
const LOG_SOURCE: string = 'PortalSettingsApplicationCustomizer';
export interface IPortalSettingsApplicationCustomizerProperties {
}
export default class PortalSettingsApplicationCustomizer
extends BaseApplicationCustomizer<IPortalSettingsApplicationCustomizerProperties> {
@override
public onInit(): Promise<void> {
Log.info(LOG_SOURCE, 'Initialized ' + strings.Title);
return Promise.resolve();
}
}

View File

@@ -1,5 +0,0 @@
define([], function() {
return {
"Title": "PortalSettingsApplicationCustomizer"
}
});

View File

@@ -1,9 +0,0 @@
declare interface IPortalSettingsApplicationCustomizerStrings {
Title: string;
}
declare module 'PortalSettingsApplicationCustomizerStrings' {
const strings: IPortalSettingsApplicationCustomizerStrings;
// tslint:disable-next-line:export-name
export = strings;
}

View File

@@ -1 +1,2 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler
export * from './core/ProviderContracts';
export * from './providers/ProviderRegistry';

View File

@@ -0,0 +1,148 @@
import { ISettingsProvider } from '../core/ProviderContracts';
export interface ICustomBrandingSettings {
schemaVersion: number;
enabled: boolean;
debug: boolean;
allowedCssHosts: string[];
cssfiles: Array<{ path: string; media?: string }>;
placeholdertop: { elements: any[] }; // tslint:disable-line:no-any
placeholderbottom: { elements: any[] }; // tslint:disable-line:no-any
[key: string]: any; // tslint:disable-line:no-any
}
export const CustomBrandingSettingsProvider: ISettingsProvider<ICustomBrandingSettings> = {
key: 'custombranding',
displayName: 'Custom Branding',
description: 'Stylesheets und strukturierte Inhalte für Header und Footer.',
iconName: 'Color',
schemaVersion: 2,
minimumPortalSettingsVersion: '3.0.0',
componentIds: ['035ba968-6488-4d42-86b3-0470ffcc95b9'],
storage: { kind: 'siteUserCustomAction' },
sections: [
{
key: 'general', title: 'Allgemein', layout: 'twoColumns', iconName: 'Settings',
elements: [
{
type: 'boolean', key: 'enabled', path: 'enabled', label: 'CustomBranding aktivieren',
description: 'Deaktiviert Rendering und Stylesheets, erhält aber die Konfiguration.', columnSpan: 6
},
{
type: 'boolean', key: 'debug', path: 'debug', label: 'Debug-Ausgaben aktivieren',
description: 'Protokolliert Validierungs- und Ladeinformationen.', columnSpan: 6
}
]
},
{
key: 'stylesheets', title: 'Stylesheets', layout: 'twoColumns', iconName: 'FileCSS',
description: 'Lokale Stylesheets sind direkt erlaubt; externe Hosts benötigen eine Freigabe.',
elements: [
{
type: 'json', key: 'cssfiles', path: 'cssfiles', label: 'CSS-Dateien', columnSpan: 8,
description: 'Array mit path und optional media.'
},
{
type: 'stringList', key: 'allowedCssHosts', path: 'allowedCssHosts', label: 'Freigegebene CSS-Hosts',
description: 'Nur Hostnamen; externe Stylesheets müssen HTTPS verwenden.', columnSpan: 4
},
{
type: 'notice', appearance: 'warning', title: 'Sicherheitsgrenze', columnSpan: 12,
text: 'Freigaben gelten für die gesamte Site Collection. Nehmen Sie nur kontrollierte Hosts auf.'
}
]
},
{
key: 'content', title: 'Header und Footer', layout: 'twoColumns', iconName: 'WebAppBuilderFragment',
description: 'Die Elementstrukturen werden vor dem Speichern als JSON geprüft.',
elements: [
{ type: 'json', key: 'placeholdertop', path: 'placeholdertop.elements', label: 'Header-Elemente', columnSpan: 6 },
{ type: 'json', key: 'placeholderbottom', path: 'placeholderbottom.elements', label: 'Footer-Elemente', columnSpan: 6 }
]
},
{
key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn',
elements: [
{ type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 },
{ type: 'jsonPreview', title: 'ClientSideComponentProperties', collapsedByDefault: true, columnSpan: 12 }
]
}
],
createDefault: (): ICustomBrandingSettings => ({
schemaVersion: 2,
enabled: true,
debug: false,
allowedCssHosts: [],
cssfiles: [],
placeholdertop: { elements: [] },
placeholderbottom: { elements: [] }
}),
normalize: (value: any): ICustomBrandingSettings => { // tslint:disable-line:no-any
const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any
const result: any = {}; // tslint:disable-line:no-any
Object.keys(source).forEach((key: string): void => { result[key] = source[key]; });
result.schemaVersion = 2;
result.enabled = source.enabled !== false;
result.debug = source.debug === true;
result.allowedCssHosts = Array.isArray(source.allowedCssHosts) ? source.allowedCssHosts : [];
result.cssfiles = Array.isArray(source.cssfiles) ? source.cssfiles : [];
result.placeholdertop = source.placeholdertop && Array.isArray(source.placeholdertop.elements)
? source.placeholdertop : { elements: Array.isArray(source.elements) ? source.elements : [] };
result.placeholderbottom = source.placeholderbottom && Array.isArray(source.placeholderbottom.elements)
? source.placeholderbottom : { elements: [] };
delete result.elements;
return result as ICustomBrandingSettings;
},
validate: (value: ICustomBrandingSettings): string[] => {
const errors: string[] = [];
if (!Array.isArray(value.cssfiles)) { errors.push('CSS-Dateien müssen ein Array sein.'); }
if (!Array.isArray(value.allowedCssHosts)) { errors.push('CSS-Hosts müssen ein Array sein.'); }
if (!value.placeholdertop || !Array.isArray(value.placeholdertop.elements)) { errors.push('Header-Elemente sind ungültig.'); }
if (!value.placeholderbottom || !Array.isArray(value.placeholderbottom.elements)) { errors.push('Footer-Elemente sind ungültig.'); }
const cssFiles: Array<{ path: string; media?: string }> = Array.isArray(value.cssfiles) ? value.cssfiles : [];
if (cssFiles.length > 20) { errors.push('Es sind maximal 20 Stylesheets erlaubt.'); }
for (let i: number = 0; i < cssFiles.length; i++) {
if (!cssFiles[i] || typeof cssFiles[i].path !== 'string' || !cssFiles[i].path.trim()) {
errors.push('CSS-Datei ' + (i + 1) + ' besitzt keinen gültigen Pfad.');
}
}
if (value.placeholdertop && Array.isArray(value.placeholdertop.elements)) {
validateElements(value.placeholdertop.elements, 'Header', 1, { count: 0 }, errors);
}
if (value.placeholderbottom && Array.isArray(value.placeholderbottom.elements)) {
validateElements(value.placeholderbottom.elements, 'Footer', 1, { count: 0 }, errors);
}
return errors;
}
};
const AllowedTags: string[] = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'];
function validateElements(elements: any[], path: string, depth: number, state: { count: number }, errors: string[]): void { // tslint:disable-line:no-any
if (depth > 8) { errors.push(path + ' überschreitet die maximale Tiefe von 8.'); return; }
if (!Array.isArray(elements)) { errors.push(path + ' muss ein Array sein.'); return; }
for (let i: number = 0; i < elements.length; i++) {
const element: any = elements[i]; // tslint:disable-line:no-any
state.count++; if (state.count > 200) { errors.push(path + ' enthält mehr als 200 Elemente.'); return; }
const tag: string = String(element && element.type || '').toLowerCase();
if (!element || AllowedTags.indexOf(tag) < 0) {
errors.push(path + '[' + i + '] verwendet einen nicht erlaubten Elementtyp.'); continue;
}
if (tag === 'img' && (!element.attributes || typeof element.attributes.alt !== 'string')) {
errors.push(path + '[' + i + '] ist ein Bild ohne alt-Attribut.');
}
if (element.attributes && typeof element.attributes === 'object') {
Object.keys(element.attributes).forEach((name: string): void => {
const lower: string = name.toLowerCase(); const attributeValue: string = String(element.attributes[name] || '');
if (lower.indexOf('on') === 0) { errors.push(path + '[' + i + '] enthält ein Ereignisattribut.'); }
if (lower === 'href' || lower === 'src') {
const protocol: RegExpMatchArray = attributeValue.trim().match(/^([a-z][a-z0-9+.-]*):/i);
const allowed: string[] = lower === 'href' ? ['http', 'https', 'mailto'] : ['http', 'https'];
if (protocol && allowed.indexOf(protocol[1].toLowerCase()) < 0) {
errors.push(path + '[' + i + '] enthält eine unsichere URL.');
}
}
});
}
if (element.children) { validateElements(element.children, path + '[' + i + '].children', depth + 1, state, errors); }
}
}

View File

@@ -0,0 +1,169 @@
import { ISettingsProvider } from '../core/ProviderContracts';
export interface IExpiryIndicatorSettings {
baseField: string;
expiryField: string;
default: {
lifeTime: { value: number; unit: 'days' | 'months' | 'years' };
columnRule: any[]; // tslint:disable-line:no-any
};
rules: any[]; // tslint:disable-line:no-any
nullText: string;
confirmExtension: boolean;
[key: string]: any; // tslint:disable-line:no-any
}
export const ExpiryIndicatorSettingsProvider: ISettingsProvider<IExpiryIndicatorSettings> = {
key: 'expiryindicator',
displayName: 'ExpiryIndicator',
description: 'Zentrale Standards, Vererbung und Bindungen für Listen und Bibliotheken.',
iconName: 'DateTime2',
schemaVersion: 2,
minimumPortalSettingsVersion: '3.0.0',
componentIds: [
'a555f4fc-d6a6-4421-8189-457449d9bbde',
'95691218-bcb1-4fad-adda-02bb12014d15',
'cd58f5d9-ffc8-4df7-a910-3054842d7abf'
],
storage: { kind: 'rootPropertyBag', propertyKey: 'PortalSettings.ExpiryIndicator.Defaults' },
sections: [
{
key: 'scope', title: 'Gültigkeitsbereich', layout: 'singleColumn', iconName: 'Globe',
elements: [
{
type: 'notice', appearance: 'info', title: 'Site-Collection-Standard',
text: 'Diese Werte gelten für alle vererbenden Bindungen im Root Web und in Unterwebs.', columnSpan: 12
},
{ type: 'bindingInventory', key: 'bindings', title: 'Registrierte Bindungen', columnSpan: 12 }
]
},
{
key: 'dates', title: 'Datumsberechnung', layout: 'twoColumns', iconName: 'Calendar',
elements: [
{
type: 'textField', key: 'baseField', path: 'baseField', label: 'Basis-Datumsfeld',
description: 'Interner Feldname, normalerweise Created.', required: true, columnSpan: 6
},
{
type: 'textField', key: 'expiryField', path: 'expiryField', label: 'Ablaufdatumsfeld',
description: 'Standardwert für neue Bindungen; vorhandene Bindungen behalten ihren Feldnamen.', required: true, columnSpan: 6
},
{
type: 'number', key: 'lifeTimeValue', path: 'default.lifeTime.value', label: 'Standardlaufzeit',
minimum: 1, maximum: 10000, columnSpan: 6
},
{
type: 'choice', key: 'lifeTimeUnit', path: 'default.lifeTime.unit', label: 'Einheit', columnSpan: 6,
options: [
{ key: 'days', text: 'Tage' }, { key: 'months', text: 'Monate' }, { key: 'years', text: 'Jahre' }
]
},
{
type: 'textField', key: 'nullText', path: 'nullText', label: 'Text ohne Ablaufdatum', columnSpan: 6
},
{
type: 'boolean', key: 'confirmExtension', path: 'confirmExtension',
label: 'Verlängerung bestätigen', description: 'Zeigt vor der Verlängerung eine Sicherheitsabfrage.', columnSpan: 6
}
]
},
{
key: 'rules', title: 'Farben und fachliche Regeln', layout: 'twoColumns', iconName: 'BranchFork',
elements: [
{
type: 'json', key: 'columnRule', path: 'default.columnRule', label: 'Standard-Farbregeln',
description: 'Die erste passende Schwelle gewinnt.', columnSpan: 6
},
{
type: 'json', key: 'rules', path: 'rules', label: 'Feldabhängige Regeln',
description: 'Unterstützt Legacy-Regeln und verschachtelte AND-/OR-Bedingungen.', columnSpan: 6
}
]
},
{
key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn',
elements: [
{ type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 },
{ type: 'jsonPreview', title: 'Zentraler Standard', collapsedByDefault: true, columnSpan: 12 }
]
}
],
createDefault: (): IExpiryIndicatorSettings => ({
baseField: 'Created',
expiryField: 'ExpiryDate',
default: {
lifeTime: { value: 2, unit: 'years' },
columnRule: [
{ daysUntilExpiry: 0, operator: 'lessOrEqual', backgroundColor: '#a4262c', textColor: '#ffffff', label: 'Abgelaufen' },
{ daysUntilExpiry: 30, operator: 'lessOrEqual', backgroundColor: '#ffaa44', textColor: '#000000', label: 'Läuft bald ab' },
{ daysUntilExpiry: 90, operator: 'lessOrEqual', backgroundColor: '#fff4ce', textColor: '#000000', label: 'Beobachten' }
]
},
rules: [], nullText: 'Kein Ablaufdatum', confirmExtension: true
}),
normalize: (value: any): IExpiryIndicatorSettings => { // tslint:disable-line:no-any
const defaults: IExpiryIndicatorSettings = ExpiryIndicatorSettingsProvider.createDefault();
const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any
const result: any = {}; // tslint:disable-line:no-any
Object.keys(source).forEach((key: string): void => { result[key] = source[key]; });
result.baseField = safeName(source.baseField) ? source.baseField : defaults.baseField;
result.expiryField = safeName(source.expiryField) ? source.expiryField : defaults.expiryField;
result.default = source.default && typeof source.default === 'object' ? source.default : defaults.default;
result.default.lifeTime = result.default.lifeTime || defaults.default.lifeTime;
result.default.columnRule = Array.isArray(result.default.columnRule) ? result.default.columnRule : defaults.default.columnRule;
result.rules = Array.isArray(source.rules) ? source.rules : [];
result.nullText = typeof source.nullText === 'string' ? source.nullText : defaults.nullText;
result.confirmExtension = source.confirmExtension !== false;
return result as IExpiryIndicatorSettings;
},
validate: (value: IExpiryIndicatorSettings): string[] => {
const errors: string[] = [];
if (!safeName(value.baseField)) { errors.push('Das Basis-Datumsfeld ist ungültig.'); }
if (!safeName(value.expiryField)) { errors.push('Das Ablaufdatumsfeld ist ungültig.'); }
if (!value.default || !value.default.lifeTime || value.default.lifeTime.value < 1 ||
['days', 'months', 'years'].indexOf(value.default.lifeTime.unit) < 0) { errors.push('Die Standardlaufzeit ist ungültig.'); }
if (!value.default || !Array.isArray(value.default.columnRule)) { errors.push('Die Standard-Farbregeln sind ungültig.'); }
if (!Array.isArray(value.rules)) { errors.push('Die fachlichen Regeln sind ungültig.'); }
if (value.default) { validateColorRules(value.default.columnRule, 'Standard-Farbregeln', errors); }
for (let i: number = 0; i < value.rules.length; i++) {
const rule: any = value.rules[i]; // tslint:disable-line:no-any
if (!rule || (!rule.condition && (!safeName(rule.columnName) || rule.columnValue === undefined))) {
errors.push('Regel ' + (i + 1) + ' benötigt eine Bedingung oder columnName/columnValue.');
}
if (rule && rule.condition) { validateCondition(rule.condition, 'Regel ' + (i + 1), 1, errors); }
if (!rule || !rule.lifeTime || Number(rule.lifeTime.value) < 1 || ['days', 'months', 'years'].indexOf(rule.lifeTime.unit) < 0) {
errors.push('Regel ' + (i + 1) + ' enthält eine ungültige Laufzeit.');
}
if (rule) { validateColorRules(rule.columnRule, 'Regel ' + (i + 1), errors); }
}
return errors;
}
};
function safeName(value: string): boolean {
return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function validateColorRules(rules: any[], path: string, errors: string[]): void { // tslint:disable-line:no-any
if (!Array.isArray(rules)) { return; }
const operators: string[] = ['lessThan', 'lessOrEqual', 'equal', 'greaterOrEqual', 'greaterThan'];
for (let i: number = 0; i < rules.length; i++) {
const rule: any = rules[i]; // tslint:disable-line:no-any
if (!rule || typeof rule.daysUntilExpiry !== 'number' || operators.indexOf(rule.operator) < 0 ||
typeof rule.backgroundColor !== 'string' || typeof rule.textColor !== 'string') {
errors.push(path + '[' + i + '] ist ungültig.');
}
}
}
function validateCondition(condition: any, path: string, depth: number, errors: string[]): void { // tslint:disable-line:no-any
if (depth > 10) { errors.push(path + ' überschreitet die maximale Bedingungstiefe.'); return; }
if (!condition || (condition.operator !== 'and' && condition.operator !== 'or') || !Array.isArray(condition.conditions) || !condition.conditions.length) {
errors.push(path + ' enthält eine ungültige AND-/OR-Bedingungsgruppe.'); return;
}
for (let i: number = 0; i < condition.conditions.length; i++) {
const child: any = condition.conditions[i]; // tslint:disable-line:no-any
if (child && child.operator) { validateCondition(child, path + '.' + i, depth + 1, errors); }
else if (!child || !safeName(child.columnName) || child.columnValue === undefined) { errors.push(path + '.' + i + ' ist keine gültige Feldbedingung.'); }
}
}

View File

@@ -0,0 +1,116 @@
import { ISettingsProvider } from '../core/ProviderContracts';
export interface IMegaMenuSettings {
termSetId: string;
termSetName: string;
cacheMinutes: number;
cacheVersion: string;
menuMode: 'megaMenu' | 'flyout';
debug: boolean;
[key: string]: any; // tslint:disable-line:no-any
}
export const MegaMenuSettingsProvider: ISettingsProvider<IMegaMenuSettings> = {
key: 'megamenu',
displayName: 'Mega Menu',
description: 'Zentrale Navigation für das Root Web und alle Unterwebs.',
iconName: 'GlobalNavButton',
schemaVersion: 1,
minimumPortalSettingsVersion: '3.0.0',
componentIds: [
'c0abbffb-355d-4d4e-bd38-9e15fb811506',
'abc3361f-bb2d-491f-aba3-cd51c19a299b'
],
storage: { kind: 'siteUserCustomAction' },
sections: [
{
key: 'navigation',
title: 'Navigation',
description: 'Navigationsquelle und Darstellung des Menüs.',
iconName: 'BranchFork',
layout: 'twoColumns',
elements: [
{ type: 'heading', level: 3, text: 'Navigationsquelle', columnSpan: 12 },
{
type: 'termSet', key: 'termSetId', path: 'termSetId', companionNamePath: 'termSetName',
label: 'Navigations-Termset', description: 'Das Termset liefert die Navigationseinträge.',
required: true, columnSpan: 12
},
{ type: 'divider', columnSpan: 12 },
{ type: 'heading', level: 3, text: 'Darstellung', columnSpan: 12 },
{
type: 'choiceCards', key: 'menuMode', path: 'menuMode', label: 'Darstellungsmodus', columnSpan: 12,
options: [
{ key: 'megaMenu', text: 'Mega Menu', description: 'Breites, mehrspaltiges Navigationspanel.' },
{ key: 'flyout', text: 'Strukturelles Flyout', description: 'Kompaktes Menü mit seitlichen Untermenüs.' }
]
},
{
type: 'notice', appearance: 'info', title: 'Styling', columnSpan: 12,
text: 'Farben und individuelle Styles werden zentral über CustomBranding verwaltet.'
}
]
},
{
key: 'performance', title: 'Performance und Cache', iconName: 'SpeedHigh', layout: 'twoColumns',
description: 'Steuert die lokale Zwischenspeicherung der Navigation.',
elements: [
{
type: 'number', key: 'cacheMinutes', path: 'cacheMinutes', label: 'Cache-Dauer',
description: 'Gültigkeitsdauer des Browser-Caches.', minimum: 1, maximum: 1440, suffix: 'Minuten', columnSpan: 6
},
{
type: 'textField', key: 'cacheVersion', path: 'cacheVersion', label: 'Cache-Version',
description: 'Eine Änderung verwirft vorhandene Browser-Caches.', placeholder: '1', columnSpan: 6
}
]
},
{
key: 'diagnostics', title: 'Diagnose', iconName: 'Diagnostic', layout: 'singleColumn',
collapsible: true, collapsedByDefault: true,
elements: [
{
type: 'boolean', key: 'debug', path: 'debug', label: 'Debug-Ausgaben aktivieren',
description: 'Schreibt technische Meldungen in die Browserkonsole.', columnSpan: 12
},
{
type: 'notice', appearance: 'warning', title: 'Nur vorübergehend aktivieren',
text: 'Deaktivieren Sie die Debug-Ausgaben nach Abschluss der Diagnose.', columnSpan: 12,
visibleWhen: { field: 'debug', operator: 'equals', value: true }
}
]
},
{
key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn',
elements: [
{ type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 },
{ type: 'jsonPreview', title: 'ClientSideComponentProperties', collapsedByDefault: true, columnSpan: 12 }
]
}
],
createDefault: (): IMegaMenuSettings => ({
termSetId: '', termSetName: '', cacheMinutes: 15, cacheVersion: '1', menuMode: 'megaMenu', debug: false
}),
normalize: (value: any): IMegaMenuSettings => { // tslint:disable-line:no-any
const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any
const minutes: number = Number(source.cacheMinutes);
const result: any = {}; // tslint:disable-line:no-any
Object.keys(source).forEach((key: string): void => { result[key] = source[key]; });
result.termSetId = typeof source.termSetId === 'string' ? source.termSetId.trim() : '';
result.termSetName = typeof source.termSetName === 'string' ? source.termSetName.trim() : '';
result.cacheMinutes = isFinite(minutes) ? Math.min(Math.max(Math.floor(minutes), 1), 1440) : 15;
result.cacheVersion = typeof source.cacheVersion === 'string' && source.cacheVersion.trim() ? source.cacheVersion.trim() : '1';
result.menuMode = source.menuMode === 'flyout' ? 'flyout' : 'megaMenu';
result.debug = source.debug === true;
return result as IMegaMenuSettings;
},
validate: (value: IMegaMenuSettings): string[] => {
const errors: string[] = [];
if (!value.termSetId && !value.termSetName) { errors.push('Bitte ein Navigations-Termset auswählen.'); }
if (value.termSetId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.termSetId)) {
errors.push('Die Termset-ID ist keine gültige GUID.');
}
if (value.cacheMinutes < 1 || value.cacheMinutes > 1440) { errors.push('Die Cache-Dauer muss zwischen 1 und 1440 liegen.'); }
return errors;
}
};

View File

@@ -0,0 +1,21 @@
import { ISettingsProvider } from '../core/ProviderContracts';
import { CustomBrandingSettingsProvider } from './CustomBrandingSettingsProvider';
import { ExpiryIndicatorSettingsProvider } from './ExpiryIndicatorSettingsProvider';
import { MegaMenuSettingsProvider } from './MegaMenuSettingsProvider';
const Providers: Array<ISettingsProvider<any>> = [ // tslint:disable-line:no-any
MegaMenuSettingsProvider,
CustomBrandingSettingsProvider,
ExpiryIndicatorSettingsProvider
];
export function getSettingsProviders(): Array<ISettingsProvider<any>> { // tslint:disable-line:no-any
return Providers.slice();
}
export function getSettingsProvider(key: string): ISettingsProvider<any> | undefined { // tslint:disable-line:no-any
for (let i: number = 0; i < Providers.length; i++) {
if (Providers[i].key === key) { return Providers[i]; }
}
return undefined;
}

View File

@@ -0,0 +1,351 @@
// tslint:disable:no-any max-line-length
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ILoadedSettingsProvider,
IProviderLoadContext,
ISettingsProvider,
IUserCustomActionInfo
} from '../core/ProviderContracts';
import { cloneValue, mergePreservingUnknown } from '../core/ObjectPath';
const BindingsProperty: string = 'PortalSettings.ExpiryIndicator.Bindings';
const DescriptorProperty: string = 'PortalSettings.Solutions.ExpiryIndicator';
const ExpiryFieldComponentId: string = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
export interface IListOption { id: string; title: string; }
export interface IFieldOption {
id: string;
title: string;
internalName: string;
componentId: string;
properties: string;
entityType: string;
}
export interface IExpiryBindingLoadResult { config: any; inherited: boolean; field: IFieldOption; }
export class PortalSettingsDataService {
private readonly siteUrl: string;
public constructor(private context: WebPartContext) {
this.siteUrl = context.pageContext.site.absoluteUrl.replace(/\/$/, '');
}
public load(providers: Array<ISettingsProvider<any>>): Promise<ILoadedSettingsProvider[]> {
return Promise.all([this.getSiteActions(), this.getRootProperties()])
.then((values: any[]): ILoadedSettingsProvider[] => {
const actions: IUserCustomActionInfo[] = values[0];
const properties: any = values[1];
const loaded: ILoadedSettingsProvider[] = [];
for (let i: number = 0; i < providers.length; i++) {
const result: ILoadedSettingsProvider = this.loadProvider(providers[i], actions, properties);
if (result) { loaded.push(result); }
}
return loaded;
});
}
public save(item: ILoadedSettingsProvider): Promise<void> {
const errors: string[] = item.provider.validate(item.config);
if (errors.length > 0) { return Promise.reject(new Error(errors.join(' '))); }
const merged: any = mergePreservingUnknown(item.originalConfig, item.provider.normalize(item.config));
if (item.provider.storage.kind === 'siteUserCustomAction') {
if (!item.context.action) { return Promise.reject(new Error('Die zentrale UserCustomAction wurde nicht gefunden.')); }
return this.saveUserCustomAction(item.context.action, merged).then((): void => {
item.config = item.provider.normalize(merged);
item.originalConfig = cloneValue(merged);
item.dirty = false;
});
}
const propertyKey: string = item.provider.storage.propertyKey || '';
return this.saveRootProperty(propertyKey, JSON.stringify(merged)).then((): void => {
item.config = item.provider.normalize(merged);
item.originalConfig = cloneValue(merged);
item.dirty = false;
});
}
public getSiteUrl(): string { return this.siteUrl; }
public getLists(webUrl: string): Promise<IListOption[]> {
let base: string;
try { base = this.normalizeScopedWebUrl(webUrl); } catch (error) { return Promise.reject(error); }
const url: string = base + '/_api/web/lists?$select=Id,Title,Hidden&$filter=Hidden eq false&$orderby=Title';
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() })
.then((response: SPHttpClientResponse): Promise<any> => this.ensureJson(response, 'Listen konnten nicht geladen werden.'))
.then((payload: any): IListOption[] => this.results(payload).map((source: any): IListOption => ({
id: this.normalizeGuid(source.Id), title: source.Title || source.Id
})));
}
public getDateFields(webUrl: string, listId: string): Promise<IFieldOption[]> {
let base: string;
try { base = this.normalizeScopedWebUrl(webUrl); } catch (error) { return Promise.reject(error); }
const url: string = base + "/_api/web/lists(guid'" + this.normalizeGuid(listId) + "')/fields" +
'?$select=Id,Title,InternalName,TypeAsString,Hidden,ReadOnlyField,ClientSideComponentId,ClientSideComponentProperties';
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() })
.then((response: SPHttpClientResponse): Promise<any> => this.ensureJson(response, 'Felder konnten nicht geladen werden.'))
.then((payload: any): IFieldOption[] => this.results(payload)
.filter((source: any): boolean => source.TypeAsString === 'DateTime' && source.Hidden !== true && source.ReadOnlyField !== true)
.map((source: any): IFieldOption => ({
id: this.normalizeGuid(source.Id), title: source.Title || source.InternalName, internalName: source.InternalName,
componentId: this.normalizeGuid(source.ClientSideComponentId), properties: source.ClientSideComponentProperties || '',
entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.Field'
})));
}
public bindExpiryField(
webUrl: string,
list: IListOption,
field: IFieldOption,
currentBindings: any[]
): Promise<any[]> {
if (field.componentId && field.componentId !== '00000000-0000-0000-0000-000000000000' && field.componentId !== ExpiryFieldComponentId) {
return Promise.reject(new Error('Das Feld ist bereits mit einem anderen Field Customizer verbunden.'));
}
let base: string;
try { base = this.normalizeScopedWebUrl(webUrl); } catch (error) { return Promise.reject(error); }
const fieldUrl: string = base + "/_api/web/lists(guid'" + this.normalizeGuid(list.id) + "')/fields(guid'" + field.id + "')";
const properties: string = field.properties && field.properties.trim()
? field.properties
: JSON.stringify({ schemaVersion: 2, expiryField: field.internalName, inheritSiteDefaults: true });
const body: any = {
'__metadata': { 'type': field.entityType || 'SP.Field' },
'ClientSideComponentId': ExpiryFieldComponentId,
'ClientSideComponentProperties': properties
};
return this.context.spHttpClient.post(fieldUrl, SPHttpClient.configurations.v1, {
headers: this.writeHeaders('MERGE'), body: JSON.stringify(body)
}).then((response: SPHttpClientResponse): Promise<void> => this.ensureOk(response, 'Das Feld konnte nicht angebunden werden.'))
.then((): Promise<any[]> => {
const bindings: any[] = (currentBindings || []).filter((binding: any): boolean =>
!(String(binding.webUrl || '').replace(/\/$/, '').toLowerCase() === base.toLowerCase() && this.normalizeGuid(binding.listId) === this.normalizeGuid(list.id)));
bindings.push({ webUrl: base, listId: this.normalizeGuid(list.id), listTitle: list.title, fieldInternalName: field.internalName, fieldId: field.id });
const descriptor: any = {
key: 'ExpiryIndicator', displayName: 'Expiry Indicator', version: '2.1.2', provider: 'expiryIndicator',
componentIds: [ExpiryFieldComponentId, 'cd58f5d9-ffc8-4df7-a910-3054842d7abf', '95691218-bcb1-4fad-adda-02bb12014d15'],
settingsScope: ['siteCollection', 'web', 'list']
};
return this.saveRootProperty(DescriptorProperty, JSON.stringify(descriptor))
.then((): Promise<void> => this.saveRootProperty(BindingsProperty, JSON.stringify(bindings)))
.then((): any[] => bindings);
});
}
public loadExpiryBinding(binding: any, inheritedConfig: any): Promise<IExpiryBindingLoadResult> {
let baseUrl: string;
try { baseUrl = this.expiryBindingFieldUrl(binding); } catch (error) { return Promise.reject(error); }
const url: string = baseUrl + '?$select=Id,Title,InternalName,ClientSideComponentId,ClientSideComponentProperties';
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() })
.then((response: SPHttpClientResponse): Promise<any> => this.ensureJson(response, 'Die lokale Bindung konnte nicht geladen werden.'))
.then((payload: any): IExpiryBindingLoadResult => {
const source: any = payload.d || payload;
const field: IFieldOption = {
id: this.normalizeGuid(source.Id), title: source.Title || source.InternalName, internalName: source.InternalName,
componentId: this.normalizeGuid(source.ClientSideComponentId), properties: source.ClientSideComponentProperties || '',
entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.Field'
};
if (field.componentId !== ExpiryFieldComponentId) { throw new Error('Das Feld ist nicht mit dem ExpiryIndicator verbunden.'); }
const properties: any = this.parseJson(field.properties, {});
let config: any; let inherited: boolean = false;
if (!field.properties || Object.keys(properties).length === 0 || properties.inheritSiteDefaults === true || this.isBindingOnly(properties)) {
config = cloneValue(inheritedConfig); inherited = true;
if (properties.override) { config = mergePreservingUnknown(config, properties.override); inherited = false; }
} else { config = properties; }
config.expiryField = field.internalName;
return { config: config, inherited: inherited, field: field };
});
}
public saveExpiryBinding(binding: any, field: IFieldOption, config: any, inherit: boolean): Promise<void> {
let fieldUrl: string;
try { fieldUrl = this.expiryBindingFieldUrl(binding); } catch (error) { return Promise.reject(error); }
const properties: any = inherit
? { schemaVersion: 2, expiryField: field.internalName, inheritSiteDefaults: true }
: mergePreservingUnknown(config, { expiryField: field.internalName });
const body: any = {
'__metadata': { 'type': field.entityType || 'SP.Field' },
'ClientSideComponentProperties': JSON.stringify(properties)
};
return this.context.spHttpClient.post(fieldUrl, SPHttpClient.configurations.v1, {
headers: this.writeHeaders('MERGE'), body: JSON.stringify(body)
}).then((response: SPHttpClientResponse): Promise<void> => this.ensureOk(response, 'Die lokale Konfiguration konnte nicht gespeichert werden.'));
}
private loadProvider(
provider: ISettingsProvider<any>,
actions: IUserCustomActionInfo[],
properties: any
): ILoadedSettingsProvider | undefined {
let raw: any;
const context: IProviderLoadContext = {};
if (provider.storage.kind === 'siteUserCustomAction') {
const action: IUserCustomActionInfo = this.findAction(provider, actions);
if (!action) { return undefined; }
context.action = action;
raw = this.parseJson(action.properties, {});
} else {
const propertyKey: string = provider.storage.propertyKey || '';
const stored: any = this.getProperty(properties, propertyKey);
const descriptor: any = this.getProperty(properties, DescriptorProperty);
const bindingsValue: any = this.getProperty(properties, BindingsProperty);
const registration: IUserCustomActionInfo = this.findAnyAction(provider, actions);
if (!stored && !descriptor && !bindingsValue && !registration) { return undefined; }
if (registration) { context.action = registration; }
raw = this.parseJson(stored, provider.createDefault());
context.propertyBag = properties;
context.bindings = this.normalizeBindings(this.parseJson(bindingsValue, []));
}
const normalized: any = provider.normalize(raw || provider.createDefault());
return {
provider: provider,
config: cloneValue(normalized),
originalConfig: cloneValue(raw || normalized),
context: context,
dirty: false
};
}
private getSiteActions(): Promise<IUserCustomActionInfo[]> {
const url: string = this.siteUrl + '/_api/site/UserCustomActions' +
'?$select=Id,Title,Name,Location,ClientSideComponentId,ClientSideComponentProperties';
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() })
.then((response: SPHttpClientResponse): Promise<any> => this.ensureJson(response, 'UserCustomActions konnten nicht gelesen werden.'))
.then((payload: any): IUserCustomActionInfo[] => this.results(payload).map((source: any): IUserCustomActionInfo => ({
id: this.normalizeGuid(source.Id),
title: source.Title || '',
name: source.Name || '',
location: source.Location || '',
componentId: this.normalizeGuid(source.ClientSideComponentId),
properties: source.ClientSideComponentProperties || '{}',
entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.UserCustomAction'
})));
}
private getRootProperties(): Promise<any> {
return this.context.spHttpClient.get(this.siteUrl + '/_api/web/AllProperties', SPHttpClient.configurations.v1, {
headers: this.readHeaders()
}).then((response: SPHttpClientResponse): Promise<any> => this.ensureJson(response, 'Root-Web-Properties konnten nicht gelesen werden.'))
.then((payload: any): any => payload.d || payload);
}
private saveUserCustomAction(action: IUserCustomActionInfo, config: any): Promise<void> {
const url: string = this.siteUrl + "/_api/site/UserCustomActions(guid'" + action.id + "')";
const body: any = {
'__metadata': { 'type': action.entityType || 'SP.UserCustomAction' },
'ClientSideComponentProperties': JSON.stringify(config)
};
return this.context.spHttpClient.post(url, SPHttpClient.configurations.v1, {
headers: this.writeHeaders('MERGE'), body: JSON.stringify(body)
}).then((response: SPHttpClientResponse): Promise<void> => this.ensureOk(response, 'Konfiguration konnte nicht gespeichert werden.'));
}
private saveRootProperty(key: string, value: string): Promise<void> {
if (!key) { return Promise.reject(new Error('Der Property-Bag-Schlüssel fehlt.')); }
const body: any = { '__metadata': { 'type': 'SP.PropertyValues' } };
body[this.encodePropertyName(key)] = value;
return this.context.spHttpClient.post(this.siteUrl + '/_api/web/AllProperties', SPHttpClient.configurations.v1, {
headers: this.writeHeaders('MERGE'), body: JSON.stringify(body)
}).then((response: SPHttpClientResponse): Promise<void> => this.ensureOk(response, 'Der Site-Collection-Standard konnte nicht gespeichert werden.'));
}
private findAction(provider: ISettingsProvider<any>, actions: IUserCustomActionInfo[]): IUserCustomActionInfo | undefined {
for (let i: number = 0; i < actions.length; i++) {
if (provider.componentIds.indexOf(actions[i].componentId) >= 0 &&
actions[i].location.indexOf('ClientSideExtension.ApplicationCustomizer') >= 0) {
return actions[i];
}
}
return undefined;
}
private findAnyAction(provider: ISettingsProvider<any>, actions: IUserCustomActionInfo[]): IUserCustomActionInfo | undefined {
for (let i: number = 0; i < actions.length; i++) {
if (provider.componentIds.indexOf(actions[i].componentId) >= 0) { return actions[i]; }
}
return undefined;
}
private getProperty(properties: any, key: string): any {
if (!properties) { return undefined; }
const normalizedExpected: string = this.normalizePropertyName(key);
const names: string[] = Object.keys(properties);
for (let i: number = 0; i < names.length; i++) {
if (this.normalizePropertyName(names[i]) === normalizedExpected) { return properties[names[i]]; }
}
return undefined;
}
private parseJson(value: any, fallback: any): any {
if (typeof value !== 'string' || !value.trim()) { return cloneValue(fallback); }
try { return JSON.parse(value); } catch (error) { return cloneValue(fallback); }
}
private normalizeBindings(value: any): any[] {
if (Array.isArray(value)) { return value; }
if (!value || typeof value !== 'object') { return []; }
const result: any[] = [];
Object.keys(value).forEach((key: string): void => {
if (Array.isArray(value[key])) { Array.prototype.push.apply(result, value[key]); }
else if (value[key] && typeof value[key] === 'object') { result.push(value[key]); }
});
return result;
}
private expiryBindingFieldUrl(binding: any): string {
const webUrl: string = this.normalizeScopedWebUrl(binding.webUrl);
const listId: string = this.normalizeGuid(binding.listId);
const fieldId: string = this.normalizeGuid(binding.fieldId);
if (!webUrl || !listId || !fieldId) { throw new Error('Die Bindung enthält keine vollständigen Web-, Listen- oder Feldinformationen.'); }
return webUrl + "/_api/web/lists(guid'" + listId + "')/fields(guid'" + fieldId + "')";
}
private isBindingOnly(properties: any): boolean {
if (!properties || typeof properties.expiryField !== 'string') { return false; }
const keys: string[] = ['baseField', 'default', 'rules', 'nullText', 'confirmExtension'];
for (let i: number = 0; i < keys.length; i++) { if (properties[keys[i]] !== undefined) { return false; } }
return true;
}
private results(payload: any): any[] { return payload.value || (payload.d && payload.d.results) || []; }
private normalizeGuid(value: any): string { return String(value || '').replace(/[{}]/g, '').toLowerCase(); }
private normalizeScopedWebUrl(value: any): string {
const url: string = String(value || '').trim().replace(/\/$/, '');
const expected: string = this.siteUrl.toLowerCase(); const normalized: string = url.toLowerCase();
if (!url || (normalized !== expected && normalized.indexOf(expected + '/') !== 0)) {
throw new Error('Die Web-URL muss innerhalb der aktuellen Site Collection liegen.');
}
return url;
}
private normalizePropertyName(value: string): string { return String(value || '').replace(/_x002e_/gi, '.').toLowerCase(); }
private encodePropertyName(value: string): string { return value.replace(/\./g, '_x002e_'); }
private readHeaders(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; }
private writeHeaders(method: string): any {
return {
'Accept': 'application/json;odata=verbose',
'Content-Type': 'application/json;odata=verbose',
'OData-Version': '3.0', 'IF-MATCH': '*', 'X-HTTP-Method': method
};
}
private ensureJson(response: SPHttpClientResponse, message: string): Promise<any> {
if (!response.ok) { return this.responseError(response, message).then((error: Error): Promise<any> => Promise.reject(error)); }
return response.json();
}
private ensureOk(response: SPHttpClientResponse, message: string): Promise<void> {
return response.ok ? Promise.resolve() : this.responseError(response, message).then((error: Error): Promise<void> => Promise.reject(error));
}
private responseError(response: SPHttpClientResponse, prefix: string): Promise<Error> {
return response.text().then((text: string): Error => {
let detail: string = text;
try {
const parsed: any = JSON.parse(text);
detail = parsed.error && parsed.error.message ? (parsed.error.message.value || parsed.error.message) : text;
} catch (error) { /* Textantwort verwenden. */ }
return new Error(prefix + ' (' + response.status + '): ' + detail);
});
}
}

View File

@@ -0,0 +1,50 @@
// tslint:disable:no-any max-line-length
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ITermSetInfo } from '../core/ProviderContracts';
export class TaxonomyService {
public constructor(private context: WebPartContext) { }
public getTermSets(): Promise<ITermSetInfo[]> {
const requestXml: string = '<Request AddExpandoFieldTypeSuffix="true" SchemaVersion="15.0.0.0" LibraryVersion="16.0.0.0" ApplicationName="PortalSettingsV3" xmlns="http://schemas.microsoft.com/sharepoint/clientquery/2009"><Actions><ObjectPath Id="2" ObjectPathId="1" /><ObjectIdentityQuery Id="3" ObjectPathId="1" /><ObjectPath Id="5" ObjectPathId="4" /><ObjectIdentityQuery Id="6" ObjectPathId="4" /><Query Id="7" ObjectPathId="4"><Query SelectAllProperties="false"><Properties><Property Name="Id" ScalarProperty="true" /><Property Name="Name" ScalarProperty="true" /><Property Name="Groups"><Query SelectAllProperties="false"><Properties /></Query><ChildItemQuery SelectAllProperties="false"><Properties><Property Name="Name" ScalarProperty="true" /><Property Name="Id" ScalarProperty="true" /><Property Name="IsSystemGroup" ScalarProperty="true" /><Property Name="TermSets"><Query SelectAllProperties="false"><Properties /></Query><ChildItemQuery SelectAllProperties="false"><Properties><Property Name="Name" ScalarProperty="true" /><Property Name="Id" ScalarProperty="true" /><Property Name="Description" ScalarProperty="true" /></Properties></ChildItemQuery></Property></Properties></ChildItemQuery></Property></Properties></Query></Query></Actions><ObjectPaths><StaticMethod Id="1" Name="GetTaxonomySession" TypeId="{981cbc68-9edc-4f8d-872f-71146fcbb84f}" /><Method Id="4" ParentId="1" Name="GetDefaultSiteCollectionTermStore" /></ObjectPaths></Request>';
const url: string = this.context.pageContext.site.absoluteUrl.replace(/\/$/, '') + '/_vti_bin/client.svc/ProcessQuery';
return this.context.spHttpClient.post(url, SPHttpClient.configurations.v1, {
headers: { 'Accept': 'application/json', 'Content-Type': 'application/xml' }, body: requestXml
}).then((response: SPHttpClientResponse): Promise<string> => {
if (!response.ok) { return Promise.reject(new Error('Termsets konnten nicht geladen werden (' + response.status + ').')); }
return response.text();
}).then((text: string): ITermSetInfo[] => this.parse(JSON.parse(text || '[]')));
}
private parse(payload: any[]): ITermSetInfo[] {
if (!Array.isArray(payload)) { return []; }
if (payload.length && payload[0] && payload[0].ErrorInfo) {
throw new Error(payload[0].ErrorInfo.ErrorMessage || 'Taxonomy-Abfrage fehlgeschlagen.');
}
let store: any;
for (let i: number = 0; i < payload.length; i++) {
if (payload[i] && payload[i]._ObjectType_ === 'SP.Taxonomy.TermStore') { store = payload[i]; break; }
}
const groups: any[] = store && store.Groups && Array.isArray(store.Groups._Child_Items_)
? store.Groups._Child_Items_ : [];
const result: ITermSetInfo[] = [];
for (let g: number = 0; g < groups.length; g++) {
if (!groups[g] || groups[g].IsSystemGroup === true) { continue; }
const sets: any[] = groups[g].TermSets && Array.isArray(groups[g].TermSets._Child_Items_)
? groups[g].TermSets._Child_Items_ : [];
for (let s: number = 0; s < sets.length; s++) {
const id: string = this.cleanGuid(sets[s] && sets[s].Id);
if (id) {
result.push({ id: id, name: sets[s].Name || id, groupName: groups[g].Name || 'Ohne Gruppe', description: sets[s].Description || '' });
}
}
}
return result.sort((left: ITermSetInfo, right: ITermSetInfo): number =>
(left.groupName + left.name).localeCompare(right.groupName + right.name));
}
private cleanGuid(value: any): string {
return String(value || '').replace('/Guid(', '').replace(')/', '').replace(')', '').replace(/[{}]/g, '').toLowerCase();
}
}

20
src/ui/Dom.ts Normal file
View File

@@ -0,0 +1,20 @@
export function createElement(
tag: string,
className?: string,
text?: string
): HTMLElement {
const element: HTMLElement = document.createElement(tag);
if (className) { element.className = className; }
if (text !== undefined) { element.textContent = text; }
return element;
}
export function clearElement(element: HTMLElement): void {
while (element.firstChild) { element.removeChild(element.firstChild); }
}
export function appendText(parent: HTMLElement, tag: 'p' | 'span' | 'strong', className: string, text: string): HTMLElement {
const element: HTMLElement = createElement(tag, className, text);
parent.appendChild(element);
return element;
}

View File

@@ -0,0 +1,314 @@
// tslint:disable:no-any max-line-length
import { ILoadedSettingsProvider, ISettingsElement, ISettingsOption, ISettingsSection } from '../core/ProviderContracts';
import { getPathValue, isEmptyValue, setPathValue } from '../core/ObjectPath';
import { appendText, clearElement, createElement } from './Dom';
export interface IDynamicFormCallbacks {
changed: () => void;
selectTermSet: (element: ISettingsElement) => void;
addBinding: () => void;
editBinding: (binding: any) => void; // tslint:disable-line:no-any
}
export class DynamicFormRenderer {
private host: HTMLElement;
private item: ILoadedSettingsProvider;
private callbacks: IDynamicFormCallbacks;
public render(host: HTMLElement, item: ILoadedSettingsProvider, callbacks: IDynamicFormCallbacks): void {
this.host = host;
this.item = item;
this.callbacks = callbacks;
clearElement(host);
for (let i: number = 0; i < item.provider.sections.length; i++) {
host.appendChild(this.renderSection(item.provider.sections[i]));
}
this.refreshDerivedElements();
}
public refreshDerivedElements(): void {
if (!this.host || !this.item) { return; }
const previews: NodeListOf<HTMLElement> = this.host.querySelectorAll('[data-ps3-json-preview]') as NodeListOf<HTMLElement>;
for (let i: number = 0; i < previews.length; i++) { previews[i].textContent = JSON.stringify(this.item.config, null, 2); }
const summaries: NodeListOf<HTMLElement> = this.host.querySelectorAll('[data-ps3-summary]') as NodeListOf<HTMLElement>;
for (let j: number = 0; j < summaries.length; j++) { this.renderSummary(summaries[j]); }
const conditional: NodeListOf<HTMLElement> = this.host.querySelectorAll('[data-ps3-condition-path]') as NodeListOf<HTMLElement>;
for (let c: number = 0; c < conditional.length; c++) {
const element: HTMLElement = conditional[c];
const path: string = element.getAttribute('data-ps3-condition-path') || '';
const operator: string = element.getAttribute('data-ps3-condition-operator') || '';
const expectedRaw: string = element.getAttribute('data-ps3-condition-value') || '';
let expected: any = expectedRaw;
try { expected = JSON.parse(expectedRaw); } catch (error) { /* String verwenden. */ }
element.hidden = !this.matchesCondition(getPathValue(this.item.config, path), operator, expected);
}
}
private renderSection(section: ISettingsSection): HTMLElement {
const sectionElement: HTMLElement = section.collapsible ? createElement('details', 'ps3-section') : createElement('section', 'ps3-section');
sectionElement.setAttribute('data-layout', section.layout || 'singleColumn');
if (section.collapsible && !section.collapsedByDefault) { (sectionElement as any).open = true; }
const header: HTMLElement = section.collapsible ? createElement('summary', 'ps3-sectionHeader') : createElement('div', 'ps3-sectionHeader');
const heading: HTMLElement = createElement('h2', 'ps3-sectionTitle', section.title);
header.appendChild(heading);
if (section.description) { appendText(header, 'p', 'ps3-sectionDescription', section.description); }
sectionElement.appendChild(header);
const grid: HTMLElement = createElement('div', 'ps3-grid');
for (let i: number = 0; i < section.elements.length; i++) { grid.appendChild(this.renderElement(section.elements[i])); }
sectionElement.appendChild(grid);
return sectionElement;
}
private renderElement(definition: ISettingsElement): HTMLElement {
const wrapper: HTMLElement = createElement('div', 'ps3-element ps3-span-' + (definition.columnSpan || 12));
if (definition.visibleWhen) {
wrapper.setAttribute('data-ps3-condition-path', definition.visibleWhen.field);
wrapper.setAttribute('data-ps3-condition-operator', definition.visibleWhen.operator);
wrapper.setAttribute('data-ps3-condition-value', JSON.stringify(definition.visibleWhen.value));
}
switch (definition.type) {
case 'heading': return this.renderHeading(wrapper, definition);
case 'text': appendText(wrapper, 'p', 'ps3-copy', definition.text || ''); return wrapper;
case 'notice': return this.renderNotice(wrapper, definition);
case 'divider': wrapper.className += ' ps3-divider'; return wrapper;
case 'group': return this.renderGroup(wrapper, definition);
case 'propertySummary': return this.renderSummaryElement(wrapper, definition);
case 'jsonPreview': return this.renderPreview(wrapper, definition);
case 'boolean': return this.renderBoolean(wrapper, definition);
case 'choice': return this.renderChoice(wrapper, definition);
case 'choiceCards': return this.renderChoiceCards(wrapper, definition);
case 'number': return this.renderInput(wrapper, definition, 'number');
case 'textField': return this.renderInput(wrapper, definition, 'text');
case 'json': return this.renderJson(wrapper, definition);
case 'stringList': return this.renderStringList(wrapper, definition);
case 'termSet': return this.renderTermSet(wrapper, definition);
case 'bindingInventory': return this.renderBindings(wrapper, definition);
default: appendText(wrapper, 'p', 'ps3-errorText', 'Nicht unterstütztes Element: ' + definition.type); return wrapper;
}
}
private renderHeading(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
const tag: 'h2' | 'h3' | 'h4' = definition.level === 2 ? 'h2' : (definition.level === 4 ? 'h4' : 'h3');
wrapper.appendChild(createElement(tag, 'ps3-elementHeading', definition.text || definition.title || ''));
return wrapper;
}
private renderNotice(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
wrapper.className += ' ps3-notice ps3-notice-' + (definition.appearance || 'info');
wrapper.setAttribute('role', definition.appearance === 'danger' ? 'alert' : 'note');
if (definition.title) { appendText(wrapper, 'strong', 'ps3-noticeTitle', definition.title); }
appendText(wrapper, 'p', 'ps3-noticeText', definition.text || '');
return wrapper;
}
private renderGroup(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
wrapper.className += ' ps3-group';
if (definition.title) { wrapper.appendChild(createElement('h3', 'ps3-elementHeading', definition.title)); }
const grid: HTMLElement = createElement('div', 'ps3-grid');
const elements: ISettingsElement[] = definition.elements || [];
for (let i: number = 0; i < elements.length; i++) { grid.appendChild(this.renderElement(elements[i])); }
wrapper.appendChild(grid);
return wrapper;
}
private renderInput(wrapper: HTMLElement, definition: ISettingsElement, type: string): HTMLElement {
const input: HTMLInputElement = createElement('input', 'ps3-input') as HTMLInputElement;
input.type = type;
input.id = this.controlId(definition);
input.value = String(getPathValue(this.item.config, definition.path || '') || '');
input.readOnly = definition.readOnly === true;
if (definition.placeholder) { input.placeholder = definition.placeholder; }
if (definition.minimum !== undefined) { input.min = String(definition.minimum); }
if (definition.maximum !== undefined) { input.max = String(definition.maximum); }
if (definition.step !== undefined) { input.step = String(definition.step); }
this.appendLabel(wrapper, definition, input.id);
const controlRow: HTMLElement = createElement('div', 'ps3-controlRow');
controlRow.appendChild(input);
if (definition.suffix) { appendText(controlRow, 'span', 'ps3-suffix', definition.suffix); }
wrapper.appendChild(controlRow);
this.appendDescription(wrapper, definition);
input.addEventListener('input', (): void => {
const value: any = type === 'number' ? Number(input.value) : input.value;
setPathValue(this.item.config, definition.path || '', value);
this.notifyChanged();
});
return wrapper;
}
private renderBoolean(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
const row: HTMLElement = createElement('label', 'ps3-toggle');
const input: HTMLInputElement = createElement('input') as HTMLInputElement;
input.type = 'checkbox'; input.checked = getPathValue(this.item.config, definition.path || '') === true;
const visual: HTMLElement = createElement('span', 'ps3-toggleVisual');
const content: HTMLElement = createElement('span', 'ps3-toggleContent');
appendText(content, 'strong', 'ps3-label', definition.label || definition.key || '');
if (definition.description) { appendText(content, 'span', 'ps3-description', definition.description); }
row.appendChild(input); row.appendChild(visual); row.appendChild(content); wrapper.appendChild(row);
input.addEventListener('change', (): void => {
setPathValue(this.item.config, definition.path || '', input.checked);
this.notifyChanged();
});
return wrapper;
}
private renderChoice(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
const select: HTMLSelectElement = createElement('select', 'ps3-input') as HTMLSelectElement;
select.id = this.controlId(definition);
const value: string = String(getPathValue(this.item.config, definition.path || '') || '');
(definition.options || []).forEach((option: ISettingsOption): void => {
const optionElement: HTMLOptionElement = createElement('option') as HTMLOptionElement;
optionElement.value = option.key; optionElement.textContent = option.text; optionElement.selected = option.key === value;
select.appendChild(optionElement);
});
this.appendLabel(wrapper, definition, select.id); wrapper.appendChild(select); this.appendDescription(wrapper, definition);
select.addEventListener('change', (): void => { setPathValue(this.item.config, definition.path || '', select.value); this.notifyChanged(); });
return wrapper;
}
private renderChoiceCards(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
this.appendLabel(wrapper, definition, '');
const group: HTMLElement = createElement('div', 'ps3-choiceCards'); group.setAttribute('role', 'radiogroup');
const current: string = String(getPathValue(this.item.config, definition.path || '') || '');
(definition.options || []).forEach((option: ISettingsOption): void => {
const card: HTMLLabelElement = createElement('label', 'ps3-choiceCard' + (option.key === current ? ' is-selected' : '')) as HTMLLabelElement;
const input: HTMLInputElement = createElement('input') as HTMLInputElement;
input.type = 'radio'; input.name = this.controlId(definition); input.value = option.key; input.checked = option.key === current;
const content: HTMLElement = createElement('span', 'ps3-choiceContent');
appendText(content, 'strong', 'ps3-choiceTitle', option.text);
if (option.description) { appendText(content, 'span', 'ps3-description', option.description); }
card.appendChild(input); card.appendChild(content); group.appendChild(card);
input.addEventListener('change', (): void => {
if (input.checked) {
const cards: NodeListOf<HTMLElement> = group.querySelectorAll('.ps3-choiceCard') as NodeListOf<HTMLElement>;
for (let index: number = 0; index < cards.length; index++) { cards[index].className = cards[index].className.replace(/\s+is-selected/g, ''); }
card.className += ' is-selected';
setPathValue(this.item.config, definition.path || '', option.key); this.notifyChanged();
}
});
});
wrapper.appendChild(group); return wrapper;
}
private renderJson(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
const textarea: HTMLTextAreaElement = createElement('textarea', 'ps3-textarea ps3-code') as HTMLTextAreaElement;
textarea.id = this.controlId(definition); textarea.spellcheck = false;
textarea.value = JSON.stringify(getPathValue(this.item.config, definition.path || ''), null, 2);
const error: HTMLElement = createElement('div', 'ps3-fieldError'); error.hidden = true;
this.appendLabel(wrapper, definition, textarea.id); wrapper.appendChild(textarea); wrapper.appendChild(error); this.appendDescription(wrapper, definition);
textarea.addEventListener('input', (): void => {
try {
setPathValue(this.item.config, definition.path || '', JSON.parse(textarea.value));
textarea.removeAttribute('aria-invalid'); error.hidden = true; this.notifyChanged();
} catch (parseError) {
textarea.setAttribute('aria-invalid', 'true'); error.textContent = 'Ungültiges JSON: ' + parseError.message; error.hidden = false;
}
});
return wrapper;
}
private renderStringList(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
const textarea: HTMLTextAreaElement = createElement('textarea', 'ps3-textarea ps3-listEditor') as HTMLTextAreaElement;
textarea.id = this.controlId(definition); textarea.value = (getPathValue(this.item.config, definition.path || '') || []).join('\n');
this.appendLabel(wrapper, definition, textarea.id); wrapper.appendChild(textarea); this.appendDescription(wrapper, definition);
textarea.addEventListener('input', (): void => {
const values: string[] = textarea.value.split(/\r?\n/).map((value: string): string => value.trim()).filter((value: string): boolean => !!value);
setPathValue(this.item.config, definition.path || '', values); this.notifyChanged();
});
return wrapper;
}
private renderTermSet(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
this.appendLabel(wrapper, definition, '');
const selected: HTMLElement = createElement('div', 'ps3-selectedItem');
const text: HTMLElement = createElement('div');
const name: string = String(getPathValue(this.item.config, definition.companionNamePath || '') || 'Kein Termset ausgewählt');
const id: string = String(getPathValue(this.item.config, definition.path || '') || '-');
appendText(text, 'strong', 'ps3-selectedTitle', name); appendText(text, 'span', 'ps3-mono', id);
const button: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Auswählen') as HTMLButtonElement;
button.type = 'button'; button.addEventListener('click', (): void => this.callbacks.selectTermSet(definition));
selected.appendChild(text); selected.appendChild(button); wrapper.appendChild(selected); this.appendDescription(wrapper, definition);
return wrapper;
}
private renderBindings(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
const header: HTMLElement = createElement('div', 'ps3-bindingHeader');
if (definition.title) { header.appendChild(createElement('h3', 'ps3-elementHeading', definition.title)); }
const add: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Bindung hinzufügen') as HTMLButtonElement;
add.type = 'button'; add.addEventListener('click', (): void => this.callbacks.addBinding()); header.appendChild(add); wrapper.appendChild(header);
const bindings: any[] = this.item.context.bindings || [];
const list: HTMLElement = createElement('div', 'ps3-bindingList');
if (!bindings.length) { appendText(list, 'p', 'ps3-emptyInline', 'Keine Bindungen registriert.'); }
for (let i: number = 0; i < bindings.length; i++) {
const binding: any = bindings[i];
const card: HTMLElement = createElement('article', 'ps3-binding');
const bindingText: HTMLElement = createElement('div');
appendText(bindingText, 'strong', 'ps3-bindingTitle', binding.listTitle || binding.listId || 'Liste/Bibliothek');
appendText(bindingText, 'span', 'ps3-mono', (binding.webUrl || '') + ' · ' + (binding.fieldInternalName || binding.fieldName || ''));
const edit: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Konfigurieren') as HTMLButtonElement;
edit.type = 'button'; edit.addEventListener('click', (): void => this.callbacks.editBinding(binding));
card.appendChild(bindingText); card.appendChild(edit);
list.appendChild(card);
}
wrapper.appendChild(list); return wrapper;
}
private renderSummaryElement(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
if (definition.title) { wrapper.appendChild(createElement('h3', 'ps3-elementHeading', definition.title)); }
const host: HTMLElement = createElement('div', 'ps3-summary'); host.setAttribute('data-ps3-summary', 'true');
wrapper.appendChild(host); return wrapper;
}
private renderPreview(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement {
const details: any = createElement('details', 'ps3-preview');
details.open = !definition.collapsedByDefault;
details.appendChild(createElement('summary', 'ps3-previewTitle', definition.title || 'JSON-Vorschau'));
const pre: HTMLElement = createElement('pre', 'ps3-code ps3-previewCode'); pre.setAttribute('data-ps3-json-preview', 'true');
details.appendChild(pre); wrapper.appendChild(details); return wrapper;
}
private renderSummary(host: HTMLElement): void {
clearElement(host);
const table: HTMLTableElement = createElement('table', 'ps3-summaryTable') as HTMLTableElement;
const keys: string[] = Object.keys(this.item.config).filter((key: string): boolean => key !== 'placeholdertop' && key !== 'placeholderbottom' && key !== 'rules');
for (let i: number = 0; i < keys.length; i++) {
const row: HTMLTableRowElement = table.insertRow(-1);
const keyCell: HTMLTableCellElement = row.insertCell(-1); keyCell.textContent = keys[i];
const valueCell: HTMLTableCellElement = row.insertCell(-1);
const value: any = this.item.config[keys[i]];
valueCell.textContent = typeof value === 'object' ? JSON.stringify(value) : String(value);
}
host.appendChild(table);
}
private appendLabel(wrapper: HTMLElement, definition: ISettingsElement, htmlFor: string): void {
const label: HTMLLabelElement = createElement('label', 'ps3-label', definition.label || definition.key || '') as HTMLLabelElement;
if (htmlFor) { label.htmlFor = htmlFor; }
if (definition.required) { label.appendChild(createElement('span', 'ps3-required', ' *')); }
wrapper.appendChild(label);
}
private appendDescription(wrapper: HTMLElement, definition: ISettingsElement): void {
if (definition.description) { appendText(wrapper, 'p', 'ps3-description', definition.description); }
}
private notifyChanged(): void {
this.item.dirty = true; this.refreshDerivedElements(); this.callbacks.changed();
}
private controlId(definition: ISettingsElement): string {
return 'ps3-' + this.item.provider.key + '-' + String(definition.key || definition.path || 'field').replace(/[^a-z0-9_-]/gi, '-');
}
private matchesCondition(actual: any, operator: string, expected: any): boolean {
switch (operator) {
case 'equals': return actual === expected;
case 'notEquals': return actual !== expected;
case 'isEmpty': return isEmptyValue(actual);
case 'isNotEmpty': return !isEmptyValue(actual);
case 'contains': return Array.isArray(actual) ? actual.indexOf(expected) >= 0 : String(actual || '').indexOf(String(expected)) >= 0;
case 'greaterThan': return Number(actual) > Number(expected);
case 'lessThan': return Number(actual) < Number(expected);
default: return true;
}
}
}

344
src/ui/PortalSettingsApp.ts Normal file
View File

@@ -0,0 +1,344 @@
// tslint:disable:no-any max-line-length
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ILoadedSettingsProvider, ISettingsElement, ITermSetInfo } from '../core/ProviderContracts';
import { setPathValue } from '../core/ObjectPath';
import { getSettingsProviders } from '../providers/ProviderRegistry';
import { PortalSettingsDataService } from '../services/PortalSettingsDataService';
import { TaxonomyService } from '../services/TaxonomyService';
import { IListOption, IFieldOption } from '../services/PortalSettingsDataService';
import { clearElement, createElement } from './Dom';
import { DynamicFormRenderer } from './DynamicFormRenderer';
export class PortalSettingsApp {
private readonly dataService: PortalSettingsDataService;
private readonly taxonomyService: TaxonomyService;
private readonly formRenderer: DynamicFormRenderer = new DynamicFormRenderer();
private host: HTMLElement;
private contentHost: HTMLElement;
private tabsHost: HTMLElement;
private statusHost: HTMLElement;
private saveButton: HTMLButtonElement;
private items: ILoadedSettingsProvider[] = [];
private activeKey: string = '';
private disposed: boolean = false;
public constructor(private context: WebPartContext, private configuredTitle?: string) {
this.dataService = new PortalSettingsDataService(context);
this.taxonomyService = new TaxonomyService(context);
}
public render(host: HTMLElement): void {
this.host = host;
this.disposed = false;
clearElement(host);
host.className += ' ps3-root';
if (!this.isSiteCollectionAdmin()) {
const denied: HTMLElement = createElement('div', 'ps3-accessDenied');
denied.appendChild(createElement('h2', '', 'Zugriff nicht möglich'));
denied.appendChild(createElement('p', '', 'PortalSettings kann nur von Site-Collection-Administratoren verwendet werden.'));
host.appendChild(denied);
return;
}
this.buildShell();
this.load();
}
public dispose(): void {
this.disposed = true;
if (this.host) { clearElement(this.host); }
this.items = [];
}
private buildShell(): void {
const shell: HTMLElement = createElement('div', 'ps3-shell');
this.statusHost = createElement('div', 'ps3-statusHost');
this.statusHost.setAttribute('aria-live', 'polite');
shell.appendChild(this.statusHost);
const hero: HTMLElement = createElement('header', 'ps3-hero');
hero.appendChild(createElement('span', 'ps3-eyebrow', 'PORTAL SETTINGS V3'));
hero.appendChild(createElement('h1', 'ps3-title', this.configuredTitle || 'Zentrale Verwaltung der Portal-Erweiterungen'));
hero.appendChild(createElement('p', 'ps3-lead', 'Installierte Solutions werden automatisch erkannt. Ihre versionierten Provider erzeugen strukturierte und validierte Einstellungsseiten.'));
const meta: HTMLElement = createElement('div', 'ps3-heroMeta');
meta.appendChild(this.metric('Site Collection', this.dataService.getSiteUrl()));
meta.appendChild(this.metric('Konfigurationsmodell', 'Providerbasiert'));
hero.appendChild(meta); shell.appendChild(hero);
this.tabsHost = createElement('nav', 'ps3-tabs');
this.tabsHost.setAttribute('role', 'tablist'); this.tabsHost.setAttribute('aria-label', 'Erweiterungen');
shell.appendChild(this.tabsHost);
this.contentHost = createElement('main', 'ps3-content'); shell.appendChild(this.contentHost);
this.host.appendChild(shell);
}
private load(preferredKey?: string): void {
this.showStatus('Installierte Einstellungsanbieter werden geladen …', 'info', true);
this.dataService.load(getSettingsProviders()).then((items: ILoadedSettingsProvider[]): void => {
if (this.disposed) { return; }
this.items = items;
this.activeKey = preferredKey && this.findItem(preferredKey) ? preferredKey : (items.length ? items[0].provider.key : '');
this.renderTabs(); this.renderActive();
this.showStatus(items.length ? items.length + ' Erweiterung(en) wurden erkannt.' : 'Keine unterstützte Erweiterung wurde aktiv registriert.', items.length ? 'success' : 'warning');
}).catch((error: Error): void => {
if (!this.disposed) { this.showStatus(error.message || String(error), 'danger', true); this.renderEmpty(true); }
});
}
private renderTabs(): void {
clearElement(this.tabsHost);
for (let i: number = 0; i < this.items.length; i++) {
const item: ILoadedSettingsProvider = this.items[i];
const button: HTMLButtonElement = createElement('button', 'ps3-tab', item.provider.displayName) as HTMLButtonElement;
button.type = 'button'; button.setAttribute('role', 'tab');
button.setAttribute('aria-selected', String(item.provider.key === this.activeKey));
if (item.provider.key === this.activeKey) { button.className += ' is-active'; }
if (item.dirty) { button.appendChild(createElement('span', 'ps3-dirtyDot', '●')); }
button.addEventListener('click', (): void => {
if (this.activeKey !== item.provider.key && this.currentItem() && this.currentItem().dirty &&
!window.confirm('Nicht gespeicherte Änderungen verwerfen und den Tab wechseln?')) { return; }
this.activeKey = item.provider.key; this.renderTabs(); this.renderActive();
});
this.tabsHost.appendChild(button);
}
}
private renderActive(): void {
clearElement(this.contentHost);
const item: ILoadedSettingsProvider = this.currentItem();
if (!item) { this.renderEmpty(false); return; }
const header: HTMLElement = createElement('div', 'ps3-providerHeader');
const heading: HTMLElement = createElement('div');
heading.appendChild(createElement('span', 'ps3-providerVersion', 'Schema ' + item.provider.schemaVersion));
heading.appendChild(createElement('h2', 'ps3-providerTitle', item.provider.displayName));
heading.appendChild(createElement('p', 'ps3-providerDescription', item.provider.description));
header.appendChild(heading);
const storage: HTMLElement = createElement('div', 'ps3-storageBadge',
item.provider.storage.kind === 'siteUserCustomAction' ? 'Site-Collection-Action' : 'Root-Web-Standard');
header.appendChild(storage); this.contentHost.appendChild(header);
const form: HTMLElement = createElement('div', 'ps3-form'); this.contentHost.appendChild(form);
this.formRenderer.render(form, item, {
changed: (): void => { this.updateSaveState(); this.renderTabs(); },
selectTermSet: (element: ISettingsElement): void => this.openTermSetPicker(item, element),
addBinding: (): void => this.openExpiryBindingDialog(item),
editBinding: (binding: any): void => this.openExpiryBindingEditor(item, binding)
});
const actions: HTMLElement = createElement('div', 'ps3-actionBar');
const reset: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Änderungen verwerfen') as HTMLButtonElement;
reset.type = 'button'; reset.addEventListener('click', (): void => this.resetCurrent());
this.saveButton = createElement('button', 'ps3-button ps3-button-primary', 'Konfiguration speichern') as HTMLButtonElement;
this.saveButton.type = 'button'; this.saveButton.addEventListener('click', (): void => this.saveCurrent());
actions.appendChild(reset); actions.appendChild(this.saveButton); this.contentHost.appendChild(actions);
this.updateSaveState();
}
private renderEmpty(failed: boolean): void {
clearElement(this.contentHost);
const empty: HTMLElement = createElement('section', 'ps3-empty');
empty.appendChild(createElement('h2', '', failed ? 'PortalSettings konnte nicht geladen werden' : 'Keine aktiven Provider gefunden'));
empty.appendChild(createElement('p', '', failed
? 'Prüfen Sie die Statusmeldung und die REST-Berechtigungen.'
: 'MegaMenu und CustomBranding benötigen eine zentrale Site-Collection-Action. ExpiryIndicator wird über seinen Descriptor oder vorhandene Bindungen erkannt.'));
this.contentHost.appendChild(empty);
}
private saveCurrent(): void {
const item: ILoadedSettingsProvider = this.currentItem();
if (!item || !item.dirty) { return; }
const errors: string[] = item.provider.validate(item.config);
if (errors.length) { this.showStatus(errors.join(' '), 'danger', true); return; }
if (!window.confirm('Konfiguration für „' + item.provider.displayName + '“ speichern?')) { return; }
this.saveButton.disabled = true; this.showStatus('Konfiguration wird gespeichert …', 'info', true);
this.dataService.save(item).then((): void => {
this.showStatus('Die Konfiguration wurde erfolgreich gespeichert.', 'success'); this.renderTabs(); this.renderActive();
}).catch((error: Error): void => {
this.showStatus(error.message || String(error), 'danger', true); this.updateSaveState();
});
}
private resetCurrent(): void {
const item: ILoadedSettingsProvider = this.currentItem();
if (!item || !item.dirty || !window.confirm('Alle nicht gespeicherten Änderungen verwerfen?')) { return; }
item.config = item.provider.normalize(item.originalConfig); item.dirty = false; this.renderTabs(); this.renderActive();
}
private openTermSetPicker(item: ILoadedSettingsProvider, definition: ISettingsElement): void {
const backdrop: HTMLElement = createElement('div', 'ps3-modalBackdrop');
const dialog: HTMLElement = createElement('section', 'ps3-modal');
dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'ps3-termset-title');
dialog.appendChild(createElement('h2', '', 'Termset auswählen')).id = 'ps3-termset-title';
const search: HTMLInputElement = createElement('input', 'ps3-input') as HTMLInputElement;
search.type = 'search'; search.placeholder = 'Gruppe, Termset oder GUID durchsuchen'; search.setAttribute('aria-label', 'Termsets durchsuchen');
dialog.appendChild(search);
const results: HTMLElement = createElement('div', 'ps3-pickerResults'); results.appendChild(createElement('p', 'ps3-emptyInline', 'Termsets werden geladen …'));
dialog.appendChild(results);
const close: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Abbrechen') as HTMLButtonElement;
close.type = 'button'; close.addEventListener('click', (): void => { document.body.removeChild(backdrop); }); dialog.appendChild(close);
backdrop.appendChild(dialog); document.body.appendChild(backdrop); search.focus();
this.taxonomyService.getTermSets().then((termSets: ITermSetInfo[]): void => {
const renderResults: () => void = (): void => {
clearElement(results); const filter: string = search.value.trim().toLowerCase(); let count: number = 0;
for (let i: number = 0; i < termSets.length; i++) {
const termSet: ITermSetInfo = termSets[i];
if (filter && (termSet.groupName + ' ' + termSet.name + ' ' + termSet.id + ' ' + termSet.description).toLowerCase().indexOf(filter) < 0) { continue; }
const row: HTMLElement = createElement('article', 'ps3-pickerItem');
const text: HTMLElement = createElement('div'); text.appendChild(createElement('strong', '', termSet.name));
text.appendChild(createElement('span', 'ps3-mono', termSet.groupName + ' · ' + termSet.id));
const select: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Auswählen') as HTMLButtonElement;
select.type = 'button'; select.addEventListener('click', (): void => {
setPathValue(item.config, definition.path || '', termSet.id);
setPathValue(item.config, definition.companionNamePath || '', termSet.name);
item.dirty = true; document.body.removeChild(backdrop); this.renderTabs(); this.renderActive();
});
row.appendChild(text); row.appendChild(select); results.appendChild(row); count++;
}
if (!count) { results.appendChild(createElement('p', 'ps3-emptyInline', 'Keine passenden Termsets gefunden.')); }
};
search.addEventListener('input', renderResults); renderResults();
}).catch((error: Error): void => { clearElement(results); results.appendChild(createElement('p', 'ps3-errorText', error.message || String(error))); });
}
private updateSaveState(): void {
const item: ILoadedSettingsProvider = this.currentItem();
if (this.saveButton) { this.saveButton.disabled = !item || !item.dirty; }
}
private openExpiryBindingDialog(item: ILoadedSettingsProvider): void {
const backdrop: HTMLElement = createElement('div', 'ps3-modalBackdrop');
const dialog: HTMLElement = createElement('section', 'ps3-modal');
dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
dialog.appendChild(createElement('h2', '', 'ExpiryIndicator-Bindung hinzufügen'));
dialog.appendChild(createElement('p', 'ps3-providerDescription', 'Wählen Sie ein Web, eine Liste oder Bibliothek und ein vorhandenes Datumsfeld aus. Die Solution legt keine Spalte an.'));
const webLabel: HTMLLabelElement = createElement('label', 'ps3-label', 'Web-URL') as HTMLLabelElement;
const webInput: HTMLInputElement = createElement('input', 'ps3-input') as HTMLInputElement;
webInput.value = this.context.pageContext.web.absoluteUrl; webLabel.htmlFor = 'ps3-bind-web'; webInput.id = 'ps3-bind-web';
const loadLists: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Listen laden') as HTMLButtonElement;
loadLists.type = 'button';
const listLabel: HTMLLabelElement = createElement('label', 'ps3-label', 'Liste oder Bibliothek') as HTMLLabelElement;
const listSelect: HTMLSelectElement = createElement('select', 'ps3-input') as HTMLSelectElement; listSelect.id = 'ps3-bind-list'; listLabel.htmlFor = listSelect.id;
const fieldLabel: HTMLLabelElement = createElement('label', 'ps3-label', 'Ablaufdatumsfeld') as HTMLLabelElement;
const fieldSelect: HTMLSelectElement = createElement('select', 'ps3-input') as HTMLSelectElement; fieldSelect.id = 'ps3-bind-field'; fieldLabel.htmlFor = fieldSelect.id;
const status: HTMLElement = createElement('div', 'ps3-dialogStatus'); status.setAttribute('aria-live', 'polite');
const actions: HTMLElement = createElement('div', 'ps3-dialogActions');
const cancel: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Abbrechen') as HTMLButtonElement;
const bind: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-primary', 'Feld anbinden') as HTMLButtonElement;
cancel.type = 'button'; bind.type = 'button'; bind.disabled = true;
actions.appendChild(cancel); actions.appendChild(bind);
dialog.appendChild(webLabel); const webRow: HTMLElement = createElement('div', 'ps3-controlRow'); webRow.appendChild(webInput); webRow.appendChild(loadLists); dialog.appendChild(webRow);
dialog.appendChild(listLabel); dialog.appendChild(listSelect); dialog.appendChild(fieldLabel); dialog.appendChild(fieldSelect); dialog.appendChild(status); dialog.appendChild(actions);
backdrop.appendChild(dialog); document.body.appendChild(backdrop);
const closeDialog: () => void = (): void => { if (backdrop.parentNode) { backdrop.parentNode.removeChild(backdrop); } };
cancel.addEventListener('click', closeDialog);
const setOptions: (select: HTMLSelectElement, options: Array<{ id: string; title: string }>) => void =
(select: HTMLSelectElement, options: Array<{ id: string; title: string }>): void => {
clearElement(select); const empty: HTMLOptionElement = createElement('option') as HTMLOptionElement; empty.value = ''; empty.textContent = 'Bitte auswählen'; select.appendChild(empty);
options.forEach((option: { id: string; title: string }): void => {
const element: HTMLOptionElement = createElement('option') as HTMLOptionElement; element.value = option.id; element.textContent = option.title; select.appendChild(element);
});
};
const loadWebLists: () => void = (): void => {
status.textContent = 'Listen werden geladen …'; bind.disabled = true; setOptions(listSelect, []); setOptions(fieldSelect, []);
this.dataService.getLists(webInput.value).then((lists: IListOption[]): void => {
setOptions(listSelect, lists.map((list: IListOption): { id: string; title: string } => ({ id: list.id, title: list.title })));
status.textContent = lists.length ? '' : 'Keine Listen oder Bibliotheken gefunden.';
}).catch((error: Error): void => { status.textContent = error.message || String(error); });
};
loadLists.addEventListener('click', loadWebLists);
listSelect.addEventListener('change', (): void => {
bind.disabled = true; setOptions(fieldSelect, []); if (!listSelect.value) { return; }
status.textContent = 'Datumsfelder werden geladen …';
this.dataService.getDateFields(webInput.value, listSelect.value).then((fields: IFieldOption[]): void => {
setOptions(fieldSelect, fields.map((field: IFieldOption): { id: string; title: string } => ({ id: field.id, title: field.title + ' (' + field.internalName + ')' })));
(fieldSelect as any)._ps3Fields = fields; status.textContent = fields.length ? '' : 'Keine verwendbaren Datumsfelder gefunden.';
}).catch((error: Error): void => { status.textContent = error.message || String(error); });
});
fieldSelect.addEventListener('change', (): void => { bind.disabled = !fieldSelect.value; });
bind.addEventListener('click', (): void => {
const lists: HTMLOptionElement = listSelect.options[listSelect.selectedIndex];
const fields: IFieldOption[] = (fieldSelect as any)._ps3Fields || [];
let selectedField: IFieldOption;
for (let i: number = 0; i < fields.length; i++) { if (fields[i].id === fieldSelect.value) { selectedField = fields[i]; break; } }
if (!selectedField) { return; }
bind.disabled = true; status.textContent = 'Feld wird angebunden …';
this.dataService.bindExpiryField(webInput.value, { id: listSelect.value, title: lists.text }, selectedField, item.context.bindings || [])
.then((bindings: any[]): void => {
item.context.bindings = bindings; closeDialog(); this.renderActive(); this.showStatus('ExpiryIndicator wurde erfolgreich an das Feld gebunden.', 'success');
}).catch((error: Error): void => { bind.disabled = false; status.textContent = error.message || String(error); });
});
loadWebLists();
}
private openExpiryBindingEditor(siteItem: ILoadedSettingsProvider, binding: any): void {
const backdrop: HTMLElement = createElement('div', 'ps3-modalBackdrop');
const dialog: HTMLElement = createElement('section', 'ps3-modal ps3-modal-large');
dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
dialog.appendChild(createElement('h2', '', 'Lokale ExpiryIndicator-Konfiguration'));
dialog.appendChild(createElement('p', 'ps3-providerDescription', (binding.listTitle || binding.listId) + ' · ' + (binding.fieldInternalName || '')));
const status: HTMLElement = createElement('div', 'ps3-dialogStatus'); status.textContent = 'Konfiguration wird geladen …'; status.setAttribute('aria-live', 'polite');
const form: HTMLElement = createElement('div', 'ps3-form'); const actions: HTMLElement = createElement('div', 'ps3-dialogActions');
dialog.appendChild(status); dialog.appendChild(form); dialog.appendChild(actions); backdrop.appendChild(dialog); document.body.appendChild(backdrop);
const closeDialog: () => void = (): void => { if (backdrop.parentNode) { backdrop.parentNode.removeChild(backdrop); } };
this.dataService.loadExpiryBinding(binding, siteItem.config).then((loaded: any): void => {
status.textContent = loaded.inherited ? 'Die Bindung erbt derzeit den Site-Collection-Standard.' : 'Die Bindung verwendet eine lokale Ausnahme.';
const provider: any = {}; Object.keys(siteItem.provider).forEach((key: string): void => { provider[key] = (siteItem.provider as any)[key]; });
provider.sections = siteItem.provider.sections.filter(section => section.key === 'dates' || section.key === 'rules' || section.key === 'preview');
const editItem: ILoadedSettingsProvider = {
provider: provider, config: provider.normalize(loaded.config), originalConfig: loaded.config,
context: {}, dirty: false
};
const editor: DynamicFormRenderer = new DynamicFormRenderer();
editor.render(form, editItem, {
changed: (): void => { saveLocal.disabled = false; },
selectTermSet: (): void => undefined,
addBinding: (): void => undefined,
editBinding: (): void => undefined
});
const cancel: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Abbrechen') as HTMLButtonElement;
const inherit: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Site-Standard erben') as HTMLButtonElement;
const saveLocal: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-primary', 'Als lokale Ausnahme speichern') as HTMLButtonElement;
cancel.type = 'button'; inherit.type = 'button'; saveLocal.type = 'button'; saveLocal.disabled = !editItem.dirty;
cancel.addEventListener('click', closeDialog);
inherit.addEventListener('click', (): void => {
inherit.disabled = true; saveLocal.disabled = true; status.textContent = 'Vererbung wird aktiviert …';
this.dataService.saveExpiryBinding(binding, loaded.field, editItem.config, true).then((): void => {
closeDialog(); this.showStatus('Die Bindung erbt jetzt den Site-Collection-Standard.', 'success');
}).catch((error: Error): void => { inherit.disabled = false; saveLocal.disabled = false; status.textContent = error.message || String(error); });
});
saveLocal.addEventListener('click', (): void => {
const errors: string[] = provider.validate(editItem.config); if (errors.length) { status.textContent = errors.join(' '); return; }
inherit.disabled = true; saveLocal.disabled = true; status.textContent = 'Lokale Ausnahme wird gespeichert …';
this.dataService.saveExpiryBinding(binding, loaded.field, editItem.config, false).then((): void => {
closeDialog(); this.showStatus('Die lokale ExpiryIndicator-Ausnahme wurde gespeichert.', 'success');
}).catch((error: Error): void => { inherit.disabled = false; saveLocal.disabled = false; status.textContent = error.message || String(error); });
});
actions.appendChild(cancel); actions.appendChild(inherit); actions.appendChild(saveLocal);
}).catch((error: Error): void => {
status.textContent = error.message || String(error);
const close: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Schließen') as HTMLButtonElement;
close.type = 'button'; close.addEventListener('click', closeDialog); actions.appendChild(close);
});
}
private showStatus(message: string, appearance: string, persistent?: boolean): void {
if (!this.statusHost) { return; }
clearElement(this.statusHost);
const status: HTMLElement = createElement('div', 'ps3-status ps3-status-' + appearance, message);
status.setAttribute('role', appearance === 'danger' ? 'alert' : 'status'); this.statusHost.appendChild(status);
if (!persistent) { window.setTimeout((): void => { if (!this.disposed && this.statusHost.contains(status)) { clearElement(this.statusHost); } }, 5000); }
}
private metric(label: string, value: string): HTMLElement {
const metric: HTMLElement = createElement('div', 'ps3-heroMetric'); metric.appendChild(createElement('span', '', label)); metric.appendChild(createElement('strong', '', value)); return metric;
}
private currentItem(): ILoadedSettingsProvider { return this.findItem(this.activeKey); }
private findItem(key: string): ILoadedSettingsProvider {
for (let i: number = 0; i < this.items.length; i++) { if (this.items[i].provider.key === key) { return this.items[i]; } }
return undefined;
}
private isSiteCollectionAdmin(): boolean {
const legacy: any = this.context.pageContext.legacyPageContext;
return legacy && legacy.isSiteAdmin === true;
}
}

View File

@@ -0,0 +1,171 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
$white: "[theme: white, default: #ffffff]";
$neutral-lighter-alt: "[theme: neutralLighterAlt, default: #f8f8f8]";
$neutral-lighter: "[theme: neutralLighter, default: #f4f4f4]";
$neutral-light: "[theme: neutralLight, default: #eaeaea]";
$neutral-tertiary: "[theme: neutralTertiary, default: #a6a6a6]";
$neutral-secondary: "[theme: neutralSecondary, default: #666666]";
$neutral-primary: "[theme: neutralPrimary, default: #333333]";
$theme-lighter-alt: "[theme: themeLighterAlt, default: #eff6fc]";
$theme-lighter: "[theme: themeLighter, default: #deecf9]";
$theme-light: "[theme: themeLight, default: #c7e0f4]";
$theme-primary: "[theme: themePrimary, default: #0078d7]";
$theme-dark: "[theme: themeDark, default: #005a9e]";
$red-dark: "[theme: redDark, default: #a80000]";
$green: "[theme: green, default: #107c10]";
.ps3-root {
box-sizing: border-box;
width: 100%;
background: $neutral-lighter-alt;
color: $neutral-primary;
font-family: 'Segoe UI', 'Segoe UI Web (West European)', sans-serif;
}
.ps3-root *, .ps3-root *::before, .ps3-root *::after { box-sizing: border-box; }
.ps3-shell { width: 100%; max-width: 1280px; margin: 0 auto; padding: 24px; }
.ps3-statusHost { min-height: 50px; }
.ps3-status { margin-bottom: 14px; padding: 12px 16px; border: 1px solid $neutral-light; border-radius: 4px; background: $white; }
.ps3-status-info { border-color: $theme-light; background: $theme-lighter-alt; color: $theme-dark; }
.ps3-status-success { border-color: $green; color: $green; }
.ps3-status-warning { border-color: #ca5010; background: #fff4ce; color: #8a3707; }
.ps3-status-danger { border-color: $red-dark; background: #fde7e9; color: $red-dark; }
.ps3-hero {
padding: 28px 30px;
border-radius: 8px;
background: $theme-primary;
color: $white;
box-shadow: 0 4px 16px rgba(0, 0, 0, .12);
}
.ps3-eyebrow { display: block; margin-bottom: 8px; font-size: 11px; font-weight: 700; letter-spacing: .16em; }
.ps3-title { margin: 0; font-size: 30px; font-weight: 300; line-height: 1.2; }
.ps3-lead { max-width: 850px; margin: 12px 0 0; font-size: 15px; line-height: 1.6; }
.ps3-heroMeta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px; }
.ps3-heroMetric { min-width: 210px; padding: 10px 14px; border: 1px solid rgba(255,255,255,.32); border-radius: 4px; background: rgba(255,255,255,.1); }
.ps3-heroMetric span, .ps3-heroMetric strong { display: block; }
.ps3-heroMetric span { margin-bottom: 3px; font-size: 11px; opacity: .85; text-transform: uppercase; }
.ps3-heroMetric strong { font-size: 13px; word-break: break-word; }
.ps3-tabs { display: flex; gap: 4px; margin: 22px 0 0; padding: 0 4px; border-bottom: 1px solid $neutral-light; }
.ps3-tab { position: relative; min-height: 44px; padding: 0 18px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: $neutral-secondary; cursor: pointer; font: inherit; font-weight: 600; }
.ps3-tab:hover { background: $neutral-lighter; color: $neutral-primary; }
.ps3-tab.is-active { border-bottom-color: $theme-primary; color: $theme-primary; }
.ps3-tab:focus { outline: 2px solid $theme-primary; outline-offset: -2px; }
.ps3-dirtyDot { margin-left: 8px; color: #ca5010; font-size: 9px; }
.ps3-content { padding-top: 18px; }
.ps3-providerHeader { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin-bottom: 16px; padding: 20px 22px; border: 1px solid $neutral-light; border-radius: 6px; background: $white; }
.ps3-providerVersion { color: $theme-primary; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.ps3-providerTitle { margin: 3px 0 5px; font-size: 24px; font-weight: 400; }
.ps3-providerDescription { margin: 0; color: $neutral-secondary; line-height: 1.5; }
.ps3-storageBadge { flex: 0 0 auto; padding: 6px 10px; border-radius: 12px; background: $theme-lighter-alt; color: $theme-dark; font-size: 12px; font-weight: 600; }
.ps3-form { display: grid; gap: 16px; }
.ps3-section { display: block; margin: 0; padding: 0; border: 1px solid $neutral-light; border-radius: 6px; background: $white; box-shadow: 0 2px 8px rgba(0,0,0,.04); }
.ps3-sectionHeader { display: block; padding: 18px 20px 14px; border-bottom: 1px solid $neutral-light; }
details.ps3-section > summary { cursor: pointer; }
details.ps3-section > summary:focus { outline: 2px solid $theme-primary; outline-offset: -2px; }
.ps3-sectionTitle { margin: 0; font-size: 18px; font-weight: 600; }
.ps3-sectionDescription { margin: 5px 0 0; color: $neutral-secondary; line-height: 1.5; }
.ps3-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 18px; padding: 20px; }
.ps3-span-1 { grid-column: span 1; } .ps3-span-2 { grid-column: span 2; } .ps3-span-3 { grid-column: span 3; }
.ps3-span-4 { grid-column: span 4; } .ps3-span-6 { grid-column: span 6; } .ps3-span-8 { grid-column: span 8; } .ps3-span-12 { grid-column: span 12; }
.ps3-element { min-width: 0; }
.ps3-elementHeading { margin: 0; font-size: 15px; font-weight: 600; }
.ps3-label { display: block; margin: 0 0 7px; font-size: 13px; font-weight: 600; }
.ps3-required { color: $red-dark; }
.ps3-description { display: block; margin: 6px 0 0; color: $neutral-secondary; font-size: 12px; line-height: 1.5; }
.ps3-controlRow { display: flex; align-items: stretch; }
.ps3-input, .ps3-textarea { width: 100%; border: 1px solid $neutral-tertiary; border-radius: 2px; background: $white; color: $neutral-primary; font: inherit; font-size: 14px; }
.ps3-input { height: 36px; padding: 0 10px; }
.ps3-textarea { min-height: 180px; padding: 10px; resize: vertical; line-height: 1.5; }
.ps3-listEditor { min-height: 120px; }
.ps3-input:focus, .ps3-textarea:focus { border-color: $theme-primary; outline: 1px solid $theme-primary; }
.ps3-textarea[aria-invalid='true'] { border-color: $red-dark; }
.ps3-suffix { display: flex; align-items: center; padding: 0 10px; border: 1px solid $neutral-tertiary; border-left: 0; background: $neutral-lighter; font-size: 12px; }
.ps3-code { font-family: Consolas, 'Courier New', monospace; font-size: 12px; }
.ps3-fieldError, .ps3-errorText { margin-top: 5px; color: $red-dark; font-size: 12px; }
.ps3-divider { height: 1px; align-self: center; background: $neutral-light; }
.ps3-notice { padding: 13px 15px; border-left: 4px solid $theme-primary; background: $theme-lighter-alt; }
.ps3-notice-warning { border-left-color: #ca5010; background: #fff4ce; }
.ps3-notice-danger { border-left-color: $red-dark; background: #fde7e9; }
.ps3-notice-success { border-left-color: $green; background: #dff6dd; }
.ps3-noticeTitle { display: block; margin-bottom: 3px; }
.ps3-noticeText { margin: 0; line-height: 1.5; }
.ps3-toggle { display: grid; grid-template-columns: 42px minmax(0,1fr); gap: 10px; cursor: pointer; }
.ps3-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.ps3-toggleVisual { position: relative; width: 40px; height: 20px; margin-top: 2px; border: 1px solid $neutral-secondary; border-radius: 10px; background: $white; }
.ps3-toggleVisual::after { content: ''; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: $neutral-secondary; transition: left .12s ease; }
.ps3-toggle input:checked + .ps3-toggleVisual { border-color: $theme-primary; background: $theme-primary; }
.ps3-toggle input:checked + .ps3-toggleVisual::after { left: 23px; background: $white; }
.ps3-toggle input:focus + .ps3-toggleVisual { outline: 2px solid $theme-primary; outline-offset: 2px; }
.ps3-toggleContent .ps3-description { margin-top: 2px; }
.ps3-choiceCards { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; }
.ps3-choiceCard { position: relative; display: block; min-height: 90px; padding: 15px; border: 1px solid $neutral-light; border-radius: 4px; cursor: pointer; }
.ps3-choiceCard:hover { border-color: $theme-primary; background: $theme-lighter-alt; }
.ps3-choiceCard input { position: absolute; top: 14px; right: 14px; }
.ps3-choiceCard.is-selected { border: 2px solid $theme-primary; padding: 14px; background: $theme-lighter-alt; }
.ps3-choiceTitle, .ps3-choiceContent { display: block; }
.ps3-selectedItem, .ps3-pickerItem { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 14px; border: 1px solid $neutral-light; border-radius: 4px; background: $neutral-lighter-alt; }
.ps3-selectedTitle, .ps3-mono { display: block; }
.ps3-mono { margin-top: 3px; color: $neutral-secondary; font-family: Consolas, 'Courier New', monospace; font-size: 11px; word-break: break-all; }
.ps3-bindingList, .ps3-pickerResults { display: grid; gap: 8px; }
.ps3-bindingHeader { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
.ps3-binding { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid $neutral-light; border-radius: 4px; }
.ps3-bindingTitle { display: block; }
.ps3-emptyInline { margin: 0; padding: 14px; color: $neutral-secondary; text-align: center; }
.ps3-preview { border: 1px solid $neutral-light; border-radius: 4px; }
.ps3-previewTitle { padding: 12px 14px; cursor: pointer; font-weight: 600; }
.ps3-previewCode { max-height: 440px; margin: 0; padding: 14px; overflow: auto; border-top: 1px solid $neutral-light; background: $neutral-lighter-alt; white-space: pre-wrap; }
.ps3-summaryTable { width: 100%; border-collapse: collapse; }
.ps3-summaryTable td { padding: 8px 10px; border-bottom: 1px solid $neutral-light; word-break: break-word; }
.ps3-summaryTable td:first-child { width: 28%; color: $neutral-secondary; font-weight: 600; }
.ps3-actionBar { position: sticky; bottom: 12px; z-index: 3; display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; padding: 14px; border: 1px solid $neutral-light; border-radius: 6px; background: rgba(255,255,255,.96); box-shadow: 0 4px 16px rgba(0,0,0,.12); }
.ps3-button { min-height: 36px; padding: 0 16px; border: 1px solid transparent; border-radius: 2px; cursor: pointer; font: inherit; font-size: 14px; font-weight: 600; }
.ps3-button-primary { border-color: $theme-primary; background: $theme-primary; color: $white; }
.ps3-button-primary:hover { border-color: $theme-dark; background: $theme-dark; }
.ps3-button-secondary { border-color: $neutral-tertiary; background: $white; color: $neutral-primary; }
.ps3-button-secondary:hover { border-color: $theme-primary; color: $theme-primary; }
.ps3-button:focus { outline: 2px solid $theme-primary; outline-offset: 2px; }
.ps3-button:disabled { cursor: default; opacity: .45; }
.ps3-modalBackdrop { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 100000; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0,0,0,.48); }
.ps3-modal { width: 100%; max-width: 760px; max-height: calc(100vh - 48px); padding: 22px; overflow: auto; border-radius: 6px; background: $white; color: $neutral-primary; box-shadow: 0 12px 40px rgba(0,0,0,.3); }
.ps3-modal-large { max-width: 1120px; }
.ps3-modal h2 { margin: 0 0 14px; }
.ps3-modal > .ps3-label { margin-top: 14px; }
.ps3-dialogStatus { min-height: 42px; padding: 10px 0; color: $neutral-secondary; }
.ps3-dialogActions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
.ps3-pickerResults { max-height: 460px; margin: 14px 0; overflow: auto; }
.ps3-empty, .ps3-accessDenied { padding: 28px; border: 1px solid $neutral-light; border-radius: 6px; background: $white; text-align: center; }
@media (max-width: 800px) {
.ps3-shell { padding: 12px; }
.ps3-hero { padding: 22px 18px; }
.ps3-title { font-size: 25px; }
.ps3-tabs { flex-direction: column; border-bottom: 0; }
.ps3-tab { width: 100%; border-bottom: 0; border-left: 3px solid transparent; text-align: left; }
.ps3-tab.is-active { border-left-color: $theme-primary; }
.ps3-span-1, .ps3-span-2, .ps3-span-3, .ps3-span-4, .ps3-span-6, .ps3-span-8 { grid-column: span 12; }
.ps3-choiceCards { grid-template-columns: 1fr; }
.ps3-providerHeader { display: block; }
.ps3-storageBadge { display: inline-block; margin-top: 12px; }
.ps3-actionBar { display: grid; }
}
@media (forced-colors: active) {
.ps3-hero, .ps3-button-primary, .ps3-toggle input:checked + .ps3-toggleVisual { border: 1px solid CanvasText; background: Canvas; color: CanvasText; }
.ps3-section, .ps3-providerHeader, .ps3-actionBar { border: 1px solid CanvasText; }
}
@media (prefers-reduced-motion: reduce) {
.ps3-toggleVisual::after { transition: none; }
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "c41d05ec-0682-4af0-b807-649806ed56b3",
"alias": "PortalSettingsWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"preconfiguredEntries": [
{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Other" },
"title": { "default": "Portal Settings", "de-de": "Portal-Einstellungen" },
"description": {
"default": "Central administration for installed portal extensions.",
"de-de": "Zentrale Verwaltung installierter Portal-Erweiterungen."
},
"officeFabricIconFontName": "Settings",
"properties": {
"title": "Zentrale Verwaltung der Portal-Erweiterungen"
}
}
]
}

View File

@@ -0,0 +1,38 @@
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneTextField
} from '@microsoft/sp-webpart-base';
import * as strings from 'PortalSettingsWebPartStrings';
import { PortalSettingsApp } from '../../ui/PortalSettingsApp';
import './PortalSettings.scss';
export interface IPortalSettingsWebPartProperties {
title?: string;
}
export default class PortalSettingsWebPart extends BaseClientSideWebPart<IPortalSettingsWebPartProperties> {
private app: PortalSettingsApp;
public render(): void {
if (this.app) { this.app.dispose(); }
this.app = new PortalSettingsApp(this.context, this.properties.title);
this.app.render(this.domElement);
}
protected onDispose(): void {
if (this.app) { this.app.dispose(); this.app = undefined; }
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [{
header: { description: strings.PropertyPaneDescription },
groups: [{
groupName: strings.BasicGroupName,
groupFields: [PropertyPaneTextField('title', { label: strings.TitleFieldLabel })]
}]
}]
};
}
}

View File

@@ -0,0 +1,7 @@
define([], function() {
return {
"PropertyPaneDescription": "Portal-Einstellungen",
"BasicGroupName": "Allgemein",
"TitleFieldLabel": "Titel"
};
});

View File

@@ -0,0 +1,7 @@
define([], function() {
return {
"PropertyPaneDescription": "Portal Settings",
"BasicGroupName": "General",
"TitleFieldLabel": "Title"
};
});

View File

@@ -0,0 +1,10 @@
declare interface IPortalSettingsWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
TitleFieldLabel: string;
}
declare module 'PortalSettingsWebPartStrings' {
const strings: IPortalSettingsWebPartStrings;
export = strings;
}

View File

@@ -0,0 +1,55 @@
import { cloneValue, getPathValue, mergePreservingUnknown, setPathValue } from '../src/core/ObjectPath';
import { ISettingsProvider } from '../src/core/ProviderContracts';
import { getSettingsProvider, getSettingsProviders } from '../src/providers/ProviderRegistry';
let passed: number = 0;
function assert(condition: boolean, message: string): void {
if (!condition) { throw new Error(message); }
passed++;
}
const providers: Array<ISettingsProvider<any>> = getSettingsProviders(); // tslint:disable-line:no-any
assert(providers.length === 3, 'Genau drei Provider werden erwartet.');
assert(!!getSettingsProvider('megamenu'), 'MegaMenu-Provider fehlt.');
assert(!!getSettingsProvider('custombranding'), 'CustomBranding-Provider fehlt.');
assert(!!getSettingsProvider('expiryindicator'), 'ExpiryIndicator-Provider fehlt.');
providers.forEach((provider: ISettingsProvider<any>): void => { // tslint:disable-line:no-any
assert(provider.sections.length > 0, provider.key + ' hat keine Sections.');
assert(provider.sections.every(section => section.elements.length > 0), provider.key + ' enthält eine leere Section.');
assert(provider.validate(provider.createDefault()) instanceof Array, provider.key + ' liefert keine Validierungsliste.');
});
const mega: ISettingsProvider<any> = getSettingsProvider('megamenu'); // tslint:disable-line:no-any
const normalizedMega: any = mega.normalize({ cacheMinutes: 5000, cacheVersion: '', unknownFuture: 'keep', menuMode: 'other' }); // tslint:disable-line:no-any
assert(normalizedMega.cacheMinutes === 1440, 'MegaMenu Cache-Maximum greift nicht.');
assert(normalizedMega.cacheVersion === '1', 'MegaMenu Cache-Fallback fehlt.');
assert(normalizedMega.menuMode === 'megaMenu', 'MegaMenu Modus-Fallback fehlt.');
assert(normalizedMega.unknownFuture === 'keep', 'Unbekannte MegaMenu-Properties gehen verloren.');
const branding: ISettingsProvider<any> = getSettingsProvider('custombranding'); // tslint:disable-line:no-any
const normalizedBranding: any = branding.normalize({ elements: [{ type: 'span' }], future: 7 }); // tslint:disable-line:no-any
assert(normalizedBranding.placeholdertop.elements.length === 1, 'Legacy-Branding-Elemente werden nicht übernommen.');
assert(normalizedBranding.future === 7, 'Unbekannte Branding-Properties gehen verloren.');
assert(normalizedBranding.elements === undefined, 'Legacy-Root-Elemente werden nicht bereinigt.');
const unsafeBranding: any = branding.createDefault(); // tslint:disable-line:no-any
unsafeBranding.placeholdertop.elements = [{ type: 'script', content: 'alert(1)' }, { type: 'a', attributes: { onclick: 'alert(1)', href: 'javascript:alert(1)' } }];
assert(branding.validate(unsafeBranding).length >= 3, 'Unsichere Branding-Elemente werden nicht erkannt.');
const expiry: ISettingsProvider<any> = getSettingsProvider('expiryindicator'); // tslint:disable-line:no-any
const normalizedExpiry: any = expiry.normalize({ baseField: 'Invalid field', future: true }); // tslint:disable-line:no-any
assert(normalizedExpiry.baseField === 'Created', 'Expiry-Feldname wird nicht abgesichert.');
assert(normalizedExpiry.future === true, 'Unbekannte Expiry-Properties gehen verloren.');
const invalidExpiry: any = expiry.createDefault(); // tslint:disable-line:no-any
invalidExpiry.rules = [{ condition: { operator: 'xor', conditions: [] }, lifeTime: { value: 0, unit: 'hours' }, columnRule: [{}] }];
assert(expiry.validate(invalidExpiry).length >= 3, 'Ungültige Expiry-Regeln werden nicht erkannt.');
const value: any = {}; // tslint:disable-line:no-any
setPathValue(value, 'default.lifeTime.value', 3);
assert(getPathValue(value, 'default.lifeTime.value') === 3, 'Objektpfade funktionieren nicht.');
const merged: any = mergePreservingUnknown({ known: 1, future: { keep: true } }, { known: 2 }); // tslint:disable-line:no-any
assert(merged.known === 2 && merged.future.keep === true, 'Schemaerhaltendes Mergen ist fehlerhaft.');
const cloned: any = cloneValue(merged); cloned.future.keep = false; // tslint:disable-line:no-any
assert(merged.future.keep === true, 'Konfiguration wird nicht tief kopiert.');
console.log('PortalSettings Provider tests passed: ' + passed);

20
tests/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../temp/tests",
"skipLibCheck": true,
"types": [],
"lib": ["es5", "dom", "es2015.collection"]
},
"files": [
"../src/core/ProviderContracts.ts",
"../src/core/ObjectPath.ts",
"../src/providers/MegaMenuSettingsProvider.ts",
"../src/providers/CustomBrandingSettingsProvider.ts",
"../src/providers/ExpiryIndicatorSettingsProvider.ts",
"../src/providers/ProviderRegistry.ts",
"./ProviderRegistry.test.ts"
]
}

View File

@@ -0,0 +1,26 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$tokens = $null
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $root 'deployment\Add-PortalSettingsPage.ps1'),
[ref]$tokens,
[ref]$errors
)
if ($errors.Count -gt 0) {
throw ('PowerShell-Syntaxfehler: ' + $errors[0].Message)
}
$jsonFiles = @(
'package.json',
'config\config.json',
'config\package-solution.json',
'config\serve.json',
'src\webparts\portalSettings\PortalSettingsWebPart.manifest.json'
)
foreach ($relativePath in $jsonFiles) {
Get-Content -Raw -LiteralPath (Join-Path $root $relativePath) | ConvertFrom-Json | Out-Null
}
Write-Host ("PortalSettings project validation passed: 1 PowerShell file and $($jsonFiles.Count) JSON files.")

View File

@@ -0,0 +1,36 @@
'use strict';
var fs = require('fs');
var path = require('path');
var root = path.resolve(__dirname, '..');
var passed = 0;
function read(file) { return fs.readFileSync(path.join(root, file), 'utf8'); }
function json(file) { return JSON.parse(read(file)); }
function assert(condition, message) { if (!condition) { throw new Error(message); } passed++; }
var packageJson = json('package.json');
var solution = json('config/package-solution.json').solution;
var config = json('config/config.json');
var manifest = json('src/webparts/portalSettings/PortalSettingsWebPart.manifest.json');
var styles = read('src/webparts/portalSettings/PortalSettings.scss');
var sourceFiles = [
'src/ui/Dom.ts',
'src/ui/DynamicFormRenderer.ts',
'src/ui/PortalSettingsApp.ts'
].map(read).join('\n');
assert(packageJson.version === '3.0.0', 'Package-Version ist nicht 3.0.0.');
assert(solution.version === '3.0.0.0', 'Solution-Version ist nicht 3.0.0.0.');
assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.');
assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.');
assert(manifest.componentType === 'WebPart', 'Komponente ist kein WebPart.');
assert(!fs.existsSync(path.join(root, 'src/assets/PortalSettings.aspx')), 'Alte ASPX-Seite ist noch vorhanden.');
assert(!fs.existsSync(path.join(root, 'src/extensions/portalSettings/PortalSettingsApplicationCustomizer.ts')), 'Alter Application Customizer ist noch vorhanden.');
assert(sourceFiles.indexOf('innerHTML') < 0, 'UI darf innerHTML nicht verwenden.');
['themePrimary', 'neutralPrimary', 'neutralLight', 'white'].forEach(function (slot) {
assert(styles.indexOf('[theme: ' + slot + ', default:') >= 0, 'Themeslot fehlt: ' + slot);
});
assert(packageJson.scripts.package.indexOf('npm test') === 0, 'Paketierung startet nicht mit Tests.');
console.log('PortalSettings static tests passed: ' + passed);