diff --git a/resources/js/components/navigation/View.vue b/resources/js/components/navigation/View.vue
index 630f7d9258..4c62b50f87 100644
--- a/resources/js/components/navigation/View.vue
+++ b/resources/js/components/navigation/View.vue
@@ -383,9 +383,9 @@ export default {
},
treeSaved(response) {
- this.changed = false;
-
this.replaceGeneratedIds(response.data.generatedIds);
+
+ this.changed = false;
},
replaceGeneratedIds(ids) {
diff --git a/resources/js/components/publish/Field.vue b/resources/js/components/publish/Field.vue
index ae3beddff5..dcf4cf21a2 100644
--- a/resources/js/components/publish/Field.vue
+++ b/resources/js/components/publish/Field.vue
@@ -12,7 +12,7 @@
v-if="showLabelText"
class="mr-1"
:class="{ 'text-gray-600': syncable && isSynced }"
- v-text="labelText"
+ v-text="__(labelText)"
v-tooltip="{content: config.handle, delay: 500, autoHide: false}"
/>
*
@@ -126,7 +126,7 @@ export default {
instructions() {
return this.config.instructions
- ? this.renderMarkdownAndLinks(this.config.instructions)
+ ? this.renderMarkdownAndLinks(__(this.config.instructions))
: null
},
diff --git a/resources/js/components/publish/HasHiddenFields.js b/resources/js/components/publish/HasHiddenFields.js
index 735e653a3c..25a215b18c 100644
--- a/resources/js/components/publish/HasHiddenFields.js
+++ b/resources/js/components/publish/HasHiddenFields.js
@@ -36,9 +36,7 @@ export default {
let originalValues = new Values(this.values, this.jsonSubmittingFields);
let newValues = new Values(responseValues, this.jsonSubmittingFields);
- preserveFields.forEach(dottedKey => {
- newValues.set(dottedKey, originalValues.get(dottedKey));
- });
+ newValues.mergeDottedKeys(preserveFields, originalValues);
return newValues.all();
},
diff --git a/resources/js/components/publish/Sections.vue b/resources/js/components/publish/Sections.vue
index 50442357c3..75bf816204 100644
--- a/resources/js/components/publish/Sections.vue
+++ b/resources/js/components/publish/Sections.vue
@@ -4,8 +4,8 @@
@@ -46,7 +46,7 @@
v-for="(tab, index) in mainTabs"
v-show="shouldShowInDropdown(index)"
:key="tab.handle"
- :text="tab.display || `${tab.handle[0].toUpperCase()}${tab.handle.slice(1)}`"
+ :text="__(tab.display || `${tab.handle[0].toUpperCase()}${tab.handle.slice(1)}`)"
@click.prevent="setActive(tab.handle)"
/>
diff --git a/resources/js/components/publish/Values.js b/resources/js/components/publish/Values.js
index ba4c8a9990..59dc2b9467 100644
--- a/resources/js/components/publish/Values.js
+++ b/resources/js/components/publish/Values.js
@@ -39,6 +39,20 @@ export default class Values {
return this;
}
+ mergeDottedKeys(dottedKeys, values) {
+ let decodedValues = new this.constructor(clone(values.values), values.jsonFields)
+ .jsonDecode()
+ .values;
+
+ this.jsonDecode();
+ dottedKeys.forEach(dottedKey => {
+ data_set(this.values, dottedKey, data_get(decodedValues, dottedKey));
+ });
+ this.jsonEncode();
+
+ return this;
+ }
+
except(dottedKeys) {
return this.jsonDecode()
.rejectValuesByKey(dottedKeys)
diff --git a/resources/lang/de.json b/resources/lang/de.json
index 9f7f2e05ba..9b26e93abe 100644
--- a/resources/lang/de.json
+++ b/resources/lang/de.json
@@ -137,6 +137,7 @@
"Before": "Bevor",
"Behavior": "Verhalten",
"Below": "Unterhalb",
+ "Between": "Zwischen",
"Blockquote": "Blockzitat",
"Blueprint": "Blueprint",
"Blueprint created": "Blueprint erstellt",
@@ -497,6 +498,7 @@
"Inline": "Inline",
"Inline Code": "Inline-Code",
"Inline Label": "Inline Beschriftung",
+ "Inline Label when True": "Inline Beschriftung wenn aktiv",
"Input Behavior": "Eingabeverhalten",
"Input Label": "Beschriftung",
"Input Type": "Eingabetyp",
@@ -659,6 +661,7 @@
"Per Page": "Pro Seite",
"Per-site": "Pro Website",
"Permissions": "Berechtigungen",
+ "Phone": "Telefon",
"PHP Info": "PHP Info",
"Pick Color": "Farbe wählen",
"Pin to Favorites": "Als Favorit speichern",
@@ -1010,6 +1013,7 @@
"You are not authorized to view collections.": "Du bist nicht berechtigt, Sammlungen anzuzeigen.",
"You are not authorized to view navs.": "Du bist nicht berechtigt, Navigationen anzuzeigen.",
"You are not authorized to view this collection.": "Du bist nicht berechtigt, diese Sammlung anzuzeigen.",
+ "You are now impersonating": "Du gibst dich jetzt aus als",
"Your Favorites": "Meine Favoriten",
"Your working copy will be replaced by the contents of this revision.": "Deine Arbeitskopie wird durch den Inhalt dieser Überarbeitung ersetzt."
}
diff --git a/resources/lang/de/fieldtypes.php b/resources/lang/de/fieldtypes.php
index 6b3a58351a..234ec79011 100644
--- a/resources/lang/de/fieldtypes.php
+++ b/resources/lang/de/fieldtypes.php
@@ -154,8 +154,8 @@
'slug.title' => 'Slug',
'structures.title' => 'Strukturen',
'table.title' => 'Tabelle',
- 'taggable.title' => 'Tags',
'taggable.config.placeholder' => 'Tippen und ↩ Enter drücken',
+ 'taggable.title' => 'Tags',
'taxonomies.title' => 'Taxonomien',
'template.config.blueprint' => 'Fügt die Option „Korrespondiert mit Blueprint“ hinzu. Erfahre mehr in der [Dokumentation](https://statamic.dev/views#inferring-templates-from-entry-blueprints).',
'template.config.folder' => 'Nur Templates aus diesem Ordner anzeigen.',
@@ -175,11 +175,12 @@
'textarea.title' => 'Textbereich',
'time.config.seconds_enabled' => 'Sekunden im Timepicker anzeigen.',
'time.title' => 'Zeit',
- 'toggle.config.inline_label' => 'Inline-Beschriftung festlegen, welche neben dem Schalter angezeigt werden soll.',
+ 'toggle.config.inline_label' => 'Eine Inline-Beschriftung festlegen, die neben dem Schalter angezeigt wird.',
+ 'toggle.config.inline_label_when_true' => 'Eine Inline-Beschriftung festlegen, die neben dem Schalter angezeigt wird, wenn der Wert true ist.',
'toggle.title' => 'Schalter',
'user_groups.title' => 'Benutzergruppen',
- 'users.config.query_scopes' => 'Auswählen, welche Abfragebereiche beim Abrufen der auswählbaren Benutzer:innen angewendet werden sollen.',
'user_roles.title' => 'Benutzerrollen',
+ 'users.config.query_scopes' => 'Auswählen, welche Abfragebereiche beim Abrufen der auswählbaren Benutzer:innen angewendet werden sollen.',
'users.title' => 'Benutzer:innen',
'video.title' => 'Video',
'width.config.options' => 'Verfügbare Breiten festlegen.',
diff --git a/resources/lang/de/messages.php b/resources/lang/de/messages.php
index c469864768..6c10ed5fc2 100644
--- a/resources/lang/de/messages.php
+++ b/resources/lang/de/messages.php
@@ -81,7 +81,7 @@
'field_validation_required_instructions' => 'Steuert, ob dieses Feld erforderlich ist oder nicht.',
'field_validation_sometimes_instructions' => 'Dieses Feld wird nur validiert, wenn es sichtbar ist oder abgeschickt wird.',
'fields_blueprints_description' => 'Blueprints definieren die Felder für den inhaltlichen Aufbau von Sammlungen, Taxonomien, Benutzer:innen und Formularen.',
- 'fields_default_instructions' => 'Standardwert festlegen.',
+ 'fields_default_instructions' => 'Diesen Wert immer dann einfügen, wenn das Feld in einem Veröffentlichungsformular leer ist.',
'fields_display_instructions' => 'Name des Feldes im Control Panel.',
'fields_duplicate_instructions' => 'Soll dieses Feld beim Duplizieren des Eintrags berücksichtigt werden?',
'fields_fieldsets_description' => 'Fieldsets sind einfache, flexible und völlig optionale Gruppen von Feldern, mit deren Hilfe wiederverwendbare, vorkonfigurierte Blöcke organisiert werden können.',
diff --git a/resources/lang/de/permissions.php b/resources/lang/de/permissions.php
index 56f790409b..166b65b5aa 100644
--- a/resources/lang/de/permissions.php
+++ b/resources/lang/de/permissions.php
@@ -12,6 +12,8 @@
'configure_addons_desc' => 'Ermöglicht den Zugriff auf den Addon-Bereich zum Installieren und Deinstallieren von Addons.',
'manage_preferences' => 'Voreinstellungen verwalten',
'manage_preferences_desc' => 'Ermöglicht globale und rollenspezifische Voreinstellungen anzupassen.',
+ 'group_sites' => 'Websites',
+ 'access_{site}_site' => 'Zugriff auf :site',
'group_collections' => 'Sammlungen',
'configure_collections' => 'Sammlungen konfigurieren',
'configure_collections_desc' => 'Gewährt Zugriff auf alle Berechtigungen im Zusammenhang mit Sammlungen.',
diff --git a/resources/lang/de/validation.php b/resources/lang/de/validation.php
index 629cf5e3af..ad8f01d46b 100644
--- a/resources/lang/de/validation.php
+++ b/resources/lang/de/validation.php
@@ -2,6 +2,7 @@
return [
'accepted' => 'Muss akzeptiert werden.',
+ 'accepted_if' => 'Muss akzeptiert werden, wenn :other gleich :value ist.',
'active_url' => 'Dies ist keine gültige URL.',
'after' => 'Muss ein Datum nach dem :date sein.',
'after_or_equal' => 'Muss ein Datum nach oder gleich dem :date sein.',
@@ -9,35 +10,44 @@
'alpha_dash' => 'Darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten.',
'alpha_num' => 'Darf nur Buchstaben und Zahlen enthalten.',
'array' => 'Muss ein Array sein.',
+ 'ascii' => 'Darf nur alphanumerische Ein-Byte-Zeichen und Symbole enthalten.',
'before' => 'Muss ein Datum vor dem :date sein.',
'before_or_equal' => 'Muss ein Datum vor oder gleich dem :date sein.',
- 'between.numeric' => 'Muss zwischen :min und :max liegen.',
+ 'between.array' => 'Muss zwischen :min und :max Element haben.',
'between.file' => 'Muss zwischen :min und :max Kilobyte liegen.',
+ 'between.numeric' => 'Muss zwischen :min und :max liegen.',
'between.string' => 'Muss zwischen :min und :max Zeichen liegen.',
- 'between.array' => 'Muss zwischen :min und :max Element haben.',
'boolean' => 'Muss wahr oder falsch sein.',
+ 'can' => 'Enthält einen unzulässigen Wert.',
'confirmed' => 'Bestätigung stimmt nicht überein.',
'current_password' => 'Das Passwort ist falsch.',
'date' => 'Kein gültiges Datum.',
+ 'date_equals' => 'Das Datum muss gleich :date sein.',
'date_format' => 'Entspricht nicht dem Format :format .',
+ 'decimal' => 'Muss :decimal Dezimalstellen haben.',
+ 'declined' => 'Muss abgelehnt werden.',
+ 'declined_if' => 'Muss abgelehnt werden, wenn :other gleich :value ist.',
'different' => 'Dieses Feld und :other müssen unterschiedlich sein.',
'digits' => 'Muss aus :digits Ziffern bestehen.',
'digits_between' => 'Muss zwischen den Ziffern :min und :max liegen.',
'dimensions' => 'Ungültige Bildabmessungen.',
'distinct' => 'Dieses Feld hat einen doppelten Wert.',
+ 'doesnt_end_with' => 'Darf nicht mit einer der folgenden Angaben enden: :values.',
+ 'doesnt_start_with' => 'Darf nicht mit einer der folgenden Angaben beginnen: :values.',
'email' => 'Muss eine gültige E-Mail-Adresse sein.',
'ends_with' => 'Muss auf :values enden',
+ 'enum' => 'Das ausgewählte :attribute ist ungültig.',
'exists' => 'Dies ist ungültig.',
'file' => 'Muss eine Datei sein.',
'filled' => 'Muss einen Wert haben.',
- 'gt.numeric' => 'Muss größer als :value sein.',
+ 'gt.array' => 'Muss mehr als :value Elemente haben.',
'gt.file' => 'Muss größer als :value Kilobyte sein.',
+ 'gt.numeric' => 'Muss größer als :value sein.',
'gt.string' => 'Muss größer als :value Zeichen sein.',
- 'gt.array' => 'Muss mehr als :value Elemente haben.',
- 'gte.numeric' => 'Muss größer oder gleich :value sein.',
+ 'gte.array' => 'Muss :value oder mehr Elemente haben.',
'gte.file' => 'Muss größer oder gleich :value Kilobyte sein.',
+ 'gte.numeric' => 'Muss größer oder gleich :value sein.',
'gte.string' => 'Muss größer oder gleich :value Zeichen sein.',
- 'gte.array' => 'Muss :value oder mehr Elemente haben.',
'image' => 'Muss ein Bild sein.',
'in' => 'Dies ist ungültig.',
'in_array' => 'Dieses Feld existiert nicht in :other .',
@@ -47,47 +57,65 @@
'ipv6' => 'Muss eine gültige IPv6-Adresse sein.',
'json' => 'Muss eine gültige JSON-Zeichenfolge sein.',
'lowercase' => 'Muss klein geschrieben sein.',
- 'lt.numeric' => 'Muss kleiner als :value sein.',
+ 'lt.array' => 'Muss weniger als :value Elemente enthalten.',
'lt.file' => 'Muss kleiner als :value Kilobyte sein.',
+ 'lt.numeric' => 'Muss kleiner als :value sein.',
'lt.string' => 'Muss aus weniger als :value Zeichen bestehen.',
- 'lt.array' => 'Muss weniger als :value Elemente enthalten.',
- 'lte.numeric' => 'Muss kleiner oder gleich :value sein.',
+ 'lte.array' => 'Darf nicht mehr als :value Elemente enthalten.',
'lte.file' => 'Muss kleiner oder gleich :value Kilobyte sein.',
+ 'lte.numeric' => 'Muss kleiner oder gleich :value sein.',
'lte.string' => 'Muss kleiner oder gleich :value Zeichen sein.',
- 'lte.array' => 'Darf nicht mehr als :value Elemente enthalten.',
- 'max.numeric' => 'Darf nicht größer als :max sein.',
+ 'mac_address' => 'Muss eine gültige MAC-Adresse sein.',
+ 'max.array' => 'Darf nicht mehr als :max Elemente haben.',
'max.file' => 'Darf nicht größer als :max kilobytes sein.',
+ 'max.numeric' => 'Darf nicht größer als :max sein.',
'max.string' => 'Darf nicht mehr als :max Zeichen haben.',
- 'max.array' => 'Darf nicht mehr als :max Elemente haben.',
+ 'max_digits' => 'Darf nicht mehr als :max Ziffern enthalten.',
'mimes' => 'Muss eine Datei vom Typ :values sein.',
'mimetypes' => 'Muss eine Datei vom Typ :values sein.',
- 'min.numeric' => 'Muss mindestens :min sein.',
+ 'min.array' => 'Muss mindestens :min Elemente enthalten.',
'min.file' => 'Muss mindestens :min Kilobyte sein.',
+ 'min.numeric' => 'Muss mindestens :min sein.',
'min.string' => 'Muss mindestens :min Zeichen haben.',
- 'min.array' => 'Muss mindestens :min Elemente enthalten.',
+ 'min_digits' => 'Darf nicht weniger als :max Ziffern enthalten.',
+ 'missing' => 'Muss fehlen.',
+ 'missing_if' => 'Muss fehlen, wenn :other gleich :value ist.',
+ 'missing_unless' => 'Muss fehlen, wenn :other nicht :value ist.',
+ 'missing_with' => 'Muss fehlen, wenn :values vorhanden ist.',
+ 'missing_with_all' => 'Muss fehlen, wenn :values vorhanden sind.',
+ 'multiple_of' => 'Muss ein Vielfaches von :value sein.',
'not_in' => 'Dies ist ungültig.',
'not_regex' => 'Das Format ist ungültig.',
'numeric' => 'Muss eine Nummer sein.',
'present' => 'Muss vorhanden sein.',
+ 'prohibited' => 'Unzulässig.',
+ 'prohibited_if' => 'Unzulässig, wenn :other gleich :value ist.',
+ 'prohibited_unless' => 'Unzulässig, wenn :other nicht in :values enthalten ist.',
+ 'prohibits' => 'Verbietet :other die Anwesenheit.',
'regex' => 'Das Format ist ungültig.',
'required' => 'Dieses Feld wird benötigt.',
+ 'required_array_keys' => 'Muss Einträge für :values enthalten.',
'required_if' => 'Dieses Feld ist erforderlich, wenn :other den Wert :value hat.',
+ 'required_if_accepted' => 'Dieses Feld ist erforderlich, wenn :other zugelassen ist.',
'required_unless' => 'Dieses Feld ist erforderlich, es sei denn, :other hat den Wert :values.',
'required_with' => 'Dieses Feld ist erforderlich, wenn :values vorhanden ist.',
'required_with_all' => 'Dieses Feld ist erforderlich, wenn :values vorhanden ist.',
'required_without' => 'Dieses Feld ist erforderlich, wenn keiner der :values vorhanden ist.',
'required_without_all' => 'Dieses Feld ist erforderlich, wenn keiner der :values vorhanden ist:',
'same' => 'Dieses Feld und :other müssen übereinstimmen.',
- 'size.numeric' => 'Muss :size sein.',
+ 'size.array' => 'Muss :size Elemente enthalten.',
'size.file' => 'Muss :size Kilobyte sein.',
+ 'size.numeric' => 'Muss :size sein.',
'size.string' => 'Muss :size Zeichen haben.',
- 'size.array' => 'Muss :size Elemente enthalten.',
'starts_with' => 'Muss mit :values beginnen',
'string' => 'Muss eine Zeichenfolge sein.',
'timezone' => 'Muss eine gültige Zeitzone sein.',
'unique' => 'Dieser Wert wurde bereits vergeben.',
'uploaded' => 'Der Upload ist fehlgeschlagen.',
+ 'uppercase' => 'Muss in Großbuchstaben geschrieben sein.',
'url' => 'Das Format ist ungültig.',
+ 'ulid' => 'Muss eine gültige ULID sein.',
+ 'uuid' => 'Muss eine gültige UUID sein.',
'unique_entry_value' => 'Dieser Wert wurde bereits vergeben.',
'unique_term_value' => 'Dieser Wert wurde bereits vergeben.',
'unique_user_value' => 'Dieser Wert wurde bereits vergeben.',
diff --git a/resources/lang/de_CH.json b/resources/lang/de_CH.json
index 2143cb0789..2d15b40bc4 100644
--- a/resources/lang/de_CH.json
+++ b/resources/lang/de_CH.json
@@ -137,6 +137,7 @@
"Before": "Bevor",
"Behavior": "Verhalten",
"Below": "Unterhalb",
+ "Between": "Zwischen",
"Blockquote": "Blockzitat",
"Blueprint": "Blueprint",
"Blueprint created": "Blueprint erstellt",
@@ -497,6 +498,7 @@
"Inline": "Inline",
"Inline Code": "Inline-Code",
"Inline Label": "Inline Beschriftung",
+ "Inline Label when True": "Inline Beschriftung wenn aktiv",
"Input Behavior": "Eingabeverhalten",
"Input Label": "Beschriftung",
"Input Type": "Eingabetyp",
@@ -659,6 +661,7 @@
"Per Page": "Pro Seite",
"Per-site": "Pro Website",
"Permissions": "Berechtigungen",
+ "Phone": "Telefon",
"PHP Info": "PHP Info",
"Pick Color": "Farbe wählen",
"Pin to Favorites": "Als Favorit speichern",
@@ -1010,6 +1013,7 @@
"You are not authorized to view collections.": "Du bist nicht berechtigt, Sammlungen anzuzeigen.",
"You are not authorized to view navs.": "Du bist nicht berechtigt, Navigationen anzuzeigen.",
"You are not authorized to view this collection.": "Du bist nicht berechtigt, diese Sammlung anzuzeigen.",
+ "You are now impersonating": "Du gibst dich jetzt aus als",
"Your Favorites": "Meine Favoriten",
"Your working copy will be replaced by the contents of this revision.": "Deine Arbeitskopie wird durch den Inhalt dieser Überarbeitung ersetzt."
}
diff --git a/resources/lang/de_CH/fieldtypes.php b/resources/lang/de_CH/fieldtypes.php
index fc32a71232..d091a99f03 100644
--- a/resources/lang/de_CH/fieldtypes.php
+++ b/resources/lang/de_CH/fieldtypes.php
@@ -154,8 +154,8 @@
'slug.title' => 'Slug',
'structures.title' => 'Strukturen',
'table.title' => 'Tabelle',
- 'taggable.title' => 'Tags',
'taggable.config.placeholder' => 'Tippen und ↩ Enter drücken',
+ 'taggable.title' => 'Tags',
'taxonomies.title' => 'Taxonomien',
'template.config.blueprint' => 'Fügt die Option «Korrespondiert mit Blueprint» hinzu. Erfahre mehr in der [Dokumentation](https://statamic.dev/views#inferring-templates-from-entry-blueprints).',
'template.config.folder' => 'Nur Templates aus diesem Ordner anzeigen.',
@@ -175,7 +175,8 @@
'textarea.title' => 'Textbereich',
'time.config.seconds_enabled' => 'Sekunden im Timepicker anzeigen.',
'time.title' => 'Zeit',
- 'toggle.config.inline_label' => 'Inline-Beschriftung festlegen, welche neben dem Schalter angezeigt werden soll.',
+ 'toggle.config.inline_label' => 'Eine Inline-Beschriftung festlegen, die neben dem Schalter angezeigt wird.',
+ 'toggle.config.inline_label_when_true' => 'Eine Inline-Beschriftung festlegen, die neben dem Schalter angezeigt wird, wenn der Wert true ist.',
'toggle.title' => 'Schalter',
'user_groups.title' => 'Benutzergruppen',
'user_roles.title' => 'Benutzerrollen',
diff --git a/resources/lang/de_CH/messages.php b/resources/lang/de_CH/messages.php
index 2a1bb20b29..d1b7e43300 100644
--- a/resources/lang/de_CH/messages.php
+++ b/resources/lang/de_CH/messages.php
@@ -81,7 +81,7 @@
'field_validation_required_instructions' => 'Steuert, ob dieses Feld erforderlich ist oder nicht.',
'field_validation_sometimes_instructions' => 'Dieses Feld wird nur validiert, wenn es sichtbar ist oder abgeschickt wird.',
'fields_blueprints_description' => 'Blueprints definieren die Felder für den inhaltlichen Aufbau von Sammlungen, Taxonomien, Benutzer:innen und Formularen.',
- 'fields_default_instructions' => 'Standardwert festlegen.',
+ 'fields_default_instructions' => 'Diesen Wert immer dann einfügen, wenn das Feld in einem Veröffentlichungsformular leer ist.',
'fields_display_instructions' => 'Name des Feldes im Control Panel.',
'fields_duplicate_instructions' => 'Soll dieses Feld beim Duplizieren des Eintrags berücksichtigt werden?',
'fields_fieldsets_description' => 'Fieldsets sind einfache, flexible und völlig optionale Gruppen von Feldern, mit deren Hilfe wiederverwendbare, vorkonfigurierte Blöcke organisiert werden können.',
diff --git a/resources/lang/de_CH/permissions.php b/resources/lang/de_CH/permissions.php
index 56f790409b..166b65b5aa 100644
--- a/resources/lang/de_CH/permissions.php
+++ b/resources/lang/de_CH/permissions.php
@@ -12,6 +12,8 @@
'configure_addons_desc' => 'Ermöglicht den Zugriff auf den Addon-Bereich zum Installieren und Deinstallieren von Addons.',
'manage_preferences' => 'Voreinstellungen verwalten',
'manage_preferences_desc' => 'Ermöglicht globale und rollenspezifische Voreinstellungen anzupassen.',
+ 'group_sites' => 'Websites',
+ 'access_{site}_site' => 'Zugriff auf :site',
'group_collections' => 'Sammlungen',
'configure_collections' => 'Sammlungen konfigurieren',
'configure_collections_desc' => 'Gewährt Zugriff auf alle Berechtigungen im Zusammenhang mit Sammlungen.',
diff --git a/resources/lang/de_CH/validation.php b/resources/lang/de_CH/validation.php
index b7c836ac79..df664d270b 100644
--- a/resources/lang/de_CH/validation.php
+++ b/resources/lang/de_CH/validation.php
@@ -2,6 +2,7 @@
return [
'accepted' => 'Muss akzeptiert werden.',
+ 'accepted_if' => 'Muss akzeptiert werden, wenn :other gleich :value ist.',
'active_url' => 'Dies ist keine gültige URL.',
'after' => 'Muss ein Datum nach dem :date sein.',
'after_or_equal' => 'Muss ein Datum nach oder gleich dem :date sein.',
@@ -9,35 +10,44 @@
'alpha_dash' => 'Darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten.',
'alpha_num' => 'Darf nur Buchstaben und Zahlen enthalten.',
'array' => 'Muss ein Array sein.',
+ 'ascii' => 'Darf nur alphanumerische Ein-Byte-Zeichen und Symbole enthalten.',
'before' => 'Muss ein Datum vor dem :date sein.',
'before_or_equal' => 'Muss ein Datum vor oder gleich dem :date sein.',
- 'between.numeric' => 'Muss zwischen :min und :max liegen.',
+ 'between.array' => 'Muss zwischen :min und :max Element haben.',
'between.file' => 'Muss zwischen :min und :max Kilobyte liegen.',
+ 'between.numeric' => 'Muss zwischen :min und :max liegen.',
'between.string' => 'Muss zwischen :min und :max Zeichen liegen.',
- 'between.array' => 'Muss zwischen :min und :max Element haben.',
'boolean' => 'Muss wahr oder falsch sein.',
+ 'can' => 'Enthält einen unzulässigen Wert.',
'confirmed' => 'Bestätigung stimmt nicht überein.',
'current_password' => 'Das Passwort ist falsch.',
'date' => 'Kein gültiges Datum.',
+ 'date_equals' => 'Das Datum muss gleich :date sein.',
'date_format' => 'Entspricht nicht dem Format :format .',
+ 'decimal' => 'Muss :decimal Dezimalstellen haben.',
+ 'declined' => 'Muss abgelehnt werden.',
+ 'declined_if' => 'Muss abgelehnt werden, wenn :other gleich :value ist.',
'different' => 'Dieses Feld und :other müssen unterschiedlich sein.',
'digits' => 'Muss aus :digits Ziffern bestehen.',
'digits_between' => 'Muss zwischen den Ziffern :min und :max liegen.',
'dimensions' => 'Ungültige Bildabmessungen.',
'distinct' => 'Dieses Feld hat einen doppelten Wert.',
+ 'doesnt_end_with' => 'Darf nicht mit einer der folgenden Angaben enden: :values.',
+ 'doesnt_start_with' => 'Darf nicht mit einer der folgenden Angaben beginnen: :values.',
'email' => 'Muss eine gültige E-Mail-Adresse sein.',
'ends_with' => 'Muss auf :values enden',
+ 'enum' => 'Das ausgewählte :attribute ist ungültig.',
'exists' => 'Dies ist ungültig.',
'file' => 'Muss eine Datei sein.',
'filled' => 'Muss einen Wert haben.',
- 'gt.numeric' => 'Muss grösser als :value sein.',
+ 'gt.array' => 'Muss mehr als :value Elemente haben.',
'gt.file' => 'Muss grösser als :value Kilobyte sein.',
+ 'gt.numeric' => 'Muss grösser als :value sein.',
'gt.string' => 'Muss grösser als :value Zeichen sein.',
- 'gt.array' => 'Muss mehr als :value Elemente haben.',
- 'gte.numeric' => 'Muss grösser oder gleich :value sein.',
+ 'gte.array' => 'Muss :value oder mehr Elemente haben.',
'gte.file' => 'Muss grösser oder gleich :value Kilobyte sein.',
+ 'gte.numeric' => 'Muss grösser oder gleich :value sein.',
'gte.string' => 'Muss grösser oder gleich :value Zeichen sein.',
- 'gte.array' => 'Muss :value oder mehr Elemente haben.',
'image' => 'Muss ein Bild sein.',
'in' => 'Dies ist ungültig.',
'in_array' => 'Dieses Feld existiert nicht in :other .',
@@ -47,47 +57,65 @@
'ipv6' => 'Muss eine gültige IPv6-Adresse sein.',
'json' => 'Muss eine gültige JSON-Zeichenfolge sein.',
'lowercase' => 'Muss klein geschrieben sein.',
- 'lt.numeric' => 'Muss kleiner als :value sein.',
+ 'lt.array' => 'Muss weniger als :value Elemente enthalten.',
'lt.file' => 'Muss kleiner als :value Kilobyte sein.',
+ 'lt.numeric' => 'Muss kleiner als :value sein.',
'lt.string' => 'Muss aus weniger als :value Zeichen bestehen.',
- 'lt.array' => 'Muss weniger als :value Elemente enthalten.',
- 'lte.numeric' => 'Muss kleiner oder gleich :value sein.',
+ 'lte.array' => 'Darf nicht mehr als :value Elemente enthalten.',
'lte.file' => 'Muss kleiner oder gleich :value Kilobyte sein.',
+ 'lte.numeric' => 'Muss kleiner oder gleich :value sein.',
'lte.string' => 'Muss kleiner oder gleich :value Zeichen sein.',
- 'lte.array' => 'Darf nicht mehr als :value Elemente enthalten.',
- 'max.numeric' => 'Darf nicht grösser als :max sein.',
+ 'mac_address' => 'Muss eine gültige MAC-Adresse sein.',
+ 'max.array' => 'Darf nicht mehr als :max Elemente haben.',
'max.file' => 'Darf nicht grösser als :max kilobytes sein.',
+ 'max.numeric' => 'Darf nicht grösser als :max sein.',
'max.string' => 'Darf nicht mehr als :max Zeichen haben.',
- 'max.array' => 'Darf nicht mehr als :max Elemente haben.',
+ 'max_digits' => 'Darf nicht mehr als :max Ziffern enthalten.',
'mimes' => 'Muss eine Datei vom Typ :values sein.',
'mimetypes' => 'Muss eine Datei vom Typ :values sein.',
- 'min.numeric' => 'Muss mindestens :min sein.',
+ 'min.array' => 'Muss mindestens :min Elemente enthalten.',
'min.file' => 'Muss mindestens :min Kilobyte sein.',
+ 'min.numeric' => 'Muss mindestens :min sein.',
'min.string' => 'Muss mindestens :min Zeichen haben.',
- 'min.array' => 'Muss mindestens :min Elemente enthalten.',
+ 'min_digits' => 'Darf nicht weniger als :max Ziffern enthalten.',
+ 'missing' => 'Muss fehlen.',
+ 'missing_if' => 'Muss fehlen, wenn :other gleich :value ist.',
+ 'missing_unless' => 'Muss fehlen, wenn :other nicht :value ist.',
+ 'missing_with' => 'Muss fehlen, wenn :values vorhanden ist.',
+ 'missing_with_all' => 'Muss fehlen, wenn :values vorhanden sind.',
+ 'multiple_of' => 'Muss ein Vielfaches von :value sein.',
'not_in' => 'Dies ist ungültig.',
'not_regex' => 'Das Format ist ungültig.',
'numeric' => 'Muss eine Nummer sein.',
'present' => 'Muss vorhanden sein.',
+ 'prohibited' => 'Unzulässig.',
+ 'prohibited_if' => 'Unzulässig, wenn :other gleich :value ist.',
+ 'prohibited_unless' => 'Unzulässig, wenn :other nicht in :values enthalten ist.',
+ 'prohibits' => 'Verbietet :other die Anwesenheit.',
'regex' => 'Das Format ist ungültig.',
'required' => 'Dieses Feld wird benötigt.',
+ 'required_array_keys' => 'Muss Einträge für :values enthalten.',
'required_if' => 'Dieses Feld ist erforderlich, wenn :other den Wert :value hat.',
+ 'required_if_accepted' => 'Dieses Feld ist erforderlich, wenn :other zugelassen ist.',
'required_unless' => 'Dieses Feld ist erforderlich, es sei denn, :other hat den Wert :values.',
'required_with' => 'Dieses Feld ist erforderlich, wenn :values vorhanden ist.',
'required_with_all' => 'Dieses Feld ist erforderlich, wenn :values vorhanden ist.',
'required_without' => 'Dieses Feld ist erforderlich, wenn keiner der :values vorhanden ist.',
'required_without_all' => 'Dieses Feld ist erforderlich, wenn keiner der :values vorhanden ist:',
'same' => 'Dieses Feld und :other müssen übereinstimmen.',
- 'size.numeric' => 'Muss :size sein.',
+ 'size.array' => 'Muss :size Elemente enthalten.',
'size.file' => 'Muss :size Kilobyte sein.',
+ 'size.numeric' => 'Muss :size sein.',
'size.string' => 'Muss :size Zeichen haben.',
- 'size.array' => 'Muss :size Elemente enthalten.',
'starts_with' => 'Muss mit :values beginnen',
'string' => 'Muss eine Zeichenfolge sein.',
'timezone' => 'Muss eine gültige Zeitzone sein.',
'unique' => 'Dieser Wert wurde bereits vergeben.',
'uploaded' => 'Der Upload ist fehlgeschlagen.',
+ 'uppercase' => 'Muss in Grossbuchstaben geschrieben sein.',
'url' => 'Das Format ist ungültig.',
+ 'ulid' => 'Muss eine gültige ULID sein.',
+ 'uuid' => 'Muss eine gültige UUID sein.',
'unique_entry_value' => 'Dieser Wert wurde bereits vergeben.',
'unique_term_value' => 'Dieser Wert wurde bereits vergeben.',
'unique_user_value' => 'Dieser Wert wurde bereits vergeben.',
diff --git a/resources/lang/en/messages.php b/resources/lang/en/messages.php
index c967e26463..a33e865c0d 100644
--- a/resources/lang/en/messages.php
+++ b/resources/lang/en/messages.php
@@ -68,6 +68,9 @@
'collections_sort_direction_instructions' => 'The default sort direction.',
'collections_preview_target_refresh_instructions' => 'Automatically refresh the preview while editing. Disabling this will use postMessage.',
'collections_taxonomies_instructions' => 'Connect entries in this collection to taxonomies. Fields will be automatically added to publish forms.',
+ 'duplicate_action_warning_localization' => 'This entry is a localization. The origin entry will be duplicated.',
+ 'duplicate_action_warning_localizations' => 'One or more selected entries are localizations. In those cases, the origin entry will be duplicated instead.',
+ 'duplicate_action_localizations_confirmation' => 'Are you sure you want to run this action? Localizations will also be duplicated.',
'email_utility_configuration_description' => 'Mail settings are configured in :path
',
'email_utility_description' => 'Check email configuration settings and send test emails.',
'entry_origin_instructions' => 'The new localization will inherit values from the entry in the selected site.',
diff --git a/resources/lang/en/permissions.php b/resources/lang/en/permissions.php
index d3186c9a8c..cedf334b07 100644
--- a/resources/lang/en/permissions.php
+++ b/resources/lang/en/permissions.php
@@ -15,6 +15,9 @@
'manage_preferences' => 'Manage Preferences',
'manage_preferences_desc' => 'Ability to customize global and role-specific preferences.',
+ 'group_sites' => 'Sites',
+ 'access_{site}_site' => 'Access :site site',
+
'group_collections' => 'Collections',
'configure_collections' => 'Configure Collections',
'configure_collections_desc' => 'Grants access to all collection related permissions',
diff --git a/resources/lang/fr.json b/resources/lang/fr.json
index e0ffa18b54..d7bb6899a9 100644
--- a/resources/lang/fr.json
+++ b/resources/lang/fr.json
@@ -498,6 +498,7 @@
"Inline": "En ligne",
"Inline Code": "Code en ligne",
"Inline Label": "Etiquette en ligne",
+ "Inline Label when True": "Etiquette en ligne si vrai (True)",
"Input Behavior": "Comportement",
"Input Label": "Etiquette de saisie",
"Input Type": "Type de saisie",
@@ -660,6 +661,7 @@
"Per Page": "Par page",
"Per-site": "Par site",
"Permissions": "Permissions",
+ "Phone": "Téléphone",
"PHP Info": "Info PHP",
"Pick Color": "Choisir une couleur",
"Pin to Favorites": "Épingler aux Favoris",
diff --git a/resources/lang/fr/fieldtypes.php b/resources/lang/fr/fieldtypes.php
index 8ca1bcabcd..c57f12ec42 100644
--- a/resources/lang/fr/fieldtypes.php
+++ b/resources/lang/fr/fieldtypes.php
@@ -176,6 +176,7 @@
'time.config.seconds_enabled' => 'Affichez les secondes dans l’horodateur.',
'time.title' => 'Time',
'toggle.config.inline_label' => 'Définissez une étiquette à afficher en ligne à côté du commutateur.',
+ 'toggle.config.inline_label_when_true' => 'Définissez une étiquette à afficher en ligne quand la valeur du commutateur est vraie (True).',
'toggle.title' => 'Toggle',
'user_groups.title' => 'User Groups',
'user_roles.title' => 'User Roles',
diff --git a/resources/lang/fr/permissions.php b/resources/lang/fr/permissions.php
index 5ebe7cf2de..a994aa35cb 100644
--- a/resources/lang/fr/permissions.php
+++ b/resources/lang/fr/permissions.php
@@ -12,6 +12,8 @@
'configure_addons_desc' => 'Permet d’accéder à la section des addons pour en installer ou désinstaller.',
'manage_preferences' => 'Gérer les préférences',
'manage_preferences_desc' => 'Possibilité de personnaliser les préférences globales et celles spécifiques au rôle.',
+ 'group_sites' => 'Sites',
+ 'access_{site}_site' => 'Accéder au site :site',
'group_collections' => 'Collections',
'configure_collections' => 'Configurer les collections',
'configure_collections_desc' => 'Permet d’accéder à toutes les autorisations liées à la collection.',
diff --git a/resources/lang/fr/validation.php b/resources/lang/fr/validation.php
index 8f2c80c710..a973341399 100644
--- a/resources/lang/fr/validation.php
+++ b/resources/lang/fr/validation.php
@@ -2,6 +2,7 @@
return [
'accepted' => 'Doit être accepté.',
+ 'accepted_if' => 'Doit être accepté quand :other correspond à :value.',
'active_url' => 'Ce n’est pas une URL valide.',
'after' => 'Doit être une date strictement postérieure à :date.',
'after_or_equal' => 'Doit être une date postérieure ou égale à :date.',
@@ -9,35 +10,44 @@
'alpha_dash' => 'Peut uniquement contenir des lettres, des chiffres, des tirets et des traits de soulignement.',
'alpha_num' => 'Peut uniquement contenir des lettres et des chiffres.',
'array' => 'Doit être un tableau.',
+ 'ascii' => 'Peut uniquement contenir des symboles et caractères alphanumériques mono-bits.',
'before' => 'Doit être une date strictement antérieure à :date.',
'before_or_equal' => 'Doit être une date antérieure ou égale à :date.',
- 'between.numeric' => 'Doit être compris entre :min et :max .',
+ 'between.array' => 'Doit avoir entre :min et :max éléments.',
'between.file' => 'Doit être compris entre :min et :max kilo-octets.',
+ 'between.numeric' => 'Doit être compris entre :min et :max.',
'between.string' => 'Doit être compris entre :min et :max caractères.',
- 'between.array' => 'Doit avoir entre :min et :max éléments.',
'boolean' => 'Doit être vrai ou faux.',
+ 'can' => 'Contient une valeur interdite.',
'confirmed' => 'La confirmation ne correspond pas.',
'current_password' => 'Le mot de passe est incorrect.',
'date' => 'Date non valide.',
+ 'date_equals' => 'Doit être une date égale à :date.',
'date_format' => 'Ne correspond pas au format :format.',
+ 'decimal' => 'Doit avoir :decimal décimales.',
+ 'declined' => 'Doit être décliné.',
+ 'declined_if' => 'Doit être décliné quand :other correspond à :value.',
'different' => 'Ce champ et :other doivent être différents.',
'digits' => 'Doit faire :digits chiffres.',
'digits_between' => 'Doit être compris entre :min et :max chiffres.',
'dimensions' => 'Dimensions de l’image non valides.',
'distinct' => 'Ce champ a une valeur en double.',
+ 'doesnt_end_with' => 'Ne doit pas se terminer par l’une des valeurs suivantes : :values.',
+ 'doesnt_start_with' => 'Ne doit pas commencer par l’une des valeurs suivantes : :values.',
'email' => 'Doit être une adresse e-mail valide.',
'ends_with' => 'Doit se terminer par :values',
+ 'enum' => ':attribute sélectionné est invalide.',
'exists' => 'Ceci est invalide.',
'file' => 'Doit être un fichier.',
'filled' => 'Doit avoir une valeur.',
- 'gt.numeric' => 'Doit être supérieur à :value .',
- 'gt.file' => 'Doit être supérieur à :value kilo-octets.',
- 'gt.string' => 'Doit faire plus de :value caractères.',
'gt.array' => 'Doit avoir plus de :value éléments.',
+ 'gt.file' => 'Doit faire strictement plus de :value kilo-octets.',
+ 'gt.numeric' => 'Doit être supérieur à :value.',
+ 'gt.string' => 'Doit avoir plus de :value caractères.',
+ 'gte.array' => 'Doit avoir au moins :value éléments.',
+ 'gte.file' => 'Doit faire au moins :value kilo-octets.',
'gte.numeric' => 'Doit être supérieur ou égal à :value.',
- 'gte.file' => 'Doit être supérieur ou égal à :value kilo-octets.',
- 'gte.string' => 'Doit être supérieur ou égal à :value caractères.',
- 'gte.array' => 'Doit avoir :value éléments ou plus.',
+ 'gte.string' => 'Doit avoir au moins :value caractères.',
'image' => 'Doit être une image.',
'in' => 'Ceci est invalide.',
'in_array' => 'Ce champ n’existe pas dans :other.',
@@ -47,47 +57,65 @@
'ipv6' => 'Doit être une adresse IPv6 valide.',
'json' => 'Doit être une chaîne JSON valide.',
'lowercase' => 'Doit être en minuscules.',
+ 'lt.array' => 'Doit avoir strictement moins de :value éléments.',
+ 'lt.file' => 'Doit faire strictement moins de :value kilo-octets.',
'lt.numeric' => 'Doit être strictement inférieur à :value.',
- 'lt.file' => 'Doit être strictement inférieur à :value kilo-octets.',
- 'lt.string' => 'Doit être strictement inférieur à :value caractères.',
- 'lt.array' => 'Doit être strictement inférieur à :value éléments.',
- 'lte.numeric' => 'Doit être inférieur ou égal à :value.',
- 'lte.file' => 'Doit être inférieur ou égal à :value kilo-octets.',
- 'lte.string' => 'Doit être inférieur ou égal à :value caractères.',
+ 'lt.string' => 'Doit avoir strictement moins de :value caractères.',
'lte.array' => 'Ne doit pas avoir plus de :value éléments.',
- 'max.numeric' => 'Ne peut pas être supérieur à :max.',
- 'max.file' => 'Ne peut pas être supérieur à :max kilo-octets.',
- 'max.string' => 'Ne peut pas être supérieur à :max caractères.',
+ 'lte.file' => 'Ne doit pas faire plus de :value kilo-octets.',
+ 'lte.numeric' => 'Doit être inférieur ou égal à :value.',
+ 'lte.string' => 'Ne doit pas avoir plus de :value caractères.',
+ 'mac_address' => 'Doit être une adresse MAC valide.',
'max.array' => 'Ne peut pas avoir plus de :max éléments.',
+ 'max.file' => 'Ne peut pas être supérieur à :max kilo-octets.',
+ 'max.numeric' => 'Ne peut pas être supérieur à :max.',
+ 'max.string' => 'Ne peut pas avoir plus de :max caractères.',
+ 'max_digits' => 'Ne peut pas avoir plus de :max chiffres.',
'mimes' => 'Doit être un fichier de type :values.',
'mimetypes' => 'Doit être un fichier de type :values.',
- 'min.numeric' => 'Doit faire au moins :min.',
- 'min.file' => 'Doit faire au moins :min kilo-octets.',
- 'min.string' => 'Doit faire au moins :min caractères.',
'min.array' => 'Doit avoir au moins :min éléments.',
+ 'min.file' => 'Doit faire au moins :min kilo-octets.',
+ 'min.numeric' => 'Doit faire au moins :min.',
+ 'min.string' => 'Doit avoir au moins :min caractères.',
+ 'min_digits' => 'Doit faire au moins :min chiffres.',
+ 'missing' => 'Doit être absent.',
+ 'missing_if' => 'Doit être absent quand :other correspond à :value.',
+ 'missing_unless' => 'Doit être absent à moins que :other corresponde à :value.',
+ 'missing_with' => 'Doit être absent quand :values existe.',
+ 'missing_with_all' => 'Doit être absent quand :values existent.',
+ 'multiple_of' => 'Doit être un multiple de :value.',
'not_in' => 'Ceci est invalide.',
'not_regex' => 'Le format est invalide.',
'numeric' => 'Doit être un nombre.',
'present' => 'Doit être présent.',
+ 'prohibited' => 'Prohibé.',
+ 'prohibited_if' => 'Prohibé quand :other correspond à :value.',
+ 'prohibited_unless' => 'Prohibé à moins que :other soit dans :values.',
+ 'prohibits' => 'Interdit la présence de :other.',
'regex' => 'Le format est invalide.',
'required' => 'Ce champ est obligatoire.',
+ 'required_array_keys' => 'Doit contenir des entrées pour : :values.',
'required_if' => 'Ce champ est obligatoire lorsque :other est :value.',
+ 'required_if_accepted' => 'Ce champ est obligatoire quand :other est accepté.',
'required_unless' => 'Ce champ est obligatoire, sauf si :other est dans :values.',
- 'required_with' => 'Ce champ est requis lorsque :values est présent.',
- 'required_with_all' => 'Ce champ est requis lorsque :values est présent.',
+ 'required_with' => 'Ce champ est obligatoire lorsque :values est présent.',
+ 'required_with_all' => 'Ce champ est obligatoire lorsque :values sont présentes.',
'required_without' => 'Ce champ est obligatoire lorsque :values n’est pas présente.',
'required_without_all' => 'Ce champ est obligatoire lorsqu’aucune des :values n’est présente.',
'same' => 'Ce champ et :other doivent correspondre.',
- 'size.numeric' => 'Doit faire :size .',
+ 'size.array' => 'Doit contenir :size éléments.',
'size.file' => 'Doit faire :size kilo-octets.',
+ 'size.numeric' => 'Doit faire :size .',
'size.string' => 'Doit faire :size caractères.',
- 'size.array' => 'Doit contenir :size éléments.',
'starts_with' => 'Doit commencer par :values',
'string' => 'Doit être une chaîne.',
'timezone' => 'Doit être une zone valide.',
'unique' => 'Cette valeur a déjà été prise.',
'uploaded' => 'Échec du téléchargement.',
+ 'uppercase' => 'Doit être en majuscules.',
'url' => 'Le format est invalide.',
+ 'ulid' => 'Doit être une ULID valide.',
+ 'uuid' => 'Doit être un UUID valide.',
'unique_entry_value' => 'Cette valeur a déjà été prise.',
'unique_term_value' => 'Cette valeur a déjà été prise.',
'unique_user_value' => 'Cette valeur a déjà été prise.',
diff --git a/resources/lang/nl.json b/resources/lang/nl.json
index 9fd8d962ea..c879e36212 100644
--- a/resources/lang/nl.json
+++ b/resources/lang/nl.json
@@ -137,6 +137,7 @@
"Before": "Voor",
"Behavior": "Gedrag",
"Below": "Onder",
+ "Between": "Tussen",
"Blockquote": "Blockquote",
"Blueprint": "Blueprint",
"Blueprint created": "Blueprint aangemaakt",
@@ -497,6 +498,7 @@
"Inline": "Inline",
"Inline Code": "Inline code",
"Inline Label": "Inline label",
+ "Inline Label when True": "Inline label als waar",
"Input Behavior": "Invoer Gedrag",
"Input Label": "Inputlabel",
"Input Type": "Inputtype",
@@ -659,6 +661,7 @@
"Per Page": "Per pagina",
"Per-site": "Per site",
"Permissions": "Permissies",
+ "Phone": "Telefoon",
"PHP Info": "PHP-Info",
"Pick Color": "Kies een kleur",
"Pin to Favorites": "Vastpinnen aan favorieten",
diff --git a/resources/lang/nl/fieldtypes.php b/resources/lang/nl/fieldtypes.php
index 56471c5475..4bed4bdc2b 100644
--- a/resources/lang/nl/fieldtypes.php
+++ b/resources/lang/nl/fieldtypes.php
@@ -176,6 +176,7 @@
'time.config.seconds_enabled' => 'Toon seconden in de tijdpicker.',
'time.title' => 'Time',
'toggle.config.inline_label' => 'Een inline label die naast de toggle getoond wordt.',
+ 'toggle.config.inline_label_when_true' => 'Een inline label die naast de toggle getoond wordt als de waarde true is.',
'toggle.title' => 'Toggle',
'user_groups.title' => 'User Groups',
'user_roles.title' => 'User Roles',
diff --git a/resources/lang/nl/validation.php b/resources/lang/nl/validation.php
index a0944fe934..80d05e4594 100644
--- a/resources/lang/nl/validation.php
+++ b/resources/lang/nl/validation.php
@@ -2,6 +2,7 @@
return [
'accepted' => 'Moet worden geaccepteerd',
+ 'accepted_if' => 'Moet worden geaccepteerd als :other :value is.',
'active_url' => 'Dit is geen geldige URL.',
'after' => 'Moet een datum zijn na :date.',
'after_or_equal' => 'Moet een datum zijn na of gelijk aan :date.',
@@ -9,35 +10,50 @@
'alpha_dash' => 'Mag alleen letters, nummers, streepjes en lage streepjes bevatten.',
'alpha_num' => 'Mag alleen cijfers en letters bevatten.',
'array' => 'Moet een array zijn.',
+ 'ascii' => 'ASCII',
'before' => 'Moet een datum zijn voor :date.',
'before_or_equal' => 'Moet een datum zijn voor of gelijk aan :date.',
- 'between.numeric' => 'Moet tussen :min en :max zitten.',
- 'between.file' => 'Moet tussen :min en :max kilobytes zitten.',
- 'between.string' => 'Moet tussen de :min en :max aantal karakters zitten.',
- 'between.array' => 'Moet minimaal tussen de :min en :max items bevatten.',
+ 'between' => [
+ 'array' => 'Moet minimaal tussen de :min en :max items bevatten.',
+ 'file' => 'Moet tussen :min en :max kilobytes zitten.',
+ 'numeric' => 'Moet tussen :min en :max zitten.',
+ 'string' => 'Moet tussen de :min en :max aantal karakters zitten.',
+ ],
'boolean' => 'Moet true of false zijn.',
+ 'can' => 'Bevat een niet toegestane waarde.',
'confirmed' => 'De bevestiging komt niet overeen.',
'current_password' => 'Huidig wachtwoord is onjuist.',
'date' => 'Geen geldige datum.',
+ 'date_equals' => 'Moet een datum zijn gelijk aan :date.',
'date_format' => 'Voldoet niet aan het :format formaat.',
+ 'decimal' => 'Moet :decimal decimalen bevatten.',
+ 'declined' => 'Moet worden afgewezen.',
+ 'declined_if' => 'Moet worden afgewezen als :other :value is.',
'different' => 'Dit veld en :other moeten verschillend zijn.',
'digits' => 'Moet :digits cijfers zijn.',
'digits_between' => 'Moet tussen de :min en :max aantal cijfers zitten.',
'dimensions' => 'Geen geldige afbeeldingsafmetingen.',
'distinct' => 'Dit veld heeft een dubbele waarde.',
+ 'doesnt_end_with' => 'Mag niet eindigen met :values.',
+ 'doesnt_start_with' => 'Mag niet beginnen met :values.',
'email' => 'Moet een geldig e-mailadres zijn.',
'ends_with' => 'Moet eindigen op :values',
+ 'enum' => 'De geselecteerde :attribute is ongeldig.',
'exists' => 'Dit is niet geldig.',
'file' => 'Moet een bestand zijn.',
'filled' => 'Moet een waarde hebben.',
- 'gt.numeric' => 'Moet hoger zijn dan :value.',
- 'gt.file' => 'Moet groter zijn dan :value kilobytes.',
- 'gt.string' => 'Moet groter zijn dan :value karakters.',
- 'gt.array' => 'Moet meer dan :value items bevatten.',
- 'gte.numeric' => 'Moet groter zijn dan of gelijk aan :value.',
- 'gte.file' => 'Moet groter zijn dan of gelijk aan :value kilobytes.',
- 'gte.string' => 'Moet groter zijn dan of gelijk aan :value karakters.',
- 'gte.array' => 'Moet :value items of meer hebben.',
+ 'gt' => [
+ 'array' => 'Moet meer dan :value items bevatten.',
+ 'file' => 'Moet groter zijn dan :value kilobytes.',
+ 'numeric' => 'Moet hoger zijn dan :value.',
+ 'string' => 'Moet groter zijn dan :value karakters.',
+ ],
+ 'gt' => [
+ 'numeric' => 'Moet groter zijn dan of gelijk aan :value.',
+ 'file' => 'Moet groter zijn dan of gelijk aan :value kilobytes.',
+ 'string' => 'Moet groter zijn dan of gelijk aan :value karakters.',
+ 'array' => 'Moet :value items of meer hebben.',
+ ],
'image' => 'Moet een afbeelding zijn.',
'in' => 'Dit is ongeldig.',
'in_array' => 'Dit veld bestaat niet in :other.',
@@ -47,47 +63,75 @@
'ipv6' => 'Moet een geldig IPv6-adres zijn.',
'json' => 'Moet een geldige JSON-string zijn.',
'lowercase' => 'Moet in kleine letters zijn.',
- 'lt.numeric' => 'Moet kleiner zijn dan :value.',
- 'lt.file' => 'Moet kleiner zijn dan :value kilobytes.',
- 'lt.string' => 'Moet kleiner zijn dan :value karakters.',
- 'lt.array' => 'Moet minder dan :value items bevatten.',
- 'lte.numeric' => 'Moet kleiner zijn dan of gelijk aan :value.',
- 'lte.file' => 'Moet kleiner zijn dan of gelijk aan :value kilobytes.',
- 'lte.string' => 'Moet kleiner zijn dan of gelijk aan :value karakters.',
- 'lte.array' => 'Mag niet meer dan :items bevatten.',
- 'max.numeric' => 'Mag niet groter zijn dan :max.',
- 'max.file' => 'Mag niet groter zijn dan :max kilobytes.',
- 'max.string' => 'Mag niet langer zijn dan :max karakters.',
- 'max.array' => 'Mag niet meer dan :items bevatten.',
+ 'lt' => [
+ 'numeric' => 'Moet kleiner zijn dan :value.',
+ 'file' => 'Moet kleiner zijn dan :value kilobytes.',
+ 'string' => 'Moet kleiner zijn dan :value karakters.',
+ 'array' => 'Moet minder dan :value items bevatten.',
+ ],
+ 'lte' => [
+ 'numeric' => 'Moet kleiner zijn dan of gelijk aan :value.',
+ 'file' => 'Moet kleiner zijn dan of gelijk aan :value kilobytes.',
+ 'string' => 'Moet kleiner zijn dan of gelijk aan :value karakters.',
+ 'array' => 'Mag niet meer dan :items bevatten.',
+ ],
+ 'mac_address' => 'Moet een geldig MAC-adres zijn.',
+ 'max' => [
+ 'numeric' => 'Mag niet groter zijn dan :max.',
+ 'file' => 'Mag niet groter zijn dan :max kilobytes.',
+ 'string' => 'Mag niet langer zijn dan :max karakters.',
+ 'array' => 'Mag niet meer dan :items bevatten.',
+ ],
+ 'max_digits' => 'Mag niet meer dan :max cijfers bevatten.',
'mimes' => 'Moet een bestand zijn van het type: :values.',
'mimetypes' => 'Moet een bestand zijn van het type: values.',
- 'min.numeric' => 'Moet minimaal :min zijn.',
- 'min.file' => 'Moet minimaal :min kilobytes zijn.',
- 'min.string' => 'Moet minimaal :min karakters lang zijn.',
- 'min.array' => 'Moet minimaal :min items bevatten.',
+ 'min' => [
+ 'numeric' => 'Moet minimaal :min zijn.',
+ 'file' => 'Moet minimaal :min kilobytes zijn.',
+ 'string' => 'Moet minimaal :min karakters lang zijn.',
+ 'array' => 'Moet minimaal :min items bevatten.',
+ ],
+ 'min_digits' => 'Moet minimaal :min cijfers bevatten.',
+ 'missing' => 'Moet ontbreken.',
+ 'missing_if' => 'Moet ontbreken als :other :value is.',
+ 'missing_unless' => 'Moet ontbreken tenzij :other :value is.',
+ 'missing_with' => 'Moet ontbreken als :values aanwezig is.',
+ 'missing_with_all' => 'Moet ontbreken als :values aanwezig zijn.',
+ 'multiple_of' => 'Moet een veelvoud zijn van :value.',
'not_in' => 'Dit is ongeldig.',
'not_regex' => 'Formaat ongeldig.',
'numeric' => 'Moet een nummer zijn.',
'present' => 'Moet aanwezig zijn.',
+ 'prohibited' => 'Niet toegestaan.',
+ 'prohibited_if' => 'Niet toegestaan als :other :value is.',
+ 'prohibited_unless' => 'Niet toegestaan tenzij :other :value is.',
+ 'prohibits' => 'Voorkomt dat :other aanwezig is.',
'regex' => 'Formaat is ongeldig.',
'required' => 'Dit veld is verplicht.',
+ 'required_array_keys' => 'Vereist items voor: :values.',
'required_if' => 'Dit veld is verplicht als :other :value is.',
+ 'required_if_accepted' => 'Dit veld is verplicht als :other geaccepteerd is.',
'required_unless' => 'Dit veld is verplicht behalve als :other :value is.',
'required_with' => 'Dit veld is verplicht als :value aanwezig is.',
'required_with_all' => 'Dit veld is verplicht als :value aanwezig zijn.',
'required_without' => 'Dit veld is verplicht als :value niet aanwezig is.',
'required_without_all' => 'Dit veld is verplicht als geen van :values aanwezig zijn.',
'same' => 'Dit veld en :other komen niet overeen.',
- 'size.numeric' => 'Moet :size zijn.',
- 'size.file' => 'Moet :size kilobytes zijn.',
- 'size.string' => 'Moet :size karakters zijn.',
- 'size.array' => 'Moet :size items bevatten.',
+ 'size' => [
+ 'array' => 'Moet :size items bevatten.',
+ 'file' => 'Moet :size kilobytes zijn.',
+ 'numeric' => 'Moet :size zijn.',
+ 'string' => 'Moet :size karakters zijn.',
+ ],
'starts_with' => 'Moet beginnen met :values',
'string' => 'Moet een string zijn.',
'timezone' => 'Moet een geldige tijdzone zijn.',
'unique' => 'Deze waarde is al gekozen.',
'uploaded' => 'Het uploaden is mislukt.',
+ 'uppercase' => 'Moet in hoofdletters zijn.',
'url' => 'Formaat is ongeldig.',
+ 'ulid' => 'Moet een geldige ULID zijn.',
+ 'uuid' => 'Moet een geldige UUID zijn.',
'unique_entry_value' => 'Deze waarde is al gekozen.',
'unique_term_value' => 'Deze waarde is al gekozen.',
'unique_user_value' => 'Deze waarde is al gekozen.',
diff --git a/resources/views/partials/global-header.blade.php b/resources/views/partials/global-header.blade.php
index a4b288be5e..9795eb1a6e 100644
--- a/resources/views/partials/global-header.blade.php
+++ b/resources/views/partials/global-header.blade.php
@@ -22,7 +22,7 @@
- @if (Statamic\Facades\Site::hasMultiple())
+ @if (Statamic\Facades\Site::authorized()->count() > 1)
@cp_svg('icons/light/sites')
diff --git a/src/Actions/DuplicateEntry.php b/src/Actions/DuplicateEntry.php
index 388f513c74..0737468330 100644
--- a/src/Actions/DuplicateEntry.php
+++ b/src/Actions/DuplicateEntry.php
@@ -5,7 +5,7 @@
use Illuminate\Support\Str;
use Statamic\Contracts\Entries\Entry;
use Statamic\Facades\Entry as Entries;
-use Statamic\Facades\Site;
+use Statamic\Facades\User;
class DuplicateEntry extends Action
{
@@ -16,45 +16,83 @@ public static function title()
public function visibleTo($item)
{
- return $item instanceof Entry && ! Site::hasMultiple();
+ return $item instanceof Entry;
+ }
+
+ public function confirmationText()
+ {
+ $hasDescendants = $this->items
+ ->map(fn ($entry) => $entry->hasOrigin() ? $entry->root() : $entry)
+ ->unique()
+ ->contains(fn ($entry) => $entry->descendants()->count());
+
+ if ($hasDescendants) {
+ return 'duplicate_action_localizations_confirmation';
+ }
+
+ return parent::confirmationText();
+ }
+
+ public function warningText()
+ {
+ if ($this->items->contains(fn ($entry) => $entry->hasOrigin())) {
+ return $this->items->count() === 1
+ ? 'duplicate_action_warning_localization'
+ : 'duplicate_action_warning_localizations';
+ }
}
public function run($items, $values)
{
- $items->each(function (Entry $original) {
- $originalParent = $this->getEntryParentFromStructure($original);
- [$title, $slug] = $this->generateTitleAndSlug($original);
-
- $data = $original
- ->data()
- ->except($original->blueprint()->fields()->all()->reject->shouldBeDuplicated()->keys())
- ->merge(['title' => $title,
- 'duplicated_from' => $original->id(),
- ])->all();
-
- $entry = Entries::make()
- ->collection($original->collection())
- ->blueprint($original->blueprint()->handle())
- ->published(false)
- ->data($data);
-
- if ($original->collection()->requiresSlugs()) {
- $entry->slug($slug);
- }
+ $items
+ ->map(fn ($entry) => $entry->hasOrigin() ? $entry->root() : $entry)
+ ->unique()
+ ->each(fn ($original) => $this->duplicateEntry($original));
+ }
- if ($original->hasDate()) {
- $entry->date($original->date());
- }
+ private function duplicateEntry(Entry $original, string $origin = null)
+ {
+ $originalParent = $this->getEntryParentFromStructure($original);
+ [$title, $slug] = $this->generateTitleAndSlug($original);
+
+ $data = $original
+ ->data()
+ ->except($original->blueprint()->fields()->all()->reject->shouldBeDuplicated()->keys())
+ ->merge(['title' => $title,
+ 'duplicated_from' => $original->id(),
+ ])->all();
+
+ $entry = Entries::make()
+ ->locale($original->locale())
+ ->collection($original->collection())
+ ->blueprint($original->blueprint()->handle())
+ ->published(false)
+ ->data($data)
+ ->origin($origin);
+
+ if ($original->collection()->requiresSlugs()) {
+ $entry->slug($slug);
+ }
- $entry->save();
+ if ($original->hasDate()) {
+ $entry->date($original->date());
+ }
- if ($originalParent && $originalParent !== $original->id()) {
- $entry->structure()
- ->in($original->locale())
- ->appendTo($originalParent->id(), $entry)
- ->save();
- }
- });
+ $entry->save();
+
+ $original
+ ->directDescendants()
+ ->filter(fn ($entry) => User::current()->can('create', [Entry::class, $entry->collection(), $entry->site()]))
+ ->each(function ($descendant) use ($entry) {
+ $this->duplicateEntry($descendant, origin: $entry->id());
+ });
+
+ if ($originalParent && $originalParent !== $original->id()) {
+ $entry->structure()
+ ->in($original->locale())
+ ->appendTo($originalParent->id(), $entry)
+ ->save();
+ }
}
protected function getEntryParentFromStructure(Entry $entry)
@@ -82,7 +120,7 @@ protected function getEntryParentFromStructure(Entry $entry)
protected function generateTitleAndSlug(Entry $entry, $attempt = 1)
{
- $title = $entry->get('title');
+ $title = $entry->value('title');
$slug = $entry->slug();
$suffix = ' ('.__('Duplicated').')';
@@ -101,7 +139,7 @@ protected function generateTitleAndSlug(Entry $entry, $attempt = 1)
$slug .= '-'.$attempt;
// If the slug we've just built already exists, we'll try again, recursively.
- if ($entry->collection()->queryEntries()->where('slug', $slug)->count()) {
+ if ($entry->collection()->queryEntries()->where('locale', $entry->locale())->where('slug', $slug)->count()) {
[$title, $slug] = $this->generateTitleAndSlug($entry, $attempt + 1);
}
@@ -110,6 +148,6 @@ protected function generateTitleAndSlug(Entry $entry, $attempt = 1)
public function authorize($user, $item)
{
- return $user->can('create', [Entry::class, $item->collection()]);
+ return $user->can('create', [Entry::class, $item->collection(), $item->site()]);
}
}
diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php
index d20399873d..a3228f6157 100644
--- a/src/Assets/AssetContainer.php
+++ b/src/Assets/AssetContainer.php
@@ -20,6 +20,7 @@
use Statamic\Facades\Blueprint;
use Statamic\Facades\File;
use Statamic\Facades\Image;
+use Statamic\Facades\Pattern;
use Statamic\Facades\Search;
use Statamic\Facades\Stache;
use Statamic\Facades\URL;
@@ -357,7 +358,7 @@ public function assets($folder = '/', $recursive = false)
if ($folder !== null) {
if ($recursive) {
- $query->where('path', 'like', "{$folder}/%");
+ $query->where('path', 'like', Pattern::sqlLikeQuote($folder).'/%');
} else {
$query->where('folder', $folder);
}
diff --git a/src/Assets/AssetUploader.php b/src/Assets/AssetUploader.php
index 746705bce2..f13c2e4ac5 100644
--- a/src/Assets/AssetUploader.php
+++ b/src/Assets/AssetUploader.php
@@ -27,6 +27,11 @@ public static function asset(Asset $asset)
protected function uploadPath(UploadedFile $file)
{
$ext = $this->getNewExtension() ?? $file->getClientOriginalExtension();
+
+ if (config('statamic.assets.lowercase')) {
+ $ext = strtolower($ext);
+ }
+
$filename = self::getSafeFilename(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$directory = $this->asset->folder();
diff --git a/src/Auth/CorePermissions.php b/src/Auth/CorePermissions.php
index a4008dbd54..af37326194 100644
--- a/src/Auth/CorePermissions.php
+++ b/src/Auth/CorePermissions.php
@@ -8,6 +8,7 @@
use Statamic\Facades\GlobalSet;
use Statamic\Facades\Nav;
use Statamic\Facades\Permission;
+use Statamic\Facades\Site;
use Statamic\Facades\Taxonomy;
use Statamic\Facades\Utility;
@@ -23,6 +24,10 @@ public function boot()
$this->register('manage preferences');
});
+ $this->group('sites', function () {
+ $this->registerSites();
+ });
+
$this->group('collections', function () {
$this->registerCollections();
});
@@ -63,6 +68,21 @@ public function boot()
$this->register('view graphql');
}
+ protected function registerSites()
+ {
+ if (! Site::hasMultiple()) {
+ return;
+ }
+
+ $this->register('access {site} site', function ($permission) {
+ $permission->replacements('site', function () {
+ return Site::all()->map(function ($site) {
+ return ['value' => $site->handle(), 'label' => $site->name().' ('.$site->handle().')', 'handle' => $site->handle()];
+ });
+ });
+ });
+ }
+
protected function registerCollections()
{
$this->register('configure collections');
diff --git a/src/Auth/User.php b/src/Auth/User.php
index ccb5db6dab..572b2ecac6 100644
--- a/src/Auth/User.php
+++ b/src/Auth/User.php
@@ -233,6 +233,10 @@ public function generatePasswordResetToken()
{
$broker = config('statamic.users.passwords.'.PasswordReset::BROKER_RESETS);
+ if (is_array($broker)) {
+ $broker = $broker['cp'];
+ }
+
return Password::broker($broker)->createToken($this);
}
@@ -240,6 +244,10 @@ public function generateActivateAccountToken()
{
$broker = config('statamic.users.passwords.'.PasswordReset::BROKER_ACTIVATIONS);
+ if (is_array($broker)) {
+ $broker = $broker['cp'];
+ }
+
return Password::broker($broker)->createToken($this);
}
diff --git a/src/CP/Navigation/CoreNav.php b/src/CP/Navigation/CoreNav.php
index 0cfd637c44..383c4af073 100644
--- a/src/CP/Navigation/CoreNav.php
+++ b/src/CP/Navigation/CoreNav.php
@@ -18,7 +18,6 @@
use Statamic\Facades\Role as RoleAPI;
use Statamic\Facades\Stache;
use Statamic\Facades\Taxonomy as TaxonomyAPI;
-use Statamic\Facades\User;
use Statamic\Facades\UserGroup as UserGroupAPI;
use Statamic\Facades\Utility;
use Statamic\Statamic;
@@ -117,10 +116,6 @@ protected function makeContentSection()
return GlobalSetAPI::all()->sortBy->title()->map(function ($globalSet) {
$localized = $globalSet->inSelectedSite();
- if (! $localized && User::current()->cant('edit', $globalSet)) {
- return null;
- }
-
return Nav::item($globalSet->title())
->url($localized ? $localized->editUrl() : $globalSet->editUrl())
->can('view', $globalSet);
diff --git a/src/Console/Commands/Multisite.php b/src/Console/Commands/Multisite.php
index 9ffcb0a641..69b1414c42 100644
--- a/src/Console/Commands/Multisite.php
+++ b/src/Console/Commands/Multisite.php
@@ -10,6 +10,7 @@
use Statamic\Facades\File;
use Statamic\Facades\GlobalSet;
use Statamic\Facades\Nav;
+use Statamic\Facades\Role;
use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Facades\YAML;
@@ -80,6 +81,8 @@ public function handle()
$this->checkLine("Nav [{$nav->handle()}] updated.");
});
+ $this->addPermissions();
+
Cache::clear();
$this->checkLine('Cache cleared.');
@@ -278,4 +281,14 @@ protected function newSites()
{
return $this->sites->slice(1);
}
+
+ protected function addPermissions()
+ {
+ Role::all()->each(function ($role) {
+ Site::all()->each(fn ($site) => $role->addPermission("access {$site->handle()} site"));
+ $role->save();
+ $this->checkLine("Site permissions added to [{$role->handle()}] role.");
+ });
+
+ }
}
diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php
index 43b6a03655..c59e29c4bf 100644
--- a/src/Entries/Entry.php
+++ b/src/Entries/Entry.php
@@ -29,6 +29,7 @@
use Statamic\Events\EntryBlueprintFound;
use Statamic\Events\EntryCreated;
use Statamic\Events\EntryDeleted;
+use Statamic\Events\EntryDeleting;
use Statamic\Events\EntrySaved;
use Statamic\Events\EntrySaving;
use Statamic\Facades;
@@ -166,6 +167,10 @@ public function newAugmentedInstance(): Augmented
public function delete()
{
+ if (EntryDeleting::dispatch($this) === false) {
+ return false;
+ }
+
if ($this->descendants()->map->fresh()->filter()->isNotEmpty()) {
throw new \Exception('Cannot delete an entry with localizations.');
}
diff --git a/src/Events/EntryDeleting.php b/src/Events/EntryDeleting.php
new file mode 100644
index 0000000000..3b2e64ce8f
--- /dev/null
+++ b/src/Events/EntryDeleting.php
@@ -0,0 +1,23 @@
+entry = $entry;
+ }
+
+ /**
+ * Dispatch the event with the given arguments, and halt on first non-null listener response.
+ *
+ * @return mixed
+ */
+ public static function dispatch()
+ {
+ return event(new static(...func_get_args()), [], true);
+ }
+}
diff --git a/src/Facades/Endpoint/Pattern.php b/src/Facades/Endpoint/Pattern.php
index 19f571af8e..3697c67fb6 100644
--- a/src/Facades/Endpoint/Pattern.php
+++ b/src/Facades/Endpoint/Pattern.php
@@ -2,6 +2,8 @@
namespace Statamic\Facades\Endpoint;
+use Statamic\Support\Str;
+
/**
* Regular expressions, et al.
*/
@@ -104,4 +106,35 @@ public function isUUID($value)
{
return (bool) preg_match($this->uuid(), $value);
}
+
+ /**
+ * Quotes (escapes) SQL LIKE syntax.
+ *
+ * Similar to the preg_quote method for regular expressions.
+ */
+ public function sqlLikeQuote(string $like): string
+ {
+ return Str::of($like)
+ ->replace('%', '\%')
+ ->replace('_', '\_');
+ }
+
+ /**
+ * Converts SQL LIKE syntax to a regular expression.
+ *
+ * @return string The regular expression without delimiters.
+ */
+ public function sqlLikeToRegex(string $like): string
+ {
+ return Str::of($like)
+ ->replace('\_', $underscore = Str::random())
+ ->replace('\%', $percent = Str::random())
+ ->pipe(fn ($str) => preg_quote($str, '/'))
+ ->replace('%', '.*')
+ ->replace('_', '.')
+ ->replace($underscore, '_')
+ ->replace($percent, '%')
+ ->prepend('^')
+ ->append('$');
+ }
}
diff --git a/src/Facades/Site.php b/src/Facades/Site.php
index e0ecf44293..7d5c5b9c77 100644
--- a/src/Facades/Site.php
+++ b/src/Facades/Site.php
@@ -7,6 +7,7 @@
/**
* @method static mixed all()
+ * @method static mixed authorized()
* @method static mixed default()
* @method static bool hasMultiple()
* @method static mixed get($handle)
@@ -14,6 +15,7 @@
* @method static mixed current()
* @method static void setCurrent($site)
* @method static mixed selected()
+ * @method static void setSelected($site)
* @method static void setConfig($key, $value = null)
*
* @see \Statamic\Sites\Sites
diff --git a/src/Fields/Blueprint.php b/src/Fields/Blueprint.php
index 307cee257f..66272c2a7a 100644
--- a/src/Fields/Blueprint.php
+++ b/src/Fields/Blueprint.php
@@ -39,6 +39,7 @@ class Blueprint implements Arrayable, ArrayAccess, Augmentable, QueryableValue
protected $ensuredFields = [];
protected $afterSaveCallbacks = [];
protected $withEvents = true;
+ private ?Columns $columns = null;
public function setHandle(string $handle)
{
@@ -359,6 +360,10 @@ public function field($field)
public function columns()
{
+ if ($this->columns) {
+ return $this->columns;
+ }
+
$columns = $this->fields()
->all()
->values()
@@ -375,7 +380,7 @@ public function columns()
})
->keyBy('field');
- return new Columns($columns);
+ return $this->columns = new Columns($columns);
}
public function isEmpty(): bool
diff --git a/src/Fields/Field.php b/src/Fields/Field.php
index f2b82e0ff8..bf9bee6e5d 100644
--- a/src/Fields/Field.php
+++ b/src/Fields/Field.php
@@ -7,6 +7,7 @@
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Facades\Lang;
use Rebing\GraphQL\Support\Field as GqlField;
+use Statamic\Contracts\Forms\Form;
use Statamic\Facades\GraphQL;
use Statamic\Support\Arr;
use Statamic\Support\Str;
@@ -20,6 +21,7 @@ class Field implements Arrayable
protected $parent;
protected $parentField;
protected $validationContext;
+ protected ?Form $form = null;
public function __construct($handle, array $config)
{
@@ -409,4 +411,16 @@ public function isRelationship(): bool
{
return $this->fieldtype()->isRelationship();
}
+
+ public function setForm(Form $form)
+ {
+ $this->form = $form;
+
+ return $this;
+ }
+
+ public function form(): ?Form
+ {
+ return $this->form;
+ }
}
diff --git a/src/Fieldtypes/Bard/LinkMark.php b/src/Fieldtypes/Bard/LinkMark.php
index 96aafeaf4d..91f21ec4d2 100644
--- a/src/Fieldtypes/Bard/LinkMark.php
+++ b/src/Fieldtypes/Bard/LinkMark.php
@@ -43,6 +43,13 @@ public function addAttributes()
},
],
'title' => [],
+ 'rel' => [
+ 'renderHTML' => function ($attributes) {
+ return [
+ 'rel' => $attributes->rel ?? '',
+ ];
+ },
+ ],
];
}
diff --git a/src/Fieldtypes/Icon.php b/src/Fieldtypes/Icon.php
index 362bf7bb47..33ab650204 100644
--- a/src/Fieldtypes/Icon.php
+++ b/src/Fieldtypes/Icon.php
@@ -9,6 +9,8 @@
class Icon extends Fieldtype
{
+ public const DEFAULT_FOLDER = 'regular';
+
protected $categories = ['media'];
protected $icon = 'icon_picker';
@@ -71,7 +73,7 @@ private function resolveParts()
$folder = $this->config(
'folder',
- $hasConfiguredDirectory ? null : 'regular' // Only apply a default folder if using Statamic icons.
+ $hasConfiguredDirectory ? null : self::DEFAULT_FOLDER // Only apply a default folder if using Statamic icons.
);
$path = Path::tidy($directory.'/'.$folder);
diff --git a/src/Forms/Tags.php b/src/Forms/Tags.php
index dc876d4011..1800713d82 100644
--- a/src/Forms/Tags.php
+++ b/src/Forms/Tags.php
@@ -64,7 +64,9 @@ public function create()
$jsDriver = $this->parseJsParamDriverAndOptions($this->params->get('js'), $form);
$data['sections'] = $this->getSections($this->sessionHandle(), $jsDriver);
- $data['fields'] = $this->getFields($this->sessionHandle(), $jsDriver);
+
+ $data['fields'] = collect($data['sections'])->flatMap->fields->all();
+
$data['honeypot'] = $form->honeypot();
if ($jsDriver) {
@@ -240,7 +242,10 @@ protected function getSections($sessionHandle, $jsDriver)
*/
protected function getFields($sessionHandle, $jsDriver, $fields = null)
{
- return collect($fields ?? $this->form()->fields())
+ $form = $this->form();
+
+ return collect($fields ?? $form->fields())
+ ->each(fn ($field) => $field->setForm($form))
->map(function ($field) use ($sessionHandle, $jsDriver) {
return $this->getRenderableField($field, $sessionHandle, function ($data, $field) use ($jsDriver) {
return $jsDriver
diff --git a/src/Globals/GlobalSet.php b/src/Globals/GlobalSet.php
index b9e9cdeb6f..f22a55eae1 100644
--- a/src/Globals/GlobalSet.php
+++ b/src/Globals/GlobalSet.php
@@ -170,6 +170,11 @@ public function removeLocalization($localization)
return $this;
}
+ public function sites()
+ {
+ return $this->localizations()->map->locale()->values()->toBase();
+ }
+
public function in($locale)
{
return $this->localizations()->get($locale);
diff --git a/src/Http/Controllers/CP/Collections/CollectionsController.php b/src/Http/Controllers/CP/Collections/CollectionsController.php
index 744319a9a4..34dd7591e0 100644
--- a/src/Http/Controllers/CP/Collections/CollectionsController.php
+++ b/src/Http/Controllers/CP/Collections/CollectionsController.php
@@ -93,18 +93,7 @@ public function show(Request $request, $collection)
'collection' => $collection->handle(),
'blueprints' => $blueprints->pluck('handle')->all(),
]),
- 'sites' => $collection->sites()->map(function ($site_handle) {
- $site = Site::get($site_handle);
-
- if (! $site) {
- throw new SiteNotFoundException($site_handle);
- }
-
- return [
- 'handle' => $site->handle(),
- 'name' => $site->name(),
- ];
- })->values()->all(),
+ 'sites' => $this->getAuthorizedSitesForCollection($collection),
'createUrls' => $collection->sites()
->mapWithKeys(fn ($site) => [$site => cp_route('collections.entries.create', [$collection->handle(), $site])])
->all(),
@@ -210,7 +199,7 @@ public function store(Request $request)
->futureDateBehavior('private');
if (Site::hasMultiple()) {
- $collection->sites([Site::default()->handle()]);
+ $collection->sites([Site::selected()->handle()]);
}
$collection->save();
@@ -560,4 +549,21 @@ protected function editFormBlueprint($collection)
return Blueprint::makeFromTabs($fields);
}
+
+ protected function getAuthorizedSitesForCollection($collection)
+ {
+ return $collection
+ ->sites()
+ ->mapWithKeys(fn ($handle) => [$handle => Site::get($handle)])
+ ->each(fn ($site, $handle) => throw_unless($site, new SiteNotFoundException($handle)))
+ ->filter(fn ($site) => User::current()->can('view', $site))
+ ->map(function ($site) {
+ return [
+ 'handle' => $site->handle(),
+ 'name' => $site->name(),
+ ];
+ })
+ ->values()
+ ->all();
+ }
}
diff --git a/src/Http/Controllers/CP/Collections/EntriesController.php b/src/Http/Controllers/CP/Collections/EntriesController.php
index 96f1101bac..1a2057f65d 100644
--- a/src/Http/Controllers/CP/Collections/EntriesController.php
+++ b/src/Http/Controllers/CP/Collections/EntriesController.php
@@ -72,6 +72,10 @@ protected function indexQuery($collection)
$query->where('title', 'like', '%'.$search.'%');
}
+ if (Site::hasMultiple()) {
+ $query->whereIn('site', Site::authorized()->map->handle()->all());
+ }
+
return $query;
}
@@ -126,7 +130,7 @@ public function edit(Request $request, $collection, $entry)
'originValues' => $originValues ?? null,
'originMeta' => $originMeta ?? null,
'permalink' => $entry->absoluteUrl(),
- 'localizations' => $collection->sites()->map(function ($handle) use ($entry) {
+ 'localizations' => $this->getAuthorizedSitesForCollection($collection)->map(function ($handle) use ($entry) {
$localized = $entry->in($handle);
$exists = $localized !== null;
@@ -142,7 +146,7 @@ public function edit(Request $request, $collection, $entry)
'url' => $exists ? $localized->editUrl() : null,
'livePreviewUrl' => $exists ? $localized->livePreviewUrl() : null,
];
- })->all(),
+ })->values()->all(),
'hasWorkingCopy' => $entry->hasWorkingCopy(),
'preloadedAssets' => $this->extractAssetsFromValues($values),
'revisionsEnabled' => $entry->revisionsEnabled(),
@@ -261,7 +265,7 @@ public function update(Request $request, $collection, $entry)
public function create(Request $request, $collection, $site)
{
- $this->authorize('create', [EntryContract::class, $collection]);
+ $this->authorize('create', [EntryContract::class, $collection, $site]);
$blueprint = $collection->entryBlueprint($request->blueprint);
@@ -303,7 +307,7 @@ public function create(Request $request, $collection, $site)
'blueprint' => $blueprint->toPublishArray(),
'published' => $collection->defaultPublishState(),
'locale' => $site->handle(),
- 'localizations' => $collection->sites()->map(function ($handle) use ($collection, $site, $blueprint) {
+ 'localizations' => $this->getAuthorizedSitesForCollection($collection)->map(function ($handle) use ($collection, $site, $blueprint) {
return [
'handle' => $handle,
'name' => Site::get($handle)->name(),
@@ -313,7 +317,7 @@ public function create(Request $request, $collection, $site)
'url' => cp_route('collections.entries.create', [$collection->handle(), $handle, 'blueprint' => $blueprint->handle()]),
'livePreviewUrl' => $collection->route($handle) ? cp_route('collections.entries.preview.create', [$collection->handle(), $handle]) : null,
];
- })->all(),
+ })->values()->all(),
'revisionsEnabled' => $collection->revisionsEnabled(),
'breadcrumbs' => $this->breadcrumbs($collection),
'canManagePublishState' => User::current()->can('publish '.$collection->handle().' entries'),
@@ -553,4 +557,11 @@ protected function breadcrumbs($collection)
],
]);
}
+
+ protected function getAuthorizedSitesForCollection($collection)
+ {
+ return $collection
+ ->sites()
+ ->filter(fn ($handle) => User::current()->can('view', Site::get($handle)));
+ }
}
diff --git a/src/Http/Controllers/CP/Globals/GlobalVariablesController.php b/src/Http/Controllers/CP/Globals/GlobalVariablesController.php
index 66adf29424..b3925432a8 100644
--- a/src/Http/Controllers/CP/Globals/GlobalVariablesController.php
+++ b/src/Http/Controllers/CP/Globals/GlobalVariablesController.php
@@ -50,7 +50,7 @@ public function edit(Request $request, $id)
'hasOrigin' => $hasOrigin,
'originValues' => $originValues ?? null,
'originMeta' => $originMeta ?? null,
- 'localizations' => $variables->globalSet()->localizations()->map(function ($localized) use ($variables) {
+ 'localizations' => $this->getAuthorizedLocalizationsForVariables($variables)->map(function ($localized) use ($variables) {
return [
'handle' => $localized->locale(),
'name' => $localized->site()->name(),
@@ -118,4 +118,12 @@ protected function extractFromFields($set, $blueprint)
return [$fields->values()->all(), $fields->meta()->all()];
}
+
+ protected function getAuthorizedLocalizationsForVariables($variables)
+ {
+ return $variables
+ ->globalSet()
+ ->localizations()
+ ->filter(fn ($set) => User::current()->can('edit', $set));
+ }
}
diff --git a/src/Http/Controllers/CP/Navigation/NavigationController.php b/src/Http/Controllers/CP/Navigation/NavigationController.php
index 4a92c1c3ea..4acb58ad74 100644
--- a/src/Http/Controllers/CP/Navigation/NavigationController.php
+++ b/src/Http/Controllers/CP/Navigation/NavigationController.php
@@ -65,20 +65,20 @@ public function show(Request $request, $nav)
{
abort_if(! $nav = Nav::find($nav), 404);
- $this->authorize('view', $nav, __('You are not authorized to view navs.'));
-
$site = $request->site ?? Site::selected()->handle();
if (! $nav->existsIn($site)) {
return redirect($nav->trees()->first()->showUrl());
}
+ $this->authorize('view', $nav->in($site), __('You are not authorized to view navs.'));
+
return view('statamic::navigation.show', [
'site' => $site,
'nav' => $nav,
'expectsRoot' => $nav->expectsRoot(),
'collections' => $nav->collections()->map->handle()->all(),
- 'sites' => $nav->trees()->map(function ($tree) {
+ 'sites' => $this->getAuthorizedTreesForNav($nav)->map(function ($tree) {
return [
'handle' => $tree->locale(),
'name' => $tree->site()->name(),
@@ -89,6 +89,13 @@ public function show(Request $request, $nav)
]);
}
+ private function getAuthorizedTreesForNav($nav)
+ {
+ return $nav
+ ->trees()
+ ->filter(fn ($tree) => User::current()->can('view', Site::get($tree->locale())));
+ }
+
public function update(Request $request, $nav)
{
$nav = Nav::find($nav);
diff --git a/src/Http/Controllers/CP/Navigation/NavigationTreeController.php b/src/Http/Controllers/CP/Navigation/NavigationTreeController.php
index beecc0f3e4..7cecc54201 100644
--- a/src/Http/Controllers/CP/Navigation/NavigationTreeController.php
+++ b/src/Http/Controllers/CP/Navigation/NavigationTreeController.php
@@ -39,6 +39,8 @@ public function update(Request $request, $nav)
$tree = $nav->in($request->site);
+ $this->authorize('edit', $tree);
+
$this->data = $this->flattenExistingBranchData([], $tree->tree());
$blueprint = $nav->blueprint()
diff --git a/src/Http/Controllers/CP/SelectSiteController.php b/src/Http/Controllers/CP/SelectSiteController.php
index 06235f60db..59b3277dd7 100644
--- a/src/Http/Controllers/CP/SelectSiteController.php
+++ b/src/Http/Controllers/CP/SelectSiteController.php
@@ -2,11 +2,19 @@
namespace Statamic\Http\Controllers\CP;
+use Statamic\Facades\Site;
+
class SelectSiteController extends CpController
{
public function select($handle)
{
- session()->put('statamic.cp.selected-site', $handle);
+ if (! $site = Site::get($handle)) {
+ return back()->withError('Invalid site.');
+ }
+
+ $this->authorize('view', $site);
+
+ Site::setSelected($handle);
return back()->with('success', __('Site selected.'));
}
diff --git a/src/Http/Controllers/CP/Taxonomies/TermsController.php b/src/Http/Controllers/CP/Taxonomies/TermsController.php
index f5268243e8..fd11217810 100644
--- a/src/Http/Controllers/CP/Taxonomies/TermsController.php
+++ b/src/Http/Controllers/CP/Taxonomies/TermsController.php
@@ -119,7 +119,7 @@ public function edit(Request $request, $taxonomy, $term)
'originValues' => $originValues ?? null,
'originMeta' => $originMeta ?? null,
'permalink' => $term->absoluteUrl(),
- 'localizations' => $taxonomy->sites()->map(function ($handle) use ($term) {
+ 'localizations' => $this->getAuthorizedSitesForTaxonomy($taxonomy)->map(function ($handle) use ($term) {
$localized = $term->in($handle);
return [
@@ -203,7 +203,7 @@ public function update(Request $request, $taxonomy, $term, $site)
public function create(Request $request, $taxonomy, $site)
{
- $this->authorize('create', [TermContract::class, $taxonomy]);
+ $this->authorize('create', [TermContract::class, $taxonomy, $site]);
$blueprint = $taxonomy->termBlueprint($request->blueprint);
@@ -232,7 +232,7 @@ public function create(Request $request, $taxonomy, $site)
'blueprint' => $blueprint->toPublishArray(),
'published' => $taxonomy->defaultPublishState(),
'locale' => $site->handle(),
- 'localizations' => $taxonomy->sites()->map(function ($handle) use ($taxonomy, $site) {
+ 'localizations' => $this->getAuthorizedSitesForTaxonomy($taxonomy)->map(function ($handle) use ($taxonomy, $site) {
return [
'handle' => $handle,
'name' => Site::get($handle)->name(),
@@ -242,7 +242,7 @@ public function create(Request $request, $taxonomy, $site)
'url' => cp_route('taxonomies.terms.create', [$taxonomy->handle(), $handle]),
'livePreviewUrl' => cp_route('taxonomies.terms.preview.create', [$taxonomy->handle(), $handle]),
];
- })->all(),
+ })->values()->all(),
'breadcrumbs' => $this->breadcrumbs($taxonomy),
'previewTargets' => $taxonomy->previewTargets()->all(),
];
@@ -359,4 +359,11 @@ protected function breadcrumbs($taxonomy)
],
]);
}
+
+ protected function getAuthorizedSitesForTaxonomy($taxonomy)
+ {
+ return $taxonomy
+ ->sites()
+ ->filter(fn ($handle) => User::current()->can('view', Site::get($handle)));
+ }
}
diff --git a/src/Http/Middleware/CP/SelectedSite.php b/src/Http/Middleware/CP/SelectedSite.php
new file mode 100644
index 0000000000..bbb9e833e9
--- /dev/null
+++ b/src/Http/Middleware/CP/SelectedSite.php
@@ -0,0 +1,28 @@
+updateSelectedSite();
+
+ return $next($request);
+ }
+
+ private function updateSelectedSite()
+ {
+ if (User::current()->can('view', Site::selected())) {
+ return;
+ }
+
+ if ($first = Site::authorized()->first()) {
+ Site::setSelected($first->handle());
+ }
+ }
+}
diff --git a/src/Http/View/Composers/JavascriptComposer.php b/src/Http/View/Composers/JavascriptComposer.php
index e3e44f790c..6239bc7376 100644
--- a/src/Http/View/Composers/JavascriptComposer.php
+++ b/src/Http/View/Composers/JavascriptComposer.php
@@ -72,7 +72,7 @@ private function protectedVariables()
protected function sites()
{
- return Site::all()->map(function ($site) {
+ return Site::authorized()->map(function ($site) {
return [
'name' => $site->name(),
'handle' => $site->handle(),
diff --git a/src/Policies/CollectionPolicy.php b/src/Policies/CollectionPolicy.php
index 1c6798be73..d17a9682ce 100644
--- a/src/Policies/CollectionPolicy.php
+++ b/src/Policies/CollectionPolicy.php
@@ -7,7 +7,9 @@
class CollectionPolicy
{
- public function before($user, $ability)
+ use Concerns\HasMultisitePolicy;
+
+ public function before($user)
{
$user = User::fromUser($user);
@@ -43,7 +45,8 @@ public function view($user, $collection)
{
$user = User::fromUser($user);
- return $user->hasPermission("view {$collection->handle()} entries");
+ return $user->hasPermission("view {$collection->handle()} entries")
+ && $this->userCanAccessAnySite($user, $collection->sites());
}
public function edit($user, $collection)
diff --git a/src/Policies/Concerns/HasMultisitePolicy.php b/src/Policies/Concerns/HasMultisitePolicy.php
new file mode 100644
index 0000000000..e6092241fb
--- /dev/null
+++ b/src/Policies/Concerns/HasMultisitePolicy.php
@@ -0,0 +1,26 @@
+can('view', $site);
+ }
+
+ protected function userCanAccessAnySite($user, $sites)
+ {
+ if (! Sites::hasMultiple()) {
+ return true;
+ }
+
+ return $sites
+ ->map(fn ($site) => Sites::get($site))
+ ->filter(fn ($site) => $user->can('view', $site))
+ ->isNotEmpty();
+ }
+}
diff --git a/src/Policies/EntryPolicy.php b/src/Policies/EntryPolicy.php
index e381bc51e0..ba6f7ab9e5 100644
--- a/src/Policies/EntryPolicy.php
+++ b/src/Policies/EntryPolicy.php
@@ -6,7 +6,9 @@
class EntryPolicy
{
- public function before($user, $ability)
+ use Concerns\HasMultisitePolicy;
+
+ public function before($user)
{
$user = User::fromUser($user);
@@ -24,6 +26,10 @@ public function view($user, $entry)
{
$user = User::fromUser($user);
+ if (! $this->userCanAccessSite($user, $entry->site())) {
+ return false;
+ }
+
return $this->edit($user, $entry)
|| $user->hasPermission("view {$entry->collectionHandle()} entries");
}
@@ -32,6 +38,10 @@ public function edit($user, $entry)
{
$user = User::fromUser($user);
+ if (! $this->userCanAccessSite($user, $entry->site())) {
+ return false;
+ }
+
if ($this->hasAnotherAuthor($user, $entry)) {
return $user->hasPermission("edit other authors {$entry->collectionHandle()} entries");
}
@@ -57,16 +67,20 @@ public function update($user, $entry)
return $this->edit($user, $entry);
}
- public function create($user, $collection)
+ public function create($user, $collection, $site = null)
{
$user = User::fromUser($user);
+ if ($site && (! $collection->sites()->contains($site->handle()) || ! $this->userCanAccessSite($user, $site))) {
+ return false;
+ }
+
return $user->hasPermission("create {$collection->handle()} entries");
}
- public function store($user, $collection)
+ public function store($user, $collection, $site = null)
{
- return $this->create($user, $collection);
+ return $this->create($user, $collection, $site);
}
public function delete($user, $entry)
diff --git a/src/Policies/GlobalSetPolicy.php b/src/Policies/GlobalSetPolicy.php
index bd66e63536..6f48eb36b4 100644
--- a/src/Policies/GlobalSetPolicy.php
+++ b/src/Policies/GlobalSetPolicy.php
@@ -7,7 +7,9 @@
class GlobalSetPolicy
{
- public function before($user, $ability)
+ use Concerns\HasMultisitePolicy;
+
+ public function before($user)
{
$user = User::fromUser($user);
@@ -38,6 +40,10 @@ public function view($user, $set)
{
$user = User::fromUser($user);
+ if (! $this->userCanAccessAnySite($user, $set->sites())) {
+ return false;
+ }
+
return $user->hasPermission("edit {$set->handle()} globals");
}
diff --git a/src/Policies/GlobalSetVariablesPolicy.php b/src/Policies/GlobalSetVariablesPolicy.php
index 45594274f6..785a408984 100644
--- a/src/Policies/GlobalSetVariablesPolicy.php
+++ b/src/Policies/GlobalSetVariablesPolicy.php
@@ -2,10 +2,25 @@
namespace Statamic\Policies;
+use Statamic\Facades\User;
+
class GlobalSetVariablesPolicy extends GlobalSetPolicy
{
- public function edit($user, $set)
+ use Concerns\HasMultisitePolicy;
+
+ public function view($user, $variables)
+ {
+ $user = User::fromUser($user);
+
+ if (! $this->userCanAccessSite($user, $variables->site())) {
+ return false;
+ }
+
+ return $user->hasPermission("edit {$variables->handle()} globals");
+ }
+
+ public function edit($user, $variables)
{
- return $this->view($user, $set);
+ return $this->view($user, $variables);
}
}
diff --git a/src/Policies/LocalizedTermPolicy.php b/src/Policies/LocalizedTermPolicy.php
new file mode 100644
index 0000000000..250b27b76c
--- /dev/null
+++ b/src/Policies/LocalizedTermPolicy.php
@@ -0,0 +1,19 @@
+userCanAccessSite($user, $term->site())) {
+ return false;
+ }
+
+ return $user->hasPermission("edit {$term->taxonomyHandle()} terms");
+ }
+}
diff --git a/src/Policies/NavPolicy.php b/src/Policies/NavPolicy.php
index 0481c01741..8614dc0db4 100644
--- a/src/Policies/NavPolicy.php
+++ b/src/Policies/NavPolicy.php
@@ -7,7 +7,9 @@
class NavPolicy
{
- public function before($user, $ability)
+ use Concerns\HasMultisitePolicy;
+
+ public function before($user)
{
$user = User::fromUser($user);
@@ -43,13 +45,22 @@ public function view($user, $nav)
{
$user = User::fromUser($user);
- return $user->hasPermission("view {$nav->handle()} nav");
+ if (! $this->userCanAccessAnySite($user, $nav->sites())) {
+ return false;
+ }
+
+ return $this->edit($user, $nav)
+ || $user->hasPermission("view {$nav->handle()} nav");
}
public function edit($user, $nav)
{
$user = User::fromUser($user);
+ if (! $this->userCanAccessAnySite($user, $nav->sites())) {
+ return false;
+ }
+
return $user->hasPermission("edit {$nav->handle()} nav");
}
diff --git a/src/Policies/NavTreePolicy.php b/src/Policies/NavTreePolicy.php
new file mode 100644
index 0000000000..2caa568e0a
--- /dev/null
+++ b/src/Policies/NavTreePolicy.php
@@ -0,0 +1,32 @@
+userCanAccessSite($user, $nav->site())) {
+ return false;
+ }
+
+ return $user->hasPermission("view {$nav->handle()} nav");
+ }
+
+ public function edit($user, $nav)
+ {
+ $user = User::fromUser($user);
+
+ if (! $this->userCanAccessSite($user, $nav->site())) {
+ return false;
+ }
+
+ return $user->hasPermission("edit {$nav->handle()} nav");
+ }
+}
diff --git a/src/Policies/SitePolicy.php b/src/Policies/SitePolicy.php
new file mode 100644
index 0000000000..4870432466
--- /dev/null
+++ b/src/Policies/SitePolicy.php
@@ -0,0 +1,18 @@
+hasPermission("access {$site->handle()} site");
+ }
+}
diff --git a/src/Policies/TaxonomyPolicy.php b/src/Policies/TaxonomyPolicy.php
index d378418899..ed91bd4c8b 100644
--- a/src/Policies/TaxonomyPolicy.php
+++ b/src/Policies/TaxonomyPolicy.php
@@ -7,7 +7,9 @@
class TaxonomyPolicy
{
- public function before($user, $ability)
+ use Concerns\HasMultisitePolicy;
+
+ public function before($user)
{
$user = User::fromUser($user);
@@ -43,7 +45,8 @@ public function view($user, $taxonomy)
{
$user = User::fromUser($user);
- return $user->hasPermission("view {$taxonomy->handle()} terms");
+ return $user->hasPermission("view {$taxonomy->handle()} terms")
+ && $this->userCanAccessAnySite($user, $taxonomy->sites());
}
public function edit($user, $taxonomy)
diff --git a/src/Policies/TermPolicy.php b/src/Policies/TermPolicy.php
index 5f84f7d782..b823b87743 100644
--- a/src/Policies/TermPolicy.php
+++ b/src/Policies/TermPolicy.php
@@ -6,7 +6,9 @@
class TermPolicy
{
- public function before($user, $ability)
+ use Concerns\HasMultisitePolicy;
+
+ public function before($user)
{
$user = User::fromUser($user);
@@ -42,16 +44,20 @@ public function update($user, $term)
return $this->edit($user, $term);
}
- public function create($user, $taxonomy)
+ public function create($user, $taxonomy, $site = null)
{
$user = User::fromUser($user);
+ if ($site && (! $taxonomy->sites()->contains($site->handle()) || ! $this->userCanAccessSite($user, $site))) {
+ return false;
+ }
+
return $user->hasPermission("create {$taxonomy->handle()} terms");
}
- public function store($user, $taxonomy)
+ public function store($user, $taxonomy, $site = null)
{
- return $this->create($user, $taxonomy);
+ return $this->create($user, $taxonomy, $site);
}
public function delete($user, $term)
diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php
index 1a8f3132d2..3aba6e4eee 100755
--- a/src/Providers/AuthServiceProvider.php
+++ b/src/Providers/AuthServiceProvider.php
@@ -21,6 +21,7 @@ class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
\Statamic\Contracts\Structures\Nav::class => Policies\NavPolicy::class,
+ \Statamic\Contracts\Structures\NavTree::class => Policies\NavTreePolicy::class,
\Statamic\Contracts\Entries\Collection::class => Policies\CollectionPolicy::class,
\Statamic\Contracts\Entries\Entry::class => Policies\EntryPolicy::class,
\Statamic\Contracts\Taxonomies\Taxonomy::class => Policies\TaxonomyPolicy::class,
@@ -34,6 +35,7 @@ class AuthServiceProvider extends ServiceProvider
\Statamic\Contracts\Assets\AssetFolder::class => Policies\AssetFolderPolicy::class,
\Statamic\Contracts\Assets\AssetContainer::class => Policies\AssetContainerPolicy::class,
\Statamic\Fields\Fieldset::class => Policies\FieldsetPolicy::class,
+ \Statamic\Sites\Site::class => Policies\SitePolicy::class,
];
public function register()
diff --git a/src/Providers/CpServiceProvider.php b/src/Providers/CpServiceProvider.php
index b17afe4bfd..f526f9e738 100644
--- a/src/Providers/CpServiceProvider.php
+++ b/src/Providers/CpServiceProvider.php
@@ -80,6 +80,7 @@ protected function registerMiddlewareGroups()
$router->middlewareGroup('statamic.cp.authenticated', [
\Statamic\Http\Middleware\CP\Authorize::class,
\Statamic\Http\Middleware\CP\Localize::class,
+ \Statamic\Http\Middleware\CP\SelectedSite::class,
\Statamic\Http\Middleware\CP\BootPermissions::class,
\Statamic\Http\Middleware\CP\BootPreferences::class,
\Statamic\Http\Middleware\CP\BootUtilities::class,
diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php
index 8ecd66a23a..4ad6400b92 100644
--- a/src/Providers/ExtensionServiceProvider.php
+++ b/src/Providers/ExtensionServiceProvider.php
@@ -228,6 +228,7 @@ class ExtensionServiceProvider extends ServiceProvider
Updates\AddDefaultPreferencesToGitConfig::class,
Updates\DisableRefreshOnPreviewTargetsIfPostMessageLivePreviewWasUsed::class,
Updates\AddConfigureFormFieldsPermission::class,
+ Updates\AddSitePermissions::class,
];
public function register()
diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php
index 551fe06a92..224e5ab5ee 100644
--- a/src/Providers/RouteServiceProvider.php
+++ b/src/Providers/RouteServiceProvider.php
@@ -2,7 +2,8 @@
namespace Statamic\Providers;
-use Illuminate\Support\Facades\Route;
+use Illuminate\Routing\Route;
+use Illuminate\Support\Facades\Route as Routes;
use Illuminate\Support\ServiceProvider;
use Statamic\Exceptions\NotFoundHttpException;
use Statamic\Facades\Asset;
@@ -29,7 +30,7 @@ public function register()
public function boot()
{
- Route::mixin(new Router);
+ Routes::mixin(new Router);
$this->bindCollections();
$this->bindEntries();
@@ -45,13 +46,19 @@ public function boot()
protected function bindCollections()
{
- Route::bind('collection', function ($handle, $route = null) {
- if (! $this->isCpOrApiRoute($route)) {
+ Routes::bind('collection', function ($handle, $route = null) {
+ if (! $this->needsCollectionBinding($route)) {
return $handle;
}
+ $field = $route->bindingFieldFor('collection') ?? 'handle';
+
+ $collection = $field == 'handle'
+ ? Collection::findByHandle($handle)
+ : Collection::all()->firstWhere($field, $handle);
+
throw_unless(
- $collection = Collection::findByHandle($handle),
+ $collection,
new NotFoundHttpException("Collection [$handle] not found.")
);
@@ -59,15 +66,32 @@ protected function bindCollections()
});
}
+ private function needsCollectionBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ return $this->isCpOrApiRoute($route) || $this->isFrontendBindingEnabled();
+ }
+
protected function bindEntries()
{
- Route::bind('entry', function ($handle, $route = null) {
- if ($this->isApiRoute($route) || ! $this->isCpRoute($route)) {
+ Routes::bind('entry', function ($handle, $route = null) {
+ if (! $this->needsEntryBinding($route)) {
return $handle;
}
+ $field = $route->bindingFieldFor('entry') ?? 'id';
+
+ $entry = $field == 'id'
+ ? Entry::find($handle)
+ : Entry::query()->where($field, $handle)->first();
+
+ $collection = $route->parameter('collection');
+
throw_if(
- ! ($entry = Entry::find($handle)) || $entry->collection()->id() !== $route->parameter('collection')->id(),
+ ! $entry || ($collection && $entry->collection()->id() !== $collection->id()),
new NotFoundHttpException("Entry [$handle] not found.")
);
@@ -75,15 +99,38 @@ protected function bindEntries()
});
}
+ private function needsEntryBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpRoute($route)) {
+ return true;
+ }
+
+ if ($this->isApiRoute($route)) {
+ return false;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindTaxonomies()
{
- Route::bind('taxonomy', function ($handle, $route = null) {
- if (! $this->isCpOrApiRoute($route)) {
+ Routes::bind('taxonomy', function ($handle, $route = null) {
+ if (! $this->needsTaxonomyBinding($route)) {
return $handle;
}
+ $field = $route->bindingFieldFor('taxonomy') ?? 'handle';
+
+ $taxonomy = $field == 'handle'
+ ? Taxonomy::findByHandle($handle)
+ : Taxonomy::all()->firstWhere($field, $handle);
+
throw_unless(
- $taxonomy = Taxonomy::findByHandle($handle),
+ $taxonomy,
new NotFoundHttpException("Taxonomy [$handle] not found.")
);
@@ -91,18 +138,46 @@ protected function bindTaxonomies()
});
}
+ private function needsTaxonomyBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpOrApiRoute($route)) {
+ return true;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindTerms()
{
- Route::bind('term', function ($handle, $route = null) {
- if ($this->isApiRoute($route) || ! $this->isCpRoute($route)) {
+ Routes::bind('term', function ($handle, $route = null) {
+ if (! $this->needsTermBinding($route)) {
return $handle;
}
- $id = $route->parameter('taxonomy')->handle().'::'.$handle;
+ $field = $route->bindingFieldFor('term') ?? 'id';
+
+ $taxonomy = $route->parameter('taxonomy');
+
+ if ($field == 'id' && $taxonomy) {
+ $handle = $taxonomy->handle().'::'.$handle;
+ }
+
$site = $route->parameter('site') ?? Site::default()->handle();
+ $term = $field == 'id'
+ ? Term::find($handle)?->in($site)
+ : Term::query()
+ ->where($field, $handle)
+ ->where('site', $site)
+ ->when($taxonomy, fn ($query) => $query->where('taxonomy', $taxonomy->handle()))
+ ->first();
+
throw_unless(
- ($term = Term::find($id)->in($site)) && $term->taxonomy()->id() === $route->parameter('taxonomy')->id(),
+ $term || ($term && $taxonomy && $term->taxonomy()->id() === $taxonomy->id()),
new NotFoundHttpException("Taxonomy term [$handle] not found.")
);
@@ -110,15 +185,38 @@ protected function bindTerms()
});
}
+ private function needsTermBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpRoute($route)) {
+ return true;
+ }
+
+ if ($this->isApiRoute($route)) {
+ return false;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindAssetContainers()
{
- Route::bind('asset_container', function ($handle, $route = null) {
- if (! $this->isCpOrApiRoute($route)) {
+ Routes::bind('asset_container', function ($handle, $route = null) {
+ if (! $this->needsAssetContainerBinding($route)) {
return $handle;
}
+ $field = $route->bindingFieldFor('asset_container') ?? 'handle';
+
+ $container = $field == 'handle'
+ ? AssetContainer::findByHandle($handle)
+ : AssetContainer::all()->firstWhere($field, $handle);
+
throw_unless(
- $container = AssetContainer::findByHandle($handle),
+ $container,
new NotFoundHttpException("Asset container [$handle] not found.")
);
@@ -126,17 +224,40 @@ protected function bindAssetContainers()
});
}
+ private function needsAssetContainerBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpOrApiRoute($route)) {
+ return true;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindAssets()
{
- Route::bind('asset', function ($handle, $route = null) {
- if (! $this->isCpOrApiRoute($route)) {
+ Routes::bind('asset', function ($handle, $route = null) {
+ if (! $this->needsAssetBinding($route)) {
return $handle;
}
- $id = $route->parameter('asset_container')->handle().'::'.$handle;
+ $field = $route->bindingFieldFor('asset') ?? 'id';
+
+ $container = $route->parameter('asset_container');
+
+ if ($field == 'id') {
+ $handle = $container->handle().'::'.$handle;
+ }
+
+ $asset = $field == 'id'
+ ? Asset::find($handle)
+ : $container->queryAssets()->where($field, $handle)->first();
throw_unless(
- $asset = Asset::find($id),
+ $asset,
new NotFoundHttpException("Asset [$handle] not found.")
);
@@ -144,17 +265,36 @@ protected function bindAssets()
});
}
+ private function needsAssetBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpOrApiRoute($route)) {
+ return true;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindGlobalSets()
{
- Route::bind('global', function ($handle, $route = null) {
- if (! $this->isCpOrApiRoute($route)) {
+ Routes::bind('global', function ($handle, $route = null) {
+ if (! $this->needsGlobalsBinding($route)) {
return $handle;
}
+ $field = $route->bindingFieldFor('global') ?? 'handle';
+
+ $global = $field == 'handle'
+ ? GlobalSet::findByHandle($handle)
+ : GlobalSet::all()->first(fn ($set) => $set->$field($handle));
+
$site = Site::default()->handle();
throw_unless(
- $globalSet = GlobalSet::findByHandle($handle)->in($site),
+ $globalSet = $global?->in($site),
new NotFoundHttpException("Global set [$handle] not found.")
);
@@ -162,15 +302,34 @@ protected function bindGlobalSets()
});
}
+ private function needsGlobalsBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpOrApiRoute($route)) {
+ return true;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindSites()
{
- Route::bind('site', function ($handle, $route = null) {
- if (! $this->isCpOrApiRoute($route)) {
+ Routes::bind('site', function ($handle, $route = null) {
+ if (! $this->needsSiteBinding($route)) {
return $handle;
}
+ $field = $route->bindingFieldFor('site') ?? 'handle';
+
+ $site = $field == 'handle'
+ ? Site::get($handle)
+ : Site::all()->firstWhere($field, $handle);
+
throw_unless(
- $site = Site::get($handle),
+ $site,
new NotFoundHttpException("Site [$handle] not found.")
);
@@ -178,10 +337,23 @@ protected function bindSites()
});
}
+ private function needsSiteBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpOrApiRoute($route)) {
+ return true;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindRevisions()
{
- Route::bind('revision', function ($reference, $route = null) {
- if (! $this->isCpOrApiRoute($route)) {
+ Routes::bind('revision', function ($reference, $route = null) {
+ if (! $this->needsRevisionBinding($route)) {
return $reference;
}
@@ -202,16 +374,38 @@ protected function bindRevisions()
});
}
+ private function needsRevisionBinding(?Route $route): bool
+ {
+ if (! $route) {
+ return false;
+ }
+
+ if ($this->isCpRoute($route)) {
+ return true;
+ }
+
+ if ($this->isApiRoute($route)) {
+ return false;
+ }
+
+ return $this->isFrontendBindingEnabled();
+ }
+
protected function bindForms()
{
- Route::bind('form', function ($handle, $route = null) {
- if (! $this->isCpOrApiRoute($route)
- && ! Str::startsWith($route->uri(), config('statamic.routes.action').'/forms/')) {
+ Routes::bind('form', function ($handle, $route = null) {
+ if (! $this->needsFormBinding($route)) {
return $handle;
}
+ $field = $route->bindingFieldFor('form') ?? 'handle';
+
+ $form = $field == 'handle'
+ ? Form::find($handle)
+ : Form::all()->firstWhere($field, $handle);
+
throw_unless(
- $form = Form::find($handle),
+ $form,
new NotFoundHttpException("Form [$handle] not found.")
);
@@ -219,12 +413,34 @@ protected function bindForms()
});
}
- private function isApiRoute(\Illuminate\Routing\Route $route = null)
+ private function needsFormBinding(?Route $route): bool
{
- if (is_null($route)) {
+ if (! $route) {
return false;
}
+ if ($this->isCpOrApiRoute($route)) {
+ return true;
+ }
+
+ if ($this->isFrontendBindingEnabled()) {
+ return true;
+ }
+
+ if ($route) {
+ return Str::startsWith($route->uri(), config('statamic.routes.action').'/forms/');
+ }
+
+ return false;
+ }
+
+ private function isFrontendBindingEnabled()
+ {
+ return config('statamic.routes.bindings', false);
+ }
+
+ private function isApiRoute(Route $route)
+ {
$api = Str::ensureRight(config('statamic.api.route'), '/');
if ($api === '/') {
@@ -234,12 +450,8 @@ private function isApiRoute(\Illuminate\Routing\Route $route = null)
return Str::startsWith($route->uri(), $api);
}
- private function isCpRoute(\Illuminate\Routing\Route $route = null)
+ private function isCpRoute(Route $route)
{
- if (is_null($route)) {
- return false;
- }
-
$cp = Str::ensureRight(config('statamic.cp.route'), '/');
if ($cp === '/') {
@@ -249,12 +461,8 @@ private function isCpRoute(\Illuminate\Routing\Route $route = null)
return Str::startsWith($route->uri(), $cp);
}
- private function isCpOrApiRoute(\Illuminate\Routing\Route $route = null)
+ private function isCpOrApiRoute(Route $route)
{
- if (is_null($route)) {
- return false;
- }
-
return $this->isCpRoute($route) || $this->isApiRoute($route);
}
}
diff --git a/src/Query/Builder.php b/src/Query/Builder.php
index cefe3feead..7951112284 100644
--- a/src/Query/Builder.php
+++ b/src/Query/Builder.php
@@ -10,6 +10,7 @@
use InvalidArgumentException;
use Statamic\Contracts\Query\Builder as Contract;
use Statamic\Extensions\Pagination\LengthAwarePaginator;
+use Statamic\Facades\Pattern;
abstract class Builder implements Contract
{
@@ -629,13 +630,13 @@ protected function filterTestGreaterThanOrEqualTo($item, $value)
protected function filterTestLike($item, $like)
{
- $pattern = '/^'.str_replace(['%', '_'], ['.*', '.'], preg_quote($like, '/')).'$/im';
-
if (is_array($item)) {
$item = json_encode($item);
}
- return preg_match($pattern, (string) $item);
+ $pattern = Pattern::sqlLikeToRegex($like);
+
+ return preg_match('/'.$pattern.'/im', (string) $item);
}
protected function filterTestNotLike($item, $like)
diff --git a/src/Query/Scopes/Filters/Site.php b/src/Query/Scopes/Filters/Site.php
index 5e5ad09210..c316400c08 100644
--- a/src/Query/Scopes/Filters/Site.php
+++ b/src/Query/Scopes/Filters/Site.php
@@ -41,7 +41,7 @@ public function badge($values)
{
$site = Facades\Site::get($values['site']);
- return __('Site').': '.$site->name();
+ return __('Site').': '.__($site->name());
}
public function visibleTo($key)
@@ -51,8 +51,8 @@ public function visibleTo($key)
protected function options()
{
- return Facades\Site::all()->mapWithKeys(function ($site) {
- return [$site->handle() => $site->name()];
+ return Facades\Site::authorized()->mapWithKeys(function ($site) {
+ return [$site->handle() => __($site->name())];
});
}
}
diff --git a/src/Sites/Sites.php b/src/Sites/Sites.php
index 90e9984547..b67fd13f9b 100644
--- a/src/Sites/Sites.php
+++ b/src/Sites/Sites.php
@@ -2,6 +2,7 @@
namespace Statamic\Sites;
+use Statamic\Facades\User;
use Statamic\Support\Str;
class Sites
@@ -20,6 +21,11 @@ public function all()
return $this->sites;
}
+ public function authorized()
+ {
+ return $this->sites->filter(fn ($site) => User::current()->can('view', $site));
+ }
+
public function default()
{
return $this->sites->first();
@@ -62,6 +68,11 @@ public function selected()
return $this->get(session('statamic.cp.selected-site')) ?? $this->default();
}
+ public function setSelected($site)
+ {
+ session()->put('statamic.cp.selected-site', $site);
+ }
+
public function setConfig($key, $value = null)
{
// If no value is provided, then the key must've been the entire config.
diff --git a/src/Structures/Nav.php b/src/Structures/Nav.php
index 4d7ee5cb0e..bb26b83793 100644
--- a/src/Structures/Nav.php
+++ b/src/Structures/Nav.php
@@ -104,6 +104,11 @@ public function trees()
})->filter();
}
+ public function sites()
+ {
+ return $this->trees()->keys();
+ }
+
public function existsIn($site)
{
return $this->trees()->has($site);
diff --git a/src/Tags/Svg.php b/src/Tags/Svg.php
index 212ff3999b..8ac9d502ce 100644
--- a/src/Tags/Svg.php
+++ b/src/Tags/Svg.php
@@ -5,6 +5,7 @@
use Rhukster\DomSanitizer\DOMSanitizer;
use Statamic\Facades\File;
use Statamic\Facades\URL;
+use Statamic\Fieldtypes\Icon;
use Statamic\Support\Str;
use Stringy\StaticStringy;
@@ -28,6 +29,7 @@ public function index()
resource_path(),
public_path('svg'),
public_path(),
+ statamic_path('resources/svg/icons/'.Icon::DEFAULT_FOLDER),
];
$svg = null;
@@ -42,6 +44,10 @@ public function index()
}
}
+ if (! $svg && Str::startsWith(mb_strtolower(trim($name)), '