Extends the checking of message*.properties (#1781)
This commit is contained in:
parent
09e963b160
commit
a14b78ff91
2 changed files with 305 additions and 67 deletions
229
.github/scripts/check_language_properties.py
vendored
229
.github/scripts/check_language_properties.py
vendored
|
@ -1,5 +1,124 @@
|
||||||
|
"""
|
||||||
|
Author: Ludy87
|
||||||
|
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
|
||||||
|
a reference file to ensure consistency. The script performs two main checks:
|
||||||
|
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
|
||||||
|
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||||
|
|
||||||
|
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
|
||||||
|
adjusting the format.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python script_name.py --reference-file <path_to_reference_file> --branch <branch_name> [--files <list_of_changed_files>]
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def parse_properties_file(file_path):
|
||||||
|
"""Parses a .properties file and returns a list of objects (including comments, empty lines, and line numbers)."""
|
||||||
|
properties_list = []
|
||||||
|
with open(file_path, "r", encoding="utf-8") as file:
|
||||||
|
for line_number, line in enumerate(file, start=1):
|
||||||
|
stripped_line = line.strip()
|
||||||
|
|
||||||
|
# Empty lines
|
||||||
|
if not stripped_line:
|
||||||
|
properties_list.append(
|
||||||
|
{"line_number": line_number, "type": "empty", "content": ""}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Comments
|
||||||
|
if stripped_line.startswith("#"):
|
||||||
|
properties_list.append(
|
||||||
|
{
|
||||||
|
"line_number": line_number,
|
||||||
|
"type": "comment",
|
||||||
|
"content": stripped_line,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Key-value pairs
|
||||||
|
match = re.match(r"^([^=]+)=(.*)$", line)
|
||||||
|
if match:
|
||||||
|
key, value = match.groups()
|
||||||
|
properties_list.append(
|
||||||
|
{
|
||||||
|
"line_number": line_number,
|
||||||
|
"type": "entry",
|
||||||
|
"key": key.strip(),
|
||||||
|
"value": value.strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return properties_list
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_file(file_path, updated_properties):
|
||||||
|
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
|
||||||
|
|
||||||
|
# Sort by line numbers and retain comments and empty lines
|
||||||
|
all_lines = sorted(set(updated_lines.keys()))
|
||||||
|
|
||||||
|
original_format = []
|
||||||
|
for line in all_lines:
|
||||||
|
if line in updated_lines:
|
||||||
|
entry = updated_lines[line]
|
||||||
|
else:
|
||||||
|
entry = None
|
||||||
|
ref_entry = updated_lines[line]
|
||||||
|
if ref_entry["type"] in ["comment", "empty"]:
|
||||||
|
original_format.append(ref_entry)
|
||||||
|
elif entry is None:
|
||||||
|
# Add missing entries from the reference file
|
||||||
|
original_format.append(ref_entry)
|
||||||
|
elif entry["type"] == "entry":
|
||||||
|
# Replace entries with those from the current JSON
|
||||||
|
original_format.append(entry)
|
||||||
|
|
||||||
|
# Write back in the original format
|
||||||
|
with open(file_path, "w", encoding="utf-8") as file:
|
||||||
|
for entry in original_format:
|
||||||
|
if entry["type"] == "comment":
|
||||||
|
file.write(f"{entry['content']}\n")
|
||||||
|
elif entry["type"] == "empty":
|
||||||
|
file.write(f"{entry['content']}\n")
|
||||||
|
elif entry["type"] == "entry":
|
||||||
|
file.write(f"{entry['key']}={entry['value']}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def update_missing_keys(reference_file, file_list, branch=""):
|
||||||
|
reference_properties = parse_properties_file(reference_file)
|
||||||
|
for file_path in file_list:
|
||||||
|
basename_current_file = os.path.basename(branch + file_path)
|
||||||
|
if (
|
||||||
|
basename_current_file == os.path.basename(reference_file)
|
||||||
|
or not file_path.endswith(".properties")
|
||||||
|
or not basename_current_file.startswith("messages_")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_properties = parse_properties_file(branch + file_path)
|
||||||
|
updated_properties = []
|
||||||
|
for ref_entry in reference_properties:
|
||||||
|
ref_entry_copy = copy.deepcopy(ref_entry)
|
||||||
|
for current_entry in current_properties:
|
||||||
|
if current_entry["type"] == "entry":
|
||||||
|
if ref_entry_copy["type"] != "entry":
|
||||||
|
continue
|
||||||
|
if ref_entry_copy["key"] == current_entry["key"]:
|
||||||
|
ref_entry_copy["value"] = current_entry["value"]
|
||||||
|
updated_properties.append(ref_entry_copy)
|
||||||
|
write_json_file(branch + file_path, updated_properties)
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_missing_keys(reference_file, file_list, branch):
|
||||||
|
update_missing_keys(reference_file, file_list, branch + "/")
|
||||||
|
|
||||||
|
|
||||||
def read_properties(file_path):
|
def read_properties(file_path):
|
||||||
|
@ -7,86 +126,96 @@ def read_properties(file_path):
|
||||||
return file.read().splitlines()
|
return file.read().splitlines()
|
||||||
|
|
||||||
|
|
||||||
def check_difference(reference_file, file_list, branch):
|
def check_for_differences(reference_file, file_list, branch):
|
||||||
reference_branch = reference_file.split("/")[0]
|
reference_branch = reference_file.split("/")[0]
|
||||||
basename_reference_file = os.path.basename(reference_file)
|
basename_reference_file = os.path.basename(reference_file)
|
||||||
|
|
||||||
report = []
|
report = []
|
||||||
report.append(
|
report.append(
|
||||||
f"#### Checking with the file `{basename_reference_file}` from the `{reference_branch}` - Checking the `{branch}`"
|
f"### 📋 Checking with the file `{basename_reference_file}` from the `{reference_branch}` - Checking the `{branch}`"
|
||||||
)
|
)
|
||||||
reference_list = read_properties(reference_file)
|
reference_lines = read_properties(reference_file)
|
||||||
is_diff = False
|
has_differences = False
|
||||||
|
|
||||||
|
only_reference_file = True
|
||||||
|
|
||||||
for file_path in file_list:
|
for file_path in file_list:
|
||||||
basename_current_file = os.path.basename(branch + "/" + file_path)
|
basename_current_file = os.path.basename(branch + "/" + file_path)
|
||||||
if (
|
if (
|
||||||
branch + "/" + file_path == reference_file
|
basename_current_file == basename_reference_file
|
||||||
or not file_path.endswith(".properties")
|
or not file_path.endswith(".properties")
|
||||||
or not basename_current_file.startswith("messages_")
|
or not basename_current_file.startswith("messages_")
|
||||||
):
|
):
|
||||||
# report.append(f"File '{basename_current_file}' is ignored.")
|
|
||||||
continue
|
continue
|
||||||
report.append(f"Checking the language file `{basename_current_file}`...")
|
only_reference_file = False
|
||||||
current_list = read_properties(branch + "/" + file_path)
|
report.append(f"#### 🗂️ **Checking File:** `{basename_current_file}`...")
|
||||||
reference_list_len = len(reference_list)
|
current_lines = read_properties(branch + "/" + file_path)
|
||||||
current_list_len = len(current_list)
|
reference_line_count = len(reference_lines)
|
||||||
|
current_line_count = len(current_lines)
|
||||||
|
|
||||||
if reference_list_len != current_list_len:
|
if reference_line_count != current_line_count:
|
||||||
report.append("")
|
report.append("")
|
||||||
report.append("- ❌ Test 1 failed! Difference in the file!")
|
report.append("- **Test 1 Status:** ❌ Failed")
|
||||||
is_diff = True
|
has_differences = True
|
||||||
if reference_list_len > current_list_len:
|
if reference_line_count > current_line_count:
|
||||||
report.append(
|
report.append(
|
||||||
f" - Missing lines! Either comments, empty lines, or translation strings are missing! {reference_list_len}:{current_list_len}"
|
f" - **Issue:** Missing lines! Comments, empty lines, or translation strings are missing. Details: {reference_line_count} (reference) vs {current_line_count} (current)."
|
||||||
)
|
)
|
||||||
elif reference_list_len < current_list_len:
|
elif reference_line_count < current_line_count:
|
||||||
report.append(
|
report.append(
|
||||||
f" - Too many lines! Check your translation files! {reference_list_len}:{current_list_len}"
|
f" - **Issue:** Too many lines! Check your translation files! Details: {reference_line_count} (reference) vs {current_line_count} (current)."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
report.append("- ✅ Test 1 passed")
|
report.append("- **Test 1 Status:** ✅ Passed")
|
||||||
if 1 == 1:
|
|
||||||
|
# Check for missing or extra keys
|
||||||
current_keys = []
|
current_keys = []
|
||||||
reference_keys = []
|
reference_keys = []
|
||||||
for item in current_list:
|
for line in current_lines:
|
||||||
if not item.startswith("#") and item != "" and "=" in item:
|
if not line.startswith("#") and line != "" and "=" in line:
|
||||||
key, _ = item.split("=", 1)
|
key, _ = line.split("=", 1)
|
||||||
current_keys.append(key)
|
current_keys.append(key)
|
||||||
for item in reference_list:
|
for line in reference_lines:
|
||||||
if not item.startswith("#") and item != "" and "=" in item:
|
if not line.startswith("#") and line != "" and "=" in line:
|
||||||
key, _ = item.split("=", 1)
|
key, _ = line.split("=", 1)
|
||||||
reference_keys.append(key)
|
reference_keys.append(key)
|
||||||
|
|
||||||
current_set = set(current_keys)
|
current_keys_set = set(current_keys)
|
||||||
reference_set = set(reference_keys)
|
reference_keys_set = set(reference_keys)
|
||||||
set_test1 = current_set.difference(reference_set)
|
missing_keys = current_keys_set.difference(reference_keys_set)
|
||||||
set_test2 = reference_set.difference(current_set)
|
extra_keys = reference_keys_set.difference(current_keys_set)
|
||||||
set_test1_list = list(set_test1)
|
missing_keys_list = list(missing_keys)
|
||||||
set_test2_list = list(set_test2)
|
extra_keys_list = list(extra_keys)
|
||||||
|
|
||||||
if len(set_test1_list) > 0 or len(set_test2_list) > 0:
|
if missing_keys_list or extra_keys_list:
|
||||||
is_diff = True
|
has_differences = True
|
||||||
set_test1_list = "`, `".join(set_test1_list)
|
missing_keys_str = "`, `".join(missing_keys_list)
|
||||||
set_test2_list = "`, `".join(set_test2_list)
|
extra_keys_str = "`, `".join(extra_keys_list)
|
||||||
report.append("- ❌ Test 2 failed")
|
report.append("- **Test 2 Status:** ❌ Failed")
|
||||||
if len(set_test1_list) > 0:
|
if missing_keys_list:
|
||||||
report.append(
|
report.append(
|
||||||
f" - There are keys in ***{basename_current_file}*** `{set_test1_list}` that are not present in ***{basename_reference_file}***!"
|
f" - **Issue:** There are keys in ***{basename_current_file}*** `{missing_keys_str}` that are not present in ***{basename_reference_file}***!"
|
||||||
)
|
)
|
||||||
if len(set_test2_list) > 0:
|
if extra_keys_list:
|
||||||
report.append(
|
report.append(
|
||||||
f" - There are keys in ***{basename_reference_file}*** `{set_test2_list}` that are not present in ***{basename_current_file}***!"
|
f" - **Issue:** There are keys in ***{basename_reference_file}*** `{extra_keys_str}` that are not present in ***{basename_current_file}***!"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
report.append("- ✅ Test 2 passed")
|
report.append("- **Test 2 Status:** ✅ Passed")
|
||||||
|
if has_differences:
|
||||||
report.append("")
|
report.append("")
|
||||||
|
report.append(f"#### 🚧 ***{basename_current_file}*** will be corrected...")
|
||||||
report.append("")
|
report.append("")
|
||||||
if is_diff:
|
report.append("---")
|
||||||
report.append("## ❌ Check fail")
|
report.append("")
|
||||||
|
update_file_list = glob.glob(branch + "/src/**/messages_*.properties", recursive=True)
|
||||||
|
update_missing_keys(reference_file, update_file_list)
|
||||||
|
if has_differences:
|
||||||
|
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||||
else:
|
else:
|
||||||
report.append("## ✅ Check success")
|
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||||
|
|
||||||
|
if not only_reference_file:
|
||||||
print("\n".join(report))
|
print("\n".join(report))
|
||||||
|
|
||||||
|
|
||||||
|
@ -106,10 +235,16 @@ if __name__ == "__main__":
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--files",
|
"--files",
|
||||||
nargs="+",
|
nargs="+",
|
||||||
required=True,
|
required=False,
|
||||||
help="List of changed files, separated by spaces.",
|
help="List of changed files, separated by spaces.",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
file_list = args.files
|
file_list = args.files
|
||||||
check_difference(args.reference_file, file_list, args.branch)
|
if file_list is None:
|
||||||
|
file_list = glob.glob(
|
||||||
|
os.getcwd() + "/src/**/messages_*.properties", recursive=True
|
||||||
|
)
|
||||||
|
update_missing_keys(args.reference_file, file_list)
|
||||||
|
else:
|
||||||
|
check_for_differences(args.reference_file, file_list, args.branch)
|
||||||
|
|
117
.github/workflows/check_properties.yml
vendored
117
.github/workflows/check_properties.yml
vendored
|
@ -1,15 +1,22 @@
|
||||||
name: Check Properties Files in PR
|
name: Check Properties Files
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
paths:
|
paths:
|
||||||
- "src/main/resources/messages_*.properties"
|
- "src/main/resources/messages_*.properties"
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "src/main/resources/messages_en_GB.properties"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-files:
|
check-files:
|
||||||
|
if: github.event_name == 'pull_request_target'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout PR branch
|
- name: Checkout PR branch
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
@ -54,11 +61,12 @@ jobs:
|
||||||
id: determine-file
|
id: determine-file
|
||||||
run: |
|
run: |
|
||||||
echo "Determining reference file..."
|
echo "Determining reference file..."
|
||||||
if echo "${{ env.CHANGED_FILES }}"| grep -q 'src/main/resources/messages_en_GB.properties'; then
|
if echo "${{ env.CHANGED_FILES }}" | grep -q 'src/main/resources/messages_en_GB.properties'; then
|
||||||
echo "REFERENCE_FILE=pr-branch/src/main/resources/messages_en_GB.properties" >> $GITHUB_ENV
|
echo "REFERENCE_FILE=pr-branch/src/main/resources/messages_en_GB.properties" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "REFERENCE_FILE=main-branch/src/main/resources/messages_en_GB.properties" >> $GITHUB_ENV
|
echo "REFERENCE_FILE=main-branch/src/main/resources/messages_en_GB.properties" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
echo "REFERENCE_FILE=${{ env.REFERENCE_FILE }}"
|
||||||
|
|
||||||
- name: Show REFERENCE_FILE
|
- name: Show REFERENCE_FILE
|
||||||
run: echo "Reference file is set to ${{ env.REFERENCE_FILE }}"
|
run: echo "Reference file is set to ${{ env.REFERENCE_FILE }}"
|
||||||
|
@ -71,26 +79,121 @@ jobs:
|
||||||
- name: Capture output
|
- name: Capture output
|
||||||
id: capture-output
|
id: capture-output
|
||||||
run: |
|
run: |
|
||||||
if [ -f failure.txt ]; then
|
if [ -f failure.txt ] && [ -s failure.txt ]; then
|
||||||
echo "Test failed, capturing output..."
|
echo "Test failed, capturing output..."
|
||||||
# Use the cat command to avoid issues with special characters in environment variables
|
|
||||||
ERROR_OUTPUT=$(cat failure.txt)
|
ERROR_OUTPUT=$(cat failure.txt)
|
||||||
echo "ERROR_OUTPUT<<EOF" >> $GITHUB_ENV
|
echo "ERROR_OUTPUT<<EOF" >> $GITHUB_ENV
|
||||||
echo "$ERROR_OUTPUT" >> $GITHUB_ENV
|
echo "$ERROR_OUTPUT" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
echo $ERROR_OUTPUT
|
echo $ERROR_OUTPUT
|
||||||
|
else
|
||||||
|
echo "No errors found."
|
||||||
|
echo "ERROR_OUTPUT=" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Post comment on PR
|
- name: Post comment on PR
|
||||||
|
if: env.ERROR_OUTPUT != ''
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { GITHUB_REPOSITORY, ERROR_OUTPUT } = process.env;
|
const { GITHUB_REPOSITORY, ERROR_OUTPUT } = process.env;
|
||||||
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
|
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
|
||||||
const prNumber = context.issue.number; // Pull request number from context
|
const prNumber = context.issue.number;
|
||||||
|
|
||||||
|
// Find existing comment
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: repoOwner,
|
||||||
|
repo: repoName,
|
||||||
|
issue_number: prNumber
|
||||||
|
});
|
||||||
|
|
||||||
|
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
|
||||||
|
|
||||||
|
// Only allow the action user to update comments
|
||||||
|
const expectedActor = "github-actions[bot]";
|
||||||
|
|
||||||
|
if (comment && comment.user.login === expectedActor) {
|
||||||
|
// Update existing comment
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: repoOwner,
|
||||||
|
repo: repoName,
|
||||||
|
comment_id: comment.id,
|
||||||
|
body: `## 🚀 Translation Verification Summary\n\n\n${ERROR_OUTPUT}\n`
|
||||||
|
});
|
||||||
|
console.log("Updated existing comment.");
|
||||||
|
} else if (!comment) {
|
||||||
|
// Create new comment if no existing comment is found
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
owner: repoOwner,
|
owner: repoOwner,
|
||||||
repo: repoName,
|
repo: repoName,
|
||||||
issue_number: prNumber,
|
issue_number: prNumber,
|
||||||
body: `## Translation Verification Summary\n\n\n${ERROR_OUTPUT}\n`
|
body: `## 🚀 Translation Verification Summary\n\n\n${ERROR_OUTPUT}\n`
|
||||||
});
|
});
|
||||||
|
console.log("Created new comment.");
|
||||||
|
} else {
|
||||||
|
console.log("Comment update attempt denied. Actor does not match.");
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Set up git config
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Add translation keys
|
||||||
|
run: |
|
||||||
|
cd ${{ env.BRANCH_PATH }}
|
||||||
|
git add src/main/resources/messages_*.properties
|
||||||
|
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||||
|
git commit -m "Update translation files" || echo "No changes to commit"
|
||||||
|
- name: Push
|
||||||
|
if: env.CHANGES_DETECTED == 'true'
|
||||||
|
run: |
|
||||||
|
cd pr-branch
|
||||||
|
git push origin ${{ github.head_ref }} || echo "Push failed: possibly no changes to push"
|
||||||
|
|
||||||
|
update-translations-main:
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
|
||||||
|
- name: Run Python script to check files
|
||||||
|
id: run-check
|
||||||
|
run: |
|
||||||
|
python .github/scripts/check_language_properties.py --reference-file src/main/resources/messages_en_GB.properties --branch main
|
||||||
|
|
||||||
|
- name: Set up git config
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Add translation keys
|
||||||
|
run: |
|
||||||
|
git add src/main/resources/messages_*.properties
|
||||||
|
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
id: cpr
|
||||||
|
if: env.CHANGES_DETECTED == 'true'
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: "Update translation files"
|
||||||
|
committer: GitHub Action <action@github.com>
|
||||||
|
author: GitHub Action <action@github.com>
|
||||||
|
signoff: true
|
||||||
|
branch: update_translation_files
|
||||||
|
title: "Update translation files"
|
||||||
|
body: |
|
||||||
|
Auto-generated by [create-pull-request][1]
|
||||||
|
|
||||||
|
[1]: https://github.com/peter-evans/create-pull-request
|
||||||
|
labels: Translation
|
||||||
|
draft: false
|
||||||
|
delete-branch: true
|
||||||
|
|
Loading…
Reference in a new issue