diff --git a/.eslintrc.js b/.eslintrc.js index 342099f5..e2cad14f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,10 +11,10 @@ module.exports = { overrides: [ { files: ['**/_locales/**/*.json'], - plugins: ['@rafaelgssa/local'], - extends: ['plugin:@rafaelgssa/local/recommended'], + plugins: ['@rafaelgomesxyz/i18n-json'], + extends: ['plugin:@rafaelgomesxyz/i18n-json/recommended'], rules: { - '@rafaelgssa/local/identical-keys': [ + '@rafaelgomesxyz/i18n-json/identical-keys': [ 'error', { filePath: path.resolve('./src/_locales/en/messages.json'), @@ -23,7 +23,7 @@ module.exports = { ], }, settings: { - '@rafaelgssa/local/ignore-keys': ['*.description', '*.placeholders'], + '@rafaelgomesxyz/i18n-json/ignore-keys': ['*.description', '*.placeholders'], }, }, { @@ -49,7 +49,7 @@ module.exports = { }, { files: ['**/*.{ts,tsx}'], - plugins: ['@rafaelgssa/local', 'prefer-arrow'], + plugins: ['prefer-arrow'], extends: [ 'eslint:recommended', 'plugin:react/recommended', diff --git a/.eslintrc.typed.js b/.eslintrc.typed.js index ab4f0803..4ff21ea0 100644 --- a/.eslintrc.typed.js +++ b/.eslintrc.typed.js @@ -11,10 +11,10 @@ module.exports = { overrides: [ { files: ['**/_locales/**/*.json'], - plugins: ['@rafaelgssa/local'], - extends: ['plugin:@rafaelgssa/local/recommended'], + plugins: ['@rafaelgomesxyz/i18n-json'], + extends: ['plugin:@rafaelgomesxyz/i18n-json/recommended'], rules: { - '@rafaelgssa/local/identical-keys': [ + '@rafaelgomesxyz/i18n-json/identical-keys': [ 'error', { filePath: path.resolve('./src/_locales/en/messages.json'), @@ -23,7 +23,7 @@ module.exports = { ], }, settings: { - '@rafaelgssa/local/ignore-keys': ['*.description', '*.placeholders'], + '@rafaelgomesxyz/i18n-json/ignore-keys': ['*.description', '*.placeholders'], }, }, { @@ -53,7 +53,7 @@ module.exports = { tsconfigRootDir: __dirname, project: ['./tsconfig.json'], }, - plugins: ['@rafaelgssa/local', 'prefer-arrow'], + plugins: ['prefer-arrow'], extends: [ 'eslint:recommended', 'plugin:react/recommended', diff --git a/eslint-local.js b/eslint-local.js deleted file mode 100644 index d059ffc1..00000000 --- a/eslint-local.js +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck - -const i18nJson = require('./local-eslint-plugins/eslint-plugin-i18n-json'); - -module.exports = { - ...i18nJson, - rules: { - ...i18nJson.rules, - }, -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/formatter.js b/local-eslint-plugins/eslint-plugin-i18n-json/formatter.js deleted file mode 100644 index 9b601ce0..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/formatter.js +++ /dev/null @@ -1,63 +0,0 @@ -// @ts-nocheck - -/* - Custom eslint formatter for eslint-plugin-i18n-json to allow better error message display. - Heavily inspired from https://github.com/sindresorhus/eslint-formatter-pretty. -*/ - -/* eslint no-useless-concat: "off" */ - -const chalk = require('chalk'); -const plur = require('plur'); -const logSymbols = require('log-symbols'); -const indentString = require('indent-string'); -const path = require('path'); - -const CWD = process.cwd(); - -const formatter = (results) => { - let totalErrorsCount = 0; - let totalWarningsCount = 0; - - const formattedLintMessagesPerFile = results.map(({ - filePath, - messages: fileMessages, - errorCount: fileErrorCount, - warningCount: fileWarningCount - }) => { - if (fileErrorCount + fileWarningCount === 0) { - return ''; - } - - totalErrorsCount += fileErrorCount; - totalWarningsCount += fileWarningCount; - - const relativePath = path.relative(CWD, filePath); - const fileMessagesHeader = chalk.underline.white(relativePath); - - fileMessages.sort((a, b) => b.severity - a.severity); // display errors first - - const formattedFileMessages = fileMessages.map(({ ruleId, severity, message }) => { - let messageHeader = severity === 1 ? `${logSymbols.warning} ${chalk.inverse.yellow(' WARNING ')}` - : `${logSymbols.error} ${chalk.inverse.red(' ERROR ')}`; - - messageHeader += (` ${chalk.white(`(${ruleId})`)}`); - - return `\n\n${messageHeader}\n${indentString(message, 2)}`; - }).join(''); - - return `${fileMessagesHeader}${formattedFileMessages}`; - }).filter(fileLintMessages => fileLintMessages.trim().length > 0); - - let aggregateReport = formattedLintMessagesPerFile.join('\n\n'); - - // append in total error and warnings count to aggregrate report - const totalErrorsCountFormatted = `${chalk.bold.red('>')} ${logSymbols.error} ${chalk.bold.red(totalErrorsCount)} ${chalk.bold.red(plur('ERROR', totalErrorsCount))}`; - const totalWarningsCountFormatted = `${chalk.bold.yellow('>')} ${logSymbols.warning} ${chalk.bold.yellow(totalWarningsCount)} ${chalk.bold.yellow(plur('WARNING', totalWarningsCount))}`; - - aggregateReport += `\n\n${totalErrorsCountFormatted}\n${totalWarningsCountFormatted}`; - - return (totalErrorsCount + totalWarningsCount > 0) ? aggregateReport : ''; -}; - -module.exports = formatter; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/index.js b/local-eslint-plugins/eslint-plugin-i18n-json/index.js deleted file mode 100644 index 78508daf..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/index.js +++ /dev/null @@ -1,50 +0,0 @@ -// @ts-nocheck - -/* eslint-disable global-require */ - -module.exports = { - rules: { - 'valid-json': require('./src/valid-json'), - 'valid-message-syntax': require('./src/valid-message-syntax'), - 'identical-keys': require('./src/identical-keys'), - 'sorted-keys': require('./src/sorted-keys') - }, - processors: { - '.json': { - preprocess: (source, filePath) => - // augment the json into a comment - // along with the source path :D - // so we can pass it to the rules - - // note: due to the spaced comment rule, include - // spaced comments - [`/* ${source.trim()} *//* ${filePath.trim()} */\n`], - // since we only return one line in the preprocess step, - // we only care about the first array of errors - postprocess: ([errors]) => [...errors], - supportsAutofix: true - } - }, - configs: { - recommended: { - plugins: ['@rafaelgssa/local'], - rules: { - '@rafaelgssa/local/valid-message-syntax': [ - 2, - { - syntax: 'icu' // default syntax - } - ], - '@rafaelgssa/local/valid-json': 2, - '@rafaelgssa/local/sorted-keys': [ - 2, - { - order: 'asc', - indentSpaces: 2 - } - ], - '@rafaelgssa/local/identical-keys': 0 - } - } - } -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/identical-keys.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/identical-keys.js deleted file mode 100644 index e0f68b3c..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/identical-keys.js +++ /dev/null @@ -1,235 +0,0 @@ -// @ts-nocheck - -const requireNoCache = require('./util/require-no-cache'); -const compareTranslationsStructure = require('./util/compare-translations-structure'); -const getTranslationFileSource = require('./util/get-translation-file-source'); - -const noDifferenceRegex = /Compared\s+values\s+have\s+no\s+visual\s+difference/i; - -// suffix match each key in the mapping with the current source file path. -// pick the first match. -const getKeyStructureFromMap = (filePathMap, sourceFilePath) => { - // do a suffix match - const match = Object.keys(filePathMap) - .filter(filePath => sourceFilePath.endsWith(filePath)) - .pop(); - if (match) { - try { - const filepath = filePathMap[match]; - return requireNoCache(filepath); - } catch (e) { - throw new Error(`\n Error parsing or retrieving key structure comparison file based on "filePath" mapping\n\n "${match}" => "${filePathMap[match]}".\n\n Check the "filePath" option for this rule. \n ${e}`); - } - } - throw new Error('\n Current translation file does not have a matching entry in the "filePath" map.\n Check the "filePath" option for this rule.\n'); -}; - -/* - comparisonOptions : { - filePath = (string | Function | Object) - - If it's a string, then it can be a file to require in order to compare - it's key structure with the current translation file. - - - If the required value is a function, then the function is called - with the sourceFilePath and parsed translations to retreive the key structure. - - If it's an object , then it should have a mapping b/w file names - and what key structure file to require. - - checkDuplicateValues = boolean - - If true, the values will also be checked for duplicates, - in comparison to the file specified in filePath. - } -*/ - -const getKeyStructureToMatch = ( - options = {}, - currentTranslations, - sourceFilePath -) => { - let keyStructure = null; - let { filePath } = options; - - if (typeof filePath === 'string') { - filePath = filePath.trim(); - } - - if (!filePath) { - return { - errors: [ - { - message: '"filePath" rule option not specified.', - loc: { - start: { - line: 0, - col: 0 - } - } - } - ] - }; - } - - if (typeof filePath === 'string') { - try { - keyStructure = requireNoCache(filePath); //eslint-disable-line - } catch (e) { - return { - errors: [ - { - message: `\n Error parsing or retrieving key structure comparison file from\n "${filePath}".\n Check the "filePath" option for this rule.\n ${e}`, - loc: { - start: { - line: 0, - col: 0 - } - } - } - ] - }; - } - - if (typeof keyStructure !== 'function') { - return { - keyStructure - }; - } - - // keyStructure exported a function - try { - return { - keyStructure: keyStructure(currentTranslations, sourceFilePath) - }; - } catch (e) { - return { - errors: [ - { - message: `\n Error when calling custom key structure function from\n "${filePath}".\n Check the "filePath" option for this rule.\n ${e}`, - loc: { - start: { - line: 0, - col: 0 - } - } - } - ] - }; - } - } - - // due to eslint rule schema, we can assume the "filePath" option is an object. - // anything else will be caught by the eslint rule schema validator. - try { - return { - keyStructure: getKeyStructureFromMap(filePath, sourceFilePath) - }; - } catch (e) { - return { - errors: [ - { - message: `${e}`, - loc: { - start: { - line: 0, - col: 0 - } - } - } - ] - }; - } -}; - -const identicalKeys = (context, source, sourceFilePath) => { - const { options, settings = {} } = context; - - const comparisonOptions = options[0]; - - let currentTranslations = null; - try { - currentTranslations = JSON.parse(source); - } catch (e) { - // don't return any errors - // will be caught with the valid-json rule. - return []; - } - const { errors, keyStructure } = getKeyStructureToMatch( - comparisonOptions, - currentTranslations, - sourceFilePath - ); - - if (errors) { - // errors generated from trying to get the key structure - return errors; - } - - const { checkDuplicateValues = false } = comparisonOptions; - const isSourceFile = sourceFilePath === comparisonOptions.filePath; - const diffString = compareTranslationsStructure( - settings, - keyStructure, - currentTranslations, - checkDuplicateValues && !isSourceFile - ); - - if (noDifferenceRegex.test(diffString.trim())) { - // success - return []; - } - // mismatch - return [ - { - message: `\n${diffString}`, - loc: { - start: { - line: 0, - col: 0 - } - } - } - ]; -}; - -module.exports = { - meta: { - docs: { - category: 'Consistency', - description: - 'Verifies the key structure for the translation file matches the key structure specified in the options', - recommended: false - }, - schema: [ - { - properties: { - filePath: { - type: ['string', 'object'] - }, - checkDuplicateValues: { - type: 'boolean' - } - }, - type: 'object' - } - ] - }, - create(context) { - return { - Program(node) { - const { valid, source, sourceFilePath } = getTranslationFileSource({ - context, - node - }); - if (!valid) { - return; - } - const errors = identicalKeys(context, source, sourceFilePath); - errors.forEach((error) => { - context.report(error); - }); - } - }; - } -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/icu.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/icu.js deleted file mode 100644 index 2ae73c71..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/icu.js +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck - -const intlMessageParser = require('intl-messageformat-parser'); - -// a message validator should throw if there is any error -module.exports = (message) => { - intlMessageParser.parse(message); -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/is-string.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/is-string.js deleted file mode 100644 index 9c6cb474..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/is-string.js +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck - -module.exports = (message) => { - if (typeof message !== 'string') { - throw new TypeError('Message must be a String.'); - } -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/not-empty.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/not-empty.js deleted file mode 100644 index 7a3cd7de..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/message-validators/not-empty.js +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck - -module.exports = (message) => { - let normalized = message; - if (typeof message === 'string') { - normalized = message.trim(); - } - if (!normalized) { - throw new SyntaxError('Message is Empty.'); - } -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/sorted-keys.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/sorted-keys.js deleted file mode 100644 index 495d85b6..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/sorted-keys.js +++ /dev/null @@ -1,116 +0,0 @@ -// @ts-nocheck - -const set = require('lodash.set'); -const equal = require('lodash.isequal'); -const isPlainObject = require('lodash.isplainobject'); -const deepForOwn = require('./util/deep-for-own'); -const keyTraversals = require('./util/key-traversals'); -const getTranslationFileSource = require('./util/get-translation-file-source'); - -const sortedKeys = ([{ order = 'asc', indentSpaces = 2 } = {}], source) => { - let translations = null; - - try { - translations = JSON.parse(source); - } catch (e) { - // ignore errors, this will - // be caught by the i18n-json/valid-json rule - return []; - } - - // default traversal is asc. - let traversalOrder = keyTraversals.asc; - - if (order.toLowerCase() === 'desc') { - traversalOrder = keyTraversals.desc; - } - - const sortedTranslations = {}; - const sortedTranslationPaths = []; - - deepForOwn( - translations, - (value, key, path) => { - // if plain object, stub in a clean one to then get filled. - set(sortedTranslations, path, isPlainObject(value) ? {} : value); - sortedTranslationPaths.push(path); - }, - { - keyTraversal: traversalOrder - } - ); - - // only need to fix if the order of the keys is not the same - const originalTranslationPaths = []; - deepForOwn(translations, (value, key, path) => { - originalTranslationPaths.push(path); - }); - - if (!equal(originalTranslationPaths, sortedTranslationPaths)) { - const sortedWithIndent = JSON.stringify( - sortedTranslations, - null, - indentSpaces - ); - - return [ - { - message: 'Keys should be sorted, please use --fix.', - loc: { - start: { - line: 0, - col: 0 - } - }, - fix: fixer => - fixer.replaceTextRange([0, source.length], sortedWithIndent), - line: 0, - column: 0 - } - ]; - } - // no errors - return []; -}; - -module.exports = { - meta: { - fixable: 'code', - docs: { - category: 'Stylistic Issues', - description: 'Ensure an order for the translation keys. (Recursive)', - recommended: true - }, - schema: [ - { - properties: { - order: { - type: 'string' - }, - indentSpaces: { - type: 'number' - } - }, - type: 'object', - additionalProperties: false - } - ] - }, - create(context) { - return { - Program(node) { - const { valid, source } = getTranslationFileSource({ - context, - node - }); - if (!valid) { - return; - } - const errors = sortedKeys(context.options, source); - errors.forEach((error) => { - context.report(error); - }); - } - }; - } -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/compare-translations-structure.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/util/compare-translations-structure.js deleted file mode 100644 index c5ad5ece..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/compare-translations-structure.js +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-nocheck - -const set = require('lodash.set'); -const get = require('lodash.get'); -const diff = require('jest-diff'); -const deepForOwn = require('./deep-for-own'); - -const DIFF_OPTIONS = { - expand: false, - contextLines: 1 -}; - -const noDifferenceRegex = /Compared\s+values\s+have\s+no\s+visual\s+difference/i; - -// we don't care what the actual values are. -// lodash.set will automatically convert a previous string value -// into an object, if the current path states that a key is nested inside. -// reminder, deepForOwn goes from the root level to the deepest level (preorder) -const compareTranslationsStructure = ( - settings, - translationsA, - translationsB, - checkDuplicateValues -) => { - const augmentedTranslationsA = {}; - const augmentedTranslationsB = {}; - - const ignorePaths = settings['@rafaelgssa/local/ignore-keys'] || []; - - const opts = { - ignorePaths - }; - - const duplicateTranslations = {}; - - deepForOwn(translationsA, (valueA, key, path) => { - set(augmentedTranslationsA, path, 'Message'); - }, opts); - deepForOwn(translationsB, (valueB, key, path) => { - let newValue = 'Message'; - if (checkDuplicateValues) { - set(duplicateTranslations, path, newValue); - const valueA = get(translationsA, path); - if (valueA === valueB) { - newValue = valueB; - } - } - set(augmentedTranslationsB, path, newValue); - }, opts); - const diffString = diff(augmentedTranslationsA, augmentedTranslationsB, DIFF_OPTIONS); - if (checkDuplicateValues && noDifferenceRegex.test(diffString.trim())) { - return diff(augmentedTranslationsB, duplicateTranslations, DIFF_OPTIONS); - } - return diffString; -}; - -module.exports = compareTranslationsStructure; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/deep-for-own.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/util/deep-for-own.js deleted file mode 100644 index 166aff90..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/deep-for-own.js +++ /dev/null @@ -1,49 +0,0 @@ -// @ts-nocheck - -const isPlainObject = require('lodash.isplainobject'); -const micromatch = require('micromatch'); - -/* - deep level order traversal. - Takes the `keyTraversal` iterator - which specify in what order the current level's - key should be visited. - - // calls iteratee with the path to the object. -*/ - -const shouldIgnorePath = (ignoreList, keyPath) => micromatch.isMatch(keyPath.join('.'), ignoreList); -const defaultTraversal = obj => Object.keys(obj); - -const deepForOwn = (obj, iteratee, { - keyTraversal = defaultTraversal, - ignorePaths = [] -} = {}) => { - const queue = []; - queue.push({ - currentObj: obj, - path: [] - }); - while (queue.length > 0) { - const { currentObj, path } = queue.shift(); - const levelSuccess = keyTraversal(currentObj).every((key) => { - const keyPath = [...path, key]; - // skip over ignored paths and their children - if (shouldIgnorePath(ignorePaths, keyPath)) { - return true; - } - if (isPlainObject(currentObj[key])) { - queue.push({ - currentObj: currentObj[key], - path: keyPath - }); - } - return iteratee(currentObj[key], key, keyPath) !== false; - }); - if (!levelSuccess) { - break; - } - } -}; - -module.exports = deepForOwn; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/get-translation-file-source.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/util/get-translation-file-source.js deleted file mode 100644 index 8416e9e0..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/get-translation-file-source.js +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-nocheck - -const path = require('path'); - -const isJSONFile = context => path.extname(context.getFilename()) === '.json'; - -const INVALID_SOURCE = { - valid: false, - source: null, - sourceFilePath: null -}; - -module.exports = ({ context, node }) => { - if ( - !isJSONFile(context) || - !Array.isArray(node.comments) || - node.comments.length < 2 - ) { - // is not a json file or the file - // has not been through the plugin preprocessor - return INVALID_SOURCE; - } - - const { value: source } = node.comments[0]; - const { value: sourceFilePath } = node.comments[1]; - - // valid source - return { - valid: true, - source: source && source.trim(), - sourceFilePath: sourceFilePath && sourceFilePath.trim() - }; -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/key-traversals.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/util/key-traversals.js deleted file mode 100644 index 4315071c..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/key-traversals.js +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck - -// case sensitive traversal orders -const keyTraversals = { - asc: obj => Object.keys(obj).sort(), - desc: obj => Object.keys(obj).sort((a, b) => { - // note, objects can't have duplicate keys of the same case - if (a < b) { - return 1; - } - return -1; - }) -}; - -module.exports = keyTraversals; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/require-no-cache.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/util/require-no-cache.js deleted file mode 100644 index 6dbd4d01..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/util/require-no-cache.js +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck - -/* eslint-disable global-require, import/no-dynamic-require */ - -// Delete the file from the require cache. -// This forces the file to be read from disk again. -// e.g) webpack dev server eslint loader support - -const requireNoCache = (path) => { - delete require.cache[path]; - return require(path); -}; - -module.exports = requireNoCache; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/valid-json.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/valid-json.js deleted file mode 100644 index 9949200b..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/valid-json.js +++ /dev/null @@ -1,77 +0,0 @@ -// @ts-nocheck - -const jsonlint = require('jsonlint'); -const isPlainObject = require('lodash.isplainobject'); -const chalk = require('chalk'); -const requireNoCache = require('./util/require-no-cache'); -const getTranslationFileSource = require('./util/get-translation-file-source'); - -const lineRegex = /line\s+(\d+):?/i; - -const validJSON = ([{ linter } = {}], source) => { - const errors = []; - try { - let parsed; - - if (linter) { - // use custom linter - parsed = requireNoCache(linter)(source); - } else { - parsed = jsonlint.parse(source); - } - - if (!isPlainObject(parsed)) { - throw new SyntaxError('Translation file must be a JSON object.'); - } - } catch (e) { - const [, lineNumber = 0] = e.message.match(lineRegex) || []; - errors.push({ - message: `\n${chalk.bold.red('Invalid JSON.')}\n\n${e}`, - loc: { - start: { - line: Number.parseInt(lineNumber, 10), - col: 0 - } - } - }); - } - return errors; -}; - -module.exports = { - meta: { - docs: { - category: 'Validation', - description: 'Validates the JSON translation file', - recommended: true - }, - schema: [ - { - properties: { - linter: { - type: 'string' - } - }, - type: 'object', - additionalProperties: false - } - ] - }, - create(context) { - return { - Program(node) { - const { valid, source } = getTranslationFileSource({ - context, - node - }); - if (!valid) { - return; - } - const errors = validJSON(context.options, source); - errors.forEach((error) => { - context.report(error); - }); - } - }; - } -}; diff --git a/local-eslint-plugins/eslint-plugin-i18n-json/src/valid-message-syntax.js b/local-eslint-plugins/eslint-plugin-i18n-json/src/valid-message-syntax.js deleted file mode 100644 index 2ba3f025..00000000 --- a/local-eslint-plugins/eslint-plugin-i18n-json/src/valid-message-syntax.js +++ /dev/null @@ -1,231 +0,0 @@ -// @ts-nocheck - -const set = require('lodash.set'); -const diff = require('jest-diff'); -const isPlainObject = require('lodash.isplainobject'); -const prettyFormat = require('pretty-format'); -const icuValidator = require('./message-validators/icu'); -const notEmpty = require('./message-validators/not-empty'); -const isString = require('./message-validators/is-string'); -const deepForOwn = require('./util/deep-for-own'); -const requireNoCache = require('./util/require-no-cache'); -const getTranslationFileSource = require('./util/get-translation-file-source'); - -/* Error tokens */ -const EMPTY_OBJECT = Symbol.for('EMPTY_OBJECT'); -const ARRAY = Symbol.for('ARRAY'); - -/* Formatting */ -const ALL_BACKSLASHES = /[\\]/g; -const ALL_DOUBLE_QUOTES = /["]/g; - -const prettyFormatTypePlugin = { - test(val) { - return typeof val === 'number' || typeof val === 'string'; - }, - serialize(val) { - return ( - (typeof val === 'string' && `String(${`'${val}'`})`) || `Number(${val})` - ); - } -}; - -const formatExpectedValue = ({ value }) => { - switch (value) { - case EMPTY_OBJECT: - case ARRAY: - return 'ObjectContaining | ValidMessage'; - default: - return 'ValidMessage'; - } -}; - -const formatReceivedValue = ({ value, error }) => { - const errorMessage = error.message - .replace(ALL_BACKSLASHES, '') - .replace(ALL_DOUBLE_QUOTES, "'"); - switch (value) { - case EMPTY_OBJECT: - return `${prettyFormat({})} ===> ${error}`; - case ARRAY: - return `${prettyFormat([])} ===> ${error}`; - default: - return `${prettyFormat(value, { - plugins: [prettyFormatTypePlugin] - })} ===> ${errorMessage}`; - } -}; - -const createValidator = (syntax) => { - // each syntax type defined here must have a case! - if (['icu', 'non-empty-string'].includes(syntax)) { - return (value) => { - switch (syntax) { - case 'icu': - notEmpty(value); - isString(value); - icuValidator(value); - break; - default: - notEmpty(value); - isString(value); - } - }; - } - // custom validator - const customValidator = requireNoCache(syntax); - return (value, key) => { - customValidator(value, key); - }; -}; - -const validMessageSyntax = (context, source) => { - const { options, settings = {} } = context; - - let { syntax } = options[0] || {}; - - syntax = syntax && syntax.trim(); - - let translations = null; - const invalidMessages = []; - - if (!syntax) { - return [ - { - message: '"syntax" not specified in rule option.', - loc: { - start: { - line: 0, - col: 0 - } - } - } - ]; - } - - try { - translations = JSON.parse(source); - } catch (e) { - return []; - } - - let validate; - - try { - validate = createValidator(syntax); - } catch (e) { - return [ - { - message: `Error configuring syntax validator. Rule option specified: ${syntax}. ${e}`, - loc: { - start: { - line: 0, - col: 0 - } - } - } - ]; - } - - const ignorePaths = settings['@rafaelgssa/local/ignore-keys'] || []; - - deepForOwn( - translations, - (value, key, path) => { - // empty object itself is an error - if (isPlainObject(value)) { - if (Object.keys(value).length === 0) { - invalidMessages.push({ - value: EMPTY_OBJECT, - key, - path, - error: new SyntaxError('Empty object.') - }); - } - } else if (Array.isArray(value)) { - invalidMessages.push({ - value: ARRAY, - key, - path, - error: new TypeError('An Array cannot be a translation value.') - }); - } else { - try { - validate(value, key); - } catch (validationError) { - invalidMessages.push({ - value, - key, - path, - error: validationError - }); - } - } - }, - { - ignorePaths - } - ); - - if (invalidMessages.length > 0) { - const expected = {}; - const received = {}; - invalidMessages.forEach((invalidMessage) => { - set(expected, invalidMessage.path, formatExpectedValue(invalidMessage)); - set(received, invalidMessage.path, formatReceivedValue(invalidMessage)); - }); - - return [ - { - message: `\n${diff(expected, received)}`, - loc: { - start: { - line: 0, - col: 0 - } - } - } - ]; - } - // no errors - return []; -}; - -module.exports = { - meta: { - docs: { - category: 'Validation', - description: - 'Validates message syntax for each translation key in the file.', - recommended: true - }, - schema: [ - { - properties: { - syntax: { - type: ['string'] - } - }, - type: 'object', - additionalProperties: false - } - ] - }, - create(context) { - return { - Program(node) { - const { valid, source } = getTranslationFileSource({ - context, - node - }); - if (!valid) { - return; - } - const errors = validMessageSyntax(context, source); - errors.forEach((error) => { - context.report(error); - }); - } - }; - } -}; diff --git a/package.json b/package.json index 42475b98..91f2a465 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.56", "@material-ui/pickers": "^3.2.10", + "axios": "^0.21.1", "history": "^4.10.1", "moment": "^2.27.0", "prop-types": "^15.7.2", @@ -52,20 +53,6 @@ "typeface-roboto": "^0.0.75", "webextension-polyfill": "^0.6.0" }, - "@comment devDependencies": { - "chalk": "Do not update, required for local eslint-plugin-i18n-json", - "indent-string": "Do not update, required for local eslint-plugin-i18n-json", - "intl-messageformat-parser": "Do not update, required for local eslint-plugin-i18n-json", - "jest-diff": "Do not update, required for local eslint-plugin-i18n-json", - "lodash.get": "Do not update, required for local eslint-plugin-i18n-json", - "lodash.isequal": "Do not update, required for local eslint-plugin-i18n-json", - "lodash.isplainobject": "Do not update, required for local eslint-plugin-i18n-json", - "lodash.set": "Do not update, required for local eslint-plugin-i18n-json", - "log-symbols": "Do not update, required for local eslint-plugin-i18n-json", - "micromatch": "Do not update, required for local eslint-plugin-i18n-json", - "plur": "Do not update, required for local eslint-plugin-i18n-json", - "pretty-format": "Do not update, required for local eslint-plugin-i18n-json" - }, "devDependencies": { "@babel/core": "^7.11.4", "@babel/plugin-proposal-class-properties": "^7.10.4", @@ -75,7 +62,7 @@ "@babel/preset-typescript": "^7.10.4", "@babel/runtime": "^7.11.2", "@octokit/rest": "^18.0.4", - "@rafaelgssa/eslint-plugin-local": "^1.1.0", + "@rafaelgomesxyz/eslint-plugin-i18n-json": "^3.1.2", "@svgr/webpack": "^5.4.0", "@types/fs-extra": "^9.0.1", "@types/gulp": "^4.0.6", @@ -93,7 +80,6 @@ "babel-eslint": "^10.1.0", "babel-loader": "^8.1.0", "babel-preset-minify": "^0.5.1", - "chalk": "^2.3.2", "clean-webpack-plugin": "^3.0.0", "css-loader": "^4.2.2", "eslint": "^7.7.0", @@ -106,21 +92,10 @@ "gulp": "^4.0.2", "gulp-zip": "^5.0.2", "husky": "^4.2.5", - "indent-string": "^3.2.0", - "intl-messageformat-parser": "^3.0.7", - "jest-diff": "^22.0.3", "jsonlint": "^1.6.2", "lint-staged": "^10.2.13", - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "lodash.isplainobject": "^4.0.6", - "lodash.set": "^4.3.2", - "log-symbols": "^2.2.0", - "micromatch": "^4.0.2", "npm-run-all": "^4.1.5", - "plur": "^2.1.2", "prettier": "^2.1.1", - "pretty-format": "^22.0.3", "progress-bar-webpack-plugin": "^2.1.0", "react-svg-loader": "^3.0.3", "sass": "^1.32.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa1f5cb8..8a96a091 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,7 @@ dependencies: '@material-ui/icons': 4.9.1_fbf7b062eed057bd42835e13c128ad4c '@material-ui/lab': 4.0.0-alpha.56_fbf7b062eed057bd42835e13c128ad4c '@material-ui/pickers': 3.2.10_1f98156bd48bdafcda5c0dbaa7af31be + axios: 0.21.1 history: 4.10.1 moment: 2.27.0 prop-types: 15.7.2 @@ -22,7 +23,7 @@ devDependencies: '@babel/preset-typescript': 7.10.4_@babel+core@7.11.4 '@babel/runtime': 7.11.2 '@octokit/rest': 18.0.4 - '@rafaelgssa/eslint-plugin-local': 1.1.0 + '@rafaelgomesxyz/eslint-plugin-i18n-json': 3.1.2_eslint@7.7.0 '@svgr/webpack': 5.4.0 '@types/fs-extra': 9.0.1 '@types/gulp': 4.0.6 @@ -40,7 +41,6 @@ devDependencies: babel-eslint: 10.1.0_eslint@7.7.0 babel-loader: 8.1.0_fbab4801791d746d265885e0d19f0bda babel-preset-minify: 0.5.1 - chalk: 2.4.2 clean-webpack-plugin: 3.0.0_webpack@4.44.1 css-loader: 4.2.2_webpack@4.44.1 eslint: 7.7.0 @@ -53,21 +53,10 @@ devDependencies: gulp: 4.0.2 gulp-zip: 5.0.2_gulp@4.0.2 husky: 4.2.5 - indent-string: 3.2.0 - intl-messageformat-parser: 3.6.4 - jest-diff: 22.4.3 jsonlint: 1.6.3 lint-staged: 10.2.13 - lodash.get: 4.4.2 - lodash.isequal: 4.5.0 - lodash.isplainobject: 4.0.6 - lodash.set: 4.3.2 - log-symbols: 2.2.0 - micromatch: 4.0.2 npm-run-all: 4.1.5 - plur: 2.1.2 prettier: 2.1.1 - pretty-format: 22.4.3 progress-bar-webpack-plugin: 2.1.0_webpack@4.44.1 react-svg-loader: 3.0.3 sass: 1.32.5 @@ -1459,17 +1448,19 @@ packages: dev: false resolution: integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== - /@formatjs/intl-unified-numberformat/3.3.7: + /@formatjs/ecma402-abstract/1.4.0: dependencies: - '@formatjs/intl-utils': 2.3.0 - deprecated: We have renamed the package to @formatjs/intl-numberformat + tslib: 2.2.0 dev: true resolution: - integrity: sha512-KnWgLRHzCAgT9eyt3OS34RHoyD7dPDYhRcuKn+/6Kv2knDF8Im43J6vlSW6Hm1w63fNq3ZIT1cFk7RuVO3Psag== - /@formatjs/intl-utils/2.3.0: + integrity: sha512-Mv027hcLFjE45K8UJ8PjRpdDGfR0aManEFj1KzoN8zXNveHGEygpZGfFf/FTTMl+QEVSrPAUlyxaCApvmv47AQ== + /@formatjs/intl-numberformat/5.7.6: + dependencies: + '@formatjs/ecma402-abstract': 1.4.0 + tslib: 2.2.0 dev: true resolution: - integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ== + integrity: sha512-ZlZfYtvbVHYZY5OG3RXizoCwxKxEKOrzEe2YOw9wbzoxF3PmFn0SAgojCFGLyNXkkR6xVxlylhbuOPf1dkIVNg== /@material-ui/core/4.11.0_cf9b8f424c3b21f800381c1e20e82bf8: dependencies: '@babel/runtime': 7.11.2 @@ -1738,10 +1729,29 @@ packages: dev: true resolution: integrity: sha512-OlMlSySBJoJ6uozkr/i03nO5dlYQyE05vmQNZhAh9MyO4DPBP88QlwsDVLmVjIMFssvIZB6WO0ctIGMRG+xsJQ== - /@rafaelgssa/eslint-plugin-local/1.1.0: + /@rafaelgomesxyz/eslint-plugin-i18n-json/3.1.2_eslint@7.7.0: + dependencies: + chalk: 2.4.2 + eslint: 7.7.0 + indent-string: 3.2.0 + intl-messageformat-parser: 5.5.1 + jest-diff: 22.4.3 + jsonlint: 1.6.3 + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + lodash.isplainobject: 4.0.6 + lodash.set: 4.3.2 + log-symbols: 2.2.0 + micromatch: 3.1.10 + plur: 2.1.2 + pretty-format: 22.4.3 dev: true + engines: + node: '>=6.0.0' + peerDependencies: + eslint: '>=4.0.0' resolution: - integrity: sha512-IpQhUwMMVTUbPs6VOv53pcDWVdorVpLih9+8HOt/4Dcay06XGxNwtGDsyx4R235s9BwQ5c6pvwnHzCJbHiNREA== + integrity: sha512-EoqmiMvZgPB+PxeNJUYlgJbajrx+4Bd5sKqamxyEdXHRQWfw591YQuRZ3kJrt9n6N/0mOT0/oBLgMpv5SOtvCQ== /@svgr/babel-plugin-add-jsx-attribute/5.4.0: dev: true engines: @@ -2724,6 +2734,12 @@ packages: hasBin: true resolution: integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + /axios/0.21.1: + dependencies: + follow-redirects: 1.14.0 + dev: false + resolution: + integrity: sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== /babel-eslint/10.1.0_eslint@7.7.0: dependencies: '@babel/code-frame': 7.10.4 @@ -4618,6 +4634,17 @@ packages: dev: true resolution: integrity: sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== + /follow-redirects/1.14.0: + dev: false + engines: + node: '>=4.0' + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + resolution: + integrity: sha512-0vRwd7RKQBTt+mgu87mtYeofLFZpTas2S9zY+jIeuLJMNvudIgF52nr19q40HOwH5RrhWIPuj9puybzSJiRrVg== /for-in/0.1.8: dev: true engines: @@ -5232,12 +5259,13 @@ packages: node: '>= 0.10' resolution: integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== - /intl-messageformat-parser/3.6.4: + /intl-messageformat-parser/5.5.1: dependencies: - '@formatjs/intl-unified-numberformat': 3.3.7 + '@formatjs/intl-numberformat': 5.7.6 + deprecated: We've written a new parser that's 6x faster and is backwards compatible. Please use @formatjs/icu-messageformat-parser dev: true resolution: - integrity: sha512-RgPGwue0mJtoX2Ax8EmMzJzttxjnva7gx0Q7mKJ4oALrTZvtmCeAw5Msz2PcjW4dtCh/h7vN/8GJCxZO1uv+OA== + integrity: sha512-TvB3LqF2VtP6yI6HXlRT5TxX98HKha6hCcrg9dwlPwNaedVNuQA9KgBdtWKgiyakyCTYHQ+KJeFEstNKfZr64w== /invariant/2.2.4: dependencies: loose-envify: 1.4.0 @@ -8550,6 +8578,10 @@ packages: dev: true resolution: integrity: sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== + /tslib/2.2.0: + dev: true + resolution: + integrity: sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== /tsutils/3.17.1_typescript@4.0.2: dependencies: tslib: 1.13.0 @@ -9162,7 +9194,7 @@ specifiers: '@material-ui/lab': ^4.0.0-alpha.56 '@material-ui/pickers': ^3.2.10 '@octokit/rest': ^18.0.4 - '@rafaelgssa/eslint-plugin-local': ^1.1.0 + '@rafaelgomesxyz/eslint-plugin-i18n-json': ^3.1.2 '@svgr/webpack': ^5.4.0 '@types/fs-extra': ^9.0.1 '@types/gulp': ^4.0.6 @@ -9177,10 +9209,10 @@ specifiers: '@types/webpack': ^4.41.21 '@typescript-eslint/eslint-plugin': ^3.10.1 '@typescript-eslint/parser': ^3.10.1 + axios: ^0.21.1 babel-eslint: ^10.1.0 babel-loader: ^8.1.0 babel-preset-minify: ^0.5.1 - chalk: ^2.3.2 clean-webpack-plugin: ^3.0.0 css-loader: ^4.2.2 eslint: ^7.7.0 @@ -9194,22 +9226,11 @@ specifiers: gulp-zip: ^5.0.2 history: ^4.10.1 husky: ^4.2.5 - indent-string: ^3.2.0 - intl-messageformat-parser: ^3.0.7 - jest-diff: ^22.0.3 jsonlint: ^1.6.2 lint-staged: ^10.2.13 - lodash.get: ^4.4.2 - lodash.isequal: ^4.5.0 - lodash.isplainobject: ^4.0.6 - lodash.set: ^4.3.2 - log-symbols: ^2.2.0 - micromatch: ^4.0.2 moment: ^2.27.0 npm-run-all: ^4.1.5 - plur: ^2.1.2 prettier: ^2.1.1 - pretty-format: ^22.0.3 progress-bar-webpack-plugin: ^2.1.0 prop-types: ^15.7.2 react: ^16.13.1 diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index e287547a..a7b12535 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -209,6 +209,9 @@ "options": { "message": "Options" }, + "previousPage": { + "message": "Previous Page" + }, "progress": { "message": "Progress: $PROGRESS$%", "placeholders": { diff --git a/src/_locales/nb/messages.json b/src/_locales/nb/messages.json index 06ebec44..9f625d63 100644 --- a/src/_locales/nb/messages.json +++ b/src/_locales/nb/messages.json @@ -209,6 +209,9 @@ "options": { "message": "Alternativer" }, + "previousPage": { + "message": "Forrige side" + }, "progress": { "message": "Fullført: $PROGRESS$%", "placeholders": { diff --git a/src/_locales/pt_BR/messages.json b/src/_locales/pt_BR/messages.json index 1446fa99..7fe6bb1e 100644 --- a/src/_locales/pt_BR/messages.json +++ b/src/_locales/pt_BR/messages.json @@ -209,6 +209,9 @@ "options": { "message": "Opções" }, + "previousPage": { + "message": "Página Anterior" + }, "progress": { "message": "Progresso: $PROGRESS$%", "placeholders": { diff --git a/src/api/TmdbApi.ts b/src/api/TmdbApi.ts index f64bb713..f21099bd 100644 --- a/src/api/TmdbApi.ts +++ b/src/api/TmdbApi.ts @@ -1,7 +1,7 @@ import { CacheValues } from '../common/Cache'; import { Errors } from '../common/Errors'; import { Messaging } from '../common/Messaging'; -import { Requests } from '../common/Requests'; +import { RequestException, Requests } from '../common/Requests'; import { Item } from '../models/Item'; import { TraktItem } from '../models/TraktItem'; import { secrets } from '../secrets'; @@ -74,7 +74,9 @@ class _TmdbApi { try { await this.activate(); } catch (err) { - Errors.warning('Failed to get TMDB config.', err); + if (!(err as RequestException).canceled) { + Errors.warning('Failed to get TMDB config.', err); + } return null; } } @@ -109,7 +111,9 @@ class _TmdbApi { } } } catch (err) { - Errors.warning('Failed to find item on TMDB.', err); + if (!(err as RequestException).canceled) { + Errors.warning('Failed to find item on TMDB.', err); + } } return null; }; @@ -130,7 +134,11 @@ class _TmdbApi { return `${this.API_URL}/${type}/${path}/images?api_key=${secrets.tmdbApiKey}`; }; - loadImages = async (serviceId: StreamingServiceId): Promise => { + loadImages = async (serviceId: StreamingServiceId, items: Item[]): Promise => { + const missingItems = items.filter((item) => typeof item.imageUrl === 'undefined'); + if (missingItems.length === 0) { + return; + } if ( !(await browser.permissions.contains({ origins: ['*://script.google.com/*', '*://script.googleusercontent.com/*'], @@ -138,14 +146,13 @@ class _TmdbApi { ) { return; } - let items = getSyncStore(serviceId).data.items; try { const cache = (await Messaging.toBackground({ action: 'get-cache', key: 'tmdbImages', })) as CacheValues['tmdbImages']; - const missingItems = []; - for (const item of items) { + const itemsToFetch = []; + for (const item of missingItems) { if (!item.trakt) { continue; } @@ -153,17 +160,17 @@ class _TmdbApi { if (imageUrl) { item.imageUrl = imageUrl; } else { - missingItems.push(item); + itemsToFetch.push(item); } } - if (missingItems.length > 0) { + if (itemsToFetch.length > 0) { let json; try { const response = await Requests.send({ method: 'POST', url: this.DATABASE_URL, body: { - items: missingItems.map((item) => ({ + items: itemsToFetch.map((item) => ({ id: item.trakt?.id, tmdbId: item.trakt?.tmdbId, type: item.trakt?.type, @@ -176,7 +183,7 @@ class _TmdbApi { } catch (err) { // Do nothing } - for (const item of missingItems) { + for (const item of itemsToFetch) { if (!item.trakt) { continue; } @@ -195,11 +202,10 @@ class _TmdbApi { } catch (err) { // Do nothing } - items = items.map((item) => ({ - ...item, - imageUrl: item.imageUrl ?? this.PLACEHOLDER_IMAGE, - })); - await getSyncStore(serviceId).update({ items }, true); + for (const item of missingItems) { + item.imageUrl = item.imageUrl ?? this.PLACEHOLDER_IMAGE; + } + await getSyncStore(serviceId).update(); }; loadItemImage = async (item: Item): Promise => { diff --git a/src/api/TraktSync.ts b/src/api/TraktSync.ts index 1555f83c..1470433f 100644 --- a/src/api/TraktSync.ts +++ b/src/api/TraktSync.ts @@ -1,8 +1,8 @@ import * as moment from 'moment'; -import { Item } from '../models/Item'; import { Errors } from '../common/Errors'; import { EventDispatcher } from '../common/Events'; -import { Requests } from '../common/Requests'; +import { RequestException, Requests } from '../common/Requests'; +import { Item } from '../models/Item'; import { TraktApi } from './TraktApi'; export interface TraktHistoryItem { @@ -98,8 +98,10 @@ class _TraktSync extends TraktApi { added: responseJson.added, }); } catch (err) { - Errors.error('Failed to sync history.', err); - await EventDispatcher.dispatch('HISTORY_SYNC_ERROR', null, { error: err as Error }); + if (!(err as RequestException).canceled) { + Errors.error('Failed to sync history.', err); + await EventDispatcher.dispatch('HISTORY_SYNC_ERROR', null, { error: err as Error }); + } } }; } diff --git a/src/api/WrongItemApi.ts b/src/api/WrongItemApi.ts index 977b2f97..8010a666 100644 --- a/src/api/WrongItemApi.ts +++ b/src/api/WrongItemApi.ts @@ -9,7 +9,11 @@ import { StreamingServiceId } from '../streaming-services/streaming-services'; class _WrongItemApi { URL = 'https://script.google.com/macros/s/AKfycbyz0AYx9-R2cKHxyyRNrMYbqUnqvJbiYxSZTFV0/exec'; - loadSuggestions = async (serviceId: StreamingServiceId): Promise => { + loadSuggestions = async (serviceId: StreamingServiceId, items: Item[]): Promise => { + const missingItems = items.filter((item) => typeof item.correctionSuggestions === 'undefined'); + if (missingItems.length === 0) { + return; + } const { options } = await BrowserStorage.get('options'); if ( !options?.sendReceiveSuggestions || @@ -19,35 +23,34 @@ class _WrongItemApi { ) { return; } - let items = getSyncStore(serviceId).data.items; try { const cache = (await Messaging.toBackground({ action: 'get-cache', key: 'correctionSuggestions', })) as CacheValues['correctionSuggestions']; let serviceSuggestions = cache[serviceId]; - const missingItems = []; - for (const item of items) { + const itemsToFetch = []; + for (const item of missingItems) { const suggestions = serviceSuggestions?.[item.id]; if (suggestions) { item.correctionSuggestions = suggestions; } else { - missingItems.push(item); + itemsToFetch.push(item); } } - if (missingItems.length > 0) { + if (itemsToFetch.length > 0) { const response = await Requests.send({ method: 'GET', url: `${this.URL}?serviceId=${encodeURIComponent( serviceId - )}&ids=${missingItems.map((item) => encodeURIComponent(item.id)).join(',')}`, + )}&ids=${itemsToFetch.map((item) => encodeURIComponent(item.id)).join(',')}`, }); const json = JSON.parse(response) as Record; if (!serviceSuggestions) { serviceSuggestions = {}; cache[serviceId] = serviceSuggestions; } - for (const item of missingItems) { + for (const item of itemsToFetch) { serviceSuggestions[item.id] = json[item.id]?.sort((a, b) => { if (a.count > b.count) { return -1; @@ -68,11 +71,10 @@ class _WrongItemApi { } catch (err) { // Do nothing } - items = items.map((item) => ({ - ...item, - correctionSuggestions: item.correctionSuggestions ?? null, - })); - await getSyncStore(serviceId).update({ items }, true); + for (const item of missingItems) { + item.correctionSuggestions = item.correctionSuggestions ?? null; + } + await getSyncStore(serviceId).update(); }; loadItemSuggestions = async (item: Item): Promise => { diff --git a/src/common/BrowserStorage.tsx b/src/common/BrowserStorage.tsx index 7f974ba6..4c317211 100644 --- a/src/common/BrowserStorage.tsx +++ b/src/common/BrowserStorage.tsx @@ -83,8 +83,9 @@ export interface ListOption extends BaseOp export type SyncOptions = { [K in keyof StorageValuesSyncOptions]: { id: K; - value: StorageValuesSyncOptions[K]; name: string; + value: StorageValuesSyncOptions[K]; + minValue?: number; }; }; @@ -281,12 +282,16 @@ class _BrowserStorage { id: 'itemsPerLoad', name: '', value: 10, + minValue: 1, }, }; const values = await BrowserStorage.get('syncOptions'); for (const option of Object.values(options)) { option.name = I18N.translate(`${option.id}Name` as MessageName); option.value = (values.syncOptions && values.syncOptions[option.id]) || option.value; + if (typeof option.value === 'number' && typeof option.minValue !== 'undefined') { + option.value = Math.max(option.value, option.minValue); + } } return options; }; diff --git a/src/common/Events.ts b/src/common/Events.ts index a2662379..a5c7404a 100644 --- a/src/common/Events.ts +++ b/src/common/Events.ts @@ -4,7 +4,7 @@ import { TraktSearchItem } from '../api/TraktSearch'; import { MissingWatchedDateType } from '../components/MissingWatchedDateDialog'; import { Item } from '../models/Item'; import { TraktItem } from '../models/TraktItem'; -import { StoreData } from '../streaming-services/common/SyncStore'; +import { SyncStoreData } from '../streaming-services/common/SyncStore'; import { StreamingServiceId } from '../streaming-services/streaming-services'; import { StorageValuesOptions, StorageValuesSyncOptions } from './BrowserStorage'; import { Errors } from './Errors'; @@ -35,12 +35,13 @@ export interface EventData { WRONG_ITEM_DIALOG_SHOW: WrongItemDialogShowData; WRONG_ITEM_CORRECTED: WrongItemCorrectedData; HISTORY_OPTIONS_CHANGE: HistoryOptionsChangeData; - STREAMING_SERVICE_STORE_UPDATE: StreamingServiceStoreUpdateData; + SYNC_STORE_UPDATE: SyncStoreUpdateData; STREAMING_SERVICE_HISTORY_LOAD_ERROR: ErrorData; STREAMING_SERVICE_HISTORY_CHANGE: StreamingServiceHistoryChangeData; TRAKT_HISTORY_LOAD_ERROR: ErrorData; HISTORY_SYNC_SUCCESS: HistorySyncSuccessData; HISTORY_SYNC_ERROR: ErrorData; + REQUESTS_CANCEL: RequestsCancelData; } export type Event = keyof EventData; @@ -123,8 +124,8 @@ export interface HistoryOptionsChangeData { value: boolean | number; } -export interface StreamingServiceStoreUpdateData { - data: StoreData; +export interface SyncStoreUpdateData { + data: SyncStoreData; } export interface StreamingServiceHistoryChangeData { @@ -139,6 +140,10 @@ export interface HistorySyncSuccessData { }; } +export interface RequestsCancelData { + key: string; +} + export type EventDispatcherListeners = Record< string, Record[]> diff --git a/src/common/Messaging.ts b/src/common/Messaging.ts index 2f0847b3..795b7a07 100644 --- a/src/common/Messaging.ts +++ b/src/common/Messaging.ts @@ -1,10 +1,12 @@ import { Item } from '../models/Item'; -import { MessageRequest, GetCacheMessage } from '../modules/background/background'; +import { GetCacheMessage, GetTabIdMessage, MessageRequest } from '../modules/background/background'; +import { CacheValues } from './Cache'; import { Errors } from './Errors'; import { EventDispatcher } from './Events'; -import { CacheValues } from './Cache'; -export type ReturnTypes = T extends GetCacheMessage +export type ReturnTypes = T extends GetTabIdMessage + ? { tabId: number | undefined } + : T extends GetCacheMessage ? CacheValues[keyof CacheValues] : Record; diff --git a/src/common/Requests.ts b/src/common/Requests.ts index 48bf549a..266c8a04 100644 --- a/src/common/Requests.ts +++ b/src/common/Requests.ts @@ -1,5 +1,7 @@ +import axios, { AxiosResponse, CancelTokenSource, Method } from 'axios'; import { TraktAuth } from '../api/TraktAuth'; import { BrowserStorage } from './BrowserStorage'; +import { EventDispatcher, RequestsCancelData } from './Events'; import { Messaging } from './Messaging'; import { Shared } from './Shared'; @@ -7,6 +9,7 @@ export type RequestException = { request: RequestDetails; status: number; text: string; + canceled: boolean; }; export type RequestDetails = { @@ -14,16 +17,33 @@ export type RequestDetails = { method: string; headers?: Record; body?: unknown; + cancelKey?: string; }; -export type Fetch = (input: RequestInfo, init?: RequestInit) => Promise; - class _Requests { - send = async (request: RequestDetails, tabId = 0): Promise => { + cancelTokens = new Map(); + + startListeners = () => { + EventDispatcher.subscribe('REQUESTS_CANCEL', null, this.cancelRequests); + }; + + stopListeners = () => { + EventDispatcher.unsubscribe('REQUESTS_CANCEL', null, this.cancelRequests); + }; + + cancelRequests = (data: RequestsCancelData) => { + const cancelToken = this.cancelTokens.get(data.key); + if (cancelToken) { + cancelToken.cancel(); + this.cancelTokens.delete(data.key); + } + }; + + send = async (request: RequestDetails, tabId = Shared.tabId): Promise => { let responseText = ''; if ( Shared.pageType === 'background' || - (Shared.pageType === 'popup' && tabId) || + (Shared.pageType === 'popup' && typeof tabId !== 'undefined') || (Shared.pageType === 'content' && request.url.includes(window.location.host)) ) { responseText = await this.sendDirectly(request, tabId); @@ -37,13 +57,13 @@ class _Requests { return responseText; }; - sendDirectly = async (request: RequestDetails, tabId = 0): Promise => { + sendDirectly = async (request: RequestDetails, tabId = Shared.tabId): Promise => { let responseStatus = 0; let responseText = ''; try { const response = await this.fetch(request, tabId); responseStatus = response.status; - responseText = await response.text(); + responseText = response.data; if (responseStatus < 200 || responseStatus >= 400) { throw responseText; } @@ -52,24 +72,31 @@ class _Requests { request, status: responseStatus, text: responseText, + canceled: err instanceof axios.Cancel, }; } return responseText; }; - fetch = async (request: RequestDetails, tabId = 0): Promise => { - let fetch = window.fetch; - let options = await this.getOptions(request, tabId); - if (window.wrappedJSObject) { - // Firefox wraps page objects, so if we want to send the request from a container, we have to unwrap them. - fetch = XPCNativeWrapper(window.wrappedJSObject.fetch); - window.wrappedJSObject.fetchOptions = cloneInto(options, window); - options = XPCNativeWrapper(window.wrappedJSObject.fetchOptions); + fetch = async (request: RequestDetails, tabId = Shared.tabId): Promise> => { + const options = await this.getOptions(request, tabId); + const cancelKey = request.cancelKey || 'default'; + if (!this.cancelTokens.has(cancelKey)) { + this.cancelTokens.set(cancelKey, axios.CancelToken.source()); } - return fetch(request.url, options); + const cancelToken = this.cancelTokens.get(cancelKey)?.token; + return axios.request({ + url: request.url, + method: options.method as Method, + headers: options.headers, + data: options.body, + responseType: 'text', + cancelToken, + transformResponse: (res: string) => res, + }); }; - getOptions = async (request: RequestDetails, tabId = 0): Promise => { + getOptions = async (request: RequestDetails, tabId = Shared.tabId): Promise => { return { method: request.method, headers: await this.getHeaders(request, tabId), @@ -77,7 +104,7 @@ class _Requests { }; }; - getHeaders = async (request: RequestDetails, tabId = 0): Promise => { + getHeaders = async (request: RequestDetails, tabId = Shared.tabId): Promise => { const headers: HeadersInit = { 'Content-Type': typeof request.body === 'string' ? 'application/x-www-form-urlencoded' : 'application/json', @@ -97,7 +124,13 @@ class _Requests { return headers; }; - getCookies = async (request: RequestDetails, tabId = 0): Promise => { + getCookies = async ( + request: RequestDetails, + tabId = Shared.tabId + ): Promise => { + if (typeof tabId === 'undefined') { + return; + } const storage = await BrowserStorage.get('options'); if (!storage.options?.grantCookies || !browser.cookies || !browser.webRequest) { return; diff --git a/src/common/Session.ts b/src/common/Session.ts index c2bfa0f7..73c37264 100644 --- a/src/common/Session.ts +++ b/src/common/Session.ts @@ -1,6 +1,7 @@ import { Errors } from './Errors'; import { EventDispatcher } from './Events'; import { Messaging } from './Messaging'; +import { RequestException } from './Requests'; class _Session { isLoggedIn: boolean; @@ -34,9 +35,11 @@ class _Session { throw auth; } } catch (err) { - Errors.error('Failed to log in.', err); this.isLoggedIn = false; - await EventDispatcher.dispatch('LOGIN_ERROR', null, { error: err as Error }); + if (!(err as RequestException).canceled) { + Errors.error('Failed to log in.', err); + await EventDispatcher.dispatch('LOGIN_ERROR', null, { error: err as Error }); + } } }; @@ -46,9 +49,11 @@ class _Session { this.isLoggedIn = false; await EventDispatcher.dispatch('LOGOUT_SUCCESS', null, {}); } catch (err) { - Errors.error('Failed to log out.', err); this.isLoggedIn = true; - await EventDispatcher.dispatch('LOGOUT_ERROR', null, { error: err as Error }); + if (!(err as RequestException).canceled) { + Errors.error('Failed to log out.', err); + await EventDispatcher.dispatch('LOGOUT_ERROR', null, { error: err as Error }); + } } }; diff --git a/src/common/Shared.ts b/src/common/Shared.ts index f876858b..6fc2c483 100644 --- a/src/common/Shared.ts +++ b/src/common/Shared.ts @@ -1,6 +1,7 @@ interface SharedValues { browser: BrowserName; pageType: PageType; + tabId?: number; } type BrowserPrefix = 'moz' | 'chrome' | 'unknown'; diff --git a/src/components/MissingWatchedDateDialog.tsx b/src/components/MissingWatchedDateDialog.tsx index 7013fbfa..9a427607 100644 --- a/src/components/MissingWatchedDateDialog.tsx +++ b/src/components/MissingWatchedDateDialog.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import { Errors } from '../common/Errors'; import { EventDispatcher, MissingWatchedDateDialogShowData } from '../common/Events'; import { I18N } from '../common/I18N'; +import { RequestException } from '../common/Requests'; import { Item } from '../models/Item'; import { StreamingServiceId } from '../streaming-services/streaming-services'; import { UtsCenter } from './UtsCenter'; @@ -102,11 +103,13 @@ export const MissingWatchedDateDialog: React.FC = () => { date: dialog.date, }); } catch (err) { - Errors.error('Failed to add missing watched date.', err); - await EventDispatcher.dispatch('SNACKBAR_SHOW', null, { - messageName: 'addMissingWatchedDateFailed', - severity: 'error', - }); + if (!(err as RequestException).canceled) { + Errors.error('Failed to add missing watched date.', err); + await EventDispatcher.dispatch('SNACKBAR_SHOW', null, { + messageName: 'addMissingWatchedDateFailed', + severity: 'error', + }); + } } setDialog((prevDialog) => ({ ...prevDialog, diff --git a/src/components/WrongItemDialog.tsx b/src/components/WrongItemDialog.tsx index 6e57e292..99491ecf 100644 --- a/src/components/WrongItemDialog.tsx +++ b/src/components/WrongItemDialog.tsx @@ -18,6 +18,7 @@ import { Errors } from '../common/Errors'; import { EventDispatcher, WrongItemDialogShowData } from '../common/Events'; import { I18N } from '../common/I18N'; import { Messaging } from '../common/Messaging'; +import { RequestException } from '../common/Requests'; import { Shared } from '../common/Shared'; import { CorrectionSuggestion, Item } from '../models/Item'; import { StreamingServiceId, streamingServices } from '../streaming-services/streaming-services'; @@ -114,11 +115,13 @@ export const WrongItemDialog: React.FC = () => { } } } catch (err) { - Errors.error('Failed to correct item.', err); - await EventDispatcher.dispatch('SNACKBAR_SHOW', null, { - messageName: 'correctWrongItemFailed', - severity: 'error', - }); + if (!(err as RequestException).canceled) { + Errors.error('Failed to correct item.', err); + await EventDispatcher.dispatch('SNACKBAR_SHOW', null, { + messageName: 'correctWrongItemFailed', + severity: 'error', + }); + } } setDialog((prevDialog) => ({ ...prevDialog, diff --git a/src/global.d.ts b/src/global.d.ts index f13c2857..7cc34854 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,7 +1,5 @@ declare interface Window { wrappedJSObject?: { - fetch: import('./common/Requests').Fetch; - fetchOptions: RequestInit; localStorage: Storage; netflix?: import('./streaming-services/netflix/NetflixApi').NetflixGlobalObject; sdk?: import('./streaming-services/hbo-go/HboGoApi').HboGoGlobalObject; diff --git a/src/models/Item.ts b/src/models/Item.ts index ff278c26..c8ffc896 100644 --- a/src/models/Item.ts +++ b/src/models/Item.ts @@ -77,9 +77,11 @@ export class Item implements IItem { this.episodeTitle = options.episodeTitle; this.isCollection = options.isCollection; } + this.watchedAt = options.watchedAt?.clone(); this.percentageWatched = options.percentageWatched ?? 0; - this.watchedAt = options.watchedAt; - this.trakt = options.trakt; + this.trakt = options.trakt && new TraktItem(options.trakt); // Ensures immutability. + this.isSelected = options.isSelected; + this.index = options.index; this.correctionSuggestions = options.correctionSuggestions; this.imageUrl = options.imageUrl; } diff --git a/src/models/TraktItem.ts b/src/models/TraktItem.ts index bf25d005..d9a7af2f 100644 --- a/src/models/TraktItem.ts +++ b/src/models/TraktItem.ts @@ -28,9 +28,9 @@ export class TraktItem implements ITraktItem { season?: number; episode?: number; episodeTitle?: string; + releaseDate: string | null; watchedAt?: Moment; progress: number; - releaseDate: string | null; constructor(options: ITraktItem) { this.id = options.id; @@ -44,7 +44,7 @@ export class TraktItem implements ITraktItem { this.episodeTitle = options.episodeTitle; } this.releaseDate = options.releaseDate; - this.watchedAt = options.watchedAt; + this.watchedAt = options.watchedAt?.clone(); this.progress = options.progress ?? 0; } diff --git a/src/modules/background/background.ts b/src/modules/background/background.ts index 4ed65399..063944f9 100644 --- a/src/modules/background/background.ts +++ b/src/modules/background/background.ts @@ -12,6 +12,7 @@ import { TraktItem } from '../../models/TraktItem'; import { StreamingServiceId, streamingServices } from '../../streaming-services/streaming-services'; export type MessageRequest = + | GetTabIdMessage | CheckLoginMessage | FinishLoginMessage | LoginMessage @@ -28,6 +29,10 @@ export type MessageRequest = | WrongItemCorrectedMessage | SaveCorrectionSuggestionMessage; +export interface GetTabIdMessage { + action: 'get-tab-id'; +} + export interface CheckLoginMessage { action: 'check-login'; } @@ -205,6 +210,10 @@ const onMessage = (request: string, sender: browser.runtime.MessageSender): Prom let executingAction: Promise; const parsedRequest = JSON.parse(request) as MessageRequest; switch (parsedRequest.action) { + case 'get-tab-id': { + executingAction = Promise.resolve({ tabId: sender.tab?.id }); + break; + } case 'check-login': { executingAction = TraktAuth.validateToken(); break; diff --git a/src/modules/history/components/HistoryActions.tsx b/src/modules/history/components/HistoryActions.tsx index 9fc50768..e02c8dc8 100644 --- a/src/modules/history/components/HistoryActions.tsx +++ b/src/modules/history/components/HistoryActions.tsx @@ -4,19 +4,25 @@ import * as React from 'react'; import { I18N } from '../../../common/I18N'; interface HistoryActionsProps { + hasPreviousPage: boolean; + hasNextPage: boolean; + onPreviousPageClick: () => void; onNextPageClick: () => void; onSyncClick: () => void; } export const HistoryActions: React.FC = (props: HistoryActionsProps) => { - const { onNextPageClick, onSyncClick } = props; + const { hasPreviousPage, hasNextPage, onPreviousPageClick, onNextPageClick, onSyncClick } = props; const theme = useTheme(); return ( - +