diff --git a/CMakeLists.txt b/CMakeLists.txt index 456c1df6..28b8cc00 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,10 @@ function(znc_add_executable name) add_executable("${name}" ${ARGN}) set(_all_targets "${_all_targets};${name}" CACHE INTERNAL "") endfunction() +function(znc_add_custom_target name) + add_custom_target("${name}" ${ARGN}) + set(_all_targets "${_all_targets};${name}" CACHE INTERNAL "") +endfunction() list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake") @@ -175,6 +179,24 @@ if(WANT_TCL) endif() endif() +tristate_option(I18N "Native language support (i18n)") +if(WANT_I18N) + find_package(Boost ${TRISTATE_I18N_REQUIRED} COMPONENTS locale) + find_package(Gettext ${TRISTATE_I18N_REQUIRED}) +endif() +if(Boost_LOCALE_FOUND AND GETTEXT_MSGFMT_EXECUTABLE) + set(HAVE_I18N true) +else() + set(HAVE_I18N false) +endif() + +if(HAVE_I18N AND GETTEXT_MSGMERGE_EXECUTABLE) + find_program(XGETTEXT_EXECUTABLE xgettext) + if(XGETTEXT_EXECUTABLE) + add_custom_target(translation) + endif() +endif() + # poll() is broken on Mac OS, it fails with POLLNVAL for pipe()s. if(APPLE) set(CSOCK_USE_POLL false) @@ -321,6 +343,7 @@ summary_line("Tcl " "${TCL_FOUND}") summary_line("Cyrus " "${CYRUS_FOUND}") summary_line("Charset " "${ICU_FOUND}") summary_line("Zlib " "${ZLIB_FOUND}") +summary_line("i18n " "${HAVE_I18N}") include(render_framed_multiline) render_framed_multiline("${summary_lines}") diff --git a/Makefile.in b/Makefile.in index 04448287..ed477f7f 100644 --- a/Makefile.in +++ b/Makefile.in @@ -53,7 +53,7 @@ LIB_SRCS := ZNCString.cpp Csocket.cpp znc.cpp IRCNetwork.cpp User.cpp IRCSock.c Client.cpp Chan.cpp Nick.cpp Server.cpp Modules.cpp MD5.cpp Buffer.cpp Utils.cpp \ FileUtils.cpp HTTPSock.cpp Template.cpp ClientCommand.cpp Socket.cpp SHA256.cpp \ WebModules.cpp Listener.cpp Config.cpp ZNCDebug.cpp Threads.cpp version.cpp Query.cpp \ - SSLVerifyHost.cpp Message.cpp + SSLVerifyHost.cpp Message.cpp Translation.cpp LIB_SRCS := $(addprefix src/,$(LIB_SRCS)) BIN_SRCS := src/main.cpp LIB_OBJS := $(patsubst %cpp,%o,$(LIB_SRCS)) diff --git a/cmake/translation.cmake b/cmake/translation.cmake new file mode 100644 index 00000000..7eff7e2f --- /dev/null +++ b/cmake/translation.cmake @@ -0,0 +1,75 @@ +# +# Copyright (C) 2004-2016 ZNC, see the NOTICE file for details. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +include(CMakeParseArguments) +function(translation) + cmake_parse_arguments(arg "" "FULL;SHORT" "SOURCES;TMPLDIRS" ${ARGN}) + set(short "${arg_SHORT}") + file(GLOB all_po "${short}.*.po") + + if(XGETTEXT_EXECUTABLE) + set(params) + foreach(i ${arg_SOURCES}) + list(APPEND params "--explicit_sources=${i}") + endforeach() + foreach(i ${arg_TMPLDIRS}) + list(APPEND params "--tmpl_dirs=${i}") + endforeach() + add_custom_target("translation_${short}" + COMMAND "${PROJECT_SOURCE_DIR}/translation_pot.py" + "--include_dir=${CMAKE_CURRENT_SOURCE_DIR}/.." + "--strip_prefix=${PROJECT_SOURCE_DIR}/" + "--tmp_prefix=${CMAKE_CURRENT_BINARY_DIR}/${short}" + "--output=${CMAKE_CURRENT_SOURCE_DIR}/${short}.pot" + ${params} + VERBATIM) + foreach(one_po ${all_po}) + add_custom_command(TARGET "translation_${short}" POST_BUILD + COMMAND "${GETTEXT_MSGMERGE_EXECUTABLE}" + --update --quiet --backup=none "${one_po}" "${short}.pot" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + VERBATIM) + endforeach() + add_dependencies(translation "translation_${short}") + endif() + + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${short}.pot") + return() + endif() + + znc_add_custom_target("po_${short}") + foreach(one_po ${all_po}) + get_filename_component(longext "${one_po}" EXT) + if(NOT longext MATCHES "^\\.([a-zA-Z_]+)\\.po$") + message(WARNING "Unrecognized translation file ${one_po}") + continue() + endif() + set(lang "${CMAKE_MATCH_1}") + + add_custom_command(OUTPUT "${short}.${lang}.gmo" + COMMAND "${GETTEXT_MSGFMT_EXECUTABLE}" + -D "${CMAKE_CURRENT_SOURCE_DIR}" + -o "${short}.${lang}.gmo" + "${short}.${lang}.po" + DEPENDS "${short}.${lang}.po" + VERBATIM) + add_custom_target("po_${short}_${lang}" DEPENDS "${short}.${lang}.gmo") + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/${short}.${lang}.gmo" + DESTINATION "${CMAKE_INSTALL_LOCALEDIR}/${lang}/LC_MESSAGES" + RENAME "${arg_FULL}.mo") + add_dependencies("po_${short}" "po_${short}_${lang}") + endforeach() +endfunction() diff --git a/cmake/translation_tmpl.py b/cmake/translation_tmpl.py new file mode 100755 index 00000000..d1739c91 --- /dev/null +++ b/cmake/translation_tmpl.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2004-2016 ZNC, see the NOTICE file for details. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import glob +import os +import re + +parser = argparse.ArgumentParser( + description='Extract translateable strings from .tmpl files') +parser.add_argument('--directory', action='store') +parser.add_argument('--output', action='store') +args = parser.parse_args() + +pattern = re.compile(r'<\?\s*(?:FORMAT|(PLURAL))\s+(?:CTX="([^"]+?)"\s+)?"([^"]+?)"(?(1)\s+"([^"]+?)"|).*?\?>') + +result = [] + +for fname in glob.iglob(args.directory + '/*.tmpl'): + fbase = os.path.basename(fname) + with open(fname) as f: + for linenum, line in enumerate(f): + for x in pattern.finditer(line): + text, plural, context = x.group(3), x.group(4), x.group(2) + result.append('#: {}:{}'.format(fbase, linenum + 1)) + if context: + result.append('msgctxt "{}"'.format(context)) + result.append('msgid "{}"'.format(text)) + if plural: + result.append('msgid_plural "{}"'.format(plural)) + result.append('msgstr[0] ""') + result.append('msgstr[1] ""') + else: + result.append('msgstr ""') + result.append('') + +if result: + with open(args.output, 'w') as f: + for line in result: + print(line, file=f) diff --git a/configure.sh b/configure.sh index 54d28a35..5ee169be 100755 --- a/configure.sh +++ b/configure.sh @@ -82,6 +82,7 @@ tristate('swig') tristate('cyrus') tristate('charset', 'ICU') tristate('tcl') +tristate('i18n') class HandlePython(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): diff --git a/include/znc/Modules.h b/include/znc/Modules.h index 7c7ecf45..5a2dd694 100644 --- a/include/znc/Modules.h +++ b/include/znc/Modules.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -58,20 +59,24 @@ class CModInfo; #define ZNC_EXPORT_LIB_EXPORT #endif -#define MODCOMMONDEFS(CLASS, DESCRIPTION, TYPE) \ - extern "C" { \ - ZNC_EXPORT_LIB_EXPORT bool ZNCModInfo(double dCoreVersion, \ - CModInfo& Info); \ - ZNC_EXPORT_LIB_EXPORT bool ZNCModInfo(double dCoreVersion, \ - CModInfo& Info) { \ - if (dCoreVersion != VERSION) return false; \ - Info.SetDescription(DESCRIPTION); \ - Info.SetDefaultType(TYPE); \ - Info.AddType(TYPE); \ - Info.SetLoader(TModLoad); \ - TModInfo(Info); \ - return true; \ - } \ +#define MODCOMMONDEFS(CLASS, DESCRIPTION, TYPE) \ + extern "C" { \ + ZNC_EXPORT_LIB_EXPORT bool ZNCModInfo(double dCoreVersion, \ + CModInfo& Info); \ + ZNC_EXPORT_LIB_EXPORT bool ZNCModInfo(double dCoreVersion, \ + CModInfo& Info) { \ + if (dCoreVersion != VERSION) return false; \ + auto t = [&](const CString& sEnglish, const CString& sContext = "") { \ + return sEnglish.empty() ? "" : Info.t(sEnglish, sContext); \ + }; \ + t(CString()); /* Don't warn about unused t */ \ + Info.SetDescription(DESCRIPTION); \ + Info.SetDefaultType(TYPE); \ + Info.AddType(TYPE); \ + Info.SetLoader(TModLoad); \ + TModInfo(Info); \ + return true; \ + } \ } /** Instead of writing a constructor, you should call this macro. It accepts all @@ -273,6 +278,9 @@ class CModInfo { void SetLoader(ModLoader fLoader) { m_fLoader = fLoader; } void SetDefaultType(EModuleType eType) { m_eDefaultType = eType; } // !Setters + + CString t(const CString& sEnglish, const CString& sContext = "") const; + private: protected: std::set m_seType; @@ -316,16 +324,18 @@ class CModCommand { const CString& sArgs, const CString& sDesc); CModCommand(const CString& sCmd, CmdFunc func, const CString& sArgs, const CString& sDesc); + CModCommand(const CString& sCmd, CmdFunc func, const CString& sArgs, + const CDelayedTranslation& dDesc); /** Copy constructor, needed so that this can be saved in a std::map. * @param other Object to copy from. */ - CModCommand(const CModCommand& other); + CModCommand(const CModCommand& other) = default; /** Assignment operator, needed so that this can be saved in a std::map. * @param other Object to copy from. */ - CModCommand& operator=(const CModCommand& other); + CModCommand& operator=(const CModCommand& other) = default; /** Initialize a CTable so that it can be used with AddHelp(). * @param Table The instance of CTable to initialize. @@ -341,7 +351,7 @@ class CModCommand { const CString& GetCommand() const { return m_sCmd; } CmdFunc GetFunction() const { return m_pFunc; } const CString& GetArgs() const { return m_sArgs; } - const CString& GetDescription() const { return m_sDesc; } + CString GetDescription() const; void Call(const CString& sLine) const { m_pFunc(sLine); } @@ -350,6 +360,8 @@ class CModCommand { CmdFunc m_pFunc; CString m_sArgs; CString m_sDesc; + CDelayedTranslation m_dDesc; + bool m_bTranslating; }; /** The base class for your own ZNC modules. @@ -1093,6 +1105,10 @@ class CModule { bool AddCommand(const CString& sCmd, const CString& sArgs, const CString& sDesc, std::function func); + /// @return True if the command was successfully added. + bool AddCommand(const CString& sCmd, const CString& sArgs, + const CDelayedTranslation& dDesc, + std::function func); /// @return True if the command was successfully removed. bool RemCommand(const CString& sCmd); /// @return The CModCommand instance or nullptr if none was found. @@ -1267,6 +1283,17 @@ class CModule { CModInfo::EModuleType eType); // !Global Modules +#ifndef SWIG + // Translation + CString t(const CString& sEnglish, const CString& sContext = "") const; + CInlineFormatMessage f(const CString& sEnglish, + const CString& sContext = "") const; + CInlineFormatMessage p(const CString& sEnglish, const CString& sEnglishes, + int iNum, const CString& sContext = "") const; + CDelayedTranslation d(const CString& sEnglish, + const CString& sContext = "") const; +#endif + protected: CModInfo::EModuleType m_eType; CString m_sDescription; @@ -1285,6 +1312,7 @@ class CModule { CString m_sSavePath; CString m_sArgs; CString m_sModPath; + CTranslationDomainRefHolder m_Translation; private: MCString diff --git a/include/znc/Socket.h b/include/znc/Socket.h index 9804d0a5..a1648ea1 100644 --- a/include/znc/Socket.h +++ b/include/znc/Socket.h @@ -20,10 +20,11 @@ #include #include #include +#include class CModule; -class CZNCSock : public Csock { +class CZNCSock : public Csock, public CCoreTranslationMixin { public: CZNCSock(int timeout = 60); CZNCSock(const CString& sHost, u_short port, int timeout = 60); @@ -267,6 +268,18 @@ class CSocket : public CZNCSock { // Getters CModule* GetModule() const; // !Getters + +#ifndef SWIG + // Translation. As opposed to CCoreTranslationMixin, this one uses module.mo + CString t(const CString& sEnglish, const CString& sContext = "") const; + CInlineFormatMessage f(const CString& sEnglish, + const CString& sContext = "") const; + CInlineFormatMessage p(const CString& sEnglish, const CString& sEnglishes, + int iNum, const CString& sContext) const; + CDelayedTranslation d(const CString& sEnglish, + const CString& sContext = "") const; +#endif + private: protected: CModule* diff --git a/include/znc/Translation.h b/include/znc/Translation.h new file mode 100644 index 00000000..e415be75 --- /dev/null +++ b/include/znc/Translation.h @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2004-2016 ZNC, see the NOTICE file for details. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef ZNC_TRANSLATION_H +#define ZNC_TRANSLATION_H + +#include +#include + +// All instances of modules share single message map using this class stored in +// CZNC. +class CTranslation { + public: + static CTranslation& Get(); + CString Singular(const CString& sDomain, const CString& sContext, + const CString& sEnglish); + CString Plural(const CString& sDomain, const CString& sContext, + const CString& sEnglish, const CString& sEnglishes, + int iNum); + + void PushLanguage(const CString& sLanguage); + void PopLanguage(); + + void NewReference(const CString& sDomain); + void DelReference(const CString& sDomain); + + private: + const std::locale& LoadTranslation(const CString& sDomain); + std::unordered_map> + m_Translations; + VCString m_sLanguageStack; + std::unordered_map m_miReferences; +}; + +struct CLanguageScope { + explicit CLanguageScope(const CString& sLanguage); + ~CLanguageScope(); +}; + +struct CTranslationDomainRefHolder { + explicit CTranslationDomainRefHolder(const CString& sDomain); + ~CTranslationDomainRefHolder(); + + private: + CString m_sDomain; +}; + +// This is inspired by boost::locale::message, but without boost +class CDelayedTranslation { + public: + CDelayedTranslation() = default; + CDelayedTranslation(const CString& sDomain, const CString& sContext, + const CString& sEnglish) + : m_sDomain(sDomain), m_sContext(sContext), m_sEnglish(sEnglish) {} + CString Resolve() const; + + private: + CString m_sDomain; + CString m_sContext; + CString m_sEnglish; +}; + +// Used by everything except modules. +// CModule defines its own version of these functions. +class CCoreTranslationMixin { + protected: + static CString t(const CString& sEnglish, const CString& sContext = ""); + static CInlineFormatMessage f(const CString& sEnglish, + const CString& sContext = ""); + static CInlineFormatMessage p(const CString& sEnglish, + const CString& sEnglishes, int iNum, + const CString& sContext = ""); + static CDelayedTranslation d(const CString& sEnglish, + const CString& sContext = ""); +}; + +#endif diff --git a/include/znc/User.h b/include/znc/User.h index 304161f7..f1bc04b0 100644 --- a/include/znc/User.h +++ b/include/znc/User.h @@ -143,6 +143,7 @@ class CUser { bool SetQueryBufferSize(unsigned int u, bool bForce = false); void SetAutoClearChanBuffer(bool b); void SetAutoClearQueryBuffer(bool b); + bool SetLanguage(const CString& s); void SetBeingDeleted(bool b) { m_bBeingDeleted = b; } void SetTimestampFormat(const CString& s) { m_sTimestampFormat = s; } @@ -200,6 +201,7 @@ class CUser { unsigned int JoinTries() const { return m_uMaxJoinTries; } unsigned int MaxJoins() const { return m_uMaxJoins; } CString GetSkinName() const; + CString GetLanguage() const; unsigned int MaxNetworks() const { return m_uMaxNetworks; } unsigned int MaxQueryBuffers() const { return m_uMaxQueryBuffers; } // !Getters @@ -253,6 +255,7 @@ class CUser { unsigned int m_uMaxQueryBuffers; unsigned int m_uMaxJoins; CString m_sSkinName; + CString m_sLanguage; CModules* m_pModules; diff --git a/include/znc/ZNCString.h b/include/znc/ZNCString.h index 9ab0f631..f076810b 100644 --- a/include/znc/ZNCString.h +++ b/include/znc/ZNCString.h @@ -663,4 +663,38 @@ class MCString : public std::map { virtual CString& Decode(CString& sValue) const; }; +namespace std { +template <> +struct hash : hash {}; +} + +// Make translateable messages easy to write: +// _f("Foo is {1}")(foo) +class CInlineFormatMessage { + public: + explicit CInlineFormatMessage(const CString& sFormat) + : m_sFormat(sFormat) {} + explicit CInlineFormatMessage(CString&& sFormat) + : m_sFormat(std::move(sFormat)) {} + + template + CString operator()(const Args&... args) const { + MCString values; + apply(values, 1, args...); + return CString::NamedFormat(m_sFormat, values); + } + + private: + template + void apply(MCString& values, int index, const Arg& arg, + const Rest&... rest) const { + values[CString(index)] = CString(arg); + apply(values, index + 1, rest...); + } + + void apply(MCString& values, int index) const {} + + CString m_sFormat; +}; + #endif // !ZNCSTRING_H diff --git a/include/znc/znc.h b/include/znc/znc.h index ca9d5f56..e57cdb23 100644 --- a/include/znc/znc.h +++ b/include/znc/znc.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -298,6 +299,7 @@ class CZNC { TCacheMap m_sConnectThrottle; bool m_bProtectWebSessions; bool m_bHideVersion; + CTranslationDomainRefHolder m_Translation; }; #endif // !ZNC_H diff --git a/include/znc/zncconfig.h.cmake.in b/include/znc/zncconfig.h.cmake.in index b1da6db8..de955d90 100644 --- a/include/znc/zncconfig.h.cmake.in +++ b/include/znc/zncconfig.h.cmake.in @@ -32,6 +32,7 @@ #cmakedefine HAVE_LIBSSL 1 #cmakedefine HAVE_IPV6 1 #cmakedefine HAVE_ZLIB 1 +#cmakedefine HAVE_I18N 1 #cmakedefine CSOCK_USE_POLL 1 #cmakedefine HAVE_GETOPT_LONG 1 @@ -49,5 +50,6 @@ #define _MODDIR_ "@CMAKE_INSTALL_FULL_LIBDIR@/znc" #define _DATADIR_ "@CMAKE_INSTALL_FULL_DATADIR@/znc" +#define LOCALE_DIR "@CMAKE_INSTALL_FULL_LOCALEDIR@" #endif /* ZNCCONFIG_H */ diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index 54846a75..f7a47c40 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -75,10 +75,19 @@ else() set(moddisable_modtcl true) endif() +set(actual_modules) + foreach(modpath ${all_modules}) - string(REGEX MATCH "/([-a-zA-Z0-9_]+)\\.([a-z]+)$" unused "${modpath}") + if(NOT "${modpath}" MATCHES "/([-a-zA-Z0-9_]+)\\.([a-z]+)$") + continue() + endif() set(mod "${CMAKE_MATCH_1}") set(modtype "${CMAKE_MATCH_2}") + if(mod STREQUAL "CMakeLists" OR mod STREQUAL "Makefile") + continue() + endif() + + list(APPEND actual_modules "${modpath}") set(modenabled true) @@ -107,5 +116,9 @@ foreach(modpath ${all_modules}) endif() endforeach() +if(HAVE_I18N) + add_subdirectory(po) +endif() + install(DIRECTORY data/ DESTINATION "${CMAKE_INSTALL_DATADIR}/znc/modules") diff --git a/modules/po/CMakeLists.txt b/modules/po/CMakeLists.txt new file mode 100644 index 00000000..537fda0d --- /dev/null +++ b/modules/po/CMakeLists.txt @@ -0,0 +1,24 @@ +# +# Copyright (C) 2004-2016 ZNC, see the NOTICE file for details. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +include(translation) + +foreach(modpath ${actual_modules}) + get_filename_component(mod "${modpath}" NAME_WE) + get_filename_component(module "${modpath}" NAME) + translation(SHORT "${mod}" FULL "znc-${mod}" SOURCES "${module}" + TMPLDIRS "${CMAKE_CURRENT_SOURCE_DIR}/../data/${mod}/tmpl") +endforeach() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5f86b06b..53b88ef1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,13 +19,13 @@ if(CMAKE_VERSION VERSION_LESS 3.2) set_source_files_properties("versionc.cpp" PROPERTIES GENERATED true) endif() -znc_add_library(znclib ${lib_type} "ZNCString.cpp" "znc.cpp" "IRCNetwork.cpp" +set(znc_cpp "ZNCString.cpp" "znc.cpp" "IRCNetwork.cpp" "Translation.cpp" "IRCSock.cpp" "Client.cpp" "Chan.cpp" "Nick.cpp" "Server.cpp" "Modules.cpp" "MD5.cpp" "Buffer.cpp" "Utils.cpp" "FileUtils.cpp" "HTTPSock.cpp" "Template.cpp" "ClientCommand.cpp" "Socket.cpp" "SHA256.cpp" "WebModules.cpp" "Listener.cpp" "Config.cpp" "ZNCDebug.cpp" - "Threads.cpp" "Query.cpp" "SSLVerifyHost.cpp" "Message.cpp" "Csocket.cpp" - "versionc.cpp" "User.cpp") + "Threads.cpp" "Query.cpp" "SSLVerifyHost.cpp" "Message.cpp" "User.cpp") +znc_add_library(znclib ${lib_type} ${znc_cpp} "Csocket.cpp" "versionc.cpp") znc_add_executable(znc "main.cpp") target_link_libraries(znc PRIVATE znclib) @@ -76,6 +76,10 @@ if(ICU_FOUND) target_link_libraries(znclib ${ICU_LDFLAGS}) list(APPEND znc_include_dirs ${ICU_INCLUDE_DIRS}) endif() +if(Boost_FOUND) + target_link_libraries(znclib ${Boost_LIBRARIES}) + list(APPEND znc_include_dirs ${Boost_INCLUDE_DIRS}) +endif() target_include_directories(znc PUBLIC ${znc_include_dirs}) target_include_directories(znclib PUBLIC ${znc_include_dirs}) @@ -91,7 +95,9 @@ set_target_properties(znclib PROPERTIES OUTPUT_NAME "znc" SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}") - +if(HAVE_I18N) + add_subdirectory(po) +endif() install(TARGETS znc ${install_lib} diff --git a/src/Client.cpp b/src/Client.cpp index 7f75cb64..abbb79ae 100644 --- a/src/Client.cpp +++ b/src/Client.cpp @@ -94,6 +94,7 @@ void CClient::SendRequiredPasswordNotice() { } void CClient::ReadLine(const CString& sData) { + CLanguageScope user_lang(GetUser() ? GetUser()->GetLanguage() : ""); CString sLine = sData; sLine.TrimRight("\n\r"); diff --git a/src/Modules.cpp b/src/Modules.cpp index 6caddd73..ad1a7767 100644 --- a/src/Modules.cpp +++ b/src/Modules.cpp @@ -138,6 +138,7 @@ CModule::CModule(ModHandle pDLL, CUser* pUser, CIRCNetwork* pNetwork, m_sSavePath(""), m_sArgs(""), m_sModPath(""), + m_Translation("znc-" + sModName), m_mssRegistry(), m_vSubPages(), m_mCommands() { @@ -516,6 +517,13 @@ bool CModule::AddCommand(const CString& sCmd, const CString& sArgs, return AddCommand(std::move(cmd)); } +bool CModule::AddCommand(const CString& sCmd, const CString& sArgs, + const CDelayedTranslation& dDesc, + std::function func) { + CModCommand cmd(sCmd, std::move(func), sArgs, dDesc); + return AddCommand(std::move(cmd)); +} + void CModule::AddHelpCommand() { AddCommand("Help", &CModule::HandleHelpCommand, "search", "Generate this output"); @@ -1595,6 +1603,8 @@ bool CModules::LoadModule(const CString& sModule, const CString& sArgs, sRetMsg = "Unable to find module [" + sModule + "]"; return false; } + Info.SetName(sModule); + Info.SetPath(sModPath); ModHandle p = OpenModule(sModule, sModPath, bVersionMismatch, Info, sRetMsg); @@ -1754,14 +1764,13 @@ bool CModules::GetModPathInfo(CModInfo& ModInfo, const CString& sModule, const CString& sModPath, CString& sRetMsg) { bool bVersionMismatch; - ModHandle p = - OpenModule(sModule, sModPath, bVersionMismatch, ModInfo, sRetMsg); - - if (!p) return false; - ModInfo.SetName(sModule); ModInfo.SetPath(sModPath); + ModHandle p = + OpenModule(sModule, sModPath, bVersionMismatch, ModInfo, sRetMsg); + if (!p) return false; + if (bVersionMismatch) { ModInfo.SetDescription( "--- Version mismatch, recompile this module. ---"); @@ -1910,6 +1919,7 @@ ModHandle CModules::OpenModule(const CString& sModule, const CString& sModPath, return nullptr; } + CTranslationDomainRefHolder translation("znc-" + sModule); typedef bool (*InfoFP)(double, CModInfo&); InfoFP ZNCModInfo = (InfoFP)dlsym(p, "ZNCModInfo"); @@ -1930,32 +1940,32 @@ ModHandle CModules::OpenModule(const CString& sModule, const CString& sModPath, return p; } -CModCommand::CModCommand() : m_sCmd(), m_pFunc(nullptr), m_sArgs(), m_sDesc() {} +CModCommand::CModCommand() + : m_sCmd(), m_pFunc(nullptr), m_sArgs(), m_sDesc(), m_bTranslating(false) {} CModCommand::CModCommand(const CString& sCmd, CModule* pMod, ModCmdFunc func, const CString& sArgs, const CString& sDesc) : m_sCmd(sCmd), m_pFunc([pMod, func](const CString& sLine) { (pMod->*func)(sLine); }), m_sArgs(sArgs), - m_sDesc(sDesc) {} + m_sDesc(sDesc), + m_bTranslating(false) {} CModCommand::CModCommand(const CString& sCmd, CmdFunc func, const CString& sArgs, const CString& sDesc) - : m_sCmd(sCmd), m_pFunc(std::move(func)), m_sArgs(sArgs), m_sDesc(sDesc) {} + : m_sCmd(sCmd), + m_pFunc(std::move(func)), + m_sArgs(sArgs), + m_sDesc(sDesc), + m_bTranslating(false) {} -CModCommand::CModCommand(const CModCommand& other) - : m_sCmd(other.m_sCmd), - m_pFunc(other.m_pFunc), - m_sArgs(other.m_sArgs), - m_sDesc(other.m_sDesc) {} - -CModCommand& CModCommand::operator=(const CModCommand& other) { - m_sCmd = other.m_sCmd; - m_pFunc = other.m_pFunc; - m_sArgs = other.m_sArgs; - m_sDesc = other.m_sDesc; - return *this; -} +CModCommand::CModCommand(const CString& sCmd, CmdFunc func, + const CString& sArgs, const CDelayedTranslation& dDesc) + : m_sCmd(sCmd), + m_pFunc(std::move(func)), + m_sArgs(sArgs), + m_dDesc(dDesc), + m_bTranslating(true) {} void CModCommand::InitHelp(CTable& Table) { Table.AddColumn("Command"); @@ -1967,3 +1977,33 @@ void CModCommand::AddHelp(CTable& Table) const { Table.SetCell("Command", GetCommand() + " " + GetArgs()); Table.SetCell("Description", GetDescription()); } + +CString CModCommand::GetDescription() const { + return m_bTranslating ? m_dDesc.Resolve() : m_sDesc; +} + +CString CModule::t(const CString& sEnglish, const CString& sContext) const { + return CTranslation::Get().Singular("znc-" + GetModName(), sContext, + sEnglish); +} + +CInlineFormatMessage CModule::f(const CString& sEnglish, + const CString& sContext) const { + return CInlineFormatMessage(t(sEnglish, sContext)); +} + +CInlineFormatMessage CModule::p(const CString& sEnglish, + const CString& sEnglishes, int iNum, + const CString& sContext) const { + return CInlineFormatMessage(CTranslation::Get().Plural( + "znc-" + GetModName(), sContext, sEnglish, sEnglishes, iNum)); +} + +CDelayedTranslation CModule::d(const CString& sEnglish, + const CString& sContext) const { + return CDelayedTranslation("znc-" + GetModName(), sContext, sEnglish); +} + +CString CModInfo::t(const CString& sEnglish, const CString& sContext) const { + return CTranslation::Get().Singular("znc-" + GetName(), sContext, sEnglish); +} diff --git a/src/Socket.cpp b/src/Socket.cpp index 8417b5d5..57e8d066 100644 --- a/src/Socket.cpp +++ b/src/Socket.cpp @@ -607,3 +607,24 @@ void CIRCSocket::IcuExtFromUCallback(UConverterFromUnicodeArgs* fromArgs, err); } #endif + + +CString CSocket::t(const CString& sEnglish, const CString& sContext) const { + return GetModule()->t(sEnglish, sContext); +} + +CInlineFormatMessage CSocket::f(const CString& sEnglish, + const CString& sContext) const { + return GetModule()->f(sEnglish, sContext); +} + +CInlineFormatMessage CSocket::p(const CString& sEnglish, + const CString& sEnglishes, int iNum, + const CString& sContext) const { + return GetModule()->p(sEnglish, sEnglishes, iNum, sContext); +} + +CDelayedTranslation CSocket::d(const CString& sEnglish, + const CString& sContext) const { + return GetModule()->d(sEnglish, sContext); +} diff --git a/src/Template.cpp b/src/Template.cpp index 3248382c..ab5fe042 100644 --- a/src/Template.cpp +++ b/src/Template.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include using std::stringstream; @@ -313,6 +314,10 @@ bool CTemplate::Print(const CString& sFileName, ostream& oOut) { bool bLoopBreak = false; bool bExit = false; + // Single template works across multiple translation domains, e.g. .tmpl of + // a module can INC'lude Footer.tmpl from core + CString sI18N; + while (File.ReadLine(sLine)) { CString sOutput; bool bFoundATag = false; @@ -420,6 +425,12 @@ bool CTemplate::Print(const CString& sFileName, ostream& oOut) { } else if (sAction.Equals("SETBLOCK")) { sSetBlockVar = sArgs; bInSetBlock = true; + } else if (sAction.Equals("ENDSETBLOCK")) { + CString sName = sSetBlockVar.Token(0); + (*this)[sName] += sOutput; + sOutput = ""; + bInSetBlock = false; + sSetBlockVar = ""; } else if (sAction.Equals("EXPAND")) { sOutput += ExpandFile(sArgs, true); } else if (sAction.Equals("VAR")) { @@ -536,6 +547,50 @@ bool CTemplate::Print(const CString& sFileName, ostream& oOut) { } } else if (sAction.Equals("REM")) { uSkip++; + } else if (sAction.Equals("I18N")) { + sI18N = sArgs; + } else if (sAction.Equals("FORMAT") || + sAction.Equals("PLURAL")) { + bool bHaveContext = false; + if (sArgs.TrimPrefix("CTX=")) { + bHaveContext = true; + } + VCString vsArgs; + sArgs.QuoteSplit(vsArgs); + CString sEnglish, sEnglishes, sContext; + int idx = 0; + if (bHaveContext && vsArgs.size() > idx) { + sContext = vsArgs[idx]; + idx++; + } + if (vsArgs.size() > idx) { + sEnglish = vsArgs[idx]; + idx++; + } + CString sFormat; + if (sAction.Equals("PLURAL")) { + CString sEnglishes; + int iNum = 0; + if (vsArgs.size() > idx) { + sEnglishes = vsArgs[idx]; + idx++; + } + if (vsArgs.size() > idx) { + iNum = GetValue(vsArgs[idx], true).ToInt(); + idx++; + } + sFormat = CTranslation::Get().Plural( + sI18N, sContext, sEnglish, sEnglishes, iNum); + } else { + sFormat = CTranslation::Get().Singular( + sI18N, sContext, sEnglish); + } + MCString msParams; + for (int i = 0; i + idx < vsArgs.size(); ++i) { + msParams[CString(i + 1)] = + GetValue(vsArgs[i + idx], false); + } + sOutput += CString::NamedFormat(sFormat, msParams); } else { bNotFound = true; } @@ -557,9 +612,6 @@ bool CTemplate::Print(const CString& sFileName, ostream& oOut) { if (uSkip) { uSkip--; } - } else if (sAction.Equals("ENDSETBLOCK")) { - bInSetBlock = false; - sSetBlockVar = ""; } else if (sAction.Equals("ENDLOOP")) { if (bLoopCont && uSkip == 1) { uSkip--; diff --git a/src/Threads.cpp b/src/Threads.cpp index 817913a8..5f8fc60e 100644 --- a/src/Threads.cpp +++ b/src/Threads.cpp @@ -29,8 +29,6 @@ static const size_t MAX_IDLE_THREADS = 3; static const size_t MAX_TOTAL_THREADS = 20; CThreadPool& CThreadPool::Get() { - // Beware! The following is not thread-safe! This function must - // be called once any thread is started. static CThreadPool pool; return pool; } diff --git a/src/Translation.cpp b/src/Translation.cpp new file mode 100644 index 00000000..30c1f97c --- /dev/null +++ b/src/Translation.cpp @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2004-2016 ZNC, see the NOTICE file for details. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#ifdef HAVE_I18N +#include +#endif + +CTranslation& CTranslation::Get() { + static CTranslation translation; + return translation; +} + +CString CTranslation::Singular(const CString& sDomain, const CString& sContext, + const CString& sEnglish) { +#ifdef HAVE_I18N + const std::locale& loc = LoadTranslation(sDomain); + return boost::locale::translate(sContext, sEnglish).str(loc); +#else + return sEnglish; +#endif +} +CString CTranslation::Plural(const CString& sDomain, const CString& sContext, + const CString& sEnglish, const CString& sEnglishes, + int iNum) { +#ifdef HAVE_I18N + const std::locale& loc = LoadTranslation(sDomain); + return boost::locale::translate(sContext, sEnglish, sEnglishes, iNum) + .str(loc); +#else + if (iNum == 1) { + return sEnglish; + } else { + return sEnglishes; + } +#endif +} + +const std::locale& CTranslation::LoadTranslation(const CString& sDomain) { + CString sLanguage = m_sLanguageStack.empty() ? "" : m_sLanguageStack.back(); +#ifdef HAVE_I18N + // Not using built-in support for multiple domains in single std::locale + // via overloaded call to .str() because we need to be able to reload + // translations from disk independently when a module gets updated + auto& domain = m_Translations[sDomain]; + auto lang_it = domain.find(sLanguage); + if (lang_it == domain.end()) { + boost::locale::generator gen; + gen.add_messages_path(LOCALE_DIR); + gen.add_messages_domain(sDomain); + std::tie(lang_it, std::ignore) = + domain.emplace(sLanguage, gen(sLanguage + ".UTF-8")); + } + return lang_it->second; +#else + // dummy, it's not used anyway + return std::locale::classic(); +#endif +} + +void CTranslation::PushLanguage(const CString& sLanguage) { + m_sLanguageStack.push_back(sLanguage); +} +void CTranslation::PopLanguage() { m_sLanguageStack.pop_back(); } + +void CTranslation::NewReference(const CString& sDomain) { + m_miReferences[sDomain]++; +} +void CTranslation::DelReference(const CString& sDomain) { + if (!--m_miReferences[sDomain]) { + m_Translations.erase(sDomain); + } +} + +CString CCoreTranslationMixin::t(const CString& sEnglish, + const CString& sContext) { + return CTranslation::Get().Singular("znc", sContext, sEnglish); +} + +CInlineFormatMessage CCoreTranslationMixin::f(const CString& sEnglish, + const CString& sContext) { + return CInlineFormatMessage(t(sEnglish, sContext)); +} + +CInlineFormatMessage CCoreTranslationMixin::p(const CString& sEnglish, + const CString& sEnglishes, + int iNum, + const CString& sContext) { + return CInlineFormatMessage(CTranslation::Get().Plural( + "znc", sContext, sEnglish, sEnglishes, iNum)); +} + +CDelayedTranslation CCoreTranslationMixin::d(const CString& sEnglish, + const CString& sContext) { + return CDelayedTranslation("znc", sContext, sEnglish); +} + + +CLanguageScope::CLanguageScope(const CString& sLanguage) { + CTranslation::Get().PushLanguage(sLanguage); +} +CLanguageScope::~CLanguageScope() { CTranslation::Get().PopLanguage(); } + +CString CDelayedTranslation::Resolve() const { + return CTranslation::Get().Singular(m_sDomain, m_sContext, m_sEnglish); +} + +CTranslationDomainRefHolder::CTranslationDomainRefHolder(const CString& sDomain) + : m_sDomain(sDomain) { + CTranslation::Get().NewReference(sDomain); +} +CTranslationDomainRefHolder::~CTranslationDomainRefHolder() { + CTranslation::Get().DelReference(m_sDomain); +} diff --git a/src/User.cpp b/src/User.cpp index 23ea9758..7f2598a6 100644 --- a/src/User.cpp +++ b/src/User.cpp @@ -272,6 +272,9 @@ bool CUser::ParseConfig(CConfig* pConfig, CString& sError) { } } } + if (pConfig->FindStringEntry("language", sValue)) { + SetLanguage(sValue); + } pConfig->FindStringEntry("pass", sValue); // There are different formats for this available: // Pass = @@ -749,6 +752,7 @@ bool CUser::Clone(const CUser& User, CString& sErrorRet, bool bCloneNetworks) { SetMaxQueryBuffers(User.MaxQueryBuffers()); SetMaxJoins(User.MaxJoins()); SetClientEncoding(User.GetClientEncoding()); + SetLanguage(User.GetLanguage()); // Allowed Hosts m_ssAllowedHosts.clear(); @@ -1057,6 +1061,7 @@ CConfig CUser::ToConfig() const { config.AddKeyValuePair("MaxQueryBuffers", CString(m_uMaxQueryBuffers)); config.AddKeyValuePair("MaxJoins", CString(m_uMaxJoins)); config.AddKeyValuePair("ClientEncoding", GetClientEncoding()); + config.AddKeyValuePair("Language", GetLanguage()); // Allow Hosts if (!m_ssAllowedHosts.empty()) { @@ -1398,6 +1403,18 @@ bool CUser::SetStatusPrefix(const CString& s) { return false; } + +bool CUser::SetLanguage(const CString& s) { + // They look like ru_RU + for (char c : s) { + if (isalpha(c) || c == '_') { + } else { + return false; + } + } + m_sLanguage = s; + return true; +} // !Setters // Getters @@ -1462,6 +1479,7 @@ bool CUser::AutoClearQueryBuffer() const { return m_bAutoClearQueryBuffer; } // CString CUser::GetSkinName() const { return (!m_sSkinName.empty()) ? // m_sSkinName : CZNC::Get().GetSkinName(); } CString CUser::GetSkinName() const { return m_sSkinName; } +CString CUser::GetLanguage() const { return m_sLanguage; } const CString& CUser::GetUserPath() const { if (!CFile::Exists(m_sUserPath)) { CDir::MakeDir(m_sUserPath); diff --git a/src/WebModules.cpp b/src/WebModules.cpp index b9bf435a..6408244c 100644 --- a/src/WebModules.cpp +++ b/src/WebModules.cpp @@ -672,6 +672,8 @@ CWebSock::EPageReqResult CWebSock::OnPageRequestInternal(const CString& sURI, m_sUser = GetSession()->GetUser()->GetUserName(); m_bLoggedIn = true; } + CLanguageScope user_language( + m_bLoggedIn ? GetSession()->GetUser()->GetLanguage() : ""); // Handle the static pages that don't require a login if (sURI == "/") { diff --git a/src/po/CMakeLists.txt b/src/po/CMakeLists.txt new file mode 100644 index 00000000..9b91697a --- /dev/null +++ b/src/po/CMakeLists.txt @@ -0,0 +1,25 @@ +# +# Copyright (C) 2004-2016 ZNC, see the NOTICE file for details. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +include(translation) + +set(tmpl_dirs) +file(GLOB skins "${PROJECT_SOURCE_DIR}/webskins/*") +foreach(skin ${skins}) + list(APPEND tmpl_dirs "${skin}/tmpl") +endforeach() +translation(SHORT "znc" FULL "znc" SOURCES ${znc_cpp} + TMPLDIRS ${tmpl_dirs}) diff --git a/src/znc.cpp b/src/znc.cpp index 2790b690..7e0998c2 100644 --- a/src/znc.cpp +++ b/src/znc.cpp @@ -75,7 +75,8 @@ CZNC::CZNC() m_uiForceEncoding(0), m_sConnectThrottle(), m_bProtectWebSessions(true), - m_bHideVersion(false) { + m_bHideVersion(false), + m_Translation("znc") { if (!InitCsocket()) { CUtils::PrintError("Could not initialize Csocket!"); exit(-1); @@ -160,11 +161,17 @@ CString CZNC::GetCompileOptionsString() { #else "no" #endif - ", build: " + ", build: " #ifdef BUILD_WITH_CMAKE - "cmake" + "cmake" #else - "autoconf" + "autoconf" +#endif + ", i18n: " +#ifdef HAVE_I18N + "yes" +#else + "no" #endif ; } @@ -1419,6 +1426,7 @@ void CZNC::Broadcast(const CString& sMessage, bool bAdminOnly, CUser* pSkipUser, if (bAdminOnly && !it.second->IsAdmin()) continue; if (it.second != pSkipUser) { + // TODO: translate message to user's language CString sMsg = sMessage; bool bContinue = false; diff --git a/translation_pot.py b/translation_pot.py new file mode 100755 index 00000000..b253bd3d --- /dev/null +++ b/translation_pot.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2004-2016 ZNC, see the NOTICE file for details. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import glob +import os +import re +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument('--include_dir', action='store') +parser.add_argument('--explicit_sources', action='append') +parser.add_argument('--tmpl_dirs', action='append') +parser.add_argument('--strip_prefix', action='store') +parser.add_argument('--tmp_prefix', action='store') +parser.add_argument('--output', action='store') +args = parser.parse_args() + +pot_list = [] + +# .cpp +main_pot = args.tmp_prefix + '_main.pot' +subprocess.check_call(['xgettext', + '--omit-header', + '-D', args.include_dir, + '-o', main_pot, + '--keyword=t:1,1t', '--keyword=t:1,2c,2t', + '--keyword=f:1,1t', '--keyword=f:1,2c,2t', + '--keyword=p:1,2,3t', '--keyword=p:1,2,4c,4t', + '--keyword=d:1,1t', '--keyword=d:1,2c,2t', +] + args.explicit_sources) +if os.path.isfile(main_pot): + pot_list.append(main_pot) + +# .tmpl +tmpl_pot = args.tmp_prefix + '_tmpl.pot' +tmpl_uniq_pot = args.tmp_prefix + '_tmpl_uniq.pot' +tmpl = [] +pattern = re.compile(r'<\?\s*(?:FORMAT|(PLURAL))\s+(?:CTX="([^"]+?)"\s+)?"([^"]+?)"(?(1)\s+"([^"]+?)"|).*?\?>') +for tmpl_dir in args.tmpl_dirs: + for fname in glob.iglob(tmpl_dir + '/*.tmpl'): + fbase = fname[len(args.strip_prefix):] + with open(fname) as f: + for linenum, line in enumerate(f): + for x in pattern.finditer(line): + text, plural, context = x.group(3), x.group(4), x.group(2) + tmpl.append('#: {}:{}'.format(fbase, linenum + 1)) + if context: + tmpl.append('msgctxt "{}"'.format(context)) + tmpl.append('msgid "{}"'.format(text)) + if plural: + tmpl.append('msgid_plural "{}"'.format(plural)) + tmpl.append('msgstr[0] ""') + tmpl.append('msgstr[1] ""') + else: + tmpl.append('msgstr ""') + tmpl.append('') +if tmpl: + with open(tmpl_pot, 'w') as f: + for line in tmpl: + print(line, file=f) + subprocess.check_call(['msguniq', '-o', tmpl_uniq_pot, tmpl_pot]) + pot_list.append(tmpl_uniq_pot) + +# combine +if pot_list: + subprocess.check_call(['msgcat', '-o', args.output] + pot_list)