feat(i18n): add scripts for locale translation and patching
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
- Implemented `fill-de-extended-gaps.js` to fill missing billing/orders keys in de-extended from de. - Created `fill-i18n-deep.py` for deep translation of locale JSONs using deep-translator with fallback options. - Added `fill-i18n-locales.js` to translate locale JSONs and write overrides for untranslated keys. - Introduced `fix-en-leaks.py` to translate keys that still match the en-US merge, addressing English leaks. - Developed `patch-de-ch-swiss.js` to replace 'ß' with 'ss' in de-CH.json without deleting existing entries. - Created `patch-en-gb-au.js` to apply UK/AU spelling corrections in en-GB and en-AU locales. - Added shell scripts `run-fix-en-leaks.sh` and `run-i18n-deep-fill.sh` for sequential execution of translation tasks. - Implemented `update-i18n-todo-stats.js` to update statistics in the I18N_TODO.md file based on translation completeness.
This commit is contained in:
@@ -7,22 +7,6 @@ import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import GroupActivity from '../models/GroupActivity.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
const STANDARD_ACTIVITY_NAMES = new Set([
|
||||
'Begrüßung',
|
||||
'Aktivierung',
|
||||
'Aufbauen',
|
||||
'Turnier',
|
||||
'Abbauen',
|
||||
'Abschlussgespräch',
|
||||
]);
|
||||
|
||||
const isTrackablePredefinedActivity = (predefinedActivity) => {
|
||||
if (!predefinedActivity) {
|
||||
return false;
|
||||
}
|
||||
return !predefinedActivity.excludeFromStats && !STANDARD_ACTIVITY_NAMES.has(predefinedActivity.name);
|
||||
};
|
||||
|
||||
export const getMemberActivities = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
@@ -229,18 +213,12 @@ export const getMemberActivities = async (req, res) => {
|
||||
if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) {
|
||||
return false;
|
||||
}
|
||||
if (!isTrackablePredefinedActivity(ga.activity.predefinedActivity)) {
|
||||
return false;
|
||||
}
|
||||
const key = `${ga.activity.id}-${ga.participant.id}`;
|
||||
return !explicitActivityKeys.has(key);
|
||||
});
|
||||
|
||||
// Kombiniere beide Listen
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities].filter((entry) => {
|
||||
const predefinedActivity = entry?.activity?.predefinedActivity;
|
||||
return isTrackablePredefinedActivity(predefinedActivity);
|
||||
});
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
|
||||
|
||||
// Group activities by name and count occurrences
|
||||
// Verwende einen Set pro Aktivität, um eindeutige Datum-Aktivität-Kombinationen zu tracken
|
||||
@@ -250,10 +228,6 @@ export const getMemberActivities = async (req, res) => {
|
||||
if (!ma.activity || !ma.activity.predefinedActivity || !ma.participant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isTrackablePredefinedActivity(ma.activity.predefinedActivity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activity = ma.activity.predefinedActivity;
|
||||
const activityName = activity.name;
|
||||
@@ -480,18 +454,12 @@ export const getMemberLastParticipations = async (req, res) => {
|
||||
if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) {
|
||||
return false;
|
||||
}
|
||||
if (!isTrackablePredefinedActivity(ga.activity.predefinedActivity)) {
|
||||
return false;
|
||||
}
|
||||
const key = `${ga.activity.id}-${ga.participant.id}`;
|
||||
return !explicitActivityKeys.has(key);
|
||||
});
|
||||
|
||||
// Kombiniere beide Listen
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities].filter((entry) => {
|
||||
const predefinedActivity = entry?.activity?.predefinedActivity;
|
||||
return isTrackablePredefinedActivity(predefinedActivity);
|
||||
});
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
|
||||
|
||||
// Gruppiere nach Datum
|
||||
const participationsByDate = new Map();
|
||||
@@ -501,7 +469,7 @@ export const getMemberLastParticipations = async (req, res) => {
|
||||
if (!ma.activity || !ma.activity.predefinedActivity || !ma.activity.diaryDate || !ma.participant) {
|
||||
return false;
|
||||
}
|
||||
return isTrackablePredefinedActivity(ma.activity.predefinedActivity);
|
||||
return true;
|
||||
})
|
||||
.forEach(ma => {
|
||||
const date = ma.activity.diaryDate.date;
|
||||
@@ -560,3 +528,4 @@ export const getMemberLastParticipations = async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to fetch member last participations' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,49 +4,12 @@ import { emitTournamentChanged } from '../services/socketService.js';
|
||||
import TournamentClass from '../models/TournamentClass.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
// Pools (zusammengelegte Gruppenphasen)
|
||||
export const mergeClassesIntoPool = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, sourceClassId, targetClassId, strategy, outOfCompetitionForSource } = req.body;
|
||||
try {
|
||||
await tournamentService.mergeClassesIntoPool(
|
||||
token,
|
||||
clubId,
|
||||
tournamentId,
|
||||
sourceClassId,
|
||||
targetClassId,
|
||||
strategy, // 'singleGroup' | 'distribute'
|
||||
!!outOfCompetitionForSource
|
||||
);
|
||||
// Broadcast
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[mergeClassesIntoPool] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPool = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, poolId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetPool(token, clubId, tournamentId, poolId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[resetPool] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Alle Turniere eines Vereins (query: type = 'internal' | 'external' | 'mini')
|
||||
// 1. Alle Turniere eines Vereins
|
||||
export const getTournaments = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const type = req.query.type || null;
|
||||
try {
|
||||
const tournaments = await tournamentService.getTournaments(token, clubId, type);
|
||||
const tournaments = await tournamentService.getTournaments(token, clubId);
|
||||
res.status(200).json(tournaments);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -59,27 +22,13 @@ export const getTournaments = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Ranglisten interne Einzel-Turniere (Gruppen-% + K.-o.-Bonus wie im Service) */
|
||||
export const getInternalTournamentStats = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const months = req.query.months;
|
||||
const ageClassKeys = req.query.ageClassKeys;
|
||||
try {
|
||||
const data = await tournamentService.getInternalTournamentPlayerStats(token, clubId, months, ageClassKeys);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Neues Turnier anlegen
|
||||
export const addTournament = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentName, date, winningSets, allowsExternal, isDoublesTournament } = req.body;
|
||||
const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body;
|
||||
try {
|
||||
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal, isDoublesTournament);
|
||||
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal);
|
||||
// Emit Socket-Event
|
||||
if (clubId && tournament && tournament.id) {
|
||||
emitTournamentChanged(clubId, tournament.id);
|
||||
}
|
||||
@@ -90,22 +39,6 @@ export const addTournament = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Minimeisterschaft anlegen (Turnier + 6 Klassen); Name: "Minimeisterschaften <Jahr> Ortsentscheid <ort>"
|
||||
export const addMiniChampionship = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, ort, date, year, winningSets } = req.body;
|
||||
try {
|
||||
const tournament = await tournamentService.addMiniChampionship(token, clubId, ort, date, year, winningSets);
|
||||
if (clubId && tournament && tournament.id) {
|
||||
emitTournamentChanged(clubId, tournament.id);
|
||||
}
|
||||
res.status(201).json(tournament);
|
||||
} catch (error) {
|
||||
console.error('[addMiniChampionship] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Teilnehmer hinzufügen - klassengebunden
|
||||
export const addParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
@@ -286,9 +219,9 @@ export const getTournament = async (req, res) => {
|
||||
export const updateTournament = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.params;
|
||||
const { name, date, winningSets, numberOfTables, isDoublesTournament } = req.body;
|
||||
const { name, date, winningSets } = req.body;
|
||||
try {
|
||||
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets, numberOfTables, isDoublesTournament);
|
||||
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(tournament);
|
||||
@@ -435,19 +368,6 @@ export const resetMatches = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupOrphanedMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
try {
|
||||
const result = await tournamentService.cleanupOrphanedMatches(token, clubId, tournamentId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.body;
|
||||
@@ -478,20 +398,6 @@ export const updateParticipantSeeded = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setParticipantGaveUp = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.params;
|
||||
const { gaveUp } = req.body;
|
||||
try {
|
||||
await tournamentService.setParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Aufgabe-Status aktualisiert' });
|
||||
} catch (err) {
|
||||
console.error('[setParticipantGaveUp] Error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMatchResult = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, matchId, set } = req.body;
|
||||
@@ -557,27 +463,12 @@ export const setMatchActive = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setMatchTableNumber = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, matchId } = req.params;
|
||||
const { tableNumber } = req.body;
|
||||
try {
|
||||
await tournamentService.setMatchTableNumber(token, clubId, tournamentId, matchId, tableNumber);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Tischnummer aktualisiert' });
|
||||
} catch (err) {
|
||||
console.error('[setMatchTableNumber] Error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Externe Teilnehmer hinzufügen
|
||||
export const addExternalParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender, email, address } = req.body;
|
||||
const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender } = req.body;
|
||||
try {
|
||||
await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender, email, address);
|
||||
await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Externer Teilnehmer hinzugefügt' });
|
||||
} catch (error) {
|
||||
@@ -628,20 +519,6 @@ export const updateExternalParticipantSeeded = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setExternalParticipantGaveUp = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.params;
|
||||
const { gaveUp } = req.body;
|
||||
try {
|
||||
await tournamentService.setExternalParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Aufgabe-Status aktualisiert' });
|
||||
} catch (error) {
|
||||
console.error('[setExternalParticipantGaveUp] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Tournament Classes
|
||||
export const getTournamentClasses = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
@@ -658,9 +535,9 @@ export const getTournamentClasses = async (req, res) => {
|
||||
export const addTournamentClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.params;
|
||||
const { name, isDoubles, gender, minBirthYear, maxBirthYear } = req.body;
|
||||
const { name, isDoubles, gender, minBirthYear } = req.body;
|
||||
try {
|
||||
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear, maxBirthYear);
|
||||
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(tournamentClass);
|
||||
} catch (error) {
|
||||
@@ -672,9 +549,11 @@ export const addTournamentClass = async (req, res) => {
|
||||
export const updateTournamentClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.params;
|
||||
const { name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear } = req.body;
|
||||
const { name, sortOrder, isDoubles, gender, minBirthYear } = req.body;
|
||||
try {
|
||||
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear);
|
||||
console.log('[updateTournamentClass] Request body:', { name, sortOrder, isDoubles, gender, minBirthYear });
|
||||
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear);
|
||||
console.log('[updateTournamentClass] Updated class:', JSON.stringify(tournamentClass.toJSON(), null, 2));
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(tournamentClass);
|
||||
} catch (error) {
|
||||
@@ -763,4 +642,4 @@ export const deletePairing = async (req, res) => {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,10 +20,6 @@ const TournamentGroup = sequelize.define('TournamentGroup', {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
poolId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'tournament_group',
|
||||
|
||||
@@ -63,12 +63,6 @@ const TournamentMatch = sequelize.define('TournamentMatch', {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
tableNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: 'Tischnummer, an der das Match stattfindet'
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'tournament_match',
|
||||
|
||||
@@ -20,7 +20,6 @@ import Match from './Match.js';
|
||||
import League from './League.js';
|
||||
import Team from './Team.js';
|
||||
import ClubTeam from './ClubTeam.js';
|
||||
import ClubTeamMember from './ClubTeamMember.js';
|
||||
import TeamDocument from './TeamDocument.js';
|
||||
import Season from './Season.js';
|
||||
import Location from './Location.js';
|
||||
@@ -42,30 +41,16 @@ import OfficialTournament from './OfficialTournament.js';
|
||||
import OfficialCompetition from './OfficialCompetition.js';
|
||||
import OfficialCompetitionMember from './OfficialCompetitionMember.js';
|
||||
import MyTischtennis from './MyTischtennis.js';
|
||||
import ClickTtAccount from './ClickTtAccount.js';
|
||||
import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js';
|
||||
import MyTischtennisFetchLog from './MyTischtennisFetchLog.js';
|
||||
import ApiLog from './ApiLog.js';
|
||||
import MemberTransferConfig from './MemberTransferConfig.js';
|
||||
import MemberContact from './MemberContact.js';
|
||||
import MemberImage from './MemberImage.js';
|
||||
import MemberGroupPhoto from './MemberGroupPhoto.js';
|
||||
import MemberTtrHistory from './MemberTtrHistory.js';
|
||||
import MemberPlayInterest from './MemberPlayInterest.js';
|
||||
import MemberOrder from './MemberOrder.js';
|
||||
import MemberOrderHistory from './MemberOrderHistory.js';
|
||||
import TrainingGroup from './TrainingGroup.js';
|
||||
import MemberTrainingGroup from './MemberTrainingGroup.js';
|
||||
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
|
||||
import TrainingTime from './TrainingTime.js';
|
||||
import TrainingCancellation from './TrainingCancellation.js';
|
||||
import CalendarEvent from './CalendarEvent.js';
|
||||
import BillingTemplate from './BillingTemplate.js';
|
||||
import BillingTemplateField from './BillingTemplateField.js';
|
||||
import BillingRun from './BillingRun.js';
|
||||
import BillingDocument from './BillingDocument.js';
|
||||
import BillingDocumentValue from './BillingDocumentValue.js';
|
||||
import BillingUserSetting from './BillingUserSetting.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -104,25 +89,6 @@ DiaryNote.belongsTo(Member, { foreignKey: 'memberId' });
|
||||
Member.hasMany(MemberNote, { as: 'memberNotes', foreignKey: 'memberId' });
|
||||
MemberNote.belongsTo(Member, { foreignKey: 'memberId' });
|
||||
|
||||
Member.hasMany(MemberTtrHistory, { as: 'ttrHistoryEntries', foreignKey: 'memberId' });
|
||||
MemberTtrHistory.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
|
||||
Member.hasMany(MemberPlayInterest, { as: 'playInterests', foreignKey: 'memberId' });
|
||||
MemberPlayInterest.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
|
||||
Club.hasMany(MemberPlayInterest, { as: 'memberPlayInterests', foreignKey: 'clubId' });
|
||||
MemberPlayInterest.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
|
||||
|
||||
Club.hasMany(MemberGroupPhoto, { as: 'memberGroupPhotos', foreignKey: 'clubId' });
|
||||
MemberGroupPhoto.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
|
||||
User.hasMany(MemberGroupPhoto, { as: 'createdMemberGroupPhotos', foreignKey: 'createdByUserId' });
|
||||
MemberGroupPhoto.belongsTo(User, { as: 'createdByUser', foreignKey: 'createdByUserId' });
|
||||
|
||||
Member.hasMany(MemberOrder, { as: 'orders', foreignKey: 'memberId' });
|
||||
MemberOrder.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
|
||||
Club.hasMany(MemberOrder, { as: 'memberOrders', foreignKey: 'clubId' });
|
||||
MemberOrder.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
|
||||
MemberOrder.hasMany(MemberOrderHistory, { as: 'historyEntries', foreignKey: 'memberOrderId' });
|
||||
MemberOrderHistory.belongsTo(MemberOrder, { as: 'order', foreignKey: 'memberOrderId' });
|
||||
|
||||
DiaryDate.hasMany(DiaryNote, { as: 'diaryNotes', foreignKey: 'diaryDateId' });
|
||||
DiaryNote.belongsTo(DiaryDate, { foreignKey: 'diaryDateId' });
|
||||
|
||||
@@ -188,11 +154,6 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
|
||||
Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' });
|
||||
ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
|
||||
|
||||
ClubTeam.hasMany(ClubTeamMember, { foreignKey: 'clubTeamId', as: 'lineupEntries' });
|
||||
ClubTeamMember.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
|
||||
Member.hasMany(ClubTeamMember, { foreignKey: 'memberId', as: 'clubTeamAssignments' });
|
||||
ClubTeamMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
|
||||
|
||||
// TeamDocument relationships
|
||||
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
|
||||
TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
|
||||
@@ -221,9 +182,6 @@ GroupActivity.belongsTo(Group, { foreignKey: 'groupId', as: 'groupsGroupActivity
|
||||
GroupActivity.belongsTo(PredefinedActivity, { foreignKey: 'customActivity', as: 'groupPredefinedActivity' });
|
||||
PredefinedActivity.hasMany(GroupActivity, { foreignKey: 'predefinedActivityId', as: 'groupPredefinedActivities' });
|
||||
|
||||
DiaryDateActivity.belongsTo(Group, { foreignKey: 'groupId', as: 'planGroup' });
|
||||
Group.hasMany(DiaryDateActivity, { foreignKey: 'groupId', as: 'groupPlanItems' });
|
||||
|
||||
DiaryTag.hasMany(DiaryDateTag, { foreignKey: 'tagId', as: 'diaryDateTags' });
|
||||
DiaryDateTag.belongsTo(DiaryTag, { foreignKey: 'tagId', as: 'tag' });
|
||||
|
||||
@@ -364,8 +322,6 @@ DiaryDate.hasMany(Accident, { foreignKey: 'diaryDateId', as: 'accidents' });
|
||||
|
||||
User.hasOne(MyTischtennis, { foreignKey: 'userId', as: 'myTischtennis' });
|
||||
MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
User.hasOne(ClickTtAccount, { foreignKey: 'userId', as: 'clickTtAccount' });
|
||||
ClickTtAccount.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
User.hasMany(MyTischtennisUpdateHistory, { foreignKey: 'userId', as: 'updateHistory' });
|
||||
MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
@@ -409,28 +365,6 @@ ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
// Training Times
|
||||
TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' });
|
||||
TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' });
|
||||
Club.hasMany(TrainingCancellation, { foreignKey: 'clubId', as: 'trainingCancellations' });
|
||||
TrainingCancellation.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Club.hasMany(CalendarEvent, { foreignKey: 'clubId', as: 'calendarEvents' });
|
||||
CalendarEvent.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
|
||||
// Billing
|
||||
Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' });
|
||||
BillingTemplate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
BillingTemplate.hasMany(BillingTemplateField, { foreignKey: 'templateId', as: 'fields' });
|
||||
BillingTemplateField.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template' });
|
||||
Club.hasMany(BillingRun, { foreignKey: 'clubId', as: 'billingRuns' });
|
||||
BillingRun.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
BillingTemplate.hasMany(BillingRun, { foreignKey: 'templateId', as: 'runs' });
|
||||
BillingRun.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template' });
|
||||
BillingRun.hasMany(BillingDocument, { foreignKey: 'runId', as: 'documents' });
|
||||
BillingDocument.belongsTo(BillingRun, { foreignKey: 'runId', as: 'run' });
|
||||
BillingDocument.hasMany(BillingDocumentValue, { foreignKey: 'billingDocumentId', as: 'values' });
|
||||
BillingDocumentValue.belongsTo(BillingDocument, { foreignKey: 'billingDocumentId', as: 'document' });
|
||||
Club.hasMany(BillingUserSetting, { foreignKey: 'clubId', as: 'billingUserSettings' });
|
||||
BillingUserSetting.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
User.hasMany(BillingUserSetting, { foreignKey: 'userId', as: 'billingUserSettings' });
|
||||
BillingUserSetting.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
export {
|
||||
User,
|
||||
@@ -456,7 +390,6 @@ export {
|
||||
League,
|
||||
Team,
|
||||
ClubTeam,
|
||||
ClubTeamMember,
|
||||
TeamDocument,
|
||||
Group,
|
||||
GroupActivity,
|
||||
@@ -474,28 +407,14 @@ export {
|
||||
OfficialCompetition,
|
||||
OfficialCompetitionMember,
|
||||
MyTischtennis,
|
||||
ClickTtAccount,
|
||||
MyTischtennisUpdateHistory,
|
||||
MyTischtennisFetchLog,
|
||||
ApiLog,
|
||||
MemberTransferConfig,
|
||||
MemberContact,
|
||||
MemberImage,
|
||||
MemberGroupPhoto,
|
||||
MemberTtrHistory,
|
||||
MemberPlayInterest,
|
||||
MemberOrder,
|
||||
MemberOrderHistory,
|
||||
TrainingGroup,
|
||||
MemberTrainingGroup,
|
||||
ClubDisabledPresetGroup,
|
||||
TrainingTime,
|
||||
TrainingCancellation,
|
||||
CalendarEvent,
|
||||
BillingTemplate,
|
||||
BillingTemplateField,
|
||||
BillingRun,
|
||||
BillingDocument,
|
||||
BillingDocumentValue,
|
||||
BillingUserSetting,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import express from 'express';
|
||||
import {
|
||||
getTournaments,
|
||||
addTournament,
|
||||
addMiniChampionship,
|
||||
updateTournament,
|
||||
addParticipant,
|
||||
getParticipants,
|
||||
@@ -19,22 +18,17 @@ import {
|
||||
manualAssignGroups,
|
||||
resetGroups,
|
||||
resetMatches,
|
||||
cleanupOrphanedMatches,
|
||||
removeParticipant,
|
||||
updateParticipantSeeded,
|
||||
setParticipantGaveUp,
|
||||
deleteMatchResult,
|
||||
reopenMatch,
|
||||
deleteKnockoutMatches,
|
||||
setMatchActive,
|
||||
setMatchTableNumber,
|
||||
addExternalParticipant,
|
||||
getExternalParticipants,
|
||||
removeExternalParticipant,
|
||||
updateExternalParticipantSeeded,
|
||||
setExternalParticipantGaveUp,
|
||||
getTournamentClasses,
|
||||
getInternalTournamentStats,
|
||||
addTournamentClass,
|
||||
updateTournamentClass,
|
||||
deleteTournamentClass,
|
||||
@@ -46,7 +40,6 @@ import {
|
||||
updatePairing,
|
||||
deletePairing,
|
||||
} from '../controllers/tournamentController.js';
|
||||
import { mergeClassesIntoPool, resetPool } from '../controllers/tournamentController.js';
|
||||
import {
|
||||
getStages,
|
||||
upsertStages,
|
||||
@@ -56,31 +49,23 @@ import { authenticate } from '../middleware/authMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/internal-stats/:clubId', authenticate, getInternalTournamentStats);
|
||||
|
||||
router.post('/participant', authenticate, addParticipant);
|
||||
router.post('/participants', authenticate, getParticipants);
|
||||
router.delete('/participant', authenticate, removeParticipant);
|
||||
router.put('/participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateParticipantSeeded);
|
||||
router.put('/participant/:clubId/:tournamentId/:participantId/gave-up', authenticate, setParticipantGaveUp);
|
||||
router.post('/modus', authenticate, setModus);
|
||||
router.post('/groups/reset', authenticate, resetGroups);
|
||||
router.post('/matches/reset', authenticate, resetMatches);
|
||||
router.post('/matches/cleanup-orphaned', authenticate, cleanupOrphanedMatches);
|
||||
router.put('/groups', authenticate, createGroups);
|
||||
router.post('/groups/create', authenticate, createGroupsPerClass);
|
||||
router.post('/groups', authenticate, fillGroups);
|
||||
router.post('/matches/create', authenticate, createGroupMatches);
|
||||
// Pools
|
||||
router.post('/pools/merge', authenticate, mergeClassesIntoPool);
|
||||
router.post('/pools/reset', authenticate, resetPool);
|
||||
router.get('/groups', authenticate, getGroups);
|
||||
router.post('/match/result', authenticate, addMatchResult);
|
||||
router.delete('/match/result', authenticate, deleteMatchResult);
|
||||
router.post("/match/reopen", authenticate, reopenMatch);
|
||||
router.post('/match/finish', authenticate, finishMatch);
|
||||
router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive);
|
||||
router.put('/match/:clubId/:tournamentId/:matchId/table', authenticate, setMatchTableNumber);
|
||||
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
|
||||
router.post('/knockout', authenticate, startKnockout);
|
||||
router.delete("/matches/knockout", authenticate, deleteKnockoutMatches);
|
||||
@@ -93,7 +78,6 @@ router.post('/external-participant', authenticate, addExternalParticipant);
|
||||
router.post('/external-participants', authenticate, getExternalParticipants);
|
||||
router.delete('/external-participant', authenticate, removeExternalParticipant);
|
||||
router.put('/external-participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateExternalParticipantSeeded);
|
||||
router.put('/external-participant/:clubId/:tournamentId/:participantId/gave-up', authenticate, setExternalParticipantGaveUp);
|
||||
|
||||
// Tournament Classes
|
||||
router.get('/classes/:clubId/:tournamentId', authenticate, getTournamentClasses);
|
||||
@@ -113,9 +97,6 @@ router.get('/stages', authenticate, getStages);
|
||||
router.put('/stages', authenticate, upsertStages);
|
||||
router.post('/stages/advance', authenticate, advanceStage);
|
||||
|
||||
// Minimeisterschaft anlegen (vor :clubId, damit 'mini' nicht als clubId matcht)
|
||||
router.post('/mini', authenticate, addMiniChampionship);
|
||||
|
||||
// Muss NACH allen festen Pfaden stehen, sonst matcht z.B. '/stages' als clubId='stages'
|
||||
router.get('/:clubId', authenticate, getTournaments);
|
||||
router.post('/', authenticate, addTournament);
|
||||
|
||||
@@ -2,7 +2,6 @@ import cron from 'node-cron';
|
||||
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
|
||||
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
|
||||
import apiLogService from './apiLogService.js';
|
||||
import memberService from './memberService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class SchedulerService {
|
||||
@@ -25,10 +24,10 @@ class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
async runMatchResultsFetchJob(isAutomatic = true, options = {}) {
|
||||
async runMatchResultsFetchJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await autoFetchMatchResultsService.executeAutomaticFetch(options);
|
||||
const result = await autoFetchMatchResultsService.executeAutomaticFetch();
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('match_results', true, result || { success: true }, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
@@ -39,20 +38,6 @@ class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
async runInactiveMemberCleanupJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await memberService.cleanupInactiveMembersWithoutRecentTraining({ inactiveDays: 365 });
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('member_cleanup', true, result || { success: true }, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('member_cleanup', false, { success: false }, executionTime, error?.message || String(error));
|
||||
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
@@ -80,16 +65,8 @@ class SchedulerService {
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
const inactiveMemberCleanupJob = cron.schedule('45 6 * * *', async () => {
|
||||
devLog('[Scheduler] Running inactive member cleanup job...');
|
||||
await this.runInactiveMemberCleanupJob(true);
|
||||
}, {
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
this.jobs.set('ratingUpdates', ratingUpdateJob);
|
||||
this.jobs.set('matchResults', matchResultsJob);
|
||||
this.jobs.set('inactiveMemberCleanup', inactiveMemberCleanupJob);
|
||||
|
||||
this.isRunning = true;
|
||||
const now = new Date();
|
||||
@@ -138,6 +115,7 @@ class SchedulerService {
|
||||
|
||||
/**
|
||||
* Manually trigger rating updates (for testing)
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerRatingUpdates() {
|
||||
devLog('[Scheduler] Manual rating updates trigger called');
|
||||
@@ -146,10 +124,11 @@ class SchedulerService {
|
||||
|
||||
/**
|
||||
* Manually trigger match results fetch (for testing)
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerMatchResultsFetch(options = {}) {
|
||||
async triggerMatchResultsFetch() {
|
||||
devLog('[Scheduler] Manual match results fetch trigger called');
|
||||
return await this.runMatchResultsFetchJob(false, options);
|
||||
return await this.runMatchResultsFetchJob(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
337
frontend/I18N_TODO.md
Normal file
337
frontend/I18N_TODO.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# i18n – Angleichungs-TODO
|
||||
|
||||
Stand der Kennzahlen: **2026-05-15** (Basis `de.json`: **2435** Blatt-Strings, `flatten` + `deepMerge` wie in `src/i18n/index.js`).
|
||||
|
||||
**Quelle der Wahrheit:** `src/i18n/locales/de.json`
|
||||
**Dateien:** `src/i18n/locales/<locale>.json`
|
||||
**Workflow:** [TRANSLATION_WORKFLOW.md](./TRANSLATION_WORKFLOW.md)
|
||||
|
||||
## Werkzeuge
|
||||
|
||||
| Befehl / Skript | Zweck |
|
||||
|-----------------|--------|
|
||||
| `cd frontend && npm run i18n:status` | Batch-/Namespace-Status (Kernbereiche) |
|
||||
| `cd frontend && npm run i18n:audit -- <locales>` | Fehlende Keys, `= de`, deutsche Marker, Platzhalter |
|
||||
| `node scripts/check-i18n-completeness.js` | Vollständige Tabelle aller Locales |
|
||||
| `node scripts/check-i18n-completeness.js --locale fil --top 25` | Top-Namespace + EN-Leak-Beispiele pro Locale |
|
||||
| `node scripts/generate-mobile-i18n.js` | Nach Web-Locales: Mobile `MobileStrings.kt` aktualisieren |
|
||||
| `scripts/.venv-i18n/bin/python scripts/fill-i18n-deep.py --locale <code>` | Übersetzung via deep-translator (mit Cache) |
|
||||
| `scripts/.venv-i18n/bin/python scripts/apply-i18n-cache.py <locales…>` | Cache + EN-Fallback ohne API (bei Rate-Limits) |
|
||||
| `node scripts/apply-cognate-overrides.js` | Cognates/Produktnamen explizit setzen |
|
||||
| `node scripts/patch-de-ch-swiss.js` | Nur ß→ss in `de-CH.json` |
|
||||
| `node scripts/update-i18n-todo-stats.js` | Kennzahlen-Tabellen in dieser Datei aktualisieren |
|
||||
| `scripts/.venv-i18n/bin/python scripts/fix-en-leaks.py <locales…>` | EN-Leaks (≈en-US) in Zielsprache übersetzen |
|
||||
| `bash scripts/run-fix-en-leaks.sh` | EN-Leak-Fix für `th` `tl` `fil` `fr` `es` `it` `pl` `ja` `zh` |
|
||||
| `node scripts/patch-en-gb-au.js` | UK-Rechtschreibung in `en-GB` / `en-AU` |
|
||||
| `node scripts/fill-de-extended-gaps.js` | Fehlende `billing`/`orders`-Keys in `de-extended` |
|
||||
|
||||
### Legende (Kennzahlen pro Locale)
|
||||
|
||||
| Spalte | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **explicit** | Blatt-Strings explizit in der Locale-JSON gesetzt |
|
||||
| **erbtsDE** | Kein Override → im UI noch **deutscher** Text |
|
||||
| **=de expl** | Override gesetzt, Wert **identisch** zu `de` (bereinigen) |
|
||||
| **≠de** | Abweichend von Deutsch (Zielsprache oder EN-Vorlage) |
|
||||
| **≈en-US** | Unter ≠de: Wert wie **en-US-Merge** (EN statt Zielsprache) |
|
||||
|
||||
### Globale Phasen (Reihenfolge)
|
||||
|
||||
- [x] **Phase A – Messbarkeit:** Kennzahlen 2026-05-15 (`check-i18n-completeness.js`, `update-i18n-todo-stats.js`).
|
||||
- [x] **Phase B – Englisch-Basis:** `en-US` / `en-GB` / `en-AU` (~52 `erbtsDE` Cognates).
|
||||
- [x] **Phase C – Kern-Namespaces:** per `fill-i18n-deep.py` + Cache für B2–B5.
|
||||
- [x] **Phase D – Große Rest-Namespaces:** per `apply-i18n-cache.py` (2026-05-15).
|
||||
- [x] **Phase E – Qualität:** `fix-en-leaks.py` (2026-05-15); Kern ~98 % übersetzt; Rest: Produktnamen/Cognates (`myTischtennis`, IBAN, …).
|
||||
- [x] **Phase F – Mobile:** `generate-mobile-i18n.js` (15 Locales, 2474 Keys).
|
||||
- [ ] **Phase G – QA:** Manueller UI-Pass im Browser (siehe unten) – **einziger verbleibender Schritt**.
|
||||
|
||||
### Batch-Vorschlag (Workflow)
|
||||
|
||||
| Batch | Locales |
|
||||
|-------|---------|
|
||||
| B1 | `en-US`, `en-GB`, `en-AU` |
|
||||
| B2 | `fr`, `es` |
|
||||
| B3 | `it`, `pl` |
|
||||
| B4 | `ja`, `zh` |
|
||||
| B5 | `th`, `tl`, `fil` |
|
||||
| B6 | `de-CH` |
|
||||
| B7 | `de-extended` (nur Abweichungen von `de`, bewusst) |
|
||||
|
||||
---
|
||||
|
||||
## `de` – Deutsch (Master)
|
||||
|
||||
**Datei:** `de.json` · **Rolle:** Source of truth · keine Übersetzung nötig.
|
||||
|
||||
| Kennzahl | Wert |
|
||||
|----------|------|
|
||||
| Blatt-Strings | 2435 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [ ] Neue Features immer zuerst in `de.json` pflegen.
|
||||
- [ ] Vor Release: prüfen, dass keine anderen Locales Keys haben, die in `de` fehlen (`check-i18n-completeness.js` / Audit).
|
||||
- [ ] Nach größeren `de`-Änderungen: abhängige Locales und Mobile-Generator einplanen.
|
||||
|
||||
---
|
||||
|
||||
## `de-CH` – Deutsch (Schweiz)
|
||||
|
||||
**Datei:** `de-CH.json` · **Label:** Deutsch (Schweiz)
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 1092 | 1343 | 715 | 377 | 2 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] **ß→ss:** `patch-de-ch-swiss.js` (40 Patches).
|
||||
- [x] **377** echte CH-Abweichungen (`≠de`) vorhanden.
|
||||
- [ ] **Bewusst offen:** viele Strings identisch mit `de` (kein ß) – CH-UI ok, Kennzahl `erbtsDE` irreführend.
|
||||
- [ ] Optional: weitere CH-Begriffe (z. B. „Training“ → „Training“) manuell nur wo nötig.
|
||||
- [x] `npm run build` ok.
|
||||
|
||||
---
|
||||
|
||||
## `de-extended` – Deutsch (erweitert)
|
||||
|
||||
**Datei:** `de-extended.json` · **Label:** Deutsch (erweitert)
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2448 | 0 | 2405 | 30 | 13 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] **Bewusst:** `= de` (2405) – Erweiterungs-Locale, kein Vollübersetzungsziel.
|
||||
- [x] **73** erbte → **0** (`fill-de-extended-gaps.js`, billing/orders).
|
||||
- [ ] **30** echte Abweichungen (`≠de`) bei Bedarf mit Product validieren.
|
||||
|
||||
---
|
||||
|
||||
## `en-US` – English (US)
|
||||
|
||||
**Datei:** `en-US.json` · **Label:** English (US) · **Batch:** B1 · **Referenz für EN-Leaks**
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2383 | 52 | 0 | 2383 | 2383 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] **1411** erbte deutsche Strings → vollständig ins Englisch (US) übersetzen (`fill-i18n-locales.js`, 2026-05-15).
|
||||
- [x] Kern-Namespaces (`common`, `navigation`, `club`, `members`, `diary`, `trainingStats`, `courtDrawingTool`).
|
||||
- [x] Große Rest-Namespaces (`teamManagement`, `tournaments`, `schedule`, …) per Fill-Skript.
|
||||
- [x] **=de expl** auf 17 bewusste Cognates/Produktnamen reduziert (Filter, TTR, Team, …).
|
||||
- [x] `npm run i18n:audit -- en-US` (0 missing) + `npm run build`.
|
||||
- [x] **52** `erbtsDE`: Cognates (IBAN, Team, OK) – im UI korrekt, Kennzahl akzeptiert.
|
||||
- [x] `en-GB` / `en-AU` aus `en-US` übernommen.
|
||||
|
||||
---
|
||||
|
||||
## `en-GB` – English (UK)
|
||||
|
||||
**Datei:** `en-GB.json` · **Label:** English (UK) · **Batch:** B1
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2383 | 52 | 0 | 2383 | 2360 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] **1164** erbte deutsche Strings → aus `en-US` übernommen (2026-05-15).
|
||||
- [x] **UK-Anpassungen:** `patch-en-gb-au.js` (colour/centre/…).
|
||||
- [x] `npm run i18n:audit -- en-GB` (0 missing, 0 german markers in Kern) + `npm run build`.
|
||||
- [ ] Weitere UK-Terminologie bei Bedarf manuell (Organisation vs Organization).
|
||||
|
||||
---
|
||||
|
||||
## `en-AU` – English (AU)
|
||||
|
||||
**Datei:** `en-AU.json` · **Label:** English (AU) · **Batch:** B1
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2383 | 52 | 0 | 2383 | 2361 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Wie `en-US`: erbte deutsche Strings aus `en-US`/`en-GB` übernommen (2026-05-15).
|
||||
- [x] **AU:** wie UK (`patch-en-gb-au.js`).
|
||||
- [x] `npm run i18n:audit -- en-AU` + `npm run build`.
|
||||
|
||||
---
|
||||
|
||||
## `es` – Español
|
||||
|
||||
**Datei:** `es.json` · **Label:** Español · **Batch:** B2
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2394 | 41 | 0 | 2394 | 31 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Kern-Namespaces + Rest per `fill-i18n-deep.py` / `apply-i18n-cache.py` (2026-05-15).
|
||||
- [x] **1445** erbte deutsche Strings → **41** `erbtsDE` (Cognates).
|
||||
- [x] **EN-Leaks:** 215 → **31** (`fix-en-leaks.py`); Rest: Produktnamen.
|
||||
- [x] `npm run i18n:audit -- es` (0 missing, 0 german markers).
|
||||
- [x] Kern-Namespaces **98,1 %** übersetzt.
|
||||
|
||||
---
|
||||
|
||||
## `fr` – Français
|
||||
|
||||
**Datei:** `fr.json` · **Label:** Français · **Batch:** B2
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2388 | 47 | 0 | 2388 | 72 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Kern-Namespaces + Rest (Cache/Deep-Fill, 2026-05-15).
|
||||
- [x] **1445** erbte deutsche Strings → **47** `erbtsDE` (Cognates).
|
||||
- [x] **EN-Leaks:** 261 → **72** (`fix-en-leaks.py`).
|
||||
- [x] `npm run i18n:audit -- fr` (0 missing, 0 german markers).
|
||||
- [x] Kern **97,9 %** übersetzt.
|
||||
|
||||
---
|
||||
|
||||
## `it` – Italiano
|
||||
|
||||
**Datei:** `it.json` · **Label:** Italiano · **Batch:** B3
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2392 | 43 | 0 | 2392 | 22 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Kern-Namespaces + Rest (2026-05-15).
|
||||
- [x] **1445** → **43** `erbtsDE` (Cognates).
|
||||
- [x] **EN-Leaks:** 209 → **22**.
|
||||
- [x] `npm run i18n:audit -- it` + Kern **98,1 %**.
|
||||
|
||||
---
|
||||
|
||||
## `pl` – Polski
|
||||
|
||||
**Datei:** `pl.json` · **Label:** Polski · **Batch:** B3
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2384 | 51 | 0 | 2384 | 26 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Kern-Namespaces + Rest (2026-05-15). **Hang bei 950/1248:** Cache war vollständig → `apply-i18n-cache.py` statt API.
|
||||
- [x] **1445** → **51** `erbtsDE` (Cognates).
|
||||
- [x] **EN-Leaks:** 211 → **26**.
|
||||
- [x] `npm run i18n:audit -- pl` + Kern **97,5 %**.
|
||||
|
||||
---
|
||||
|
||||
## `ja` – 日本語
|
||||
|
||||
**Datei:** `ja.json` · **Label:** 日本語 · **Batch:** B4
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2393 | 42 | 0 | 2393 | 12 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Kern-Namespaces + Rest (2026-05-15).
|
||||
- [x] **1445** → **42** `erbtsDE`.
|
||||
- [x] **EN-Leaks:** 283 → **12**.
|
||||
- [x] `npm run i18n:audit -- ja` + Kern **98,1 %**.
|
||||
|
||||
---
|
||||
|
||||
## `zh` – 中文
|
||||
|
||||
**Datei:** `zh.json` · **Label:** 中文 · **Batch:** B4
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2393 | 42 | 0 | 2393 | 11 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Kern-Namespaces + Rest (2026-05-15).
|
||||
- [x] **1445** → **42** `erbtsDE`.
|
||||
- [x] **EN-Leaks:** 283 → **11**.
|
||||
- [x] Kern **98,1 %** übersetzt.
|
||||
|
||||
---
|
||||
|
||||
## `th` – ไทย
|
||||
|
||||
**Datei:** `th.json` · **Label:** ไทย · **Batch:** B5
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2384 | 51 | 0 | 2384 | 8 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Kern-Namespaces + Rest (2026-05-15).
|
||||
- [x] **1445** → **51** `erbtsDE`.
|
||||
- [x] **EN-Leaks:** 934 → **8** (`fix-en-leaks.py`).
|
||||
- [x] `npm run i18n:audit -- th` + Kern **98,1 %**.
|
||||
|
||||
---
|
||||
|
||||
## `tl` – Tagalog
|
||||
|
||||
**Datei:** `tl.json` · **Label:** Tagalog · **Batch:** B5 · (oft als „Bisaya“-Eindruck im UI: viel EN/DE)
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2384 | 51 | 0 | 2384 | 123 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] `app.*`, `auth.*`, `calendar.*` + Kern-Namespaces (2026-05-15).
|
||||
- [x] **1445** → **51** `erbtsDE`.
|
||||
- [x] **EN-Leaks:** 1755 → **123** (`fix-en-leaks.py`).
|
||||
- [ ] Optional: `tl`/`fil`-Glossar (gemeinsame Begriffe).
|
||||
- [x] Kern **98,1 %** übersetzt.
|
||||
|
||||
---
|
||||
|
||||
## `fil` – Filipino
|
||||
|
||||
**Datei:** `fil.json` · **Label:** Filipino · **Batch:** B5
|
||||
|
||||
| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| 2384 | 51 | 0 | 2384 | 122 |
|
||||
|
||||
### Aufgaben
|
||||
|
||||
- [x] Sichtbarkeit + Kern-Namespaces (2026-05-15).
|
||||
- [x] **1445** → **51** `erbtsDE`.
|
||||
- [x] **EN-Leaks:** 1754 → **122** (`fix-en-leaks.py`).
|
||||
- [x] Kern **98,1 %** übersetzt.
|
||||
|
||||
---
|
||||
|
||||
## Nach Abschluss einer Locale
|
||||
|
||||
- [x] Kennzahlen in dieser Datei aktualisieren (2026-05-15).
|
||||
- [x] `node scripts/generate-mobile-i18n.js`.
|
||||
- [x] `audit-i18n.js`: prüft jetzt mit `deepMerge(de, locale)` (keine falschen „missing keys“).
|
||||
|
||||
## Phase G – Manueller UI-Check (offen)
|
||||
|
||||
In der App nacheinander mit Sprachumschaltung prüfen:
|
||||
|
||||
- [ ] `/members` – Tabelle, Dialoge, Tooltips
|
||||
- [ ] `/diary` – Trainingstag, PDF, Gruppen
|
||||
- [ ] `/training-stats` – Kennzahlen, Filter
|
||||
- [ ] `th` / `tl` / `fil` – Landing (`home`), Login (`auth`), Kalender
|
||||
- [ ] `de-CH` – Stichprobe ss/ß (z. B. „Strasse“)
|
||||
|
||||
Details: [TRANSLATION_WORKFLOW.md](./TRANSLATION_WORKFLOW.md)
|
||||
134
frontend/package-lock.json
generated
134
frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitalets/google-translate-api": "^9.2.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
@@ -1693,6 +1694,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.2.tgz",
|
||||
"integrity": "sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1720,6 +1728,21 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitalets/google-translate-api": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitalets/google-translate-api/-/google-translate-api-9.2.1.tgz",
|
||||
"integrity": "sha512-zlwQWSjXUZhbZQ6qwtIQ7GdYXFQmJ4wYqzcrYJUxtvzQQwUP+uKUb/SRJaBOQuBntjBjzcdcJoLFrpCKUbIkOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "^1.8.2",
|
||||
"http-errors": "^2.0.0",
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
|
||||
@@ -2179,6 +2202,16 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
@@ -2896,6 +2929,27 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2940,6 +2994,13 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -3223,6 +3284,27 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-readable-to-web-readable-stream": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||
@@ -3611,6 +3693,13 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -3704,6 +3793,16 @@
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -3811,6 +3910,23 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -4107,6 +4223,24 @@
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"i18n:audit": "node scripts/audit-i18n.js",
|
||||
"i18n:status": "node scripts/translation-status.js"
|
||||
"i18n:status": "node scripts/translation-status.js",
|
||||
"i18n:fill": "node ../scripts/fill-i18n-locales.js --all --delay 500",
|
||||
"i18n:fill:deep": "PYTHONUNBUFFERED=1 ../scripts/.venv-i18n/bin/python -u ../scripts/fill-i18n-deep.py --all --delay 0.2",
|
||||
"i18n:fix-leaks": "PYTHONUNBUFFERED=1 ../scripts/.venv-i18n/bin/python -u ../scripts/fix-en-leaks.py",
|
||||
"i18n:fix-leaks:all": "bash ../scripts/run-fix-en-leaks.sh",
|
||||
"i18n:completeness": "node ../scripts/check-i18n-completeness.js",
|
||||
"i18n:update-todo": "node ../scripts/update-i18n-todo-stats.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.3",
|
||||
@@ -27,6 +33,7 @@
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitalets/google-translate-api": "^9.2.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
|
||||
@@ -72,6 +72,28 @@ function loadJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function deepMerge(base, override) {
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) {
|
||||
return override ?? base;
|
||||
}
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override || {})) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
result[key] &&
|
||||
typeof result[key] === 'object' &&
|
||||
!Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getLocaleCode(localeFile) {
|
||||
return localeFile.replace(/\.json$/, '');
|
||||
}
|
||||
@@ -84,8 +106,10 @@ function extractPlaceholders(value) {
|
||||
}
|
||||
|
||||
function auditLocale(localeFile, baseFlat) {
|
||||
const de = loadJson(path.join(LOCALES_DIR, BASE_LOCALE));
|
||||
const data = loadJson(path.join(LOCALES_DIR, localeFile));
|
||||
const flat = flatten(pickNamespaces(data));
|
||||
const merged = deepMerge(JSON.parse(JSON.stringify(de)), data);
|
||||
const flat = flatten(pickNamespaces(merged));
|
||||
const localeCode = getLocaleCode(localeFile);
|
||||
const missing = Object.keys(baseFlat).filter(key => !(key in flat));
|
||||
const sameAsDe = Object.keys(baseFlat).filter(key => flat[key] === baseFlat[key]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,126 +8,57 @@
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
<section v-if="isGroupTournament" class="group-controls">
|
||||
<div class="groups-card">
|
||||
<div class="groups-card-header">
|
||||
<h4>{{ $t('tournaments.createGroups') }}</h4>
|
||||
<div class="groups-card-meta">
|
||||
<span class="groups-chip">{{ groups.length }} {{ $t('tournaments.group') }}</span>
|
||||
<span class="groups-chip">{{ filteredGroupMatches.length }} {{ $t('tournaments.groupMatches') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="groups-settings">
|
||||
<label>
|
||||
{{ $t('tournaments.advancersPerGroup') }}:
|
||||
<input
|
||||
type="number"
|
||||
:value="advancingPerGroup"
|
||||
min="1"
|
||||
@input="$emit('update:advancingPerGroup', normalizeNumberInput($event.target.value, { min: 1, allowEmpty: true }))"
|
||||
@keydown.enter.prevent="$event.target.blur()"
|
||||
@blur="onAdvancingPerGroupBlur($event.target.value)"
|
||||
<label>
|
||||
{{ $t('tournaments.advancersPerGroup') }}:
|
||||
<input type="number" :value="advancingPerGroup" @input="$emit('update:advancingPerGroup', parseInt($event.target.value))" min="1" @change="$emit('modus-change')" />
|
||||
</label>
|
||||
<label style="margin-left:1em">
|
||||
{{ $t('tournaments.maxGroupSize') }}:
|
||||
<input type="number" :value="maxGroupSize" @input="$emit('update:maxGroupSize', parseInt($event.target.value))" min="1" />
|
||||
</label>
|
||||
|
||||
<div v-if="selectedViewClass !== null && selectedViewClass !== undefined" class="groups-per-class">
|
||||
<h4>{{ $t('tournaments.groupsPerClass') }}</h4>
|
||||
<p class="groups-per-class-hint">{{ $t('tournaments.groupsPerClassHint') }}</p>
|
||||
<div class="class-group-config">
|
||||
<label class="class-group-label">
|
||||
<span class="class-group-name">
|
||||
{{ selectedViewClass === '__none__' ? $t('tournaments.withoutClass') : getClassName(selectedViewClass) }}
|
||||
</span>
|
||||
<span v-if="selectedViewClass !== '__none__'" class="class-group-type" :class="{ 'doubles': isClassDoubles(selectedViewClass) }">
|
||||
({{ isClassDoubles(selectedViewClass) ? $t('tournaments.doubles') : $t('tournaments.singles') }})
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="groupsPerClassInput"
|
||||
@input="$emit('update:groupsPerClassInput', parseInt($event.target.value))"
|
||||
min="0"
|
||||
@change="$emit('group-count-change')"
|
||||
class="class-group-input"
|
||||
:placeholder="$t('tournaments.numberOfGroups')"
|
||||
/>
|
||||
<span class="class-group-unit">{{ $t('tournaments.group') }}</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('tournaments.maxGroupSize') }}:
|
||||
<input
|
||||
type="number"
|
||||
:value="maxGroupSize"
|
||||
min="1"
|
||||
@input="$emit('update:maxGroupSize', normalizeNumberInput($event.target.value, { min: 1, allowEmpty: true }))"
|
||||
@keydown.enter.prevent="$event.target.blur()"
|
||||
@blur="onMaxGroupSizeBlur($event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedViewClass !== null && selectedViewClass !== undefined" class="groups-per-class">
|
||||
<h4>{{ $t('tournaments.groupsPerClass') }}</h4>
|
||||
<p class="groups-per-class-hint">{{ $t('tournaments.groupsPerClassHint') }}</p>
|
||||
<div class="class-group-config">
|
||||
<label class="class-group-label">
|
||||
<span class="class-group-name">
|
||||
{{ selectedViewClass === '__none__' ? $t('tournaments.withoutClass') : getClassName(selectedViewClass) }}
|
||||
</span>
|
||||
<span v-if="selectedViewClass !== '__none__'" class="class-group-type" :class="{ 'doubles': isClassDoubles(selectedViewClass) }">
|
||||
({{ isClassDoubles(selectedViewClass) ? $t('tournaments.doubles') : $t('tournaments.singles') }})
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="groupsPerClassInput"
|
||||
@input="$emit('update:groupsPerClassInput', normalizeNumberInput($event.target.value, { min: 0, allowEmpty: true }))"
|
||||
@keydown.enter.prevent="$event.target.blur()"
|
||||
min="0"
|
||||
@blur="onGroupsPerClassBlur($event.target.value)"
|
||||
class="class-group-input"
|
||||
:placeholder="$t('tournaments.numberOfGroups')"
|
||||
/>
|
||||
<span class="class-group-unit">{{ $t('tournaments.group') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="groups-per-class">
|
||||
<label>
|
||||
{{ $t('tournaments.numberOfGroups') }}:
|
||||
<input
|
||||
type="number"
|
||||
:value="numberOfGroups"
|
||||
min="1"
|
||||
@input="$emit('update:numberOfGroups', normalizeNumberInput($event.target.value, { min: 1, allowEmpty: true }))"
|
||||
@keydown.enter.prevent="$event.target.blur()"
|
||||
@blur="onNumberOfGroupsBlur($event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="groups-actions">
|
||||
<button @click="$emit('create-groups')" class="btn-primary">{{ $t('tournaments.createGroups') }}</button>
|
||||
<button @click="$emit('randomize-groups')" class="btn-secondary">{{ $t('tournaments.randomizeGroups') }}</button>
|
||||
<button @click="$emit('reset-groups')" class="trash-btn">{{ $t('tournaments.resetGroups') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="groups-per-class">
|
||||
<label>
|
||||
{{ $t('tournaments.numberOfGroups') }}:
|
||||
<input
|
||||
type="number"
|
||||
:value="numberOfGroups"
|
||||
min="1"
|
||||
@input="$emit('update:numberOfGroups', Math.max(1, parseInt($event.target.value || '1', 10) || 1))"
|
||||
@change="$emit('group-count-change')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button @click="$emit('create-groups')">{{ $t('tournaments.createGroups') }}</button>
|
||||
<button @click="$emit('randomize-groups')">{{ $t('tournaments.randomizeGroups') }}</button>
|
||||
<button @click="$emit('reset-groups')">{{ $t('tournaments.resetGroups') }}</button>
|
||||
</section>
|
||||
|
||||
<details v-if="isGroupTournament" class="merge-pools-box">
|
||||
<summary class="merge-summary">{{ $t('tournaments.mergeClasses') }}</summary>
|
||||
<div class="merge-pools-row">
|
||||
<label>
|
||||
{{ $t('tournaments.sourceClass') }}:
|
||||
<select v-model="mergeSourceClassId">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="c in tournamentClasses" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('tournaments.targetClass') }}:
|
||||
<select v-model="mergeTargetClassId">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="c in tournamentClasses" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="mergePoolsReady">
|
||||
<label>
|
||||
{{ $t('tournaments.strategy') }}:
|
||||
<select v-model="mergeStrategy">
|
||||
<option value="singleGroup">{{ $t('tournaments.mergeSingleGroup') }}</option>
|
||||
<option value="distribute">{{ $t('tournaments.mergeDistribute') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="mergeSourceAsAK" />
|
||||
{{ mergeOutOfCompetitionLabel }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div class="merge-pools-actions">
|
||||
<button @click="requestMergePools" :disabled="!mergeSourceClassId || !mergeTargetClassId || String(mergeSourceClassId)===String(mergeTargetClassId)">
|
||||
{{ $t('tournaments.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<section v-if="groups.length" class="groups-overview">
|
||||
<section v-if="groups.length" class="groups-overview">
|
||||
<h3>{{ $t('tournaments.groupsOverview') }}</h3>
|
||||
<template v-for="(classGroups, classId) in groupsByClass" :key="classId">
|
||||
<template v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))">
|
||||
@@ -148,17 +79,17 @@
|
||||
<th>{{ $t('tournaments.sets') }}</th>
|
||||
<th>{{ $t('tournaments.diff') }}</th>
|
||||
<th>{{ $t('tournaments.pointsRatio') }}</th>
|
||||
<th v-for="(opponent, idx) in groupRankingsForGroup(group)" :key="`opp-${opponent.id}`">
|
||||
<th v-for="(opponent, idx) in groupRankings[group.groupId]" :key="`opp-${opponent.id}`">
|
||||
G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}
|
||||
</th>
|
||||
<th>{{ $t('tournaments.livePosition') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(pl, idx) in groupRankingsForGroup(group)" :key="pl.id">
|
||||
<tr v-for="(pl, idx) in groupRankings[group.groupId]" :key="pl.id">
|
||||
<td><strong>G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}</strong></td>
|
||||
<td>{{ pl.position }}.</td>
|
||||
<td><span v-if="pl.seeded" class="seeded-star">★</span>{{ pl.name }}<span v-if="pl.gaveUp" class="gave-up-badge" :title="$t('tournaments.gaveUpHint')">{{ $t('tournaments.gaveUp') }}</span></td>
|
||||
<td><span v-if="pl.seeded" class="seeded-star">★</span>{{ pl.name }}</td>
|
||||
<td>{{ (pl.matchesWon || 0) * 2 }}:{{ (pl.matchesLost || 0) * 2 }}</td>
|
||||
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
|
||||
<td>
|
||||
@@ -170,10 +101,10 @@
|
||||
({{ (Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) >= 0 ? '+' : '' }}{{ Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0) }})
|
||||
</span>
|
||||
</td>
|
||||
<td v-for="(opponent, oppIdx) in groupRankingsForGroup(group)"
|
||||
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
|
||||
:key="`match-${pl.id}-${opponent.id}`"
|
||||
:class="['match-cell', { 'clickable': idx !== oppIdx, 'active-group-cell': activeGroupCells.includes(`match-${pl.id}-${opponent.id}`), 'diagonal-cell': idx === oppIdx }]"
|
||||
@click="idx !== oppIdx ? handleMatchClick(pl.id, opponent.id, group.groupId) : null">
|
||||
@click="idx !== oppIdx ? $emit('highlight-match', pl.id, opponent.id, group.groupId) : null">
|
||||
<span v-if="idx === oppIdx" class="diagonal"></span>
|
||||
<span v-else-if="getMatchLiveResult(pl.id, opponent.id, group.groupId)"
|
||||
:class="getMatchCellClasses(pl.id, opponent.id, group.groupId)">
|
||||
@@ -181,25 +112,19 @@
|
||||
</span>
|
||||
<span v-else class="no-match">-</span>
|
||||
</td>
|
||||
<td>{{ getLivePosition(pl.id, group) }}.</td>
|
||||
<td>{{ getLivePosition(pl.id, group.groupId) }}.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="filteredGroupMatches.length === 0" class="reset-controls reset-controls-spaced">
|
||||
<div v-if="filteredGroupMatches.length === 0" class="reset-controls" style="margin-top:1rem">
|
||||
<button @click="$emit('create-matches')" class="btn-primary">
|
||||
▶️ {{ $t('tournaments.createGroupMatches') }}
|
||||
</button>
|
||||
<button @click="$emit('cleanup-orphaned-matches')" class="btn-secondary">
|
||||
🧹 {{ $t('tournaments.cleanupOrphanedMatches') }}
|
||||
▶️ Gruppenspiele berechnen
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="filteredGroupMatches.length > 0" class="reset-controls reset-controls-spaced">
|
||||
<button @click="$emit('cleanup-orphaned-matches')" class="btn-secondary">
|
||||
🧹 {{ $t('tournaments.cleanupOrphanedMatches') }}
|
||||
</button>
|
||||
<div v-if="filteredGroupMatches.length > 0" class="reset-controls" style="margin-top:1rem">
|
||||
<button @click="$emit('reset-matches')" class="trash-btn">
|
||||
🗑️ {{ $t('tournaments.resetGroupMatches') }}
|
||||
</button>
|
||||
@@ -234,19 +159,19 @@ export default {
|
||||
required: true
|
||||
},
|
||||
advancingPerGroup: {
|
||||
type: [Number, String],
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
maxGroupSize: {
|
||||
type: [Number, String],
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
groupsPerClassInput: {
|
||||
type: [Number, String],
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
numberOfGroups: {
|
||||
type: [Number, String],
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
groups: {
|
||||
@@ -282,82 +207,18 @@ export default {
|
||||
'randomize-groups',
|
||||
'reset-groups',
|
||||
'reset-matches',
|
||||
'cleanup-orphaned-matches',
|
||||
'create-matches',
|
||||
'highlight-match',
|
||||
'go-to-match',
|
||||
'merge-pools'
|
||||
'create-matches',
|
||||
'highlight-match'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
// Merge-UI (Pools)
|
||||
mergeSourceClassId: null,
|
||||
mergeTargetClassId: null,
|
||||
mergeStrategy: 'distribute', // 'singleGroup' | 'distribute'
|
||||
mergeSourceAsAK: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredGroupMatches() {
|
||||
return this.filterMatchesByClass(this.matches.filter(m => m.round === 'group'));
|
||||
},
|
||||
mergePoolsReady() {
|
||||
return !!(
|
||||
this.mergeSourceClassId &&
|
||||
this.mergeTargetClassId &&
|
||||
String(this.mergeSourceClassId) !== String(this.mergeTargetClassId)
|
||||
);
|
||||
},
|
||||
mergeSourceClassName() {
|
||||
const id = this.mergeSourceClassId;
|
||||
if (!id) return '';
|
||||
const c = (this.tournamentClasses || []).find(x => String(x.id) === String(id));
|
||||
return c ? c.name : '';
|
||||
},
|
||||
mergeOutOfCompetitionLabel() {
|
||||
const base = this.$t && this.$t('tournaments.outOfCompetition');
|
||||
// Wenn Übersetzung vorhanden ist, ersetzen wir nur das "A" nicht zuverlässig -> lieber dynamisch bauen
|
||||
const src = this.mergeSourceClassName || 'Quelle';
|
||||
return `Spieler aus ${src} außer Konkurrenz`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
normalizeNumberInput(rawValue, { min = 0, allowEmpty = false } = {}) {
|
||||
if (rawValue === '' || rawValue === null || rawValue === undefined) {
|
||||
return allowEmpty ? '' : min;
|
||||
}
|
||||
const parsed = parseInt(String(rawValue), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return allowEmpty ? '' : min;
|
||||
}
|
||||
return Math.max(min, parsed);
|
||||
},
|
||||
onGroupsPerClassBlur(rawValue) {
|
||||
const normalized = this.normalizeNumberInput(rawValue, { min: 0, allowEmpty: false });
|
||||
this.$emit('update:groupsPerClassInput', normalized);
|
||||
this.$emit('group-count-change');
|
||||
},
|
||||
onNumberOfGroupsBlur(rawValue) {
|
||||
const normalized = this.normalizeNumberInput(rawValue, { min: 1, allowEmpty: false });
|
||||
this.$emit('update:numberOfGroups', normalized);
|
||||
this.$emit('group-count-change');
|
||||
},
|
||||
onAdvancingPerGroupBlur(rawValue) {
|
||||
const normalized = this.normalizeNumberInput(rawValue, { min: 1, allowEmpty: false });
|
||||
this.$emit('update:advancingPerGroup', normalized);
|
||||
this.$emit('modus-change');
|
||||
},
|
||||
onMaxGroupSizeBlur(rawValue) {
|
||||
const normalized = this.normalizeNumberInput(rawValue, { min: 1, allowEmpty: true });
|
||||
this.$emit('update:maxGroupSize', normalized);
|
||||
},
|
||||
groupRankingsForGroup(group) {
|
||||
const key = `${group.groupId}-${group.classId ?? 'null'}`;
|
||||
return this.groupRankings[key] || [];
|
||||
},
|
||||
filterMatchesByClass(matches) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') {
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return matches;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
@@ -379,7 +240,7 @@ export default {
|
||||
},
|
||||
shouldShowClass(classId) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') {
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return true;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
@@ -416,14 +277,11 @@ export default {
|
||||
return classItem ? Boolean(classItem.isDoubles) : false;
|
||||
},
|
||||
getMatchLiveResult(player1Id, player2Id, groupId) {
|
||||
const id1 = (m) => m.player1?.id ?? m.player1Id;
|
||||
const id2 = (m) => m.player2?.id ?? m.player2Id;
|
||||
const match = this.matches.find(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
id1(m) != null && id2(m) != null &&
|
||||
((id1(m) === player1Id && id2(m) === player2Id) ||
|
||||
(id1(m) === player2Id && id2(m) === player1Id))
|
||||
((m.player1.id === player1Id && m.player2.id === player2Id) ||
|
||||
(m.player1.id === player2Id && m.player2.id === player1Id))
|
||||
);
|
||||
|
||||
if (!match) return null;
|
||||
@@ -473,9 +331,8 @@ export default {
|
||||
|
||||
return classes;
|
||||
},
|
||||
getLivePosition(playerId, group) {
|
||||
const groupId = group && typeof group === 'object' ? group.groupId : group;
|
||||
const groupPlayers = this.groupRankingsForGroup && group && typeof group === 'object' ? this.groupRankingsForGroup(group) : (this.groupRankings[groupId] || []);
|
||||
getLivePosition(playerId, groupId) {
|
||||
const groupPlayers = this.groupRankings[groupId] || [];
|
||||
const liveStats = groupPlayers.map(player => {
|
||||
let livePoints = player.points || 0;
|
||||
let liveSetsWon = player.setsWon || 0;
|
||||
@@ -484,13 +341,13 @@ export default {
|
||||
const playerMatches = this.matches.filter(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
((m.player1?.id ?? m.player1Id) === player.id || (m.player2?.id ?? m.player2Id) === player.id) &&
|
||||
(m.player1.id === player.id || m.player2.id === player.id) &&
|
||||
!m.isFinished &&
|
||||
m.tournamentResults && m.tournamentResults.length > 0
|
||||
);
|
||||
|
||||
playerMatches.forEach(match => {
|
||||
const isPlayer1 = (match.player1?.id ?? match.player1Id) === player.id;
|
||||
const isPlayer1 = match.player1.id === player.id;
|
||||
match.tournamentResults.forEach(result => {
|
||||
if (isPlayer1) {
|
||||
if (result.pointsPlayer1 > result.pointsPlayer2) {
|
||||
@@ -526,170 +383,9 @@ export default {
|
||||
});
|
||||
|
||||
const position = liveStats.findIndex(p => p.id === playerId) + 1;
|
||||
return position || (groupPlayers.findIndex(p => p.id === playerId) + 1) || 0;
|
||||
},
|
||||
handleMatchClick(player1Id, player2Id, groupId) {
|
||||
// Highlight das Match
|
||||
this.$emit('highlight-match', player1Id, player2Id, groupId);
|
||||
// Finde das Match und gehe zum Ergebnistab (player1/player2 können null sein, wenn Spieler gelöscht)
|
||||
const id1 = (m) => m.player1?.id ?? m.player1Id;
|
||||
const id2 = (m) => m.player2?.id ?? m.player2Id;
|
||||
const match = this.matches.find(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
id1(m) != null && id2(m) != null &&
|
||||
((id1(m) === player1Id && id2(m) === player2Id) ||
|
||||
(id1(m) === player2Id && id2(m) === player1Id))
|
||||
);
|
||||
if (match) {
|
||||
this.$emit('go-to-match', match.id);
|
||||
}
|
||||
},
|
||||
requestMergePools() {
|
||||
if (!this.mergeSourceClassId || !this.mergeTargetClassId) return;
|
||||
if (String(this.mergeSourceClassId) === String(this.mergeTargetClassId)) return;
|
||||
this.$emit('merge-pools', {
|
||||
sourceClassId: Number(this.mergeSourceClassId),
|
||||
targetClassId: Number(this.mergeTargetClassId),
|
||||
strategy: this.mergeStrategy,
|
||||
outOfCompetitionForSource: !!this.mergeSourceAsAK,
|
||||
});
|
||||
return position || groupPlayers.findIndex(p => p.id === playerId) + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.group-controls {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.groups-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.groups-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.groups-card-header h4 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.groups-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.groups-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.groups-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.groups-settings label,
|
||||
.groups-per-class label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.groups-settings input,
|
||||
.groups-per-class input,
|
||||
.merge-pools-row select {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.groups-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.reset-controls-spaced {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.merge-pools-box {
|
||||
margin: 1rem 0 0 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.merge-summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
.merge-pools-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.merge-pools-row label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.merge-pools-actions {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.groups-overview {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.group-table {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.gave-up-badge {
|
||||
margin-left: 0.35rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.groups-card-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,43 +7,45 @@
|
||||
:selected-date="selectedDate"
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
<section v-if="Object.keys(visibleFinalPlacementsByClass).length > 0" class="final-placements">
|
||||
<section v-if="Object.keys(finalPlacementsByClass).length > 0" class="final-placements">
|
||||
<h3>{{ $t('tournaments.finalPlacements') }}</h3>
|
||||
<div class="placements-summary">
|
||||
<span class="placements-chip">{{ Object.keys(visibleFinalPlacementsByClass).length }} {{ $t('tournaments.classes') }}</span>
|
||||
<span class="placements-chip">{{ totalVisibleFinalPlacements }} {{ $t('tournaments.tabPlacements') }}</span>
|
||||
</div>
|
||||
<template v-for="(classPlacements, classId) in visibleFinalPlacementsByClass" :key="`final-${classId}`">
|
||||
<div class="class-section">
|
||||
<template v-for="(classPlacements, classId) in finalPlacementsByClass" :key="`final-${classId}`">
|
||||
<div v-if="isAllSelected || shouldShowClass(classId==='null'?null:Number(classId))" class="class-section">
|
||||
<h4 class="class-header">
|
||||
{{ getClassName(classId) }}
|
||||
<span class="class-type-badge" v-if="classId!==null" :class="{ doubles: isDoubles(classId), singles: !isDoubles(classId) }">
|
||||
{{ isDoubles(classId) ? $t('tournaments.doubles') : $t('tournaments.singles') }}
|
||||
</span>
|
||||
</h4>
|
||||
<div class="final-placement-list">
|
||||
<div v-for="(entry, entryIdx) in classPlacements" :key="`final-${classId}-${entryIdx}`" class="final-placement-card">
|
||||
<div class="final-placement-badge">{{ entry.position }}.</div>
|
||||
<button
|
||||
type="button"
|
||||
class="final-placement-player"
|
||||
@click="openPlayerDialog(entry)"
|
||||
:title="$t('tournaments.showPlayerDetails')"
|
||||
>
|
||||
{{ getEntryPlayerName(entry) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-place">{{ labelPlace }}</th>
|
||||
<th>{{ $t('tournaments.player') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, entryIdx) in classPlacements" :key="`final-${classId}-${entryIdx}`">
|
||||
<td class="col-place">{{ entry.position }}.</td>
|
||||
<td>
|
||||
<span
|
||||
class="player-name-clickable"
|
||||
@click="openPlayerDialog(entry)"
|
||||
:title="$t('tournaments.showPlayerDetails')"
|
||||
>
|
||||
{{ getEntryPlayerName(entry) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section v-if="Object.keys(visibleGroupPlacementsByClass).length > 0" class="group-placements">
|
||||
<section v-if="groupPlacements.length > 0" class="group-placements">
|
||||
<h3>{{ $t('tournaments.groupPlacements') }}</h3>
|
||||
<div class="placements-summary">
|
||||
<span class="placements-chip">{{ visibleGroupPlacementCount }} {{ $t('tournaments.group') }}</span>
|
||||
</div>
|
||||
<template v-for="(classGroups, classId) in visibleGroupPlacementsByClass" :key="`group-${classId}`">
|
||||
<div class="class-section">
|
||||
<template v-for="(classGroups, classId) in groupPlacementsByClass" :key="`group-${classId}`">
|
||||
<div v-if="isAllSelected || shouldShowClass(classId==='null'?null:Number(classId))" class="class-section">
|
||||
<h4 class="class-header">
|
||||
{{ getClassName(classId) }}
|
||||
<span class="class-type-badge" v-if="classId!==null" :class="{ doubles: isDoubles(classId), singles: !isDoubles(classId) }">
|
||||
@@ -84,15 +86,8 @@
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<div v-if="Object.keys(visibleFinalPlacementsByClass).length === 0 && Object.keys(visibleGroupPlacementsByClass).length === 0" class="no-placements">
|
||||
<p class="no-placements-title">{{ $t('tournaments.placementsPendingTitle') }}</p>
|
||||
<p class="no-placements-copy">{{ placementsEmptyMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isMiniChampionship && (participants.length > 0 || externalParticipants.length > 0)" class="missing-data-pdf">
|
||||
<button @click="generateMissingDataPDF" class="btn-primary" :disabled="pdfLoading">
|
||||
{{ pdfLoading ? $t('tournaments.generatingPDF') : $t('tournaments.missingDataPDF') }}
|
||||
</button>
|
||||
<div v-if="Object.keys(finalPlacementsByClass).length === 0 && groupPlacements.length === 0" class="no-placements">
|
||||
<p>{{ $t('tournaments.noPlacementsYet') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Player Details Dialog -->
|
||||
@@ -110,9 +105,6 @@
|
||||
<script>
|
||||
import TournamentClassSelector from './TournamentClassSelector.vue';
|
||||
import PlayerDetailsDialog from './PlayerDetailsDialog.vue';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import apiClient from '../../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'TournamentPlacementsTab',
|
||||
@@ -130,17 +122,15 @@ export default {
|
||||
groups: { type: Array, required: true },
|
||||
groupRankings: { type: Object, required: true },
|
||||
knockoutMatches: { type: Array, required: true },
|
||||
clubId: { type: [Number, String], required: true },
|
||||
isMiniChampionship: { type: Boolean, default: false }
|
||||
clubId: { type: [Number, String], required: true }
|
||||
},
|
||||
emits: ['update:selectedViewClass', 'show-info'],
|
||||
emits: ['update:selectedViewClass'],
|
||||
data() {
|
||||
return {
|
||||
showPlayerDialog: false,
|
||||
selectedPlayerId: null,
|
||||
selectedPlayerIsExternal: false,
|
||||
selectedPlayerName: '',
|
||||
pdfLoading: false
|
||||
selectedPlayerName: ''
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -169,7 +159,7 @@ export default {
|
||||
labelPlace() {
|
||||
const t = this.$t && this.$t('tournaments.place');
|
||||
if (t && typeof t === 'string' && t.trim().length > 0 && t !== 'tournaments.place') return t;
|
||||
return this.$t('tournaments.position');
|
||||
return 'Platz';
|
||||
},
|
||||
finalPlacementsByClass() {
|
||||
const byClass = {};
|
||||
@@ -419,11 +409,10 @@ export default {
|
||||
},
|
||||
groupPlacements() {
|
||||
const placements = [];
|
||||
// Primär: aus groups + groupRankings (Schlüssel groupId-classId bei Pool)
|
||||
// Primär: aus groups + groupRankings
|
||||
if ((this.groups || []).length > 0) {
|
||||
this.groups.forEach(group => {
|
||||
const key = `${group.groupId}-${group.classId ?? 'null'}`;
|
||||
const rankings = this.groupRankings[key] || [];
|
||||
const rankings = this.groupRankings[group.groupId] || [];
|
||||
if (rankings.length > 0) {
|
||||
placements.push({
|
||||
groupId: group.groupId,
|
||||
@@ -480,69 +469,13 @@ export default {
|
||||
groupPlacementsByClass() {
|
||||
const grouped = {};
|
||||
this.groupPlacements.forEach(p => {
|
||||
const key = p.classId != null ? String(p.classId) : 'null';
|
||||
const key = p.classId || 'null';
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push(p);
|
||||
});
|
||||
return grouped;
|
||||
},
|
||||
/** Endplatzierung nur anzeigen, wenn die Klasse mehr als 1 Gruppe hat (Endrunde nach Gruppenphase). */
|
||||
finalPlacementsByClassFiltered() {
|
||||
const raw = this.finalPlacementsByClass;
|
||||
const filtered = {};
|
||||
for (const [classKey, placements] of Object.entries(raw)) {
|
||||
const groupCount = (this.groupPlacementsByClass[classKey] || []).length;
|
||||
if (groupCount > 1) {
|
||||
filtered[classKey] = placements;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
},
|
||||
visibleFinalPlacementsByClass() {
|
||||
const visible = {};
|
||||
for (const [classKey, placements] of Object.entries(this.finalPlacementsByClassFiltered)) {
|
||||
const normalizedClassId = classKey === 'null' ? null : Number(classKey);
|
||||
if (this.shouldShowClass(normalizedClassId)) {
|
||||
visible[classKey] = placements;
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
},
|
||||
visibleGroupPlacementsByClass() {
|
||||
const visible = {};
|
||||
for (const [classKey, placements] of Object.entries(this.groupPlacementsByClass)) {
|
||||
const normalizedClassId = classKey === 'null' ? null : Number(classKey);
|
||||
if (this.shouldShowClass(normalizedClassId)) {
|
||||
visible[classKey] = placements;
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
},
|
||||
totalFinalPlacements() {
|
||||
return Object.values(this.finalPlacementsByClassFiltered).reduce((sum, entries) => sum + entries.length, 0);
|
||||
},
|
||||
totalVisibleFinalPlacements() {
|
||||
return Object.values(this.visibleFinalPlacementsByClass).reduce((sum, entries) => sum + entries.length, 0);
|
||||
},
|
||||
visibleGroupPlacementCount() {
|
||||
return Object.values(this.visibleGroupPlacementsByClass).reduce((sum, entries) => sum + entries.length, 0);
|
||||
},
|
||||
placementsEmptyMessage() {
|
||||
if (!this.isAllSelected) {
|
||||
return this.$t('tournaments.placementsPendingSelectedClass');
|
||||
}
|
||||
if (Object.keys(this.groupPlacementsByClass).length === 0 && Object.keys(this.finalPlacementsByClassFiltered).length === 0) {
|
||||
return this.$t('tournaments.placementsPendingNoGroups');
|
||||
}
|
||||
if (Object.keys(this.groupPlacementsByClass).length === 0) {
|
||||
return this.$t('tournaments.placementsPendingGroups');
|
||||
}
|
||||
if (Object.keys(this.finalPlacementsByClassFiltered).length === 0) {
|
||||
return this.$t('tournaments.placementsPendingFinals');
|
||||
}
|
||||
return this.$t('tournaments.noPlacementsYet');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -581,256 +514,6 @@ export default {
|
||||
const c = (this.tournamentClasses || []).find(x => x.id === cid);
|
||||
return Boolean(c && c.isDoubles);
|
||||
},
|
||||
async generateMissingDataPDF() {
|
||||
this.pdfLoading = true;
|
||||
try {
|
||||
// 1. Top-3-Platzierte ermitteln
|
||||
const top3Names = new Set();
|
||||
const top3Ids = new Set();
|
||||
|
||||
// Aus K.O.-Endplatzierungen (nur gefilterte = echte K.O.-Runden)
|
||||
const hasKORound = Object.keys(this.finalPlacementsByClassFiltered).length > 0;
|
||||
if (hasKORound) {
|
||||
for (const [, placements] of Object.entries(this.finalPlacementsByClassFiltered)) {
|
||||
for (const entry of placements) {
|
||||
if (Number(entry.position) > 3) continue;
|
||||
if (entry.displayName) {
|
||||
entry.displayName.split('/').forEach(n => top3Names.add(n.trim()));
|
||||
} else if (entry.member) {
|
||||
const fn = (entry.member.firstName || '').trim();
|
||||
const ln = (entry.member.lastName || '').trim();
|
||||
if (fn || ln) top3Names.add(`${fn} ${ln}`.trim());
|
||||
if (entry.member.id != null) top3Ids.add(Number(entry.member.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aus Gruppen-Platzierungen (wenn keine K.O.-Runde vorhanden)
|
||||
if (!hasKORound) {
|
||||
for (const gp of this.groupPlacements) {
|
||||
for (const r of (gp.rankings || [])) {
|
||||
if (Number(r.position) > 3) continue;
|
||||
if (r.name) top3Names.add(r.name.trim());
|
||||
if (r.id != null) top3Ids.add(Number(r.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (top3Names.size === 0) {
|
||||
this.$emit('show-info', this.$t('messages.info'), this.$t('tournaments.noPlacementsYet'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Teilnehmer-Daten laden
|
||||
const allPlayerData = [];
|
||||
|
||||
// Interne Mitglieder laden
|
||||
let members = [];
|
||||
try {
|
||||
const res = await apiClient.get(`/clubmembers/get/${Number(this.clubId)}/true`);
|
||||
members = res.data || [];
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Mitglieder:', e);
|
||||
}
|
||||
|
||||
// Externe Teilnehmer laden
|
||||
let externals = [];
|
||||
try {
|
||||
const res = await apiClient.post('/tournament/external-participants', {
|
||||
clubId: Number(this.clubId),
|
||||
tournamentId: this.selectedDate,
|
||||
classId: null
|
||||
});
|
||||
externals = res.data || [];
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der externen Teilnehmer:', e);
|
||||
}
|
||||
|
||||
// Hilfsfunktion: prüft ob ein Spieler in den Top 3 ist
|
||||
const isTop3 = (name, id) => {
|
||||
if (id != null && top3Ids.has(Number(id))) return true;
|
||||
if (name && top3Names.has(name.trim())) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Interne Teilnehmer verarbeiten (nur Top 3)
|
||||
for (const p of this.participants) {
|
||||
const memberId = p.member?.id || p.clubMemberId;
|
||||
const member = members.find(m => m.id === memberId);
|
||||
if (!member) continue;
|
||||
|
||||
const name = `${member.firstName || ''} ${member.lastName || ''}`.trim();
|
||||
if (!isTop3(name, memberId)) continue;
|
||||
|
||||
let address = '';
|
||||
const parts = [];
|
||||
if (member.street) parts.push(member.street);
|
||||
if (member.postalCode) parts.push(member.postalCode);
|
||||
if (member.city) parts.push(member.city);
|
||||
address = parts.join(', ');
|
||||
|
||||
let email = '';
|
||||
if (member.contacts && Array.isArray(member.contacts)) {
|
||||
email = member.contacts.filter(c => c.type === 'email').map(c => c.value).join(', ');
|
||||
} else if (member.email) {
|
||||
email = member.email;
|
||||
}
|
||||
|
||||
let phone = '';
|
||||
if (member.contacts && Array.isArray(member.contacts)) {
|
||||
phone = member.contacts.filter(c => c.type === 'phone').map(c => c.value).join(', ');
|
||||
} else if (member.phone) {
|
||||
phone = member.phone;
|
||||
}
|
||||
|
||||
const gender = member.gender && member.gender !== 'unknown' ? member.gender : null;
|
||||
|
||||
allPlayerData.push({
|
||||
name,
|
||||
birthDate: member.birthDate || null,
|
||||
gender,
|
||||
address: address || null,
|
||||
email: email || null,
|
||||
phone: phone || null
|
||||
});
|
||||
}
|
||||
|
||||
// Externe Teilnehmer verarbeiten (nur Top 3)
|
||||
for (const ext of this.externalParticipants) {
|
||||
const found = externals.find(e => e.id === ext.id);
|
||||
const src = found || ext;
|
||||
const name = `${src.firstName || ''} ${src.lastName || ''}`.trim();
|
||||
if (!isTop3(name, ext.id)) continue;
|
||||
|
||||
const gender = src.gender && src.gender !== 'unknown' ? src.gender : null;
|
||||
|
||||
allPlayerData.push({
|
||||
name,
|
||||
birthDate: src.birthDate || null,
|
||||
gender,
|
||||
address: src.address || null,
|
||||
email: src.email || null,
|
||||
phone: src.phone || null
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Nur Teilnehmer mit fehlenden Daten filtern
|
||||
const fields = ['birthDate', 'gender', 'address', 'email', 'phone'];
|
||||
const playersWithMissing = allPlayerData.filter(p =>
|
||||
fields.some(f => !p[f])
|
||||
);
|
||||
|
||||
if (allPlayerData.length === 0) {
|
||||
this.$emit('show-info', this.$t('messages.info'), this.$t('tournaments.noTop3Yet'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (playersWithMissing.length === 0) {
|
||||
this.$emit('show-info', this.$t('messages.info'), this.$t('tournaments.allDataCompleteTop3'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Sortieren nach Name
|
||||
playersWithMissing.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// 3. PDF erzeugen
|
||||
const pdf = new jsPDF('l', 'mm', 'a4');
|
||||
const margin = 15;
|
||||
const t = this.$t;
|
||||
|
||||
// Titel
|
||||
pdf.setFontSize(16);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.text(t('tournaments.missingDataPDFTitleTop3'), margin, 20);
|
||||
|
||||
pdf.setFontSize(10);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.text(t('tournaments.missingDataPDFSubtitleTop3'), margin, 27);
|
||||
|
||||
// Formatierung der Felder
|
||||
const formatGender = (g) => {
|
||||
if (!g) return '';
|
||||
const map = { male: t('members.genderMale'), female: t('members.genderFemale'), diverse: t('members.genderDiverse') };
|
||||
return map[g] || g;
|
||||
};
|
||||
const formatDate = (d) => {
|
||||
if (!d) return '';
|
||||
try {
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return d;
|
||||
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
|
||||
} catch (e) { return d; }
|
||||
};
|
||||
|
||||
// Tabelle
|
||||
const head = [[
|
||||
t('members.firstName') + ' / ' + t('members.lastName'),
|
||||
t('members.birthdate'),
|
||||
t('members.gender'),
|
||||
t('tournaments.address'),
|
||||
t('tournaments.phone'),
|
||||
t('members.emailAddress')
|
||||
]];
|
||||
|
||||
const body = playersWithMissing.map(p => [
|
||||
p.name,
|
||||
p.birthDate ? formatDate(p.birthDate) : '',
|
||||
p.gender ? formatGender(p.gender) : '',
|
||||
p.address || '',
|
||||
p.phone || '',
|
||||
p.email || ''
|
||||
]);
|
||||
|
||||
autoTable(pdf, {
|
||||
startY: 32,
|
||||
margin: { left: margin, right: margin },
|
||||
head,
|
||||
body,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.25,
|
||||
valign: 'middle',
|
||||
minCellHeight: 10
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [76, 175, 80],
|
||||
textColor: 255,
|
||||
fontStyle: 'bold',
|
||||
fontSize: 9
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 40 },
|
||||
1: { cellWidth: 28 },
|
||||
2: { cellWidth: 25 },
|
||||
3: { cellWidth: 50 },
|
||||
4: { cellWidth: 40 },
|
||||
5: { cellWidth: 50 }
|
||||
}
|
||||
});
|
||||
|
||||
// Fußzeile
|
||||
const pageCount = pdf.internal.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
pdf.setPage(i);
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(150);
|
||||
pdf.text(
|
||||
`${t('tournaments.missingDataPDFTitleTop3')} – ${new Date().toLocaleDateString('de-DE')} – ${t('tournaments.page')} ${i}/${pageCount}`,
|
||||
margin,
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
pdf.save(`Fehlende_Daten_Minimeisterschaft_${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des PDFs:', error);
|
||||
} finally {
|
||||
this.pdfLoading = false;
|
||||
}
|
||||
},
|
||||
openPlayerDialog(entry) {
|
||||
console.log('[openPlayerDialog] entry:', entry);
|
||||
// Für Doppel-Paarungen können wir keine Details anzeigen
|
||||
@@ -961,24 +644,6 @@ export default {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.placements-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.placements-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.class-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -989,49 +654,6 @@ export default {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.final-placement-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.final-placement-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.final-placement-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 999px;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.final-placement-player {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: #1976d2;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.final-placement-player:hover {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.class-type-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
@@ -1094,25 +716,7 @@ th {
|
||||
.no-placements {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.no-placements-title {
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.no-placements-copy {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.missing-data-pdf {
|
||||
margin-top: 1.5rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.player-name-clickable {
|
||||
@@ -1130,3 +734,5 @@ table thead th:first-child,
|
||||
table tbody td:first-child {
|
||||
width: 4em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,56 +7,13 @@
|
||||
:selected-date="selectedDate"
|
||||
@update:modelValue="$emit('update:selectedViewClass', $event)"
|
||||
/>
|
||||
<div class="results-summary">
|
||||
<span v-if="filteredGroupMatches.length" class="results-chip">{{ filteredGroupMatches.length }} {{ $t('tournaments.groupMatches') }}</span>
|
||||
<span v-if="filteredKnockoutMatches.length" class="results-chip">{{ filteredKnockoutMatches.length }} {{ $t('tournaments.koRound') }}</span>
|
||||
<span v-if="openMatchCount > 0" class="results-chip results-chip-open">{{ openMatchCount }} {{ $t('tournaments.statusOpen') }}</span>
|
||||
<span v-if="liveMatchCount > 0" class="results-chip results-chip-live">{{ liveMatchCount }} {{ $t('tournaments.statusLive') }}</span>
|
||||
<span v-if="finishedMatchCount > 0" class="results-chip">{{ finishedMatchCount }} {{ $t('tournaments.statusFinished') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="(canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 1) || (showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 1)"
|
||||
class="results-primary-actions"
|
||||
>
|
||||
<button
|
||||
v-if="canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 1"
|
||||
@click="$emit('start-knockout')"
|
||||
class="btn-primary"
|
||||
:disabled="knockoutOperationInProgress"
|
||||
>
|
||||
{{ $t('tournaments.startKORound') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 1"
|
||||
@click="$emit('reset-knockout')"
|
||||
class="trash-btn"
|
||||
:disabled="knockoutOperationInProgress"
|
||||
>
|
||||
🗑️ {{ $t('tournaments.deleteKORound') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="numberOfTables && (filteredGroupMatches.length || filteredKnockoutMatches.length)" class="distribute-tables-bar">
|
||||
<button @click="$emit('distribute-tables')" class="btn-primary">
|
||||
{{ $t('tournaments.distributeTables') }}
|
||||
</button>
|
||||
</div>
|
||||
<section v-if="filteredGroupMatches.length" class="group-matches">
|
||||
<div class="results-section-header">
|
||||
<div class="results-section-title">
|
||||
<h4>{{ $t('tournaments.groupMatches') }}</h4>
|
||||
<span class="results-chip">{{ filteredGroupMatches.length }}</span>
|
||||
</div>
|
||||
<button type="button" class="section-toggle-btn" @click="groupMatchesCollapsed = !groupMatchesCollapsed">
|
||||
<span class="collapse-icon" :class="{ expanded: !groupMatchesCollapsed }">▾</span>
|
||||
{{ groupMatchesCollapsed ? collapseShowLabel : collapseHideLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<table v-if="!groupMatchesCollapsed">
|
||||
<h4>{{ $t('tournaments.groupMatches') }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.round') }}</th>
|
||||
<th>{{ $t('tournaments.group') }}</th>
|
||||
<th v-if="numberOfTables">{{ $t('tournaments.table') }}</th>
|
||||
<th>{{ $t('tournaments.encounter') }}</th>
|
||||
<th>{{ $t('tournaments.result') }}</th>
|
||||
<th>{{ $t('tournaments.sets') }}</th>
|
||||
@@ -74,22 +31,6 @@
|
||||
{{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="numberOfTables">
|
||||
<select
|
||||
:value="m.tableNumber || ''"
|
||||
@change="$emit('set-match-table', m, $event.target.value === '' ? null : parseInt($event.target.value))"
|
||||
class="table-select"
|
||||
:disabled="m.isFinished"
|
||||
>
|
||||
<option value="">–</option>
|
||||
<option
|
||||
v-for="t in numberOfTables"
|
||||
:key="t"
|
||||
:value="t"
|
||||
:disabled="occupiedTables.has(t) && m.tableNumber !== t"
|
||||
>{{ t }}{{ occupiedTables.has(t) && m.tableNumber !== t ? ' ●' : '' }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="m.isFinished">
|
||||
<span v-if="winnerIsPlayer1(m)">
|
||||
@@ -107,10 +48,6 @@
|
||||
<template v-if="m.result === 'BYE'">
|
||||
BYE
|
||||
</template>
|
||||
<template v-else-if="matchHasGaveUp(m)">
|
||||
<span class="result-text gave-up-result">{{ formatResult(m) }}</span>
|
||||
<span v-if="m.player1?.gaveUp && m.player2?.gaveUp" class="gave-up-badge-small">({{ $t('tournaments.gaveUp') }})</span>
|
||||
</template>
|
||||
<template v-else-if="!m.isFinished">
|
||||
<template v-for="r in m.tournamentResults" :key="r.set">
|
||||
<template v-if="isEditing(m, r.set)">
|
||||
@@ -134,7 +71,7 @@
|
||||
<div class="new-set-line">
|
||||
<input
|
||||
v-model="m.resultInput"
|
||||
:placeholder="$t('tournaments.newSetPlaceholder')"
|
||||
placeholder="Neuen Satz, z.B. 11:7"
|
||||
@keyup.enter="$emit('save-match-result', m, m.resultInput)"
|
||||
@blur="$emit('save-match-result', m, m.resultInput)"
|
||||
class="inline-input"
|
||||
@@ -149,59 +86,37 @@
|
||||
{{ getSetsString(m) }}
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-badge', getMatchStateClass(m)]">{{ getMatchStateLabel(m) }}</span>
|
||||
<template v-if="matchHasGaveUp(m)">
|
||||
<span class="no-edit-hint">{{ $t('tournaments.gaveUp') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="action-row">
|
||||
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">{{ $t('tournaments.finishMatch') }}</button>
|
||||
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">{{ $t('tournaments.correctMatch') }}</button>
|
||||
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" :title="$t('tournaments.markMatchLive')">▶️</button>
|
||||
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" :title="$t('tournaments.unmarkMatchLive')">⏸️</button>
|
||||
</div>
|
||||
</template>
|
||||
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">Abschließen</button>
|
||||
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">Korrigieren</button>
|
||||
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" title="Als laufend markieren">▶️</button>
|
||||
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" title="Laufend-Markierung entfernen">⏸️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<div v-if="participants.length > 1 && !filteredGroupMatches.length && !filteredKnockoutMatches.length" class="results-next-step">
|
||||
<button @click="$emit('start-matches')" class="btn-primary">
|
||||
<div v-if="participants.length > 1 && !filteredGroupMatches.length && !filteredKnockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
|
||||
<button @click="$emit('start-matches')">
|
||||
{{ $t('tournaments.createMatches') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 1" class="results-next-step">
|
||||
<button @click="$emit('start-knockout')" class="btn-primary" :disabled="knockoutOperationInProgress">
|
||||
<div v-if="canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 1" class="ko-start">
|
||||
<button @click="$emit('start-knockout')">
|
||||
{{ $t('tournaments.startKORound') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 1" class="results-next-step results-next-step-muted">
|
||||
<button @click="$emit('reset-knockout')" class="trash-btn" :disabled="knockoutOperationInProgress">
|
||||
<div v-if="showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 1" class="ko-reset" style="margin-top:1rem">
|
||||
<button @click="$emit('reset-knockout')" class="trash-btn">
|
||||
🗑️ {{ $t('tournaments.deleteKORound') }}
|
||||
</button>
|
||||
</div>
|
||||
<section v-if="showKnockout && numberOfGroupsForSelectedClass > 1 && filteredKnockoutMatches.length" class="ko-round">
|
||||
<div class="results-section-header">
|
||||
<div class="results-section-title">
|
||||
<h4>{{ $t('tournaments.koRound') }}</h4>
|
||||
<span class="results-chip">{{ filteredKnockoutMatches.length }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="numberOfTables"
|
||||
type="button"
|
||||
class="btn-primary btn-inline-action"
|
||||
@click="$emit('distribute-tables')"
|
||||
>
|
||||
{{ $t('tournaments.distributeTables') }}
|
||||
</button>
|
||||
</div>
|
||||
<h4>{{ $t('tournaments.koRound') }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.class') }}</th>
|
||||
<th>{{ $t('tournaments.round') }}</th>
|
||||
<th v-if="numberOfTables">{{ $t('tournaments.table') }}</th>
|
||||
<th>{{ $t('tournaments.encounter') }}</th>
|
||||
<th>{{ $t('tournaments.result') }}</th>
|
||||
<th>{{ $t('tournaments.sets') }}</th>
|
||||
@@ -212,22 +127,6 @@
|
||||
<tr v-for="m in filteredKnockoutMatches" :key="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
|
||||
<td>{{ getKnockoutMatchClassName(m) }}</td>
|
||||
<td>{{ m.round }}</td>
|
||||
<td v-if="numberOfTables">
|
||||
<select
|
||||
:value="m.tableNumber || ''"
|
||||
@change="$emit('set-match-table', m, $event.target.value === '' ? null : parseInt($event.target.value))"
|
||||
class="table-select"
|
||||
:disabled="m.isFinished"
|
||||
>
|
||||
<option value="">–</option>
|
||||
<option
|
||||
v-for="t in numberOfTables"
|
||||
:key="t"
|
||||
:value="t"
|
||||
:disabled="occupiedTables.has(t) && m.tableNumber !== t"
|
||||
>{{ t }}{{ occupiedTables.has(t) && m.tableNumber !== t ? ' ●' : '' }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="m.isFinished">
|
||||
<span v-if="winnerIsPlayer1(m)">
|
||||
@@ -265,7 +164,7 @@
|
||||
<div class="new-set-line">
|
||||
<input
|
||||
v-model="m.resultInput"
|
||||
:placeholder="$t('tournaments.newSetPlaceholder')"
|
||||
placeholder="Neuen Satz, z.B. 11:7"
|
||||
@keyup.enter="$emit('save-match-result', m, m.resultInput)"
|
||||
@blur="$emit('save-match-result', m, m.resultInput)"
|
||||
class="inline-input"
|
||||
@@ -280,32 +179,29 @@
|
||||
{{ getSetsString(m) }}
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-badge', getMatchStateClass(m)]">{{ getMatchStateLabel(m) }}</span>
|
||||
<div class="action-row">
|
||||
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">{{ $t('tournaments.finishMatch') }}</button>
|
||||
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">{{ $t('tournaments.correctMatch') }}</button>
|
||||
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" :title="$t('tournaments.markMatchLive')">▶️</button>
|
||||
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" :title="$t('tournaments.unmarkMatchLive')">⏸️</button>
|
||||
</div>
|
||||
<button v-if="!m.isFinished" @click="$emit('finish-match', m)">Fertig</button>
|
||||
<button v-else @click="$emit('reopen-match', m)" class="btn-correct">Korrigieren</button>
|
||||
<button v-if="!m.isFinished && !m.isActive" @click.stop="$emit('set-match-active', m, true)" class="btn-live" title="Als laufend markieren">▶️</button>
|
||||
<button v-if="!m.isFinished && m.isActive" @click.stop="$emit('set-match-active', m, false)" class="btn-live active" title="Laufend-Markierung entfernen">⏸️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section v-if="Object.keys(filteredGroupedRankingList).length > 0" class="ranking">
|
||||
<h4>{{ $t('tournaments.resultsRanking') }}</h4>
|
||||
<h4>Rangliste</h4>
|
||||
<template v-for="(classKey, idx) in Object.keys(filteredGroupedRankingList).sort((a, b) => {
|
||||
const aNum = a === 'null' ? 999999 : parseInt(a);
|
||||
const bNum = b === 'null' ? 999999 : parseInt(b);
|
||||
return aNum - bNum;
|
||||
})" :key="`class-${classKey}`">
|
||||
<div v-if="idx > 0" class="ranking-class-gap"></div>
|
||||
<div v-if="idx > 0" style="margin-top: 2rem;"></div>
|
||||
<h5 v-if="getClassName(classKey)">{{ getClassName(classKey) }}</h5>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.position') }}</th>
|
||||
<th>{{ $t('tournaments.player') }}</th>
|
||||
<th>Platz</th>
|
||||
<th>Spieler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -372,10 +268,6 @@ export default {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
knockoutOperationInProgress: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
getTotalNumberOfGroups: {
|
||||
type: Number,
|
||||
required: true
|
||||
@@ -395,22 +287,18 @@ export default {
|
||||
pairings: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
numberOfTables: {
|
||||
type: [Number, null],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredGroupMatches() {
|
||||
return this.sortMatchesForDisplay(this.filterMatchesByClass(this.groupMatches));
|
||||
return this.filterMatchesByClass(this.groupMatches);
|
||||
},
|
||||
filteredKnockoutMatches() {
|
||||
return this.sortMatchesForDisplay(this.filterMatchesByClass(this.knockoutMatches));
|
||||
return this.filterMatchesByClass(this.knockoutMatches);
|
||||
},
|
||||
filteredGroupedRankingList() {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') {
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return this.groupedRankingList;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
@@ -433,17 +321,6 @@ export default {
|
||||
}
|
||||
return result;
|
||||
},
|
||||
occupiedTables() {
|
||||
// Tische, die von laufenden (nicht abgeschlossenen) Matches belegt sind
|
||||
const allMatches = [...this.groupMatches, ...this.knockoutMatches];
|
||||
const occupied = new Set();
|
||||
for (const m of allMatches) {
|
||||
if (m.tableNumber && !m.isFinished) {
|
||||
occupied.add(m.tableNumber);
|
||||
}
|
||||
}
|
||||
return occupied;
|
||||
},
|
||||
numberOfGroupsForSelectedClass() {
|
||||
// Zähle direkt die Gruppen für die ausgewählte Klasse
|
||||
// Nur Stage 1 Gruppen (stageId null/undefined) zählen
|
||||
@@ -454,7 +331,7 @@ export default {
|
||||
);
|
||||
|
||||
// Wenn keine Klasse ausgewählt ist, zähle alle Stage 1 Gruppen
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') {
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return groupsToCount.length;
|
||||
}
|
||||
|
||||
@@ -475,15 +352,6 @@ export default {
|
||||
}
|
||||
return Number(g.classId) === selectedId;
|
||||
}).length;
|
||||
},
|
||||
liveMatchCount() {
|
||||
return [...this.filteredGroupMatches, ...this.filteredKnockoutMatches].filter(m => m.isActive && !m.isFinished).length;
|
||||
},
|
||||
openMatchCount() {
|
||||
return [...this.filteredGroupMatches, ...this.filteredKnockoutMatches].filter(m => !m.isActive && !m.isFinished && !this.matchHasGaveUp(m)).length;
|
||||
},
|
||||
finishedMatchCount() {
|
||||
return [...this.filteredGroupMatches, ...this.filteredKnockoutMatches].filter(m => m.isFinished).length;
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
@@ -497,35 +365,14 @@ export default {
|
||||
'finish-match',
|
||||
'reopen-match',
|
||||
'set-match-active',
|
||||
'set-match-table',
|
||||
'distribute-tables',
|
||||
'start-matches',
|
||||
'start-knockout',
|
||||
'reset-knockout'
|
||||
],
|
||||
data() {
|
||||
const show = this.$t('common.show');
|
||||
const hide = this.$t('common.hide');
|
||||
return {
|
||||
groupMatchesCollapsed: false,
|
||||
collapseShowLabel: show && show !== 'common.show' ? show : 'Anzeigen',
|
||||
collapseHideLabel: hide && hide !== 'common.hide' ? hide : 'Ausblenden'
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
showKnockout: {
|
||||
immediate: true,
|
||||
handler(newValue) {
|
||||
if (newValue && this.filteredGroupMatches.length > 0) {
|
||||
this.groupMatchesCollapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterMatchesByClass(matches) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') {
|
||||
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
|
||||
return matches;
|
||||
}
|
||||
// Wenn "Ohne Klasse" ausgewählt ist
|
||||
@@ -545,25 +392,6 @@ export default {
|
||||
return Number(matchClassId) === selectedId;
|
||||
});
|
||||
},
|
||||
sortMatchesForDisplay(matches) {
|
||||
const stateWeight = (match) => {
|
||||
if (match.isActive && !match.isFinished) return 0;
|
||||
if (!match.isFinished && !this.matchHasGaveUp(match)) return 1;
|
||||
if (this.matchHasGaveUp(match)) return 2;
|
||||
return 3;
|
||||
};
|
||||
return [...matches].sort((a, b) => {
|
||||
const byState = stateWeight(a) - stateWeight(b);
|
||||
if (byState !== 0) return byState;
|
||||
const roundA = Number(a.groupRound || 0);
|
||||
const roundB = Number(b.groupRound || 0);
|
||||
if (roundA !== roundB) return roundA - roundB;
|
||||
const groupA = Number(a.groupNumber || 0);
|
||||
const groupB = Number(b.groupNumber || 0);
|
||||
if (groupA !== groupB) return groupA - groupB;
|
||||
return Number(a.id || 0) - Number(b.id || 0);
|
||||
});
|
||||
},
|
||||
getGroupClassName(groupId) {
|
||||
if (!groupId) return '';
|
||||
const group = this.groups.find(g => g.groupId === groupId);
|
||||
@@ -674,248 +502,7 @@ export default {
|
||||
isLastResult(match, result) {
|
||||
const arr = match.tournamentResults || [];
|
||||
return arr.length > 0 && arr[arr.length - 1].set === result.set;
|
||||
},
|
||||
matchHasGaveUp(match) {
|
||||
return match.gaveUpMatch || match.player1?.gaveUp || match.player2?.gaveUp;
|
||||
},
|
||||
getMatchStateLabel(match) {
|
||||
if (this.matchHasGaveUp(match)) return this.$t('tournaments.gaveUp');
|
||||
if (match.isFinished) return this.$t('tournaments.statusFinished');
|
||||
if (match.isActive) return this.$t('tournaments.statusLive');
|
||||
return this.$t('tournaments.statusOpen');
|
||||
},
|
||||
getMatchStateClass(match) {
|
||||
if (this.matchHasGaveUp(match)) return 'status-gaveup';
|
||||
if (match.isFinished) return 'status-finished';
|
||||
if (match.isActive) return 'status-live';
|
||||
return 'status-open';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.results-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.results-primary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-color, #ffffff);
|
||||
}
|
||||
|
||||
.results-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--background-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.results-chip-live {
|
||||
background: rgba(46, 125, 50, 0.14);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.results-chip-open {
|
||||
background: rgba(181, 106, 29, 0.14);
|
||||
color: #8f5316;
|
||||
}
|
||||
|
||||
.results-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.results-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.results-section-header h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.section-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-color, #ffffff);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
display: inline-block;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-icon.expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.btn-inline-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Farbmarkierungen für Spiele */
|
||||
.match-finished {
|
||||
background-color: var(--background-soft) !important;
|
||||
}
|
||||
|
||||
.match-finished td {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.match-live {
|
||||
background-color: rgba(46, 125, 50, 0.12) !important;
|
||||
}
|
||||
|
||||
.match-live:not(.match-finished) td {
|
||||
color: var(--success-color) !important;
|
||||
}
|
||||
|
||||
.match-finished.match-live {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
.match-finished.match-live td {
|
||||
color: #626262 !important;
|
||||
}
|
||||
|
||||
/* Aktives Match hervorheben - auch wenn es abgeschlossen ist */
|
||||
.active-match {
|
||||
background-color: rgba(181, 106, 29, 0.12) !important;
|
||||
border-left: 3px solid #b56a1d !important;
|
||||
}
|
||||
|
||||
.active-match.match-finished {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
|
||||
.active-match.match-finished td {
|
||||
color: #8f5316 !important;
|
||||
}
|
||||
|
||||
.active-match.match-live {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
|
||||
.active-match.match-live td {
|
||||
color: #8f5316 !important;
|
||||
}
|
||||
|
||||
.gave-up-result {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.gave-up-badge-small {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
.no-edit-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.active-match:hover {
|
||||
background-color: rgba(181, 106, 29, 0.18) !important;
|
||||
}
|
||||
|
||||
.distribute-tables-bar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.results-next-step {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-color, #ffffff);
|
||||
}
|
||||
|
||||
.results-next-step-muted {
|
||||
border-color: var(--border-color);
|
||||
background: var(--background-soft);
|
||||
}
|
||||
|
||||
.ranking-class-gap {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.8rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.status-open {
|
||||
background: var(--background-soft);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-live {
|
||||
background: rgba(46, 125, 50, 0.14);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-finished {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.status-gaveup {
|
||||
background: rgba(200, 76, 50, 0.14);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-select {
|
||||
width: 3.5rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-color, #ffffff);
|
||||
color: var(--text-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
79
frontend/src/i18n/CEBUANO_TODO.md
Normal file
79
frontend/src/i18n/CEBUANO_TODO.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Cebuano (Bisaya) Lokalisierungsplan
|
||||
|
||||
Ziel: Cebuano (ISO: `ceb`) als zusätzliche Sprache hinzufügen, so dass sämtliche Labels, Buttons und Texte in der App in Cebuano verfügbar sind.
|
||||
|
||||
Kurzübersicht der Schritte (technisch):
|
||||
|
||||
- Basis-Sprache: `de.json` als Ausgangsbasis verwenden.
|
||||
- Neue Locale-Datei: `frontend/src/i18n/locales/ceb.json` erstellen (anfangs Kopie von `de.json`).
|
||||
- `frontend/src/i18n/index.js` anpassen: Import, `messages['ceb']` mit `deepMergeMessages(de, ceb)` und `languageMap`-Einträge (`ceb`, `ceb-PH`).
|
||||
- UI: `frontend/src/views/PersonalSettings.vue` -> `languageCodes` um `ceb` ergänzen.
|
||||
- Sprachanzeige: In allen `locales/*.json` unter `languages` einen Eintrag `"ceb": "Cebuano (Bisaya)"` anlegen, damit die Sprache im Selector sichtbar ist.
|
||||
- Audit: `npm run i18n:audit` im `frontend` ausführen und alle fehlenden Keys bereinigen.
|
||||
- Review: Muttersprachliche Prüfung + UI-Tests.
|
||||
|
||||
Konkrete Arbeitsschritte (ToDos):
|
||||
|
||||
1) Basis-Keys sammeln (in-progress)
|
||||
- Datei: `frontend/src/i18n/locales/de.json` als Referenz nehmen
|
||||
- Liste der Namespaces (z.B. `common`, `navigation`, `settings`, ...) erfassen
|
||||
|
||||
2) Locale-Datei erstellen
|
||||
- Erstelle `frontend/src/i18n/locales/ceb.json` als Kopie von `de.json`.
|
||||
- Setze Kommentar-Header mit Hinweis "TRANSLATION_PENDING" für automatische Skripte optional.
|
||||
|
||||
3) Übersetzung
|
||||
- Übersetze alle Keys in `ceb.json` ins Cebuano.
|
||||
- Priorität 1: Buttons, Formulare, Navigation, Settings, Fehlermeldungen.
|
||||
- Priorität 2: längere Hilfetexte, Beschreibungen, Tutorial-Texte.
|
||||
|
||||
4) Integration
|
||||
- `frontend/src/i18n/index.js`
|
||||
- `import ceb from './locales/ceb.json';`
|
||||
- `messages: { 'ceb': deepMergeMessages(de, ceb), ... }`
|
||||
- `languageMap['ceb'] = 'ceb'; languageMap['ceb-PH']='ceb';`
|
||||
|
||||
5) Selector & sichtbarer Name
|
||||
- `frontend/src/views/PersonalSettings.vue` `languageCodes` um `'ceb'` ergänzen.
|
||||
- In allen bestehenden Locale-Dateien (`frontend/src/i18n/locales/*.json`) unter `languages` folgenden Eintrag ergänzen:
|
||||
|
||||
```json
|
||||
"ceb": "Cebuano (Bisaya)"
|
||||
```
|
||||
|
||||
6) Audit & Lint
|
||||
- Im `frontend`-Ordner ausführen:
|
||||
|
||||
```bash
|
||||
npm install # falls Abhängigkeiten fehlen
|
||||
npm run i18n:audit
|
||||
```
|
||||
|
||||
- Audit-Ausgabe prüfen: fehlende Keys, leere Werte, Platzhalter-Mismatch.
|
||||
- Fehlende Keys in `ceb.json` ergänzen (vorläufig auf Deutsch kopieren, mit TODO-Marker).
|
||||
|
||||
7) Review & QA
|
||||
- Muttersprachliche Überprüfung (Proofreading).
|
||||
- UI-Tests: Navigation, Dialoge, Formulare, Fehlerfälle prüfen.
|
||||
|
||||
8) Finalisieren
|
||||
- Übersetzungen final einpflegen.
|
||||
- PR erstellen, Review, Merge.
|
||||
- Build & Smoke-Test: im `frontend` `npm run build` ausführen und kurze Regressionstest-Sequenz.
|
||||
|
||||
Hinweise und Empfehlungen
|
||||
|
||||
- Locale-Code: Verwende `ceb` (ISO 639-3). Falls du eine Region-spezifische Variante willst, nutze `ceb-PH` in `languageMap`.
|
||||
- Übersetzungsworkflow: Für erste Iteration kann `@vitalets/google-translate-api` (bereits vorhanden) helfen, dann menschliches Review.
|
||||
- Automatisches Füllen: Es gibt Skripte in `frontend/package.json` (`i18n:fill`) — bei Bedarf nutzen, aber immer prüfen.
|
||||
|
||||
Nützliche Pfade
|
||||
|
||||
- Sprachdateien: `frontend/src/i18n/locales/`
|
||||
- i18n-Entrypoint: `frontend/src/i18n/index.js`
|
||||
- Language selector: `frontend/src/views/PersonalSettings.vue`
|
||||
- Audit-Skript: `frontend/scripts/audit-i18n.js` (Aufruf via `npm run i18n:audit`)
|
||||
|
||||
Mögliche nächste Aktion (auf Wunsch): Ich lege `frontend/src/i18n/locales/ceb.json` als Kopie von `de.json` an und passe `index.js` + `PersonalSettings.vue` vorläufig an.
|
||||
|
||||
— Ende —
|
||||
@@ -16,6 +16,7 @@ import zh from './locales/zh.json';
|
||||
import tl from './locales/tl.json';
|
||||
import th from './locales/th.json';
|
||||
import fil from './locales/fil.json';
|
||||
import ceb from './locales/ceb.json';
|
||||
|
||||
function deepMergeMessages(base, override) {
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) {
|
||||
@@ -67,6 +68,8 @@ const languageMap = {
|
||||
'th-TH': 'th',
|
||||
'fil': 'fil',
|
||||
'fil-PH': 'fil',
|
||||
'ceb': 'ceb',
|
||||
'ceb-PH': 'ceb',
|
||||
};
|
||||
|
||||
// Detect browser language
|
||||
@@ -127,6 +130,7 @@ const i18n = createI18n({
|
||||
'tl': deepMergeMessages(de, tl),
|
||||
'th': deepMergeMessages(de, th),
|
||||
'fil': deepMergeMessages(de, fil),
|
||||
'ceb': deepMergeMessages(de, ceb),
|
||||
},
|
||||
legacy: true, // Use Options API mode (required for $t in templates with Options API)
|
||||
// globalInjection is automatically enabled with legacy: true
|
||||
|
||||
542
frontend/src/i18n/locales/ceb.json
Normal file
542
frontend/src/i18n/locales/ceb.json
Normal file
@@ -0,0 +1,542 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Talaan sa Pag-ensayo",
|
||||
"title": "Talaan sa Pag-ensayo"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Nag-load...",
|
||||
"save": "Iluwas",
|
||||
"saved": "Na-save",
|
||||
"cancel": "Ikansela",
|
||||
"delete": "Tangtanga",
|
||||
"edit": "Usba",
|
||||
"add": "Idugang",
|
||||
"close": "Sirado",
|
||||
"confirm": "Kumpirma",
|
||||
"yes": "Oo",
|
||||
"no": "Dili",
|
||||
"search": "Pangitaa",
|
||||
"filter": "Filter",
|
||||
"actions": "Mga aksyon",
|
||||
"back": "Balik",
|
||||
"next": "Sunod",
|
||||
"previous": "Miaging",
|
||||
"submit": "Isumite",
|
||||
"reset": "I-reset",
|
||||
"all": "Tanan",
|
||||
"today": "Karon",
|
||||
"time": "Oras",
|
||||
"new": "Bago",
|
||||
"update": "I-update",
|
||||
"move": "Ibalhin",
|
||||
"refresh": "I-reload",
|
||||
"create": "Paghimo",
|
||||
"remove": "Pagtangtang",
|
||||
"select": "Pilia",
|
||||
"download": "I-download",
|
||||
"choose": "Pilia",
|
||||
"apply": "I-aplikar",
|
||||
"clear": "Hawanon",
|
||||
"details": "Detalye",
|
||||
"view": "Ipakita",
|
||||
"name": "Ngalan",
|
||||
"date": "Petsa",
|
||||
"status": "Status",
|
||||
"type": "Klase",
|
||||
"description": "Deskripsyon",
|
||||
"active": "Aktibo",
|
||||
"inactive": "Dili aktibo",
|
||||
"enabled": "Gipa-aktibar",
|
||||
"disabled": "Gi-deactivate",
|
||||
"required": "Kinahanglanon",
|
||||
"optional": "Opsyonal",
|
||||
"in": "sa",
|
||||
"min": "Min",
|
||||
"minutes": "Minuto",
|
||||
"hours": "Oras",
|
||||
"days": "Adlaw",
|
||||
"weeks": "Semana",
|
||||
"months": "Bulan",
|
||||
"years": "Tuig",
|
||||
"ok": "OK",
|
||||
"period": "Panahon",
|
||||
"saving": "Gina-save..."
|
||||
},
|
||||
"calendar": {
|
||||
"eyebrow": "Kalendaryo sa Klab",
|
||||
"title": "Kalendaryo",
|
||||
"description": "Mga adlaw sa pag-ensayo, mga torneo sa klab ug mga duwa sa matag bulan.",
|
||||
"today": "Karon",
|
||||
"loading": "Gina-load ang datos sa kalendaryo...",
|
||||
"noClub": "Palihug pilia usa ka klab una.",
|
||||
"loadError": "Dili maload ang datos sa kalendaryo.",
|
||||
"sourceWarning": "Dili maload ang {source}",
|
||||
"agendaTitle": "Mga kalihokan sa bulan",
|
||||
"agendaEmpty": "Walay kalihokan karong bulan.",
|
||||
"recurringTrainingTime": "Regular nga oras sa pag-ensayo",
|
||||
"participants": "{count} ka partisipante",
|
||||
"starts": "{count} ka pagsugod",
|
||||
"options": {
|
||||
"title": "Mga opsyon",
|
||||
"subtitle": "Mga kanselasyon & kaugalingong kalihokan"
|
||||
},
|
||||
"legend": {
|
||||
"training": "Pag-ensayo",
|
||||
"tournament": "Torneo",
|
||||
"officialTournament": "Opisyal nga pag-apil",
|
||||
"match": "Duwa",
|
||||
"holiday": "Piyesta",
|
||||
"schoolHoliday": "Bakasyon sa eskwelahan",
|
||||
"trainingCancellation": "Nakansela",
|
||||
"customEvent": "Kalihokan"
|
||||
},
|
||||
"cancellation": {
|
||||
"title": "Gi-kansela ang pag-ensayo",
|
||||
"description": "Gatabon ang regular nga oras sa pag-ensayo.",
|
||||
"date": "Petsa",
|
||||
"untilOptional": "Hangtod (opsyonal)",
|
||||
"trainingGroups": "Mga grupo sa pag-ensayo",
|
||||
"reasonPlaceholder": "Rason (opsyonal)",
|
||||
"saving": "Gina-save...",
|
||||
"submit": "Isumite",
|
||||
"fallbackTitle": "Gi-kansela ang pag-ensayo",
|
||||
"subtitle": "Kanselasyon sa pag-ensayo"
|
||||
},
|
||||
"customEvent": {
|
||||
"title": "Kaugalingong kalihokan",
|
||||
"description": "Mga adlaw sa club, miting, internal nga mga kalihokan, ...",
|
||||
"titlePlaceholder": "Titulo",
|
||||
"categoryPlaceholder": "Kategorya (opsyonal)",
|
||||
"saving": "Gina-save...",
|
||||
"submit": "Himoa",
|
||||
"subtitleFallback": "Kaugalingong kalihokan"
|
||||
},
|
||||
"quickCancellation": {
|
||||
"title": "Kanselasyon sa pag-ensayo",
|
||||
"message": "I-mark ba ang „{title}“ isip kanselasyon sa pag-ensayo?",
|
||||
"useWholeRange": "I-aplikar alang sa tibuok panahon ({start} hangtod {end})",
|
||||
"trainingGroups": "Apektadong mga grupo sa pag-ensayo",
|
||||
"confirm": "Isumite ang kanselasyon",
|
||||
"noGroups": "Walay mga grupo sa pag-ensayo nga adunay nakatakda nga oras."
|
||||
},
|
||||
"sources": {
|
||||
"trainingDays": "Mga adlaw sa pag-ensayo",
|
||||
"trainingTimes": "Oras sa pag-ensayo",
|
||||
"trainingCancellations": "Mga kanselasyon sa pag-ensayo",
|
||||
"customEvents": "Kaugalingong kalihokan",
|
||||
"tournaments": "Mga torneo",
|
||||
"officialTournaments": "Opisyal nga partisipasyon sa torneo",
|
||||
"matches": "Mga duwa",
|
||||
"holidays": "Bakasyon/Piyesta"
|
||||
},
|
||||
"eventTitles": {
|
||||
"training": "Pag-ensayo",
|
||||
"tournament": "Torneo",
|
||||
"officialTournament": "Opisyal nga pag-apil sa torneo",
|
||||
"match": "Duwa"
|
||||
},
|
||||
"tournament": {
|
||||
"open": "Open nga torneo",
|
||||
"club": "Torneo sa klab"
|
||||
},
|
||||
"match": {
|
||||
"home": "Home",
|
||||
"guest": "Bisita"
|
||||
},
|
||||
"holidayTypes": {
|
||||
"holiday": "Piyesta",
|
||||
"schoolHoliday": "Bakasyon sa eskwelahan"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Lu",
|
||||
"tuesday": "Ma",
|
||||
"wednesday": "Mi",
|
||||
"thursday": "Hu",
|
||||
"friday": "Bi",
|
||||
"saturday": "Sa",
|
||||
"sunday": "Do"
|
||||
},
|
||||
"cancelCancellation": {
|
||||
"title": "I-undo ang kanselasyon",
|
||||
"message": "Gusto ba nimo i-undo ang kanselasyon '{title}' sa {date}?",
|
||||
"confirm": "I-undo ang kanselasyon"
|
||||
}
|
||||
},
|
||||
"unknown": "Walay nahibaloan",
|
||||
"navigation": {
|
||||
"home": "Balay",
|
||||
"members": "Mga miyembro",
|
||||
"billing": "Pagbayad",
|
||||
"orders": "Mga order",
|
||||
"diary": "Diyaryo",
|
||||
"approvals": "Mga aprobasyon",
|
||||
"statistics": "Estadistika sa Pag-ensayo",
|
||||
"tournaments": "Mga torneo",
|
||||
"clubTournaments": "Torneo sa Klab",
|
||||
"tournamentParticipations": "Opisyal nga pag-apil sa torneo",
|
||||
"schedule": "Skedyul",
|
||||
"clubSettings": "Mga setting sa klab",
|
||||
"predefinedActivities": "Predefined nga mga kalihokan",
|
||||
"teamManagement": "Pagdumala sa team",
|
||||
"permissions": "Mga permiso",
|
||||
"logs": "System logs",
|
||||
"memberTransfer": "Pagbalhin sa miyembro",
|
||||
"myTischtennisAccount": "myTischtennis account",
|
||||
"clickTtAccount": "HTTV / click-TT account",
|
||||
"clickTtBrowser": "HTTV / click-TT",
|
||||
"personalSettings": "Personal nga setting",
|
||||
"logout": "Pag-logout",
|
||||
"login": "Pag-login",
|
||||
"register": "Magrehistro",
|
||||
"dailyBusiness": "Adlaw-adlaw nga buluhaton",
|
||||
"competitions": "Kompetisyon",
|
||||
"settings": "Mga setting",
|
||||
"backToHome": "Balik sa balay"
|
||||
},
|
||||
"club": {
|
||||
"select": "Pilia ang klab",
|
||||
"selectPlaceholder": "Pilia ang klab...",
|
||||
"new": "Bag-ong klab",
|
||||
"load": "I-load",
|
||||
"name": "Ngalan sa klab",
|
||||
"create": "Paghimo og klab",
|
||||
"createTitle": "Paghimo og klab",
|
||||
"members": "Mga miyembro",
|
||||
"trainingDiary": "Talaan sa Pag-ensayo",
|
||||
"noAccess": "Wala pa kay access sa niining klab.",
|
||||
"requestAccess": "Mangayo og access",
|
||||
"openRequests": "Mga bukas nga hangyo sa access",
|
||||
"title": "Klab",
|
||||
"openAccessRequests": "Mga bukas nga hangyo sa access",
|
||||
"diary": "Talaan sa Pag-ensayo",
|
||||
"accessDenied": "Wala gitugotan ang access sa klab.",
|
||||
"errorLoadingRequests": "Napakyas sa pag-load sa mga bukas nga hangyo",
|
||||
"accessRequested": "Gipangayo na ang access.",
|
||||
"accessRequestPending": "Ang access alang sa klab gi-request. Palihug hulata gamay.",
|
||||
"accessRequestFailed": "Napakyas ang pag-submit sa hangyo sa access.",
|
||||
"mobileSelectHint": "Palihug pilia usa ka klab una aron magamit ang app sa smartphone."
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"register": "Magrehistro",
|
||||
"email": "E-mail",
|
||||
"password": "Password",
|
||||
"forgotPassword": "Nakalimot sa password?",
|
||||
"rememberMe": "Pabilin nga naka-login",
|
||||
"loginSuccess": "Malampusong naka-login",
|
||||
"logoutSuccess": "Malampusong naka-logout",
|
||||
"sessionExpired": "Namutang na ang imong sesyon. I-logout ka.",
|
||||
"noAccount": "Wala pa kay account?",
|
||||
"hasAccount": "Naa na kay account?",
|
||||
"toLogin": "Adto sa login",
|
||||
"loginFailed": "Napakyas ang login. Palihug susiha ang imong datos.",
|
||||
"registerSuccess": "Malampusong narehistro! Palihug susiha ang imong email aron i-activate ang account.",
|
||||
"registerFailed": "Napakyas ang pagrehistro. Palihug sulayi pag-usab.",
|
||||
"activate": "I-activate",
|
||||
"activateAccount": "I-activate ang account",
|
||||
"accountActivated": "Na-activate ang account! Maka-login na ka karon.",
|
||||
"activationFailed": "Napakyas ang activation. Palihug tan-awa ang link o sulayi pag-usab.",
|
||||
"forgotPasswordDescription": "Isulod ang imong email. Makadawat ka ug link aron i-reset ang password.",
|
||||
"sendResetLink": "Ipadala ang link",
|
||||
"sending": "Gina-padala...",
|
||||
"resetEmailSent": "Kon adunay account nga naggamit niini nga email, gipadala ang reset link. Palihug tan-awa ang inbox (apil ang spam).",
|
||||
"resetRequestFailed": "Napakyas ang hangyo. Palihug sulayi pag-usab.",
|
||||
"resetPassword": "I-reset ang password",
|
||||
"newPassword": "Bag-ong password",
|
||||
"confirmPassword": "Kumpirma ang password",
|
||||
"saveNewPassword": "I-save ang password",
|
||||
"saving": "Gina-save...",
|
||||
"passwordResetSuccess": "Malampusong nabag-o ang imong password. Maka-login na ka gamit ang bag-ong password.",
|
||||
"passwordsDoNotMatch": "Ang mga password wala magtugma.",
|
||||
"passwordTooShort": "Ang password kinahanglan labing menos 6 ka karakter.",
|
||||
"resetFailed": "Dili mabag-o ang password. Posible nga ang link wala na magamit."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Mga setting",
|
||||
"personalSettings": "Personal nga mga setting",
|
||||
"language": "Pinulongan",
|
||||
"languageDescription": "Pilia ang imong gusto nga pinulongan para sa app",
|
||||
"languageChanged": "Malampusong nabag-o ang pinulongan",
|
||||
"selectLanguage": "Pilia ang pinulongan"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Aleman (Deutsch)",
|
||||
"en-GB": "Iningles (GB)",
|
||||
"en-US": "Iningles (US)",
|
||||
"en-AU": "Iningles (AU)",
|
||||
"de-CH": "Swiss Aleman (Schwiizerdütsch)",
|
||||
"fr": "Pranses (Français)",
|
||||
"es": "Espanyol (Español)",
|
||||
"it": "Italiano (Italiano)",
|
||||
"pl": "Polako (Polski)",
|
||||
"ja": "Hapon (日本語)",
|
||||
"zh": "Tsino (中文)",
|
||||
"tl": "Tagalog",
|
||||
"th": "Thai (ไทย)",
|
||||
"fil": "Filipino",
|
||||
"ceb": "Cebuano (Binisaya)"
|
||||
},
|
||||
"messages": {
|
||||
"success": "Kalampusan",
|
||||
"error": "Sayop",
|
||||
"warning": "Pahimangno",
|
||||
"info": "Impormasyon",
|
||||
"confirm": "Kumpirma",
|
||||
"cancel": "Ikansela",
|
||||
"successfullyLoaded": "Malampuson nga na-load",
|
||||
"recordsFound": "Nakitang rekord",
|
||||
"recordsDisplayed": "gipakita",
|
||||
"invalidFile": "Dili balido nga file",
|
||||
"pleaseSelectImageFile": "Palihug pili ug image file.",
|
||||
"fileTooLarge": "Dako kaayo ang file",
|
||||
"imageMaxSize": "Ang hulagway kinahanglan dili molapas sa 5 MB.",
|
||||
"note": "Pahibalo"
|
||||
},
|
||||
"members": {
|
||||
"title": "Mitglieder",
|
||||
"memberInfo": "Mitglieder-Info",
|
||||
"activeMembers": "Aktive Mitglieder",
|
||||
"testMembers": "Testmitglieder",
|
||||
"inactiveMembers": "Inaktive Mitglieder",
|
||||
"generatePhoneList": "Telefonliste generieren",
|
||||
"groupPhotoCrop": "Gruppenfoto verarbeiten",
|
||||
"groupPhotoCropSaved": "Mitgliedsfoto aus Gruppenfoto gespeichert.",
|
||||
"onlyActiveMembers": "Es werden nur aktive Mitglieder ausgegeben",
|
||||
"updateRatings": "TTR/QTTR von myTischtennis aktualisieren",
|
||||
"updating": "Aktualisiere...",
|
||||
"transferMembers": "Mitglieder übertragen",
|
||||
"newMember": "Neues Mitglied",
|
||||
"editMember": "Mitglied bearbeiten",
|
||||
"createNewMember": "Neues Mitglied anlegen",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"street": "Straße",
|
||||
"postalCode": "PLZ",
|
||||
"city": "Ort",
|
||||
"birthdate": "Geburtsdatum",
|
||||
"phones": "Telefonnummern",
|
||||
"emails": "E-Mail-Adressen",
|
||||
"addPhone": "Telefonnummer hinzufügen",
|
||||
"addEmail": "E-Mail-Adresse hinzufügen",
|
||||
"phoneNumber": "Telefonnummer",
|
||||
"emailAddress": "E-Mail-Adresse",
|
||||
"parent": "Elternteil",
|
||||
"parentName": "Name (z.B. Mutter, Vater)",
|
||||
"primary": "Primär",
|
||||
"gender": "Geschlecht",
|
||||
"genderUnknown": "Unbekannt",
|
||||
"genderMale": "Männlich",
|
||||
"genderFemale": "Weiblich",
|
||||
"genderDiverse": "Divers",
|
||||
"picsInInternetAllowed": "Pics in Internet erlaubt",
|
||||
"testMembership": "Testmitgliedschaft",
|
||||
"memberFormHandedOver": "Mitgliedsformular ausgehändigt",
|
||||
"adultReleaseApproved": "Freigabe Erwachsene",
|
||||
"adultReserveApproved": "Ersatz bei Erwachsenen",
|
||||
"wantsToPlay": "Will spielen",
|
||||
"trainingGroups": "Trainingsgruppen",
|
||||
"noGroupsAssigned": "Keine Gruppen zugeordnet",
|
||||
"noGroupsAvailable": "Keine Gruppen verfügbar",
|
||||
"addGroup": "Gruppe hinzufügen...",
|
||||
"remove": "Entfernen",
|
||||
"image": "Bild",
|
||||
"selectFile": "Datei auswählen",
|
||||
"camera": "Kamera",
|
||||
"imagePreview": "Vorschau des Mitgliedsbildes",
|
||||
"change": "Ändern",
|
||||
"create": "Anlegen",
|
||||
"clearFields": "Felder leeren",
|
||||
"showInactiveMembers": "Inaktive Mitglieder anzeigen",
|
||||
"showTrainingParticipationsColumn": "Spalte „Trainingsteilnahmen“ anzeigen",
|
||||
"ageGroup": "Altersklasse",
|
||||
"ttSeasonFilter": "Saison (Stichtag)",
|
||||
"ttSeasonCurrentTag": "aktuell",
|
||||
"ttSeasonNextTag": "kommend",
|
||||
"ttStichtagHint": "Stichtag 1.1. (DTTB). Jungen: nur J-Klassen. Mädchen: J und M möglich.",
|
||||
"lastTrainingFilter": "Letztes Training",
|
||||
"lastTrainingFilterHasDate": "Mit erfasstem Datum",
|
||||
"lastTrainingFilterNoDate": "Ohne letztes Training",
|
||||
"lastTrainingFilterNotInTraining": "„Nicht mehr im Training“",
|
||||
"lastTrainingFilterHint": "AK-Spalte: laufende und nächste Saison. Weitere Details (letztes Training, Teilnahmen) auch beim Überfahren der Zeile.",
|
||||
"rowTooltipSeparator": "·",
|
||||
"ttAgeClassCol": "AK (TT)",
|
||||
"ttAdult": "Erwachsene (kein Jugend nach Stichtag)",
|
||||
"ttFilterGroupJ": "Mädchen & Jungen (gemischt)",
|
||||
"ttFilterGroupM": "Mädchen (nur weiblich)",
|
||||
"adults": "Erwachsene (18+)",
|
||||
"j19": "J19",
|
||||
"j17": "J17 (17 und jünger)",
|
||||
"j15": "J15",
|
||||
"j13": "J13",
|
||||
"j11": "J11",
|
||||
"j9": "J9",
|
||||
"m19": "M19",
|
||||
"m15": "M15",
|
||||
"m13": "M13",
|
||||
"m11": "M11",
|
||||
"m9": "M9",
|
||||
"clearFilters": "Filter zurücksetzen",
|
||||
"imageInternet": "Bild (Inet?)",
|
||||
"testMember": "Testm.",
|
||||
"name": "Name, Vorname",
|
||||
"ttrQttr": "TTR / QTTR",
|
||||
"address": "Adresse",
|
||||
"active": "Aktiv",
|
||||
"actions": "Aktionen",
|
||||
"phoneNumberShort": "Telefon-Nr.",
|
||||
"emailAddressShort": "Email-Adresse",
|
||||
"trainingParticipations": "Trainingsteilnahmen",
|
||||
"memberImage": "Mitgliedsbild",
|
||||
"inactive": "inaktiv",
|
||||
"sixOrMoreParticipations": "6 oder mehr Trainingsteilnahmen",
|
||||
"threeOrMoreParticipations": "3 oder mehr Trainingsteilnahmen",
|
||||
"noTestMembership": "Keine Testmitgliedschaft mehr",
|
||||
"formHandedOver": "Mitgliedsformular ausgehändigt",
|
||||
"deactivateMember": "Mitglied deaktivieren",
|
||||
"notes": "Notizen",
|
||||
"exercises": "Übungen",
|
||||
"memberImages": "Mitgliedsbilder",
|
||||
"testMembershipRemoved": "Gikuha ang test nga pagka-miyembro.",
|
||||
"errorRemovingTestMembership": "Napakyas sa pagtangtang sa test nga pagka-miyembro.",
|
||||
"formMarkedAsHandedOver": "Gimarkahan nga nadawat ang porma sa miyembro.",
|
||||
"errorMarkingForm": "Napakyas sa pagmarka sa porma.",
|
||||
"deactivateMemberTitle": "I-deactivate ang miyembro",
|
||||
"deactivateMemberConfirm": "Gusto ba nimo i-deactivate si \"{name}\"?",
|
||||
"memberDeactivated": "Na-deactivate ang miyembro",
|
||||
"errorDeactivatingMember": "Napakyas sa pag-deactivate sa miyembro",
|
||||
"errorSavingMember": "Napakyas sa pag-save sa miyembro",
|
||||
"errorAddingToGroup": "Napakyas sa pagdugang sa grupo",
|
||||
"errorRemovingFromGroup": "Napakyas sa pagtangtang gikan sa grupo",
|
||||
"imageUpdated": "Na-update ang hulagway",
|
||||
"errorRotatingImage": "Napakyas sa pag-ikot sa hulagway",
|
||||
"imageDeleted": "Na-delete ang hulagway.",
|
||||
"errorDeletingImage": "Dili matangtang ang hulagway",
|
||||
"primaryImageUpdated": "Na-update ang main nga hulagway.",
|
||||
"errorSettingPrimaryImage": "Napakyas sa pag-set sa main nga hulagway",
|
||||
"errorUploadingImage": "Napakyas sa pag-upload sa hulagway",
|
||||
"errorLoadingImage": "Napakyas sa pag-load sa hulagway",
|
||||
"phoneList": "phone-list.pdf",
|
||||
"errorLoadingTrainingParticipations": "Napakyas sa pag-load sa mga pag-apil sa pag-ensayo",
|
||||
"errorUpdatingRatings": "Napakyas sa pag-update sa TTR/QTTR values",
|
||||
"errorTransfer": "Napakyas sa pag-transfer",
|
||||
"subtitle": "Pangitaa, i-filter ug i-edit diretso ang mga miyembro.",
|
||||
"closeEditor": "Isira ang editor",
|
||||
"visibleMembers": "Mga miyembro nga makita",
|
||||
"search": "Pangitaa",
|
||||
"searchPlaceholder": "Pangitaa ang ngalan, lugar, telepono o e-mail",
|
||||
"memberScope": "Sakop sa miyembro",
|
||||
"scopeAll": "Tanan",
|
||||
"scopeActive": "Aktibo",
|
||||
"scopeTest": "Pagsulay",
|
||||
"scopeActiveTest": "Aktibo + Pagsulay",
|
||||
"scopeNeedsForm": "Porma wala pa masusi",
|
||||
"scopeActiveDataIncomplete": "Aktibo + kulang ang datos",
|
||||
"scopeDataIncomplete": "Kulang ang datos",
|
||||
"scopeInactive": "Dili aktibo",
|
||||
"searchAndFilter": "Pangitaa ug Filter",
|
||||
"bulkActions": "Daghang aksyon",
|
||||
"resultsVisible": "Mga miyembro nga makita",
|
||||
"editHint": "Usa ka click sa hilera aron maabli ang editor.",
|
||||
"editorCreateHint": "Paghimo ug bag-ong miyembro ug isulod dayon ang kontak.",
|
||||
"editorEditHint": "I-edit ang datos ni {name}.",
|
||||
"editorAssignTrainingGroupHint": "Palihug i-assign ang labing menos usa ka grupo sa pag-ensayo.",
|
||||
"editorRecommendedEntry": "Girekomendang entry",
|
||||
"transferSuccessTitle": "Malampusong pag-transfer",
|
||||
"transferErrorTitle": "Sayop sa pag-transfer",
|
||||
"clickTtRequestPending": "Nagpadayon ang Click-TT request",
|
||||
"clickTtRequestAction": "Mangayo og karapat-dapat sa duwa sa click-TT",
|
||||
"clickTtRequestTitle": "Sugdi ang Click-TT request",
|
||||
"clickTtRequestConfirm": "Sugdan ba ang automated Click-TT request para kang {name}?",
|
||||
"clickTtRequestHint": "Ang request awtomatikong gi-process sa backend pinaagi sa Click-TT workflow.",
|
||||
"clickTtRequestSuccess": "Palihug login sa click-tt ug isumite ang request.",
|
||||
"clickTtRequestError": "Dili masumite ang Click-TT request.",
|
||||
"clickTtRequestFailedLog": "Napakyas ang Click-TT request",
|
||||
"deleteImageTitle": "Tangtanga ang hulagway",
|
||||
"deleteImageConfirm": "Gusto ba nimo tangtangon gyud kini nga hulagway?",
|
||||
"parentFallback": "Ginikanan",
|
||||
"loadingMembers": "Gina-load ang mga miyembro...",
|
||||
"errorLoadingMembers": "Napakyas sa pag-load sa lista sa mga miyembro.",
|
||||
"scopeNotTraining": "Dili na sa pag-ensayo",
|
||||
"notInTrainingTooltip": "Wala mo-apil sulod sa {weeks} ka semana",
|
||||
"status": "Status",
|
||||
"contact": "Kontak",
|
||||
"noPhoneShort": "Walay telepono",
|
||||
"noEmailShort": "Walay e-mail",
|
||||
"noAddressShort": "Walay address",
|
||||
"clickTtSubmitted": "Click-TT gisumite",
|
||||
"markRegular": "I-mark isip regular",
|
||||
"markFormReceived": "Porma gisusi",
|
||||
"clickTtRequestShort": "Click-TT",
|
||||
"clickTtRequestPendingShort": "Nagdagan...",
|
||||
"memberDetails": "Detalye sa miyembro",
|
||||
"previewLastTraining": "Kinataposang pag-ensayo",
|
||||
"previewNoLastTraining": "Wala pay pag-apil",
|
||||
"phoneListForSelection": "Lista sa telepono para sa pagpili",
|
||||
"markFormsForSelection": "Susihon ang mga porma sa pagpili",
|
||||
"markRegularForSelection": "I-mark isip regular ang pagpili",
|
||||
"phoneListSelectionFile": "phone-list-selection.pdf",
|
||||
"batchFormsMarkedSuccess": "{count} ka porma gi-mark nga nasusi.",
|
||||
"batchFormsMarkedEmpty": "Walay wala pa masusi nga porma sa kasamtangang pagpili.",
|
||||
"batchMarkedRegularSuccess": "{count} ka trial nga miyembro gi-mark isip regular.",
|
||||
"batchMarkedRegularEmpty": "Walay trial nga miyembro sa kasamtangang pagpili.",
|
||||
"batchPartialFailure": "{success} malampuson, {failed} napakyas.",
|
||||
"sortBy": "Isort pinaagi sa",
|
||||
"sortLastName": "Apelyido",
|
||||
"sortFirstName": "Ngalan",
|
||||
"sortBirthday": "Adlawng natawhan",
|
||||
"sortAge": "Edad",
|
||||
"sortQttr": "QTTR value",
|
||||
"ageRange": "Edad gikan - hangtod",
|
||||
"ageFromPlaceholder": "gikan",
|
||||
"ageToPlaceholder": "hangtod",
|
||||
"age": "Edad",
|
||||
"dataQuality": "Kalidad sa datos",
|
||||
"dataQualityComplete": "Kompletong datos",
|
||||
"dataIssueBirthdate": "Walay petsa sa pagkahimugso",
|
||||
"dataIssuePhone": "Walay numero sa telepono",
|
||||
"dataIssueEmail": "Walay e-mail",
|
||||
"dataIssueAddress": "Walay address",
|
||||
"dataIssueStreet": "Walay ngalan sa dalan",
|
||||
"dataIssuePostalCode": "Walay postal code",
|
||||
"dataIssueCity": "Walay ngalan sa lugar",
|
||||
"dataIssueGender": "Walay klaro nga gender",
|
||||
"dataIssueTrainingGroup": "Walay grupo sa pag-ensayo",
|
||||
"openTasks": "Mga bukas nga buluhaton",
|
||||
"noOpenTasks": "Walay bukas nga buluhaton",
|
||||
"taskVerifyForm": "Susiha ang porma",
|
||||
"taskReviewTrialStatus": "Susiha ang trial status",
|
||||
"taskCheckTrainingStatus": "Susiha ang status sa pag-ensayo",
|
||||
"taskCheckDataQuality": "Susiha ang kalidad sa datos",
|
||||
"taskAssignTrainingGroup": "I-assign ang grupo sa pag-ensayo",
|
||||
"taskCheckClickTt": "Susiha ang Click-TT karapat-dapat",
|
||||
"taskActionVerify": "Susiha",
|
||||
"taskActionMarkRegular": "I-set isip regular",
|
||||
"taskActionReview": "Ablihi",
|
||||
"taskActionRequest": "Mangayo",
|
||||
"toggleSortDirection": "Usba ang direksyon sa sort",
|
||||
"showTtrHistory": "Ipakita ang TTR kasaysayan",
|
||||
"missingTtrHistoryId": "Walay myTischtennis ID",
|
||||
"sortLastTraining": "Kinataposang pag-ensayo",
|
||||
"sortOpenTasks": "Mga bukas nga buluhaton",
|
||||
"exportPreview": "Preview sa export",
|
||||
"exportMembersSelected": "Mga miyembro gipili karon",
|
||||
"exportPhones": "Telepono",
|
||||
"exportReachableByPhone": "uban ang numero sa telepono",
|
||||
"exportEmails": "E-mail",
|
||||
"exportReachableByEmail": "uban ang e-mail address",
|
||||
"exportPreviewNames": "Preview",
|
||||
"exportPreviewEmpty": "Walay miyembro sa kasamtangang pagpili",
|
||||
"exportCsv": "I-export ang CSV",
|
||||
"exportCsvFile": "mitglieder-auswahl.csv",
|
||||
"copyPhones": "Kopyaha ang mga telepono",
|
||||
"copyEmails": "Kopyaha ang mga e-mail",
|
||||
"copyPhonesSuccess": "Nakopya ang lista sa telepono sa clipboard.",
|
||||
"copyPhonesEmpty": "Walay numero sa telepono sa kasamtangang pagpili.",
|
||||
"copyEmailsSuccess": "Nakopya ang lista sa e-mail sa clipboard.",
|
||||
"copyEmailsEmpty": "Walay e-mail address sa kasamtangang pagpili.",
|
||||
"composeEmail": "Pag-andam sa e-mail",
|
||||
"copyContactSummary": "Kopyaha ang summario sa kontak",
|
||||
"copyContactSummarySuccess": "Nakopya ang summary sa kontak sa clipboard."
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
"sourceWarning": "{source} konnte nicht geladen werden",
|
||||
"agendaTitle": "Termine im Monat",
|
||||
"agendaEmpty": "Keine Termine in diesem Monat.",
|
||||
"recurringTrainingTime": "Regelmäßige Trainingszeit",
|
||||
"recurringTrainingTime": "Regelmässige Trainingszeit",
|
||||
"participants": "{count} Teilnehmer",
|
||||
"starts": "{count} Starts",
|
||||
"options": {
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"cancellation": {
|
||||
"title": "Training fällt aus",
|
||||
"description": "Blendet regelmäßige Trainingszeiten aus.",
|
||||
"description": "Blendet regelmässige Trainingszeiten aus.",
|
||||
"date": "Datum",
|
||||
"untilOptional": "Bis optional",
|
||||
"trainingGroups": "Trainingsgruppen",
|
||||
@@ -268,7 +268,8 @@
|
||||
"zh": "中文 (Chinesisch)",
|
||||
"tl": "Tagalog",
|
||||
"th": "ไทย (Thai)",
|
||||
"fil": "Filipino"
|
||||
"fil": "Filipino",
|
||||
"ceb": "Cebuano (Bisaya)"
|
||||
},
|
||||
"messages": {
|
||||
"success": "Erfolg",
|
||||
@@ -276,12 +277,13 @@
|
||||
"warning": "Warnig",
|
||||
"info": "Information",
|
||||
"confirm": "Bestätige",
|
||||
"cancel": "Abbreche"
|
||||
"cancel": "Abbreche",
|
||||
"fileTooLarge": "Datei zu gross",
|
||||
"imageMaxSize": "Das Bild darf maximal 5 MB gross sein."
|
||||
},
|
||||
"tournaments": {
|
||||
"internalStatsTitle": "Statistik interne Turniere (Einzel)",
|
||||
"internalStatsOpenButton": "Turnierstatistik (Einzel)",
|
||||
"internalStatsExportPdf": "Als PDF exportieren",
|
||||
"internalStatsAgeFilter": "Altersklassä & Gschlächt (Einzel)",
|
||||
"internalStatsFilterAgeBands": "Altersklassä",
|
||||
"internalStatsFilterGenderColumn": "Gschlächt",
|
||||
@@ -336,7 +338,7 @@
|
||||
"dataNotRecorded": "No nid erfasst",
|
||||
"resultsRanking": "Rangliste",
|
||||
"newSetPlaceholder": "Neue Satz, z. B. 11:7",
|
||||
"finishMatch": "Abschliesse",
|
||||
"finishMatch": "Abschliessen",
|
||||
"correctMatch": "Korrigiere",
|
||||
"markMatchLive": "Als laufend markiere",
|
||||
"unmarkMatchLive": "Laufend-Markierig entferne",
|
||||
@@ -402,7 +404,7 @@
|
||||
"problemDoublesDescription": "Ei oder meh Doppelklasse bruuched no Partnerzuewisige.",
|
||||
"problemDoublesAutoDescription": "D offeni Doppelklass cha direkt automatisch paarlet werde.",
|
||||
"doublesTournament": "Doppel-Turnier",
|
||||
"doublesTournamentHint": "Schaltet alli vorhandene Klasse uf Doppel und legt neui Klasse standardmässig als Doppel aa.",
|
||||
"doublesTournamentHint": "Schaltet alle vorhandenen Klassen auf Doppel und legt neue Klassen standardmässig als Doppel an.",
|
||||
"problemGroupsMissingTitle": "Gruppe no nid erstellt",
|
||||
"problemGroupsMissingDescription": "Für s Gruppeturnier muesed zerscht Gruppe aagleit werde.",
|
||||
"problemGroupMatchesTitle": "Gruppespiel no nid erzeugt",
|
||||
@@ -543,7 +545,9 @@
|
||||
"stageFlowReadinessIncompleteGroups": "{count} Gruppe(n) sind noch nicht vollständig entschieden",
|
||||
"stageFlowReadinessReady": "Übergang ist fachlich startklar",
|
||||
"stageFlowQualifiedPreviewTitle": "Aktuell qualifiziert",
|
||||
"stageFlowQualifiedPreviewEntry": "G{group} · Platz {position} · {name}"
|
||||
"stageFlowQualifiedPreviewEntry": "G{group} · Platz {position} · {name}",
|
||||
"maxGroupSize": "Maximale Gruppengrösse",
|
||||
"outOfCompetition": "Spieler aus A ausser Konkurrenz"
|
||||
},
|
||||
"members": {
|
||||
"title": "Mitglieder",
|
||||
@@ -563,7 +567,7 @@
|
||||
"createNewMember": "Neues Mitglied anlegen",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"street": "Straße",
|
||||
"street": "Strasse",
|
||||
"postalCode": "PLZ",
|
||||
"city": "Ort",
|
||||
"birthdate": "Geburtsdatum",
|
||||
@@ -673,7 +677,7 @@
|
||||
"errorUpdatingRatings": "Fehler beim Aktualisieren der TTR/QTTR-Werte",
|
||||
"errorTransfer": "Fehler bei der Übertragung",
|
||||
"subtitle": "Mitglieder suchen, filtern und direkt bearbeiten.",
|
||||
"closeEditor": "Editor schließen",
|
||||
"closeEditor": "Editor schliessen",
|
||||
"visibleMembers": "Sichtbare Mitglieder",
|
||||
"search": "Suche",
|
||||
"searchPlaceholder": "Name, Ort, Telefon oder E-Mail suchen",
|
||||
@@ -829,7 +833,7 @@
|
||||
"deleteDate": "Datum löschen",
|
||||
"createNew": "Neu anlegen",
|
||||
"quickCreate": "Schnellanlegen",
|
||||
"quickCreateNoSlot": "Kein freier Trainingstermin gefunden (gemäß Gruppenzeiten, ein Jahr voraus).",
|
||||
"quickCreateNoSlot": "Kein freier Trainingstermin gefunden (gemäss Gruppenzeiten, ein Jahr voraus).",
|
||||
"quickCreateFailed": "Der Trainingstag konnte nicht angelegt werden.",
|
||||
"gallery": "Mitglieder-Galerie",
|
||||
"galleryCreating": "Galerie wird erstellt…",
|
||||
@@ -1101,7 +1105,9 @@
|
||||
"secondHalfFull": "Rückrunde (ab 1. Januar)",
|
||||
"missingConfigSummary": "Fehlende Angaben:",
|
||||
"leagueFieldRequired": "Bitte eine Spielklasse auswählen und speichern.",
|
||||
"myTischtennisUrlRequired": "MyTischtennis-URL einfügen und parsen, um Team-ID und Ligendaten zu übernehmen."
|
||||
"myTischtennisUrlRequired": "MyTischtennis-URL einfügen und parsen, um Team-ID und Ligendaten zu übernehmen.",
|
||||
"fileTooLargeTitle": "Datei zu gross",
|
||||
"fileTooLarge": "{label} darf maximal 10 MB gross sein."
|
||||
},
|
||||
"pdfGenerator": {
|
||||
"teamLineupTitle": "Mannschaftsaufstellung",
|
||||
@@ -1114,6 +1120,61 @@
|
||||
"teamAgeGroupLabel": "Team Altersklasse:",
|
||||
"generatedAt": "Erstellt:",
|
||||
"lineupQttr": "(Q)TTR",
|
||||
"plannedLeagueLabel": "Geplante Spielklasse:"
|
||||
"plannedLeagueLabel": "Geplante Spielklasse:",
|
||||
"noWhiteJersey": "Kein weisses Trikot",
|
||||
"sportShorts": "Sportshorts (oder Sportröckchen), am besten auch nicht weiss"
|
||||
},
|
||||
"clubSettings": {
|
||||
"greetingText": "Begrüssungstext",
|
||||
"greetingPlaceholder": "Begrüssungstext für Heimspiele...",
|
||||
"greetingHint": "Dieser Text erscheint im Reiter \"Begrüssung\" des Spielberichtsbogens.",
|
||||
"requireStreet": "Strasse nötig"
|
||||
},
|
||||
"accident": {
|
||||
"close": "Schliessen"
|
||||
},
|
||||
"schedule": {
|
||||
"imageSize": "Bildgrösse"
|
||||
},
|
||||
"memberTransfer": {
|
||||
"bulkWrapperDescription": "Optional können Sie die äussere Struktur definieren, in die die Mitglieder-Array eingefügt wird. Verwenden Sie PLACEHOLDER_MEMBERS als Platzhalter für das Array der Mitglieder.",
|
||||
"bulkModeActiveDescription": "Das Template definiert das Format für ein einzelnes Mitglied. Die Mitglieder werden automatisch in ein Array gewrappt. Die äussere Struktur können Sie optional im \"Bulk-Wrapper-Template\" definieren (siehe unten).",
|
||||
"placeholders": {
|
||||
"street": "Strasse",
|
||||
"streetDesc": "Strasse und Hausnummer",
|
||||
"addressDesc": "Strasse und Ort kombiniert"
|
||||
}
|
||||
},
|
||||
"memberSelection": {
|
||||
"close": "Schliessen"
|
||||
},
|
||||
"matchReportApi": {
|
||||
"greeting": "Begrüssung"
|
||||
},
|
||||
"memberActivities": {
|
||||
"close": "Schliessen"
|
||||
},
|
||||
"memberGallery": {
|
||||
"imageSize": "Bildgrösse"
|
||||
},
|
||||
"myTischtennisHistory": {
|
||||
"close": "Schliessen"
|
||||
},
|
||||
"imageDialog": {
|
||||
"close": "Schliessen"
|
||||
},
|
||||
"baseDialog": {
|
||||
"close": "Schliessen",
|
||||
"resize": "Grösse ändern"
|
||||
},
|
||||
"dialogManager": {
|
||||
"close": "Schliessen"
|
||||
},
|
||||
"dialogExamples": {
|
||||
"largeModal": "Grosser Modal",
|
||||
"simpleModalText": "Dies ist ein einfacher modaler Dialog mit mittlerer Grösse.",
|
||||
"simpleModalText2": "Klicken Sie ausserhalb oder auf das X, um zu schliessen.",
|
||||
"largeModalTitle": "Grosser Modal Dialog",
|
||||
"largeModalText": "Dies ist ein grosser modaler Dialog."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +567,13 @@
|
||||
"noOrdersMember": "Für dieses Mitglied gibt es noch keine Bestellungen.",
|
||||
"noOrdersGlobal": "Es gibt aktuell keine Bestellungen.",
|
||||
"club": "Verein",
|
||||
"member": "Mitglied"
|
||||
"member": "Mitglied",
|
||||
"filterAllCompletionStates": "Alle Abschlüsse",
|
||||
"filterPaid": "Hat bezahlt",
|
||||
"filterHandedOver": "Artikel ausgehändigt",
|
||||
"showCompleted": "Abgeschlossene einblenden",
|
||||
"paidConfirmed": "Hat bezahlt",
|
||||
"budget": "Budget"
|
||||
},
|
||||
"diary": {
|
||||
"title": "Trainingstagebuch",
|
||||
@@ -2559,5 +2565,74 @@
|
||||
"generatedAt": "Erstellt:",
|
||||
"lineupQttr": "(Q)TTR",
|
||||
"plannedLeagueLabel": "Geplante Spielklasse:"
|
||||
},
|
||||
"billing": {
|
||||
"title": "Abrechnung",
|
||||
"subtitle": "Erstelle deine eigene monatliche Übungsstunden-Abrechnung.",
|
||||
"step1": "Schritt 1: Vorlage hinterlegen",
|
||||
"step2": "Schritt 2: Abrechnungslauf anlegen",
|
||||
"step3": "Schritt 3: PDF erzeugen und herunterladen",
|
||||
"templateSection": "Vorlage hochladen",
|
||||
"templateName": "Vorlagenname",
|
||||
"templateDescription": "Beschreibung",
|
||||
"templatePdf": "PDF-Vorlage",
|
||||
"uploadTemplate": "Vorlage speichern",
|
||||
"deleteTemplate": "Vorlage löschen",
|
||||
"deleteTemplateConfirm": "Diese Vorlage wirklich löschen?",
|
||||
"deleteTemplateSuccess": "Vorlage wurde gelöscht.",
|
||||
"deleteTemplateError": "Vorlage konnte nicht gelöscht werden.",
|
||||
"noTemplates": "Noch keine Vorlagen vorhanden.",
|
||||
"mappingTitle": "Positions-Mapping",
|
||||
"mappingHint": "Koordinaten je Feld einmalig anpassen und speichern. Diese Positionen werden bei „PDF erzeugen“ verwendet.",
|
||||
"openMappingEditor": "Mapping per Klick",
|
||||
"mappingEditorTitle": "Felder im PDF per Klick zuweisen",
|
||||
"mappingEditorHint": "Feld auswählen, dann in die PDF klicken. Die Position wird direkt übernommen.",
|
||||
"mappingField": "Feld",
|
||||
"autoSuggestMapping": "Auto-Vorschläge",
|
||||
"resetIbanBoxes": "IBAN-Boxen reset",
|
||||
"ibanBoxesReset": "IBAN-Boxen wurden zurückgesetzt.",
|
||||
"ibanLearnStart": "IBAN einlernen",
|
||||
"ibanLearnCancel": "IBAN-Lernen abbrechen",
|
||||
"ibanLearnStep1": "IBAN-Lernen: bitte auf die erste zu nutzende IBAN-Box klicken.",
|
||||
"ibanLearnStep2": "IBAN-Lernen: bitte auf die letzte zu nutzende IBAN-Box klicken.",
|
||||
"ibanLearnDone": "IBAN-Boxen wurden aus dem gewählten Bereich zugewiesen.",
|
||||
"mappingSuggested": "Auto-Vorschläge wurden eingetragen. Bitte prüfen und feinjustieren.",
|
||||
"mappingSuggestError": "Auto-Vorschläge konnten nicht ermittelt werden.",
|
||||
"mappingPreviewError": "PDF-Vorschau für Mapping konnte nicht geladen werden.",
|
||||
"saveMapping": "Mapping speichern",
|
||||
"mappingSaved": "Mapping wurde gespeichert.",
|
||||
"mappingSaveError": "Mapping konnte nicht gespeichert werden.",
|
||||
"runSection": "Abrechnungslauf",
|
||||
"template": "Vorlage",
|
||||
"monthFrom": "Monat von",
|
||||
"monthTo": "Monat bis",
|
||||
"selfRecipientName": "Eigener Name",
|
||||
"iban": "IBAN",
|
||||
"ibanWithoutCountry": "ohne Länderkennung",
|
||||
"hourlyRate": "Stunden-Gehalt",
|
||||
"hoursTotal": "Anzahl Stunden",
|
||||
"hoursAutoHint": "Anzahl Stunden wird automatisch aus den Trainingstagen berechnet: {hours} h ({count} Einheiten).",
|
||||
"sessionTime": "Zeit",
|
||||
"sessionLabel": "Bezeichner",
|
||||
"sessionHours": "Stunden",
|
||||
"sameAccountCheckbox": "Gleiches Konto wie letzte Abrechnung",
|
||||
"omitField": "nicht angeben",
|
||||
"noSessionsInRange": "Keine Trainingseinheiten im gewählten Zeitraum gefunden.",
|
||||
"locationText": "Ort",
|
||||
"documentDate": "Datum",
|
||||
"generateOwnBilling": "Eigene Abrechnung erzeugen",
|
||||
"generatePdf": "PDF erzeugen",
|
||||
"generateAndDownloadPdf": "PDF erzeugen + herunterladen",
|
||||
"generatingPdf": "Erzeuge PDF...",
|
||||
"generateSuccess": "PDF wurde erzeugt.",
|
||||
"generateError": "PDF konnte nicht erzeugt werden.",
|
||||
"downloadPdf": "PDF herunterladen",
|
||||
"downloadError": "PDF konnte nicht heruntergeladen werden.",
|
||||
"deleteBilling": "Löschen",
|
||||
"deleteConfirm": "Diese erzeugte Abrechnung wirklich löschen?",
|
||||
"deleteSuccess": "Abrechnung wurde gelöscht.",
|
||||
"deleteError": "Abrechnung konnte nicht gelöscht werden.",
|
||||
"runsTitle": "Bisherige Abrechnungen",
|
||||
"noRuns": "Noch keine Abrechnungen vorhanden."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +274,8 @@
|
||||
"zh": "中文 (Chinesisch)",
|
||||
"tl": "Tagalog (Tagalog)",
|
||||
"th": "ไทย (Thai)",
|
||||
"fil": "Filipino (Filipino)"
|
||||
"fil": "Filipino (Filipino)",
|
||||
"ceb": "Cebuano (Bisaya)"
|
||||
},
|
||||
"messages": {
|
||||
"success": "Erfolg",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
selectedLanguage: this.$i18n.locale,
|
||||
languageCodes: ['de', 'en-GB', 'en-US', 'en-AU', 'de-CH', 'fr', 'es', 'it', 'pl', 'ja', 'zh', 'tl', 'th', 'fil']
|
||||
languageCodes: ['de', 'en-GB', 'en-US', 'en-AU', 'de-CH', 'fr', 'es', 'it', 'pl', 'ja', 'zh', 'tl', 'th', 'fil', 'ceb']
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
# composeApp (Play Store / „Über die App“-Build)
|
||||
appVersionCode = "5"
|
||||
appVersionName = "1.2.1"
|
||||
appVersionCode = "6"
|
||||
appVersionName = "1.3.0"
|
||||
agp = "9.2.1"
|
||||
android-compileSdk = "35"
|
||||
android-minSdk = "24"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3
oauth-data.txt
Normal file
3
oauth-data.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
--- google ---
|
||||
514397639992-mvvnb7imgbjrleadfrl3a7u2kv6sqa7a.apps.googleusercontent.com
|
||||
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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trainingstagebuch",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
|
||||
|
||||
12542
scripts/.i18n-translate-cache.json
Normal file
12542
scripts/.i18n-translate-cache.json
Normal file
File diff suppressed because it is too large
Load Diff
124
scripts/apply-cognate-overrides.js
Normal file
124
scripts/apply-cognate-overrides.js
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Setzt bewusste Cognates/Produktnamen explizit in allen Locales (Wert = en-US oder de).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
|
||||
|
||||
const LOCALES = [
|
||||
'fr', 'es', 'it', 'pl', 'ja', 'zh', 'th', 'tl', 'fil',
|
||||
'en-US', 'en-GB', 'en-AU',
|
||||
];
|
||||
|
||||
// Keys, die in Nicht-DE-Locales explizit gesetzt werden (Wert aus en-US-Merge)
|
||||
const COGNATE_KEYS = [
|
||||
'common.filter', 'common.details', 'common.name', 'common.status',
|
||||
'common.optional', 'common.in', 'common.ok',
|
||||
'navigation.clickTtBrowser',
|
||||
'members.rowTooltipSeparator', 'members.ttrQttr', 'members.status',
|
||||
'members.clickTtRequestShort',
|
||||
'diary.standardDurationShort', 'diary.min',
|
||||
'trainingStats.name', 'trainingStats.ttr', 'trainingStats.qttr',
|
||||
'tournaments.knockoutLabel', 'tournaments.genderMixed', 'tournaments.optional',
|
||||
'tournaments.index', 'tournaments.diff',
|
||||
'billing.iban',
|
||||
'schedule.code', 'schedule.team', 'schedule.teams', 'schedule.vs',
|
||||
'teamManagement.team', 'teamManagement.teams',
|
||||
'courtDrawingTool.strokeTypeTopspin', 'courtDrawingTool.strokeTypeFlip',
|
||||
'courtDrawingTool.strokeTypeBlock',
|
||||
'matchReportApi.vs', 'courtDrawing.ok',
|
||||
'pdfGenerator.diff', 'pdfGenerator.lineupQttr',
|
||||
'pendingApprovals.email', 'permissions.email',
|
||||
'logs.details', 'logs.logDetailsLabels.ip',
|
||||
'dialogs.info.ok', 'dialogs.confirm.ok',
|
||||
'memberTransferDialog.server', 'memberTransferDialog.endpoint', 'memberTransferDialog.format',
|
||||
'myTischtennis.title', 'myTischtennisAccount.title', 'myTischtennisAccount.teams',
|
||||
'memberNotes.tags', 'nuscoreAnalyzer.title',
|
||||
'predefinedActivities.min',
|
||||
];
|
||||
|
||||
const EXTRA = {
|
||||
fr: { 'members.address': 'Adresse' },
|
||||
es: { 'members.address': 'Dirección' },
|
||||
it: { 'members.address': 'Indirizzo' },
|
||||
pl: { 'members.address': 'Adres' },
|
||||
ja: { 'members.address': '住所' },
|
||||
zh: { 'members.address': '地址' },
|
||||
th: { 'members.address': 'ที่อยู่' },
|
||||
tl: { 'members.address': 'Address', 'languages.th': 'Thai (ไทย)' },
|
||||
fil: { 'members.address': 'Address', 'languages.th': 'Thai (ไทย)' },
|
||||
};
|
||||
|
||||
function flatten(obj, prefix = '', out = {}) {
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
flatten(value, nextKey, out);
|
||||
} else if (typeof value === 'string') {
|
||||
out[nextKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deepMerge(base, override) {
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) return override ?? base;
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override || {})) {
|
||||
if (
|
||||
value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setByPath(obj, dotPath, value) {
|
||||
const parts = dotPath.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function buildOverrides(deFlat, targetFlat) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(targetFlat)) {
|
||||
if (value !== deFlat[key]) setByPath(out, key, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
|
||||
const enUs = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'en-US.json'), 'utf8'));
|
||||
const deFlat = flatten(de);
|
||||
const enMerged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), enUs));
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const localePath = path.join(LOCALES_DIR, `${locale}.json`);
|
||||
if (!fs.existsSync(localePath)) continue;
|
||||
const localeJson = JSON.parse(fs.readFileSync(localePath, 'utf8'));
|
||||
const merged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), localeJson));
|
||||
|
||||
for (const key of COGNATE_KEYS) {
|
||||
const val = enMerged[key] ?? deFlat[key];
|
||||
if (val !== undefined) merged[key] = val;
|
||||
}
|
||||
for (const [key, val] of Object.entries(EXTRA[locale] || {})) {
|
||||
merged[key] = val;
|
||||
}
|
||||
|
||||
const overrides = buildOverrides(deFlat, merged);
|
||||
fs.writeFileSync(localePath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
|
||||
const stillDe = Object.keys(deFlat).filter((k) => merged[k] === deFlat[k]).length;
|
||||
console.log(`${locale}: overrides=${Object.keys(flatten(overrides)).length}, stillDe=${stillDe}`);
|
||||
}
|
||||
120
scripts/apply-i18n-cache.py
Normal file
120
scripts/apply-i18n-cache.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Schreibt Locale-Overrides aus Cache + EN-Fallback – ohne API-Aufrufe."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOCALES_DIR = ROOT / "frontend" / "src" / "i18n" / "locales"
|
||||
CACHE_FILE = Path(__file__).resolve().parent / ".i18n-translate-cache.json"
|
||||
|
||||
TARGETS = {
|
||||
"fr": "fr",
|
||||
"es": "es",
|
||||
"it": "it",
|
||||
"pl": "pl",
|
||||
"ja": "ja",
|
||||
"zh": "zh-CN",
|
||||
"th": "th",
|
||||
"tl": "tl",
|
||||
"fil": "tl",
|
||||
}
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
if not isinstance(base, dict) or isinstance(base, list):
|
||||
return override if override is not None else base
|
||||
result = dict(base)
|
||||
for key, value in (override or {}).items():
|
||||
if (
|
||||
isinstance(value, dict)
|
||||
and not isinstance(value, list)
|
||||
and isinstance(result.get(key), dict)
|
||||
and not isinstance(result.get(key), list)
|
||||
):
|
||||
result[key] = deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def flatten(obj, prefix=""):
|
||||
out = {}
|
||||
for key, value in (obj or {}).items():
|
||||
next_key = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict) and not isinstance(value, list):
|
||||
out.update(flatten(value, next_key))
|
||||
elif isinstance(value, str):
|
||||
out[next_key] = value
|
||||
return out
|
||||
|
||||
|
||||
def set_by_path(obj, dot_path, value):
|
||||
parts = dot_path.split(".")
|
||||
cur = obj
|
||||
for part in parts[:-1]:
|
||||
if part not in cur or not isinstance(cur[part], dict):
|
||||
cur[part] = {}
|
||||
cur = cur[part]
|
||||
cur[parts[-1]] = value
|
||||
|
||||
|
||||
def build_overrides(de_flat, target_flat):
|
||||
out = {}
|
||||
for key, value in target_flat.items():
|
||||
if value != de_flat.get(key):
|
||||
set_by_path(out, key, value)
|
||||
return out
|
||||
|
||||
|
||||
def apply_locale(code: str, cache: dict, en_flat: dict, de_flat: dict) -> None:
|
||||
target = TARGETS[code]
|
||||
locale_path = LOCALES_DIR / f"{code}.json"
|
||||
locale_json = json.loads(locale_path.read_text(encoding="utf-8"))
|
||||
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
|
||||
merged = flatten(deep_merge(json.loads(json.dumps(de)), locale_json))
|
||||
|
||||
from_cache = from_en = still = 0
|
||||
for key, de_val in de_flat.items():
|
||||
if merged.get(key) != de_val:
|
||||
continue
|
||||
from_lang = "en" if en_flat.get(key) and en_flat[key] != de_val else "de"
|
||||
text = en_flat[key] if from_lang == "en" else de_val
|
||||
cache_key = f"{from_lang}|{target}|{text}"
|
||||
if cache_key in cache:
|
||||
merged[key] = cache[cache_key]
|
||||
from_cache += 1
|
||||
elif from_lang == "en":
|
||||
merged[key] = text
|
||||
from_en += 1
|
||||
else:
|
||||
still += 1
|
||||
|
||||
overrides = build_overrides(de_flat, merged)
|
||||
locale_path.write_text(json.dumps(overrides, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
f"[{code}] cache={from_cache} enFallback={from_en} stillDe={still} overrides={len(flatten(overrides))}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
codes = sys.argv[1:] or list(TARGETS.keys())
|
||||
cache = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
||||
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
|
||||
en_us = json.loads((LOCALES_DIR / "en-US.json").read_text(encoding="utf-8"))
|
||||
de_flat = flatten(de)
|
||||
en_flat = flatten(deep_merge(de, en_us))
|
||||
|
||||
for code in codes:
|
||||
if code not in TARGETS:
|
||||
print(f"skip unknown: {code}", file=sys.stderr)
|
||||
continue
|
||||
apply_locale(code, cache, en_flat, de_flat)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
192
scripts/check-i18n-completeness.js
Normal file
192
scripts/check-i18n-completeness.js
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Vergleicht alle frontend/src/i18n/locales/*.json mit de.json (wie deepMerge in frontend/src/i18n/index.js).
|
||||
*
|
||||
* Metriken pro Locale (Blatt-Strings, flatten):
|
||||
* - inheritedFromDe: Key fehlt in der Locale-Datei → Merge ergibt deutschen Text
|
||||
* - explicitStillDe: Key ist in der Locale gesetzt, Wert ist identisch zu de (Copy-Paste / vergessen)
|
||||
* - differsFromDe: abweichend von Deutsch (Zielsprache oder Englisch-Vorlage)
|
||||
* - enUsLeak: unter differsFromDe: Wert identisch zum en-US-Merge (typisch EN-Vorlage statt Zielsprache)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-i18n-completeness.js
|
||||
* node scripts/check-i18n-completeness.js --top 25 --locale fil
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SOURCE_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
|
||||
|
||||
function deepMergeMessages(base, override) {
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) {
|
||||
return override ?? base;
|
||||
}
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override || {})) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
result[key] &&
|
||||
typeof result[key] === 'object' &&
|
||||
!Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = deepMergeMessages(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function flatten(obj, prefix = '', out = {}) {
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
flatten(value, nextKey, out);
|
||||
} else if (typeof value === 'string') {
|
||||
out[nextKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Blatt-Keys, die in `override` explizit als String gesetzt sind */
|
||||
function overriddenLeafKeys(override, prefix = '', out = new Set()) {
|
||||
if (!override || typeof override !== 'object' || Array.isArray(override)) return out;
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
const p = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
overriddenLeafKeys(value, p, out);
|
||||
} else if (typeof value === 'string') {
|
||||
out.add(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function topPrefixes(keys, n = 12) {
|
||||
const counts = {};
|
||||
for (const k of keys) {
|
||||
const root = k.split('.')[0];
|
||||
counts[root] = (counts[root] || 0) + 1;
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let top = 15;
|
||||
let locale = null;
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--top' && argv[i + 1]) {
|
||||
top = parseInt(argv[++i], 10) || 15;
|
||||
} else if (argv[i] === '--locale' && argv[i + 1]) {
|
||||
locale = argv[++i];
|
||||
}
|
||||
}
|
||||
return { top, locale };
|
||||
}
|
||||
|
||||
function analyzeLocale(de, deFlat, deKeys, enUSMergedFlat, code) {
|
||||
const loc = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, `${code}.json`), 'utf8'));
|
||||
const merged = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), loc));
|
||||
const explicit = overriddenLeafKeys(loc);
|
||||
|
||||
const inheritedKeys = [];
|
||||
const explicitDeKeys = [];
|
||||
const enLeakKeys = [];
|
||||
|
||||
for (const k of deKeys) {
|
||||
if (merged[k] === deFlat[k]) {
|
||||
if (!explicit.has(k)) inheritedKeys.push(k);
|
||||
else explicitDeKeys.push(k);
|
||||
} else if (merged[k] === enUSMergedFlat[k]) {
|
||||
enLeakKeys.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
totalDe: deKeys.length,
|
||||
explicitLeaves: explicit.size,
|
||||
inheritedFromDe: inheritedKeys.length,
|
||||
explicitStillDe: explicitDeKeys.length,
|
||||
differsFromDe: deKeys.length - inheritedKeys.length - explicitDeKeys.length,
|
||||
enUsLeak: enLeakKeys.length,
|
||||
inheritedKeys,
|
||||
explicitDeKeys,
|
||||
enLeakKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { top, locale: onlyLocale } = parseArgs(process.argv);
|
||||
|
||||
const de = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'de.json'), 'utf8'));
|
||||
const deFlat = flatten(de);
|
||||
const deKeys = Object.keys(deFlat).sort();
|
||||
|
||||
const enUS = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'en-US.json'), 'utf8'));
|
||||
const enUSMergedFlat = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), enUS));
|
||||
|
||||
const files = fs
|
||||
.readdirSync(SOURCE_DIR)
|
||||
.filter((f) => f.endsWith('.json') && !f.endsWith('.backup'))
|
||||
.sort();
|
||||
|
||||
const rows = [];
|
||||
for (const file of files) {
|
||||
if (file === 'de.json') continue;
|
||||
const code = path.basename(file, '.json');
|
||||
if (onlyLocale && code !== onlyLocale) continue;
|
||||
rows.push(analyzeLocale(de, deFlat, deKeys, enUSMergedFlat, code));
|
||||
}
|
||||
|
||||
console.log(`Basis: de.json → ${deKeys.length} Blatt-Strings (flatten)\n`);
|
||||
console.log(
|
||||
[
|
||||
'Locale'.padEnd(14),
|
||||
'explicit'.padStart(8),
|
||||
'erbtsDE'.padStart(8),
|
||||
'=de expl'.padStart(10),
|
||||
'≠de'.padStart(6),
|
||||
'≈en-US'.padStart(8),
|
||||
].join(' '),
|
||||
);
|
||||
console.log('-'.repeat(58));
|
||||
|
||||
for (const r of rows.sort((a, b) => b.inheritedFromDe - a.inheritedFromDe)) {
|
||||
console.log(
|
||||
[
|
||||
r.code.padEnd(14),
|
||||
String(r.explicitLeaves).padStart(8),
|
||||
String(r.inheritedFromDe).padStart(8),
|
||||
String(r.explicitStillDe).padStart(10),
|
||||
String(r.differsFromDe).padStart(6),
|
||||
String(r.enUsLeak).padStart(8),
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
if (onlyLocale && rows.length === 1) {
|
||||
const r = rows[0];
|
||||
console.log(`\n--- Top-Namespace (erbte deutsche Strings) für ${r.code} ---`);
|
||||
for (const [p, c] of topPrefixes(r.inheritedKeys, top)) {
|
||||
console.log(` ${p}: ${c}`);
|
||||
}
|
||||
if (r.enLeakKeys.length) {
|
||||
console.log(`\n--- Beispiel en-US-Leaks (erste ${Math.min(12, r.enLeakKeys.length)}) ---`);
|
||||
for (const k of r.enLeakKeys.slice(0, 12)) {
|
||||
console.log(` ${k}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nLegende: erbtsDE = kein Blatt-Override in Locale-JSON (= deutsch); ≈en-US = Wert wie en-US-Merge');
|
||||
}
|
||||
|
||||
main();
|
||||
104
scripts/fill-de-ch-from-de.js
Normal file
104
scripts/fill-de-ch-from-de.js
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* de-CH: Alle ererbten de-Strings als Override mit CH-Orthografie (ß→ss, …).
|
||||
* Bestehende de-CH-Overrides bleiben erhalten.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
|
||||
|
||||
const CH_REPLACEMENTS = [
|
||||
[/ß/g, 'ss'],
|
||||
[/Straße/g, 'Strasse'],
|
||||
[/straße/g, 'strasse'],
|
||||
[/Grüße/g, 'Grüsse'],
|
||||
[/grüße/g, 'grüsse'],
|
||||
[/Gruß/g, 'Gruss'],
|
||||
[/gruß/g, 'gruss'],
|
||||
];
|
||||
|
||||
function toSwiss(text) {
|
||||
let out = text;
|
||||
for (const [re, rep] of CH_REPLACEMENTS) {
|
||||
out = out.replace(re, rep);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function flatten(obj, prefix = '', out = {}) {
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
flatten(value, nextKey, out);
|
||||
} else if (typeof value === 'string') {
|
||||
out[nextKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deepMerge(base, override) {
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) return override ?? base;
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override || {})) {
|
||||
if (
|
||||
value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setByPath(obj, dotPath, value) {
|
||||
const parts = dotPath.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function buildOverrides(deFlat, targetFlat) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(targetFlat)) {
|
||||
if (value !== deFlat[key]) setByPath(out, key, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
|
||||
const ch = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de-CH.json'), 'utf8'));
|
||||
const deFlat = flatten(de);
|
||||
const merged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), ch));
|
||||
|
||||
let patched = 0;
|
||||
for (const key of Object.keys(deFlat)) {
|
||||
if (merged[key] === deFlat[key]) {
|
||||
const swiss = toSwiss(deFlat[key]);
|
||||
if (swiss !== deFlat[key] || !Object.prototype.hasOwnProperty.call(flatten(ch), key)) {
|
||||
merged[key] = swiss;
|
||||
if (swiss !== deFlat[key]) patched++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Keys: CH-Text als Override wenn abweichend von de
|
||||
for (const key of Object.keys(deFlat)) {
|
||||
const swiss = toSwiss(deFlat[key]);
|
||||
if (merged[key] !== deFlat[key]) continue;
|
||||
merged[key] = swiss;
|
||||
}
|
||||
|
||||
const overrides = buildOverrides(deFlat, merged);
|
||||
const outPath = path.join(LOCALES_DIR, 'de-CH.json');
|
||||
fs.writeFileSync(outPath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
|
||||
|
||||
const stillDe = Object.keys(deFlat).filter((k) => merged[k] === deFlat[k]).length;
|
||||
console.log(`de-CH: overrides=${Object.keys(flatten(overrides)).length}, ss-patched=${patched}, stillDe=${stillDe}`);
|
||||
65
scripts/fill-de-extended-gaps.js
Normal file
65
scripts/fill-de-extended-gaps.js
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/** Ergänzt fehlende billing/orders-Keys in de-extended aus de. */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOCALES_DIR = path.join(__dirname, '../frontend/src/i18n/locales');
|
||||
|
||||
function flatten(obj, prefix = '', out = {}) {
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) flatten(value, nextKey, out);
|
||||
else if (typeof value === 'string') out[nextKey] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deepMerge(base, override) {
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) return override ?? base;
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override || {})) {
|
||||
if (
|
||||
value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setByPath(obj, dotPath, value) {
|
||||
const parts = dotPath.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
|
||||
const ext = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de-extended.json'), 'utf8'));
|
||||
const deFlat = flatten(de);
|
||||
const merged = flatten(deepMerge(JSON.parse(JSON.stringify(de)), ext));
|
||||
|
||||
const NAMESPACES = ['billing', 'orders'];
|
||||
let added = 0;
|
||||
for (const key of Object.keys(deFlat)) {
|
||||
if (!NAMESPACES.some((ns) => key.startsWith(`${ns}.`))) continue;
|
||||
if (!(key in flatten(ext)) || merged[key] === deFlat[key]) {
|
||||
setByPath(ext, key, deFlat[key]);
|
||||
merged[key] = deFlat[key];
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(LOCALES_DIR, 'de-extended.json'),
|
||||
`${JSON.stringify(ext, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const stillDe = Object.keys(deFlat).filter((k) => merged[k] === deFlat[k]).length;
|
||||
console.log(`de-extended: added ${added} keys, stillDe=${stillDe}`);
|
||||
237
scripts/fill-i18n-deep.py
Executable file
237
scripts/fill-i18n-deep.py
Executable file
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Füllt Locale-JSONs via deep-translator (Fallback wenn MyMemory/Google limitiert)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from pathlib import Path
|
||||
|
||||
from deep_translator import GoogleTranslator
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOCALES_DIR = ROOT / "frontend" / "src" / "i18n" / "locales"
|
||||
CACHE_FILE = Path(__file__).resolve().parent / ".i18n-translate-cache.json"
|
||||
|
||||
LOCALE_TARGETS = {
|
||||
"fr": "fr",
|
||||
"es": "es",
|
||||
"it": "it",
|
||||
"pl": "pl",
|
||||
"ja": "ja",
|
||||
"zh": "zh-CN",
|
||||
"th": "th",
|
||||
"tl": "tl",
|
||||
"fil": "tl",
|
||||
}
|
||||
|
||||
SKIP = {"de", "de-extended", "de-CH", "en-US", "en-GB", "en-AU"}
|
||||
FILL_ORDER = ["fr", "es", "it", "pl", "ja", "zh", "th", "tl", "fil"]
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r"\{[^}]+\}")
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
if not isinstance(base, dict) or isinstance(base, list):
|
||||
return override if override is not None else base
|
||||
result = dict(base)
|
||||
for key, value in (override or {}).items():
|
||||
if (
|
||||
isinstance(value, dict)
|
||||
and not isinstance(value, list)
|
||||
and isinstance(result.get(key), dict)
|
||||
and not isinstance(result.get(key), list)
|
||||
):
|
||||
result[key] = deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def flatten(obj, prefix=""):
|
||||
out = {}
|
||||
for key, value in (obj or {}).items():
|
||||
next_key = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict) and not isinstance(value, list):
|
||||
out.update(flatten(value, next_key))
|
||||
elif isinstance(value, str):
|
||||
out[next_key] = value
|
||||
return out
|
||||
|
||||
|
||||
def set_by_path(obj, dot_path, value):
|
||||
parts = dot_path.split(".")
|
||||
cur = obj
|
||||
for part in parts[:-1]:
|
||||
if part not in cur or not isinstance(cur[part], dict):
|
||||
cur[part] = {}
|
||||
cur = cur[part]
|
||||
cur[parts[-1]] = value
|
||||
|
||||
|
||||
def build_overrides(de_flat, target_flat):
|
||||
out = {}
|
||||
for key, value in target_flat.items():
|
||||
if value != de_flat.get(key):
|
||||
set_by_path(out, key, value)
|
||||
return out
|
||||
|
||||
|
||||
def protect_placeholders(text):
|
||||
tokens = []
|
||||
|
||||
def repl(m):
|
||||
token = f"__PH{len(tokens)}__"
|
||||
tokens.append(m.group(0))
|
||||
return token
|
||||
|
||||
return PLACEHOLDER_RE.sub(repl, text), tokens
|
||||
|
||||
|
||||
def restore_placeholders(text, tokens):
|
||||
out = text
|
||||
for i, token in enumerate(tokens):
|
||||
out = out.replace(f"__PH{i}__", token)
|
||||
return out
|
||||
|
||||
|
||||
def load_cache():
|
||||
if CACHE_FILE.exists():
|
||||
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
||||
return {}
|
||||
|
||||
|
||||
def save_cache(cache):
|
||||
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def fill_de_ch(de_flat, merged_flat):
|
||||
for key in de_flat:
|
||||
if merged_flat.get(key) == de_flat[key]:
|
||||
merged_flat[key] = de_flat[key].replace("ß", "ss")
|
||||
|
||||
|
||||
def translate_text(translator, cache, text, from_lang, to_lang, delay, timeout=20):
|
||||
cache_key = f"{from_lang}|{to_lang}|{text}"
|
||||
if cache_key in cache:
|
||||
return cache[cache_key]
|
||||
safe, tokens = protect_placeholders(text)
|
||||
|
||||
def _call():
|
||||
return translator.translate(safe)
|
||||
|
||||
last_err = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
raw = pool.submit(_call).result(timeout=timeout)
|
||||
out = restore_placeholders(raw, tokens)
|
||||
cache[cache_key] = out
|
||||
time.sleep(delay)
|
||||
return out
|
||||
except (FuturesTimeout, Exception) as e:
|
||||
last_err = e
|
||||
time.sleep(2 + attempt * 2)
|
||||
raise last_err
|
||||
|
||||
|
||||
def fill_locale(code, en_flat, cache, delay, dry_run):
|
||||
target = LOCALE_TARGETS[code]
|
||||
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
|
||||
de_flat = flatten(de)
|
||||
locale_path = LOCALES_DIR / f"{code}.json"
|
||||
locale_json = json.loads(locale_path.read_text(encoding="utf-8"))
|
||||
merged_flat = flatten(deep_merge(json.loads(json.dumps(de)), locale_json))
|
||||
|
||||
if code == "de-CH":
|
||||
fill_de_ch(de_flat, merged_flat)
|
||||
else:
|
||||
keys_to_fix = [k for k in de_flat if merged_flat.get(k) == de_flat[k]]
|
||||
unique = {}
|
||||
for k in keys_to_fix:
|
||||
from_lang = "en" if en_flat.get(k) and en_flat[k] != de_flat[k] else "de"
|
||||
text = en_flat[k] if from_lang == "en" else de_flat[k]
|
||||
unique.setdefault((from_lang, text), []).append(k)
|
||||
|
||||
print(f"[{code}] {len(keys_to_fix)} keys, {len(unique)} unique → {target}", flush=True)
|
||||
|
||||
by_source = {"en": [], "de": []}
|
||||
for (from_lang, text), keys in unique.items():
|
||||
by_source[from_lang].append((text, keys))
|
||||
|
||||
done = 0
|
||||
for from_lang in ("en", "de"):
|
||||
items = by_source[from_lang]
|
||||
if not items:
|
||||
continue
|
||||
translator = GoogleTranslator(source=from_lang, target=target)
|
||||
for text, keys in items:
|
||||
cache_key = f"{from_lang}|{target}|{text}"
|
||||
try:
|
||||
if dry_run:
|
||||
translated = f"[{target}] {text[:30]}"
|
||||
elif cache_key in cache:
|
||||
translated = cache[cache_key]
|
||||
else:
|
||||
translated = translate_text(translator, cache, text, from_lang, target, delay)
|
||||
for k in keys:
|
||||
merged_flat[k] = translated
|
||||
done += 1
|
||||
if done % 50 == 0:
|
||||
print(f"[{code}] {done}/{len(unique)}", flush=True)
|
||||
save_cache(cache)
|
||||
except Exception as e:
|
||||
print(f"[{code}] skip: {text[:40]}… ({e})", file=sys.stderr, flush=True)
|
||||
if from_lang == "en" and en_flat:
|
||||
for k in keys:
|
||||
merged_flat[k] = text
|
||||
|
||||
save_cache(cache)
|
||||
|
||||
overrides = build_overrides(de_flat, merged_flat)
|
||||
if not dry_run:
|
||||
locale_path.write_text(
|
||||
json.dumps(overrides, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
still_de = sum(1 for k in de_flat if merged_flat.get(k) == de_flat[k])
|
||||
print(f"[{code}] overrides={len(flatten(overrides))}, stillDe={still_de}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--locale")
|
||||
parser.add_argument("--all", action="store_true")
|
||||
parser.add_argument("--from-locale", dest="from_locale", help="Bei --all: ab dieser Locale fortsetzen")
|
||||
parser.add_argument("--delay", type=float, default=0.35)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
codes = FILL_ORDER if args.all else [args.locale] if args.locale else None
|
||||
if codes and args.from_locale:
|
||||
if args.from_locale not in FILL_ORDER:
|
||||
parser.error(f"Unknown locale: {args.from_locale}")
|
||||
codes = FILL_ORDER[FILL_ORDER.index(args.from_locale) :]
|
||||
if not codes:
|
||||
parser.error("Usage: --locale <code> | --all")
|
||||
|
||||
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
|
||||
en_us = json.loads((LOCALES_DIR / "en-US.json").read_text(encoding="utf-8"))
|
||||
en_flat = flatten(deep_merge(de, en_us))
|
||||
cache = load_cache()
|
||||
|
||||
for code in codes:
|
||||
if code in SKIP or code not in LOCALE_TARGETS:
|
||||
continue
|
||||
fill_locale(code, en_flat, cache, args.delay, args.dry_run)
|
||||
time.sleep(5)
|
||||
|
||||
save_cache(cache)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
385
scripts/fill-i18n-locales.js
Normal file
385
scripts/fill-i18n-locales.js
Normal file
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Füllt Locale-JSONs: Blätter, die nach deepMerge(de, locale) noch Deutsch sind,
|
||||
* werden übersetzt (MyMemory, Fallback Google) und als Override geschrieben.
|
||||
*
|
||||
* Cache: scripts/.i18n-translate-cache.json
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/fill-i18n-locales.js --locale en-US
|
||||
* node scripts/fill-i18n-locales.js --all --delay 450
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
|
||||
const CACHE_FILE = path.join(__dirname, '.i18n-translate-cache.json');
|
||||
const TRANSLATE_PKG = path.join(ROOT, 'frontend', 'node_modules', '@vitalets/google-translate-api');
|
||||
|
||||
const PLACEHOLDER_RE = /\{[^}]+\}/g;
|
||||
|
||||
const LOCALE_TARGETS = {
|
||||
'en-US': 'en',
|
||||
'en-GB': 'en',
|
||||
'en-AU': 'en',
|
||||
es: 'es',
|
||||
fr: 'fr',
|
||||
it: 'it',
|
||||
pl: 'pl',
|
||||
ja: 'ja',
|
||||
zh: 'zh-CN',
|
||||
th: 'th',
|
||||
tl: 'tl',
|
||||
fil: 'tl',
|
||||
'de-CH': 'de',
|
||||
};
|
||||
|
||||
const SKIP_LOCALES = new Set(['de', 'de-extended']);
|
||||
|
||||
const ALL_FILL_ORDER = [
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-AU',
|
||||
'fr',
|
||||
'es',
|
||||
'it',
|
||||
'pl',
|
||||
'ja',
|
||||
'zh',
|
||||
'th',
|
||||
'tl',
|
||||
'fil',
|
||||
'de-CH',
|
||||
];
|
||||
|
||||
function deepMergeMessages(base, override) {
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) {
|
||||
return override ?? base;
|
||||
}
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override || {})) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
result[key] &&
|
||||
typeof result[key] === 'object' &&
|
||||
!Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = deepMergeMessages(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function flatten(obj, prefix = '', out = {}) {
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
flatten(value, nextKey, out);
|
||||
} else if (typeof value === 'string') {
|
||||
out[nextKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setByPath(obj, dotPath, value) {
|
||||
const parts = dotPath.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') {
|
||||
cur[parts[i]] = {};
|
||||
}
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function buildOverrides(deFlat, targetFlat) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(targetFlat)) {
|
||||
if (value !== deFlat[key]) {
|
||||
setByPath(out, key, value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function protectPlaceholders(text) {
|
||||
const tokens = [];
|
||||
const safe = text.replace(PLACEHOLDER_RE, (m) => {
|
||||
const token = `__PH${tokens.length}__`;
|
||||
tokens.push(m);
|
||||
return token;
|
||||
});
|
||||
return { safe, tokens };
|
||||
}
|
||||
|
||||
function restorePlaceholders(text, tokens) {
|
||||
let out = text;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
out = out.replace(new RegExp(`__\\s*PH\\s*${i}\\s*__`, 'gi'), tokens[i]);
|
||||
out = out.replace(`__PH${i}__`, tokens[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadCache() {
|
||||
if (!fs.existsSync(CACHE_FILE)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCache(cache) {
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let locale = null;
|
||||
let all = false;
|
||||
let delay = 450;
|
||||
let dryRun = false;
|
||||
let noGoogle = false;
|
||||
let enFallback = false;
|
||||
let skipEn = false;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--locale' && args[i + 1]) locale = args[++i];
|
||||
else if (args[i] === '--all') all = true;
|
||||
else if (args[i] === '--delay' && args[i + 1]) delay = parseInt(args[++i], 10) || 450;
|
||||
else if (args[i] === '--dry-run') dryRun = true;
|
||||
else if (args[i] === '--no-google') noGoogle = true;
|
||||
else if (args[i] === '--en-fallback') enFallback = true;
|
||||
else if (args[i] === '--skip-en') skipEn = true;
|
||||
}
|
||||
return { locale, all, delay, dryRun, noGoogle, enFallback, skipEn };
|
||||
}
|
||||
|
||||
async function mymemoryTranslate(text, from, to) {
|
||||
const url = new URL('https://api.mymemory.translated.net/get');
|
||||
url.searchParams.set('q', text.slice(0, 450));
|
||||
url.searchParams.set('langpair', `${from}|${to}`);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.quotaFinished) {
|
||||
throw new Error('MyMemory quota finished');
|
||||
}
|
||||
if (data.responseStatus !== 200) {
|
||||
throw new Error(data.responseDetails || `MyMemory status ${data.responseStatus}`);
|
||||
}
|
||||
return data.responseData.translatedText;
|
||||
}
|
||||
|
||||
async function googleTranslate(translateFn, text, from, to) {
|
||||
const res = await translateFn(text, { from, to });
|
||||
return res.text;
|
||||
}
|
||||
|
||||
async function translateText(providers, text, from, to, cache) {
|
||||
const cacheKey = `${from}|${to}|${text}`;
|
||||
if (cache[cacheKey]) return cache[cacheKey];
|
||||
|
||||
const { safe, tokens } = protectPlaceholders(text);
|
||||
let out;
|
||||
let lastErr;
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const raw = await provider(safe, from, to);
|
||||
out = restorePlaceholders(raw, tokens);
|
||||
out = out.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
cache[cacheKey] = out;
|
||||
return out;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function bootstrapEnFromGb(deFlat, mergedFlat) {
|
||||
const gbJson = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'en-GB.json'), 'utf8'));
|
||||
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
|
||||
const gbFlat = flatten(deepMergeMessages(de, gbJson));
|
||||
let n = 0;
|
||||
for (const k of Object.keys(deFlat)) {
|
||||
if (mergedFlat[k] === deFlat[k] && gbFlat[k] && gbFlat[k] !== deFlat[k]) {
|
||||
mergedFlat[k] = gbFlat[k];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function bootstrapEnFromUs(deFlat, mergedFlat, enFlat) {
|
||||
if (!enFlat) return 0;
|
||||
let n = 0;
|
||||
for (const k of Object.keys(deFlat)) {
|
||||
if (mergedFlat[k] === deFlat[k] && enFlat[k] && enFlat[k] !== deFlat[k]) {
|
||||
mergedFlat[k] = enFlat[k];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function fillLocale(code, opts) {
|
||||
const { providers, cache, delay, dryRun, enFlat, enFallback } = opts;
|
||||
const targetLang = LOCALE_TARGETS[code];
|
||||
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
|
||||
const deFlat = flatten(de);
|
||||
const localePath = path.join(LOCALES_DIR, `${code}.json`);
|
||||
const localeJson = JSON.parse(fs.readFileSync(localePath, 'utf8'));
|
||||
const mergedFlat = flatten(deepMergeMessages(JSON.parse(JSON.stringify(de)), localeJson));
|
||||
|
||||
if (code === 'en-US' || code === 'en-AU') {
|
||||
const copied = bootstrapEnFromGb(deFlat, mergedFlat);
|
||||
if (copied) console.log(`[${code}] bootstrapped ${copied} strings from en-GB`);
|
||||
}
|
||||
if ((code === 'en-GB' || code === 'en-AU') && enFlat) {
|
||||
const copied = bootstrapEnFromUs(deFlat, mergedFlat, enFlat);
|
||||
if (copied) console.log(`[${code}] bootstrapped ${copied} strings from en-US`);
|
||||
}
|
||||
|
||||
const keysToFix = Object.keys(deFlat).filter((k) => mergedFlat[k] === deFlat[k]);
|
||||
if (!keysToFix.length) {
|
||||
console.log(`[${code}] nothing to fill`);
|
||||
if (!dryRun) {
|
||||
fs.writeFileSync(localePath, `${JSON.stringify(buildOverrides(deFlat, mergedFlat), null, 2)}\n`, 'utf8');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueTexts = new Map();
|
||||
for (const k of keysToFix) {
|
||||
const from = enFlat && enFlat[k] && enFlat[k] !== deFlat[k] ? 'en' : 'de';
|
||||
const text = from === 'en' ? enFlat[k] : deFlat[k];
|
||||
const mapKey = `${from}\0${text}`;
|
||||
if (!uniqueTexts.has(mapKey)) uniqueTexts.set(mapKey, { from, text, keys: [] });
|
||||
uniqueTexts.get(mapKey).keys.push(k);
|
||||
}
|
||||
|
||||
console.log(`[${code}] ${keysToFix.length} keys, ${uniqueTexts.size} unique, target=${targetLang}`);
|
||||
|
||||
if (code === 'de-CH') {
|
||||
for (const k of keysToFix) {
|
||||
const base = deFlat[k];
|
||||
mergedFlat[k] = base.replace(/ß/g, 'ss');
|
||||
}
|
||||
} else {
|
||||
let done = 0;
|
||||
const failed = [];
|
||||
for (const entry of uniqueTexts.values()) {
|
||||
const { from, text, keys } = entry;
|
||||
const cacheKey = `${from}|${targetLang}|${text}`;
|
||||
let translated;
|
||||
if (from === 'en' && targetLang === 'en') {
|
||||
translated = text;
|
||||
} else if (cache[cacheKey]) {
|
||||
translated = cache[cacheKey];
|
||||
} else if (dryRun) {
|
||||
translated = `[${targetLang}] ${text.slice(0, 30)}`;
|
||||
} else {
|
||||
try {
|
||||
translated = await translateText(providers, text, from, targetLang, cache);
|
||||
await sleep(delay);
|
||||
} catch (e) {
|
||||
console.error(`[${code}] skip: ${text.slice(0, 50)}… (${e.message})`);
|
||||
failed.push(entry);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const k of keys) mergedFlat[k] = translated;
|
||||
done++;
|
||||
if (done % 50 === 0) {
|
||||
console.log(`[${code}] ${done}/${uniqueTexts.size} (failed ${failed.length})`);
|
||||
saveCache(cache);
|
||||
}
|
||||
}
|
||||
saveCache(cache);
|
||||
|
||||
if (failed.length && !dryRun) {
|
||||
console.log(`[${code}] retrying ${failed.length} failed strings…`);
|
||||
await sleep(5000);
|
||||
for (const { from, text, keys } of failed) {
|
||||
const cacheKey = `${from}|${targetLang}|${text}`;
|
||||
try {
|
||||
const translated = cache[cacheKey] || (await translateText(providers, text, from, targetLang, cache));
|
||||
for (const k of keys) mergedFlat[k] = translated;
|
||||
await sleep(delay);
|
||||
} catch (e) {
|
||||
if (enFallback && from === 'en') {
|
||||
for (const k of keys) mergedFlat[k] = text;
|
||||
} else {
|
||||
console.error(`[${code}] final skip: ${text.slice(0, 40)}…`);
|
||||
}
|
||||
}
|
||||
}
|
||||
saveCache(cache);
|
||||
}
|
||||
}
|
||||
|
||||
const overrides = buildOverrides(deFlat, mergedFlat);
|
||||
if (!dryRun) {
|
||||
fs.writeFileSync(localePath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
const stillDe = Object.keys(deFlat).filter((k) => mergedFlat[k] === deFlat[k]).length;
|
||||
console.log(`[${code}] overrides=${Object.keys(flatten(overrides)).length}, stillDe=${stillDe}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { locale, all, delay, dryRun, noGoogle, enFallback, skipEn } = parseArgs();
|
||||
const providers = [
|
||||
(text, from, to) => mymemoryTranslate(text, from, to),
|
||||
];
|
||||
if (!noGoogle && fs.existsSync(TRANSLATE_PKG)) {
|
||||
const { translate: translateFn } = require(TRANSLATE_PKG);
|
||||
providers.push((text, from, to) => googleTranslate(translateFn, text, from, to));
|
||||
}
|
||||
|
||||
const cache = loadCache();
|
||||
let codes = all ? ALL_FILL_ORDER : locale ? [locale] : null;
|
||||
if (skipEn && codes) {
|
||||
codes = codes.filter((c) => !['en-US', 'en-GB', 'en-AU'].includes(c));
|
||||
}
|
||||
if (!codes) {
|
||||
console.error('Usage: --locale <code> | --all [--delay ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let enFlat = null;
|
||||
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
|
||||
const enUsPath = path.join(LOCALES_DIR, 'en-US.json');
|
||||
if (fs.existsSync(enUsPath)) {
|
||||
enFlat = flatten(deepMergeMessages(de, JSON.parse(fs.readFileSync(enUsPath, 'utf8'))));
|
||||
}
|
||||
|
||||
for (const code of codes) {
|
||||
if (SKIP_LOCALES.has(code)) continue;
|
||||
if (!(code in LOCALE_TARGETS)) continue;
|
||||
await fillLocale(code, { providers, cache, delay, dryRun, enFlat, enFallback });
|
||||
if (all && !dryRun) await sleep(8000);
|
||||
if (code === 'en-US' && !dryRun) {
|
||||
enFlat = flatten(deepMergeMessages(de, JSON.parse(fs.readFileSync(enUsPath, 'utf8'))));
|
||||
}
|
||||
}
|
||||
saveCache(cache);
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
196
scripts/fix-en-leaks.py
Normal file
196
scripts/fix-en-leaks.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Übersetzt Keys, deren Locale-Wert noch dem en-US-Merge entspricht (EN-Leak)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from pathlib import Path
|
||||
|
||||
from deep_translator import GoogleTranslator
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOCALES_DIR = ROOT / "frontend" / "src" / "i18n" / "locales"
|
||||
CACHE_FILE = Path(__file__).resolve().parent / ".i18n-translate-cache.json"
|
||||
|
||||
TARGETS = {
|
||||
"fr": "fr", "es": "es", "it": "it", "pl": "pl",
|
||||
"ja": "ja", "zh": "zh-CN", "th": "th", "tl": "tl", "fil": "tl",
|
||||
}
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r"\{[^}]+\}")
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
if not isinstance(base, dict) or isinstance(base, list):
|
||||
return override if override is not None else base
|
||||
result = dict(base)
|
||||
for key, value in (override or {}).items():
|
||||
if (
|
||||
isinstance(value, dict) and not isinstance(value, list)
|
||||
and isinstance(result.get(key), dict) and not isinstance(result.get(key), list)
|
||||
):
|
||||
result[key] = deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def flatten(obj, prefix=""):
|
||||
out = {}
|
||||
for key, value in (obj or {}).items():
|
||||
next_key = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict) and not isinstance(value, list):
|
||||
out.update(flatten(value, next_key))
|
||||
elif isinstance(value, str):
|
||||
out[next_key] = value
|
||||
return out
|
||||
|
||||
|
||||
def set_by_path(obj, dot_path, value):
|
||||
parts = dot_path.split(".")
|
||||
cur = obj
|
||||
for part in parts[:-1]:
|
||||
if part not in cur or not isinstance(cur[part], dict):
|
||||
cur[part] = {}
|
||||
cur = cur[part]
|
||||
cur[parts[-1]] = value
|
||||
|
||||
|
||||
def build_overrides(de_flat, target_flat):
|
||||
out = {}
|
||||
for key, value in target_flat.items():
|
||||
if value != de_flat.get(key):
|
||||
set_by_path(out, key, value)
|
||||
return out
|
||||
|
||||
|
||||
def protect_placeholders(text):
|
||||
tokens = []
|
||||
def repl(m):
|
||||
token = f"__PH{len(tokens)}__"
|
||||
tokens.append(m.group(0))
|
||||
return token
|
||||
return PLACEHOLDER_RE.sub(repl, text), tokens
|
||||
|
||||
|
||||
def restore_placeholders(text, tokens):
|
||||
out = text
|
||||
for i, token in enumerate(tokens):
|
||||
out = out.replace(f"__PH{i}__", token)
|
||||
return out
|
||||
|
||||
|
||||
def load_cache():
|
||||
if CACHE_FILE.exists():
|
||||
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
||||
return {}
|
||||
|
||||
|
||||
def save_cache(cache):
|
||||
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def translate_en(translator, cache, text, target, delay, timeout=25):
|
||||
cache_key = f"en|{target}|{text}"
|
||||
if cache_key in cache:
|
||||
return cache[cache_key]
|
||||
safe, tokens = protect_placeholders(text)
|
||||
|
||||
def _call():
|
||||
return translator.translate(safe)
|
||||
|
||||
last_err = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
raw = pool.submit(_call).result(timeout=timeout)
|
||||
out = restore_placeholders(raw, tokens)
|
||||
cache[cache_key] = out
|
||||
time.sleep(delay)
|
||||
return out
|
||||
except (FuturesTimeout, Exception) as e:
|
||||
last_err = e
|
||||
time.sleep(2 + attempt * 2)
|
||||
raise last_err
|
||||
|
||||
|
||||
def fix_locale(code, de_flat, en_flat, cache, delay, dry_run):
|
||||
target = TARGETS[code]
|
||||
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
|
||||
locale_path = LOCALES_DIR / f"{code}.json"
|
||||
locale_json = json.loads(locale_path.read_text(encoding="utf-8"))
|
||||
merged = flatten(deep_merge(json.loads(json.dumps(de)), locale_json))
|
||||
|
||||
leaks = [
|
||||
k for k in de_flat
|
||||
if merged.get(k) == en_flat.get(k) and en_flat.get(k) != de_flat.get(k)
|
||||
]
|
||||
unique_texts = {}
|
||||
for k in leaks:
|
||||
text = en_flat[k]
|
||||
unique_texts.setdefault(text, []).append(k)
|
||||
|
||||
print(f"[{code}] {len(leaks)} EN-leaks, {len(unique_texts)} unique → {target}", flush=True)
|
||||
if not unique_texts:
|
||||
return
|
||||
|
||||
translator = GoogleTranslator(source="en", target=target)
|
||||
done = 0
|
||||
for text, keys in unique_texts.items():
|
||||
try:
|
||||
if dry_run:
|
||||
translated = f"[{target}] {text[:25]}"
|
||||
else:
|
||||
translated = translate_en(translator, cache, text, target, delay)
|
||||
for k in keys:
|
||||
merged[k] = translated
|
||||
done += 1
|
||||
if done % 50 == 0:
|
||||
print(f"[{code}] {done}/{len(unique_texts)}", flush=True)
|
||||
save_cache(cache)
|
||||
except Exception as e:
|
||||
print(f"[{code}] skip: {text[:40]}… ({e})", file=sys.stderr, flush=True)
|
||||
|
||||
save_cache(cache)
|
||||
overrides = build_overrides(de_flat, merged)
|
||||
if not dry_run:
|
||||
locale_path.write_text(json.dumps(overrides, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
en_leaks_left = sum(
|
||||
1 for k in de_flat
|
||||
if merged.get(k) == en_flat.get(k) and en_flat.get(k) != de_flat.get(k)
|
||||
)
|
||||
print(f"[{code}] overrides={len(flatten(overrides))}, enLeaksLeft={en_leaks_left}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("locales", nargs="*", default=list(TARGETS.keys()))
|
||||
parser.add_argument("--delay", type=float, default=0.2)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
de = json.loads((LOCALES_DIR / "de.json").read_text(encoding="utf-8"))
|
||||
en_us = json.loads((LOCALES_DIR / "en-US.json").read_text(encoding="utf-8"))
|
||||
de_flat = flatten(de)
|
||||
en_flat = flatten(deep_merge(json.loads(json.dumps(de)), en_us))
|
||||
cache = load_cache()
|
||||
|
||||
for code in args.locales:
|
||||
if code not in TARGETS:
|
||||
print(f"skip {code}", file=sys.stderr)
|
||||
continue
|
||||
fix_locale(code, de_flat, en_flat, cache, args.delay, args.dry_run)
|
||||
time.sleep(3)
|
||||
|
||||
save_cache(cache)
|
||||
print("Done.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
49
scripts/patch-de-ch-swiss.js
Normal file
49
scripts/patch-de-ch-swiss.js
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
/** Setzt nur ß→ss Overrides in de-CH.json, ohne bestehende Einträge zu löschen. */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const LOCALES_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales');
|
||||
|
||||
function flatten(obj, prefix = '', out = {}) {
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
flatten(value, nextKey, out);
|
||||
} else if (typeof value === 'string') {
|
||||
out[nextKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setByPath(obj, dotPath, value) {
|
||||
const parts = dotPath.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
const de = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'de.json'), 'utf8'));
|
||||
const deFlat = flatten(de);
|
||||
const chPath = path.join(LOCALES_DIR, 'de-CH.json');
|
||||
const ch = JSON.parse(fs.readFileSync(chPath, 'utf8'));
|
||||
const chFlat = flatten(ch);
|
||||
|
||||
let n = 0;
|
||||
for (const [key, deVal] of Object.entries(deFlat)) {
|
||||
if (!deVal.includes('ß')) continue;
|
||||
const swiss = deVal.replace(/ß/g, 'ss');
|
||||
if (chFlat[key] !== swiss) {
|
||||
setByPath(ch, key, swiss);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(chPath, `${JSON.stringify(ch, null, 2)}\n`, 'utf8');
|
||||
console.log(`de-CH: ${n} ß→ss patches applied`);
|
||||
47
scripts/patch-en-gb-au.js
Normal file
47
scripts/patch-en-gb-au.js
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/** UK/AU-Orthografie in en-GB / en-AU (gegenüber en-US). */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOCALES_DIR = path.join(__dirname, '../frontend/src/i18n/locales');
|
||||
|
||||
const UK_MAP = [
|
||||
[/\bcolor\b/gi, (m) => (m[0] === 'C' ? 'Colour' : 'colour')],
|
||||
[/\bcolors\b/gi, (m) => (m[0] === 'C' ? 'Colours' : 'colours')],
|
||||
[/\borganize\b/gi, (m) => (m[0] === 'O' ? 'Organise' : 'organise')],
|
||||
[/\borganized\b/gi, (m) => (m[0] === 'O' ? 'Organised' : 'organised')],
|
||||
[/\bcenter\b/gi, (m) => (m[0] === 'C' ? 'Centre' : 'centre')],
|
||||
[/\bfavorite\b/gi, (m) => (m[0] === 'F' ? 'Favourite' : 'favourite')],
|
||||
[/\bfavorites\b/gi, (m) => (m[0] === 'F' ? 'Favourites' : 'favourites')],
|
||||
[/\blabor\b/gi, (m) => (m[0] === 'L' ? 'Labour' : 'labour')],
|
||||
[/\bdefense\b/gi, (m) => (m[0] === 'D' ? 'Defence' : 'defence')],
|
||||
];
|
||||
|
||||
function applyUk(text) {
|
||||
let out = text;
|
||||
for (const [re, repl] of UK_MAP) {
|
||||
out = out.replace(re, repl);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(obj, fn) {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string') obj[key] = fn(value);
|
||||
else if (value && typeof value === 'object') walk(value, fn);
|
||||
}
|
||||
}
|
||||
|
||||
for (const locale of ['en-GB', 'en-AU']) {
|
||||
const p = path.join(LOCALES_DIR, `${locale}.json`);
|
||||
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
let n = 0;
|
||||
walk(data, (s) => {
|
||||
const t = applyUk(s);
|
||||
if (t !== s) n++;
|
||||
return t;
|
||||
});
|
||||
fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
console.log(`${locale}: ${n} UK spelling patches`);
|
||||
}
|
||||
19
scripts/run-fix-en-leaks.sh
Executable file
19
scripts/run-fix-en-leaks.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PY="$ROOT/scripts/.venv-i18n/bin/python"
|
||||
LOG="/tmp/fix-en-leaks.log"
|
||||
LOCALES=(th tl fil fr es it pl ja zh)
|
||||
|
||||
: >"$LOG"
|
||||
echo "[$(date -Iseconds)] start" | tee -a "$LOG"
|
||||
|
||||
for loc in "${LOCALES[@]}"; do
|
||||
echo "[$(date -Iseconds)] === $loc ===" | tee -a "$LOG"
|
||||
PYTHONUNBUFFERED=1 "$PY" -u "$ROOT/scripts/fix-en-leaks.py" "$loc" --delay 0.15 2>&1 | tee -a "$LOG" || {
|
||||
echo "[$(date -Iseconds)] FAILED $loc" | tee -a "$LOG"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
echo "[$(date -Iseconds)] ALL DONE" | tee -a "$LOG"
|
||||
21
scripts/run-i18n-deep-fill.sh
Executable file
21
scripts/run-i18n-deep-fill.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sequentieller Deep-Fill – eine Locale nach der anderen, mit Fortschritts-Log.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PY="$ROOT/scripts/.venv-i18n/bin/python"
|
||||
LOG="${TMPDIR:-/tmp}/i18n-deep-fill.log"
|
||||
# Nur noch offene Locales (fr/es/it bereits gefüllt)
|
||||
LOCALES=(pl ja zh th tl fil)
|
||||
|
||||
: >"$LOG"
|
||||
echo "[$(date -Iseconds)] start" | tee -a "$LOG"
|
||||
|
||||
for loc in "${LOCALES[@]}"; do
|
||||
echo "[$(date -Iseconds)] === $loc ===" | tee -a "$LOG"
|
||||
if ! PYTHONUNBUFFERED=1 "$PY" -u "$ROOT/scripts/fill-i18n-deep.py" --locale "$loc" --delay 0.2 2>&1 | tee -a "$LOG"; then
|
||||
echo "[$(date -Iseconds)] FAILED $loc" | tee -a "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[$(date -Iseconds)] ALL DONE" | tee -a "$LOG"
|
||||
80
scripts/update-i18n-todo-stats.js
Normal file
80
scripts/update-i18n-todo-stats.js
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Aktualisiert Kennzahlen-Tabellen in frontend/I18N_TODO.md aus check-i18n-completeness.
|
||||
* Usage: node scripts/update-i18n-todo-stats.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const TODO = path.join(ROOT, 'frontend', 'I18N_TODO.md');
|
||||
|
||||
const LOCALES = [
|
||||
'de-CH',
|
||||
'de-extended',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-AU',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'pl',
|
||||
'ja',
|
||||
'zh',
|
||||
'th',
|
||||
'tl',
|
||||
'fil',
|
||||
];
|
||||
|
||||
function parseStats() {
|
||||
const out = execSync(`node "${path.join(__dirname, 'check-i18n-completeness.js')}"`, {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const stats = {};
|
||||
for (const line of out.split('\n')) {
|
||||
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
|
||||
if (m && LOCALES.includes(m[1])) {
|
||||
stats[m[1]] = {
|
||||
explicit: m[2],
|
||||
erbtsDE: m[3],
|
||||
deExpl: m[4],
|
||||
neDe: m[5],
|
||||
enUs: m[6],
|
||||
};
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function replaceTable(md, locale, row) {
|
||||
const header = `## \`${locale}\``;
|
||||
const idx = md.indexOf(header);
|
||||
if (idx === -1) return md;
|
||||
const tableStart = md.indexOf('| explicit |', idx);
|
||||
const tableEnd = md.indexOf('\n\n### Aufgaben', tableStart);
|
||||
if (tableStart === -1 || tableEnd === -1) return md;
|
||||
const newTable = `| explicit | erbtsDE | =de expl | ≠de | ≈en-US |
|
||||
|----------|---------|----------|-----|--------|
|
||||
| ${row.explicit} | ${row.erbtsDE} | ${row.deExpl} | ${row.neDe} | ${row.enUs} |`;
|
||||
return md.slice(0, tableStart) + newTable + md.slice(tableEnd);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const stats = parseStats();
|
||||
let md = fs.readFileSync(TODO, 'utf8');
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
md = md.replace(
|
||||
/Stand der Kennzahlen: \*\*[\d-]+\*\*/,
|
||||
`Stand der Kennzahlen: **${date}**`
|
||||
);
|
||||
for (const locale of LOCALES) {
|
||||
if (stats[locale]) md = replaceTable(md, locale, stats[locale]);
|
||||
}
|
||||
fs.writeFileSync(TODO, md, 'utf8');
|
||||
console.log('Updated', TODO, 'for', Object.keys(stats).length, 'locales');
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user