Add gettext-based translations

Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
This commit is contained in:
Christoph Wurst 2020-02-13 12:45:24 +01:00
Родитель 9aa29623f8
Коммит bd268e3d0e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: CC42AC2A7F0E56D8
18 изменённых файлов: 294 добавлений и 37 удалений

Просмотреть файл

@ -7,7 +7,8 @@ module.exports = {
jest: true
},
globals: {
SCOPE_VERSION: true
SCOPE_VERSION: true,
TRANSLATIONS: true
},
parserOptions: {
parser: 'babel-eslint',

19
.github/workflows/l10n.yml поставляемый Normal file
Просмотреть файл

@ -0,0 +1,19 @@
name: L10n
on: pull_request
jobs:
l10n-extract-check:
runs-on: ubuntu-latest
name: Pot check
steps:
- uses: actions/checkout@master
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: npm install
run: npm ci
- name: extract l10n files
run: npm run l10n:extract
- name: Check l10n file changes
run: bash -c "[[ ! \"`git status --porcelain l10n`\" ]] || ( echo 'Uncommited l10n changes. Run `npm l10n:extract`.' && git status && exit 1 )"

21
.github/workflows/transifex.yml поставляемый Normal file
Просмотреть файл

@ -0,0 +1,21 @@
name: Transifex
on: pull_request
jobs:
approve:
runs-on: ubuntu-latest
name: Approve
steps:
- uses: hmarr/auto-approve-action@v2.0.0
if: github.actor == 'Transifex-localization-platform[bot]' || github.actor == 'transifex-integration[bot]'
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
automerge:
runs-on: ubuntu-latest
name: Auto-merge
steps:
- uses: "pascalgn/automerge-action@ecb16453ce68e85b1e23596c8caa7e7499698a84"
if: github.actor == 'Transifex-localization-platform[bot]' || github.actor == 'transifex-integration[bot]'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
MERGE_LABELS: ""

Просмотреть файл

@ -1 +1,3 @@
node_modules
/build
/l10n
/node_modules

23
build/extract-l10n.js Normal file
Просмотреть файл

@ -0,0 +1,23 @@
const { GettextExtractor, JsExtractors } = require('gettext-extractor');
let extractor = new GettextExtractor();
extractor
.createJsParser([
JsExtractors.callExpression('t', {
arguments: {
text: 0,
}
}),
JsExtractors.callExpression('n', {
arguments: {
text: 1,
textPlural: 2,
}
}),
])
.parseFilesGlob('./src/**/*.@(ts|js|vue)');
extractor.savePotFile('./l10n/messages.pot');
extractor.printStats();

48
l10n/messages.pot Normal file
Просмотреть файл

@ -0,0 +1,48 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
#: src/components/MultiselectTags/MultiselectTags.vue:169
msgid "{tag} (invisible)"
msgstr ""
#: src/components/MultiselectTags/MultiselectTags.vue:172
msgid "{tag} (restricted)"
msgstr ""
#: src/components/ColorPicker/ColorPicker.vue:145
msgid "Choose"
msgstr ""
#: src/components/Modal/Modal.vue:109
msgid "Close"
msgstr ""
#: src/components/Modal/Modal.vue:154
msgid "Next"
msgstr ""
#: src/components/Multiselect/Multiselect.vue:169
#: src/components/MultiselectTags/MultiselectTags.vue:78
msgid "No results"
msgstr ""
#: src/components/Modal/Modal.vue:290
msgid "Pause slideshow"
msgstr ""
#: src/components/Modal/Modal.vue:134
msgid "Previous"
msgstr ""
#: src/components/MultiselectTags/MultiselectTags.vue:100
msgid "Select a tag"
msgstr ""
#: src/components/AppNavigationSettings/AppNavigationSettings.vue:53
msgid "Settings"
msgstr ""
#: src/components/Modal/Modal.vue:290
msgid "Start slideshow"
msgstr ""

104
package-lock.json сгенерированный
Просмотреть файл

@ -3022,6 +3022,15 @@
}
}
},
"@nextcloud/l10n": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.1.0.tgz",
"integrity": "sha512-MywbaSb31JH5LNUsC98RrMwzHdsjDELf+nL5BVtHBQWq2r0cDP0nPd7Ve+knRVdGMegnigXW+F2VXbxFBLb6mQ==",
"requires": {
"core-js": "3.6.4",
"node-gettext": "^2.0.0"
}
},
"@nextcloud/router": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-1.0.0.tgz",
@ -3186,6 +3195,12 @@
"integrity": "sha512-Zq8gcQGmn4txQEJeiXo/KiLpon8TzAl0kmKH4zdWctPj05nWwp1ClMdAVEloqrQKfaC48PNLdgN/aVaLqUrluA==",
"dev": true
},
"@types/parse5": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.2.tgz",
"integrity": "sha512-BOl+6KDs4ItndUWUFchy3aEqGdHhw0BC4Uu+qoDonN/f0rbUnJbm71Ulj8Tt9jLFRaAxPLKvdS1bBLfx1qXR9g==",
"dev": true
},
"@types/semver": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-6.2.0.tgz",
@ -5970,6 +5985,12 @@
}
}
},
"css-selector-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-1.3.0.tgz",
"integrity": "sha1-XxrUPi2O77/cME/NOaUhZklD4+s=",
"dev": true
},
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -6545,6 +6566,15 @@
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
"dev": true
},
"encoding": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
"integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
"dev": true,
"requires": {
"iconv-lite": "~0.4.13"
}
},
"end-of-stream": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz",
@ -8640,6 +8670,61 @@
"assert-plus": "^1.0.0"
}
},
"gettext-extractor": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/gettext-extractor/-/gettext-extractor-3.5.2.tgz",
"integrity": "sha512-4fJViJvAkWBUV8BHwAaY2T1oirsIEAYgYfYm/+x/gF2xWxOXgn4f7Cjsdq+xuuoA9HZga+fx2PIDP+07zbmP6g==",
"dev": true,
"requires": {
"@types/glob": "5 - 7",
"@types/parse5": "^5",
"css-selector-parser": "^1.3",
"glob": "5 - 7",
"parse5": "^5",
"pofile": "1.0.x",
"typescript": "2 - 3"
}
},
"gettext-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-4.0.2.tgz",
"integrity": "sha512-JPCBpGzm01te+nTenJwWqKDzixYPY4pInedixpcMl4GPEJeia/cH2TJCh32IggDrrLYrzqA8OitXZLpBdrx4Gg==",
"dev": true,
"requires": {
"content-type": "^1.0.4",
"encoding": "^0.1.12",
"readable-stream": "^3.4.0",
"safe-buffer": "^5.2.0"
},
"dependencies": {
"readable-stream": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.5.0.tgz",
"integrity": "sha512-gSz026xs2LfxBPudDuI41V1lka8cxg64E66SGe78zJlsUofOg/yqwezdIcdfwik6B4h8LFmWPA9ef9X3FiNFLA==",
"dev": true,
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
},
"safe-buffer": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==",
"dev": true
},
"string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"requires": {
"safe-buffer": "~5.2.0"
}
}
}
},
"github-slugger": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.2.1.tgz",
@ -12780,6 +12865,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz",
"integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw=="
},
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
},
"lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -13589,6 +13679,14 @@
"integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==",
"dev": true
},
"node-gettext": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-2.0.0.tgz",
"integrity": "sha1-8dwSN83FRvUVk9o0AwS4vrpbhSU=",
"requires": {
"lodash.get": "^4.4.2"
}
},
"node-gyp": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz",
@ -14702,6 +14800,12 @@
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==",
"dev": true
},
"pofile": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pofile/-/pofile-1.0.11.tgz",
"integrity": "sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==",
"dev": true
},
"popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",

Просмотреть файл

@ -16,6 +16,7 @@
"dev": "webpack --config webpack.dev.js",
"watch": "webpack --progress --watch --config webpack.dev.js",
"build": "NODE_ENV=production webpack --progress --hide-modules --config webpack.prod.js",
"l10n:extract": "node build/extract-l10n.js",
"lint": "eslint --ext .js,.vue src",
"lint:fix": "eslint --ext .js,.vue src --fix",
"test": "jest --verbose",
@ -33,6 +34,7 @@
],
"dependencies": {
"@nextcloud/axios": "^1.1.0",
"@nextcloud/l10n": "^1.1.0",
"@nextcloud/router": "^1.0.0",
"core-js": "^3.4.4",
"escape-html": "^1.0.3",
@ -70,6 +72,8 @@
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^6.0.1",
"file-loader": "^5.0.2",
"gettext-extractor": "^3.5.2",
"gettext-parser": "^4.0.2",
"iconfont-plugin-webpack": "^1.1.4",
"jest": "^25.1.0",
"jest-environment-jsdom-sixteen": "^1.0.0",

Просмотреть файл

@ -40,6 +40,7 @@
<script>
import { directive as ClickOutside } from 'v-click-outside'
import { t } from '../../l10n'
export default {
directives: {
@ -49,8 +50,7 @@ export default {
title: {
type: String,
required: false,
// TODO: translate
default: t('core', 'Settings')
default: t('Settings')
}
},
data() {

Просмотреть файл

@ -93,7 +93,7 @@
'app-sidebar-header--compact': compact
}" class="app-sidebar-header">
<!-- close sidebar button -->
<a href="#" class="app-sidebar__close icon-close" :title="t('core', 'close')"
<a href="#" class="app-sidebar__close icon-close" :title="t('close')"
@click.prevent="closeSidebar" />
<!-- sidebar header illustration/figure -->
@ -188,6 +188,7 @@
import Vue from 'vue'
import Actions from 'Components/Actions'
import Focus from 'Directives/Focus'
import l10n from '../../mixins/l10n'
const IsValidString = function(value) {
return value && typeof value === 'string' && value.trim() !== '' && value.indexOf(' ') === -1
@ -201,6 +202,7 @@ export default {
directives: {
focus: Focus
},
mixins: [l10n],
props: {
active: {
type: String,

Просмотреть файл

@ -142,7 +142,7 @@ export default {
v-if="advanced"
class="color-picker-navigation-button confirm"
@click="handleConfirm">
{{ t('core', 'Choose') }}
{{ t('Choose') }}
</button>
</div>
</div>
@ -152,6 +152,7 @@ export default {
<script>
import { Chrome } from 'vue-color'
import GenColors from 'Utils/GenColors'
import l10n from '../../mixins/l10n'
import Popover from '../Popover'
export default {
@ -160,6 +161,7 @@ export default {
Chrome,
Popover
},
mixins: [l10n],
props: {
/**

Просмотреть файл

@ -106,7 +106,7 @@ export default {
<!-- Close modal -->
<Actions v-if="canClose" class="header-close">
<ActionButton icon="icon-close" @click="close">
{{ t('core', 'Close') }}
{{ t('Close') }}
</ActionButton>
</Actions>
</div>
@ -131,7 +131,7 @@ export default {
@click="previous">
<div class="icon icon-previous">
<span class="hidden-visually">
{{ t('core', 'Previous') }}
{{ t('Previous') }}
</span>
</div>
</a>
@ -151,7 +151,7 @@ export default {
@click="next">
<div class="icon icon-next">
<span class="hidden-visually">
{{ t('core', 'Next') }}
{{ t('Next') }}
</span>
</div>
</a>
@ -166,6 +166,8 @@ export default {
import Hammer from 'hammerjs'
import Actions from 'Components/Actions'
import ActionButton from 'Components/ActionButton'
import l10n from '../../mixins/l10n'
import t from '../../l10n'
import Tooltip from 'Directives/Tooltip'
import Timer from 'Utils/Timer'
@ -181,6 +183,8 @@ export default {
tooltip: Tooltip
},
mixins: [l10n],
props: {
/**
* Title to be shown with the modal
@ -283,7 +287,7 @@ export default {
return `modal-${this.outTransition ? 'out' : 'in'}`
},
playPauseTitle() {
return this.playing ? t('core', 'Pause slideshow') : t('core', 'Start slideshow')
return this.playing ? t('Pause slideshow') : t('Start slideshow')
}
},

Просмотреть файл

@ -166,14 +166,14 @@ Please see the [AvatarSelectOption](#AvatarSelectOption) component
<slot :name="slot" v-bind="scope" />
</template>
<!-- TODO add translation system
<span slot="noResult">{{ t('core', 'No results') }}</span> -->
<span slot="noResult">{{ t('No results') }}</span>
</VueMultiselect>
</template>
<script>
import AvatarSelectOption from './AvatarSelectOption'
import EllipsisedOption from './EllipsisedOption'
import l10n from '../../mixins/l10n'
import Tooltip from 'Directives/Tooltip'
import VueMultiselect from 'vue-multiselect'
@ -187,6 +187,7 @@ export default {
directives: {
tooltip: Tooltip
},
mixins: [l10n],
inheritAttrs: false,
/**

Просмотреть файл

@ -75,7 +75,7 @@ export default {
:tag-width="60"
:disabled="disabled"
@input="update">
<span slot="noResult">{{ t('core', 'No results') }}</span>
<span slot="noResult">{{ t('No results') }}</span>
<template #option="scope">
{{ tagLabel(scope.option) }}
</template>
@ -83,18 +83,21 @@ export default {
</template>
<script>
import l10n from '../../mixins/l10n'
import { Multiselect } from 'Components/Multiselect'
import { searchTags } from './api'
import { t } from '../../l10n'
export default {
name: 'MultiselectTags',
components: {
Multiselect
},
mixins: [l10n],
props: {
label: {
type: String,
default: t('systemtags', 'Select a tag')
default: t('Select a tag')
},
value: {
type: [Number, Array],
@ -163,12 +166,10 @@ export default {
*/
tagLabel({ displayName, userVisible, userAssignable }) {
if (userVisible === false) {
// TODO Use proper parameters once the translation is updated in the systemtags app
return t('systemtags', '%s (invisible)').replace('%s', displayName)
return t('{tag} (invisible)', { tag: displayName })
}
if (userAssignable === false) {
// TODO Use proper parameters once the translation is updated in the systemtags app
return t('systemtags', '%s (restricted)').replace('%s', displayName)
return t('{tag} (restricted)', { tag: displayName })
}
return displayName
}

9
src/l10n.js Normal file
Просмотреть файл

@ -0,0 +1,9 @@
import { getGettextBuilder } from '@nextcloud/l10n/dist/gettext'
const gtBuilder = getGettextBuilder()
.detectLocale()
TRANSLATIONS.map(data => gtBuilder.addTranslation(data.locale, data.json))
const gt = gtBuilder.build()
export const n = gt.ngettext.bind(gt)
export const t = gt.gettext.bind(gt)

8
src/mixins/l10n.js Normal file
Просмотреть файл

@ -0,0 +1,8 @@
import { n, t } from '../l10n'
export default {
methods: {
n,
t
}
}

Просмотреть файл

@ -20,19 +20,5 @@
*
*/
import { config } from '@vue/test-utils'
// Mock nextcloud translate functions
config.mocks.$t = function(app, string) {
return string
}
config.mocks.t = config.mocks.$t
global.t = config.mocks.$t
config.mocks.$n = function(app, singular, plural, count) {
return singular
}
config.mocks.n = config.mocks.$n
global.n = config.mocks.$n
global.TRANSLATIONS = []
global.SCOPE_VERSION = 1

Просмотреть файл

@ -1,7 +1,10 @@
const path = require('path')
const fs = require('fs')
const gettextParser = require('gettext-parser')
const glob = require('glob')
const { VueLoaderPlugin } = require('vue-loader')
const md5 = require('md5')
const path = require('path')
const StyleLintPlugin = require('stylelint-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const IconfontPlugin = require('iconfont-plugin-webpack')
@ -10,7 +13,6 @@ const { DefinePlugin } = require('webpack')
const nodeExternals = require('webpack-node-externals')
// scope variable
const md5 = require('md5')
const appVersion = JSON.stringify(process.env.npm_package_version)
const versionHash = md5(appVersion).substr(0, 7)
const SCOPE_VERSION = JSON.stringify(versionHash)
@ -18,6 +20,23 @@ const ICONFONT_NAME = `iconfont-vue-${versionHash}`
console.info('This build version hash is', versionHash, '\n')
// https://github.com/alexanderwallin/node-gettext#usage
// https://github.com/alexanderwallin/node-gettext#load-and-add-translations-from-mo-or-po-files
const translations = fs
.readdirSync('./l10n')
.filter(name => name !== 'messages.pot' && name.endsWith('.pot'))
.map(file => {
const path = './l10n/' + file
const locale = file.substr(0, file.length -'.pot'.length)
const po = fs.readFileSync(path)
const json = gettextParser.po.parse(po)
return {
locale,
json
}
})
module.exports = {
entry: {
ncvuecomponents: path.join(__dirname, 'src', 'index.js'),
@ -111,7 +130,10 @@ module.exports = {
new StyleLintPlugin({
files: ['src/**/*.vue', 'src/**/*.scss', 'src/**/*.css']
}),
new DefinePlugin({ SCOPE_VERSION })
new DefinePlugin({
SCOPE_VERSION,
TRANSLATIONS: JSON.stringify(translations)
})
],
resolve: {
alias: {