2025-02-09 23:32:40 +01:00
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import path from 'path' ;
import fs from 'fs' ;
import readline from 'readline' ;
import { ControlledPromise } from '@/lib/ControlledPromise.ts' ;
import tokens from '../tokens.json' ;
2025-02-17 02:07:21 +01:00
import {
ActionByTranslationUrlByUserUrl ,
ContributionsByLanguage ,
LanguageNameByTranslationUrl ,
UsernameByUserUrl ,
UserUrlsByTranslationUrl ,
WeblateChangeResult ,
2025-02-17 02:29:50 +01:00
WeblateLanguageStatistic ,
2025-02-17 02:07:21 +01:00
} from '../weblate/Weblate.types.ts' ;
2025-02-17 02:29:50 +01:00
import {
fetchWeblateChanges ,
fetchWeblateLanguageStats ,
getLanguageName ,
getUsername ,
getWeblateLanguageStatsFor ,
meetsTranslatedPercentThreshold ,
validateWeblateDates ,
} from '../weblate/Weblate.utils.ts' ;
import {
creditRelevantActions ,
TRANSLATED_PERCENT_THRESHOLD ,
TRANSLATION_CHANGELOG_YARG_OPTIONS_DEFAULT ,
} from '../weblate/Weblate.constants.ts' ;
2025-02-09 23:32:40 +01:00
const extractContributionInfoFromChanges = (
changes : WeblateChangeResult [],
) : {
userUrlsByTranslationUrl : UserUrlsByTranslationUrl ;
actionsByTranslationUrlByUserUrl : ActionByTranslationUrlByUserUrl ;
actions : Record < number , string >;
} => {
const userUrlsByTranslationUrl : UserUrlsByTranslationUrl = {};
const actionsByTranslationUrlByUserUrl : ActionByTranslationUrlByUserUrl = {};
const actions : Record < number , string > = {};
changes . forEach (( change ) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { translation : translationUrl , action , action_name , user : userUrl } = change ;
if ( userUrl === null ) {
return ;
}
userUrlsByTranslationUrl [ translationUrl ] = [
... new Set ([...( userUrlsByTranslationUrl [ translationUrl ] ?? []), userUrl ]),
];
actions [ action ] = action_name ;
actionsByTranslationUrlByUserUrl [ userUrl ] = {
... actionsByTranslationUrlByUserUrl [ userUrl ],
[ translationUrl ] : {
count : ( actionsByTranslationUrlByUserUrl [ userUrl ] ? .[ translationUrl ] ? . count ?? 0 ) + 1 ,
actions : {
... actionsByTranslationUrlByUserUrl [ userUrl ] ? .[ translationUrl ] ? . actions ,
[ action ] : ( actionsByTranslationUrlByUserUrl [ userUrl ] ? .[ translationUrl ] ? . actions [ action ] ?? 0 ) + 1 ,
},
},
};
});
return { userUrlsByTranslationUrl , actionsByTranslationUrlByUserUrl , actions };
};
const getUsernameByUserUrlMap = async (
userUrlsByTranslationUrl : UserUrlsByTranslationUrl ,
authToken : string ,
) : Promise < Record < string , string >> =>
Object . fromEntries (
( await Promise . all (
Object . values ( userUrlsByTranslationUrl )
. flat ()
. map ( async ( userUrl ) => [ userUrl , await getUsername ( userUrl , authToken )]),
)) satisfies [ string , string ][],
);
const getLanguageNameByTranslationUrl = async (
userUrlsByTranslationUrl : UserUrlsByTranslationUrl ,
authToken : string ,
) : Promise < LanguageNameByTranslationUrl > =>
Object . fromEntries (
( await Promise . all (
Object . keys ( userUrlsByTranslationUrl )
. flat ()
. map ( async ( translationUrl ) => [ translationUrl , await getLanguageName ( translationUrl , authToken )]),
)) satisfies [ string , string ][],
);
const getUserContributionByLanguage = (
userUrlsByTranslationUrl : UserUrlsByTranslationUrl ,
languageNameByTranslationUrl : LanguageNameByTranslationUrl ,
usernameByUserUrl : UsernameByUserUrl ,
actionsByTranslationUrlByUserUrl : ActionByTranslationUrlByUserUrl ,
) =>
Object . fromEntries (
Object . entries ( userUrlsByTranslationUrl ). map (([ translationUrl , userUrls ]) => {
const languageName = languageNameByTranslationUrl [ translationUrl ];
const contributors = userUrls
. map (( userUrl ) => {
const username = usernameByUserUrl [ userUrl ];
const contributionCount = actionsByTranslationUrlByUserUrl [ userUrl ][ translationUrl ]. count ;
return { username , contributionCount };
})
. sort (( userA , userB ) => userB . contributionCount - userA . contributionCount );
return [ languageName , contributors ];
}),
);
2025-02-17 02:29:50 +01:00
const doesLanguageOfChangeMeetTranslatePercentThreshold = (
change : WeblateChangeResult ,
stats : WeblateLanguageStatistic [],
) : boolean => {
// format: 'https://hosted.weblate.org/api/translations/suwayomi/suwayomi-webui/ar/'
const code = change . translation . split ( '/' ). slice ( - 2 )[ 0 ];
const langaugeStats = getWeblateLanguageStatsFor ( code , stats );
return meetsTranslatedPercentThreshold ( langaugeStats . translated_percent );
};
2025-02-09 23:32:40 +01:00
const getContributorsForRange = async ( url : string , authToken : string ) : Promise < ContributionsByLanguage > => {
const weblateChanges = await fetchWeblateChanges ( url , authToken );
2025-02-17 02:29:50 +01:00
const weblateLanguageStats = await fetchWeblateLanguageStats ();
const validWeblateChanges = weblateChanges . filter (( change ) =>
doesLanguageOfChangeMeetTranslatePercentThreshold ( change , weblateLanguageStats ),
);
const invalidWeblateChangesAmount = Math . abs ( validWeblateChanges . length - weblateChanges . length );
if ( invalidWeblateChangesAmount > 0 ) {
console . log (
`Filtered out ${ invalidWeblateChangesAmount } invalid change(s) due languages not meeting the translated percent threshold ( ${ TRANSLATED_PERCENT_THRESHOLD } )` ,
);
}
2025-02-09 23:32:40 +01:00
const { userUrlsByTranslationUrl , actionsByTranslationUrlByUserUrl } =
2025-02-17 02:29:50 +01:00
extractContributionInfoFromChanges ( validWeblateChanges );
2025-02-09 23:32:40 +01:00
const usernameByUserUrl = await getUsernameByUserUrlMap ( userUrlsByTranslationUrl , authToken );
const languageNameByTranslationUrl = await getLanguageNameByTranslationUrl ( userUrlsByTranslationUrl , authToken );
return getUserContributionByLanguage (
userUrlsByTranslationUrl ,
languageNameByTranslationUrl ,
usernameByUserUrl ,
actionsByTranslationUrlByUserUrl ,
);
};
const getLanguageLinesFromChangelog = () : Promise < string [] > => {
const fileReadPromise = new ControlledPromise < string [] >();
const changelogFile = path . resolve ( import . meta . dirname , '../../../CHANGELOG.md' );
const fileStream = fs . createReadStream ( changelogFile );
const fileLineReader = readline . createInterface ({
input : fileStream ,
crlfDelay : Infinity , // Recognize all line breaks (CR LF) as a single line break
});
const translationRelevantLines : string [] = [];
let isTranslationSection = false ;
fileLineReader . on ( 'line' , ( line ) => {
if ( line . toLowerCase () === '## Translations' . toLowerCase ()) {
isTranslationSection = true ;
return ;
}
if ( isTranslationSection && line . toLowerCase () === '## Full Changelog' . toLowerCase ()) {
isTranslationSection = false ;
return ;
}
if ( ! isTranslationSection ) {
return ;
}
if ( ! line . startsWith ( '-' )) {
return ;
}
translationRelevantLines . push ( line );
});
fileLineReader . on ( 'close' , () => {
fileReadPromise . resolve ( translationRelevantLines );
});
fileLineReader . on ( 'error' , ( error ) => {
fileReadPromise . reject ( error );
});
return fileReadPromise . promise ;
};
const getKnownContributorsByLanguage = async () : Promise < Record < string , string [] >> => {
const changelogLanguageLines = await getLanguageLinesFromChangelog ();
const contributorByLanguage : Record < string , string [] > = {};
changelogLanguageLines . forEach (( languageLine ) => {
const languageChangeRegex = /^- (.*) \(by (.*)\)$/g ;
const languageChangeRegexMatch = [... languageLine . matchAll ( languageChangeRegex )][ 0 ];
const language = languageChangeRegexMatch [ 1 ];
const contributors = languageChangeRegexMatch [ 2 ]. split ( ', ' );
const knownContributors = contributorByLanguage [ language ] ?? [];
contributorByLanguage [ language ] = [... new Set ([... knownContributors , ... contributors ])];
});
return contributorByLanguage ;
};
export const createTranslationChangelog = async (
afterDate : string ,
beforeDate : string ,
requiredContributionCount : number = TRANSLATION_CHANGELOG_YARG_OPTIONS_DEFAULT . requiredContributionCount ,
keepKnownContributors : boolean = TRANSLATION_CHANGELOG_YARG_OPTIONS_DEFAULT . keepKnownContributors ,
) : Promise < string > => {
2025-02-10 00:12:31 +01:00
validateWeblateDates ( afterDate , beforeDate );
2025-02-09 23:32:40 +01:00
const timestampAfter = ` ${ afterDate } T00:00:00.000Z` ;
const timestampBefore = ` ${ beforeDate } T00:00:00.000Z` ;
const url = `https://hosted.weblate.org/api/components/suwayomi/suwayomi-webui/changes/?timestamp_after= ${ timestampAfter } ×tamp_before= ${ timestampBefore } & ${ creditRelevantActions . map (( action ) => `action= ${ action } ` ). join ( '&' ) } ` ;
const contributorsForRange = await getContributorsForRange ( url , tokens . weblateToken );
const knownContributorsByLanguage = await getKnownContributorsByLanguage ();
const contributionInfo : { language : string ; contributors : string [] }[] = Object . entries ( contributorsForRange ). map (
([ language , languageContributors ]) => {
const validContributors = languageContributors . filter (
({ username , contributionCount }) =>
( keepKnownContributors && knownContributorsByLanguage [ language ] ? . includes ( username )) ||
contributionCount >= requiredContributionCount ,
);
const contributors = validContributors . length ? validContributors : [ languageContributors [ 0 ]];
return {
language ,
contributors : contributors.map (({ username }) => username ),
};
},
);
const languageByNewState = Object . groupBy (
contributionInfo ,
({ language }) => ` ${ knownContributorsByLanguage [ language ] == null } ` ,
);
const newLanguages = languageByNewState . true ?? [];
const updatedLanguages = languageByNewState . false ?? [];
let changelog =
'## Translations\n' +
'Feel free to translate the project on [Weblate](https://hosted.weblate.org/projects/suwayomi/suwayomi-webui/)\n' +
'\n' +
'Thank you for your contribution to the translation of the project.\n\n' ;
if ( newLanguages . length ) {
changelog += '### Added\n' ;
newLanguages . forEach (({ language , contributors }) => {
changelog += `- ${ language } (by ${ contributors . join ( ', ' ) } ) \ n` ;
});
changelog += '\n' ;
}
if ( updatedLanguages . length ) {
changelog += '### Updated\n' ;
updatedLanguages . forEach (({ language , contributors }) => {
changelog += `- ${ language } (by ${ contributors . join ( ', ' ) } ) \ n` ;
});
}
return changelog ;
};