Merge branch 'master' into feature/response-body
This commit is contained in:
commit
ad6603cd03
23
.github/workflows/new_contributor_pr.yml
vendored
23
.github/workflows/new_contributor_pr.yml
vendored
@ -4,9 +4,9 @@ on:
|
||||
# Safety
|
||||
# This workflow uses pull_request_target so it can run with write permissions on first-time contributor PRs.
|
||||
# It is safe because it does not check out or execute any code from the pull request and
|
||||
# only uses the pinned, trusted actions/first-interaction action
|
||||
# only uses the pinned, trusted plbstl/first-contribution action
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened]
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- master
|
||||
|
||||
@ -20,20 +20,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
|
||||
- uses: plbstl/first-contribution@4b2b042fffa26792504a18e49aa9543a87bec077 # v4.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue_message: "This is a required parameter, but we don't run on this event as we consider it a bit spammy.."
|
||||
pr_message: |
|
||||
Hello! Thank you for your contribution 👋
|
||||
pr-reactions: rocket
|
||||
pr-opened-msg: >
|
||||
Hello and thanks for lending a paw to Uptime Kuma! 🐻👋
|
||||
|
||||
As this is your first contribution, please be sure to check out our
|
||||
[Pull Request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma).
|
||||
As this is your first contribution, please be sure to check out our [Pull Request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma).
|
||||
|
||||
In particular:
|
||||
- Mark your PR as Draft while you’re still making changes
|
||||
- Mark it as Ready for review once it’s fully ready 🟢
|
||||
- Mark it as Ready for review once it’s fully ready
|
||||
|
||||
If you have any design or process questions, feel free to ask them right here in this pull request - unclear documentation is a bug too.
|
||||
pr-merged-msg: >
|
||||
@{fc-author} congrats on your first contribution to Uptime Kuma! 🐻
|
||||
|
||||
Thanks for lending a paw to Uptime Kuma 🐻
|
||||
We hope you enjoy contributing to our project and look forward to seeing more of your work in the future!
|
||||
If you want to see your contribution in action, please see our [nightly builds here](https://hub.docker.com/layers/louislam/uptime-kuma/nightly2).
|
||||
|
||||
@ -2,20 +2,29 @@
|
||||
<div class="shadow-box mb-3" :style="boxStyle">
|
||||
<div class="list-header">
|
||||
<div class="header-top">
|
||||
<button
|
||||
class="btn btn-outline-normal ms-2"
|
||||
:class="{ active: selectMode }"
|
||||
type="button"
|
||||
@click="selectMode = !selectMode"
|
||||
>
|
||||
{{ $t("Select") }}
|
||||
</button>
|
||||
<div class="select-checkbox-wrapper">
|
||||
<input
|
||||
v-if="!selectMode"
|
||||
v-model="selectMode"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:aria-label="$t('selectAllMonitorsAria')"
|
||||
@change="selectAll = selectMode"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="selectAll"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="placeholder"></div>
|
||||
<div class="search-wrapper">
|
||||
<a v-if="searchText == ''" class="search-icon">
|
||||
<font-awesome-icon icon="search" />
|
||||
</a>
|
||||
<div class="header-filter">
|
||||
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
|
||||
</div>
|
||||
|
||||
<div class="search-wrapper ms-auto">
|
||||
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
|
||||
<font-awesome-icon icon="times" />
|
||||
</a>
|
||||
@ -30,25 +39,51 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-filter">
|
||||
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
|
||||
</div>
|
||||
|
||||
<!-- Selection Controls -->
|
||||
<div v-if="selectMode" class="selection-controls px-2 pt-2">
|
||||
<input v-model="selectAll" class="form-check-input select-input" type="checkbox" />
|
||||
|
||||
<button class="btn-outline-normal" @click="pauseDialog">
|
||||
<font-awesome-icon icon="pause" size="sm" />
|
||||
{{ $t("Pause") }}
|
||||
<div v-if="selectMode && selectedMonitorCount > 0" class="selected-count-row">
|
||||
<button class="btn btn-outline-normal" @click="cancelSelectMode">
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
<button class="btn-outline-normal" @click="resumeSelected">
|
||||
<font-awesome-icon icon="play" size="sm" />
|
||||
{{ $t("Resume") }}
|
||||
</button>
|
||||
|
||||
<span v-if="selectedMonitorCount > 0">
|
||||
{{ $t("selectedMonitorCount", [selectedMonitorCount]) }}
|
||||
<div class="actions-wrapper ms-2">
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-outline-normal dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
:aria-label="$t('Actions')"
|
||||
:disabled="bulkActionInProgress"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{ $t("Actions") }}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="pauseDialog">
|
||||
<font-awesome-icon icon="pause" class="me-2" />
|
||||
{{ $t("Pause") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="resumeSelected">
|
||||
<font-awesome-icon icon="play" class="me-2" />
|
||||
{{ $t("Resume") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item text-danger"
|
||||
href="#"
|
||||
@click.prevent="$refs.confirmDelete.show()"
|
||||
>
|
||||
<font-awesome-icon icon="trash" class="me-2" />
|
||||
{{ $t("Delete") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<span class="selected-count ms-2">
|
||||
{{ $tc("selectedMonitorCountMsg", selectedMonitorCount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -81,6 +116,10 @@
|
||||
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
|
||||
{{ $t("pauseMonitorMsg") }}
|
||||
</Confirm>
|
||||
|
||||
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteSelected">
|
||||
{{ $t("deleteMonitorsMsg") }}
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -109,6 +148,7 @@ export default {
|
||||
disableSelectAllWatcher: false,
|
||||
selectedMonitors: {},
|
||||
windowTop: 0,
|
||||
bulkActionInProgress: false,
|
||||
filterState: {
|
||||
status: null,
|
||||
active: null,
|
||||
@ -307,10 +347,21 @@ export default {
|
||||
* @returns {void}
|
||||
*/
|
||||
pauseSelected() {
|
||||
Object.keys(this.selectedMonitors)
|
||||
.filter((id) => this.$root.monitorList[id].active)
|
||||
.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
|
||||
if (this.bulkActionInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeMonitors = Object.keys(this.selectedMonitors).filter((id) => this.$root.monitorList[id].active);
|
||||
|
||||
if (activeMonitors.length === 0) {
|
||||
this.$root.toastError(this.$t("noMonitorsPausedMsg"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.bulkActionInProgress = true;
|
||||
activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
|
||||
this.$root.toastSuccess(this.$tc("pausedMonitorsMsg", activeMonitors.length));
|
||||
this.bulkActionInProgress = false;
|
||||
this.cancelSelectMode();
|
||||
},
|
||||
/**
|
||||
@ -318,9 +369,66 @@ export default {
|
||||
* @returns {void}
|
||||
*/
|
||||
resumeSelected() {
|
||||
Object.keys(this.selectedMonitors)
|
||||
.filter((id) => !this.$root.monitorList[id].active)
|
||||
.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
|
||||
if (this.bulkActionInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inactiveMonitors = Object.keys(this.selectedMonitors).filter(
|
||||
(id) => !this.$root.monitorList[id].active
|
||||
);
|
||||
|
||||
if (inactiveMonitors.length === 0) {
|
||||
this.$root.toastError(this.$t("noMonitorsResumedMsg"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.bulkActionInProgress = true;
|
||||
inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
|
||||
this.$root.toastSuccess(this.$tc("resumedMonitorsMsg", inactiveMonitors.length));
|
||||
this.bulkActionInProgress = false;
|
||||
this.cancelSelectMode();
|
||||
},
|
||||
/**
|
||||
* Delete each selected monitor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteSelected() {
|
||||
if (this.bulkActionInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const monitorIds = Object.keys(this.selectedMonitors);
|
||||
|
||||
this.bulkActionInProgress = true;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const id of monitorIds) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
this.$root.getSocket().emit("deleteMonitor", id, false, (res) => {
|
||||
if (res.ok) {
|
||||
successCount++;
|
||||
resolve();
|
||||
} else {
|
||||
errorCount++;
|
||||
reject();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
// Error already counted
|
||||
}
|
||||
}
|
||||
|
||||
this.bulkActionInProgress = false;
|
||||
|
||||
if (successCount > 0) {
|
||||
this.$root.toastSuccess(this.$tc("deletedMonitorsMsg", successCount));
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
this.$root.toastError(this.$tc("bulkDeleteErrorMsg", errorCount));
|
||||
}
|
||||
|
||||
this.cancelSelectMode();
|
||||
},
|
||||
@ -438,10 +546,32 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
|
||||
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.select-checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.form-check-input {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-filter {
|
||||
@ -449,6 +579,104 @@ export default {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.dropdown-toggle {
|
||||
white-space: nowrap;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 140px;
|
||||
padding: 4px 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.dark & {
|
||||
background-color: $dark-bg;
|
||||
border-color: $dark-border-color;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9em;
|
||||
|
||||
.dark & {
|
||||
color: $dark-font-color;
|
||||
|
||||
&:hover {
|
||||
background-color: $dark-bg2;
|
||||
color: $dark-font-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-danger {
|
||||
color: #dc3545;
|
||||
|
||||
.dark & {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
|
||||
.dark & {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
white-space: nowrap;
|
||||
font-size: 0.9em;
|
||||
color: $primary;
|
||||
|
||||
.dark & {
|
||||
color: $dark-font-color;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-count-row {
|
||||
padding: 5px 10px 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.d-flex {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 770px) {
|
||||
.list-header {
|
||||
margin: -20px;
|
||||
@ -460,25 +688,40 @@ export default {
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
color: #c0c0c0;
|
||||
cursor: pointer;
|
||||
transition: all ease-in-out 0.1s;
|
||||
z-index: 1;
|
||||
|
||||
// Clear filter button (X)
|
||||
svg[data-icon="times"] {
|
||||
cursor: pointer;
|
||||
transition: all ease-in-out 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
max-width: 15em;
|
||||
padding-right: 30px;
|
||||
|
||||
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.monitor-item {
|
||||
@ -498,10 +741,13 @@ export default {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
|
||||
.selection-controls {
|
||||
.selected-count {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,18 +1,5 @@
|
||||
<template>
|
||||
<div class="px-2 pt-2 d-flex">
|
||||
<button
|
||||
type="button"
|
||||
:title="$t('Clear current filters')"
|
||||
class="clear-filters-btn btn"
|
||||
:class="{ active: numFiltersActive > 0 }"
|
||||
tabindex="0"
|
||||
:disabled="numFiltersActive === 0"
|
||||
@click="clearFilters"
|
||||
>
|
||||
<font-awesome-icon icon="stream" />
|
||||
<span v-if="numFiltersActive > 0" class="px-1 fw-bold">{{ numFiltersActive }}</span>
|
||||
<font-awesome-icon v-if="numFiltersActive > 0" icon="times" />
|
||||
</button>
|
||||
<div class="d-flex align-items-center">
|
||||
<MonitorListFilterDropdown :filterActive="filterState.status?.length > 0">
|
||||
<template #status>
|
||||
<Status v-if="filterState.status?.length === 1" :status="filterState.status[0]" />
|
||||
|
||||
@ -333,7 +333,11 @@
|
||||
"Cancel": "Cancel",
|
||||
"auto-select": "Auto Select",
|
||||
"Select": "Select",
|
||||
"selectedMonitorCount": "Selected: {0}",
|
||||
"Actions": "Actions",
|
||||
"selectedMonitorCountMsg": "selected: {n} | selected: {n}",
|
||||
"selectMonitorMsg": "Select monitors to perform actions",
|
||||
"selectAllMonitorsAria": "Select all monitors",
|
||||
"deselectAllMonitorsAria": "Deselect all monitors",
|
||||
"Check/Uncheck": "Check/Uncheck",
|
||||
"Powered by": "Powered by",
|
||||
"Customize": "Customize",
|
||||
@ -629,6 +633,13 @@
|
||||
"grpcMethodDescription": "Method name is convert to camelCase format such as sayHello, check, etc.",
|
||||
"acceptedStatusCodesDescription": "Select status codes which are considered as a successful response.",
|
||||
"deleteMonitorMsg": "Are you sure want to delete this monitor?",
|
||||
"deleteMonitorsMsg": "Are you sure you want to delete the selected monitors?",
|
||||
"pausedMonitorsMsg": "Paused {n} monitor | Paused {n} monitors",
|
||||
"resumedMonitorsMsg": "Resumed {n} monitor | Resumed {n} monitors",
|
||||
"deletedMonitorsMsg": "Deleted {n} monitor | Deleted {n} monitors",
|
||||
"noMonitorsPausedMsg": "No monitors paused (none were active)",
|
||||
"noMonitorsResumedMsg": "No monitors resumed (none were inactive)",
|
||||
"bulkDeleteErrorMsg": "Failed to delete {n} monitor | Failed to delete {n} monitors",
|
||||
"deleteGroupMsg": "Are you sure you want to delete this group?",
|
||||
"deleteChildrenMonitors": "Also delete the direct child monitors and its children if it has any | Also delete all {count} direct child monitors and their children if they have any",
|
||||
"deleteMaintenanceMsg": "Are you sure want to delete this maintenance?",
|
||||
|
||||
@ -1147,7 +1147,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<div v-if="monitor.maxretries" class="my-3">
|
||||
<label for="retry-interval" class="form-label">
|
||||
{{ $t("Heartbeat Retry Interval") }}
|
||||
<span>({{ $t("retryCheckEverySecond", [monitor.retryInterval]) }})</span>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
|
||||
/**
|
||||
* Recursively walks a directory and yields file paths.
|
||||
* @param {string} dir The directory to walk.
|
||||
* @yields {string} The path to a file.
|
||||
* @returns {Generator<string>} A generator that yields file paths.
|
||||
* @returns {AsyncGenerator<string>} A generator that yields file paths.
|
||||
*/
|
||||
function* walk(dir) {
|
||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||
async function* walk(dir) {
|
||||
const files = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
if (file.isDirectory()) {
|
||||
yield* walk(path.join(dir, file.name));
|
||||
@ -20,6 +20,29 @@ function* walk(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
const UPSTREAM_EN_JSON = "https://raw.githubusercontent.com/louislam/uptime-kuma/refs/heads/master/src/lang/en.json";
|
||||
|
||||
/**
|
||||
* Extract `{placeholders}` from a translation string.
|
||||
* @param {string} value The translation string to extract placeholders from.
|
||||
* @returns {Set<string>} A set of placeholder names.
|
||||
*/
|
||||
function extractParams(value) {
|
||||
if (typeof value !== "string") {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const regex = /\{([^}]+)\}/g;
|
||||
const params = new Set();
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
params.add(match[1]);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to get start/end indices of a key within a line.
|
||||
* @param {string} line - Line of text to search in.
|
||||
@ -35,8 +58,8 @@ function getStartEnd(line, key) {
|
||||
}
|
||||
|
||||
describe("Check Translations", () => {
|
||||
it("should not have missing translation keys", () => {
|
||||
const enTranslations = JSON.parse(fs.readFileSync("src/lang/en.json", "utf-8"));
|
||||
it("should not have missing translation keys", async () => {
|
||||
const enTranslations = JSON.parse(await fs.readFile("src/lang/en.json", "utf-8"));
|
||||
|
||||
// this is a resonably crude check, you can get around this trivially
|
||||
/// this check is just to save on maintainer energy to explain this on every review ^^
|
||||
@ -50,9 +73,9 @@ describe("Check Translations", () => {
|
||||
const roots = ["src", "server"];
|
||||
|
||||
for (const root of roots) {
|
||||
for (const filePath of walk(root)) {
|
||||
for await (const filePath of walk(root)) {
|
||||
if (filePath.endsWith(".vue") || filePath.endsWith(".js")) {
|
||||
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
|
||||
const lines = (await fs.readFile(filePath, "utf-8")).split("\n");
|
||||
lines.forEach((line, lineNum) => {
|
||||
let match;
|
||||
// front-end style keys ($t / i18n-t)
|
||||
@ -112,4 +135,38 @@ describe("Check Translations", () => {
|
||||
assert.fail(report);
|
||||
}
|
||||
});
|
||||
|
||||
it("en.json translations must not change placeholder parameters", async () => {
|
||||
// Load local reference (the one translators are synced against)
|
||||
const enTranslations = JSON.parse(await fs.readFile("src/lang/en.json", "utf-8"));
|
||||
|
||||
// Fetch upstream version
|
||||
const res = await fetch(UPSTREAM_EN_JSON);
|
||||
assert.equal(res.ok, true, "Failed to fetch upstream en.json");
|
||||
|
||||
const upstreamEn = await res.json();
|
||||
|
||||
for (const [key, upstreamValue] of Object.entries(upstreamEn)) {
|
||||
if (!(key in enTranslations)) {
|
||||
// deleted keys are fine
|
||||
continue;
|
||||
}
|
||||
|
||||
const localParams = extractParams(enTranslations[key]);
|
||||
const upstreamParams = extractParams(upstreamValue);
|
||||
|
||||
assert.deepEqual(
|
||||
localParams,
|
||||
upstreamParams,
|
||||
[
|
||||
`Translation key "${key}" changed placeholder parameters.`,
|
||||
`This is a breaking change for existing translations.`,
|
||||
`Please rename the translation key instead of changing placeholders.`,
|
||||
``,
|
||||
`your version: ${[...localParams].join(", ")}`,
|
||||
`on master: ${[...upstreamParams].join(", ")}`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user