From d342d3b1352e962e130a2f543ab2970f2845401e Mon Sep 17 00:00:00 2001 From: Philip Date: Wed, 1 Jun 2022 18:39:37 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 26 + .eslintrc.js | 38 ++ .gitattributes | 2 + .gitignore | 4 + .npmignore | 8 + .npmrc | 1 + LICENSE | 21 + jest.config.js | 195 +++++++ package.json | 56 ++ readme.md | 39 ++ src/async.js | 147 +++++ src/index.js | 8 + src/options.js | 192 +++++++ src/plugin-error.js | 41 ++ src/shared.js | 347 ++++++++++++ src/sync.js | 97 ++++ src/utils.js | 156 ++++++ test/async.test.js | 343 ++++++++++++ test/examples/input/nl_NL.po | 149 +++++ test/examples/input/text-domain-nl_NL.po | 149 +++++ test/examples/output_correct/nl_NL.po | 168 ++++++ .../output_correct/text-domain-nl_NL.po | 168 ++++++ test/examples/text-domain.pot | 152 +++++ test/examples/text-domain2.pot | 152 +++++ test/options.test.js | 366 ++++++++++++ test/shared.test.js | 57 ++ test/sync.test.js | 282 ++++++++++ test/utils.test.js | 527 ++++++++++++++++++ 28 files changed, 3891 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintrc.js create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 .npmrc create mode 100644 LICENSE create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/async.js create mode 100644 src/index.js create mode 100644 src/options.js create mode 100644 src/plugin-error.js create mode 100644 src/shared.js create mode 100644 src/sync.js create mode 100644 src/utils.js create mode 100644 test/async.test.js create mode 100644 test/examples/input/nl_NL.po create mode 100644 test/examples/input/text-domain-nl_NL.po create mode 100644 test/examples/output_correct/nl_NL.po create mode 100644 test/examples/output_correct/text-domain-nl_NL.po create mode 100644 test/examples/text-domain.pot create mode 100644 test/examples/text-domain2.pot create mode 100644 test/options.test.js create mode 100644 test/shared.test.js create mode 100644 test/sync.test.js create mode 100644 test/utils.test.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9b4332b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Leave PO/MO/POT files alone +[**.{po,mo,pot}] +insert_final_newline = false + +# Matches multiple files with brace expansion notation +# Set default charset +[**.js] +charset = utf-8 +trim_trailing_whitespace = true +indent_style = tab +indent_size = 2 + +# Matches the exact files either package.json or .travis.yml +[*.json] +indent_style = space +indent_size = 2 diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..6d77097 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,38 @@ +module.exports = { + 'env': { + 'commonjs': true, + 'es2021': true, + 'node': true + }, + 'extends': 'eslint:recommended', + 'overrides': [ + { + 'env': { 'jest': true }, + 'files': ['test/**'], + 'plugins': ['jest'], + 'extends': ['plugin:jest/recommended'], + 'rules': {} + } + ], + 'parserOptions': { + 'ecmaVersion': 'latest' + }, + 'rules': { + 'indent': [ + 'error', + 'tab' + ], + 'linebreak-style': [ + 'error', + 'unix' + ], + 'quotes': [ + 'error', + 'single' + ], + 'semi': [ + 'error', + 'always' + ], + } +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..09bc62c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59b3f8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/other +*.bak +# jest.config.js diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..fc2dc85 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +/test +/other +.editorconfig +.gitattributes +.gitignore +.eslintrc.js +jest.config.js +*.bak diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21e54fe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Philip van Heemstra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c37719b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,195 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\phili\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls, instances, contexts and results before every test + // clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + // coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // Indicates which provider should be used to instrument code for coverage + // coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + rootDir: './test/', + + // A list of paths to directories that Jest should use to search for files in + roots: [ + "" + ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + verbose: false, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..501d341 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "fill-pot-po", + "version": "0.3.0", + "description": "Create pre-filled PO files from POT file, using previous PO files.", + "main": "src/index.js", + "scripts": { + "lint": "eslint src/*.js test/*.js", + "lint:fix": "eslint --fix src/*.js test/*.js", + "test": "jest", + "preversion": "npm run lint && npm test", + "postversion": "git push && git push --tags" + }, + "keywords": [ + "pot", + "po", + "fill", + "merge", + "auto-fill", + "prefill", + "pre-fill", + "generate", + "create", + "i18n", + "l10n", + "gettext", + "translation" + ], + "repository": "https://github.com/vheemstra/fill-pot-po.git", + "homepage": "https://github.com/vheemstra/fill-pot-po", + "bugs": "https://github.com/vheemstra/fill-pot-po/issues", + "author": { + "name": "Philip van Heemstra", + "url": "https://github.com/vheemstra" + }, + "engines": { + "node": ">=12" + }, + "files": [ + "src" + ], + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "color-support": "^1.1.3", + "gettext-parser": "^5.1.2", + "matched": "^5.0.1", + "vinyl": "^2.2.1" + }, + "devDependencies": { + "eslint": "^8.16.0", + "eslint-plugin-jest": "^26.4.5", + "gulp": "^4.0.2", + "gulp-wp-pot": "^2.5.0", + "jest": "^28.1.0" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..97bc525 --- /dev/null +++ b/readme.md @@ -0,0 +1,39 @@ +# fill-pot-po + +## Information + +| Package | wp-pot | +| ----------- | ---------------------------------------------------- | +| Description | Generate pre-filled PO files from POT file, using source PO files. | + +## Install + +```sh +npm install --save-dev fill-pot-po +``` + +## Example usage + +### Basic + +```js +const fillPotPo = require('fill-pot-po'); + +fillPotPo({ + ... +}); +``` + +## Options + +... + +## Related + +- [gulp-fill-pot-po](https://github.com/vheemstra/gulp-fill-pot-po) - Run fill-pot-po in gulp +- [gettext-parser](https://github.com/smhg/gettext-parser) - Parse and compile gettext PO and MO files with NodeJS +- [gulp-wp-pot](https://github.com/wp-pot/gulp-wp-pot) - Generate POT files in WordPress project in gulp + +## License + +MIT © [Philip van Heemstra](https://github.com/vheemstra) diff --git a/src/async.js b/src/async.js new file mode 100644 index 0000000..5e3355e --- /dev/null +++ b/src/async.js @@ -0,0 +1,147 @@ +'use strict'; + +const PluginError = require('./plugin-error'); +const c = require('ansi-colors'); +c.enabled = require('color-support').hasBasic; + +const Vinyl = require('vinyl'); +const { readFile } = require('fs'); +const gettextParser = require('gettext-parser'); + +const prepareOptions = require('./options'); +const { resolvePOTFilepaths, getPOFilepaths, generatePO, logResults } = require('./shared'); + +/** + * Reads and parses PO file. + * + * @param {string} po_filepath + * @param {function} resolve Resolve callback + * @param {function} reject Reject callback + * + * @resolve {object} PO content + * @reject {string} File reading error + * + * @return {void} + */ +function parsePO(po_filepath, resolve, reject) { + // Async - Read, parse and process PO file + readFile(po_filepath, (err, file_content) => { + if (err) reject(err); + const po_object = gettextParser.po.parse(file_content); + resolve(po_object); + }); +} + +/** + * Process POT file. + * + * - Finds the PO filepaths + * - Reads and parses the POT file + * + * @param {string} pot_filepath + * @param {object} options + * + * @resolve {array} [ + * {object} POT content + * {array} PO filepaths + * ] + * @reject {string} File reading error + * + * @return {void} + */ +function processPOT(pot_file, options, resolve, reject) { + const isVinyl = Vinyl.isVinyl(pot_file); + const pot_filepath = isVinyl ? pot_file.path : pot_file; + + // Get filepaths of POs + const po_filepaths = getPOFilepaths(pot_filepath, options); + + if (po_filepaths.length) { + if (isVinyl) { + const pot_object = gettextParser.po.parse(pot_file.contents); + resolve([pot_object, po_filepaths]); + } else { + // Async - Read and parse POT file + readFile(pot_filepath, (err, file_content) => { + if (err) reject(err); + const pot_object = gettextParser.po.parse(file_content); + resolve([pot_object, po_filepaths]); + }); + } + } else { + resolve(null); + } +} + +function fillPotPo(cb, options) { + if (typeof cb !== 'function') { + throw new PluginError('fillPotPo() requires a callback function as first parameter'); + } + + // Set options + try { + options = prepareOptions(options); + options = resolvePOTFilepaths(options); + } catch (error) { + cb([false, error.toString()]); + return; + } + + const po_input_files = []; + + // Process all POT files + Promise.all( options.potSources.map(pot_file => { + const pot_filepath = Vinyl.isVinyl(pot_file) ? pot_file.relative : pot_file; + + return new Promise((resolve, reject) => { + + processPOT(pot_file, options, resolve, reject); + + }).then(async (value) => { + + if (!value) { + po_input_files.push([]); + return []; + } + + // Process all PO files + let pot_object = value[0]; + let po_files = value[1]; + po_input_files.push(po_files); + const po_results = await Promise.all( po_files.map(po_file => { + return new Promise((resolve, reject) => { + + parsePO(po_file, resolve, reject); + + }).then(po_object => { + + // Generate PO and add to collection + return generatePO(pot_object, po_object, po_file, options); + + }).catch(error => { + throw new PluginError(`${c.bold(error)} ${c.gray(`(PO ${c.white(po_file)})`)}`); + }); + + }) ); + return po_results; + + }).catch(error => { + throw new PluginError(`${error.message} ${c.gray(`(POT ${c.white(pot_filepath)})`)}`); + }); + + }) ).then(pot_results => { + if (options.logResults) { + logResults(options._potFiles, po_input_files, pot_results, options.destDir); + } + + // Flatten into array with all PO files + pot_results = [].concat(...pot_results); + cb([true, pot_results]); + }).catch(error => { + cb([false, error.toString()]); + }); + + return; +} + +module.exports = fillPotPo; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..08505fb --- /dev/null +++ b/src/index.js @@ -0,0 +1,8 @@ +'use strict'; + +const fillPotPo = require('./async'); +const fillPotPoSync = require('./sync'); + +// See: https://stackoverflow.com/a/54047219/2142071 +const the_module = module.exports = fillPotPo; +the_module.sync = fillPotPoSync; diff --git a/src/options.js b/src/options.js new file mode 100644 index 0000000..638fc37 --- /dev/null +++ b/src/options.js @@ -0,0 +1,192 @@ +'use strict'; + +const PluginError = require('./plugin-error'); +const { isArray, isObject, isString, isBool, isArrayOfStrings } = require('./utils'); + +// const { sync: matchedSync } = require('matched'); +// const { pathLineSort } = require('./utils'); +const { resolve, relative } = require('path'); + +/** + * Wrapper for PluginError for all errors with options. + */ +class OptionsError extends PluginError { + constructor( message ) { + super( message, 'options' ); + } +} + +let cwd = './'; + +/** + * Validate user supplied options. + * + * @param {mixed} options + * + * @throws OptionError on invalid or missing options. + * + * @return {object} + */ +function validateOptionsInput(options) { + if (isObject(options)) { + if (typeof options.potSources !== 'undefined' && !isArray(options.potSources)) { + throw new OptionsError('Option potSources should be an array.'); + } + + if ( + typeof options.poSources !== 'undefined' + && options.poSources + && ! (isString(options.poSources) || isArrayOfStrings(options.poSources)) + ) { + throw new OptionsError('Option poSources should be a glob string or glob array.'); + } + + if (typeof options.wrapLength !== 'undefined' + && (typeof options.wrapLength !== 'number' || 0 >= options.wrapLength) + ) { + throw new OptionsError('If set, option wrapLength should be a number higher than 0.'); + } + + const if_set_string = [ 'srcDir', 'destDir', 'domain', + ]; + if_set_string.forEach(k => { + if (typeof options[k] !== 'undefined' && ! isString(options[k])) { + throw new OptionsError(`Option ${k} should be a string.`); + } + }); + + const no_newlines = [ 'srcDir', 'destDir', 'domain' ]; + no_newlines.forEach(k => { + if (typeof options[k] !== 'undefined' && options[k].match(/\n/)) { + throw new OptionsError(`Option ${k} can't contain newline characters.`); + } + }); + + const if_set_bool = [ + 'writeFiles', + 'domainFromPOTPath', + 'domainInPOPath', + 'defaultContextAsFallback', + 'appendNonIncludedFromPO', + 'includePORevisionDate', + 'logResults', + ]; + if_set_bool.forEach(k => { + if (typeof options[k] !== 'undefined' && ! isBool(options[k])) { + throw new OptionsError(`Option ${k} should be a boolean.`); + } + }); + } else if (isString(options) || isArrayOfStrings(options)) { + options = { + poSources: options + }; + } else if (typeof options !== 'undefined') { + throw new OptionsError('Options should be an object of options, glob string or glob array.'); + } else { + options = {}; + } + + return options; +} + +/** + * Clean and standardize user supplied options. + * + * @param {object} options + * + * @return {object} + */ +function sanitizeAndStandardizeOptionsInput(options) { + if (typeof options.poSources !== 'undefined') { + // Make array with one or more non-empty strings + if (!isArray(options.poSources)) { + options.poSources = [options.poSources]; + } + options.poSources = options.poSources + .map(v => (typeof v === 'string' ? v.trim() : v)) + .filter(v => (typeof v === 'string' && v.length > 0)) + ; + if (0 >= options.poSources.length) { + delete options.poSources; + } + } + if (options.srcDir) { + // NOTE: all paths starting with a slash are considered absolute paths + options.srcDir = resolve(options.srcDir.trim()); + options.srcDir = `${relative(cwd, options.srcDir)}/` // add trailing slash + .replaceAll(/\\/g, '/') // only forward slashes + .replaceAll(/\/+/g, '/') // remove duplicate slashes + .replaceAll(/^\//g, '') // remove leading slash + ; + } + if (options.destDir) { + // NOTE: all paths starting with a slash are considered absolute paths + options.destDir = resolve(options.destDir.trim()); + options.destDir = `${relative(cwd, options.destDir)}/` // add trailing slash + .replaceAll(/\\/g, '/') // only forward slashes + .replaceAll(/\/+/g, '/') // remove duplicate slashes + .replaceAll(/^\//g, '') // remove leading slash + ; + } + if (options.wrapLength) { + // Make integer + options.wrapLength = Math.ceil(options.wrapLength); + } + + return options; +} + +/** + * Process user supplied options and merge with default options. + * + * @param {mixed} options + * + * @throws OptionError on invalid, missing or incompatible options. + * + * @return {object} + */ +function prepareOptions(options, writeFiles) { + cwd = resolve(); + + // Validate/check options + options = validateOptionsInput(options); + + // Sanitize/clean and standardize options + options = sanitizeAndStandardizeOptionsInput(options); + + const defaultOptions = { + potSources: ['**/*.pot', '!node_modules/**'], + poSources: null, + srcDir: '', + srcGlobOptions: {}, + writeFiles: (typeof writeFiles !== 'undefined') ? writeFiles : true, + destDir: '', + domainFromPOTPath: true, + domain: '', + domainInPOPath: true, + wrapLength: 77, + defaultContextAsFallback: false, + appendNonIncludedFromPO: false, + includePORevisionDate: false, + logResults: false, + }; + + // Merge with defaults + options = Object.assign({}, defaultOptions, options); + + /** + * Check for logical errors + */ + + if ( + options.domainFromPOTPath === false + && options.domainInPOPath === true + && 0 >= options.domain.length + ) { + throw new OptionsError('Option domain should be a non-empty string when domainFromPOTPath is false and domainInPOPath is true.'); + } + + return options; +} + +module.exports = prepareOptions; diff --git a/src/plugin-error.js b/src/plugin-error.js new file mode 100644 index 0000000..6af1ec5 --- /dev/null +++ b/src/plugin-error.js @@ -0,0 +1,41 @@ +'use strict'; + +const util = require('util'); +const c = require('ansi-colors'); +c.enabled = require('color-support').hasBasic; + +const pluginname = require('../package.json').name; + + +// class PluginError extends Error { +class PluginError { + constructor( message, category = '' ) { + // super( message ); + // this.name = 'PluginError'; + this.message = message; + this.category = category.slice(0, 1).toUpperCase() + category.slice(1).toLowerCase(); + } + + toString() { + return `${c.cyan(pluginname)} ${c.bold.red(`${this.category}Error`)} ${this.message}`; + } + + // See: https://nodejs.org/api/util.html#custom-inspection-functions-on-objects + [util.inspect.custom]() { // (depth, options, inspect) { + return this.toString(); + } +} + +module.exports = PluginError; + +/* + * TODO? ideas for new way of error writing + compile/display + */ +// PluginError('mergePotPo() requires a callback function as first argument.').during('processing').ofType('PO').of('file.po') +// PluginError('Missing file.').during('processing', 'PO', 'file.po') +// PluginError('should be integer').during('processing').ofType('option').of('lineWrap') + +// ${item_type}${item_name}${belonging_to}${error} + +// // (Option )(lineWrap) () ( should be an integer). +// // () (First argument)( of mergePotPO())( should be a callback function). \ No newline at end of file diff --git a/src/shared.js b/src/shared.js new file mode 100644 index 0000000..a1c0f21 --- /dev/null +++ b/src/shared.js @@ -0,0 +1,347 @@ +'use strict'; + +const packageJSON = require('../package.json'); +const { basename, dirname } = require('path'); +const { sync: matchedSync } = require('matched'); +const { existsSync, mkdirSync, writeFileSync } = require('fs'); +const gettextParser = require('gettext-parser'); +const { isString, pathLineSort } = require('./utils'); +const PluginError = require('./plugin-error'); +const c = require('ansi-colors'); +c.enabled = require('color-support').hasBasic; + + +/** + * Resolve POT sources globs to filepaths. + * If options.potSources is array of Vinyl objects, leave them as-is. + * + * @param {object} options + * @return {object} options + */ +function resolvePOTFilepaths(options) { + // Resolve POT filepaths to process, if array of strings + if (options.potSources.length && isString(options.potSources[0])) { + options.potSources = matchedSync(options.potSources); + } + + // Store POT filepaths for logging + options._potFiles = options.potSources.map(f => (isString(f) ? f : f.path)); + + if (0 >= options.potSources.length) { + throw new PluginError('No POT files found to process.'); + } + + if (1 < options.potSources.length && options.poSources) { + throw new PluginError('When processing multiple POT files, leave option poSources empty.\nElse, the same generated PO files will be overwritten for each POT file.', 'options'); + } + + return options; +} + +/** + * Get filepaths for all PO files to process. + * + * @param {string} pot_filepath + * @param {object} options + * + * @return {array} PO filepaths + */ +function getPOFilepaths(pot_filepath, options) { + const pot_name = basename(pot_filepath, '.pot'); + const domain = options.domainFromPOTPath ? pot_name: options.domain; + const po_dir = options.srcDir ? options.srcDir: `${dirname(pot_filepath)}/`; + + // TODO? also search subdirectories? + // preserving glob base for re-use at write and/or return paths + // use: https://github.com/gulpjs/glob-parent + // options.srcSearchSubdirectories = true/false + + // TODO? + // const srcDirs = matchedSync(`${po_dir}`); // Always has trailing separator + + // Auto-compile PO files glob + const po_files_glob = []; + if (options.poSources) { + // TODO? prefix all with po_dir ? + po_files_glob.push(...options.poSources); + } else { + const locale_glob = '[a-z][a-z]?([a-z])?(_[A-Z][A-Z]?([A-Z]))?(_formal)'; + const domain_glob = options.domainInPOPath ? `${domain}-`: ''; + po_files_glob.push(`${po_dir}${domain_glob}${locale_glob}.po`); + // TODO? + // const sub_dirs = options.srcSearchSubdirectories ? '**/': ''; + // po_files_glob.push(`${po_dir}${sub_dirs}${domain_glob}${locale_glob}.po`); + } + + // Find and sort file paths + const po_filepaths = pathLineSort(matchedSync(po_files_glob, options.srcGlobOptions)); + // TODO? + // store or return srcDirs (for subtracting from PO paths later on) + return po_filepaths; +} + +/** + * Create the new PO file. + * + * - Clones the POT object + * - Fills the object with available translations from PO file + * - Compiles new PO content + * - Optionally writes content to file + * - Returns filepath and content of new PO file + * + * @param {object} pot_object + * @param {object} po_object + * @param {string} po_filepath + * @param {object} options + * + * @return {array} [ + * New PO filepath + * New PO compiled content + * ] + */ +function generatePO(pot_object, po_object, po_filepath, options) { + // Deep clone POT as base for the new PO + let new_po_object = JSON.parse(JSON.stringify(pot_object)); + + // Pre-fill template with PO strings + new_po_object = fillPO(new_po_object, po_object, options); + + // Compile object to PO + const new_po_output = compilePO(new_po_object, options); + + // Optionally, write to file + // TODO? preserve subdirectories from search glob? + const new_po_filepath = basename(po_filepath); + if (options.writeFiles) { + writePO(`${options.destDir}${new_po_filepath}`, new_po_output); + } + + // Add Buffer to collection + return [new_po_filepath, new_po_output]; +} + +/** + * Fill new PO object with translations from PO file. + * + * - Finds and uses translations from PO file + * When fallback translations from default context are used, + * flags these as fuzzy. + * - Optionally, append all non-included translation strings from PO file + * and flags them as "DEPRECATED". + * If used as fallback translation, flags with a note about that as well. + * + * @param {object} new_po_object + * @param {object} po_object + * @param {object} options + * + * @return {object} Prefilled PO object + */ +function fillPO(new_po_object, po_object, options) { + // Traverse template contexts + for (const [ctxt, entries] of Object.entries(new_po_object.translations)) { + // Traverse template entries + for (const [msgid, entry] of Object.entries(entries)) { + // If the PO has a translation for this + // with equal number of strings, use to pre-fill it. + if ( + po_object.translations[ ctxt ] && + po_object.translations[ ctxt ][ msgid ] && + po_object.translations[ ctxt ][ msgid ]['msgstr'].length === entry['msgstr'].length + ) { + new_po_object.translations[ ctxt ][ msgid ]['msgstr'] = [ ...po_object.translations[ ctxt ][ msgid ]['msgstr'] ]; + } else if ( + options.defaultContextAsFallback && + po_object.translations[''] && + po_object.translations[''][ msgid ] && + po_object.translations[''][ msgid ]['msgstr'].length === entry['msgstr'].length + ) { + // Optionally, fallback to default context + new_po_object.translations[ ctxt ][ msgid ]['msgstr'] = [ ...po_object.translations[''][ msgid ]['msgstr'] ]; + + // Set/add fuzzy flag comment + const fuzzy_comment = 'fuzzy'; + if (!new_po_object.translations[ ctxt ][ msgid ].comments) { + new_po_object.translations[ ctxt ][ msgid ].comments = {}; + } + if (!new_po_object.translations[ ctxt ][ msgid ].comments.flag) { + new_po_object.translations[ ctxt ][ msgid ].comments.flag = fuzzy_comment; + } else { + new_po_object.translations[ ctxt ][ msgid ].comments.flag = ( + fuzzy_comment + ', ' + new_po_object.translations[ ctxt ][ msgid ].comments.flag + ); + } + + // Set translator comment to flag re-usage in case of deprecation + // NOTE: comment set on PO object, so it's only included if appended as deprecated. + const reusage_comment = `NOTE: re-used for same message, but with context '${ctxt}'`; + if (!po_object.translations[''][ msgid ].comments) { + po_object.translations[''][ msgid ].comments = {}; + } + if (!po_object.translations[''][ msgid ].comments.translator) { + po_object.translations[''][ msgid ].comments.translator = reusage_comment; + } else { + po_object.translations[''][ msgid ].comments.translator = ( + reusage_comment + '\n' + po_object.translations[''][ msgid ].comments.translator + ); + } + } + + // TODO: move undublicate to POT generator + // Extra - Remove duplicate translator comments + // Fix for gulp-wp-pot's POT generation + if ( entry.comments?.extracted ) { + let comments = entry.comments.extracted.split('\n'); + new_po_object.translations[ ctxt ][ msgid ].comments.extracted = comments.reduce((ar, v) => { + if (0 > ar.indexOf(v)) { + ar.push(v); + } + return ar; + }, []).join('\n'); + } + } + } + + if (options.appendNonIncludedFromPO) { + // Append all strings from PO that are not present in POT + for (const [ctxt, entries] of Object.entries(po_object.translations)) { + // Add context + if (!new_po_object.translations[ ctxt ]) { + new_po_object.translations[ ctxt ] = {}; + } + + for (const [msgid, entry] of Object.entries(entries)) { + // Add entry + if (!new_po_object.translations[ ctxt ][ msgid ]) { + new_po_object.translations[ ctxt ][ msgid ] = entry; + + // Add translator comment "DEPRECATED" + if (!new_po_object.translations[ ctxt ][ msgid ].comments) { + new_po_object.translations[ ctxt ][ msgid ].comments = {}; + } + if (!new_po_object.translations[ ctxt ][ msgid ].comments.translator) { + new_po_object.translations[ ctxt ][ msgid ].comments.translator = 'DEPRECATED'; + } else if (!entry.comments.translator.match(/^DEPRECATED$/gm)) { + new_po_object.translations[ ctxt ][ msgid ].comments.translator = ( + 'DEPRECATED\n' + new_po_object.translations[ ctxt ][ msgid ].comments.translator + ); + } + } + } + } + } + + if (options.includePORevisionDate) { + const d = new Date(); + const po_rev_date_string = [ + `${d.getUTCFullYear()}`, + `-${String(d.getUTCMonth() + 1).padStart(2, '0')}`, + `-${String(d.getUTCDate()).padStart(2, '0')}`, + ` ${String(d.getUTCHours()).padStart(2, '0')}`, + `:${String(d.getUTCMinutes()).padStart(2, '0')}`, + '+0000' + ].join(''); + new_po_object.headers['po-revision-date'] = po_rev_date_string; + } + + new_po_object.headers['X-Generator'] = `${packageJSON.name}/${packageJSON.version}`; + + return new_po_object; +} + +/** + * Compiles PO object to content. + * + * Also sorts the translation string by reference file and line number. + * Optional deprecated strings are sorted at the end of the file. + * + * @param {object} new_po_object + * @param {object} options + * + * @return {string} Compiled PO file content + */ +function compilePO(new_po_object, options) { + return gettextParser.po.compile(new_po_object, { + foldLength: options.wrapLength, + // Sort entries by first reference filepath and line number. + sort: (a, b) => { + // Entries with DEPRECATED translator comment are put last (but sorted as usual there). + const b_deprecated = b.comments?.translator?.match(/^DEPRECATED$/gm); + const a_deprecated = a.comments?.translator?.match(/^DEPRECATED$/gm); + if (!a_deprecated && b_deprecated) return -1; + if (a_deprecated && !b_deprecated) return 1; + + // Entries without reference(s) are put last. + if (!b.comments?.reference) return -1; + if (!a.comments?.reference) return 1; + + a = a.comments.reference.trim().split(/\s+/)[0]; + b = b.comments.reference.trim().split(/\s+/)[0]; + return pathLineSort(a, b); + }, + }); +} + +/** + * Writes PO content to file. + * + * If needed, creates the containing directory as well. + * + * @param {string} new_po_filepath + * @param {string} new_po_output + * + * @return {void} + */ +function writePO(new_po_filepath, new_po_output) { + const new_po_dir = dirname(new_po_filepath); + if (!existsSync(new_po_dir)) { + mkdirSync(new_po_dir, {recursive: true}); + } + writeFileSync(new_po_filepath, new_po_output); +} + +/** + * Log results to console. + * + * Optionally, logs input and output PO files per processed POT file. + * + * @param {array} pots POT filepaths + * @param {array} pos_in Array of arrays with source PO filepaths + * @param {array} pos_out Array of arrays with output PO filepaths + * @param {string} dest Destination directory path (for writing files) + * + * @return {void} + */ +function logResults(pots, pos_in, pos_out, dest) { + pots.forEach((pot, i) => { + const pot_filepath = basename(pot); + const po_filepaths_in = pos_in[i]?.map(po => po); + const po_filepaths_out = pos_out[i]?.map(po => po[0]); + const max_length_in = po_filepaths_in.reduce((p, c) => (Math.max(c.length, p)), 0); + + console.log(''); + + if (po_filepaths_out && po_filepaths_out.length) { + console.log(` ${c.bold.green('■')} ${c.white(pot_filepath)}`); + + po_filepaths_out.forEach((po_filepath_out, pi) => { + console.log([ + ' ', + `${c.cyan(po_filepaths_in[pi].padEnd(max_length_in, ' '))}`, + ` ${c.gray('—►')} `, + `${c.yellow(dest)}${c.yellow(po_filepath_out)}` + ].join('')); + }); + } else { + console.log(` ${c.gray('■')} ${c.white(pot_filepath)}`); + console.log(` ${c.gray('No PO files found.')}`); + } + }); + console.log(''); +} + +module.exports = { + resolvePOTFilepaths, + getPOFilepaths, + generatePO, + logResults +}; diff --git a/src/sync.js b/src/sync.js new file mode 100644 index 0000000..fe7b1fa --- /dev/null +++ b/src/sync.js @@ -0,0 +1,97 @@ +'use strict'; + +const Vinyl = require('vinyl'); +const { readFileSync } = require('fs'); +const gettextParser = require('gettext-parser'); + +const prepareOptions = require('./options'); +const { resolvePOTFilepaths, getPOFilepaths, generatePO, logResults } = require('./shared'); + +let po_input_files = []; +let po_output_files = []; + +/** + * Process all PO files for POT file. + * + * - Reads and parses each PO file + * - Generates new PO file + * - Adds result to collection + * + * @param {array} po_filepaths + * @param {object} pot_object + * @param {object} options + * + * @return {void} + */ +function processPOs(po_filepaths, pot_object, options) { + const pos = []; + // Parse PO files + for (const po_filepath of po_filepaths) { + // Sync - Read and parse PO file + const po_content = readFileSync(po_filepath).toString(); + const po_object = gettextParser.po.parse(po_content); + + // Generate PO and add to collection + pos.push( generatePO(pot_object, po_object, po_filepath, options) ); + } + po_output_files.push(pos); +} + +/** + * Process POT file. + * + * - Finds the PO filepaths + * - Reads and parses the POT file + * - Process all PO files + * + * @param {string} pot_filepath + * @param {object} options + * + * @return {void} + */ +function processPOT(pot_file, options) { + const isVinyl = Vinyl.isVinyl(pot_file); + const pot_filepath = isVinyl ? pot_file.path : pot_file; + + // Get filepaths of POs + const po_filepaths = getPOFilepaths(pot_filepath, options); + po_input_files.push(po_filepaths); + + if (po_filepaths.length) { + let pot_content = ''; + if (isVinyl) { + pot_content = pot_file.contents; + } else { + // Sync - Read and parse POT file + pot_content = readFileSync(pot_filepath).toString(); + } + const pot_object = gettextParser.po.parse(pot_content); + processPOs(po_filepaths, pot_object, options); + } else { + po_output_files.push([]); + } +} + +function fillPotPoSync(options) { + // Reset + po_output_files = []; + + // Set options + options = prepareOptions(options); + options = resolvePOTFilepaths(options); + + // Process all POT files + options.potSources.forEach(pot_file => { + processPOT(pot_file, options); + }); + + if (options.logResults) { + logResults(options._potFiles, po_input_files, po_output_files, options.destDir); + } + + // Flatten into array with all PO files + po_output_files = [].concat(...po_output_files); + return po_output_files; +} + +module.exports = fillPotPoSync; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..dd622a1 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,156 @@ +'use strict'; + +/** + * Escape string for literal match in regex. + * + * @link https://stackoverflow.com/a/6969486/2142071 + * + * @param {string} string + * + * @return {string} + */ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} +/** + * Determine if `ar` is an array or not. + * + * @param {mixed} ar + * + * @return {boolean} + */ +function isArray(ar) { + return Object.prototype.toString.call(ar) === '[object Array]'; +} + +/** + * Determine if `obj` is a object or not. + * + * @param {mixed} obj + * + * @return {boolean} + */ +function isObject(obj) { + return Object.prototype.toString.call(obj) === '[object Object]'; +} + +/** + * Determine if `str` is a string or not. + * + * @param {mixed} str + * + * @return {boolean} + */ +function isString(str) { + return Object.prototype.toString.call(str) === '[object String]'; +} + +/** + * Determine if `bl` is a boolean or not. + * + * @param {mixed} bl + * + * @return {boolean} + */ +function isBool(bl) { + return Object.prototype.toString.call(bl) === '[object Boolean]'; +} + +/** + * Determine if `ar` is an array containing only strings or not. + * + * @param {mixed} ar + * + * @return {boolean} + */ +function isArrayOfStrings(ar) { + if (!isArray(ar)) return false; + return ar.reduce((r, v) => (isString(v) && r), true); +} + +/** + * Array sort callback for file reference strings (optionally with line numbers). + * e.g. strings like 'some/file_path.js:123' + * + * @param {string} filepath (optionally with ':' + line number) + * @param {string} filepath (optionally with ':' + line number) + * + * @return {number} -1, 0 or 1 + */ +function pathLineSort(a, b) { + // Wrapper mode + if (isArray(a) && typeof b === 'undefined') { + return a.sort(pathLineSort); + } + + if (!isString(a) || !isString(b)) { + throw new Error('pathLineSort: a or b not a string'); + } + + // split line numbers + let a_line, b_line; + [a, a_line] = a.split(':'); + [b, b_line] = b.split(':'); + + // trim leading/trailing slashes + a = a.replaceAll(/(^\/|\/$)/g, ''); + b = b.replaceAll(/(^\/|\/$)/g, ''); + + // line numbers ascending for same paths + if (a == b) { + // no line number first + if ((!a_line || '' === a_line) && b_line.length) return -1; + if ((!b_line || '' === b_line) && a_line.length) return +1; + + return (parseInt(a_line) - parseInt(b_line)); + } + + // split by directory + a = a.split('/'); + b = b.split('/'); + + const a_len = a.length; + const b_len = b.length; + + // Based on: path-sort package + const l = Math.max(a_len, b_len); + for (let i = 0; i < l; i++) { + // less deep paths at the end + if (i >= b_len || '' === b[i]) return -1; + if (i >= a_len || '' === a[i]) return +1; + + // split extension + let a_part, b_part; + let a_ext, b_ext; + [a_part, a_ext] = a[i].split(/\.(?=[^.]*$)/g); + [b_part, b_ext] = b[i].split(/\.(?=[^.]*$)/g); + const a_is_file = (a_ext && '' !== a_ext); + const b_is_file = (b_ext && '' !== b_ext); + + // files after folders + if (!a_is_file && b_is_file) return -1; + if (a_is_file && !b_is_file) return +1; + + // file/folder name - alphabetical ascending + if (a_part.toUpperCase() > b_part.toUpperCase()) return +1; + if (a_part.toUpperCase() < b_part.toUpperCase()) return -1; + + // file extension - alphabetical ascending + if (a_ext && b_ext) { + if (a_ext.toUpperCase() > b_ext.toUpperCase()) return +1; + if (a_ext.toUpperCase() < b_ext.toUpperCase()) return -1; + } + } + + return 0; +} + +module.exports = { + escapeRegExp, + isArray, + isObject, + isString, + isBool, + isArrayOfStrings, + pathLineSort +}; diff --git a/test/async.test.js b/test/async.test.js new file mode 100644 index 0000000..ee3bd22 --- /dev/null +++ b/test/async.test.js @@ -0,0 +1,343 @@ +'use strict'; + +const fillPotPo = require('../src/async'); + +const { sync: matchedSync } = require('matched'); +const { rmSync, existsSync, mkdirSync, readFileSync } = require('fs'); + +const test_dir = 'test/examples/output/fa'; + +function clearOutputFolder() { + let files = matchedSync([ + `${test_dir}*`, + ]); + files.sort((a, b) => { + const a_len = a.split(/\//); + const b_len = b.split(/\//); + return b_len - a_len; + }); + for (const file of files) { + rmSync(file, { recursive: true }); + } +} + +const potSource = './test/examples/text-domain.pot'; +// const potSources = './test/examples/*.pot'; +const poSources = './test/examples/input/*.po'; +// const poSource = './test/examples/input/nl_NL.po'; + +beforeAll(() => { + clearOutputFolder(); +}); + +afterAll(() => { + clearOutputFolder(); +}); + +describe('async.js - single POT', () => { + + let folder_i = 0; + + /* eslint-disable jest/no-done-callback */ + + test('auto domain PO - no write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('auto no-domain PO - no write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('manual multiple PO - no write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + expect(result[1][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('auto domain PO - write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/text-domain-nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('auto no-domain PO - write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('manual multiple PO - write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + + // Check if files exist + const files = matchedSync([folder_path + '/*']); + expect(files).toEqual([ + folder_path + '/nl_NL.po', + folder_path + '/text-domain-nl_NL.po', + ]); + + // Check contents of files + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + /* eslint-enable jest/no-done-callback */ + +}); + +// TODO: multiple POT files? diff --git a/test/examples/input/nl_NL.po b/test/examples/input/nl_NL.po new file mode 100644 index 0000000..54a4835 --- /dev/null +++ b/test/examples/input/nl_NL.po @@ -0,0 +1,149 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: nl_NL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Domain: text-domain\n" +"X-Generator: Poedit 3.0.1\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" + +msgid "String 1" +msgstr "String 1 - without domain" + +# Basic single string +#: some/path/file.php:10 some/path/file.php:20 some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +msgid "String 6" +msgstr "String 6" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 some/misc/string.php:20 some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" diff --git a/test/examples/input/text-domain-nl_NL.po b/test/examples/input/text-domain-nl_NL.po new file mode 100644 index 0000000..205fd35 --- /dev/null +++ b/test/examples/input/text-domain-nl_NL.po @@ -0,0 +1,149 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: nl_NL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Domain: text-domain\n" +"X-Generator: Poedit 3.0.1\n" + +msgid "String 1" +msgstr "String 1 - with domain" + +# Basic single string +#: some/path/file.php:10 some/path/file.php:20 some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +msgid "String 6" +msgstr "String 6" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 some/misc/string.php:20 some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" diff --git a/test/examples/output_correct/nl_NL.po b/test/examples/output_correct/nl_NL.po new file mode 100644 index 0000000..787fced --- /dev/null +++ b/test/examples/output_correct/nl_NL.po @@ -0,0 +1,168 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: " +"__;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_" +"attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" +"X-Generator: fill-pot-po/0.3.0\n" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +#, fuzzy +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#: some/context/file.php:30 +#. translators: %s: item +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#: some/context/file.php:40 +#. translators: %s: count +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#: some/flagged/file.php:10 +#. translators: 1: item +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "String 6" + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +msgid "ID: %d" +msgstr "ID: %d" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#: some/other/path/file.php:50 +#. translators: %s: category +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#: some/other/path/file.php:60 +#. translators: %s: count +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +msgid "Missing string 2" +msgstr "" + +msgid "String 1" +msgstr "String 1 - without domain" + +# DEPRECATED +# NOTE: re-used for same message, but with context 'The context' +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# DEPRECATED +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +# DEPRECATED +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" \ No newline at end of file diff --git a/test/examples/output_correct/text-domain-nl_NL.po b/test/examples/output_correct/text-domain-nl_NL.po new file mode 100644 index 0000000..28f6423 --- /dev/null +++ b/test/examples/output_correct/text-domain-nl_NL.po @@ -0,0 +1,168 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: " +"__;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_" +"attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" +"X-Generator: fill-pot-po/0.3.0\n" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +#, fuzzy +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#: some/context/file.php:30 +#. translators: %s: item +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#: some/context/file.php:40 +#. translators: %s: count +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#: some/flagged/file.php:10 +#. translators: 1: item +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "String 6" + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +msgid "ID: %d" +msgstr "ID: %d" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#: some/other/path/file.php:50 +#. translators: %s: category +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#: some/other/path/file.php:60 +#. translators: %s: count +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +msgid "Missing string 2" +msgstr "" + +msgid "String 1" +msgstr "String 1 - with domain" + +# DEPRECATED +# NOTE: re-used for same message, but with context 'The context' +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# DEPRECATED +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +# DEPRECATED +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" \ No newline at end of file diff --git a/test/examples/text-domain.pot b/test/examples/text-domain.pot new file mode 100644 index 0000000..ef58b9f --- /dev/null +++ b/test/examples/text-domain.pot @@ -0,0 +1,152 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" + +msgid "String 1" +msgstr "" + +msgid "Missing string 2" +msgstr "" + + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "" +msgstr[1] "" + + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "" + + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "" + + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" + + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "" + + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "" diff --git a/test/examples/text-domain2.pot b/test/examples/text-domain2.pot new file mode 100644 index 0000000..ef58b9f --- /dev/null +++ b/test/examples/text-domain2.pot @@ -0,0 +1,152 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" + +msgid "String 1" +msgstr "" + +msgid "Missing string 2" +msgstr "" + + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "" +msgstr[1] "" + + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "" + + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "" + + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" + + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "" + + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "" diff --git a/test/options.test.js b/test/options.test.js new file mode 100644 index 0000000..b29883c --- /dev/null +++ b/test/options.test.js @@ -0,0 +1,366 @@ +'use strict'; + +const prepareOptions = require('../src/options'); + +const { escapeRegExp } = require('../src/utils'); + +function reOptionError(k) { + return new RegExp(`option ${escapeRegExp(k)}`, 'i'); +} + +const potSource = ['./test/examples/text-domain.pot']; + +describe('options.js - validate', () => { + // String only + const string_only = [ 'srcDir', 'destDir', 'domain' ]; + string_only.forEach(k => { + test(`${k} - only string`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: [] }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + // Boolean only + const bool_only = [ + 'writeFiles', + 'domainFromPOTPath', + 'domainInPOPath', + 'defaultContextAsFallback', + 'appendNonIncludedFromPO', + 'includePORevisionDate', + 'logResults', + ]; + bool_only.forEach(k => { + test(`${k} - only boolean`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: [] }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + // Number only + const number_only = [ 'wrapLength' ]; + number_only.forEach(k => { + test(`${k} - only number`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: [] }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + // Array only + const array_only = [ 'potSources' ]; + array_only.forEach(k => { + test(`${k} - only array`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: potSource }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + test('options - only absent, object, glob string or glob array', () => { + const re = /Options should be an object/; + + expect(() => { + prepareOptions(); + }).not.toThrow(re); + + expect(() => { + prepareOptions(undefined); + }).not.toThrow(re); + + expect(() => { + prepareOptions(null); + }).toThrow(re); + + expect(() => { + prepareOptions(false); + }).toThrow(re); + + expect(() => { + prepareOptions(true); + }).toThrow(re); + + expect(() => { + prepareOptions(1); + }).toThrow(re); + + expect(() => { + prepareOptions('**/*.po'); + }).not.toThrow(re); + + expect(() => { + prepareOptions([]); + }).not.toThrow(re); + + expect(() => { + prepareOptions({}); + }).not.toThrow(re); + + expect(() => { + prepareOptions([1]); + }).toThrow(re); + + expect(() => { + prepareOptions([true]); + }).toThrow(re); + + expect(() => { + prepareOptions(['**/*.po']); + }).not.toThrow(re); + + expect(() => { + prepareOptions([[]]); + }).toThrow(re); + + expect(() => { + prepareOptions([{}]); + }).toThrow(re); + }); + + test('poSources - if truthy, only string or array of strings', () => { + const re = reOptionError('poSources'); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: null }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: false }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: true }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: 1 }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: '**/*.po' }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: ['**/*.po'] }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: {} }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [1] }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [true] }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [[]] }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [{}] }); + }).toThrow(re); + }); + + test('srcDir - no newlines', () => { + expect(() => { + prepareOptions({ srcDir: 'some\npath' }); + }).toThrow(reOptionError('srcDir')); + }); + + test('destDir - no newlines', () => { + expect(() => { + prepareOptions({ destDir: 'some\npath' }); + }).toThrow(reOptionError('destDir')); + }); + + test('wrapLength - only positive integer', () => { + const re = reOptionError('wrapLength'); + + expect(() => { + prepareOptions({ wrapLength: -1 }); + }).toThrow(re); + + expect(() => { + prepareOptions({ wrapLength: 0 }); + }).toThrow(re); + }); +}); + +describe('options.js - clean & standardize', () => { + test('poSources - is array of only non-empty strings', () => { + // wrap string + expect( prepareOptions({ potSources: potSource, poSources: '**/*.po' }) ).toHaveProperty('poSources', ['**/*.po']); + + // filter non-strings and (trimmed) empty strings + const all = [ + 'first', + ' ', + 'second', + '', + ' \t third\t ', + ]; + const expected = [ + 'first', + 'second', + 'third', + ]; + expect( prepareOptions({ potSources: potSource, poSources: all }) ).toHaveProperty('poSources', expected); + }); + + const directories = [ 'srcDir', 'destDir' ]; + directories.forEach(k => { + test(`${k} - trimmed whitespace`, () => { + expect( prepareOptions({ [k]: ' ' }) ).toHaveProperty(k, ''); + expect( prepareOptions({ [k]: ' some ' }) ).toHaveProperty(k, 'some/'); + }); + + test(`${k} - only forward slashes`, () => { + expect( prepareOptions({ [k]: 'some\\' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\path\\' }) ).toHaveProperty(k, 'some/path/'); + }); + + test(`${k} - only single slashes`, () => { + expect( prepareOptions({ [k]: 'some\\' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\\\' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\/\\/' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\//\\\\' }) ).toHaveProperty(k, 'some/'); + }); + + test(`${k} - without current directory parts`, () => { + expect( prepareOptions({ [k]: './some/' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some/././.\\path' }) ).toHaveProperty(k, 'some/path/'); + expect( prepareOptions({ [k]: './../some/.\\path' }) ).toHaveProperty(k, '../some/path/'); + }); + + test(`${k} - trailing slash`, () => { + expect( prepareOptions({ [k]: 'some' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some/' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some/path' }) ).toHaveProperty(k, 'some/path/'); + }); + + test(`${k} - resolve to minimal path`, () => { + // basic + expect( prepareOptions({ [k]: 'some/../path' }) ).toHaveProperty(k, 'path/'); + // nested + expect( prepareOptions({ [k]: 'some/other/../../path' }) ).toHaveProperty(k, 'path/'); + // concurrent + expect( prepareOptions({ [k]: 'some/other/../path/../' }) ).toHaveProperty(k, 'some/'); + // nested and concurrent + expect( prepareOptions({ [k]: 'some/other/../path/../../' }) ).toHaveProperty(k, ''); + // leave non-solvables + expect( prepareOptions({ [k]: './../some/../path' }) ).toHaveProperty(k, '../path/'); + // empty if result would be '/' + expect( prepareOptions({ [k]: ' ./\\//.\\/.\\ ' }) ).toHaveProperty(k, ''); + // combination + expect( prepareOptions({ [k]: ' ./some\\other/.\\path ' }) ).toHaveProperty(k, 'some/other/path/'); + }); + }); + + test('wrapLength - ceiled integer', () => { + expect( prepareOptions({ wrapLength: 0.001 }) ).toHaveProperty('wrapLength', 1); + expect( prepareOptions({ wrapLength: 1.2 }) ).toHaveProperty('wrapLength', 2); + }); +}); + +describe('options.js - logic', () => { + test('provide domain if domainInPOPath and not domainFromPOTPath', () => { + const re = new RegExp('domain should be a non-empty string'); + expect(() => { + prepareOptions({ domainFromPOTPath: false }); + }).toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domain: '' }); + }).toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domain: 'some' }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domainInPOPath: true }); + }).toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domainInPOPath: false }); + }).not.toThrow(re); + }); +}); diff --git a/test/shared.test.js b/test/shared.test.js new file mode 100644 index 0000000..8d28fe5 --- /dev/null +++ b/test/shared.test.js @@ -0,0 +1,57 @@ +'use strict'; + +const { resolvePOTFilepaths } = require('../src/shared'); +const prepareOptions = require('../src/options'); + +const potSource = ['./test/examples/text-domain.pot']; + +describe('shared.js - logic', () => { + test('leave poSources empty if multiple potSources', () => { + const re = new RegExp('leave option poSources empty'); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ poSources: '' }) + ); + }).not.toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ poSources: '*.po' }) + ); + }).toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ poSources: ['*.po'] }) + ); + }).toThrow(re); + + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: potSource, poSources: '' }) + ); + }).not.toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: potSource, poSources: '*.po' }) + ); + }).not.toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: potSource, poSources: ['*.po'] }) + ); + }).not.toThrow(re); + }); + + test('potSources has files to process', () => { + const re = new RegExp('No POT files found'); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: [] }) + ); + }).toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: ['NON_EXISTING.pot'] }) + ); + }).toThrow(re); + }); +}); diff --git a/test/sync.test.js b/test/sync.test.js new file mode 100644 index 0000000..3a78a91 --- /dev/null +++ b/test/sync.test.js @@ -0,0 +1,282 @@ +'use strict'; + +const fillPotPo = require('../src/sync'); + +const { sync: matchedSync } = require('matched'); +const { rmSync, existsSync, mkdirSync, readFileSync } = require('fs'); + +const test_dir = 'test/examples/output/fs'; + +function clearOutputFolder() { + let files = matchedSync([ + `${test_dir}*`, + ]); + files.sort((a, b) => { + const a_len = a.split(/\//); + const b_len = b.split(/\//); + return b_len - a_len; + }); + for (const file of files) { + rmSync(file, { recursive: true }); + } +} + +const potSource = './test/examples/text-domain.pot'; +// const potSources = './test/examples/*.pot'; +const poSources = './test/examples/input/*.po'; +// const poSource = './test/examples/input/nl_NL.po'; + +beforeAll(() => { + clearOutputFolder(); +}); + +afterAll(() => { + clearOutputFolder(); +}); + +describe('sync.js - single POT', () => { + + let folder_i = 0; + + test('auto domain PO - no write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + }); + + test('auto no-domain PO - no write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + }); + + test('manual multiple PO - no write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + expect(result[1][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + }); + + test('auto domain PO - write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/text-domain-nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + }); + + test('auto no-domain PO - write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + }); + + test('manual multiple PO - write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + // Check returned array + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + + // Check if files exist + const files = matchedSync([folder_path + '/*']); + expect(files).toEqual([ + folder_path + '/nl_NL.po', + folder_path + '/text-domain-nl_NL.po', + ]); + + // Check contents of files + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + }); + +}); + +// TODO: multiple POT files? diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..e10dcef --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,527 @@ +'use strict'; + +const { isArray, isObject, isString, isBool, isArrayOfStrings, pathLineSort } = require('../src/utils'); + +class SomeClass { + constructor() {} +} + +function someFunction() {} + +/** + * Helper function to create extensive test filepaths list. + * + * @return {array} Filepaths + */ +/* eslint-disable-next-line no-unused-vars */ +function generatePaths() { + const dirs = [ + '', + 'some/other/path/', + 'some/other/folder/', + 'some/', + 'some/path/', + 'some/quoted/', + ]; + + const files = [ + 'file', + 'file.ext', + 'file.oth', + 'different_file.ext', + '.ext', + ]; + + const lines = [ + '', + ':10', + ':20', + ':100', + ]; + + let filepaths = [].concat( + ...dirs.map(d => + [d].concat( + ...files.map(f => + lines.map(l => + `${d}${f}${l}` + ) + ) + ) + ) + ); + + return filepaths; +} + +/** + * Shuffle array in-place. + * + * @param {array} array + * @return {array} + */ +function shuffle(array) { + let currentIndex = array.length, randomIndex; + + // While there remain elements to shuffle. + while (currentIndex != 0) { + + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [ + array[currentIndex], array[randomIndex] + ] = [ + array[randomIndex], array[currentIndex] + ]; + } + + return array; +} + +describe('utils.js - isArray', () => { + test('false on null', () => { + expect( isArray(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isArray(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isArray(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isArray(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isArray(false) ).toEqual(false); + }); + + test('false on string', () => { + expect( isArray('string') ).toEqual(false); + }); + + test('false on class instance', () => { + expect( isArray(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isArray(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isArray(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isArray(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isArray({}) ).toEqual(false); + }); + + test('true on array', () => { + expect( isArray([]) ).toEqual(true); + }); +}); + +describe('utils.js - isObject', () => { + test('false on null', () => { + expect( isObject(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isObject(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isObject(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isObject(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isObject(false) ).toEqual(false); + }); + + test('false on string', () => { + expect( isObject('string') ).toEqual(false); + }); + + test('true on class instance', () => { + expect( isObject(new SomeClass()) ).toEqual(true); + }); + + test('false on class', () => { + expect( isObject(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isObject(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isObject(someFunction) ).toEqual(false); + }); + + test('true on object', () => { + expect( isObject({}) ).toEqual(true); + }); + + test('false on array', () => { + expect( isObject([]) ).toEqual(false); + }); +}); + +describe('utils.js - isString', () => { + test('false on null', () => { + expect( isString(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isString(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isString(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isString(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isString(false) ).toEqual(false); + }); + + test('true on string', () => { + expect( isString('string') ).toEqual(true); + }); + + test('false on class instance', () => { + expect( isString(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isString(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isString(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isString(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isString({}) ).toEqual(false); + }); + + test('false on array', () => { + expect( isString([]) ).toEqual(false); + }); +}); + +describe('utils.js - isBool', () => { + test('false on null', () => { + expect( isBool(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isBool(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isBool(1) ).toEqual(false); + }); + + test('true on boolean true', () => { + expect( isBool(true) ).toEqual(true); + }); + + test('true on boolean false', () => { + expect( isBool(false) ).toEqual(true); + }); + + test('false on string', () => { + expect( isBool('string') ).toEqual(false); + }); + + test('false on class instance', () => { + expect( isBool(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isBool(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isBool(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isBool(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isBool({}) ).toEqual(false); + }); + + test('false on array', () => { + expect( isBool([]) ).toEqual(false); + }); +}); + +describe('utils.js - isArrayOfStrings', () => { + test('false on null', () => { + expect( isArrayOfStrings(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isArrayOfStrings(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isArrayOfStrings(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isArrayOfStrings(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isArrayOfStrings(false) ).toEqual(false); + }); + + test('false on string', () => { + expect( isArrayOfStrings('string') ).toEqual(false); + }); + + test('false on class instance', () => { + expect( isArrayOfStrings(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isArrayOfStrings(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isArrayOfStrings(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isArrayOfStrings(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isArrayOfStrings({}) ).toEqual(false); + }); + + test('true on array', () => { + expect( isArrayOfStrings([]) ).toEqual(true); + }); + + test('false on array w/ null', () => { + expect( isArrayOfStrings([null]) ).toEqual(false); + }); + + test('false on array w/ undefined', () => { + expect( isArrayOfStrings([undefined]) ).toEqual(false); + }); + + test('false on array w/ boolean true', () => { + expect( isArrayOfStrings([true]) ).toEqual(false); + }); + + test('false on array w/ boolean false', () => { + expect( isArrayOfStrings([false]) ).toEqual(false); + }); + + test('false on array w/ number', () => { + expect( isArrayOfStrings([1]) ).toEqual(false); + }); + + test('true on array w/ string', () => { + expect( isArrayOfStrings(['']) ).toEqual(true); + }); + + test('false on array w/ class instance', () => { + expect( isArrayOfStrings([new SomeClass()]) ).toEqual(false); + }); + + test('false on array w/ class', () => { + expect( isArrayOfStrings([SomeClass]) ).toEqual(false); + }); + + test('false on array w/ arrow function', () => { + expect( isArrayOfStrings([() => {}]) ).toEqual(false); + }); + + test('false on array w/ function', () => { + expect( isArrayOfStrings([someFunction]) ).toEqual(false); + }); + + test('false on array w/ object', () => { + expect( isArrayOfStrings([{}]) ).toEqual(false); + }); + + test('false on array w/ array', () => { + expect( isArrayOfStrings([[]]) ).toEqual(false); + }); +}); + +describe('utils.js - pathLineSort', () => { + // NOTE: no-extension files are treated as folder + // 1) folders first, files and empty last + // 2) folder/file names sort alphabetically + // (per depth; first by name, then by extension) + // 3) no line number first + // 4) line numbers ascending numerically + const sorted = [ + 'file', + 'file:10', + 'file:20', + 'file:100', + 'some/file', + 'some/file:10', + 'some/file:20', + 'some/file:100', + 'some/other/folder/file', + 'some/other/folder/file:10', + 'some/other/folder/file:20', + 'some/other/folder/file:100', + 'some/other/folder/.ext', + 'some/other/folder/.ext:10', + 'some/other/folder/.ext:20', + 'some/other/folder/.ext:100', + 'some/other/folder/different_file.ext', + 'some/other/folder/different_file.ext:10', + 'some/other/folder/different_file.ext:20', + 'some/other/folder/different_file.ext:100', + 'some/other/folder/file.ext', + 'some/other/folder/file.ext:10', + 'some/other/folder/file.ext:20', + 'some/other/folder/file.ext:100', + 'some/other/folder/file.oth', + 'some/other/folder/file.oth:10', + 'some/other/folder/file.oth:20', + 'some/other/folder/file.oth:100', + 'some/other/folder/', + 'some/other/path/file', + 'some/other/path/file:10', + 'some/other/path/file:20', + 'some/other/path/file:100', + 'some/other/path/.ext', + 'some/other/path/.ext:10', + 'some/other/path/.ext:20', + 'some/other/path/.ext:100', + 'some/other/path/different_file.ext', + 'some/other/path/different_file.ext:10', + 'some/other/path/different_file.ext:20', + 'some/other/path/different_file.ext:100', + 'some/other/path/file.ext', + 'some/other/path/file.ext:10', + 'some/other/path/file.ext:20', + 'some/other/path/file.ext:100', + 'some/other/path/file.oth', + 'some/other/path/file.oth:10', + 'some/other/path/file.oth:20', + 'some/other/path/file.oth:100', + 'some/other/path/', + 'some/path/file', + 'some/path/file:10', + 'some/path/file:20', + 'some/path/file:100', + 'some/path/.ext', + 'some/path/.ext:10', + 'some/path/.ext:20', + 'some/path/.ext:100', + 'some/path/different_file.ext', + 'some/path/different_file.ext:10', + 'some/path/different_file.ext:20', + 'some/path/different_file.ext:100', + 'some/path/file.ext', + 'some/path/file.ext:10', + 'some/path/file.ext:20', + 'some/path/file.ext:100', + 'some/path/file.oth', + 'some/path/file.oth:10', + 'some/path/file.oth:20', + 'some/path/file.oth:100', + 'some/path/', + 'some/quoted/file', + 'some/quoted/file:10', + 'some/quoted/file:20', + 'some/quoted/file:100', + 'some/quoted/.ext', + 'some/quoted/.ext:10', + 'some/quoted/.ext:20', + 'some/quoted/.ext:100', + 'some/quoted/different_file.ext', + 'some/quoted/different_file.ext:10', + 'some/quoted/different_file.ext:20', + 'some/quoted/different_file.ext:100', + 'some/quoted/file.ext', + 'some/quoted/file.ext:10', + 'some/quoted/file.ext:20', + 'some/quoted/file.ext:100', + 'some/quoted/file.oth', + 'some/quoted/file.oth:10', + 'some/quoted/file.oth:20', + 'some/quoted/file.oth:100', + 'some/quoted/', + 'some/.ext', + 'some/.ext:10', + 'some/.ext:20', + 'some/.ext:100', + 'some/different_file.ext', + 'some/different_file.ext:10', + 'some/different_file.ext:20', + 'some/different_file.ext:100', + 'some/file.ext', + 'some/file.ext:10', + 'some/file.ext:20', + 'some/file.ext:100', + 'some/file.oth', + 'some/file.oth:10', + 'some/file.oth:20', + 'some/file.oth:100', + 'some/', + '.ext', + '.ext:10', + '.ext:20', + '.ext:100', + 'different_file.ext', + 'different_file.ext:10', + 'different_file.ext:20', + 'different_file.ext:100', + 'file.ext', + 'file.ext:10', + 'file.ext:20', + 'file.ext:100', + 'file.oth', + 'file.oth:10', + 'file.oth:20', + 'file.oth:100', + '' + ]; + + let shuffled = shuffle( sorted.slice() ); + + test('used as function', () => { + expect( pathLineSort(shuffled) ).toEqual(sorted); + }); + + test('used as callback for array.sort()', () => { + expect( shuffled.sort(pathLineSort) ).toEqual(sorted); + }); +});