diff --git a/assets/config/.eslintrc b/assets/config/.eslintrc index 408fea27..3e358608 100644 --- a/assets/config/.eslintrc +++ b/assets/config/.eslintrc @@ -1,309 +1,313 @@ { "parser": "babel-eslint", "parserOptions": { "ecmaVersion": 2017, "ecmaFeatures": { "experimentalObjectRestSpread": true, "jsx": true }, "sourceType": "module", "allowImportExportEverywhere": true }, "env": { "es6": true, "node": true, "cypress/globals": true }, "plugins": [ "import", "node", "promise", "standard", "cypress", "chai-friendly" ], "globals": { "document": false, "navigator": false, "window": false, "$": false, "jQuery": false, "history": false, "localStorage": false, "sessionStorage": false, "Urls": false, "hljs": false, "Waypoint": false, "swh": false, "fetch": false, "__STATIC__": false, "Image": false, "Cookies": false, "nb": false, "MathJax": false }, "rules": { "accessor-pairs": "error", "arrow-spacing": ["error", { "before": true, "after": true }], "block-spacing": ["error", "always"], "brace-style": ["error", "1tbs", { "allowSingleLine": true }], "camelcase": ["error", { "properties": "never" }], "comma-dangle": ["error", { "arrays": "never", "objects": "never", "imports": "never", "exports": "never", "functions": "never" }], "comma-spacing": ["error", { "before": false, "after": true }], "comma-style": ["error", "last"], "constructor-super": "error", "curly": ["error", "multi-line"], "dot-location": ["error", "property"], "eol-last": "error", "eqeqeq": ["error", "always", { "null": "ignore" }], "func-call-spacing": ["error", "never"], "generator-star-spacing": ["error", { "before": true, "after": true }], "handle-callback-err": ["error", "^(err|error)$"], "indent": ["error", 2, { "SwitchCase": 1, "VariableDeclarator": 1, "outerIIFEBody": 1, "MemberExpression": "off", "FunctionDeclaration": { "parameters": "first", "body": 1 }, "FunctionExpression": { "parameters": "first", "body": 1 }, "CallExpression": { "arguments": "first" }, "ArrayExpression": "first", "ObjectExpression": "first", "ImportDeclaration": "first", "flatTernaryExpressions": false, "ignoreComments": false }], "key-spacing": ["error", { "beforeColon": false, "afterColon": true }], "keyword-spacing": ["error", { "before": true, "after": true }], "new-cap": ["error", { "newIsCap": true, "capIsNew": false }], "new-parens": "error", "no-array-constructor": "error", "no-caller": "error", "no-class-assign": "error", "no-compare-neg-zero": "error", "no-cond-assign": "error", "no-const-assign": "error", "no-constant-condition": ["error", { "checkLoops": false }], "no-control-regex": "error", "no-debugger": "error", "no-delete-var": "error", "no-dupe-args": "error", "no-dupe-class-members": "error", "no-dupe-keys": "error", "no-duplicate-case": "error", "no-empty-character-class": "error", "no-empty-pattern": "error", "no-eval": "error", "no-ex-assign": "error", "no-extend-native": "error", "no-extra-bind": "error", "no-extra-boolean-cast": "error", "no-extra-parens": ["error", "functions"], "no-fallthrough": "error", "no-floating-decimal": "error", "no-func-assign": "error", "no-global-assign": "error", "no-implied-eval": "error", "no-inner-declarations": ["error", "functions"], "no-invalid-regexp": "error", "no-irregular-whitespace": "error", "no-iterator": "error", "no-label-var": "error", "no-labels": ["error", { "allowLoop": false, "allowSwitch": false }], "no-lone-blocks": "error", "no-mixed-operators": ["error", { "groups": [ ["==", "!=", "===", "!==", ">", ">=", "<", "<="], ["&&", "||"], ["in", "instanceof"] ], "allowSamePrecedence": true }], "no-mixed-spaces-and-tabs": "error", "no-multi-spaces": "error", "no-multi-str": "error", "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], "no-negated-in-lhs": "error", "no-new": 0, "no-new-func": "error", "no-new-object": "error", "no-new-require": "error", "no-new-symbol": "error", "no-new-wrappers": "error", "no-obj-calls": "error", "no-octal": "error", "no-octal-escape": "error", "no-path-concat": "error", "no-proto": "error", "no-redeclare": "error", "no-regex-spaces": "error", "no-return-assign": ["error", "except-parens"], "no-return-await": "error", "no-self-assign": "error", "no-self-compare": "error", "no-sequences": "error", "no-shadow-restricted-names": "error", "no-sparse-arrays": "error", "no-tabs": "error", "no-template-curly-in-string": "error", "no-this-before-super": "error", "no-throw-literal": "error", "no-trailing-spaces": "error", "no-undef": "error", "no-undef-init": "error", "no-unexpected-multiline": "error", "no-unmodified-loop-condition": "error", "no-unneeded-ternary": ["error", { "defaultAssignment": false }], "no-unreachable": "error", "no-unsafe-finally": "error", "no-unsafe-negation": "error", "no-unused-expressions": 0, "no-unused-vars": ["error", { "vars": "all", "args": "none", "ignoreRestSiblings": true }], "no-use-before-define": ["error", { "functions": false, "classes": false, "variables": false }], "no-useless-call": "error", "no-useless-computed-key": "error", "no-useless-constructor": "error", "no-useless-escape": "error", "no-useless-rename": "error", "no-useless-return": "error", "no-whitespace-before-property": "error", "no-with": "error", "object-property-newline": ["error", { "allowMultiplePropertiesPerLine": true }], "one-var": ["error", { "initialized": "never" }], "operator-linebreak": ["error", "after", { "overrides": { "?": "before", ":": "before" } }], "padded-blocks": ["off", { "blocks": "never", "switches": "never", "classes": "never" }], "prefer-promise-reject-errors": "error", + "prefer-const": ["error", { + "destructuring": "any", + "ignoreReadBeforeAssign": false + }], "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], "rest-spread-spacing": ["error", "never"], "semi": ["error", "always"], "semi-spacing": ["error", { "before": false, "after": true }], "space-before-blocks": ["error", "always"], "space-before-function-paren": ["error", "never"], "space-in-parens": ["error", "never"], "space-infix-ops": "error", "space-unary-ops": ["error", { "words": true, "nonwords": false }], "spaced-comment": ["error", "always", { "line": { "markers": ["*package", "!", "/", ",", "="] }, "block": { "balanced": true, "markers": ["*package", "!", ",", ":", "::", "flow-include"], "exceptions": ["*"] } }], "symbol-description": "error", "template-curly-spacing": ["error", "never"], "template-tag-spacing": ["error", "never"], "unicode-bom": ["error", "never"], "use-isnan": "error", "valid-typeof": ["error", { "requireStringLiterals": true }], "wrap-iife": ["error", "any", { "functionPrototypeMethods": true }], "yield-star-spacing": ["error", "both"], "yoda": ["error", "never"], "import/export": "off", "import/first": "error", "import/no-duplicates": "error", "import/no-webpack-loader-syntax": "off", "node/no-deprecated-api": "error", "node/process-exit-as-throw": "error", "promise/param-names": "error", "standard/array-bracket-even-spacing": ["error", "either"], "standard/computed-property-even-spacing": ["error", "even"], "standard/no-callback-literal": "error", "standard/object-curly-even-spacing": ["error", "either"], "chai-friendly/no-unused-expressions": 2, "object-curly-spacing": ["error", "never"] } } \ No newline at end of file diff --git a/assets/config/webpack-plugins/dump-highlightjs-languages-data-plugin.js b/assets/config/webpack-plugins/dump-highlightjs-languages-data-plugin.js index 13da23cb..df7f60f4 100644 --- a/assets/config/webpack-plugins/dump-highlightjs-languages-data-plugin.js +++ b/assets/config/webpack-plugins/dump-highlightjs-languages-data-plugin.js @@ -1,46 +1,46 @@ /** * Copyright (C) 2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ 'use strict'; const fs = require('fs'); const path = require('path'); const hljs = require('highlight.js'); var stringify = require('json-stable-stringify'); // Simple webpack plugin to dump JSON data related to the set of // programming languages supported by the highlightjs library. // The JSON file is saved into swh-web static folder and will // be consumed by the backend django application. class DumpHighlightjsLanguagesDataPlugin { apply(compiler) { compiler.hooks.done.tap('DumpHighlightjsLanguagesDataPlugin', statsObj => { const outputPath = statsObj.compilation.compiler.outputPath; const hljsDataFile = path.join(outputPath, 'json/highlightjs-languages.json'); const languages = hljs.listLanguages(); const hljsLanguagesData = {'languages': languages}; const languageAliases = {}; - for (let language of languages) { + for (const language of languages) { const languageData = hljs.getLanguage(language); if (!languageData.hasOwnProperty('aliases')) { continue; } - for (let alias of languageData.aliases) { + for (const alias of languageData.aliases) { languageAliases[alias] = language; languageAliases[alias.toLowerCase()] = language; } } hljsLanguagesData['languages_aliases'] = languageAliases; fs.writeFileSync(hljsDataFile, stringify(hljsLanguagesData, {space: 4})); }); } }; module.exports = DumpHighlightjsLanguagesDataPlugin; diff --git a/assets/config/webpack-plugins/fix-swh-source-maps-webpack-plugin.js b/assets/config/webpack-plugins/fix-swh-source-maps-webpack-plugin.js index 62b68c87..2f6d8733 100644 --- a/assets/config/webpack-plugins/fix-swh-source-maps-webpack-plugin.js +++ b/assets/config/webpack-plugins/fix-swh-source-maps-webpack-plugin.js @@ -1,48 +1,48 @@ /** * Copyright (C) 2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ 'use strict'; // This plugin modifies the generated asset sourcemaps on the fly // in order to be successfully loaded by Firefox. // The following error is reported without that: // Source map error: TypeError: Invalid URL: webpack://swh.[name]/... class FixSwhSourceMapsPlugin { constructor() { this.sourceMapRegexp = /\.map($|\?)/i; } apply(compiler) { compiler.hooks.compilation.tap('FixSwhSourceMapsPlugin', compilation => { const {Compilation, sources} = require('webpack'); compilation.hooks.processAssets.tap( { name: 'FixSwhSourceMapsPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE }, () => { Object.keys(compilation.assets).filter(key => { return this.sourceMapRegexp.test(key); }).forEach(key => { let bundleName = key.replace(/^js\//, ''); bundleName = bundleName.replace(/^css\//, ''); - let pos = bundleName.indexOf('.'); + const pos = bundleName.indexOf('.'); bundleName = bundleName.slice(0, pos); - let asset = compilation.assets[key]; - let source = asset.source().replace(/swh.\[name\]/g, 'swh.' + bundleName); + const asset = compilation.assets[key]; + const source = asset.source().replace(/swh.\[name\]/g, 'swh.' + bundleName); compilation.updateAsset(key, new sources.RawSource(source)); }); } ); }); } }; module.exports = FixSwhSourceMapsPlugin; diff --git a/assets/config/webpack-plugins/generate-weblabels-webpack-plugin/index.js b/assets/config/webpack-plugins/generate-weblabels-webpack-plugin/index.js index 4230497f..3e2482a0 100644 --- a/assets/config/webpack-plugins/generate-weblabels-webpack-plugin/index.js +++ b/assets/config/webpack-plugins/generate-weblabels-webpack-plugin/index.js @@ -1,409 +1,409 @@ /** * Copyright (C) 2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // This is a plugin for webpack >= 4 enabling to generate a Web Labels page intended // to be consume by the LibreJS Firefox plugin (https://www.gnu.org/software/librejs/). // See README.md for its complete documentation. const ejs = require('ejs'); const fs = require('fs'); const log = require('webpack-log'); const path = require('path'); const schema = require('./plugin-options-schema.json'); const spdxParse = require('spdx-expression-parse'); const spdxLicensesMapping = require('./spdx-licenses-mapping'); const {validate} = require('schema-utils'); const pluginName = 'GenerateWebLabelsPlugin'; class GenerateWebLabelsPlugin { constructor(opts) { // check that provided options match JSON schema validate(schema, opts, pluginName); this.options = opts || {}; this.weblabelsDirName = this.options['outputDir'] || 'jssources'; this.outputType = this.options['outputType'] || 'html'; // source file extension handled by webpack and compiled to js this.srcExts = ['js', 'ts', 'coffee', 'lua']; this.srcExtsRegexp = new RegExp('^.*.(' + this.srcExts.join('|') + ')$'); this.chunkIdToName = {}; this.chunkNameToJsAsset = {}; this.chunkJsAssetToSrcFiles = {}; this.srcIdsInChunkJsAsset = {}; this.packageJsonCache = {}; this.packageLicenseFile = {}; this.exclude = []; this.copiedFiles = new Set(); this.logger = log({name: pluginName}); // populate module prefix patterns to exclude if (Array.isArray(this.options['exclude'])) { this.options['exclude'].forEach(toExclude => { if (!toExclude.startsWith('.')) { this.exclude.push('./' + path.join('node_modules', toExclude)); } else { this.exclude.push(toExclude); } }); } } apply(compiler) { compiler.hooks.done.tap(pluginName, statsObj => { // get the stats object in JSON format - let stats = statsObj.toJson(); + const stats = statsObj.toJson(); this.stats = stats; // set output folder this.weblabelsOutputDir = path.join(stats.outputPath, this.weblabelsDirName); this.recursiveMkdir(this.weblabelsOutputDir); stats.assets.forEach(asset => { for (let i = 0; i < asset.chunks.length; ++i) { this.chunkIdToName[asset.chunks[i]] = asset.chunkNames[i]; } }); // map each generated webpack chunk to its js asset Object.keys(stats.assetsByChunkName).forEach((chunkName, i) => { if (Array.isArray(stats.assetsByChunkName[chunkName])) { - for (let asset of stats.assetsByChunkName[chunkName]) { + for (const asset of stats.assetsByChunkName[chunkName]) { if (asset.endsWith('.js')) { this.chunkNameToJsAsset[chunkName] = asset; this.chunkNameToJsAsset[i] = asset; break; } } } else if (stats.assetsByChunkName[chunkName].endsWith('.js')) { this.chunkNameToJsAsset[chunkName] = stats.assetsByChunkName[chunkName]; this.chunkNameToJsAsset[i] = stats.assetsByChunkName[chunkName]; } }); // iterate on all bundled webpack modules stats.modules.forEach(mod => { let srcFilePath = mod.name; // do not process non js related modules if (!this.srcExtsRegexp.test(srcFilePath)) { return; } // do not process modules unrelated to a source file if (!srcFilePath.startsWith('./')) { return; } // do not process modules in the exclusion list - for (let toExclude of this.exclude) { + for (const toExclude of this.exclude) { if (srcFilePath.startsWith(toExclude)) { return; } } // remove webpack loader call if any - let loaderEndPos = srcFilePath.indexOf('!'); + const loaderEndPos = srcFilePath.indexOf('!'); if (loaderEndPos !== -1) { srcFilePath = srcFilePath.slice(loaderEndPos + 1); } // iterate on all chunks containing the module mod.chunks.forEach(chunk => { - let chunkName = this.chunkIdToName[chunk]; - let chunkJsAsset = stats.publicPath + this.chunkNameToJsAsset[chunkName]; + const chunkName = this.chunkIdToName[chunk]; + const chunkJsAsset = stats.publicPath + this.chunkNameToJsAsset[chunkName]; // init the chunk to source files mapping if needed if (!this.chunkJsAssetToSrcFiles.hasOwnProperty(chunkJsAsset)) { this.chunkJsAssetToSrcFiles[chunkJsAsset] = []; this.srcIdsInChunkJsAsset[chunkJsAsset] = new Set(); } // check if the source file needs to be replaces if (this.options['srcReplace'] && this.options['srcReplace'].hasOwnProperty(srcFilePath)) { srcFilePath = this.options['srcReplace'][srcFilePath]; } // init source file metadata - let srcFileData = {'id': this.cleanupPath(srcFilePath)}; + const srcFileData = {'id': this.cleanupPath(srcFilePath)}; // find and parse the corresponding package.json file let packageJsonPath; - let nodeModule = srcFilePath.startsWith('./node_modules/'); + const nodeModule = srcFilePath.startsWith('./node_modules/'); if (nodeModule) { packageJsonPath = this.findPackageJsonPath(srcFilePath); } else { packageJsonPath = './package.json'; } - let packageJson = this.parsePackageJson(packageJsonPath); + const packageJson = this.parsePackageJson(packageJsonPath); // extract license information, overriding it if needed let licenseOverridden = false; let licenseFilePath; if (this.options['licenseOverride']) { - for (let srcFilePrefixKey of Object.keys(this.options['licenseOverride'])) { + for (const srcFilePrefixKey of Object.keys(this.options['licenseOverride'])) { let srcFilePrefix = srcFilePrefixKey; if (!srcFilePrefixKey.startsWith('.')) { srcFilePrefix = './' + path.join('node_modules', srcFilePrefixKey); } if (srcFilePath.startsWith(srcFilePrefix)) { - let spdxLicenseExpression = this.options['licenseOverride'][srcFilePrefixKey]['spdxLicenseExpression']; + const spdxLicenseExpression = this.options['licenseOverride'][srcFilePrefixKey]['spdxLicenseExpression']; licenseFilePath = this.options['licenseOverride'][srcFilePrefixKey]['licenseFilePath']; - let parsedSpdxLicenses = this.parseSpdxLicenseExpression(spdxLicenseExpression, `file ${srcFilePath}`); + const parsedSpdxLicenses = this.parseSpdxLicenseExpression(spdxLicenseExpression, `file ${srcFilePath}`); srcFileData['licenses'] = this.spdxToWebLabelsLicenses(parsedSpdxLicenses); licenseOverridden = true; break; } } } if (!licenseOverridden) { srcFileData['licenses'] = this.extractLicenseInformation(packageJson); - let licenseDir = path.join(...packageJsonPath.split('/').slice(0, -1)); + const licenseDir = path.join(...packageJsonPath.split('/').slice(0, -1)); licenseFilePath = this.findLicenseFile(licenseDir); } // copy original license file and get its url - let licenseCopyUrl = this.copyLicenseFile(licenseFilePath); + const licenseCopyUrl = this.copyLicenseFile(licenseFilePath); srcFileData['licenses'].forEach(license => { license['copy_url'] = licenseCopyUrl; }); // generate url for downloading non-minified source code srcFileData['src_url'] = stats.publicPath + path.join(this.weblabelsDirName, srcFileData['id']); // add source file metadata to the webpack chunk this.addSrcFileDataToJsChunkAsset(chunkJsAsset, srcFileData); // copy non-minified source to output folder this.copyFileToOutputPath(srcFilePath); }); }); // process additional scripts if needed if (this.options['additionalScripts']) { for (let script of Object.keys(this.options['additionalScripts'])) { - let scriptFilesData = this.options['additionalScripts'][script]; + const scriptFilesData = this.options['additionalScripts'][script]; if (script.indexOf('://') === -1 && !script.startsWith('/')) { script = stats.publicPath + script; } this.chunkJsAssetToSrcFiles[script] = []; this.srcIdsInChunkJsAsset[script] = new Set(); - for (let scriptSrc of scriptFilesData) { - let scriptSrcData = {'id': scriptSrc['id']}; - let licenceFilePath = scriptSrc['licenseFilePath']; - let parsedSpdxLicenses = this.parseSpdxLicenseExpression(scriptSrc['spdxLicenseExpression'], - `file ${scriptSrc['path']}`); + for (const scriptSrc of scriptFilesData) { + const scriptSrcData = {'id': scriptSrc['id']}; + const licenceFilePath = scriptSrc['licenseFilePath']; + const parsedSpdxLicenses = this.parseSpdxLicenseExpression(scriptSrc['spdxLicenseExpression'], + `file ${scriptSrc['path']}`); scriptSrcData['licenses'] = this.spdxToWebLabelsLicenses(parsedSpdxLicenses); if (licenceFilePath.indexOf('://') === -1 && !licenceFilePath.startsWith('/')) { - let licenseCopyUrl = this.copyLicenseFile(licenceFilePath); + const licenseCopyUrl = this.copyLicenseFile(licenceFilePath); scriptSrcData['licenses'].forEach(license => { license['copy_url'] = licenseCopyUrl; }); } else { scriptSrcData['licenses'].forEach(license => { license['copy_url'] = licenceFilePath; }); } if (scriptSrc['path'].indexOf('://') === -1 && !scriptSrc['path'].startsWith('/')) { scriptSrcData['src_url'] = stats.publicPath + path.join(this.weblabelsDirName, scriptSrc['id']); } else { scriptSrcData['src_url'] = scriptSrc['path']; } this.addSrcFileDataToJsChunkAsset(script, scriptSrcData); this.copyFileToOutputPath(scriptSrc['path']); } } } - for (let srcFiles of Object.values(this.chunkJsAssetToSrcFiles)) { + for (const srcFiles of Object.values(this.chunkJsAssetToSrcFiles)) { srcFiles.sort((a, b) => a.id.localeCompare(b.id)); } if (this.outputType === 'json') { // generate the jslicenses.json file - let weblabelsData = JSON.stringify(this.chunkJsAssetToSrcFiles); - let weblabelsJsonFile = path.join(this.weblabelsOutputDir, 'jslicenses.json'); + const weblabelsData = JSON.stringify(this.chunkJsAssetToSrcFiles); + const weblabelsJsonFile = path.join(this.weblabelsOutputDir, 'jslicenses.json'); fs.writeFileSync(weblabelsJsonFile, weblabelsData); } else { // generate the jslicenses.html file - let weblabelsPageFile = path.join(this.weblabelsOutputDir, 'jslicenses.html'); + const weblabelsPageFile = path.join(this.weblabelsOutputDir, 'jslicenses.html'); ejs.renderFile(path.join(__dirname, 'jslicenses.ejs'), {'jslicenses_data': this.chunkJsAssetToSrcFiles}, {'rmWhitespace': true}, (e, str) => { fs.writeFileSync(weblabelsPageFile, str); }); } }); } addSrcFileDataToJsChunkAsset(chunkJsAsset, srcFileData) { if (!this.srcIdsInChunkJsAsset[chunkJsAsset].has(srcFileData['id'])) { this.chunkJsAssetToSrcFiles[chunkJsAsset].push(srcFileData); this.srcIdsInChunkJsAsset[chunkJsAsset].add(srcFileData['id']); } } cleanupPath(moduleFilePath) { return moduleFilePath.replace(/^[./]*node_modules\//, '').replace(/^.\//, ''); } findPackageJsonPath(srcFilePath) { - let pathSplit = srcFilePath.split('/'); + const pathSplit = srcFilePath.split('/'); let packageJsonPath; for (let i = 3; i < pathSplit.length; ++i) { packageJsonPath = path.join(...pathSplit.slice(0, i), 'package.json'); if (fs.existsSync(packageJsonPath)) { break; } } return packageJsonPath; } findLicenseFile(packageJsonDir) { if (!this.packageLicenseFile.hasOwnProperty(packageJsonDir)) { let foundLicenseFile; fs.readdirSync(packageJsonDir).forEach(file => { if (foundLicenseFile) { return; } if (file.toLowerCase().startsWith('license')) { foundLicenseFile = path.join(packageJsonDir, file); } }); this.packageLicenseFile[packageJsonDir] = foundLicenseFile; } return this.packageLicenseFile[packageJsonDir]; } copyLicenseFile(licenseFilePath) { let licenseCopyPath = ''; if (licenseFilePath && fs.existsSync(licenseFilePath)) { let ext = ''; // add a .txt extension in order to serve license file with text/plain // content type to client browsers if (licenseFilePath.toLowerCase().indexOf('license.') === -1) { ext = '.txt'; } this.copyFileToOutputPath(licenseFilePath, ext); licenseFilePath = this.cleanupPath(licenseFilePath); licenseCopyPath = this.stats.publicPath + path.join(this.weblabelsDirName, licenseFilePath + ext); } return licenseCopyPath; } parsePackageJson(packageJsonPath) { if (!this.packageJsonCache.hasOwnProperty(packageJsonPath)) { - let packageJsonStr = fs.readFileSync(packageJsonPath).toString('utf8'); + const packageJsonStr = fs.readFileSync(packageJsonPath).toString('utf8'); this.packageJsonCache[packageJsonPath] = JSON.parse(packageJsonStr); } return this.packageJsonCache[packageJsonPath]; } parseSpdxLicenseExpression(spdxLicenseExpression, context) { let parsedLicense; try { parsedLicense = spdxParse(spdxLicenseExpression); if (spdxLicenseExpression.indexOf('AND') !== -1) { this.logger.warn(`The SPDX license expression '${spdxLicenseExpression}' associated to ${context} ` + 'contains an AND operator, this is currently not properly handled and erroneous ' + 'licenses information may be provided to LibreJS'); } } catch (e) { this.logger.warn(`Unable to parse the SPDX license expression '${spdxLicenseExpression}' associated to ${context}.`); this.logger.warn('Some generated JavaScript assets may be blocked by LibreJS due to missing license information.'); parsedLicense = {'license': spdxLicenseExpression}; } return parsedLicense; } spdxToWebLabelsLicense(spdxLicenceId) { for (let i = 0; i < spdxLicensesMapping.length; ++i) { if (spdxLicensesMapping[i]['spdx_ids'].indexOf(spdxLicenceId) !== -1) { - let licenseData = Object.assign({}, spdxLicensesMapping[i]); + const licenseData = Object.assign({}, spdxLicensesMapping[i]); delete licenseData['spdx_ids']; delete licenseData['magnet_link']; licenseData['copy_url'] = ''; return licenseData; } } this.logger.warn(`Unable to associate the SPDX license identifier '${spdxLicenceId}' to a LibreJS supported license.`); this.logger.warn('Some generated JavaScript assets may be blocked by LibreJS due to missing license information.'); return { 'name': spdxLicenceId, 'url': '', 'copy_url': '' }; } spdxToWebLabelsLicenses(spdxLicenses) { // This method simply extracts all referenced licenses in the SPDX expression // regardless of their combinations. // TODO: Handle licenses combination properly once LibreJS has a spec for it. let ret = []; if (spdxLicenses.hasOwnProperty('license')) { ret.push(this.spdxToWebLabelsLicense(spdxLicenses['license'])); } else if (spdxLicenses.hasOwnProperty('left')) { if (spdxLicenses['left'].hasOwnProperty('license')) { - let licenseData = this.spdxToWebLabelsLicense(spdxLicenses['left']['license']); + const licenseData = this.spdxToWebLabelsLicense(spdxLicenses['left']['license']); ret.push(licenseData); } else { ret = ret.concat(this.spdxToWebLabelsLicenses(spdxLicenses['left'])); } ret = ret.concat(this.spdxToWebLabelsLicenses(spdxLicenses['right'])); } return ret; } extractLicenseInformation(packageJson) { let spdxLicenseExpression; if (packageJson.hasOwnProperty('license')) { spdxLicenseExpression = packageJson['license']; } else if (packageJson.hasOwnProperty('licenses')) { // for node packages using deprecated licenses property - let licenses = packageJson['licenses']; + const licenses = packageJson['licenses']; if (Array.isArray(licenses)) { - let l = []; + const l = []; licenses.forEach(license => { l.push(license['type']); }); spdxLicenseExpression = l.join(' OR '); } else { spdxLicenseExpression = licenses['type']; } } - let parsedSpdxLicenses = this.parseSpdxLicenseExpression(spdxLicenseExpression, - `module ${packageJson['name']}`); + const parsedSpdxLicenses = this.parseSpdxLicenseExpression(spdxLicenseExpression, + `module ${packageJson['name']}`); return this.spdxToWebLabelsLicenses(parsedSpdxLicenses); } copyFileToOutputPath(srcFilePath, ext = '') { if (this.copiedFiles.has(srcFilePath) || srcFilePath.indexOf('://') !== -1 || !fs.existsSync(srcFilePath)) { return; } let destPath = this.cleanupPath(srcFilePath); - let destDir = path.join(this.weblabelsOutputDir, ...destPath.split('/').slice(0, -1)); + const destDir = path.join(this.weblabelsOutputDir, ...destPath.split('/').slice(0, -1)); this.recursiveMkdir(destDir); destPath = path.join(this.weblabelsOutputDir, destPath + ext); fs.copyFileSync(srcFilePath, destPath); this.copiedFiles.add(srcFilePath); } recursiveMkdir(destPath) { - let destPathSplit = destPath.split('/'); + const destPathSplit = destPath.split('/'); for (let i = 1; i < destPathSplit.length; ++i) { - let currentPath = path.join('/', ...destPathSplit.slice(0, i + 1)); + const currentPath = path.join('/', ...destPathSplit.slice(0, i + 1)); if (!fs.existsSync(currentPath)) { fs.mkdirSync(currentPath); } } } }; module.exports = GenerateWebLabelsPlugin; diff --git a/assets/config/webpack.config.development.js b/assets/config/webpack.config.development.js index 7eb0b585..8660afd5 100644 --- a/assets/config/webpack.config.development.js +++ b/assets/config/webpack.config.development.js @@ -1,482 +1,482 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // webpack configuration for compiling static assets in development mode // import required node modules and webpack plugins const chalk = require('chalk'); const fs = require('fs'); const path = require('path'); const webpack = require('webpack'); const BundleTracker = require('webpack-bundle-tracker'); const RobotstxtPlugin = require('robotstxt-webpack-plugin'); const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin; const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const FixSwhSourceMapsPlugin = require('./webpack-plugins/fix-swh-source-maps-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const GenerateWebLabelsPlugin = require('./webpack-plugins/generate-weblabels-webpack-plugin'); const ProgressBarPlugin = require('progress-bar-webpack-plugin'); const DumpHighlightjsLanguagesDataPlugin = require('./webpack-plugins/dump-highlightjs-languages-data-plugin'); // are we running webpack-dev-server ? const isDevServer = process.argv.find(v => v.includes('serve')) !== undefined; // webpack-dev-server configuration const devServerPort = 3000; const devServerPublicPath = 'http://localhost:' + devServerPort + '/static/'; // set publicPath according if we are using webpack-dev-server to serve // our assets or not const publicPath = isDevServer ? devServerPublicPath : '/static/'; const nodeModules = path.resolve(__dirname, '../../node_modules/'); // collect all bundles we want to produce with webpack var bundles = {}; const bundlesDir = path.join(__dirname, '../src/bundles'); fs.readdirSync(bundlesDir).forEach(file => { bundles[file] = ['bundles/' + file + '/index.js']; // workaround for https://github.com/webpack/webpack-dev-server/issues/2692 if (isDevServer) { bundles[file].unshift(`webpack-dev-server/client/index.js?http://localhost:${devServerPort}`); } }); // common loaders for css related assets (css, sass) -let cssLoaders = [ +const cssLoaders = [ MiniCssExtractPlugin.loader, { loader: 'cache-loader' }, { loader: 'css-loader', options: { sourceMap: !isDevServer } }, { loader: 'postcss-loader', options: { sourceMap: !isDevServer, postcssOptions: { plugins: [ // lint swh-web stylesheets ['stylelint', { 'config': { 'extends': 'stylelint-config-standard', 'rules': { 'indentation': 4, 'font-family-no-missing-generic-family-keyword': null, 'no-descending-specificity': null }, 'ignoreFiles': ['node_modules/**/*.css', 'assets/src/thirdparty/**/*.css'] } }], // automatically add vendor prefixes to css rules 'autoprefixer', 'postcss-normalize', ['postcss-reporter', { clearReportedMessages: true }] ] } } } ]; // webpack development configuration module.exports = { // use caching to speedup incremental builds cache: { type: 'memory' }, // set mode to development mode: 'development', // workaround for https://github.com/webpack/webpack-dev-server/issues/2758 target: process.env.NODE_ENV === 'development' ? 'web' : 'browserslist', // use eval source maps when using webpack-dev-server for quick debugging, // otherwise generate source map files (more expensive) devtool: isDevServer ? 'eval' : 'source-map', // webpack-dev-server configuration devServer: { clientLogLevel: 'warning', host: '0.0.0.0', port: devServerPort, publicPath: devServerPublicPath, // enable to serve static assets not managed by webpack contentBase: path.resolve('./'), // we do not use hot reloading here (as a framework like React needs to be used in order to fully benefit from that feature) // and prefer to fully reload the frontend application in the browser instead hot: false, inline: true, historyApiFallback: true, headers: { 'Access-Control-Allow-Origin': '*' }, compress: true, stats: 'errors-only', overlay: { warnings: true, errors: true }, // workaround for https://github.com/webpack/webpack-dev-server/issues/2692 injectClient: false, transportMode: 'ws' }, // set entries to the bundles we want to produce entry: bundles, // assets output configuration output: { path: path.resolve('./static/'), filename: 'js/[name].[contenthash].js', chunkFilename: 'js/[name].[contenthash].js', publicPath: publicPath, // each bundle will be compiled as a umd module with its own namespace // in order to easily use them in django templates library: ['swh', '[name]'], libraryTarget: 'umd' }, // module resolving configuration resolve: { // alias pdfjs to its minified version alias: { 'pdfjs-dist': 'pdfjs-dist/build/pdf.min.js' }, // configure base paths for resolving modules with webpack modules: [ 'node_modules', path.resolve(__dirname, '../src') ] }, stats: 'errors-warnings', // module import configuration module: { rules: [ { // Preprocess all js files with eslint for consistent code style // and avoid bad js development practices. enforce: 'pre', test: /\.js$/, exclude: /node_modules/, use: [{ loader: 'eslint-loader', options: { configFile: path.join(__dirname, '.eslintrc'), ignorePath: path.join(__dirname, '.eslintignore'), cache: true, emitWarning: true } }] }, { // Use babel-loader in order to use es6 syntax in js files // but also advanced js features like async/await syntax. // All code get transpiled to es5 in order to be executed // in a large majority of browsers. test: /\.js$/, exclude: /node_modules/, use: [ { loader: 'cache-loader' }, { loader: 'babel-loader', options: { presets: [ // use env babel presets to benefit from es6 syntax ['@babel/preset-env', { // Do not transform es6 module syntax to another module type // in order to benefit from dead code elimination (aka tree shaking) // when running webpack in production mode 'loose': true, 'modules': false }] ], plugins: [ // use babel transform-runtime plugin in order to use aync/await syntax ['@babel/plugin-transform-runtime', { 'regenerator': true }], // use other babel plugins to benefit from advanced js features (es2017) '@babel/plugin-syntax-dynamic-import' ], env: { test: { plugins: ['istanbul'] } } } }] }, { test: /\.ejs$/, use: [{ loader: 'ejs-compiled-loader', options: { htmlmin: true, htmlminOptions: { removeComments: true } } }] }, // expose jquery to the global context as $ and jQuery when importing it { test: require.resolve('jquery'), use: [{ loader: 'expose-loader', options: { exposes: [ { globalName: '$', override: true }, { globalName: 'jQuery', override: true } ] } }] }, // expose highlightjs to the global context as hljs when importing it { test: require.resolve('highlight.js'), use: [{ loader: 'expose-loader', options: { exposes: { globalName: 'hljs', override: true } } }] }, { test: require.resolve('js-cookie'), use: [{ loader: 'expose-loader', options: { exposes: { globalName: 'Cookies', override: true } } }] }, // css import configuration: // - first process it with postcss // - then extract it to a dedicated file associated to each bundle { test: /\.css$/, use: cssLoaders }, // sass import configuration: // - generate css with sass-loader // - process it with postcss // - then extract it to a dedicated file associated to each bundle { test: /\.scss$/, use: cssLoaders.concat([ { loader: 'sass-loader', options: { sourceMap: !isDevServer } } ]) }, // web fonts import configuration { test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, use: [{ loader: 'file-loader', options: { name: '[name].[ext]', outputPath: 'fonts/' } }] }, { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, use: [{ loader: 'file-loader', options: { name: '[name].[ext]', outputPath: 'fonts/' } }] }, { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, use: [{ loader: 'file-loader', options: { name: '[name].[ext]', outputPath: 'fonts/' } }] }, { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, use: [{ loader: 'file-loader', options: { name: '[name].[ext]', outputPath: 'fonts/' } }] }, { test: /\.otf(\?v=\d+\.\d+\.\d+)?$/, use: [{ loader: 'file-loader', options: { name: '[name].[ext]', outputPath: 'fonts/' } }] }, { test: /\.png$/, use: [{ loader: 'file-loader', options: { name: '[name].[ext]', outputPath: 'img/thirdParty/' } }] } ], // tell webpack to not parse already minified files to speedup build process noParse: [path.resolve(nodeModules, 'pdfjs-dist/build/pdf.min.js'), path.resolve(nodeModules, 'mathjax/es5/tex-mml-chtml.js')] }, // webpack plugins plugins: [ // cleanup previously generated assets new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: ['**/*', '!xml', '!xml/*', '!img', '!img/*', '!img/logos', '!img/logos/*', '!img/icons', '!img/icons/*', '!json', '!json/*'] }), // needed in order to use django_webpack_loader new BundleTracker({ filename: './static/webpack-stats.json' }), // for generating the robots.txt file new RobotstxtPlugin({ policy: [{ userAgent: '*', disallow: '/api/' }] }), // for extracting all stylesheets in separate css files new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash].css', chunkFilename: 'css/[name].[contenthash].css' }), // fix generated asset sourcemaps to workaround a Firefox issue new FixSwhSourceMapsPlugin(), // define some global variables accessible from js code new webpack.DefinePlugin({ __STATIC__: JSON.stringify(publicPath) }), // needed in order to use bootstrap 4.x new webpack.ProvidePlugin({ Popper: ['popper.js', 'default'], Alert: 'exports-loader?Alert!bootstrap/js/dist/alert', Button: 'exports-loader?Button!bootstrap/js/dist/button', Carousel: 'exports-loader?Carousel!bootstrap/js/dist/carousel', Collapse: 'exports-loader?Collapse!bootstrap/js/dist/collapse', Dropdown: 'exports-loader?Dropdown!bootstrap/js/dist/dropdown', Modal: 'exports-loader?Modal!bootstrap/js/dist/modal', Popover: 'exports-loader?Popover!bootstrap/js/dist/popover', Scrollspy: 'exports-loader?Scrollspy!bootstrap/js/dist/scrollspy', Tab: 'exports-loader?Tab!bootstrap/js/dist/tab', Tooltip: 'exports-loader?Tooltip!bootstrap/js/dist/tooltip', Util: 'exports-loader?Util!bootstrap/js/dist/util' }), // needed in order to use pdf.js new webpack.IgnorePlugin(/^\.\/pdf.worker.js$/), new CopyWebpackPlugin({ patterns: [ { from: path.resolve(nodeModules, 'pdfjs-dist/build/pdf.worker.min.js'), to: path.resolve(__dirname, '../../static/js/') }, { from: path.resolve(nodeModules, 'mathjax/es5/output/chtml/fonts/woff-v2/**'), to: path.resolve(__dirname, '../../static/fonts/[name].[ext]') } ] }), new GenerateWebLabelsPlugin({ outputType: 'json', exclude: ['mini-css-extract-plugin', 'bootstrap-loader'], srcReplace: { './node_modules/pdfjs-dist/build/pdf.min.js': './node_modules/pdfjs-dist/build/pdf.js', './node_modules/admin-lte/dist/js/adminlte.min.js': './node_modules/admin-lte/dist/js/adminlte.js' }, licenseOverride: { './assets/src/thirdparty/jquery.tabSlideOut/jquery.tabSlideOut.js': { 'spdxLicenseExpression': 'GPL-3.0', 'licenseFilePath': './assets/src/thirdparty/jquery.tabSlideOut/LICENSE' } }, additionalScripts: Object.assign( { 'js/pdf.worker.min.js': [ { 'id': 'pdfjs-dist/build/pdf.worker.js', 'path': './node_modules/pdfjs-dist/build/pdf.worker.js', 'spdxLicenseExpression': 'Apache-2.0', 'licenseFilePath': './node_modules/pdfjs-dist/LICENSE' } ], '/jsreverse/': [ { 'id': 'jsreverse', 'path': '/jsreverse/', 'spdxLicenseExpression': 'AGPL-3.0-or-later', 'licenseFilePath': './LICENSE' } ], 'https://piwik.inria.fr/matomo.js': [ { 'id': 'matomo.js', 'path': 'https://github.com/matomo-org/matomo/blob/master/js/piwik.js', 'spdxLicenseExpression': 'BSD-3-Clause', 'licenseFilePath': 'https://github.com/matomo-org/matomo/blob/master/js/LICENSE.txt' } ] } ) }), new ProgressBarPlugin({ format: chalk.cyan.bold('webpack build of swh-web assets') + ' [:bar] ' + chalk.green.bold(':percent') + ' (:elapsed seconds)', width: 50 }), new DumpHighlightjsLanguagesDataPlugin() ], // webpack optimizations optimization: { // ensure the vendors bundle gets emitted in a single chunk splitChunks: { cacheGroups: { defaultVendors: { test: 'vendors', chunks: 'all', name: 'vendors', enforce: true } } } }, // disable webpack warnings about bundle sizes performance: { hints: false } }; diff --git a/assets/src/bundles/admin/deposit.js b/assets/src/bundles/admin/deposit.js index b9f162fc..acb1208a 100644 --- a/assets/src/bundles/admin/deposit.js +++ b/assets/src/bundles/admin/deposit.js @@ -1,163 +1,163 @@ /** * Copyright (C) 2018-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ function genSwhLink(data, type) { if (type === 'display') { if (data && data.startsWith('swh')) { const browseUrl = Urls.browse_swhid(data); const formattedSWHID = data.replace(/;/g, ';<br/>'); return `<a href="${browseUrl}">${formattedSWHID}</a>`; } } return data; } export function initDepositAdmin() { let depositsTable; $(document).ready(() => { $.fn.dataTable.ext.errMode = 'none'; depositsTable = $('#swh-admin-deposit-list') .on('error.dt', (e, settings, techNote, message) => { $('#swh-admin-deposit-list-error').text(message); }) .DataTable({ serverSide: true, processing: true, // let's define the order of table options display // f: (f)ilter // l: (l)ength changing // r: p(r)ocessing // t: (t)able // i: (i)nfo // p: (p)agination // see https://datatables.net/examples/basic_init/dom.html dom: '<<"d-flex justify-content-between align-items-center"f' + '<"#list-exclude">l>rt<"bottom"ip>>', // div#list-exclude is a custom filter added next to dataTable // initialization below through js dom manipulation, see // https://datatables.net/examples/advanced_init/dom_toolbar.html ajax: { url: Urls.admin_deposit_list(), data: d => { d.excludePattern = $('#swh-admin-deposit-list-exclude-filter').val(); } }, columns: [ { data: 'id', name: 'id' }, { data: 'swhid_context', name: 'swhid_context', render: (data, type, row) => { if (data && type === 'display') { - let originPattern = ';origin='; - let originPatternIdx = data.indexOf(originPattern); + const originPattern = ';origin='; + const originPatternIdx = data.indexOf(originPattern); if (originPatternIdx !== -1) { let originUrl = data.slice(originPatternIdx + originPattern.length); - let nextSepPattern = ';'; - let nextSepPatternIdx = originUrl.indexOf(nextSepPattern); + const nextSepPattern = ';'; + const nextSepPatternIdx = originUrl.indexOf(nextSepPattern); if (nextSepPatternIdx !== -1) { /* Remove extra context */ originUrl = originUrl.slice(0, nextSepPatternIdx); } return `<a href="${originUrl}">${originUrl}</a>`; } } return data; } }, { data: 'reception_date', name: 'reception_date', render: (data, type, row) => { if (type === 'display') { - let date = new Date(data); + const date = new Date(data); return date.toLocaleString(); } return data; } }, { data: 'status', name: 'status' }, { data: 'status_detail', name: 'status_detail', render: (data, type, row) => { if (type === 'display' && data) { let text = data; if (typeof data === 'object') { text = JSON.stringify(data, null, 4); } return `<div style="width: 200px; white-space: pre; overflow-x: auto;">${text}</div>`; } return data; }, orderable: false, visible: false }, { data: 'swhid', name: 'swhid', render: (data, type, row) => { return genSwhLink(data, type); }, orderable: false, visible: false }, { data: 'swhid_context', name: 'swhid_context', render: (data, type, row) => { return genSwhLink(data, type); }, orderable: false, visible: false } ], scrollX: true, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']] }); // Some more customization is needed on the table $('div#list-exclude').html(`<div id="swh-admin-deposit-list-exclude-wrapper"> <div id="swh-admin-deposit-list-exclude-div-wrapper" class="dataTables_filter"> <label> Exclude:<input id="swh-admin-deposit-list-exclude-filter" type="search" value="check-deposit" class="form-control form-control-sm" placeholder="exclude-pattern" aria-controls="swh-admin-deposit-list"> </input> </label> </div> </div> `); // Adding exclusion pattern update behavior, when typing, update search $('#swh-admin-deposit-list-exclude-filter').keyup(function() { depositsTable.draw(); }); // at last draw the table depositsTable.draw(); }); $('a.toggle-col').on('click', function(e) { e.preventDefault(); var column = depositsTable.column($(this).attr('data-column')); column.visible(!column.visible()); if (column.visible()) { $(this).removeClass('col-hidden'); } else { $(this).addClass('col-hidden'); } }); } diff --git a/assets/src/bundles/admin/origin-save.js b/assets/src/bundles/admin/origin-save.js index 87c1dff5..aabc4aa2 100644 --- a/assets/src/bundles/admin/origin-save.js +++ b/assets/src/bundles/admin/origin-save.js @@ -1,353 +1,353 @@ /** * Copyright (C) 2018-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, csrfPost, htmlAlert} from 'utils/functions'; import {swhSpinnerSrc} from 'utils/constants'; let authorizedOriginTable; let unauthorizedOriginTable; let pendingSaveRequestsTable; let acceptedSaveRequestsTable; let rejectedSaveRequestsTable; function enableRowSelection(tableSel) { $(`${tableSel} tbody`).on('click', 'tr', function() { if ($(this).hasClass('selected')) { $(this).removeClass('selected'); $(tableSel).closest('.tab-pane').find('.swh-action-need-selection').prop('disabled', true); } else { $(`${tableSel} tr.selected`).removeClass('selected'); $(this).addClass('selected'); $(tableSel).closest('.tab-pane').find('.swh-action-need-selection').prop('disabled', false); } }); } export function initOriginSaveAdmin() { $(document).ready(() => { $.fn.dataTable.ext.errMode = 'throw'; authorizedOriginTable = $('#swh-authorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_authorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-authorized-origin-urls'); swh.webapp.addJumpToPagePopoverToDataTable(authorizedOriginTable); unauthorizedOriginTable = $('#swh-unauthorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_unauthorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-unauthorized-origin-urls'); swh.webapp.addJumpToPagePopoverToDataTable(unauthorizedOriginTable); - let columnsData = [ + const columnsData = [ { data: 'id', name: 'id', visible: false, searchable: false }, { data: 'save_request_date', name: 'request_date', render: (data, type, row) => { if (type === 'display') { - let date = new Date(data); + const date = new Date(data); return date.toLocaleString(); } return data; } }, { data: 'visit_type', name: 'visit_type' }, { data: 'origin_url', name: 'origin_url', render: (data, type, row) => { if (type === 'display') { let html = ''; const sanitizedURL = $.fn.dataTable.render.text().display(data); if (row.save_task_status === 'succeeded') { let browseOriginUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(sanitizedURL)}`; if (row.visit_date) { browseOriginUrl += `&timestamp=${encodeURIComponent(row.visit_date)}`; } html += `<a href="${browseOriginUrl}">${sanitizedURL}</a>`; } else { html += sanitizedURL; } html += ` <a href="${sanitizedURL}"><i class="mdi mdi-open-in-new" aria-hidden="true"></i></a>`; return html; } return data; } } ]; pendingSaveRequestsTable = $('#swh-origin-save-pending-requests').DataTable({ serverSide: true, processing: true, language: { processing: `<img src="${swhSpinnerSrc}"></img>` }, ajax: Urls.origin_save_requests_list('pending'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-pending-requests'); swh.webapp.addJumpToPagePopoverToDataTable(pendingSaveRequestsTable); rejectedSaveRequestsTable = $('#swh-origin-save-rejected-requests').DataTable({ serverSide: true, processing: true, language: { processing: `<img src="${swhSpinnerSrc}"></img>` }, ajax: Urls.origin_save_requests_list('rejected'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-rejected-requests'); swh.webapp.addJumpToPagePopoverToDataTable(rejectedSaveRequestsTable); columnsData.push({ data: 'save_task_status', name: 'save_task_status' }); columnsData.push({ name: 'info', render: (data, type, row) => { if (row.save_task_status === 'succeeded' || row.save_task_status === 'failed') { return '<i class="mdi mdi-information-outline swh-save-request-info" aria-hidden="true" style="cursor: pointer"' + `onclick="swh.save.displaySaveRequestInfo(event, ${row.id})"></i>`; } else { return ''; } } }); acceptedSaveRequestsTable = $('#swh-origin-save-accepted-requests').DataTable({ serverSide: true, processing: true, language: { processing: `<img src="${swhSpinnerSrc}"></img>` }, ajax: Urls.origin_save_requests_list('accepted'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-accepted-requests'); swh.webapp.addJumpToPagePopoverToDataTable(acceptedSaveRequestsTable); $('#swh-origin-save-requests-nav-item').on('shown.bs.tab', () => { pendingSaveRequestsTable.draw(); }); $('#swh-origin-save-url-filters-nav-item').on('shown.bs.tab', () => { authorizedOriginTable.draw(); }); $('#swh-authorized-origins-tab').on('shown.bs.tab', () => { authorizedOriginTable.draw(); }); $('#swh-unauthorized-origins-tab').on('shown.bs.tab', () => { unauthorizedOriginTable.draw(); }); $('#swh-save-requests-pending-tab').on('shown.bs.tab', () => { pendingSaveRequestsTable.draw(); }); $('#swh-save-requests-accepted-tab').on('shown.bs.tab', () => { acceptedSaveRequestsTable.draw(); }); $('#swh-save-requests-rejected-tab').on('shown.bs.tab', () => { rejectedSaveRequestsTable.draw(); }); $('#swh-save-requests-pending-tab').click(() => { pendingSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-accepted-tab').click(() => { acceptedSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-rejected-tab').click(() => { rejectedSaveRequestsTable.ajax.reload(null, false); }); $('body').on('click', e => { if ($(e.target).parents('.popover').length > 0) { e.stopPropagation(); } else if ($(e.target).parents('.swh-save-request-info').length === 0) { $('.swh-save-request-info').popover('dispose'); } }); }); } export async function addAuthorizedOriginUrl() { const originUrl = $('#swh-authorized-url-prefix').val(); const addOriginUrl = Urls.admin_origin_save_add_authorized_url(originUrl); try { const response = await csrfPost(addOriginUrl); handleFetchError(response); authorizedOriginTable.row.add({'url': originUrl}).draw(); $('.swh-add-authorized-origin-status').html( htmlAlert('success', 'The origin url prefix has been successfully added in the authorized list.', true) ); } catch (_) { $('.swh-add-authorized-origin-status').html( htmlAlert('warning', 'The provided origin url prefix is already registered in the authorized list.', true) ); } } export async function removeAuthorizedOriginUrl() { const originUrl = $('#swh-authorized-origin-urls tr.selected').text(); if (originUrl) { const removeOriginUrl = Urls.admin_origin_save_remove_authorized_url(originUrl); try { const response = await csrfPost(removeOriginUrl); handleFetchError(response); authorizedOriginTable.row('.selected').remove().draw(); } catch (_) {} } } export async function addUnauthorizedOriginUrl() { const originUrl = $('#swh-unauthorized-url-prefix').val(); const addOriginUrl = Urls.admin_origin_save_add_unauthorized_url(originUrl); try { const response = await csrfPost(addOriginUrl); handleFetchError(response); unauthorizedOriginTable.row.add({'url': originUrl}).draw(); $('.swh-add-unauthorized-origin-status').html( htmlAlert('success', 'The origin url prefix has been successfully added in the unauthorized list.', true) ); } catch (_) { $('.swh-add-unauthorized-origin-status').html( htmlAlert('warning', 'The provided origin url prefix is already registered in the unauthorized list.', true) ); } } export async function removeUnauthorizedOriginUrl() { const originUrl = $('#swh-unauthorized-origin-urls tr.selected').text(); if (originUrl) { const removeOriginUrl = Urls.admin_origin_save_remove_unauthorized_url(originUrl); try { const response = await csrfPost(removeOriginUrl); handleFetchError(response); unauthorizedOriginTable.row('.selected').remove().draw(); } catch (_) {}; } } export function acceptOriginSaveRequest() { const selectedRow = pendingSaveRequestsTable.row('.selected'); if (selectedRow.length) { const acceptOriginSaveRequestCallback = async() => { const rowData = selectedRow.data(); const acceptSaveRequestUrl = Urls.admin_origin_save_request_accept(rowData['visit_type'], rowData['origin_url']); await csrfPost(acceptSaveRequestUrl); pendingSaveRequestsTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Accept origin save request ?', 'Are you sure to accept this origin save request ?', acceptOriginSaveRequestCallback); } } export function rejectOriginSaveRequest() { const selectedRow = pendingSaveRequestsTable.row('.selected'); if (selectedRow.length) { - let rejectOriginSaveRequestCallback = async() => { + const rejectOriginSaveRequestCallback = async() => { const rowData = selectedRow.data(); const rejectSaveRequestUrl = Urls.admin_origin_save_request_reject(rowData['visit_type'], rowData['origin_url']); await csrfPost(rejectSaveRequestUrl); pendingSaveRequestsTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Reject origin save request ?', 'Are you sure to reject this origin save request ?', rejectOriginSaveRequestCallback); } } function removeOriginSaveRequest(requestTable) { - let selectedRow = requestTable.row('.selected'); + const selectedRow = requestTable.row('.selected'); if (selectedRow.length) { - let requestId = selectedRow.data()['id']; - let removeOriginSaveRequestCallback = async() => { + const requestId = selectedRow.data()['id']; + const removeOriginSaveRequestCallback = async() => { const removeSaveRequestUrl = Urls.admin_origin_save_request_remove(requestId); await csrfPost(removeSaveRequestUrl); requestTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Remove origin save request ?', 'Are you sure to remove this origin save request ?', removeOriginSaveRequestCallback); } } export function removePendingOriginSaveRequest() { removeOriginSaveRequest(pendingSaveRequestsTable); } export function removeAcceptedOriginSaveRequest() { removeOriginSaveRequest(acceptedSaveRequestsTable); } export function removeRejectedOriginSaveRequest() { removeOriginSaveRequest(rejectedSaveRequestsTable); } diff --git a/assets/src/bundles/auth/index.js b/assets/src/bundles/auth/index.js index a560efb4..4eecdf43 100644 --- a/assets/src/bundles/auth/index.js +++ b/assets/src/bundles/auth/index.js @@ -1,190 +1,190 @@ /** * Copyright (C) 2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, csrfPost, removeUrlFragment} from 'utils/functions'; import './auth.css'; let apiTokensTable; function tokenForm(infoText, buttonText) { const form = `<form id="swh-token-form" class="text-center"> <p id="swh-token-form-text">${infoText}</p> <input id="swh-token-form-submit" type="submit" value="${buttonText}"> <div id="swh-token-form-message"></div> </form>`; return form; } function errorMessage(message) { return `<p id="swh-token-error-message" class="mt-3 swh-token-form-message">${message}</p>`; } function successMessage(message) { return `<p id="swh-token-success-message" class="mt-3 swh-token-form-message">${message}</p>`; } function disableSubmitButton() { $('#swh-token-form-submit').prop('disabled', true); } function generateToken() { window.location = Urls.oidc_generate_bearer_token(); } async function displayToken(tokenId) { const postData = { token_id: tokenId }; try { const response = await csrfPost(Urls.oidc_get_bearer_token(), {}, JSON.stringify(postData)); handleFetchError(response); const token = await response.text(); const tokenHtml = `<p>Below is your token.</p> <pre id="swh-bearer-token" class="mt-3">${token}</pre>`; swh.webapp.showModalHtml('Display bearer token', tokenHtml); } catch (response) { const responseText = await response.text(); let errorMsg = 'Internal server error.'; if (response.status === 400) { errorMsg = responseText; } swh.webapp.showModalHtml('Display bearer token', errorMessage(errorMsg)); } } async function revokeTokens(tokenIds) { const postData = { token_ids: tokenIds }; try { const response = await csrfPost(Urls.oidc_revoke_bearer_tokens(), {}, JSON.stringify(postData)); handleFetchError(response); disableSubmitButton(); $('#swh-token-form-message').html( successMessage(`Bearer token${tokenIds.length > 1 ? 's' : ''} successfully revoked.`)); apiTokensTable.draw(); } catch (_) { $('#swh-token-form-message').html(errorMessage('Internal server error.')); } } function revokeToken(tokenId) { revokeTokens([tokenId]); } function revokeAllTokens() { const tokenIds = []; const rowsData = apiTokensTable.rows().data(); for (let i = 0; i < rowsData.length; ++i) { tokenIds.push(rowsData[i].id); } revokeTokens(tokenIds); } export function applyTokenAction(action, tokenId) { const actionData = { display: { submitCallback: displayToken }, generate: { modalTitle: 'Bearer token generation', infoText: 'Click on the button to generate the token. You will be redirected to ' + 'Software Heritage Authentication Service and might be asked to enter ' + 'your password again.', buttonText: 'Generate token', submitCallback: generateToken }, revoke: { modalTitle: 'Revoke bearer token', infoText: 'Click on the button to revoke the token.', buttonText: 'Revoke token', submitCallback: revokeToken }, revokeAll: { modalTitle: 'Revoke all bearer tokens', infoText: 'Click on the button to revoke all tokens.', buttonText: 'Revoke tokens', submitCallback: revokeAllTokens } }; if (!actionData[action]) { return; } if (action !== 'display') { const tokenFormHtml = tokenForm( actionData[action].infoText, actionData[action].buttonText); swh.webapp.showModalHtml(actionData[action].modalTitle, tokenFormHtml); $(`#swh-token-form`).submit(event => { event.preventDefault(); event.stopPropagation(); actionData[action].submitCallback(tokenId); }); } else { actionData[action].submitCallback(tokenId); } } export function initProfilePage() { $(document).ready(() => { apiTokensTable = $('#swh-bearer-tokens-table') .on('error.dt', (e, settings, techNote, message) => { $('#swh-origin-save-request-list-error').text( 'An error occurred while retrieving the tokens list'); console.log(message); }) .DataTable({ serverSide: true, ajax: Urls.oidc_list_bearer_tokens(), columns: [ { data: 'creation_date', name: 'creation_date', render: (data, type, row) => { if (type === 'display') { - let date = new Date(data); + const date = new Date(data); return date.toLocaleString(); } return data; } }, { render: (data, type, row) => { const html = `<button class="btn btn-default" onclick="swh.auth.applyTokenAction('display', ${row.id})"> Display token </button> <button class="btn btn-default" onclick="swh.auth.applyTokenAction('revoke', ${row.id})"> Revoke token </button>`; return html; } } ], ordering: false, searching: false, scrollY: '50vh', scrollCollapse: true }); $('#swh-oidc-profile-tokens-tab').on('shown.bs.tab', () => { apiTokensTable.draw(); window.location.hash = '#tokens'; }); $('#swh-oidc-profile-account-tab').on('shown.bs.tab', () => { removeUrlFragment(); }); if (window.location.hash === '#tokens') { $('.nav-tabs a[href="#swh-oidc-profile-tokens"]').tab('show'); } }); } diff --git a/assets/src/bundles/browse/origin-search.js b/assets/src/bundles/browse/origin-search.js index 226d722b..3f094136 100644 --- a/assets/src/bundles/browse/origin-search.js +++ b/assets/src/bundles/browse/origin-search.js @@ -1,260 +1,260 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, isArchivedOrigin} from 'utils/functions'; const limit = 100; -let linksPrev = []; +const linksPrev = []; let linkNext = null; let linkCurrent = null; let inSearch = false; function parseLinkHeader(s) { - let re = /<(.+)>; rel="next"/; + const re = /<(.+)>; rel="next"/; return s.match(re)[1]; } function fixTableRowsStyle() { setTimeout(() => { $('#origin-search-results tbody tr').removeAttr('style'); }); } function clearOriginSearchResultsTable() { $('#origin-search-results tbody tr').remove(); } async function populateOriginSearchResultsTable(origins) { if (origins.length > 0) { $('#swh-origin-search-results').show(); $('#swh-no-result').hide(); clearOriginSearchResultsTable(); - let table = $('#origin-search-results tbody'); - let promises = []; - for (let [i, origin] of origins.entries()) { - let browseUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(origin.url)}`; + const table = $('#origin-search-results tbody'); + const promises = []; + for (const [i, origin] of origins.entries()) { + const browseUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(origin.url)}`; let tableRow = `<tr id="origin-${i}" class="swh-search-result-entry swh-tr-hover-highlight">`; tableRow += `<td id="visit-type-origin-${i}" class="swh-origin-visit-type" style="width: 120px;">` + '<i title="Checking software origin type" class="mdi mdi-sync mdi-spin mdi-fw"></i>' + 'Checking</td>'; tableRow += '<td style="white-space: nowrap;">' + `<a href="${browseUrl}">${origin.url}</a></td>`; tableRow += `<td class="swh-visit-status" id="visit-status-origin-${i}">` + '<i title="Checking archiving status" class="mdi mdi-sync mdi-spin mdi-fw"></i>' + 'Checking</td>'; tableRow += '</tr>'; table.append(tableRow); // get async latest visit snapshot and update visit status icon let latestSnapshotUrl = Urls.api_1_origin_visit_latest(origin.url); latestSnapshotUrl += '?require_snapshot=true'; promises.push(fetch(latestSnapshotUrl)); } const responses = await Promise.all(promises); const responsesData = await Promise.all(responses.map(r => r.json())); for (let i = 0; i < responses.length; ++i) { const response = responses[i]; const data = responsesData[i]; if (response.status !== 404 && data.type) { $(`#visit-type-origin-${i}`).html(data.type); $(`#visit-status-origin-${i}`).html( '<i title="Software origin has been archived by Software Heritage" ' + 'class="mdi mdi-check-bold mdi-fw"></i>Archived'); } else { $(`#visit-type-origin-${i}`).html('unknown'); $(`#visit-status-origin-${i}`).html( '<i title="Software origin archival by Software Heritage is pending" ' + 'class="mdi mdi-close-thick mdi-fw"></i>Pending archival'); if ($('#swh-filter-empty-visits').prop('checked')) { $(`#origin-${i}`).remove(); } } } fixTableRowsStyle(); } else { $('#swh-origin-search-results').hide(); $('#swh-no-result').text('No origins matching the search criteria were found.'); $('#swh-no-result').show(); } if (linkNext === null) { $('#origins-next-results-button').addClass('disabled'); } else { $('#origins-next-results-button').removeClass('disabled'); } if (linksPrev.length === 0) { $('#origins-prev-results-button').addClass('disabled'); } else { $('#origins-prev-results-button').removeClass('disabled'); } inSearch = false; setTimeout(() => { window.scrollTo(0, 0); }); } function searchOriginsFirst(searchQueryText, limit) { let baseSearchUrl; - let searchMetadata = $('#swh-search-origin-metadata').prop('checked'); + const searchMetadata = $('#swh-search-origin-metadata').prop('checked'); if (searchMetadata) { baseSearchUrl = new URL(Urls.api_1_origin_metadata_search(), window.location); baseSearchUrl.searchParams.append('fulltext', searchQueryText); } else { baseSearchUrl = new URL(Urls.api_1_origin_search(searchQueryText), window.location); } - let withVisit = $('#swh-search-origins-with-visit').prop('checked'); + const withVisit = $('#swh-search-origins-with-visit').prop('checked'); baseSearchUrl.searchParams.append('limit', limit); baseSearchUrl.searchParams.append('with_visit', withVisit); const visitType = $('#swh-search-visit-type').val(); if (visitType !== 'any') { baseSearchUrl.searchParams.append('visit_type', visitType); } - let searchUrl = baseSearchUrl.toString(); + const searchUrl = baseSearchUrl.toString(); searchOrigins(searchUrl); } async function searchOrigins(searchUrl) { clearOriginSearchResultsTable(); $('.swh-loading').addClass('show'); try { const response = await fetch(searchUrl); handleFetchError(response); const data = await response.json(); // Save link to the current results page linkCurrent = searchUrl; // Save link to the next results page. linkNext = null; if (response.headers.has('Link')) { - let parsedLink = parseLinkHeader(response.headers.get('Link')); + const parsedLink = parseLinkHeader(response.headers.get('Link')); if (parsedLink !== undefined) { linkNext = parsedLink; } } // prevLinks is updated by the caller, which is the one to know if // we're going forward or backward in the pages. $('.swh-loading').removeClass('show'); populateOriginSearchResultsTable(data); } catch (response) { $('.swh-loading').removeClass('show'); inSearch = false; $('#swh-origin-search-results').hide(); $('#swh-no-result').text(`Error ${response.status}: ${response.statusText}`); $('#swh-no-result').show(); } } async function doSearch() { $('#swh-no-result').hide(); const searchQueryText = $('#swh-origins-url-patterns').val(); inSearch = true; if (searchQueryText.startsWith('swh:')) { try { // searchQueryText may be a PID so sending search queries to PID resolve endpoint const resolveSWHIDUrl = Urls.api_1_resolve_swhid(searchQueryText); const response = await fetch(resolveSWHIDUrl); handleFetchError(response); const data = await response.json(); // SWHID has been successfully resolved, // so redirect to browse page window.location = data.browse_url; } catch (response) { // display a useful error message if the input // looks like a SWHID const data = await response.json(); $('#swh-origin-search-results').hide(); $('.swh-search-pagination').hide(); $('#swh-no-result').text(data.reason); $('#swh-no-result').show(); } } else if (await isArchivedOrigin(searchQueryText)) { // redirect to the browse origin window.location.href = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(searchQueryText)}`; } else { // otherwise, proceed with origins search irrespective of the error $('#swh-origin-search-results').show(); $('.swh-search-pagination').show(); searchOriginsFirst(searchQueryText, limit); } } export function initOriginSearch() { $(document).ready(() => { $('#swh-search-origins').submit(event => { event.preventDefault(); if (event.target.checkValidity()) { $(event.target).removeClass('was-validated'); - let searchQueryText = $('#swh-origins-url-patterns').val().trim(); - let withVisit = $('#swh-search-origins-with-visit').prop('checked'); - let withContent = $('#swh-filter-empty-visits').prop('checked'); - let searchMetadata = $('#swh-search-origin-metadata').prop('checked'); + const searchQueryText = $('#swh-origins-url-patterns').val().trim(); + const withVisit = $('#swh-search-origins-with-visit').prop('checked'); + const withContent = $('#swh-filter-empty-visits').prop('checked'); + const searchMetadata = $('#swh-search-origin-metadata').prop('checked'); const visitType = $('#swh-search-visit-type').val(); - let queryParameters = new URLSearchParams(); + const queryParameters = new URLSearchParams(); queryParameters.append('q', searchQueryText); if (withVisit) { queryParameters.append('with_visit', withVisit); } if (withContent) { queryParameters.append('with_content', withContent); } if (searchMetadata) { queryParameters.append('search_metadata', searchMetadata); } if (visitType !== 'any') { queryParameters.append('visit_type', visitType); } // Update the url, triggering page reload and effective search window.location = `${Urls.browse_search()}?${queryParameters.toString()}`; } else { $(event.target).addClass('was-validated'); } }); $('#origins-next-results-button').click(event => { if ($('#origins-next-results-button').hasClass('disabled') || inSearch) { return; } inSearch = true; linksPrev.push(linkCurrent); searchOrigins(linkNext); event.preventDefault(); }); $('#origins-prev-results-button').click(event => { if ($('#origins-prev-results-button').hasClass('disabled') || inSearch) { return; } inSearch = true; searchOrigins(linksPrev.pop()); event.preventDefault(); }); - let urlParams = new URLSearchParams(window.location.search); - let query = urlParams.get('q'); - let withVisit = urlParams.has('with_visit'); - let withContent = urlParams.has('with_content'); - let searchMetadata = urlParams.has('search_metadata'); - let visitType = urlParams.get('visit_type'); + const urlParams = new URLSearchParams(window.location.search); + const query = urlParams.get('q'); + const withVisit = urlParams.has('with_visit'); + const withContent = urlParams.has('with_content'); + const searchMetadata = urlParams.has('search_metadata'); + const visitType = urlParams.get('visit_type'); if (query) { $('#swh-origins-url-patterns').val(query); $('#swh-search-origins-with-visit').prop('checked', withVisit); $('#swh-filter-empty-visits').prop('checked', withContent); $('#swh-search-origin-metadata').prop('checked', searchMetadata); if (visitType) { $('#swh-search-visit-type').val(visitType); } doSearch(); } }); } diff --git a/assets/src/bundles/browse/snapshot-navigation.js b/assets/src/bundles/browse/snapshot-navigation.js index 8ade39fb..e85496d8 100644 --- a/assets/src/bundles/browse/snapshot-navigation.js +++ b/assets/src/bundles/browse/snapshot-navigation.js @@ -1,56 +1,56 @@ /** * Copyright (C) 2018 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ export function initSnapshotNavigation(snapshotContext, branch) { function setBranchesTabActive() { $('.swh-releases-switch').removeClass('active'); $('.swh-branches-switch').addClass('active'); $('#swh-tab-releases').removeClass('active'); $('#swh-tab-branches').addClass('active'); } function setReleasesTabActive() { $('.swh-branches-switch').removeClass('active'); $('.swh-releases-switch').addClass('active'); $('#swh-tab-branches').removeClass('active'); $('#swh-tab-releases').addClass('active'); } $(document).ready(() => { $('.dropdown-menu a.swh-branches-switch').click(e => { setBranchesTabActive(); e.stopPropagation(); }); $('.dropdown-menu a.swh-releases-switch').click(e => { setReleasesTabActive(); e.stopPropagation(); }); let dropdownResized = false; // hack to resize the branches/releases dropdown content, // taking icons into account, in order to make the whole names readable $('#swh-branches-releases-dd').on('show.bs.dropdown', () => { if (dropdownResized) return; - let dropdownWidth = $('.swh-branches-releases').width(); + const dropdownWidth = $('.swh-branches-releases').width(); $('.swh-branches-releases').width(dropdownWidth + 25); dropdownResized = true; }); if (snapshotContext) { if (branch) { setBranchesTabActive(); } else { setReleasesTabActive(); } } }); } diff --git a/assets/src/bundles/browse/swhid-utils.js b/assets/src/bundles/browse/swhid-utils.js index 92b139e5..e0583216 100644 --- a/assets/src/bundles/browse/swhid-utils.js +++ b/assets/src/bundles/browse/swhid-utils.js @@ -1,122 +1,122 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import ClipboardJS from 'clipboard'; import 'thirdparty/jquery.tabSlideOut/jquery.tabSlideOut'; import 'thirdparty/jquery.tabSlideOut/jquery.tabSlideOut.css'; import {BREAKPOINT_SM} from 'utils/constants'; export function swhIdObjectTypeToggled(event) { event.preventDefault(); $(event.target).tab('show'); } export function swhIdContextOptionToggled(event) { event.stopPropagation(); - let swhIdElt = $(event.target).closest('.swhid-ui').find('.swhid'); - let swhIdWithContext = $(event.target).data('swhid-with-context'); - let swhIdWithContextUrl = $(event.target).data('swhid-with-context-url'); + const swhIdElt = $(event.target).closest('.swhid-ui').find('.swhid'); + const swhIdWithContext = $(event.target).data('swhid-with-context'); + const swhIdWithContextUrl = $(event.target).data('swhid-with-context-url'); let currentSwhId = swhIdElt.text(); if ($(event.target).prop('checked')) { swhIdElt.attr('href', swhIdWithContextUrl); currentSwhId = swhIdWithContext.replace(/;/g, ';\n'); } else { const pos = currentSwhId.indexOf(';'); if (pos !== -1) { currentSwhId = currentSwhId.slice(0, pos); } swhIdElt.attr('href', '/' + currentSwhId); } swhIdElt.text(currentSwhId); addLinesInfo(); } function addLinesInfo() { - let swhIdElt = $('#swhid-tab-content').find('.swhid'); + const swhIdElt = $('#swhid-tab-content').find('.swhid'); let currentSwhId = swhIdElt.text().replace(/;\n/g, ';'); - let lines = []; + const lines = []; let linesPart = ';lines='; - let linesRegexp = new RegExp(/L(\d+)/g); + const linesRegexp = new RegExp(/L(\d+)/g); let line = linesRegexp.exec(window.location.hash); while (line) { lines.push(parseInt(line[1])); line = linesRegexp.exec(window.location.hash); } if (lines.length > 0) { linesPart += lines[0]; } if (lines.length > 1) { linesPart += '-' + lines[1]; } if ($('#swhid-context-option-content').prop('checked')) { currentSwhId = currentSwhId.replace(/;lines=\d+-*\d*/g, ''); if (lines.length > 0) { currentSwhId += linesPart; } swhIdElt.text(currentSwhId.replace(/;/g, ';\n')); swhIdElt.attr('href', '/' + currentSwhId); } } $(document).ready(() => { new ClipboardJS('.btn-swhid-copy', { text: trigger => { - let swhId = $(trigger).closest('.swhid-ui').find('.swhid').text(); + const swhId = $(trigger).closest('.swhid-ui').find('.swhid').text(); return swhId.replace(/;\n/g, ';'); } }); new ClipboardJS('.btn-swhid-url-copy', { text: trigger => { - let swhIdUrl = $(trigger).closest('.swhid-ui').find('.swhid').attr('href'); + const swhIdUrl = $(trigger).closest('.swhid-ui').find('.swhid').attr('href'); return window.location.origin + swhIdUrl; } }); if (window.innerWidth * 0.7 > 1000) { $('#swh-identifiers').css('width', '1000px'); } - let tabSlideOptions = { + const tabSlideOptions = { tabLocation: 'right', clickScreenToCloseFilters: ['.ui-slideouttab-panel', '.modal'], offset: function() { const width = $(window).width(); if (width < BREAKPOINT_SM) { return '250px'; } else { return '200px'; } } }; // ensure tab scrolling on small screens if (window.innerHeight < 600 || window.innerWidth < 500) { tabSlideOptions['otherOffset'] = '20px'; } // initiate the sliding identifiers tab $('#swh-identifiers').tabSlideOut(tabSlideOptions); // set the tab visible once the close animation is terminated $('#swh-identifiers').css('display', 'block'); $('.swhid-context-option').trigger('click'); // highlighted code lines changed $(window).on('hashchange', () => { addLinesInfo(); }); // highlighted code lines removed $('body').click(() => { addLinesInfo(); }); }); diff --git a/assets/src/bundles/origin/visits-calendar.js b/assets/src/bundles/origin/visits-calendar.js index 90b7bb26..6c2aa630 100644 --- a/assets/src/bundles/origin/visits-calendar.js +++ b/assets/src/bundles/origin/visits-calendar.js @@ -1,147 +1,147 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import Calendar from 'js-year-calendar'; import 'js-year-calendar/dist/js-year-calendar.css'; -let minSize = 15; -let maxSize = 28; +const minSize = 15; +const maxSize = 28; let currentPopover = null; let visitsByDate = {}; function closePopover() { if (currentPopover) { $(currentPopover).popover('dispose'); currentPopover = null; } } // function to update the visits calendar view based on the selected year export function updateCalendar(year, filteredVisits, yearClickedCallback) { visitsByDate = {}; let maxNbVisitsByDate = 0; let minDate, maxDate; for (let i = 0; i < filteredVisits.length; ++i) { filteredVisits[i]['startDate'] = filteredVisits[i]['date']; filteredVisits[i]['endDate'] = filteredVisits[i]['startDate']; - let date = new Date(filteredVisits[i]['date']); + const date = new Date(filteredVisits[i]['date']); date.setHours(0, 0, 0, 0); - let dateStr = date.toDateString(); + const dateStr = date.toDateString(); if (!visitsByDate.hasOwnProperty(dateStr)) { visitsByDate[dateStr] = [filteredVisits[i]]; } else { visitsByDate[dateStr].push(filteredVisits[i]); } maxNbVisitsByDate = Math.max(maxNbVisitsByDate, visitsByDate[dateStr].length); if (i === 0) { minDate = maxDate = date; } else { if (date.getTime() < minDate.getTime()) { minDate = date; } if (date.getTime() > maxDate.getTime()) { maxDate = date; } } } closePopover(); new Calendar('#swh-visits-calendar', { dataSource: filteredVisits, style: 'custom', minDate: minDate, maxDate: maxDate, startYear: year, renderEnd: e => yearClickedCallback(e.currentYear), customDataSourceRenderer: (element, date, events) => { - let dateStr = date.toDateString(); - let nbVisits = visitsByDate[dateStr].length; + const dateStr = date.toDateString(); + const nbVisits = visitsByDate[dateStr].length; let t = nbVisits / maxNbVisitsByDate; if (maxNbVisitsByDate === 1) { t = 0; } - let size = minSize + t * (maxSize - minSize); - let offsetX = (maxSize - size) / 2 - parseInt($(element).css('padding-left')); - let offsetY = (maxSize - size) / 2 - parseInt($(element).css('padding-top')) + 1; - let cellWrapper = $('<div></div>'); + const size = minSize + t * (maxSize - minSize); + const offsetX = (maxSize - size) / 2 - parseInt($(element).css('padding-left')); + const offsetY = (maxSize - size) / 2 - parseInt($(element).css('padding-top')) + 1; + const cellWrapper = $('<div></div>'); cellWrapper.css('position', 'relative'); - let dayNumber = $('<div></div>'); + const dayNumber = $('<div></div>'); dayNumber.text($(element).text()); - let circle = $('<div></div>'); + const circle = $('<div></div>'); let r = 0; let g = 0; for (let i = 0; i < nbVisits; ++i) { - let visit = visitsByDate[dateStr][i]; + const visit = visitsByDate[dateStr][i]; if (visit.status === 'full') { g += 255; } else if (visit.status === 'partial') { r += 255; g += 255; } else { r += 255; } } r /= nbVisits; g /= nbVisits; circle.css('background-color', 'rgba(' + r + ', ' + g + ', 0, 0.3)'); circle.css('width', size + 'px'); circle.css('height', size + 'px'); circle.css('border-radius', size + 'px'); circle.css('position', 'absolute'); circle.css('top', offsetY + 'px'); circle.css('left', offsetX + 'px'); cellWrapper.append(dayNumber); cellWrapper.append(circle); $(element)[0].innerHTML = $(cellWrapper)[0].outerHTML; }, mouseOnDay: e => { if (currentPopover !== e.element) { closePopover(); } - let dateStr = e.date.toDateString(); + const dateStr = e.date.toDateString(); if (visitsByDate.hasOwnProperty(dateStr)) { - let visits = visitsByDate[dateStr]; + const visits = visitsByDate[dateStr]; let content = '<div><h6>' + e.date.toDateString() + '</h6></div>'; content += '<ul class="swh-list-unstyled">'; for (let i = 0; i < visits.length; ++i) { - let visitTime = visits[i].formatted_date.substr(visits[i].formatted_date.indexOf(',') + 2); + const visitTime = visits[i].formatted_date.substr(visits[i].formatted_date.indexOf(',') + 2); content += '<li><a class="swh-visit-icon swh-visit-' + visits[i].status + '" title="' + visits[i].status + ' visit" href="' + visits[i].url + '">' + visitTime + '</a></li>'; } content += '</ul>'; $(e.element).popover({ trigger: 'manual', container: 'body', html: true, content: content }).on('mouseleave', () => { if (!$('.popover:hover').length) { // close popover when leaving day in calendar // except if the pointer is hovering it closePopover(); } }); $(e.element).on('shown.bs.popover', () => { $('.popover').mouseleave(() => { // close popover when pointer leaves it closePopover(); }); }); $(e.element).popover('show'); currentPopover = e.element; } } }); $('#swh-visits-calendar.calendar table td').css('width', maxSize + 'px'); $('#swh-visits-calendar.calendar table td').css('height', maxSize + 'px'); $('#swh-visits-calendar.calendar table td').css('padding', '0px'); } diff --git a/assets/src/bundles/origin/visits-histogram.js b/assets/src/bundles/origin/visits-histogram.js index 6198c918..d349ff35 100644 --- a/assets/src/bundles/origin/visits-histogram.js +++ b/assets/src/bundles/origin/visits-histogram.js @@ -1,336 +1,336 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // Creation of a stacked histogram with D3.js for software origin visits history // Parameters description: // - container: selector for the div that will contain the histogram // - visitsData: raw swh origin visits data // - currentYear: the visits year to display by default // - yearClickCallback: callback when the user selects a year through the histogram export async function createVisitsHistogram(container, visitsData, currentYear, yearClickCallback) { const d3 = await import(/* webpackChunkName: "d3" */ 'utils/d3'); // remove previously created histogram and tooltip if any d3.select(container).select('svg').remove(); d3.select('div.d3-tooltip').remove(); // histogram size and margins let width = 1000; let height = 200; - let margin = {top: 20, right: 80, bottom: 30, left: 50}; + const margin = {top: 20, right: 80, bottom: 30, left: 50}; // create responsive svg - let svg = d3.select(container) + const svg = d3.select(container) .attr('style', 'padding-bottom: ' + Math.ceil(height * 100 / width) + '%') .append('svg') .attr('viewBox', '0 0 ' + width + ' ' + height); // create tooltip div - let tooltip = d3.select('body') + const tooltip = d3.select('body') .append('div') .attr('class', 'd3-tooltip') .style('opacity', 0); // update width and height without margins width = width - margin.left - margin.right; height = height - margin.top - margin.bottom; // create main svg group element - let g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + const g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); // create x scale - let x = d3.scaleTime().rangeRound([0, width]); + const x = d3.scaleTime().rangeRound([0, width]); // create y scale - let y = d3.scaleLinear().range([height, 0]); + const y = d3.scaleLinear().range([height, 0]); // create ordinal colorscale mapping visit status - let colors = d3.scaleOrdinal() + const colors = d3.scaleOrdinal() .domain(['full', 'partial', 'failed', 'ongoing']) .range(['#008000', '#edc344', '#ff0000', '#0000ff']); // first swh crawls were made in 2015 - let startYear = 2015; + const startYear = 2015; // set latest display year as the current one - let now = new Date(); - let endYear = now.getUTCFullYear() + 1; - let monthExtent = [new Date(Date.UTC(startYear, 0, 1)), new Date(Date.UTC(endYear, 0, 1))]; + const now = new Date(); + const endYear = now.getUTCFullYear() + 1; + const monthExtent = [new Date(Date.UTC(startYear, 0, 1)), new Date(Date.UTC(endYear, 0, 1))]; // create months bins based on setup extent - let monthBins = d3.timeMonths(d3.timeMonth.offset(monthExtent[0], -1), monthExtent[1]); + const monthBins = d3.timeMonths(d3.timeMonth.offset(monthExtent[0], -1), monthExtent[1]); // create years bins based on setup extent - let yearBins = d3.timeYears(monthExtent[0], monthExtent[1]); + const yearBins = d3.timeYears(monthExtent[0], monthExtent[1]); // set x scale domain x.domain(d3.extent(monthBins)); // use D3 histogram layout to create a function that will bin the visits by month - let binByMonth = d3.histogram() + const binByMonth = d3.histogram() .value(d => d.date) .domain(x.domain()) .thresholds(monthBins); // use D3 nest function to group the visits by status - let visitsByStatus = d3.groups(visitsData, d => d['status']) + const visitsByStatus = d3.groups(visitsData, d => d['status']) .sort((a, b) => d3.ascending(a[0], b[0])); // prepare data in order to be able to stack visit statuses by month - let statuses = []; - let histData = []; + const statuses = []; + const histData = []; for (let i = 0; i < monthBins.length; ++i) { histData[i] = {}; } visitsByStatus.forEach(entry => { statuses.push(entry[0]); - let monthsData = binByMonth(entry[1]); + const monthsData = binByMonth(entry[1]); for (let i = 0; i < monthsData.length; ++i) { histData[i]['x0'] = monthsData[i]['x0']; histData[i]['x1'] = monthsData[i]['x1']; histData[i][entry[0]] = monthsData[i]; } }); // create function to stack visits statuses by month - let stacked = d3.stack() + const stacked = d3.stack() .keys(statuses) .value((d, key) => d[key].length); // compute the maximum amount of visits by month - let yMax = d3.max(histData, d => { + const yMax = d3.max(histData, d => { let total = 0; for (let i = 0; i < statuses.length; ++i) { total += d[statuses[i]].length; } return total; }); // set y scale domain y.domain([0, yMax]); // compute ticks values for the y axis - let step = 5; - let yTickValues = []; + const step = 5; + const yTickValues = []; for (let i = 0; i <= yMax / step; ++i) { yTickValues.push(i * step); } if (yTickValues.length === 0) { for (let i = 0; i <= yMax; ++i) { yTickValues.push(i); } } else if (yMax % step !== 0) { yTickValues.push(yMax); } // add histogram background grid g.append('g') .attr('class', 'grid') .call(d3.axisLeft(y) .tickValues(yTickValues) .tickSize(-width) .tickFormat('')); // create one fill only rectangle by displayed year // each rectangle will be made visible when hovering the mouse over a year range // user will then be able to select a year by clicking in the rectangle g.append('g') .selectAll('rect') .data(yearBins) .enter().append('rect') .attr('class', d => 'year' + d.getUTCFullYear()) .attr('fill', 'red') .attr('fill-opacity', d => d.getUTCFullYear() === currentYear ? 0.3 : 0) .attr('stroke', 'none') .attr('x', d => { - let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return x(date); }) .attr('y', 0) .attr('height', height) .attr('width', d => { - let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); + const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); return yearWidth; }) // mouse event callbacks used to show rectangle years // when hovering the mouse over the histograms .on('mouseover', (event, d) => { svg.selectAll('rect.year' + d.getUTCFullYear()) .attr('fill-opacity', 0.5); }) .on('mouseout', (event, d) => { svg.selectAll('rect.year' + d.getUTCFullYear()) .attr('fill-opacity', 0); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.3); }) // callback to select a year after a mouse click // in a rectangle year .on('click', (event, d) => { svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'none'); currentYear = d.getUTCFullYear(); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.5); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'black'); yearClickCallback(currentYear); }); // create the stacked histogram of visits g.append('g') .selectAll('g') .data(stacked(histData)) .enter().append('g') .attr('fill', d => colors(d.key)) .selectAll('rect') .data(d => d) .enter().append('rect') .attr('class', d => 'month' + d.data.x1.getMonth()) .attr('x', d => x(d.data.x0)) .attr('y', d => y(d[1])) .attr('height', d => y(d[0]) - y(d[1])) .attr('width', d => x(d.data.x1) - x(d.data.x0) - 1) // mouse event callbacks used to show rectangle years // but also to show tooltip when hovering the mouse // over the histogram bars .on('mouseover', (event, d) => { svg.selectAll('rect.year' + d.data.x1.getUTCFullYear()) .attr('fill-opacity', 0.5); tooltip.transition() .duration(200) .style('opacity', 1); - let ds = d.data.x1.toISOString().substr(0, 7).split('-'); + const ds = d.data.x1.toISOString().substr(0, 7).split('-'); let tooltipText = '<b>' + ds[1] + ' / ' + ds[0] + ':</b><br/>'; for (let i = 0; i < statuses.length; ++i) { - let visitStatus = statuses[i]; - let nbVisits = d.data[visitStatus].length; + const visitStatus = statuses[i]; + const nbVisits = d.data[visitStatus].length; if (nbVisits === 0) continue; tooltipText += nbVisits + ' ' + visitStatus + ' visits'; if (i !== statuses.length - 1) tooltipText += '<br/>'; } tooltip.html(tooltipText) .style('left', event.pageX + 15 + 'px') .style('top', event.pageY + 'px'); }) .on('mouseout', (event, d) => { svg.selectAll('rect.year' + d.data.x1.getUTCFullYear()) .attr('fill-opacity', 0); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.3); tooltip.transition() .duration(500) .style('opacity', 0); }) .on('mousemove', (event) => { tooltip.style('left', event.pageX + 15 + 'px') .style('top', event.pageY + 'px'); }) // callback to select a year after a mouse click // inside a histogram bar .on('click', (event, d) => { svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'none'); currentYear = d.data.x1.getUTCFullYear(); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.5); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'black'); yearClickCallback(currentYear); }); // create one stroke only rectangle by displayed year // that will be displayed on top of the histogram when the user has selected a year g.append('g') .selectAll('rect') .data(yearBins) .enter().append('rect') .attr('class', d => 'yearoutline' + d.getUTCFullYear()) .attr('fill', 'none') .attr('stroke', d => d.getUTCFullYear() === currentYear ? 'black' : 'none') .attr('x', d => { - let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return x(date); }) .attr('y', 0) .attr('height', height) .attr('width', d => { - let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); + const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); return yearWidth; }); // add x axis with a tick for every 1st day of each year - let xAxis = g.append('g') + const xAxis = g.append('g') .attr('class', 'axis') .attr('transform', 'translate(0,' + height + ')') .call( d3.axisBottom(x) .ticks(d3.timeYear.every(1)) .tickFormat(d => d.getUTCFullYear()) ); // shift tick labels in order to display them at the middle // of each year range xAxis.selectAll('text') .attr('transform', d => { - let year = d.getUTCFullYear(); - let date = new Date(Date.UTC(year, 0, 1)); - let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); + const year = d.getUTCFullYear(); + const date = new Date(Date.UTC(year, 0, 1)); + const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); return 'translate(' + -yearWidth / 2 + ', 0)'; }); // add y axis for the number of visits g.append('g') .attr('class', 'axis') .call(d3.axisLeft(y).tickValues(yTickValues)); // add legend for visit statuses - let legendGroup = g.append('g') + const legendGroup = g.append('g') .attr('font-family', 'sans-serif') .attr('font-size', 10) .attr('text-anchor', 'end'); legendGroup.append('text') .attr('x', width + margin.right - 5) .attr('y', 9.5) .attr('dy', '0.32em') .text('visit status:'); - let legend = legendGroup.selectAll('g') + const legend = legendGroup.selectAll('g') .data(statuses.slice().reverse()) .enter().append('g') .attr('transform', (d, i) => 'translate(0,' + (i + 1) * 20 + ')'); legend.append('rect') .attr('x', width + 2 * margin.right / 3) .attr('width', 19) .attr('height', 19) .attr('fill', colors); legend.append('text') .attr('x', width + 2 * margin.right / 3 - 5) .attr('y', 9.5) .attr('dy', '0.32em') .text(d => d); // add text label for the y axis g.append('text') .attr('transform', 'rotate(-90)') .attr('y', -margin.left) .attr('x', -(height / 2)) .attr('dy', '1em') .style('text-anchor', 'middle') .text('Number of visits'); } diff --git a/assets/src/bundles/origin/visits-reporting.js b/assets/src/bundles/origin/visits-reporting.js index 9f541062..776f9179 100644 --- a/assets/src/bundles/origin/visits-reporting.js +++ b/assets/src/bundles/origin/visits-reporting.js @@ -1,138 +1,138 @@ /** * Copyright (C) 2018 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {createVisitsHistogram} from './visits-histogram'; import {updateCalendar} from './visits-calendar'; import './visits-reporting.css'; // will hold all visits let allVisits; // will hold filtered visits to display let filteredVisits; // will hold currently displayed year let currentYear; // function to gather full visits function filterFullVisits(differentSnapshots) { - let filteredVisits = []; + const filteredVisits = []; for (let i = 0; i < allVisits.length; ++i) { if (allVisits[i].status !== 'full') continue; if (!differentSnapshots) { filteredVisits.push(allVisits[i]); } else if (filteredVisits.length === 0) { filteredVisits.push(allVisits[i]); } else { - let lastVisit = filteredVisits[filteredVisits.length - 1]; + const lastVisit = filteredVisits[filteredVisits.length - 1]; if (allVisits[i].snapshot !== lastVisit.snapshot) { filteredVisits.push(allVisits[i]); } } } return filteredVisits; } // function to update the visits list view based on the selected year function updateVisitsList(year) { $('#swh-visits-list').children().remove(); - let visitsByYear = []; + const visitsByYear = []; for (let i = 0; i < filteredVisits.length; ++i) { if (filteredVisits[i].date.getUTCFullYear() === year) { visitsByYear.push(filteredVisits[i]); } } let visitsCpt = 0; - let nbVisitsByRow = 4; + const nbVisitsByRow = 4; let visitsListHtml = '<div class="swh-visits-list-row">'; for (let i = 0; i < visitsByYear.length; ++i) { if (visitsCpt > 0 && visitsCpt % nbVisitsByRow === 0) { visitsListHtml += '</div><div class="swh-visits-list-row">'; } visitsListHtml += '<div class="swh-visits-list-column" style="width: ' + 100 / nbVisitsByRow + '%;">'; visitsListHtml += '<a class="swh-visit-icon swh-visit-' + visitsByYear[i].status + '" title="' + visitsByYear[i].status + ' visit" href="' + visitsByYear[i].url + '">' + visitsByYear[i].formatted_date + '</a>'; visitsListHtml += '</div>'; ++visitsCpt; } visitsListHtml += '</div>'; $('#swh-visits-list').append($(visitsListHtml)); } function yearChangedCalendar(year) { currentYear = year; updateVisitsList(year); createVisitsHistogram('.d3-wrapper', filteredVisits, currentYear, yearClickedTimeline); } // callback when the user selects a year through the visits histogram function yearClickedTimeline(year) { currentYear = year; updateCalendar(year, filteredVisits, yearChangedCalendar); updateVisitsList(year); } // function to update the visits views (histogram, calendar, list) function updateDisplayedVisits() { if (filteredVisits.length === 0) { return; } if (!currentYear) { currentYear = filteredVisits[filteredVisits.length - 1].date.getUTCFullYear(); } createVisitsHistogram('.d3-wrapper', filteredVisits, currentYear, yearClickedTimeline); updateCalendar(currentYear, filteredVisits, yearChangedCalendar); updateVisitsList(currentYear); } // callback when the user only wants to see full visits pointing // to different snapshots (default) export function showFullVisitsDifferentSnapshots(event) { filteredVisits = filterFullVisits(true); updateDisplayedVisits(); } // callback when the user only wants to see full visits export function showFullVisits(event) { filteredVisits = filterFullVisits(false); updateDisplayedVisits(); } // callback when the user wants to see all visits (including partial, ongoing and failed ones) export function showAllVisits(event) { filteredVisits = allVisits; updateDisplayedVisits(); } export function initVisitsReporting(visits) { $(document).ready(() => { allVisits = visits; // process input visits let firstFullVisit; allVisits.forEach((v, i) => { // Turn Unix epoch into Javascript Date object v.date = new Date(Math.floor(v.date * 1000)); - let visitLink = '<a class="swh-visit-icon swh-visit-' + v.status + '" href="' + v.url + '">' + v.formatted_date + '</a>'; + const visitLink = '<a class="swh-visit-icon swh-visit-' + v.status + '" href="' + v.url + '">' + v.formatted_date + '</a>'; if (v.status === 'full') { if (!firstFullVisit) { firstFullVisit = v; $('#swh-first-full-visit').append($(visitLink)); if (allVisits.length === 1) { $('#swh-last-full-visit')[0].innerHTML = visitLink; } } else { $('#swh-last-full-visit')[0].innerHTML = visitLink; } } if (i === allVisits.length - 1) { $('#swh-last-visit').append($(visitLink)); } }); // display full visits pointing to different snapshots by default showFullVisitsDifferentSnapshots(); }); } diff --git a/assets/src/bundles/revision/diff-utils.js b/assets/src/bundles/revision/diff-utils.js index 8c462d48..1312f8d6 100644 --- a/assets/src/bundles/revision/diff-utils.js +++ b/assets/src/bundles/revision/diff-utils.js @@ -1,793 +1,793 @@ /** * Copyright (C) 2018-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import 'waypoints/lib/jquery.waypoints'; import {swhSpinnerSrc} from 'utils/constants'; import {removeUrlFragment} from 'utils/functions'; import diffPanelTemplate from './diff-panel.ejs'; // number of changed files in the revision let changes = null; let nbChangedFiles = 0; // to track the number of already computed files diffs let nbDiffsComputed = 0; // the no newline at end of file marker from Github -let noNewLineMarker = '<span class="no-nl-marker" title="No newline at end of file">' + +const noNewLineMarker = '<span class="no-nl-marker" title="No newline at end of file">' + '<svg aria-hidden="true" height="16" version="1.1" viewBox="0 0 16 16" width="16">' + '<path fill-rule="evenodd" d="M16 5v3c0 .55-.45 1-1 1h-3v2L9 8l3-3v2h2V5h2zM8 8c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4zM1.5 9.66L5.66 5.5C5.18 5.19 4.61 5 4 5 2.34 5 1 6.34 1 8c0 .61.19 1.17.5 1.66zM7 8c0-.61-.19-1.17-.5-1.66L2.34 10.5c.48.31 1.05.5 1.66.5 1.66 0 3-1.34 3-3z"></path>' + '</svg>' + '</span>'; // to track the total number of added lines in files diffs let nbAdditions = 0; // to track the total number of deleted lines in files diffs let nbDeletions = 0; // to track the already computed diffs by id -let computedDiffs = {}; +const computedDiffs = {}; // map a diff id to its computation url -let diffsUrls = {}; +const diffsUrls = {}; // to keep track of diff lines to highlight let startLines = null; let endLines = null; // map max line numbers characters to diff const diffMaxNumberChars = {}; // focused diff for highlighting let focusedDiff = null; // highlighting color const lineHighlightColor = '#fdf3da'; // might contain diff lines to highlight parsed from URL fragment let selectedDiffLinesInfo; // URL fragment to append when switching to 'Changes' tab const changesUrlFragment = '#swh-revision-changes'; // current displayed tab name let currentTabName = 'Files'; // to check if a DOM element is in the viewport function isInViewport(elt) { - let elementTop = $(elt).offset().top; - let elementBottom = elementTop + $(elt).outerHeight(); + const elementTop = $(elt).offset().top; + const elementBottom = elementTop + $(elt).outerHeight(); - let viewportTop = $(window).scrollTop(); - let viewportBottom = viewportTop + $(window).height(); + const viewportTop = $(window).scrollTop(); + const viewportBottom = viewportTop + $(window).height(); return elementBottom > viewportTop && elementTop < viewportBottom; } // to format the diffs line numbers export function formatDiffLineNumbers(diffId, fromLine, toLine) { const maxNumberChars = diffMaxNumberChars[diffId]; const fromLineStr = toLnStr(fromLine); const toLineStr = toLnStr(toLine); let ret = ''; for (let i = 0; i < (maxNumberChars - fromLineStr.length); ++i) { ret += ' '; } ret += fromLineStr; ret += ' '; for (let i = 0; i < (maxNumberChars - toLineStr.length); ++i) { ret += ' '; } ret += toLineStr; return ret; } function parseDiffHunkRangeIfAny(lineText) { let baseFromLine, baseToLine; if (lineText.startsWith('@@')) { - let linesInfoRegExp = new RegExp(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$/gm); - let linesInfoRegExp2 = new RegExp(/^@@ -(\d+) \+(\d+),(\d+) @@$/gm); - let linesInfoRegExp3 = new RegExp(/^@@ -(\d+),(\d+) \+(\d+) @@$/gm); - let linesInfoRegExp4 = new RegExp(/^@@ -(\d+) \+(\d+) @@$/gm); - let linesInfo = linesInfoRegExp.exec(lineText); - let linesInfo2 = linesInfoRegExp2.exec(lineText); - let linesInfo3 = linesInfoRegExp3.exec(lineText); - let linesInfo4 = linesInfoRegExp4.exec(lineText); + const linesInfoRegExp = new RegExp(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$/gm); + const linesInfoRegExp2 = new RegExp(/^@@ -(\d+) \+(\d+),(\d+) @@$/gm); + const linesInfoRegExp3 = new RegExp(/^@@ -(\d+),(\d+) \+(\d+) @@$/gm); + const linesInfoRegExp4 = new RegExp(/^@@ -(\d+) \+(\d+) @@$/gm); + const linesInfo = linesInfoRegExp.exec(lineText); + const linesInfo2 = linesInfoRegExp2.exec(lineText); + const linesInfo3 = linesInfoRegExp3.exec(lineText); + const linesInfo4 = linesInfoRegExp4.exec(lineText); if (linesInfo) { baseFromLine = parseInt(linesInfo[1]) - 1; baseToLine = parseInt(linesInfo[3]) - 1; } else if (linesInfo2) { baseFromLine = parseInt(linesInfo2[1]) - 1; baseToLine = parseInt(linesInfo2[2]) - 1; } else if (linesInfo3) { baseFromLine = parseInt(linesInfo3[1]) - 1; baseToLine = parseInt(linesInfo3[3]) - 1; } else if (linesInfo4) { baseFromLine = parseInt(linesInfo4[1]) - 1; baseToLine = parseInt(linesInfo4[2]) - 1; } } if (baseFromLine !== undefined) { return [baseFromLine, baseToLine]; } else { return null; } } function toLnInt(lnStr) { return lnStr ? parseInt(lnStr) : 0; }; function toLnStr(lnInt) { return lnInt ? lnInt.toString() : ''; }; // parse diff line numbers to an int array [from, to] export function parseDiffLineNumbers(lineNumbersStr, from, to) { let lines; if (!from && !to) { lines = lineNumbersStr.replace(/[ ]+/g, ' ').split(' '); if (lines.length > 2) { lines.shift(); } lines = lines.map(x => toLnInt(x)); } else { - let lineNumber = toLnInt(lineNumbersStr.trim()); + const lineNumber = toLnInt(lineNumbersStr.trim()); if (from) { lines = [lineNumber, 0]; } else if (to) { lines = [0, lineNumber]; } } return lines; } // serialize selected line numbers range to string for URL fragment export function selectedDiffLinesToFragment(startLines, endLines, unified) { let selectedLinesFragment = ''; selectedLinesFragment += `F${startLines[0] || 0}`; selectedLinesFragment += `T${startLines[1] || 0}`; selectedLinesFragment += `-F${endLines[0] || 0}`; selectedLinesFragment += `T${endLines[1] || 0}`; if (unified) { selectedLinesFragment += '-unified'; } else { selectedLinesFragment += '-split'; } return selectedLinesFragment; } // parse selected lines from URL fragment export function fragmentToSelectedDiffLines(fragment) { const RE_LINES = /F([0-9]+)T([0-9]+)-F([0-9]+)T([0-9]+)-([a-z]+)/; const matchObj = RE_LINES.exec(fragment); if (matchObj.length === 6) { return { startLines: [parseInt(matchObj[1]), parseInt(matchObj[2])], endLines: [parseInt(matchObj[3]), parseInt(matchObj[4])], unified: matchObj[5] === 'unified' }; } else { return null; } } // function to highlight a single diff line function highlightDiffLine(diffId, i) { - let line = $(`#${diffId} .hljs-ln-line[data-line-number="${i}"]`); - let lineNumbers = $(`#${diffId} .hljs-ln-numbers[data-line-number="${i}"]`); + const line = $(`#${diffId} .hljs-ln-line[data-line-number="${i}"]`); + const lineNumbers = $(`#${diffId} .hljs-ln-numbers[data-line-number="${i}"]`); lineNumbers.css('color', 'black'); lineNumbers.css('font-weight', 'bold'); line.css('background-color', lineHighlightColor); line.css('mix-blend-mode', 'multiply'); return line; } // function to reset highlighting function resetHighlightedDiffLines(resetVars = true) { if (resetVars) { focusedDiff = null; startLines = null; endLines = null; } $('.hljs-ln-line[data-line-number]').css('background-color', 'initial'); $('.hljs-ln-line[data-line-number]').css('mix-blend-mode', 'initial'); $('.hljs-ln-numbers[data-line-number]').css('color', '#aaa'); $('.hljs-ln-numbers[data-line-number]').css('font-weight', 'initial'); if (currentTabName === 'Changes' && window.location.hash !== changesUrlFragment) { window.history.replaceState('', document.title, window.location.pathname + window.location.search + changesUrlFragment); } } // highlight lines in a diff, return first highlighted line numbers element function highlightDiffLines(diffId, startLines, endLines, unified) { let firstHighlightedLine; // unified diff case if (unified) { let start = formatDiffLineNumbers(diffId, startLines[0], startLines[1]); let end = formatDiffLineNumbers(diffId, endLines[0], endLines[1]); const startLine = $(`#${diffId} .hljs-ln-line[data-line-number="${start}"]`); const endLine = $(`#${diffId} .hljs-ln-line[data-line-number="${end}"]`); if ($(endLine).position().top < $(startLine).position().top) { [start, end] = [end, start]; firstHighlightedLine = endLine; } else { firstHighlightedLine = startLine; } const lineTd = highlightDiffLine(diffId, start); let tr = $(lineTd).closest('tr'); let lineNumbers = $(tr).children('.hljs-ln-line').data('line-number').toString(); while (lineNumbers !== end) { if (lineNumbers.trim()) { highlightDiffLine(diffId, lineNumbers); } tr = $(tr).next(); lineNumbers = $(tr).children('.hljs-ln-line').data('line-number').toString(); } highlightDiffLine(diffId, end); // split diff case } else { // highlight only from part of the diff if (startLines[0] && endLines[0]) { const start = Math.min(startLines[0], endLines[0]); const end = Math.max(startLines[0], endLines[0]); for (let i = start; i <= end; ++i) { highlightDiffLine(`${diffId}-from`, i); } firstHighlightedLine = $(`#${diffId}-from .hljs-ln-line[data-line-number="${start}"]`); // highlight only to part of the diff } else if (startLines[1] && endLines[1]) { const start = Math.min(startLines[1], endLines[1]); const end = Math.max(startLines[1], endLines[1]); for (let i = start; i <= end; ++i) { highlightDiffLine(`${diffId}-to`, i); } firstHighlightedLine = $(`#${diffId}-to .hljs-ln-line[data-line-number="${start}"]`); // highlight both part of the diff } else { let left, right; if (startLines[0] && endLines[1]) { left = startLines[0]; right = endLines[1]; } else { left = endLines[0]; right = startLines[1]; } const leftLine = $(`#${diffId}-from .hljs-ln-line[data-line-number="${left}"]`); const rightLine = $(`#${diffId}-to .hljs-ln-line[data-line-number="${right}"]`); const leftLineAbove = $(leftLine).position().top < $(rightLine).position().top; if (leftLineAbove) { firstHighlightedLine = leftLine; } else { firstHighlightedLine = rightLine; } let fromTr = $(`#${diffId}-from tr`).first(); let fromLn = $(fromTr).children('.hljs-ln-line').data('line-number'); let toTr = $(`#${diffId}-to tr`).first(); let toLn = $(toTr).children('.hljs-ln-line').data('line-number'); let canHighlight = false; while (true) { if (leftLineAbove && fromLn === left) { canHighlight = true; } else if (!leftLineAbove && toLn === right) { canHighlight = true; } if (canHighlight && fromLn) { highlightDiffLine(`${diffId}-from`, fromLn); } if (canHighlight && toLn) { highlightDiffLine(`${diffId}-to`, toLn); } if ((leftLineAbove && toLn === right) || (!leftLineAbove && fromLn === left)) { break; } fromTr = $(fromTr).next(); fromLn = $(fromTr).children('.hljs-ln-line').data('line-number'); toTr = $(toTr).next(); toLn = $(toTr).children('.hljs-ln-line').data('line-number'); } } } - let selectedLinesFragment = selectedDiffLinesToFragment(startLines, endLines, unified); + const selectedLinesFragment = selectedDiffLinesToFragment(startLines, endLines, unified); window.location.hash = `diff_${diffId}+${selectedLinesFragment}`; return firstHighlightedLine; } // callback to switch from side-by-side diff to unified one export function showUnifiedDiff(diffId) { $(`#${diffId}-split-diff`).css('display', 'none'); $(`#${diffId}-unified-diff`).css('display', 'block'); } // callback to switch from unified diff to side-by-side one export function showSplitDiff(diffId) { $(`#${diffId}-unified-diff`).css('display', 'none'); $(`#${diffId}-split-diff`).css('display', 'block'); } // to compute diff and process it for display export async function computeDiff(diffUrl, diffId) { // force diff computation ? - let force = diffUrl.indexOf('force=true') !== -1; + const force = diffUrl.indexOf('force=true') !== -1; // it no forced computation and diff already computed, do nothing if (!force && computedDiffs.hasOwnProperty(diffId)) { return; } function setLineNumbers(lnElt, lineNumbers) { $(lnElt).attr('data-line-number', lineNumbers || ''); $(lnElt).children().attr('data-line-number', lineNumbers || ''); $(lnElt).siblings().attr('data-line-number', lineNumbers || ''); } // mark diff computation as already requested computedDiffs[diffId] = true; $(`#${diffId}-loading`).css('visibility', 'visible'); // set spinner visible while requesting diff $(`#${diffId}-loading`).css('display', 'block'); $(`#${diffId}-highlightjs`).css('display', 'none'); // request diff computation and process it const response = await fetch(diffUrl); const data = await response.json(); // increment number of computed diffs ++nbDiffsComputed; // toggle the 'Compute all diffs' button if all diffs have been computed if (nbDiffsComputed === changes.length) { $('#swh-compute-all-diffs').addClass('active'); } // Large diff (> threshold) are not automatically computed, // add a button to force its computation if (data.diff_str.indexOf('Large diff') === 0) { $(`#${diffId}`)[0].innerHTML = data.diff_str + `<br/><button class="btn btn-default btn-sm" type="button" onclick="swh.revision.computeDiff('${diffUrl}&force=true', '${diffId}')">` + 'Request diff</button>'; setDiffVisible(diffId); } else if (data.diff_str.indexOf('@@') !== 0) { $(`#${diffId}`).text(data.diff_str); setDiffVisible(diffId); } else { // prepare code highlighting $(`.${diffId}`).removeClass('nohighlight'); $(`.${diffId}`).addClass(data.language); // set unified diff text $(`#${diffId}`).text(data.diff_str); // code highlighting for unified diff $(`#${diffId}`).each((i, elt) => { hljs.highlightElement(elt); hljs.lineNumbersElementSync(elt); }); // process unified diff lines in order to generate side-by-side diffs text // but also compute line numbers for unified and side-by-side diffs let baseFromLine = ''; let baseToLine = ''; - let fromToLines = []; - let fromLines = []; - let toLines = []; + const fromToLines = []; + const fromLines = []; + const toLines = []; let maxNumberChars = 0; let diffFromStr = ''; let diffToStr = ''; let linesOffset = 0; $(`#${diffId} .hljs-ln-numbers`).each((i, lnElt) => { - let lnText = lnElt.nextSibling.innerText; - let linesInfo = parseDiffHunkRangeIfAny(lnText); + const lnText = lnElt.nextSibling.innerText; + const linesInfo = parseDiffHunkRangeIfAny(lnText); let fromLine = ''; let toLine = ''; // parsed lines info from the diff output if (linesInfo) { baseFromLine = linesInfo[0]; baseToLine = linesInfo[1]; linesOffset = 0; diffFromStr += (lnText + '\n'); diffToStr += (lnText + '\n'); fromLines.push(''); toLines.push(''); // line removed in the from file } else if (lnText.length > 0 && lnText[0] === '-') { baseFromLine = baseFromLine + 1; fromLine = baseFromLine.toString(); fromLines.push(fromLine); ++nbDeletions; diffFromStr += (lnText + '\n'); ++linesOffset; // line added in the to file } else if (lnText.length > 0 && lnText[0] === '+') { baseToLine = baseToLine + 1; toLine = baseToLine.toString(); toLines.push(toLine); ++nbAdditions; diffToStr += (lnText + '\n'); --linesOffset; // line present in both files } else { baseFromLine = baseFromLine + 1; baseToLine = baseToLine + 1; fromLine = baseFromLine.toString(); toLine = baseToLine.toString(); for (let j = 0; j < Math.abs(linesOffset); ++j) { if (linesOffset > 0) { diffToStr += '\n'; toLines.push(''); } else { diffFromStr += '\n'; fromLines.push(''); } } linesOffset = 0; diffFromStr += (lnText + '\n'); diffToStr += (lnText + '\n'); toLines.push(toLine); fromLines.push(fromLine); } if (!baseFromLine) { fromLine = ''; } if (!baseToLine) { toLine = ''; } fromToLines[i] = [fromLine, toLine]; maxNumberChars = Math.max(maxNumberChars, fromLine.length); maxNumberChars = Math.max(maxNumberChars, toLine.length); }); diffMaxNumberChars[diffId] = maxNumberChars; // set side-by-side diffs text $(`#${diffId}-from`).text(diffFromStr); $(`#${diffId}-to`).text(diffToStr); // code highlighting for side-by-side diffs $(`#${diffId}-from, #${diffId}-to`).each((i, elt) => { hljs.highlightElement(elt); hljs.lineNumbersElementSync(elt); }); // diff highlighting for added/removed lines on top of code highlighting $(`.${diffId} .hljs-ln-numbers`).each((i, lnElt) => { - let lnText = lnElt.nextSibling.innerText; + const lnText = lnElt.nextSibling.innerText; if (lnText.startsWith('@@')) { $(lnElt).parent().addClass('swh-diff-lines-info'); - let linesInfoText = $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').text(); + const linesInfoText = $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').text(); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').children().remove(); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').text(''); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').append(`<span class="hljs-meta">${linesInfoText}</span>`); } else if (lnText.length > 0 && lnText[0] === '-') { $(lnElt).parent().addClass('swh-diff-removed-line'); } else if (lnText.length > 0 && lnText[0] === '+') { $(lnElt).parent().addClass('swh-diff-added-line'); } }); // set line numbers for unified diff $(`#${diffId} .hljs-ln-numbers`).each((i, lnElt) => { const lineNumbers = formatDiffLineNumbers(diffId, fromToLines[i][0], fromToLines[i][1]); setLineNumbers(lnElt, lineNumbers); }); // set line numbers for the from side-by-side diff $(`#${diffId}-from .hljs-ln-numbers`).each((i, lnElt) => { setLineNumbers(lnElt, fromLines[i]); }); // set line numbers for the to side-by-side diff $(`#${diffId}-to .hljs-ln-numbers`).each((i, lnElt) => { setLineNumbers(lnElt, toLines[i]); }); // last processing: // - remove the '+' and '-' at the beginning of the diff lines // from code highlighting // - add the "no new line at end of file marker" if needed $(`.${diffId} .hljs-ln-code`).each((i, lnElt) => { if (lnElt.firstChild) { if (lnElt.firstChild.nodeName !== '#text') { - let lineText = lnElt.firstChild.innerHTML; + const lineText = lnElt.firstChild.innerHTML; if (lineText[0] === '-' || lineText[0] === '+') { lnElt.firstChild.innerHTML = lineText.substr(1); - let newTextNode = document.createTextNode(lineText[0]); + const newTextNode = document.createTextNode(lineText[0]); $(lnElt).prepend(newTextNode); } } $(lnElt).contents().filter((i, elt) => { return elt.nodeType === 3; // Node.TEXT_NODE }).each((i, textNode) => { - let swhNoNewLineMarker = '[swh-no-nl-marker]'; + const swhNoNewLineMarker = '[swh-no-nl-marker]'; if (textNode.textContent.indexOf(swhNoNewLineMarker) !== -1) { textNode.textContent = textNode.textContent.replace(swhNoNewLineMarker, ''); $(lnElt).append($(noNewLineMarker)); } }); } }); // hide the diff mode switch button in case of not generated diffs if (data.diff_str.indexOf('Diffs are not generated for non textual content') !== 0) { $(`#diff_${diffId} .diff-styles`).css('visibility', 'visible'); } setDiffVisible(diffId); // highlight diff lines if provided in URL fragment if (selectedDiffLinesInfo && selectedDiffLinesInfo.diffPanelId.indexOf(diffId) !== -1) { if (!selectedDiffLinesInfo.unified) { showSplitDiff(diffId); } const firstHighlightedLine = highlightDiffLines( diffId, selectedDiffLinesInfo.startLines, selectedDiffLinesInfo.endLines, selectedDiffLinesInfo.unified); $('html, body').animate( { scrollTop: firstHighlightedLine.offset().top - 50 }, { duration: 500 } ); } } } function setDiffVisible(diffId) { // set the unified diff visible by default $(`#${diffId}-loading`).css('display', 'none'); $(`#${diffId}-highlightjs`).css('display', 'block'); // update displayed counters $('#swh-revision-lines-added').text(`${nbAdditions} additions`); $('#swh-revision-lines-deleted').text(`${nbDeletions} deletions`); $('#swh-nb-diffs-computed').text(nbDiffsComputed); // refresh the waypoints triggering diffs computation as // the DOM layout has been updated Waypoint.refreshAll(); } // to compute all visible diffs in the viewport function computeVisibleDiffs() { $('.swh-file-diff-panel').each((i, elt) => { if (isInViewport(elt)) { - let diffId = elt.id.replace('diff_', ''); + const diffId = elt.id.replace('diff_', ''); computeDiff(diffsUrls[diffId], diffId); } }); } function genDiffPanel(diffData) { let diffPanelTitle = diffData.path; if (diffData.type === 'rename') { diffPanelTitle = `${diffData.from_path} → ${diffData.to_path}`; } return diffPanelTemplate({ diffData: diffData, diffPanelTitle: diffPanelTitle, swhSpinnerSrc: swhSpinnerSrc }); } // setup waypoints to request diffs computation on the fly while scrolling function setupWaypoints() { for (let i = 0; i < changes.length; ++i) { - let diffData = changes[i]; + const diffData = changes[i]; // create a waypoint that will trigger diff computation when // the top of the diff panel hits the bottom of the viewport $(`#diff_${diffData.id}`).waypoint({ handler: function() { if (isInViewport(this.element)) { - let diffId = this.element.id.replace('diff_', ''); + const diffId = this.element.id.replace('diff_', ''); computeDiff(diffsUrls[diffId], diffId); this.destroy(); } }, offset: '100%' }); // create a waypoint that will trigger diff computation when // the bottom of the diff panel hits the top of the viewport $(`#diff_${diffData.id}`).waypoint({ handler: function() { if (isInViewport(this.element)) { - let diffId = this.element.id.replace('diff_', ''); + const diffId = this.element.id.replace('diff_', ''); computeDiff(diffsUrls[diffId], diffId); this.destroy(); } }, offset: function() { return -$(this.element).height(); } }); } Waypoint.refreshAll(); } function scrollToDiffPanel(diffPanelId, setHash = true) { // disable waypoints while scrolling as we do not want to // launch computation of diffs the user is not interested in // (file changes list can be large) Waypoint.disableAll(); $('html, body').animate( { scrollTop: $(diffPanelId).offset().top }, { duration: 500, complete: () => { if (setHash) { window.location.hash = diffPanelId; } // enable waypoints back after scrolling Waypoint.enableAll(); // compute diffs visible in the viewport computeVisibleDiffs(); } }); } // callback when the user clicks on the 'Compute all diffs' button export function computeAllDiffs(event) { $(event.currentTarget).addClass('active'); - for (let diffId in diffsUrls) { + for (const diffId in diffsUrls) { if (diffsUrls.hasOwnProperty(diffId)) { computeDiff(diffsUrls[diffId], diffId); } } event.stopPropagation(); } export async function initRevisionDiff(revisionMessageBody, diffRevisionUrl) { await import(/* webpackChunkName: "highlightjs" */ 'utils/highlightjs'); // callback when the 'Changes' tab is activated $(document).on('shown.bs.tab', 'a[data-toggle="tab"]', async e => { currentTabName = e.currentTarget.text.trim(); if (currentTabName === 'Changes') { window.location.hash = changesUrlFragment; $('#readme-panel').css('display', 'none'); if (changes) { return; } // request computation of revision file changes list // when navigating to the 'Changes' tab and add diff panels // to the DOM when receiving the result const response = await fetch(diffRevisionUrl); const data = await response.json(); changes = data.changes; nbChangedFiles = data.total_nb_changes; let changedFilesText = `${nbChangedFiles} changed file`; if (nbChangedFiles !== 1) { changedFilesText += 's'; } $('#swh-revision-changed-files').text(changedFilesText); $('#swh-total-nb-diffs').text(changes.length); $('#swh-revision-changes-list pre')[0].innerHTML = data.changes_msg; $('#swh-revision-changes-loading').css('display', 'none'); $('#swh-revision-changes-list pre').css('display', 'block'); $('#swh-compute-all-diffs').css('visibility', 'visible'); $('#swh-revision-changes-list').removeClass('in'); if (nbChangedFiles > changes.length) { $('#swh-too-large-revision-diff').css('display', 'block'); $('#swh-nb-loaded-diffs').text(changes.length); } for (let i = 0; i < changes.length; ++i) { - let diffData = changes[i]; + const diffData = changes[i]; diffsUrls[diffData.id] = diffData.diff_url; $('#swh-revision-diffs').append(genDiffPanel(diffData)); } setupWaypoints(); computeVisibleDiffs(); if (selectedDiffLinesInfo) { scrollToDiffPanel(selectedDiffLinesInfo.diffPanelId, false); } } else if (currentTabName === 'Files') { removeUrlFragment(); $('#readme-panel').css('display', 'block'); } }); $(document).ready(() => { if (revisionMessageBody.length > 0) { $('#swh-revision-message').addClass('in'); } else { $('#swh-collapse-revision-message').attr('data-toggle', ''); } // callback when the user requests to scroll on a specific diff or back to top $('#swh-revision-changes-list a[href^="#"], #back-to-top a[href^="#"]').click(e => { - let href = $.attr(e.currentTarget, 'href'); + const href = $.attr(e.currentTarget, 'href'); scrollToDiffPanel(href); return false; }); // click callback for highlighting diff lines $('body').click(evt => { if (currentTabName !== 'Changes') { return; } if (evt.target.classList.contains('hljs-ln-n')) { const diffId = $(evt.target).closest('code').prop('id'); const from = diffId.indexOf('-from') !== -1; const to = diffId.indexOf('-to') !== -1; const lineNumbers = $(evt.target).data('line-number').toString(); const currentDiff = diffId.replace('-from', '').replace('-to', ''); if (!evt.shiftKey || currentDiff !== focusedDiff || !lineNumbers.trim()) { resetHighlightedDiffLines(); focusedDiff = currentDiff; } if (currentDiff === focusedDiff && lineNumbers.trim()) { if (!evt.shiftKey) { startLines = parseDiffLineNumbers(lineNumbers, from, to); highlightDiffLines(currentDiff, startLines, startLines, !from && !to); } else if (startLines) { resetHighlightedDiffLines(false); endLines = parseDiffLineNumbers(lineNumbers, from, to); highlightDiffLines(currentDiff, startLines, endLines, !from && !to); } } } else { resetHighlightedDiffLines(); } }); // if an URL fragment for highlighting a diff is present // parse highlighting info and initiate diff loading const fragment = window.location.hash; if (fragment) { const split = fragment.split('+'); if (split.length === 2) { selectedDiffLinesInfo = fragmentToSelectedDiffLines(split[1]); if (selectedDiffLinesInfo) { selectedDiffLinesInfo.diffPanelId = split[0]; $(`.nav-tabs a[href="${changesUrlFragment}"]`).tab('show'); } } if (fragment === changesUrlFragment) { $(`.nav-tabs a[href="${changesUrlFragment}"]`).tab('show'); } } }); } diff --git a/assets/src/bundles/revision/log-utils.js b/assets/src/bundles/revision/log-utils.js index a2b06b95..3ec8566e 100644 --- a/assets/src/bundles/revision/log-utils.js +++ b/assets/src/bundles/revision/log-utils.js @@ -1,27 +1,27 @@ /** * Copyright (C) 2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ export function revsOrderingTypeClicked(event) { - let urlParams = new URLSearchParams(window.location.search); - let orderingType = $(event.target).val(); + const urlParams = new URLSearchParams(window.location.search); + const orderingType = $(event.target).val(); if (orderingType) { urlParams.set('revs_ordering', $(event.target).val()); } else if (urlParams.has('revs_ordering')) { urlParams.delete('revs_ordering'); } window.location.search = urlParams.toString(); } export function initRevisionsLog() { $(document).ready(() => { - let urlParams = new URLSearchParams(window.location.search); - let revsOrderingType = urlParams.get('revs_ordering'); + const urlParams = new URLSearchParams(window.location.search); + const revsOrderingType = urlParams.get('revs_ordering'); if (revsOrderingType) { $(`:input[value="${revsOrderingType}"]`).prop('checked', true); } }); } diff --git a/assets/src/bundles/save/index.js b/assets/src/bundles/save/index.js index e9c9afc8..0a34d78f 100644 --- a/assets/src/bundles/save/index.js +++ b/assets/src/bundles/save/index.js @@ -1,566 +1,566 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {csrfPost, handleFetchError, isGitRepoUrl, htmlAlert, removeUrlFragment, getCanonicalOriginURL} from 'utils/functions'; import {swhSpinnerSrc} from 'utils/constants'; import artifactFormRowTemplate from './artifact-form-row.ejs'; let saveRequestsTable; async function originSaveRequest( originType, originUrl, extraData, acceptedCallback, pendingCallback, errorCallback ) { // Actually trigger the origin save request - let addSaveOriginRequestUrl = Urls.api_1_save_origin(originType, originUrl); + const addSaveOriginRequestUrl = Urls.api_1_save_origin(originType, originUrl); $('.swh-processing-save-request').css('display', 'block'); let headers = {}; let body = null; if (extraData !== {}) { body = JSON.stringify(extraData); headers = { 'Content-Type': 'application/json' }; }; try { const response = await csrfPost(addSaveOriginRequestUrl, headers, body); handleFetchError(response); const data = await response.json(); $('.swh-processing-save-request').css('display', 'none'); if (data.save_request_status === 'accepted') { acceptedCallback(); } else { pendingCallback(); } } catch (response) { $('.swh-processing-save-request').css('display', 'none'); const errorData = await response.json(); errorCallback(response.status, errorData); }; } function addArtifactVersionAutofillHandler(formId) { // autofill artifact version input with the filename from // the artifact url without extensions $(`#swh-input-artifact-url-${formId}`).on('input', function(event) { const artifactUrl = $(this).val().trim(); let filename = artifactUrl.split('/').slice(-1)[0]; if (filename !== artifactUrl) { filename = filename.replace(/tar.*$/, 'tar'); const filenameNoExt = filename.split('.').slice(0, -1).join('.'); const artifactVersion = $(`#swh-input-artifact-version-${formId}`); if (filenameNoExt !== filename) { artifactVersion.val(filenameNoExt); } } }); } export function maybeRequireExtraInputs() { // Read the actual selected value and depending on the origin type, display some extra // inputs or hide them. This makes the extra inputs disabled when not displayed. const originType = $('#swh-input-visit-type').val(); let display = 'none'; let disabled = true; if (originType === 'archives') { display = 'flex'; disabled = false; } $('.swh-save-origin-archives-form').css('display', display); if (!disabled) { // help paragraph must have block display for proper rendering $('#swh-save-origin-archives-help').css('display', 'block'); } $('.swh-save-origin-archives-form .form-control').prop('disabled', disabled); if (originType === 'archives' && $('.swh-save-origin-archives-form').length === 1) { // insert first artifact row when the archives visit type is selected for the first time $('.swh-save-origin-archives-form').last().after( artifactFormRowTemplate({deletableRow: false, formId: 0})); addArtifactVersionAutofillHandler(0); } } export function addArtifactFormRow() { const formId = $('.swh-save-origin-artifact-form').length; $('.swh-save-origin-artifact-form').last().after( artifactFormRowTemplate({ deletableRow: true, formId: formId }) ); addArtifactVersionAutofillHandler(formId); } export function deleteArtifactFormRow(event) { $(event.target).closest('.swh-save-origin-artifact-form').remove(); } const userRequestsFilterCheckbox = ` <div class="custom-control custom-checkbox swhid-option"> <input class="custom-control-input" value="option-user-requests-filter" type="checkbox" id="swh-save-requests-user-filter"> <label class="custom-control-label font-weight-normal" for="swh-save-requests-user-filter"> show only your own requests </label> </div> `; export function initOriginSave() { $(document).ready(async() => { $.fn.dataTable.ext.errMode = 'none'; const response = await fetch(Urls.origin_save_types_list()); const data = await response.json(); - for (let originType of data) { + for (const originType of data) { $('#swh-input-visit-type').append(`<option value="${originType}">${originType}</option>`); } // set git as the default value as before $('#swh-input-visit-type').val('git'); saveRequestsTable = $('#swh-origin-save-requests') .on('error.dt', (e, settings, techNote, message) => { $('#swh-origin-save-request-list-error').text('An error occurred while retrieving the save requests list'); console.log(message); }) .DataTable({ serverSide: true, processing: true, language: { processing: `<img src="${swhSpinnerSrc}"></img>` }, ajax: { url: Urls.origin_save_requests_list('all'), data: (d) => { if (swh.webapp.isUserLoggedIn() && $('#swh-save-requests-user-filter').prop('checked')) { d.user_requests_only = '1'; } } }, searchDelay: 1000, // see https://datatables.net/examples/advanced_init/dom_toolbar.html and the comments section // this option customizes datatables UI components by adding an extra checkbox above the table // while keeping bootstrap layout dom: '<"row"<"col-sm-3"l><"col-sm-6 text-left user-requests-filter"><"col-sm-3"f>>' + '<"row"<"col-sm-12"tr>>' + '<"row"<"col-sm-5"i><"col-sm-7"p>>', fnInitComplete: function() { if (swh.webapp.isUserLoggedIn()) { $('div.user-requests-filter').html(userRequestsFilterCheckbox); $('#swh-save-requests-user-filter').on('change', () => { saveRequestsTable.draw(); }); } }, columns: [ { data: 'save_request_date', name: 'request_date', render: (data, type, row) => { if (type === 'display') { - let date = new Date(data); + const date = new Date(data); return date.toLocaleString(); } return data; } }, { data: 'visit_type', name: 'visit_type' }, { data: 'origin_url', name: 'origin_url', render: (data, type, row) => { if (type === 'display') { let html = ''; const sanitizedURL = $.fn.dataTable.render.text().display(data); if (row.save_task_status === 'succeeded') { let browseOriginUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(sanitizedURL)}`; if (row.visit_date) { browseOriginUrl += `&timestamp=${encodeURIComponent(row.visit_date)}`; } html += `<a href="${browseOriginUrl}">${sanitizedURL}</a>`; } else { html += sanitizedURL; } html += ` <a href="${sanitizedURL}"><i class="mdi mdi-open-in-new" aria-hidden="true"></i></a>`; return html; } return data; } }, { data: 'save_request_status', name: 'status' }, { data: 'save_task_status', name: 'loading_task_status' }, { name: 'info', render: (data, type, row) => { if (row.save_task_status === 'succeeded' || row.save_task_status === 'failed') { return `<i class="mdi mdi-information-outline swh-save-request-info" ` + 'aria-hidden="true" style="cursor: pointer"' + `onclick="swh.save.displaySaveRequestInfo(event, ${row.id})"></i>`; } else { return ''; } } }, { render: (data, type, row) => { if (row.save_request_status === 'accepted') { const saveAgainButton = '<button class="btn btn-default btn-sm swh-save-origin-again" type="button" ' + `onclick="swh.save.fillSaveRequestFormAndScroll(` + `'${row.visit_type}', '${row.origin_url}');">` + '<i class="mdi mdi-camera mdi-fw" aria-hidden="true"></i>' + 'Save again</button>'; return saveAgainButton; } else { return ''; } } } ], scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); swh.webapp.addJumpToPagePopoverToDataTable(saveRequestsTable); $('#swh-origin-save-requests-list-tab').on('shown.bs.tab', () => { saveRequestsTable.draw(); window.location.hash = '#requests'; }); $('#swh-origin-save-request-help-tab').on('shown.bs.tab', () => { removeUrlFragment(); $('.swh-save-request-info').popover('dispose'); }); - let saveRequestAcceptedAlert = htmlAlert( + const saveRequestAcceptedAlert = htmlAlert( 'success', 'The "save code now" request has been accepted and will be processed as soon as possible.', true ); - let saveRequestPendingAlert = htmlAlert( + const saveRequestPendingAlert = htmlAlert( 'warning', 'The "save code now" request has been put in pending state and may be accepted for processing after manual review.', true ); - let saveRequestRateLimitedAlert = htmlAlert( + const saveRequestRateLimitedAlert = htmlAlert( 'danger', 'The rate limit for "save code now" requests has been reached. Please try again later.', true ); - let saveRequestUnknownErrorAlert = htmlAlert( + const saveRequestUnknownErrorAlert = htmlAlert( 'danger', 'An unexpected error happened when submitting the "save code now request".', true ); $('#swh-save-origin-form').submit(async event => { event.preventDefault(); event.stopPropagation(); $('.alert').alert('close'); if (event.target.checkValidity()) { $(event.target).removeClass('was-validated'); - let originType = $('#swh-input-visit-type').val(); + const originType = $('#swh-input-visit-type').val(); let originUrl = $('#swh-input-origin-url').val(); originUrl = await getCanonicalOriginURL(originUrl); // read the extra inputs for the 'archives' type - let extraData = {}; + const extraData = {}; if (originType === 'archives') { extraData['archives_data'] = []; for (let i = 0; i < $('.swh-save-origin-artifact-form').length; ++i) { extraData['archives_data'].push({ 'artifact_url': $(`#swh-input-artifact-url-${i}`).val(), 'artifact_version': $(`#swh-input-artifact-version-${i}`).val() }); } } originSaveRequest(originType, originUrl, extraData, () => $('#swh-origin-save-request-status').html(saveRequestAcceptedAlert), () => $('#swh-origin-save-request-status').html(saveRequestPendingAlert), (statusCode, errorData) => { $('#swh-origin-save-request-status').css('color', 'red'); if (statusCode === 403) { const errorAlert = htmlAlert('danger', `Error: ${errorData['reason']}`); $('#swh-origin-save-request-status').html(errorAlert); } else if (statusCode === 429) { $('#swh-origin-save-request-status').html(saveRequestRateLimitedAlert); } else if (statusCode === 400) { const errorAlert = htmlAlert('danger', errorData['reason']); $('#swh-origin-save-request-status').html(errorAlert); } else { $('#swh-origin-save-request-status').html(saveRequestUnknownErrorAlert); } }); } else { $(event.target).addClass('was-validated'); } }); $('#swh-show-origin-save-requests-list').on('click', (event) => { event.preventDefault(); $('.nav-tabs a[href="#swh-origin-save-requests-list"]').tab('show'); }); $('#swh-input-origin-url').on('input', function(event) { - let originUrl = $(this).val().trim(); + const originUrl = $(this).val().trim(); $(this).val(originUrl); $('#swh-input-visit-type option').each(function() { - let val = $(this).val(); + const val = $(this).val(); if (val && originUrl.includes(val)) { $(this).prop('selected', true); } }); }); if (window.location.hash === '#requests') { $('.nav-tabs a[href="#swh-origin-save-requests-list"]').tab('show'); } }); } export function validateSaveOriginUrl(input) { - let originType = $('#swh-input-visit-type').val(); + const originType = $('#swh-input-visit-type').val(); let originUrl = null; let validUrl = true; try { originUrl = new URL(input.value.trim()); } catch (TypeError) { validUrl = false; } if (validUrl) { - let allowedProtocols = ['http:', 'https:', 'svn:', 'git:']; + const allowedProtocols = ['http:', 'https:', 'svn:', 'git:']; validUrl = ( allowedProtocols.find(protocol => protocol === originUrl.protocol) !== undefined ); } if (validUrl && originType === 'git') { // additional checks for well known code hosting providers switch (originUrl.hostname) { case 'github.com': validUrl = isGitRepoUrl(originUrl); break; case 'git.code.sf.net': validUrl = isGitRepoUrl(originUrl, '/p/'); break; case 'bitbucket.org': validUrl = isGitRepoUrl(originUrl); break; default: if (originUrl.hostname.startsWith('gitlab.')) { validUrl = isGitRepoUrl(originUrl); } break; } } if (validUrl) { input.setCustomValidity(''); } else { input.setCustomValidity('The origin url is not valid or does not reference a code repository'); } } export function initTakeNewSnapshot() { - let newSnapshotRequestAcceptedAlert = htmlAlert( + const newSnapshotRequestAcceptedAlert = htmlAlert( 'success', 'The "take new snapshot" request has been accepted and will be processed as soon as possible.', true ); - let newSnapshotRequestPendingAlert = htmlAlert( + const newSnapshotRequestPendingAlert = htmlAlert( 'warning', 'The "take new snapshot" request has been put in pending state and may be accepted for processing after manual review.', true ); - let newSnapshotRequestRateLimitAlert = htmlAlert( + const newSnapshotRequestRateLimitAlert = htmlAlert( 'danger', 'The rate limit for "take new snapshot" requests has been reached. Please try again later.', true ); - let newSnapshotRequestUnknownErrorAlert = htmlAlert( + const newSnapshotRequestUnknownErrorAlert = htmlAlert( 'danger', 'An unexpected error happened when submitting the "save code now request".', true ); $(document).ready(() => { $('#swh-take-new-snapshot-form').submit(event => { event.preventDefault(); event.stopPropagation(); - let originType = $('#swh-input-visit-type').val(); - let originUrl = $('#swh-input-origin-url').val(); - let extraData = {}; + const originType = $('#swh-input-visit-type').val(); + const originUrl = $('#swh-input-origin-url').val(); + const extraData = {}; originSaveRequest(originType, originUrl, extraData, () => $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestAcceptedAlert), () => $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestPendingAlert), (statusCode, errorData) => { $('#swh-take-new-snapshot-request-status').css('color', 'red'); if (statusCode === 403) { const errorAlert = htmlAlert('danger', `Error: ${errorData['detail']}`, true); $('#swh-take-new-snapshot-request-status').html(errorAlert); } else if (statusCode === 429) { $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestRateLimitAlert); } else { $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestUnknownErrorAlert); } }); }); }); } export function formatValuePerType(type, value) { // Given some typed value, format and return accordingly formatted value const mapFormatPerTypeFn = { 'json': (v) => JSON.stringify(v, null, 2), 'date': (v) => new Date(v).toLocaleString(), 'raw': (v) => v, 'duration': (v) => v + ' seconds' }; return value === null ? null : mapFormatPerTypeFn[type](value); } export async function displaySaveRequestInfo(event, saveRequestId) { event.stopPropagation(); const saveRequestTaskInfoUrl = Urls.origin_save_task_info(saveRequestId); // close popover when clicking again on the info icon if ($(event.target).data('bs.popover')) { $(event.target).popover('dispose'); return; } $('.swh-save-request-info').popover('dispose'); $(event.target).popover({ animation: false, boundary: 'viewport', container: 'body', title: 'Save request task information ' + '<i style="cursor: pointer; position: absolute; right: 1rem;" ' + `class="mdi mdi-close swh-save-request-info-close"></i>`, content: `<div class="swh-popover swh-save-request-info-popover"> <div class="text-center"> <img src=${swhSpinnerSrc}></img> <p>Fetching task information ...</p> </div> </div>`, html: true, placement: 'left', sanitizeFn: swh.webapp.filterXSS }); $(event.target).on('shown.bs.popover', function() { const popoverId = $(this).attr('aria-describedby'); $(`#${popoverId} .mdi-close`).click(() => { $(this).popover('dispose'); }); }); $(event.target).popover('show'); const response = await fetch(saveRequestTaskInfoUrl); const saveRequestTaskInfo = await response.json(); let content; if ($.isEmptyObject(saveRequestTaskInfo)) { content = 'Not available'; } else { - let saveRequestInfo = []; + const saveRequestInfo = []; const taskData = { 'Type': ['raw', 'type'], 'Visit status': ['raw', 'visit_status'], 'Arguments': ['json', 'arguments'], 'Id': ['raw', 'id'], 'Backend id': ['raw', 'backend_id'], 'Scheduling date': ['date', 'scheduled'], 'Start date': ['date', 'started'], 'Completion date': ['date', 'ended'], 'Duration': ['duration', 'duration'], 'Runner': ['raw', 'worker'], 'Log': ['raw', 'message'] }; for (const [title, [type, property]] of Object.entries(taskData)) { if (saveRequestTaskInfo.hasOwnProperty(property)) { saveRequestInfo.push({ key: title, value: formatValuePerType(type, saveRequestTaskInfo[property]) }); } } content = '<table class="table"><tbody>'; - for (let info of saveRequestInfo) { + for (const info of saveRequestInfo) { content += `<tr> <th class="swh-metadata-table-row swh-metadata-table-key">${info.key}</th> <td class="swh-metadata-table-row swh-metadata-table-value"> <pre>${info.value}</pre> </td> </tr>`; } content += '</tbody></table>'; } $('.swh-popover').html(content); $(event.target).popover('update'); } export function fillSaveRequestFormAndScroll(visitType, originUrl) { $('#swh-input-origin-url').val(originUrl); let originTypeFound = false; $('#swh-input-visit-type option').each(function() { - let val = $(this).val(); + const val = $(this).val(); if (val && originUrl.includes(val)) { $(this).prop('selected', true); originTypeFound = true; } }); if (!originTypeFound) { $('#swh-input-visit-type option').each(function() { - let val = $(this).val(); + const val = $(this).val(); if (val === visitType) { $(this).prop('selected', true); } }); } window.scrollTo(0, 0); } diff --git a/assets/src/bundles/vault/vault-create-tasks.js b/assets/src/bundles/vault/vault-create-tasks.js index c043a90e..e1aa4062 100644 --- a/assets/src/bundles/vault/vault-create-tasks.js +++ b/assets/src/bundles/vault/vault-create-tasks.js @@ -1,155 +1,155 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, csrfPost, htmlAlert} from 'utils/functions'; const alertStyle = { 'position': 'fixed', 'left': '1rem', 'bottom': '1rem', 'z-index': '100000' }; export async function vaultRequest(objectType, objectId) { let vaultUrl; if (objectType === 'directory') { vaultUrl = Urls.api_1_vault_cook_directory(objectId); } else { vaultUrl = Urls.api_1_vault_cook_revision_gitfast(objectId); } // check if object has already been cooked const response = await fetch(vaultUrl); const data = await response.json(); // object needs to be cooked if (data.exception === 'NotFoundExc' || data.status === 'failed') { // if last cooking has failed, remove previous task info from localStorage // in order to force the recooking of the object swh.vault.removeCookingTaskInfo([objectId]); $(`#vault-cook-${objectType}-modal`).modal('show'); // object has been cooked and should be in the vault cache, // it will be asked to cook it again if it is not } else if (data.status === 'done') { $(`#vault-fetch-${objectType}-modal`).modal('show'); } else { const cookingServiceDownAlert = $(htmlAlert('danger', 'Archive cooking service is currently experiencing issues.<br/>' + 'Please try again later.', true)); cookingServiceDownAlert.css(alertStyle); $('body').append(cookingServiceDownAlert); } } async function addVaultCookingTask(cookingTask) { const swhidsContext = swh.webapp.getSwhIdsContext(); cookingTask.origin = swhidsContext[cookingTask.object_type].context.origin; cookingTask.path = swhidsContext[cookingTask.object_type].context.path; cookingTask.browse_url = swhidsContext[cookingTask.object_type].swhid_with_context_url; if (!cookingTask.browse_url) { cookingTask.browse_url = swhidsContext[cookingTask.object_type].swhid_url; } let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); if (!vaultCookingTasks) { vaultCookingTasks = []; } if (vaultCookingTasks.find(val => { return val.object_type === cookingTask.object_type && val.object_id === cookingTask.object_id; }) === undefined) { let cookingUrl; if (cookingTask.object_type === 'directory') { cookingUrl = Urls.api_1_vault_cook_directory(cookingTask.object_id); } else { cookingUrl = Urls.api_1_vault_cook_revision_gitfast(cookingTask.object_id); } if (cookingTask.email) { cookingUrl += '?email=' + cookingTask.email; } try { const response = await csrfPost(cookingUrl); handleFetchError(response); vaultCookingTasks.push(cookingTask); localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); $('#vault-cook-directory-modal').modal('hide'); $('#vault-cook-revision-modal').modal('hide'); const cookingTaskCreatedAlert = $(htmlAlert('success', 'Archive cooking request successfully submitted.<br/>' + `Go to the <a href="${Urls.browse_vault()}">Downloads</a> page ` + 'to get the download link once it is ready.', true)); cookingTaskCreatedAlert.css(alertStyle); $('body').append(cookingTaskCreatedAlert); } catch (_) { $('#vault-cook-directory-modal').modal('hide'); $('#vault-cook-revision-modal').modal('hide'); const cookingTaskFailedAlert = $(htmlAlert('danger', 'Archive cooking request submission failed.', true)); cookingTaskFailedAlert.css(alertStyle); $('body').append(cookingTaskFailedAlert); } } } function validateEmail(email) { - let re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(String(email).toLowerCase()); } export function cookDirectoryArchive(directoryId) { - let email = $('#swh-vault-directory-email').val().trim(); + const email = $('#swh-vault-directory-email').val().trim(); if (!email || validateEmail(email)) { - let cookingTask = { + const cookingTask = { 'object_type': 'directory', 'object_id': directoryId, 'email': email, 'status': 'new' }; addVaultCookingTask(cookingTask); } else { $('#invalid-email-modal').modal('show'); } } export async function fetchDirectoryArchive(directoryId) { $('#vault-fetch-directory-modal').modal('hide'); const vaultUrl = Urls.api_1_vault_cook_directory(directoryId); const response = await fetch(vaultUrl); const data = await response.json(); swh.vault.fetchCookedObject(data.fetch_url); } export function cookRevisionArchive(revisionId) { - let email = $('#swh-vault-revision-email').val().trim(); + const email = $('#swh-vault-revision-email').val().trim(); if (!email || validateEmail(email)) { - let cookingTask = { + const cookingTask = { 'object_type': 'revision', 'object_id': revisionId, 'email': email, 'status': 'new' }; addVaultCookingTask(cookingTask); } else { $('#invalid-email-modal').modal('show'); } } export async function fetchRevisionArchive(revisionId) { $('#vault-fetch-directory-modal').modal('hide'); const vaultUrl = Urls.api_1_vault_cook_revision_gitfast(revisionId); const response = await fetch(vaultUrl); const data = await response.json(); swh.vault.fetchCookedObject(data.fetch_url); } diff --git a/assets/src/bundles/vault/vault-ui.js b/assets/src/bundles/vault/vault-ui.js index 22ca093c..9cd669c7 100644 --- a/assets/src/bundles/vault/vault-ui.js +++ b/assets/src/bundles/vault/vault-ui.js @@ -1,241 +1,241 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, handleFetchErrors, csrfPost} from 'utils/functions'; import vaultTableRowTemplate from './vault-table-row.ejs'; -let progress = +const progress = `<div class="progress"> <div class="progress-bar progress-bar-success progress-bar-striped" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%;height: 100%;"> </div> </div>;`; -let pollingInterval = 5000; +const pollingInterval = 5000; let checkVaultId; function updateProgressBar(progressBar, cookingTask) { if (cookingTask.status === 'new') { progressBar.css('background-color', 'rgba(128, 128, 128, 0.5)'); } else if (cookingTask.status === 'pending') { progressBar.css('background-color', 'rgba(0, 0, 255, 0.5)'); } else if (cookingTask.status === 'done') { progressBar.css('background-color', '#5cb85c'); } else if (cookingTask.status === 'failed') { progressBar.css('background-color', 'rgba(255, 0, 0, 0.5)'); progressBar.css('background-image', 'none'); } progressBar.text(cookingTask.progress_message || cookingTask.status); if (cookingTask.status === 'new' || cookingTask.status === 'pending') { progressBar.addClass('progress-bar-animated'); } else { progressBar.removeClass('progress-bar-striped'); } } let recookTask; // called when the user wants to download a cooked archive export async function fetchCookedObject(fetchUrl) { recookTask = null; // first, check if the link is still available from the vault const response = await fetch(fetchUrl); // link is still alive, proceed to download if (response.ok) { $('#vault-fetch-iframe').attr('src', fetchUrl); // link is dead } else { // get the associated cooking task - let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); + const vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); for (let i = 0; i < vaultCookingTasks.length; ++i) { if (vaultCookingTasks[i].fetch_url === fetchUrl) { recookTask = vaultCookingTasks[i]; break; } } // display a modal asking the user if he wants to recook the archive $('#vault-recook-object-modal').modal('show'); } } // called when the user wants to recook an archive // for which the download link is not available anymore export async function recookObject() { if (recookTask) { // stop cooking tasks status polling clearTimeout(checkVaultId); // build cook request url let cookingUrl; if (recookTask.object_type === 'directory') { cookingUrl = Urls.api_1_vault_cook_directory(recookTask.object_id); } else { cookingUrl = Urls.api_1_vault_cook_revision_gitfast(recookTask.object_id); } if (recookTask.email) { cookingUrl += '?email=' + recookTask.email; } try { // request archive cooking const response = await csrfPost(cookingUrl); handleFetchError(response); // update task status recookTask.status = 'new'; - let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); + const vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); for (let i = 0; i < vaultCookingTasks.length; ++i) { if (vaultCookingTasks[i].object_id === recookTask.object_id) { vaultCookingTasks[i] = recookTask; break; } } // save updated tasks to local storage localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); // restart cooking tasks status polling checkVaultCookingTasks(); // hide recook archive modal $('#vault-recook-object-modal').modal('hide'); } catch (_) { // something went wrong checkVaultCookingTasks(); $('#vault-recook-object-modal').modal('hide'); } } } async function checkVaultCookingTasks() { - let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); + const vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); if (!vaultCookingTasks || vaultCookingTasks.length === 0) { $('.swh-vault-table tbody tr').remove(); checkVaultId = setTimeout(checkVaultCookingTasks, pollingInterval); return; } - let cookingTaskRequests = []; - let tasks = {}; - let currentObjectIds = []; + const cookingTaskRequests = []; + const tasks = {}; + const currentObjectIds = []; for (let i = 0; i < vaultCookingTasks.length; ++i) { - let cookingTask = vaultCookingTasks[i]; + const cookingTask = vaultCookingTasks[i]; currentObjectIds.push(cookingTask.object_id); tasks[cookingTask.object_id] = cookingTask; let cookingUrl; if (cookingTask.object_type === 'directory') { cookingUrl = Urls.api_1_vault_cook_directory(cookingTask.object_id); } else { cookingUrl = Urls.api_1_vault_cook_revision_gitfast(cookingTask.object_id); } if (cookingTask.status !== 'done' && cookingTask.status !== 'failed') { cookingTaskRequests.push(fetch(cookingUrl)); } } $('.swh-vault-table tbody tr').each((i, row) => { - let objectId = $(row).find('.vault-object-info').data('object-id'); + const objectId = $(row).find('.vault-object-info').data('object-id'); if ($.inArray(objectId, currentObjectIds) === -1) { $(row).remove(); } }); try { const responses = await Promise.all(cookingTaskRequests); handleFetchErrors(responses); const cookingTasks = await Promise.all(responses.map(r => r.json())); - let table = $('#vault-cooking-tasks tbody'); + const table = $('#vault-cooking-tasks tbody'); for (let i = 0; i < cookingTasks.length; ++i) { - let cookingTask = tasks[cookingTasks[i].obj_id]; + const cookingTask = tasks[cookingTasks[i].obj_id]; cookingTask.status = cookingTasks[i].status; cookingTask.fetch_url = cookingTasks[i].fetch_url; cookingTask.progress_message = cookingTasks[i].progress_message; } for (let i = 0; i < vaultCookingTasks.length; ++i) { - let cookingTask = vaultCookingTasks[i]; - let rowTask = $(`#vault-task-${cookingTask.object_id}`); + const cookingTask = vaultCookingTasks[i]; + const rowTask = $(`#vault-task-${cookingTask.object_id}`); if (!rowTask.length) { let browseUrl = cookingTask.browse_url; if (!browseUrl) { if (cookingTask.object_type === 'directory') { browseUrl = Urls.browse_directory(cookingTask.object_id); } else { browseUrl = Urls.browse_revision(cookingTask.object_id); } } - let progressBar = $.parseHTML(progress)[0]; - let progressBarContent = $(progressBar).find('.progress-bar'); + const progressBar = $.parseHTML(progress)[0]; + const progressBarContent = $(progressBar).find('.progress-bar'); updateProgressBar(progressBarContent, cookingTask); table.prepend(vaultTableRowTemplate({ browseUrl: browseUrl, cookingTask: cookingTask, progressBar: progressBar, Urls: Urls, swh: swh })); } else { - let progressBar = rowTask.find('.progress-bar'); + const progressBar = rowTask.find('.progress-bar'); updateProgressBar(progressBar, cookingTask); - let downloadLink = rowTask.find('.vault-dl-link'); + const downloadLink = rowTask.find('.vault-dl-link'); if (cookingTask.status === 'done') { downloadLink[0].innerHTML = '<button class="btn btn-default btn-sm" ' + `onclick="swh.vault.fetchCookedObject('${cookingTask.fetch_url}')">` + '<i class="mdi mdi-download mdi-fw" aria-hidden="true"></i>Download</button>'; } else { downloadLink[0].innerHTML = ''; } } } localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); checkVaultId = setTimeout(checkVaultCookingTasks, pollingInterval); } catch (error) { console.log('Error when fetching vault cooking tasks:', error); } } export function removeCookingTaskInfo(tasksToRemove) { let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); if (!vaultCookingTasks) { return; } vaultCookingTasks = $.grep(vaultCookingTasks, task => { return $.inArray(task.object_id, tasksToRemove) === -1; }); localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); } export function initUi() { $('#vault-tasks-toggle-selection').change(event => { $('.vault-task-toggle-selection').prop('checked', event.currentTarget.checked); }); $('#vault-remove-tasks').click(() => { clearTimeout(checkVaultId); - let tasksToRemove = []; + const tasksToRemove = []; $('.swh-vault-table tbody tr').each((i, row) => { - let taskSelected = $(row).find('.vault-task-toggle-selection').prop('checked'); + const taskSelected = $(row).find('.vault-task-toggle-selection').prop('checked'); if (taskSelected) { - let objectId = $(row).find('.vault-object-info').data('object-id'); + const objectId = $(row).find('.vault-object-info').data('object-id'); tasksToRemove.push(objectId); $(row).remove(); } }); removeCookingTaskInfo(tasksToRemove); $('#vault-tasks-toggle-selection').prop('checked', false); checkVaultId = setTimeout(checkVaultCookingTasks, pollingInterval); }); checkVaultCookingTasks(); window.onfocus = () => { clearTimeout(checkVaultId); checkVaultCookingTasks(); }; } diff --git a/assets/src/bundles/webapp/code-highlighting.js b/assets/src/bundles/webapp/code-highlighting.js index d9505c30..5a873869 100644 --- a/assets/src/bundles/webapp/code-highlighting.js +++ b/assets/src/bundles/webapp/code-highlighting.js @@ -1,114 +1,114 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {removeUrlFragment} from 'utils/functions'; export async function highlightCode(showLineNumbers = true) { await import(/* webpackChunkName: "highlightjs" */ 'utils/highlightjs'); // keep track of the first highlighted line let firstHighlightedLine = null; // highlighting color - let lineHighlightColor = 'rgb(193, 255, 193)'; + const lineHighlightColor = 'rgb(193, 255, 193)'; // function to highlight a line function highlightLine(i) { - let lineTd = $(`.hljs-ln-line[data-line-number="${i}"]`); + const lineTd = $(`.hljs-ln-line[data-line-number="${i}"]`); lineTd.css('background-color', lineHighlightColor); return lineTd; } // function to reset highlighting function resetHighlightedLines() { firstHighlightedLine = null; $('.hljs-ln-line[data-line-number]').css('background-color', 'inherit'); } function scrollToLine(lineDomElt) { if ($(lineDomElt).closest('.swh-content').length > 0) { $('html, body').animate({ scrollTop: $(lineDomElt).offset().top - 70 }, 500); } } // function to highlight lines based on a url fragment // in the form '#Lx' or '#Lx-Ly' function parseUrlFragmentForLinesToHighlight() { - let lines = []; - let linesRegexp = new RegExp(/L(\d+)/g); + const lines = []; + const linesRegexp = new RegExp(/L(\d+)/g); let line = linesRegexp.exec(window.location.hash); if (line === null) { return; } while (line) { lines.push(parseInt(line[1])); line = linesRegexp.exec(window.location.hash); } resetHighlightedLines(); if (lines.length === 1) { firstHighlightedLine = parseInt(lines[0]); scrollToLine(highlightLine(lines[0])); } else if (lines[0] < lines[lines.length - 1]) { firstHighlightedLine = parseInt(lines[0]); scrollToLine(highlightLine(lines[0])); for (let i = lines[0] + 1; i <= lines[lines.length - 1]; ++i) { highlightLine(i); } } } $(document).ready(() => { // highlight code and add line numbers $('code').each((i, elt) => { hljs.highlightElement(elt); if (showLineNumbers) { hljs.lineNumbersElement(elt, {singleLine: true}); } }); if (!showLineNumbers) { return; } // click handler to dynamically highlight line(s) // when the user clicks on a line number (lines range // can also be highlighted while holding the shift key) $('.swh-content').click(evt => { if (evt.target.classList.contains('hljs-ln-n')) { - let line = parseInt($(evt.target).data('line-number')); + const line = parseInt($(evt.target).data('line-number')); if (evt.shiftKey && firstHighlightedLine && line > firstHighlightedLine) { - let firstLine = firstHighlightedLine; + const firstLine = firstHighlightedLine; resetHighlightedLines(); for (let i = firstLine; i <= line; ++i) { highlightLine(i); } firstHighlightedLine = firstLine; window.location.hash = `#L${firstLine}-L${line}`; } else { resetHighlightedLines(); highlightLine(line); window.location.hash = `#L${line}`; scrollToLine(evt.target); } } else if ($(evt.target).closest('.hljs-ln').length) { resetHighlightedLines(); removeUrlFragment(); } }); // update lines highlighting when the url fragment changes $(window).on('hashchange', () => parseUrlFragmentForLinesToHighlight()); // schedule lines highlighting if any as hljs.lineNumbersElement() is async setTimeout(() => { parseUrlFragmentForLinesToHighlight(); }); }); } diff --git a/assets/src/bundles/webapp/notebook-rendering.js b/assets/src/bundles/webapp/notebook-rendering.js index 6bc1d3d1..73578002 100644 --- a/assets/src/bundles/webapp/notebook-rendering.js +++ b/assets/src/bundles/webapp/notebook-rendering.js @@ -1,136 +1,136 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import 'script-loader!notebookjs'; import AnsiUp from 'ansi_up'; import './notebook.css'; const ansiup = new AnsiUp(); ansiup.escape_for_html = false; function escapeHTML(text) { text = text.replace(/</g, '<'); text = text.replace(/>/g, '>'); return text; } function unescapeHTML(text) { text = text.replace(/</g, '<'); text = text.replace(/>/g, '>'); return text; } function escapeLaTeX(text) { - let blockMath = /\$\$(.+?)\$\$|\\\\\[(.+?)\\\\\]/msg; - let inlineMath = /\$(.+?)\$|\\\\\((.+?)\\\\\)/g; - let latexEnvironment = /\\begin\{([a-z]*\*?)\}(.+?)\\end\{\1\}/msg; + const blockMath = /\$\$(.+?)\$\$|\\\\\[(.+?)\\\\\]/msg; + const inlineMath = /\$(.+?)\$|\\\\\((.+?)\\\\\)/g; + const latexEnvironment = /\\begin\{([a-z]*\*?)\}(.+?)\\end\{\1\}/msg; - let mathTextFound = []; + const mathTextFound = []; let bm; while ((bm = blockMath.exec(text)) !== null) { mathTextFound.push(bm[1]); } let im; while ((im = inlineMath.exec(text)) !== null) { mathTextFound.push(im[1]); } let le; while ((le = latexEnvironment.exec(text)) !== null) { mathTextFound.push(le[1]); } - for (let mathText of mathTextFound) { + for (const mathText of mathTextFound) { // showdown will remove line breaks in LaTex array and // some escaping sequences when converting md to html. // So we use the following escaping hacks to keep them in the html // output and avoid MathJax typesetting errors. let escapedText = mathText.replace('\\\\', '\\\\\\\\'); - for (let specialLaTexChar of ['{', '}', '#', '%', '&', '_']) { + for (const specialLaTexChar of ['{', '}', '#', '%', '&', '_']) { escapedText = escapedText.replace(new RegExp(`\\\\${specialLaTexChar}`, 'g'), `\\\\${specialLaTexChar}`); } // some html escaping is also needed escapedText = escapeHTML(escapedText); // hack to prevent showdown to replace _ characters // by html em tags as it will break some math typesetting // (setting the literalMidWordUnderscores option is not // enough as iy only works for _ characters contained in words) escapedText = escapedText.replace(/_/g, '{@}underscore{@}'); if (mathText !== escapedText) { text = text.replace(mathText, escapedText); } } return text; } export async function renderNotebook(nbJsonUrl, domElt) { - let showdown = await import(/* webpackChunkName: "showdown" */ 'utils/showdown'); + const showdown = await import(/* webpackChunkName: "showdown" */ 'utils/showdown'); await import(/* webpackChunkName: "highlightjs" */ 'utils/highlightjs'); function renderMarkdown(text) { - let converter = new showdown.Converter({ + const converter = new showdown.Converter({ tables: true, simplifiedAutoLink: true, rawHeaderId: true, literalMidWordUnderscores: true }); // some LaTeX escaping is required to get correct math typesetting text = escapeLaTeX(text); // render markdown let rendered = converter.makeHtml(text); // restore underscores in rendered HTML (see escapeLaTeX function) rendered = rendered.replace(/{@}underscore{@}/g, '_'); return rendered; } function highlightCode(text, preElt, codeElt, lang) { // no need to unescape text processed by ansiup if (text.indexOf('<span style="color:rgb(') === -1) { text = unescapeHTML(text); } if (lang && hljs.getLanguage(lang)) { return hljs.highlight(text, {language: lang}).value; } else { return text; } } function renderAnsi(text) { return ansiup.ansi_to_html(text); } nb.markdown = renderMarkdown; nb.highlighter = highlightCode; nb.ansi = renderAnsi; const response = await fetch(nbJsonUrl); const nbJson = await response.json(); // parse the notebook - let notebook = nb.parse(nbJson); + const notebook = nb.parse(nbJson); // render it to HTML and apply XSS filtering - let rendered = swh.webapp.filterXSS(notebook.render()); + const rendered = swh.webapp.filterXSS(notebook.render()); // insert rendered notebook in the DOM $(domElt).append(rendered); // set light red background color for stderr output cells $('pre.nb-stderr').parent().css('background', '#fdd'); // load MathJax library for math typesetting swh.webapp.typesetMath(); } diff --git a/assets/src/bundles/webapp/pdf-rendering.js b/assets/src/bundles/webapp/pdf-rendering.js index c92be0d0..f6c2e88e 100644 --- a/assets/src/bundles/webapp/pdf-rendering.js +++ b/assets/src/bundles/webapp/pdf-rendering.js @@ -1,107 +1,107 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // adapted from pdf.js examples located at http://mozilla.github.io/pdf.js/examples/ import {staticAsset} from 'utils/functions'; export async function renderPdf(pdfUrl) { let pdfDoc = null; let pageNum = 1; let pageRendering = false; let pageNumPending = null; - let defaultScale = 1.5; - let canvas = $('#pdf-canvas')[0]; - let ctx = canvas.getContext('2d'); + const defaultScale = 1.5; + const canvas = $('#pdf-canvas')[0]; + const ctx = canvas.getContext('2d'); // Get page info from document, resize canvas accordingly, and render page. async function renderPage(num) { pageRendering = true; // Using promise to fetch the page const page = await pdfDoc.getPage(num); - let divWidth = $('.swh-content').width(); - let scale = Math.min(defaultScale, divWidth / page.getViewport({scale: 1.0}).width); + const divWidth = $('.swh-content').width(); + const scale = Math.min(defaultScale, divWidth / page.getViewport({scale: 1.0}).width); - let viewport = page.getViewport({scale: scale}); + const viewport = page.getViewport({scale: scale}); canvas.width = viewport.width; canvas.height = viewport.height; // Render PDF page into canvas context const renderContext = { canvasContext: ctx, viewport: viewport }; // Wait for rendering to finish await page.render(renderContext); pageRendering = false; if (pageNumPending !== null) { // New page rendering is pending renderPage(pageNumPending); pageNumPending = null; } // Update page counters $('#pdf-page-num').text(num); } // If another page rendering in progress, waits until the rendering is // finished. Otherwise, executes rendering immediately. function queueRenderPage(num) { if (pageRendering) { pageNumPending = num; } else { renderPage(num); } } // Displays previous page. function onPrevPage() { if (pageNum <= 1) { return; } pageNum--; queueRenderPage(pageNum); } // Displays next page. function onNextPage() { if (pageNum >= pdfDoc.numPages) { return; } pageNum++; queueRenderPage(pageNum); } - let pdfjs = await import(/* webpackChunkName: "pdfjs" */ 'pdfjs-dist'); + const pdfjs = await import(/* webpackChunkName: "pdfjs" */ 'pdfjs-dist'); pdfjs.GlobalWorkerOptions.workerSrc = staticAsset('js/pdf.worker.min.js'); $(document).ready(async() => { $('#pdf-prev').click(onPrevPage); $('#pdf-next').click(onNextPage); try { const pdf = await pdfjs.getDocument(pdfUrl).promise; pdfDoc = pdf; $('#pdf-page-count').text(pdfDoc.numPages); // Initial/first page rendering renderPage(pageNum); } catch (reason) { // PDF loading error console.error(reason); } // Render PDF on resize $(window).on('resize', function() { queueRenderPage(pageNum); }); }); } diff --git a/assets/src/bundles/webapp/readme-rendering.js b/assets/src/bundles/webapp/readme-rendering.js index 2651f6c7..3cb56fae 100644 --- a/assets/src/bundles/webapp/readme-rendering.js +++ b/assets/src/bundles/webapp/readme-rendering.js @@ -1,118 +1,118 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError} from 'utils/functions'; import {decode} from 'html-encoder-decoder'; export async function renderMarkdown(domElt, markdownDocUrl) { - let showdown = await import(/* webpackChunkName: "showdown" */ 'utils/showdown'); + const showdown = await import(/* webpackChunkName: "showdown" */ 'utils/showdown'); await import(/* webpackChunkName: "highlightjs" */ 'utils/highlightjs'); // Adapted from https://github.com/Bloggify/showdown-highlight // Copyright (c) 2016-19 Bloggify <support@bloggify.org> (https://bloggify.org) function showdownHighlight() { return [{ type: 'output', filter: function(text, converter, options) { - let left = '<pre><code\\b[^>]*>'; - let right = '</code></pre>'; - let flags = 'g'; - let classAttr = 'class="'; - let replacement = (wholeMatch, match, left, right) => { + const left = '<pre><code\\b[^>]*>'; + const right = '</code></pre>'; + const flags = 'g'; + const classAttr = 'class="'; + const replacement = (wholeMatch, match, left, right) => { match = decode(match); - let lang = (left.match(/class="([^ "]+)/) || [])[1]; + const lang = (left.match(/class="([^ "]+)/) || [])[1]; if (left.includes(classAttr)) { - let attrIndex = left.indexOf(classAttr) + classAttr.length; + const attrIndex = left.indexOf(classAttr) + classAttr.length; left = left.slice(0, attrIndex) + 'hljs ' + left.slice(attrIndex); } else { left = left.slice(0, -1) + ' class="hljs">'; } if (lang && hljs.getLanguage(lang)) { return left + hljs.highlight(match, {language: lang}).value + right; } else { return left + match + right; } }; return showdown.helper.replaceRecursiveRegExp(text, replacement, left, right, flags); } }]; } $(document).ready(async() => { - let converter = new showdown.Converter({ + const converter = new showdown.Converter({ tables: true, extensions: [showdownHighlight] }); try { const response = await fetch(markdownDocUrl); handleFetchError(response); const data = await response.text(); $(domElt).addClass('swh-showdown'); $(domElt).html(swh.webapp.filterXSS(converter.makeHtml(data))); } catch (_) { $(domElt).text('Readme bytes are not available'); } }); } export async function renderOrgData(domElt, orgDocData) { - let org = await import(/* webpackChunkName: "org" */ 'utils/org'); + const org = await import(/* webpackChunkName: "org" */ 'utils/org'); - let parser = new org.Parser(); - let orgDocument = parser.parse(orgDocData, {toc: false}); - let orgHTMLDocument = orgDocument.convert(org.ConverterHTML, {}); + const parser = new org.Parser(); + const orgDocument = parser.parse(orgDocData, {toc: false}); + const orgHTMLDocument = orgDocument.convert(org.ConverterHTML, {}); $(domElt).addClass('swh-org'); $(domElt).html(swh.webapp.filterXSS(orgHTMLDocument.toString())); // remove toc and section numbers to get consistent // with other readme renderings $('.swh-org ul').first().remove(); $('.section-number').remove(); } export function renderOrg(domElt, orgDocUrl) { $(document).ready(async() => { try { const response = await fetch(orgDocUrl); handleFetchError(response); const data = await response.text(); renderOrgData(domElt, data); } catch (_) { $(domElt).text('Readme bytes are not available'); } }); } export function renderTxt(domElt, txtDocUrl) { $(document).ready(async() => { try { const response = await fetch(txtDocUrl); handleFetchError(response); const data = await response.text(); - let orgMode = '-*- mode: org -*-'; + const orgMode = '-*- mode: org -*-'; if (data.indexOf(orgMode) !== -1) { renderOrgData(domElt, data.replace(orgMode, '')); } else { $(domElt).addClass('swh-readme-txt'); $(domElt) .html('') .append($('<pre></pre>').text(data)); } } catch (_) { $(domElt).text('Readme bytes are not available'); } }); } diff --git a/assets/src/bundles/webapp/status-widget.js b/assets/src/bundles/webapp/status-widget.js index 7eb31b3b..f78c72dd 100644 --- a/assets/src/bundles/webapp/status-widget.js +++ b/assets/src/bundles/webapp/status-widget.js @@ -1,50 +1,50 @@ /** * Copyright (C) 2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import './status-widget.css'; const statusCodeColor = { '100': 'green', // Operational '200': 'blue', // Scheduled Maintenance '300': 'yellow', // Degraded Performance '400': 'yellow', // Partial Service Disruption '500': 'red', // Service Disruption '600': 'red' // Security Event }; export function initStatusWidget(statusDataURL) { $('.swh-current-status-indicator').ready(async() => { let maxStatusCode = ''; let maxStatusDescription = ''; let sc = ''; let sd = ''; try { const response = await fetch(statusDataURL); const data = await response.json(); - for (let s of data.result.status) { + for (const s of data.result.status) { sc = s.status_code; sd = s.status; if (maxStatusCode < sc) { maxStatusCode = sc; maxStatusDescription = sd; } } if (maxStatusCode === '') { $('.swh-current-status').remove(); return; } $('.swh-current-status-indicator').removeClass('green'); $('.swh-current-status-indicator').addClass(statusCodeColor[maxStatusCode]); $('#swh-current-status-description').text(maxStatusDescription); } catch (e) { console.log(e); $('.swh-current-status').remove(); } }); } diff --git a/assets/src/bundles/webapp/webapp-utils.js b/assets/src/bundles/webapp/webapp-utils.js index 797a2469..79a65270 100644 --- a/assets/src/bundles/webapp/webapp-utils.js +++ b/assets/src/bundles/webapp/webapp-utils.js @@ -1,400 +1,400 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import objectFitImages from 'object-fit-images'; import {selectText} from 'utils/functions'; import {BREAKPOINT_MD} from 'utils/constants'; let collapseSidebar = false; -let previousSidebarState = localStorage.getItem('remember.lte.pushmenu'); +const previousSidebarState = localStorage.getItem('remember.lte.pushmenu'); if (previousSidebarState !== undefined) { collapseSidebar = previousSidebarState === 'sidebar-collapse'; } $(document).on('DOMContentLoaded', () => { // set state to collapsed on smaller devices if ($(window).width() < BREAKPOINT_MD) { collapseSidebar = true; } // restore previous sidebar state (collapsed/expanded) if (collapseSidebar) { // hack to avoid animated transition for collapsing sidebar // when loading a page - let sidebarTransition = $('.main-sidebar, .main-sidebar:before').css('transition'); - let sidebarEltsTransition = $('.sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info').css('transition'); + const sidebarTransition = $('.main-sidebar, .main-sidebar:before').css('transition'); + const sidebarEltsTransition = $('.sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info').css('transition'); $('.main-sidebar, .main-sidebar:before').css('transition', 'none'); $('.sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info').css('transition', 'none'); $('body').addClass('sidebar-collapse'); $('.swh-words-logo-swh').css('visibility', 'visible'); // restore transitions for user navigation setTimeout(() => { $('.main-sidebar, .main-sidebar:before').css('transition', sidebarTransition); $('.sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info').css('transition', sidebarEltsTransition); }); } }); $(document).on('collapsed.lte.pushmenu', event => { if ($('body').width() >= BREAKPOINT_MD) { $('.swh-words-logo-swh').css('visibility', 'visible'); } }); $(document).on('shown.lte.pushmenu', event => { $('.swh-words-logo-swh').css('visibility', 'hidden'); }); function ensureNoFooterOverflow() { $('body').css('padding-bottom', $('footer').outerHeight() + 'px'); } $(document).ready(() => { // redirect to last browse page if any when clicking on the 'Browse' entry // in the sidebar $(`.swh-browse-link`).click(event => { - let lastBrowsePage = sessionStorage.getItem('last-browse-page'); + const lastBrowsePage = sessionStorage.getItem('last-browse-page'); if (lastBrowsePage) { event.preventDefault(); window.location = lastBrowsePage; } }); const mainSideBar = $('.main-sidebar'); function updateSidebarState() { const body = $('body'); if (body.hasClass('sidebar-collapse') && !mainSideBar.hasClass('swh-sidebar-collapsed')) { mainSideBar.removeClass('swh-sidebar-expanded'); mainSideBar.addClass('swh-sidebar-collapsed'); $('.swh-words-logo-swh').css('visibility', 'visible'); } else if (!body.hasClass('sidebar-collapse') && !mainSideBar.hasClass('swh-sidebar-expanded')) { mainSideBar.removeClass('swh-sidebar-collapsed'); mainSideBar.addClass('swh-sidebar-expanded'); $('.swh-words-logo-swh').css('visibility', 'hidden'); } // ensure correct sidebar state when loading a page if (body.hasClass('hold-transition')) { setTimeout(() => { updateSidebarState(); }); } } // set sidebar state after collapse / expand animation mainSideBar.on('transitionend', evt => { updateSidebarState(); }); updateSidebarState(); // ensure footer do not overflow main content for mobile devices // or after resizing the browser window ensureNoFooterOverflow(); $(window).resize(function() { ensureNoFooterOverflow(); if ($('body').hasClass('sidebar-collapse') && $('body').width() >= BREAKPOINT_MD) { $('.swh-words-logo-swh').css('visibility', 'visible'); } }); // activate css polyfill 'object-fit: contain' in old browsers objectFitImages(); // reparent the modals to the top navigation div in order to be able // to display them $('.swh-browse-top-navigation').append($('.modal')); let selectedCode = null; function getCodeOrPreEltUnderPointer(e) { - let elts = document.elementsFromPoint(e.clientX, e.clientY); - for (let elt of elts) { + const elts = document.elementsFromPoint(e.clientX, e.clientY); + for (const elt of elts) { if (elt.nodeName === 'CODE' || elt.nodeName === 'PRE') { return elt; } } return null; } // click handler to set focus on code block for copy $(document).click(e => { selectedCode = getCodeOrPreEltUnderPointer(e); }); function selectCode(event, selectedCode) { if (selectedCode) { - let hljsLnCodeElts = $(selectedCode).find('.hljs-ln-code'); + const hljsLnCodeElts = $(selectedCode).find('.hljs-ln-code'); if (hljsLnCodeElts.length) { selectText(hljsLnCodeElts[0], hljsLnCodeElts[hljsLnCodeElts.length - 1]); } else { selectText(selectedCode.firstChild, selectedCode.lastChild); } event.preventDefault(); } } // select the whole text of focused code block when user // double clicks or hits Ctrl+A $(document).dblclick(e => { if ((e.ctrlKey || e.metaKey)) { selectCode(e, getCodeOrPreEltUnderPointer(e)); } }); $(document).keydown(e => { if ((e.ctrlKey || e.metaKey) && e.key === 'a') { selectCode(e, selectedCode); } }); // show/hide back-to-top button let scrollThreshold = 0; scrollThreshold += $('.swh-top-bar').height() || 0; scrollThreshold += $('.navbar').height() || 0; $(window).scroll(() => { if ($(window).scrollTop() > scrollThreshold) { $('#back-to-top').css('display', 'block'); } else { $('#back-to-top').css('display', 'none'); } }); // navbar search form submission callback $('#swh-origins-search-top').submit(event => { event.preventDefault(); if (event.target.checkValidity()) { $(event.target).removeClass('was-validated'); - let searchQueryText = $('#swh-origins-search-top-input').val().trim(); - let queryParameters = new URLSearchParams(); + const searchQueryText = $('#swh-origins-search-top-input').val().trim(); + const queryParameters = new URLSearchParams(); queryParameters.append('q', searchQueryText); queryParameters.append('with_visit', true); queryParameters.append('with_content', true); window.location = `${Urls.browse_search()}?${queryParameters.toString()}`; } else { $(event.target).addClass('was-validated'); } }); }); export function initPage(page) { $(document).ready(() => { // set relevant sidebar link to page active $(`.swh-${page}-item`).addClass('active'); $(`.swh-${page}-link`).addClass('active'); // triggered when unloading the current page $(window).on('unload', () => { // backup current browse page if (page === 'browse') { sessionStorage.setItem('last-browse-page', window.location); } }); }); } export function initHomePage() { $(document).ready(async() => { $('.swh-coverage-list').iFrameResize({heightCalculationMethod: 'taggedElement'}); const response = await fetch(Urls.stat_counters()); const data = await response.json(); if (data.stat_counters && !$.isEmptyObject(data.stat_counters)) { - for (let objectType of ['content', 'revision', 'origin', 'directory', 'person', 'release']) { + for (const objectType of ['content', 'revision', 'origin', 'directory', 'person', 'release']) { const count = data.stat_counters[objectType]; if (count !== undefined) { $(`#swh-${objectType}-count`).html(count.toLocaleString()); } else { $(`#swh-${objectType}-count`).closest('.swh-counter-container').hide(); } } } else { $('.swh-counter').html('0'); } if (data.stat_counters_history && !$.isEmptyObject(data.stat_counters_history)) { - for (let objectType of ['content', 'revision', 'origin']) { + for (const objectType of ['content', 'revision', 'origin']) { const history = data.stat_counters_history[objectType]; if (history) { swh.webapp.drawHistoryCounterGraph(`#swh-${objectType}-count-history`, history); } else { $(`#swh-${objectType}-count-history`).hide(); } } } else { $('.swh-counter-history').hide(); } }); initPage('home'); } export function showModalMessage(title, message) { $('#swh-web-modal-message .modal-title').text(title); $('#swh-web-modal-message .modal-content p').text(message); $('#swh-web-modal-message').modal('show'); } export function showModalConfirm(title, message, callback) { $('#swh-web-modal-confirm .modal-title').text(title); $('#swh-web-modal-confirm .modal-content p').text(message); $('#swh-web-modal-confirm #swh-web-modal-confirm-ok-btn').bind('click', () => { callback(); $('#swh-web-modal-confirm').modal('hide'); $('#swh-web-modal-confirm #swh-web-modal-confirm-ok-btn').unbind('click'); }); $('#swh-web-modal-confirm').modal('show'); } export function showModalHtml(title, html) { $('#swh-web-modal-html .modal-title').text(title); $('#swh-web-modal-html .modal-body').html(html); $('#swh-web-modal-html').modal('show'); } export function addJumpToPagePopoverToDataTable(dataTableElt) { dataTableElt.on('draw.dt', function() { $('.paginate_button.disabled').css('cursor', 'pointer'); $('.paginate_button.disabled').on('click', event => { const pageInfo = dataTableElt.page.info(); let content = '<select class="jump-to-page">'; for (let i = 1; i <= pageInfo.pages; ++i) { let selected = ''; if (i === pageInfo.page + 1) { selected = 'selected'; } content += `<option value="${i}" ${selected}>${i}</option>`; } content += `</select><span> / ${pageInfo.pages}</span>`; $(event.target).popover({ 'title': 'Jump to page', 'content': content, 'html': true, 'placement': 'top', 'sanitizeFn': swh.webapp.filterXSS }); $(event.target).popover('show'); $('.jump-to-page').on('change', function() { $('.paginate_button.disabled').popover('hide'); const pageNumber = parseInt($(this).val()) - 1; dataTableElt.page(pageNumber).draw('page'); }); }); }); dataTableElt.on('preXhr.dt', () => { $('.paginate_button.disabled').popover('hide'); }); } let swhObjectIcons; export function setSwhObjectIcons(icons) { swhObjectIcons = icons; } export function getSwhObjectIcon(swhObjectType) { return swhObjectIcons[swhObjectType]; } let browsedSwhObjectMetadata = {}; export function setBrowsedSwhObjectMetadata(metadata) { browsedSwhObjectMetadata = metadata; } export function getBrowsedSwhObjectMetadata() { return browsedSwhObjectMetadata; } // This will contain a mapping between an archived object type // and its related SWHID metadata for each object reachable from // the current browse view. // SWHID metadata contain the following keys: // * object_type: type of archived object // * object_id: sha1 object identifier // * swhid: SWHID without contextual info // * swhid_url: URL to resolve SWHID without contextual info // * context: object describing SWHID context // * swhid_with_context: SWHID with contextual info // * swhid_with_context_url: URL to resolve SWHID with contextual info let swhidsContext_ = {}; export function setSwhIdsContext(swhidsContext) { swhidsContext_ = {}; - for (let swhidContext of swhidsContext) { + for (const swhidContext of swhidsContext) { swhidsContext_[swhidContext.object_type] = swhidContext; } } export function getSwhIdsContext() { return swhidsContext_; } function setFullWidth(fullWidth) { if (fullWidth) { $('#swh-web-content').removeClass('container'); $('#swh-web-content').addClass('container-fluid'); } else { $('#swh-web-content').removeClass('container-fluid'); $('#swh-web-content').addClass('container'); } localStorage.setItem('swh-web-full-width', JSON.stringify(fullWidth)); $('#swh-full-width-switch').prop('checked', fullWidth); } export function fullWidthToggled(event) { setFullWidth($(event.target).prop('checked')); } export function setContainerFullWidth() { - let previousFullWidthState = JSON.parse(localStorage.getItem('swh-web-full-width')); + const previousFullWidthState = JSON.parse(localStorage.getItem('swh-web-full-width')); if (previousFullWidthState !== null) { setFullWidth(previousFullWidthState); } } function coreSWHIDIsLowerCase(swhid) { const qualifiersPos = swhid.indexOf(';'); let coreSWHID = swhid; if (qualifiersPos !== -1) { coreSWHID = swhid.slice(0, qualifiersPos); } return coreSWHID.toLowerCase() === coreSWHID; } export async function validateSWHIDInput(swhidInputElt) { const swhidInput = swhidInputElt.value.trim(); let customValidity = ''; if (swhidInput.toLowerCase().startsWith('swh:')) { if (coreSWHIDIsLowerCase(swhidInput)) { const resolveSWHIDUrl = Urls.api_1_resolve_swhid(swhidInput); const response = await fetch(resolveSWHIDUrl); const responseData = await response.json(); if (responseData.hasOwnProperty('exception')) { customValidity = responseData.reason; } } else { const qualifiersPos = swhidInput.indexOf(';'); if (qualifiersPos === -1) { customValidity = 'Invalid SWHID: all characters must be in lowercase. '; customValidity += `Valid SWHID is ${swhidInput.toLowerCase()}`; } else { customValidity = 'Invalid SWHID: the core part must be in lowercase. '; const coreSWHID = swhidInput.slice(0, qualifiersPos); customValidity += `Valid SWHID is ${swhidInput.replace(coreSWHID, coreSWHID.toLowerCase())}`; } } } swhidInputElt.setCustomValidity(customValidity); $(swhidInputElt).siblings('.invalid-feedback').text(customValidity); } export function isUserLoggedIn() { return JSON.parse($('#swh_user_logged_in').text()); } diff --git a/assets/src/bundles/webapp/xss-filtering.js b/assets/src/bundles/webapp/xss-filtering.js index 8eb2a65c..81be4683 100644 --- a/assets/src/bundles/webapp/xss-filtering.js +++ b/assets/src/bundles/webapp/xss-filtering.js @@ -1,42 +1,42 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import DOMPurify from 'dompurify'; // we register a hook when performing XSS filtering in order to // possibly replace a relative image url with the one for getting // the image bytes from the archive content DOMPurify.addHook('uponSanitizeAttribute', function(node, data) { if (node.nodeName === 'IMG' && data.attrName === 'src') { // image url does not need any processing here if (data.attrValue.startsWith('data:image') || data.attrValue.startsWith('http:') || data.attrValue.startsWith('https:')) { return; } // get currently browsed swh object metadata - let swhObjectMetadata = swh.webapp.getBrowsedSwhObjectMetadata(); + const swhObjectMetadata = swh.webapp.getBrowsedSwhObjectMetadata(); // the swh object is provided without any useful context // to get the image checksums from the web api if (!swhObjectMetadata.hasOwnProperty('directory')) { return; } // used internal endpoint as image url to possibly get the image data // from the archive content let url = Urls.browse_directory_resolve_content_path(swhObjectMetadata.directory); url += `?path=${data.attrValue}`; data.attrValue = url; } }); export function filterXSS(html) { return DOMPurify.sanitize(html); } diff --git a/assets/src/utils/functions.js b/assets/src/utils/functions.js index f91856cf..938acda6 100644 --- a/assets/src/utils/functions.js +++ b/assets/src/utils/functions.js @@ -1,124 +1,124 @@ /** * Copyright (C) 2018-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // utility functions export function handleFetchError(response) { if (!response.ok) { throw response; } return response; } export function handleFetchErrors(responses) { for (let i = 0; i < responses.length; ++i) { if (!responses[i].ok) { throw responses[i]; } } return responses; } export function staticAsset(asset) { return `${__STATIC__}${asset}`; } export function csrfPost(url, headers = {}, body = null) { headers['X-CSRFToken'] = Cookies.get('csrftoken'); return fetch(url, { credentials: 'include', headers: headers, method: 'POST', body: body }); } export function isGitRepoUrl(url, pathPrefix = '/') { - let allowedProtocols = ['http:', 'https:', 'git:']; + const allowedProtocols = ['http:', 'https:', 'git:']; if (allowedProtocols.find(protocol => protocol === url.protocol) === undefined) { return false; } if (!url.pathname.startsWith(pathPrefix)) { return false; } - let re = new RegExp('[\\w\\.-]+\\/?(?!=.git)(?:\\.git\\/?)?$'); + const re = new RegExp('[\\w\\.-]+\\/?(?!=.git)(?:\\.git\\/?)?$'); return re.test(url.pathname.slice(pathPrefix.length)); }; export function removeUrlFragment() { history.replaceState('', document.title, window.location.pathname + window.location.search); } export function selectText(startNode, endNode) { - let selection = window.getSelection(); + const selection = window.getSelection(); selection.removeAllRanges(); - let range = document.createRange(); + const range = document.createRange(); range.setStart(startNode, 0); if (endNode.nodeName !== '#text') { range.setEnd(endNode, endNode.childNodes.length); } else { range.setEnd(endNode, endNode.textContent.length); } selection.addRange(range); } export function htmlAlert(type, message, closable = false) { let closeButton = ''; let extraClasses = ''; if (closable) { closeButton = `<button type="button" class="close" data-dismiss="alert" aria-label="Close"> <span aria-hidden="true">×</span> </button>`; extraClasses = 'alert-dismissible'; } return `<div class="alert alert-${type} ${extraClasses}" role="alert">${message}${closeButton}</div>`; } export function isValidURL(string) { try { new URL(string); } catch (_) { return false; } return true; } export async function isArchivedOrigin(originPath) { if (!isValidURL(originPath)) { // Not a valid URL, return immediately return false; } else { const response = await fetch(Urls.api_1_origin(originPath)); return response.ok && response.status === 200; // Success response represents an archived origin } } export async function getCanonicalOriginURL(originUrl) { let originUrlLower = originUrl.toLowerCase(); // github.com URL processing const ghUrlRegex = /^http[s]*:\/\/github.com\//; if (originUrlLower.match(ghUrlRegex)) { // remove trailing .git if (originUrlLower.endsWith('.git')) { originUrlLower = originUrlLower.slice(0, -4); } // remove trailing slash if (originUrlLower.endsWith('/')) { originUrlLower = originUrlLower.slice(0, -1); } // extract {owner}/{repo} const ownerRepo = originUrlLower.replace(ghUrlRegex, ''); // fetch canonical URL from github Web API const ghApiResponse = await fetch(`https://api.github.com/repos/${ownerRepo}`); if (ghApiResponse.ok && ghApiResponse.status === 200) { const ghApiResponseData = await ghApiResponse.json(); return ghApiResponseData.html_url; } } return originUrl; } diff --git a/cypress/integration/code-highlighting.spec.js b/cypress/integration/code-highlighting.spec.js index 77470f65..9fdb2b56 100644 --- a/cypress/integration/code-highlighting.spec.js +++ b/cypress/integration/code-highlighting.spec.js @@ -1,92 +1,92 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {random} from '../utils'; const $ = Cypress.$; let origin; const lineStart = 32; const lineEnd = 42; let url; describe('Code highlighting tests', function() { before(function() { origin = this.origin[0]; url = `${this.Urls.browse_origin_content()}?origin_url=${origin.url}&path=${origin.content[0].path}`; }); it('should highlight source code and add line numbers', function() { cy.visit(url); cy.get('.hljs-ln-numbers').then(lnNumbers => { cy.get('.hljs-ln-code') .should('have.length', lnNumbers.length); }); }); it('should emphasize source code lines based on url fragment', function() { cy.visit(`${url}/#L${lineStart}-L${lineEnd}`); cy.get('.hljs-ln-line').then(lines => { - for (let line of lines) { + for (const line of lines) { const lineElt = $(line); const lineNumber = parseInt(lineElt.data('line-number')); if (lineNumber >= lineStart && lineNumber <= lineEnd) { assert.notEqual(lineElt.css('background-color'), 'rgba(0, 0, 0, 0)'); } else { assert.equal(lineElt.css('background-color'), 'rgba(0, 0, 0, 0)'); } } }); }); it('should emphasize a line by clicking on its number', function() { cy.visit(url); cy.get('.hljs-ln-numbers').then(lnNumbers => { const lnNumber = lnNumbers[random(0, lnNumbers.length)]; const lnNumberElt = $(lnNumber); assert.equal(lnNumberElt.css('background-color'), 'rgba(0, 0, 0, 0)'); const line = parseInt(lnNumberElt.data('line-number')); cy.get(`.hljs-ln-numbers[data-line-number="${line}"]`) .click() .then(() => { assert.notEqual(lnNumberElt.css('background-color'), 'rgba(0, 0, 0, 0)'); }); }); }); it('should emphasize a range of lines by clicking on two line numbers and holding shift', function() { cy.visit(url); cy.get(`.hljs-ln-numbers[data-line-number="${lineStart}"]`) .click() .get(`.hljs-ln-numbers[data-line-number="${lineEnd}"]`) .click({shiftKey: true}) .get('.hljs-ln-line') .then(lines => { - for (let line of lines) { + for (const line of lines) { const lineElt = $(line); const lineNumber = parseInt(lineElt.data('line-number')); if (lineNumber >= lineStart && lineNumber <= lineEnd) { assert.notEqual(lineElt.css('background-color'), 'rgba(0, 0, 0, 0)'); } else { assert.equal(lineElt.css('background-color'), 'rgba(0, 0, 0, 0)'); } } }); }); it('should remove emphasized lines when clicking anywhere in code', function() { cy.visit(`${url}/#L${lineStart}-L${lineEnd}`); cy.get(`.hljs-ln-code[data-line-number="1"]`) .click() .get('.hljs-ln-line') .should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); }); }); diff --git a/cypress/integration/content-rendering.spec.js b/cypress/integration/content-rendering.spec.js index fa532f02..6a693959 100644 --- a/cypress/integration/content-rendering.spec.js +++ b/cypress/integration/content-rendering.spec.js @@ -1,99 +1,99 @@ /** * Copyright (C) 2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {checkLanguageHighlighting, describeSlowTests} from '../utils'; describeSlowTests('Code highlighting tests', function() { const extensions = require('../fixtures/source-file-extensions.json'); extensions.forEach(ext => { it(`should highlight source files with extension ${ext}`, function() { cy.request(this.Urls.tests_content_code_extension(ext)).then(response => { const data = response.body; cy.visit(`${this.Urls.browse_content(data.sha1)}?path=file.${ext}`); checkLanguageHighlighting(data.language); }); }); }); const filenames = require('../fixtures/source-file-names.json'); filenames.forEach(filename => { it(`should highlight source files with filenames ${filename}`, function() { cy.request(this.Urls.tests_content_code_filename(filename)).then(response => { const data = response.body; cy.visit(`${this.Urls.browse_content(data.sha1)}?path=${filename}`); checkLanguageHighlighting(data.language); }); }); }); }); describe('Image rendering tests', function() { const imgExtensions = ['gif', 'jpeg', 'png', 'webp']; imgExtensions.forEach(ext => { it(`should render image with extension ${ext}`, function() { cy.request(this.Urls.tests_content_other_extension(ext)).then(response => { const data = response.body; cy.visit(`${this.Urls.browse_content(data.sha1)}?path=file.${ext}`); cy.get('.swh-content img') .should('be.visible'); }); }); }); }); describe('PDF rendering test', function() { function sum(previousValue, currentValue) { return previousValue + currentValue; } it(`should render a PDF file`, function() { cy.request(this.Urls.tests_content_other_extension('pdf')).then(response => { const data = response.body; cy.visit(`${this.Urls.browse_content(data.sha1)}?path=file.pdf`); cy.get('.swh-content canvas') .wait(2000) .then(canvas => { - let width = canvas[0].width; - let height = canvas[0].height; - let context = canvas[0].getContext('2d'); - let imgData = context.getImageData(0, 0, width, height); + const width = canvas[0].width; + const height = canvas[0].height; + const context = canvas[0].getContext('2d'); + const imgData = context.getImageData(0, 0, width, height); assert.notEqual(imgData.data.reduce(sum), 0); }); }); }); }); describe('Jupyter notebook rendering test', function() { it(`should render a notebook file to HTML`, function() { cy.request(this.Urls.tests_content_other_extension('ipynb')).then(response => { const data = response.body; cy.visit(`${this.Urls.browse_content(data.sha1)}?path=file.ipynb`); cy.get('.nb-notebook') .should('be.visible') .and('not.be.empty'); cy.get('.nb-cell.nb-markdown-cell') .should('be.visible') .and('not.be.empty'); cy.get('.nb-cell.nb-code-cell') .should('be.visible') .and('not.be.empty'); cy.get('.MathJax') .should('be.visible') .and('not.be.empty'); }); }); }); diff --git a/cypress/integration/deposit-admin.spec.js b/cypress/integration/deposit-admin.spec.js index b0e78f11..cba933bc 100644 --- a/cypress/integration/deposit-admin.spec.js +++ b/cypress/integration/deposit-admin.spec.js @@ -1,156 +1,156 @@ /** * Copyright (C) 2020-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // data to use as request query response let responseDeposits; let expectedOrigins; describe('Test admin deposit page', function() { beforeEach(() => { responseDeposits = [ { 'id': 614, 'external_id': 'ch-de-1', 'reception_date': '2020-05-18T13:48:27Z', 'status': 'done', 'status_detail': null, 'swhid': 'swh:1:dir:ef04a768', 'swhid_context': 'swh:1:dir:ef04a768;origin=https://w.s.o/c-d-1;visit=swh:1:snp:b234be1e;anchor=swh:1:rev:d24a75c9;path=/' }, { 'id': 613, 'external_id': 'ch-de-2', 'reception_date': '2020-05-18T11:20:16Z', 'status': 'done', 'status_detail': null, 'swhid': 'swh:1:dir:181417fb', 'swhid_context': 'swh:1:dir:181417fb;origin=https://w.s.o/c-d-2;visit=swh:1:snp:8c32a2ef;anchor=swh:1:rev:3d1eba04;path=/' }, { 'id': 612, 'external_id': 'ch-de-3', 'reception_date': '2020-05-18T11:20:16Z', 'status': 'rejected', 'status_detail': 'incomplete deposit!', 'swhid': null, 'swhid_context': null } ]; // those are computed from the expectedOrigins = { 614: 'https://w.s.o/c-d-1', 613: 'https://w.s.o/c-d-2', 612: '' }; }); it('Should display properly entries', function() { cy.adminLogin(); cy.visit(this.Urls.admin_deposit()); - let testDeposits = responseDeposits; + const testDeposits = responseDeposits; cy.intercept(`${this.Urls.admin_deposit_list()}**`, { body: { 'draw': 10, 'recordsTotal': testDeposits.length, 'recordsFiltered': testDeposits.length, 'data': testDeposits } }).as('listDeposits'); cy.location('pathname') .should('be.equal', this.Urls.admin_deposit()); cy.url().should('include', '/admin/deposit'); cy.get('#swh-admin-deposit-list') .should('exist'); cy.wait('@listDeposits').then((xhr) => { cy.log('response:', xhr.response); cy.log(xhr.response.body); - let deposits = xhr.response.body.data; + const deposits = xhr.response.body.data; cy.log('Deposits: ', deposits); expect(deposits.length).to.equal(testDeposits.length); cy.get('#swh-admin-deposit-list').find('tbody > tr').as('rows'); // only 2 entries cy.get('@rows').each((row, idx, collection) => { - let deposit = deposits[idx]; - let responseDeposit = testDeposits[idx]; + const deposit = deposits[idx]; + const responseDeposit = testDeposits[idx]; assert.isNotNull(deposit); assert.isNotNull(responseDeposit); expect(deposit.id).to.be.equal(responseDeposit['id']); expect(deposit.external_id).to.be.equal(responseDeposit['external_id']); expect(deposit.status).to.be.equal(responseDeposit['status']); expect(deposit.status_detail).to.be.equal(responseDeposit['status_detail']); expect(deposit.swhid).to.be.equal(responseDeposit['swhid']); expect(deposit.swhid_context).to.be.equal(responseDeposit['swhid_context']); - let expectedOrigin = expectedOrigins[deposit.id]; + const expectedOrigin = expectedOrigins[deposit.id]; // ensure it's in the dom cy.contains(deposit.id).should('be.visible'); if (deposit.status !== 'rejected') { expect(row).to.not.contain(deposit.external_id); cy.contains(expectedOrigin).should('be.visible'); } cy.contains(deposit.status).should('be.visible'); // those are hidden by default, so now visible if (deposit.status_detail !== null) { cy.contains(deposit.status_detail).should('not.exist'); } // those are hidden by default if (deposit.swhid !== null) { cy.contains(deposit.swhid).should('not.exist'); cy.contains(deposit.swhid_context).should('not.exist'); } }); // toggling all links and ensure, the previous checks are inverted cy.get('a.toggle-col').click({'multiple': true}).then(() => { cy.get('#swh-admin-deposit-list').find('tbody > tr').as('rows'); cy.get('@rows').each((row, idx, collection) => { - let deposit = deposits[idx]; - let expectedOrigin = expectedOrigins[deposit.id]; + const deposit = deposits[idx]; + const expectedOrigin = expectedOrigins[deposit.id]; // ensure it's in the dom cy.contains(deposit.id).should('not.exist'); if (deposit.status !== 'rejected') { expect(row).to.not.contain(deposit.external_id); expect(row).to.contain(expectedOrigin); } expect(row).to.not.contain(deposit.status); // those are hidden by default, so now visible if (deposit.status_detail !== null) { cy.contains(deposit.status_detail).should('be.visible'); } // those are hidden by default, so now they should be visible if (deposit.swhid !== null) { cy.contains(deposit.swhid).should('be.visible'); cy.contains(deposit.swhid_context).should('be.visible'); // check SWHID link text formatting cy.contains(deposit.swhid_context).then(elt => { expect(elt[0].innerHTML).to.equal(deposit.swhid_context.replace(/;/g, ';<br>')); }); } }); }); cy.get('#swh-admin-deposit-list-error') .should('not.contain', 'An error occurred while retrieving the list of deposits'); }); }); }); diff --git a/cypress/integration/directory.spec.js b/cypress/integration/directory.spec.js index c5be3bc2..4f1ba217 100644 --- a/cypress/integration/directory.spec.js +++ b/cypress/integration/directory.spec.js @@ -1,83 +1,83 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const $ = Cypress.$; let origin; let url; -let dirs = []; -let files = []; +const dirs = []; +const files = []; describe('Directory Tests', function() { before(function() { origin = this.origin[0]; url = `${this.Urls.browse_origin_directory()}?origin_url=${origin.url}`; - for (let entry of origin.dirContent) { + for (const entry of origin.dirContent) { if (entry.type === 'file') { files.push(entry); } else { dirs.push(entry); } } }); beforeEach(function() { cy.visit(url); }); it('should display all files and directories', function() { cy.get('.swh-directory') .should('have.length', dirs.length) .and('be.visible'); cy.get('.swh-content') .should('have.length', files.length) .and('be.visible'); }); it('should display sizes for files', function() { cy.get('.swh-content') .parent('tr') .then((rows) => { - for (let row of rows) { - let text = $(row).children('td').eq(2).text(); + for (const row of rows) { + const text = $(row).children('td').eq(2).text(); expect(text.trim()).to.not.be.empty; } }); }); it('should display readme when it is present', function() { cy.get('#readme-panel > .card-body') .should('be.visible') .and('have.class', 'swh-showdown') .and('not.be.empty') .and('not.contain', 'Readme bytes are not available'); }); it('should open subdirectory when clicked', function() { cy.get('.swh-directory') .first() .children('a') .click(); cy.url() .should('include', `${url}&path=${dirs[0]['name']}`); cy.get('.swh-directory-table') .should('be.visible'); }); it('should have metadata available from javascript', function() { cy.window().then(win => { const metadata = win.swh.webapp.getBrowsedSwhObjectMetadata(); expect(metadata).to.not.be.empty; expect(metadata).to.have.any.keys('directory'); }); }); }); diff --git a/cypress/integration/errors.spec.js b/cypress/integration/errors.spec.js index ce16fa1b..4708eba7 100644 --- a/cypress/integration/errors.spec.js +++ b/cypress/integration/errors.spec.js @@ -1,147 +1,147 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ let origin; const invalidChecksum = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const invalidPageUrl = '/invalidPath'; function urlShouldShowError(url, error) { cy.visit(url, { failOnStatusCode: false }); cy.get('.swh-http-error') .should('be.visible'); cy.get('.swh-http-error-code') .should('contain', error.code); cy.get('.swh-http-error-desc') .should('contain', error.msg); } describe('Test Errors', function() { before(function() { origin = this.origin[0]; }); it('should show navigation buttons on error page', function() { cy.visit(invalidPageUrl, { failOnStatusCode: false }); cy.get('a[onclick="window.history.back();"]') .should('be.visible'); cy.get('a[href="/"') .should('be.visible'); }); context('For unarchived repositories', function() { it('should display NotFoundExc for unarchived repo', function() { const url = `${this.Urls.browse_origin_directory()}?origin_url=${this.unarchivedRepo.url}`; urlShouldShowError(url, { code: '404', msg: 'NotFoundExc: Origin with url ' + this.unarchivedRepo.url + ' not found!' }); }); it('should display NotFoundExc for unarchived content', function() { const url = this.Urls.browse_content(`sha1_git:${this.unarchivedRepo.content[0].sha1git}`); urlShouldShowError(url, { code: '404', msg: 'NotFoundExc: Content with sha1_git checksum equals to ' + this.unarchivedRepo.content[0].sha1git + ' not found!' }); }); it('should display NotFoundExc for unarchived directory sha1git', function() { const url = this.Urls.browse_directory(this.unarchivedRepo.rootDirectory); urlShouldShowError(url, { code: '404', msg: 'NotFoundExc: Directory with sha1_git ' + this.unarchivedRepo.rootDirectory + ' not found' }); }); it('should display NotFoundExc for unarchived revision sha1git', function() { const url = this.Urls.browse_revision(this.unarchivedRepo.revision); urlShouldShowError(url, { code: '404', msg: 'NotFoundExc: Revision with sha1_git ' + this.unarchivedRepo.revision + ' not found.' }); }); it('should display NotFoundExc for unarchived snapshot sha1git', function() { const url = this.Urls.browse_snapshot(this.unarchivedRepo.snapshot); urlShouldShowError(url, { code: '404', msg: 'Snapshot with id ' + this.unarchivedRepo.snapshot + ' not found!' }); }); }); context('For archived repositories', function() { before(function() { const url = `${this.Urls.browse_origin_directory()}?origin_url=${origin.url}`; cy.visit(url); }); it('should display NotFoundExc for invalid directory from archived repo', function() { const subDir = `${this.Urls.browse_origin_directory()}?origin_url=${origin.url}&path=${origin.invalidSubDir}`; urlShouldShowError(subDir, { code: '404', msg: 'NotFoundExc: Directory entry with path ' + origin.invalidSubDir + ' from root directory ' + origin.rootDirectory + ' not found' }); }); it(`should display NotFoundExc for incorrect origin_url with correct content hash`, function() { const url = this.Urls.browse_content(`sha1_git:${origin.content[0].sha1git}`) + `?origin_url=${this.unarchivedRepo.url}`; urlShouldShowError(url, { code: '404', msg: 'The Software Heritage archive has a content ' + 'with the hash you provided but the origin ' + 'mentioned in your request appears broken: ' + this.unarchivedRepo.url + '. ' + 'Please check the URL and try again.\n\n' + 'Nevertheless, you can still browse the content ' + 'without origin information: ' + '/browse/content/sha1_git:' + origin.content[0].sha1git + '/' }); }); }); context('For invalid data', function() { it(`should display 400 for invalid checksum for directory, snapshot, revision, content`, function() { const types = ['directory', 'snapshot', 'revision', 'content']; - for (let type of types) { + for (const type of types) { const url = this.Urls[`browse_${type}`](invalidChecksum); urlShouldShowError(url, { code: '400', msg: 'BadInputExc: Invalid checksum query string ' + invalidChecksum }); } }); it('should show 404 error for invalid path', function() { urlShouldShowError(invalidPageUrl, { code: '404', msg: 'The resource ' + invalidPageUrl + ' could not be found on the server.' }); }); }); }); diff --git a/cypress/integration/home.spec.js b/cypress/integration/home.spec.js index 0491e710..1f8c6efd 100644 --- a/cypress/integration/home.spec.js +++ b/cypress/integration/home.spec.js @@ -1,108 +1,108 @@ /** * Copyright (C) 2019-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const $ = Cypress.$; const url = '/'; describe('Home Page Tests', function() { it('should have focus on search form after page load', function() { cy.visit(url); cy.get('#swh-origins-url-patterns') .should('have.attr', 'autofocus'); // for some reason, autofocus is not honored when running cypress tests // while it is in non controlled browsers // .should('have.focus'); }); it('should display positive stats for each category', function() { cy.intercept(this.Urls.stat_counters()) .as('getStatCounters'); cy.visit(url) .wait('@getStatCounters') .wait(500) .get('.swh-counter:visible') .then((counters) => { - for (let counter of counters) { - let innerText = $(counter).text(); + for (const counter of counters) { + const innerText = $(counter).text(); const value = parseInt(innerText.replace(/,/g, '')); assert.isAbove(value, 0); } }); }); it('should display null counters and hide history graphs when storage is empty', function() { cy.intercept(this.Urls.stat_counters(), { body: { 'stat_counters': {}, 'stat_counters_history': {} } }).as('getStatCounters'); cy.visit(url) .wait('@getStatCounters') .wait(500) .get('.swh-counter:visible') .then((counters) => { - for (let counter of counters) { + for (const counter of counters) { const value = parseInt($(counter).text()); assert.equal(value, 0); } }); cy.get('.swh-counter-history') .should('not.be.visible'); }); it('should hide counters when data is missing', function() { cy.intercept(this.Urls.stat_counters(), { body: { 'stat_counters': { 'content': 150, 'directory': 45, 'revision': 78 }, 'stat_counters_history': {} } }).as('getStatCounters'); cy.visit(url) .wait('@getStatCounters') .wait(500); cy.get('#swh-content-count, #swh-directory-count, #swh-revision-count') .should('be.visible'); cy.get('#swh-release-count, #swh-person-count, #swh-origin-count') .should('not.be.visible'); cy.get('.swh-counter-history') .should('not.be.visible'); }); it('should redirect to search page when submitting search form', function() { const searchText = 'git'; cy.get('#swh-origins-url-patterns') .type(searchText) .get('.swh-search-icon') .click(); cy.location('pathname') .should('equal', this.Urls.browse_search()); cy.location('search') .should('equal', `?q=${searchText}&with_visit=true&with_content=true`); }); }); diff --git a/cypress/integration/layout.spec.js b/cypress/integration/layout.spec.js index dd17d854..5944b1d5 100644 --- a/cypress/integration/layout.spec.js +++ b/cypress/integration/layout.spec.js @@ -1,231 +1,231 @@ /** * Copyright (C) 2019-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const url = '/browse/help/'; const statusUrl = 'https://status.softwareheritage.org'; describe('Test top-bar', function() { beforeEach(function() { cy.clearLocalStorage(); cy.visit(url); }); it('should should contain all navigation links', function() { cy.get('.swh-top-bar a') .should('have.length.of.at.least', 4) .and('be.visible') .and('have.attr', 'href'); }); it('should show donate button on lg screen', function() { cy.get('.swh-donate-link') .should('be.visible'); }); it('should hide donate button on sm screen', function() { cy.viewport(600, 800); cy.get('.swh-donate-link') .should('not.be.visible'); }); it('should hide full width switch on small screens', function() { cy.viewport(360, 740); cy.get('#swh-full-width-switch-container') .should('not.be.visible'); cy.viewport(600, 800); cy.get('#swh-full-width-switch-container') .should('not.be.visible'); cy.viewport(800, 600); cy.get('#swh-full-width-switch-container') .should('not.be.visible'); }); it('should show full width switch on large screens', function() { cy.viewport(1024, 768); cy.get('#swh-full-width-switch-container') .should('be.visible'); cy.viewport(1920, 1080); cy.get('#swh-full-width-switch-container') .should('be.visible'); }); it('should change container width when toggling Full width switch', function() { cy.get('#swh-web-content') .should('have.class', 'container') .should('not.have.class', 'container-fluid'); cy.should(() => { expect(JSON.parse(localStorage.getItem('swh-web-full-width'))).to.be.null; }); cy.get('#swh-full-width-switch') .click({force: true}); cy.get('#swh-web-content') .should('not.have.class', 'container') .should('have.class', 'container-fluid'); cy.should(() => { expect(JSON.parse(localStorage.getItem('swh-web-full-width'))).to.be.true; }); cy.get('#swh-full-width-switch') .click({force: true}); cy.get('#swh-web-content') .should('have.class', 'container') .should('not.have.class', 'container-fluid'); cy.should(() => { expect(JSON.parse(localStorage.getItem('swh-web-full-width'))).to.be.false; }); }); it('should restore container width when loading page again', function() { cy.visit(url) .get('#swh-web-content') .should('have.class', 'container') .should('not.have.class', 'container-fluid'); cy.get('#swh-full-width-switch') .click({force: true}); cy.visit(url) .get('#swh-web-content') .should('not.have.class', 'container') .should('have.class', 'container-fluid'); cy.get('#swh-full-width-switch') .click({force: true}); cy.visit(url) .get('#swh-web-content') .should('have.class', 'container') .should('not.have.class', 'container-fluid'); }); function genStatusResponse(status, statusCode) { return { 'result': { 'status': [ { 'id': '5f7c4c567f50b304c1e7bd5f', 'name': 'Save Code Now', 'updated': '2020-11-30T13:51:21.151Z', 'status': 'Operational', 'status_code': 100 }, { 'id': '5f7c4c6f8338bc04b7f476fe', 'name': 'Source Code Crawlers', 'updated': '2020-11-30T13:51:21.151Z', 'status': status, 'status_code': statusCode } ] } }; } it('should display swh status widget when data are available', function() { const statusTestData = [ { status: 'Operational', statusCode: 100, color: 'green' }, { status: 'Scheduled Maintenance', statusCode: 200, color: 'blue' }, { status: 'Degraded Performance', statusCode: 300, color: 'yellow' }, { status: 'Partial Service Disruption', statusCode: 400, color: 'yellow' }, { status: 'Service Disruption', statusCode: 500, color: 'red' }, { status: 'Security Event', statusCode: 600, color: 'red' } ]; const responses = []; - for (let std of statusTestData) { + for (const std of statusTestData) { responses.push(genStatusResponse(std.status, std.statusCode)); } let i = 0; - for (let std of statusTestData) { + for (const std of statusTestData) { cy.visit(url); // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept(`${statusUrl}/**`, req => req.reply(responses.shift())) .as(`getSwhStatusData${i}`); cy.wait(`@getSwhStatusData${i}`); cy.get('.swh-current-status-indicator').should('have.class', std.color); cy.get('#swh-current-status-description').should('have.text', std.status); ++i; } }); it('should not display swh status widget when data are not available', function() { cy.intercept(`${statusUrl}/**`, { body: {} }).as('getSwhStatusData'); cy.visit(url); cy.wait('@getSwhStatusData'); cy.get('.swh-current-status').should('not.exist'); }); }); describe('Test navbar', function() { it('should redirect to search page when submitting search form in navbar', function() { const keyword = 'python'; cy.get('#swh-origins-search-top-input') .type(keyword); cy.get('.swh-search-navbar') .submit(); cy.url() .should('include', `${this.Urls.browse_search()}?q=${keyword}`); }); }); describe('Test footer', function() { beforeEach(function() { cy.visit(url); }); it('should be visible', function() { cy.get('footer') .should('be.visible'); }); it('should have correct copyright years', function() { const currentYear = new Date().getFullYear(); const copyrightText = '(C) 2015–' + currentYear.toString(); cy.get('footer') .should('contain', copyrightText); }); it('should contain link to Web API', function() { cy.get('footer') .get(`a[href="${this.Urls.api_1_homepage()}"]`) .should('contain', 'Web API'); }); }); diff --git a/cypress/integration/origin-save.spec.js b/cypress/integration/origin-save.spec.js index becb6ed8..b2300530 100644 --- a/cypress/integration/origin-save.spec.js +++ b/cypress/integration/origin-save.spec.js @@ -1,711 +1,711 @@ /** * Copyright (C) 2019-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ let url; let origin; const $ = Cypress.$; const saveCodeMsg = { 'success': 'The "save code now" request has been accepted and will be processed as soon as possible.', 'warning': 'The "save code now" request has been put in pending state and may be accepted for processing after manual review.', 'rejected': 'The "save code now" request has been rejected because the provided origin url is blacklisted.', 'rateLimit': 'The rate limit for "save code now" requests has been reached. Please try again later.', 'not-found': 'The provided url does not exist', 'unknownError': 'An unexpected error happened when submitting the "save code now request', 'csrfError': 'CSRF Failed: Referrer checking failed - no Referrer.' }; const anonymousVisitTypes = ['git', 'hg', 'svn']; const allVisitTypes = ['archives', 'git', 'hg', 'svn']; function makeOriginSaveRequest(originType, originUrl) { cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select(originType) .get('#swh-save-origin-form') .submit(); } function checkAlertVisible(alertType, msg) { cy.get('#swh-origin-save-request-status') .should('be.visible') .find(`.alert-${alertType}`) .should('be.visible') .and('contain', msg); } // Stub requests to save an origin function stubSaveRequest({ requestUrl, visitType = 'git', saveRequestStatus, originUrl, saveTaskStatus, responseStatus = 200, // For error code with the error message in the 'reason' key response errorMessage = '', saveRequestDate = new Date(), visitDate = new Date(), visitStatus = null } = {}) { let response; if (responseStatus !== 200 && errorMessage) { response = { 'reason': errorMessage }; } else { response = genOriginSaveResponse({visitType: visitType, saveRequestStatus: saveRequestStatus, originUrl: originUrl, saveRequestDate: saveRequestDate, saveTaskStatus: saveTaskStatus, visitDate: visitDate, visitStatus: visitStatus }); } cy.intercept('POST', requestUrl, {body: response, statusCode: responseStatus}) .as('saveRequest'); } // Mocks API response : /save/(:visit_type)/(:origin_url) // visit_type : {'git', 'hg', 'svn', ...} function genOriginSaveResponse({ visitType = 'git', saveRequestStatus, originUrl, saveRequestDate = new Date(), saveTaskStatus, visitDate = new Date(), visitStatus } = {}) { return { 'visit_type': visitType, 'save_request_status': saveRequestStatus, 'origin_url': originUrl, 'id': 1, 'save_request_date': saveRequestDate ? saveRequestDate.toISOString() : null, 'save_task_status': saveTaskStatus, 'visit_date': visitDate ? visitDate.toISOString() : null, 'visit_status': visitStatus }; }; describe('Origin Save Tests', function() { before(function() { url = this.Urls.origin_save(); origin = this.origin[0]; this.originSaveUrl = this.Urls.api_1_save_origin(origin.type, origin.url); }); beforeEach(function() { cy.fixture('origin-save').as('originSaveJSON'); cy.fixture('save-task-info').as('saveTaskInfoJSON'); cy.visit(url); }); it('should format appropriately values depending on their type', function() { - let inputValues = [ // null values stay null + const inputValues = [ // null values stay null {type: 'json', value: null, expectedValue: null}, {type: 'date', value: null, expectedValue: null}, {type: 'raw', value: null, expectedValue: null}, {type: 'duration', value: null, expectedValue: null}, // non null values formatted depending on their type {type: 'json', value: '{}', expectedValue: '"{}"'}, {type: 'date', value: '04/04/2021 01:00:00', expectedValue: '4/4/2021, 1:00:00 AM'}, {type: 'raw', value: 'value-for-identity', expectedValue: 'value-for-identity'}, {type: 'duration', value: '10', expectedValue: '10 seconds'}, {type: 'duration', value: 100, expectedValue: '100 seconds'} ]; cy.window().then(win => { inputValues.forEach(function(input, index, array) { - let actualValue = win.swh.save.formatValuePerType(input.type, input.value); + const actualValue = win.swh.save.formatValuePerType(input.type, input.value); assert.equal(actualValue, input.expectedValue); }); }); }); it('should display accepted message when accepted', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'accepted', originUrl: origin.url, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should validate gitlab subproject url', function() { const gitlabSubProjectUrl = 'https://gitlab.com/user/project/sub/'; const originSaveUrl = this.Urls.api_1_save_origin('git', gitlabSubProjectUrl); stubSaveRequest({requestUrl: originSaveUrl, saveRequestStatus: 'accepted', originurl: gitlabSubProjectUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', gitlabSubProjectUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should validate project url with _ in username', function() { const gitlabSubProjectUrl = 'https://gitlab.com/user_name/project.git'; const originSaveUrl = this.Urls.api_1_save_origin('git', gitlabSubProjectUrl); stubSaveRequest({requestUrl: originSaveUrl, saveRequestStatus: 'accepted', originurl: gitlabSubProjectUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', gitlabSubProjectUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should display warning message when pending', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'pending', originUrl: origin.url, saveTaskStatus: 'not created'}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('warning', saveCodeMsg['warning']); }); }); it('should show error when the origin does not exist (status: 400)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, originUrl: origin.url, responseStatus: 400, errorMessage: saveCodeMsg['not-found']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['not-found']); }); }); it('should show error when csrf validation failed (status: 403)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'rejected', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 403, errorMessage: saveCodeMsg['csrfError']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['csrfError']); }); }); it('should show error when origin is rejected (status: 403)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'rejected', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 403, errorMessage: saveCodeMsg['rejected']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['rejected']); }); }); it('should show error when rate limited (status: 429)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'Request was throttled. Expected available in 60 seconds.', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 429}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['rateLimit']); }); }); it('should show error when unknown error occurs (status other than 200, 403, 429)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'Error', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 406}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['unknownError']); }); }); it('should display origin save info in the requests table', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('tbody tr').then(rows => { let i = 0; - for (let row of rows) { + for (const row of rows) { const cells = row.cells; const requestDateStr = new Date(this.originSaveJSON.data[i].save_request_date).toLocaleString(); const saveStatus = this.originSaveJSON.data[i].save_task_status; assert.equal($(cells[0]).text(), requestDateStr); assert.equal($(cells[1]).text(), this.originSaveJSON.data[i].visit_type); let html = ''; if (saveStatus === 'succeeded') { let browseOriginUrl = `${this.Urls.browse_origin()}?origin_url=${encodeURIComponent(this.originSaveJSON.data[i].origin_url)}`; browseOriginUrl += `&timestamp=${encodeURIComponent(this.originSaveJSON.data[i].visit_date)}`; html += `<a href="${browseOriginUrl}">${this.originSaveJSON.data[i].origin_url}</a>`; } else { html += this.originSaveJSON.data[i].origin_url; } html += ` <a href="${this.originSaveJSON.data[i].origin_url}">`; html += '<i class="mdi mdi-open-in-new" aria-hidden="true"></i></a>'; assert.equal($(cells[2]).html(), html); assert.equal($(cells[3]).text(), this.originSaveJSON.data[i].save_request_status); assert.equal($(cells[4]).text(), saveStatus); ++i; } }); }); it('should not add timestamp to the browse origin URL is no visit date has been found', function() { const originUrl = 'https://git.example.org/example.git'; const saveRequestData = genOriginSaveResponse({ saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'succeeded', visitDate: null, visitStatus: 'full' }); const saveRequestsListData = { 'recordsTotal': 1, 'draw': 2, 'recordsFiltered': 1, 'data': [saveRequestData] }; cy.intercept('/save/requests/list/**', {body: saveRequestsListData}) .as('saveRequestsList'); cy.get('#swh-origin-save-requests-list-tab').click(); cy.wait('@saveRequestsList'); cy.get('tbody tr').then(rows => { const firstRowCells = rows[0].cells; const browseOriginUrl = `${this.Urls.browse_origin()}?origin_url=${encodeURIComponent(originUrl)}`; const browseOriginLink = `<a href="${browseOriginUrl}">${originUrl}</a>`; expect($(firstRowCells[2]).html()).to.have.string(browseOriginLink); }); }); it('should display/close task info popover when clicking on the info button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.intercept('/save/task/info/**', {fixture: 'save-task-info'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('.swh-save-request-info') .eq(0) .click(); cy.get('.swh-save-request-info-popover') .should('be.visible'); cy.get('.swh-save-request-info') .eq(0) .click(); cy.get('.swh-save-request-info-popover') .should('not.exist'); }); it('should hide task info popover when clicking on the close button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.intercept('/save/task/info/**', {fixture: 'save-task-info'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('.swh-save-request-info') .eq(0) .click(); cy.get('.swh-save-request-info-popover') .should('be.visible'); cy.get('.swh-save-request-info-close') .click(); cy.get('.swh-save-request-info-popover') .should('not.exist'); }); it('should fill save request form when clicking on "Save again" button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('.swh-save-origin-again') .eq(0) .click(); cy.get('tbody tr').eq(0).then(row => { const cells = row[0].cells; cy.get('#swh-input-visit-type') .should('have.value', $(cells[1]).text()); cy.get('#swh-input-origin-url') .should('have.value', $(cells[2]).text().slice(0, -1)); }); }); it('should select correct visit type if possible when clicking on "Save again" button', function() { const originUrl = 'https://gitlab.inria.fr/solverstack/maphys/maphys/'; const badVisitType = 'hg'; const goodVisitType = 'git'; cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); stubSaveRequest({requestUrl: this.Urls.api_1_save_origin(badVisitType, originUrl), visitType: badVisitType, saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'failed', visitStatus: 'failed', responseStatus: 200, errorMessage: saveCodeMsg['accepted']}); makeOriginSaveRequest(badVisitType, originUrl); cy.get('#swh-origin-save-requests-list-tab').click(); cy.wait('@saveRequest').then(() => { cy.get('.swh-save-origin-again') .eq(0) .click(); cy.get('tbody tr').eq(0).then(row => { const cells = row[0].cells; cy.get('#swh-input-visit-type') .should('have.value', goodVisitType); cy.get('#swh-input-origin-url') .should('have.value', $(cells[2]).text().slice(0, -1)); }); }); }); it('should create save request for authenticated user', function() { cy.userLogin(); cy.visit(url); const originUrl = 'https://git.example.org/account/repo'; stubSaveRequest({requestUrl: this.Urls.api_1_save_origin('git', originUrl), saveRequestStatus: 'accepted', originUrl: origin.url, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', originUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should not show user requests filter checkbox for anonymous users', function() { cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('not.exist'); }); it('should show user requests filter checkbox for authenticated users', function() { cy.userLogin(); cy.visit(url); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('exist'); }); it('should show only user requests when filter is activated', function() { cy.intercept('POST', '/api/1/origin/save/**') .as('saveRequest'); const originAnonymousUser = 'https://some.git.server/project/'; const originAuthUser = 'https://other.git.server/project/'; // anonymous user creates a save request makeOriginSaveRequest('git', originAnonymousUser); cy.wait('@saveRequest'); // authenticated user creates another save request cy.userLogin(); cy.visit(url); makeOriginSaveRequest('git', originAuthUser); cy.wait('@saveRequest'); // user requests filter checkbox should be in the DOM cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('exist'); // check unfiltered user requests cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2); expect($(rows[0].cells[2]).text()).to.contain(originAuthUser); expect($(rows[1].cells[2]).text()).to.contain(originAnonymousUser); }); // activate filter and check filtered user requests cy.get('#swh-save-requests-user-filter') .click({force: true}); cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(1); expect($(rows[0].cells[2]).text()).to.contain(originAuthUser); }); // deactivate filter and check unfiltered user requests cy.get('#swh-save-requests-user-filter') .click({force: true}); cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2); }); }); it('should list unprivileged visit types when not connected', function() { cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(anonymousVisitTypes); }); }); it('should list unprivileged visit types when connected as unprivileged user', function() { cy.userLogin(); cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(anonymousVisitTypes); }); }); it('should list privileged visit types when connected as ambassador', function() { cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(allVisitTypes); }); }); it('should display extra inputs when dealing with \'archives\' visit type', function() { cy.ambassadorLogin(); cy.visit(url); - for (let visitType of anonymousVisitTypes) { + for (const visitType of anonymousVisitTypes) { cy.get('#swh-input-visit-type').select(visitType); cy.get('.swh-save-origin-archives-form').should('not.be.visible'); } // this should display more inputs with the 'archives' type cy.get('#swh-input-visit-type').select('archives'); cy.get('.swh-save-origin-archives-form').should('be.visible'); }); it('should be allowed to submit \'archives\' save request when connected as ambassador', function() { - let originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; - let artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; - let artifactVersion = '1.1.4'; + const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; + const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; + const artifactVersion = '1.1.4'; stubSaveRequest({ requestUrl: this.Urls.api_1_save_origin('archives', originUrl), saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'not yet scheduled' }); cy.ambassadorLogin(); cy.visit(url); // input new 'archives' information and submit cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives') .get('#swh-input-artifact-url-0') .type(artifactUrl) .get('#swh-input-artifact-version-0') .clear() .type(artifactVersion) .get('#swh-save-origin-form') .submit(); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should submit multiple artifacts for the archives visit type', function() { - let originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; - let artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; - let artifactVersion = '1.1.4'; - let artifact2Url = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.5.tar.gz'; - let artifact2Version = '1.1.5'; + const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; + const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; + const artifactVersion = '1.1.4'; + const artifact2Url = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.5.tar.gz'; + const artifact2Version = '1.1.5'; cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives'); // fill first artifact info cy.get('#swh-input-artifact-url-0') .type(artifactUrl) .get('#swh-input-artifact-version-0') .clear() .type(artifactVersion); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // check new row is displayed cy.get('#swh-input-artifact-url-1') .should('exist'); // request removal of newly added row cy.get('#swh-remove-archive-artifact-1') .click(); // check row has been removed cy.get('#swh-input-artifact-url-1') .should('not.exist'); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // fill second artifact info cy.get('#swh-input-artifact-url-1') .type(artifact2Url) .get('#swh-input-artifact-version-1') .clear() .type(artifact2Version); // setup request interceptor to check POST data and stub response cy.intercept('POST', this.Urls.api_1_save_origin('archives', originUrl), (req) => { expect(req.body).to.deep.equal({ archives_data: [ {artifact_url: artifactUrl, artifact_version: artifactVersion}, {artifact_url: artifact2Url, artifact_version: artifact2Version} ] }); req.reply(genOriginSaveResponse({ visitType: 'archives', saveRequestStatus: 'accepted', originUrl: originUrl, saveRequestDate: new Date(), saveTaskStatus: 'not yet scheduled', visitDate: null, visitStatus: null })); }).as('saveRequest'); // submit form cy.get('#swh-save-origin-form') .submit(); // submission should be successful cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should autofill artifact version when pasting artifact url', function() { - let originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; - let artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; - let artifactVersion = '3DLDF-1.1.4'; - let artifact2Url = 'https://example.org/artifact/test/1.3.0.zip'; - let artifact2Version = '1.3.0'; + const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; + const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; + const artifactVersion = '3DLDF-1.1.4'; + const artifact2Url = 'https://example.org/artifact/test/1.3.0.zip'; + const artifact2Version = '1.3.0'; cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives'); // fill first artifact info cy.get('#swh-input-artifact-url-0') .type(artifactUrl); // check autofilled version cy.get('#swh-input-artifact-version-0') .should('have.value', artifactVersion); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // fill second artifact info cy.get('#swh-input-artifact-url-1') .type(artifact2Url); // check autofilled version cy.get('#swh-input-artifact-version-1') .should('have.value', artifact2Version); }); it('should use canonical URL for github repository to save', function() { const ownerRepo = 'BIC-MNI/mni_autoreg'; const canonicalOriginUrl = 'https://github.com/BIC-MNI/mni_autoreg'; // stub call to github Web API fetching canonical repo URL cy.intercept(`https://api.github.com/repos/${ownerRepo.toLowerCase()}`, (req) => { req.reply({html_url: canonicalOriginUrl}); }).as('ghWebApiRequest'); // stub save request creation with canonical URL of github repo cy.intercept('POST', this.Urls.api_1_save_origin('git', canonicalOriginUrl), (req) => { req.reply(genOriginSaveResponse({ visitType: 'git', saveRequestStatus: 'accepted', originUrl: canonicalOriginUrl, saveRequestDate: new Date(), saveTaskStatus: 'not yet scheduled', visitDate: null, visitStatus: null })); }).as('saveRequest'); - for (let originUrl of ['https://github.com/BiC-MnI/MnI_AuToReG', - 'https://github.com/BiC-MnI/MnI_AuToReG.git', - 'https://github.com/BiC-MnI/MnI_AuToReG/']) { + for (const originUrl of ['https://github.com/BiC-MnI/MnI_AuToReG', + 'https://github.com/BiC-MnI/MnI_AuToReG.git', + 'https://github.com/BiC-MnI/MnI_AuToReG/']) { // enter non canonical URL of github repo cy.get('#swh-input-origin-url') .clear() .type(originUrl); // submit form cy.get('#swh-save-origin-form') .submit(); // submission should be successful cy.wait('@ghWebApiRequest') .wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); } }); }); diff --git a/cypress/integration/origin-search.spec.js b/cypress/integration/origin-search.spec.js index 4ecf0b12..6a7f9288 100644 --- a/cypress/integration/origin-search.spec.js +++ b/cypress/integration/origin-search.spec.js @@ -1,569 +1,569 @@ /** * Copyright (C) 2019-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const nonExistentText = 'NoMatchExists'; let origin; let url; function doSearch(searchText, searchInputElt = '#swh-origins-url-patterns') { if (searchText.startsWith('swh:')) { cy.intercept('**/api/1/resolve/**') .as('swhidResolve'); } cy.get(searchInputElt) // to avoid sending too much SWHID validation requests // as cypress insert character one by one when using type .invoke('val', searchText.slice(0, -1)) .type(searchText.slice(-1)) .get('.swh-search-icon') .click({force: true}); if (searchText.startsWith('swh:')) { cy.wait('@swhidResolve'); } } function searchShouldRedirect(searchText, redirectUrl) { doSearch(searchText); cy.location('pathname') .should('equal', redirectUrl); } function searchShouldShowNotFound(searchText, msg) { doSearch(searchText); if (searchText.startsWith('swh:')) { cy.get('.invalid-feedback') .should('be.visible') .and('contain', msg); } } function stubOriginVisitLatestRequests(status = 200, response = {type: 'tar'}, aliasSuffix = '') { cy.intercept({url: '**/visit/latest/**'}, { body: response, statusCode: status }).as(`originVisitLatest${aliasSuffix}`); } describe('Test origin-search', function() { before(function() { origin = this.origin[0]; url = this.Urls.browse_search(); }); beforeEach(function() { cy.visit(url); }); it('should have focus on search form after page load', function() { cy.get('#swh-origins-url-patterns') .should('have.attr', 'autofocus'); // for some reason, autofocus is not honored when running cypress tests // while it is in non controlled browsers // .should('have.focus'); }); it('should redirect to browse when archived URL is searched', function() { cy.get('#swh-origins-url-patterns') .type(origin.url); cy.get('.swh-search-icon') .click(); cy.location('pathname') .should('eq', this.Urls.browse_origin_directory()); cy.location('search') .should('eq', `?origin_url=${origin.url}`); }); it('should not redirect for non valid URL', function() { cy.get('#swh-origins-url-patterns') .type('www.example'); // Invalid URL cy.get('.swh-search-icon') .click(); cy.location('pathname') .should('eq', this.Urls.browse_search()); // Stay in the current page }); it('should not redirect for valid non archived URL', function() { cy.get('#swh-origins-url-patterns') .type('http://eaxmple.com/test/'); // Valid URL, but not archived cy.get('.swh-search-icon') .click(); cy.location('pathname') .should('eq', this.Urls.browse_search()); // Stay in the current page }); it('should remove origin URL with no archived content', function() { stubOriginVisitLatestRequests(404); // Using a non full origin URL here // This is because T3354 redirects to the origin in case of a valid, archived URL cy.get('#swh-origins-url-patterns') .type(origin.url.slice(0, -1)); cy.get('.swh-search-icon') .click(); cy.wait('@originVisitLatest'); cy.get('#origin-search-results') .should('be.visible') .find('tbody tr').should('have.length', 0); stubOriginVisitLatestRequests(200, {}, '2'); cy.get('.swh-search-icon') .click(); cy.wait('@originVisitLatest2'); cy.get('#origin-search-results') .should('be.visible') .find('tbody tr').should('have.length', 0); }); it('should filter origins by visit type', function() { cy.intercept('**/visit/latest/**').as('checkOriginVisits'); cy.get('#swh-origins-url-patterns') .type('http'); - for (let visitType of ['git', 'tar']) { + for (const visitType of ['git', 'tar']) { cy.get('#swh-search-visit-type') .select(visitType); cy.get('.swh-search-icon') .click(); cy.wait('@checkOriginVisits'); cy.get('#origin-search-results') .should('be.visible'); cy.get('tbody tr td.swh-origin-visit-type').then(elts => { - for (let elt of elts) { + for (const elt of elts) { cy.get(elt).should('have.text', visitType); } }); } }); it('should show not found message when no repo matches', function() { searchShouldShowNotFound(nonExistentText, 'No origins matching the search criteria were found.'); }); it('should add appropriate URL parameters', function() { // Check all three checkboxes and check if // correct url params are added cy.get('#swh-search-origins-with-visit') .check({force: true}) .get('#swh-filter-empty-visits') .check({force: true}) .get('#swh-search-origin-metadata') .check({force: true}) .then(() => { const searchText = origin.url; doSearch(searchText); cy.location('search').then(locationSearch => { const urlParams = new URLSearchParams(locationSearch); const query = urlParams.get('q'); const withVisit = urlParams.has('with_visit'); const withContent = urlParams.has('with_content'); const searchMetadata = urlParams.has('search_metadata'); assert.strictEqual(query, searchText); assert.strictEqual(withVisit, true); assert.strictEqual(withContent, true); assert.strictEqual(searchMetadata, true); }); }); }); it('should search in origin intrinsic metadata', function() { cy.intercept('GET', '**/origin/metadata-search/**').as( 'originMetadataSearch' ); cy.get('#swh-search-origins-with-visit') .check({force: true}) .get('#swh-filter-empty-visits') .check({force: true}) .get('#swh-search-origin-metadata') .check({force: true}) .then(() => { const searchText = 'plugin'; doSearch(searchText); console.log(searchText); cy.wait('@originMetadataSearch').then((req) => { expect(req.response.body[0].metadata.metadata.description).to.equal( 'Line numbering plugin for Highlight.js' // metadata is defined in _TEST_ORIGINS variable in swh/web/tests/data.py ); }); }); }); it('should not send request to the resolve endpoint', function() { cy.intercept(`${this.Urls.api_1_resolve_swhid('').slice(0, -1)}**`) .as('resolveSWHID'); cy.intercept(`${this.Urls.api_1_origin_search(origin.url.slice(0, -1))}**`) .as('searchOrigin'); cy.get('#swh-origins-url-patterns') .type(origin.url.slice(0, -1)); cy.get('.swh-search-icon') .click(); cy.wait('@searchOrigin'); cy.xhrShouldBeCalled('resolveSWHID', 0); cy.xhrShouldBeCalled('searchOrigin', 1); }); context('Test pagination', function() { it('should not paginate if there are not many results', function() { // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'libtess'; // Get first page of results doSearch(searchText); cy.get('.swh-search-result-entry') .should('have.length', 1); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://github.com/memononen/libtess2'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('have.class', 'disabled'); }); }); it('should paginate forward when there are many results', function() { stubOriginVisitLatestRequests(); // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'many.origins'; // Get first page of results doSearch(searchText); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/1'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/100'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get second page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/101'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/200'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get third (and last) page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 50); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/201'); cy.get('.swh-search-result-entry#origin-49 td a') .should('have.text', 'https://many.origins/250'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('have.class', 'disabled'); }); }); it('should paginate backward from a middle page', function() { stubOriginVisitLatestRequests(); // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'many.origins'; // Get first page of results doSearch(searchText); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get second page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get first page of results again cy.get('#origins-prev-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/1'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/100'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); }); }); it('should paginate backward from the last page', function() { stubOriginVisitLatestRequests(); // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'many.origins'; // Get first page of results doSearch(searchText); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get second page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get third (and last) page of results cy.get('#origins-next-results-button a') .click(); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('have.class', 'disabled'); // Get second page of results again cy.get('#origins-prev-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/101'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/200'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get first page of results again cy.get('#origins-prev-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/1'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/100'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); }); }); }); context('Test valid SWHIDs', function() { it('should resolve directory', function() { const redirectUrl = this.Urls.browse_directory(origin.content[0].directory); const swhid = `swh:1:dir:${origin.content[0].directory}`; searchShouldRedirect(swhid, redirectUrl); }); it('should resolve revision', function() { const redirectUrl = this.Urls.browse_revision(origin.revisions[0]); const swhid = `swh:1:rev:${origin.revisions[0]}`; searchShouldRedirect(swhid, redirectUrl); }); it('should resolve snapshot', function() { const redirectUrl = this.Urls.browse_snapshot_directory(origin.snapshot); const swhid = `swh:1:snp:${origin.snapshot}`; searchShouldRedirect(swhid, redirectUrl); }); it('should resolve content', function() { const redirectUrl = this.Urls.browse_content(`sha1_git:${origin.content[0].sha1git}`); const swhid = `swh:1:cnt:${origin.content[0].sha1git}`; searchShouldRedirect(swhid, redirectUrl); }); it('should not send request to the search endpoint', function() { const swhid = `swh:1:rev:${origin.revisions[0]}`; cy.intercept(this.Urls.api_1_resolve_swhid(swhid)) .as('resolveSWHID'); cy.intercept(`${this.Urls.api_1_origin_search('').slice(0, -1)}**`) .as('searchOrigin'); cy.get('#swh-origins-url-patterns') .type(swhid); cy.get('.swh-search-icon') .click(); cy.wait('@resolveSWHID'); cy.xhrShouldBeCalled('resolveSWHID', 1); cy.xhrShouldBeCalled('searchOrigin', 0); }); }); context('Test invalid SWHIDs', function() { it('should show not found for directory', function() { const swhid = `swh:1:dir:${this.unarchivedRepo.rootDirectory}`; const msg = `Directory with sha1_git ${this.unarchivedRepo.rootDirectory} not found`; searchShouldShowNotFound(swhid, msg); }); it('should show not found for snapshot', function() { const swhid = `swh:1:snp:${this.unarchivedRepo.snapshot}`; const msg = `Snapshot with id ${this.unarchivedRepo.snapshot} not found!`; searchShouldShowNotFound(swhid, msg); }); it('should show not found for revision', function() { const swhid = `swh:1:rev:${this.unarchivedRepo.revision}`; const msg = `Revision with sha1_git ${this.unarchivedRepo.revision} not found.`; searchShouldShowNotFound(swhid, msg); }); it('should show not found for content', function() { const swhid = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git}`; const msg = `Content with sha1_git checksum equals to ${this.unarchivedRepo.content[0].sha1git} not found!`; searchShouldShowNotFound(swhid, msg); }); function checkInvalidSWHIDReport(url, searchInputElt, swhidInput, validationMessagePattern = '') { cy.visit(url); doSearch(swhidInput, searchInputElt); cy.get(searchInputElt) .then($el => $el[0].checkValidity()).should('be.false'); cy.get(searchInputElt) .invoke('prop', 'validationMessage') .should('not.equal', '') .should('contain', validationMessagePattern); } it('should report invalid SWHID in search page input', function() { const swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git};lines=45-60/`; checkInvalidSWHIDReport(this.Urls.browse_search(), '#swh-origins-url-patterns', swhidInput); cy.get('.invalid-feedback') .should('be.visible'); }); it('should report invalid SWHID in top right search input', function() { const swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git};lines=45-60/`; checkInvalidSWHIDReport(this.Urls.browse_help(), '#swh-origins-search-top-input', swhidInput); }); it('should report SWHID with uppercase chars in search page input', function() { const swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git}`.toUpperCase(); checkInvalidSWHIDReport(this.Urls.browse_search(), '#swh-origins-url-patterns', swhidInput, swhidInput.toLowerCase()); cy.get('.invalid-feedback') .should('be.visible'); }); it('should report SWHID with uppercase chars in top right search input', function() { let swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git}`.toUpperCase(); swhidInput += ';lines=45-60/'; checkInvalidSWHIDReport(this.Urls.browse_help(), '#swh-origins-search-top-input', swhidInput.toLowerCase()); }); }); }); diff --git a/cypress/integration/persistent-identifiers.spec.js b/cypress/integration/persistent-identifiers.spec.js index a4d57cfd..5211d963 100644 --- a/cypress/integration/persistent-identifiers.spec.js +++ b/cypress/integration/persistent-identifiers.spec.js @@ -1,228 +1,228 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ let origin, originBadgeUrl, originBrowseUrl; let url, urlPrefix; let cntSWHID, cntSWHIDWithContext; let dirSWHID, dirSWHIDWithContext; let relSWHID, relSWHIDWithContext; let revSWHID, revSWHIDWithContext; let snpSWHID, snpSWHIDWithContext; let testsData; const firstSelLine = 6; const lastSelLine = 12; describe('Persistent Identifiers Tests', function() { before(function() { origin = this.origin[1]; url = `${this.Urls.browse_origin_content()}?origin_url=${origin.url}&path=${origin.content[0].path}`; url = `${url}&release=${origin.release.name}#L${firstSelLine}-L${lastSelLine}`; originBadgeUrl = this.Urls.swh_badge('origin', origin.url); originBrowseUrl = `${this.Urls.browse_origin()}?origin_url=${origin.url}`; cy.visit(url).window().then(win => { urlPrefix = `${win.location.protocol}//${win.location.hostname}`; if (win.location.port) { urlPrefix += `:${win.location.port}`; } const swhids = win.swh.webapp.getSwhIdsContext(); cntSWHID = swhids.content.swhid; cntSWHIDWithContext = swhids.content.swhid_with_context; cntSWHIDWithContext += `;lines=${firstSelLine}-${lastSelLine}`; dirSWHID = swhids.directory.swhid; dirSWHIDWithContext = swhids.directory.swhid_with_context; revSWHID = swhids.revision.swhid; revSWHIDWithContext = swhids.revision.swhid_with_context; relSWHID = swhids.release.swhid; relSWHIDWithContext = swhids.release.swhid_with_context; snpSWHID = swhids.snapshot.swhid; snpSWHIDWithContext = swhids.snapshot.swhid_with_context; testsData = [ { 'objectType': 'content', 'objectSWHIDs': [cntSWHIDWithContext, cntSWHID], 'badgeUrl': this.Urls.swh_badge('content', swhids.content.object_id), 'badgeSWHIDUrl': this.Urls.swh_badge_swhid(cntSWHID), 'browseUrl': this.Urls.browse_swhid(cntSWHIDWithContext) }, { 'objectType': 'directory', 'objectSWHIDs': [dirSWHIDWithContext, dirSWHID], 'badgeUrl': this.Urls.swh_badge('directory', swhids.directory.object_id), 'badgeSWHIDUrl': this.Urls.swh_badge_swhid(dirSWHID), 'browseUrl': this.Urls.browse_swhid(dirSWHIDWithContext) }, { 'objectType': 'release', 'objectSWHIDs': [relSWHIDWithContext, relSWHID], 'badgeUrl': this.Urls.swh_badge('release', swhids.release.object_id), 'badgeSWHIDUrl': this.Urls.swh_badge_swhid(relSWHID), 'browseUrl': this.Urls.browse_swhid(relSWHIDWithContext) }, { 'objectType': 'revision', 'objectSWHIDs': [revSWHIDWithContext, revSWHID], 'badgeUrl': this.Urls.swh_badge('revision', swhids.revision.object_id), 'badgeSWHIDUrl': this.Urls.swh_badge_swhid(revSWHID), 'browseUrl': this.Urls.browse_swhid(revSWHIDWithContext) }, { 'objectType': 'snapshot', 'objectSWHIDs': [snpSWHIDWithContext, snpSWHID], 'badgeUrl': this.Urls.swh_badge('snapshot', swhids.snapshot.object_id), 'badgeSWHIDUrl': this.Urls.swh_badge_swhid(snpSWHID), 'browseUrl': this.Urls.browse_swhid(snpSWHIDWithContext) } ]; }); }); beforeEach(function() { cy.visit(url); }); it('should open and close identifiers tab when clicking on handle', function() { cy.get('#swh-identifiers') .should('have.class', 'ui-slideouttab-ready'); cy.get('.ui-slideouttab-handle') .click(); cy.get('#swh-identifiers') .should('have.class', 'ui-slideouttab-open'); cy.get('.ui-slideouttab-handle') .click(); cy.get('#swh-identifiers') .should('not.have.class', 'ui-slideouttab-open'); }); it('should display identifiers with permalinks for browsed objects', function() { cy.get('.ui-slideouttab-handle') .click(); - for (let td of testsData) { + for (const td of testsData) { cy.get(`a[href="#swhid-tab-${td.objectType}"]`) .click(); cy.get(`#swhid-tab-${td.objectType}`) .should('be.visible'); cy.get(`#swhid-tab-${td.objectType} .swhid`) .should('have.text', td.objectSWHIDs[0].replace(/;/g, ';\n')) .should('have.attr', 'href', this.Urls.browse_swhid(td.objectSWHIDs[0])); } }); it('should update other object identifiers contextual info when toggling context checkbox', function() { cy.get('.ui-slideouttab-handle') .click(); - for (let td of testsData) { + for (const td of testsData) { cy.get(`a[href="#swhid-tab-${td.objectType}"]`) .click(); cy.get(`#swhid-tab-${td.objectType} .swhid`) .should('have.text', td.objectSWHIDs[0].replace(/;/g, ';\n')) .should('have.attr', 'href', this.Urls.browse_swhid(td.objectSWHIDs[0])); cy.get(`#swhid-tab-${td.objectType} .swhid-option`) .click(); cy.get(`#swhid-tab-${td.objectType} .swhid`) .contains(td.objectSWHIDs[1]) .should('have.attr', 'href', this.Urls.browse_swhid(td.objectSWHIDs[1])); cy.get(`#swhid-tab-${td.objectType} .swhid-option`) .click(); cy.get(`#swhid-tab-${td.objectType} .swhid`) .should('have.text', td.objectSWHIDs[0].replace(/;/g, ';\n')) .should('have.attr', 'href', this.Urls.browse_swhid(td.objectSWHIDs[0])); } }); it('should display swh badges in identifiers tab for browsed objects', function() { cy.get('.ui-slideouttab-handle') .click(); const originBadgeUrl = this.Urls.swh_badge('origin', origin.url); - for (let td of testsData) { + for (const td of testsData) { cy.get(`a[href="#swhid-tab-${td.objectType}"]`) .click(); cy.get(`#swhid-tab-${td.objectType} .swh-badge-origin`) .should('have.attr', 'src', originBadgeUrl); cy.get(`#swhid-tab-${td.objectType} .swh-badge-${td.objectType}`) .should('have.attr', 'src', td.badgeUrl); } }); it('should display badge integration info when clicking on it', function() { cy.get('.ui-slideouttab-handle') .click(); - for (let td of testsData) { + for (const td of testsData) { cy.get(`a[href="#swhid-tab-${td.objectType}"]`) .click(); cy.get(`#swhid-tab-${td.objectType} .swh-badge-origin`) .click() .wait(500); - for (let badgeType of ['html', 'md', 'rst']) { + for (const badgeType of ['html', 'md', 'rst']) { cy.get(`.modal .swh-badge-${badgeType}`) .contains(`${urlPrefix}${originBrowseUrl}`) .contains(`${urlPrefix}${originBadgeUrl}`); } cy.get('.modal.show .close') .click() .wait(500); cy.get(`#swhid-tab-${td.objectType} .swh-badge-${td.objectType}`) .click() .wait(500); - for (let badgeType of ['html', 'md', 'rst']) { + for (const badgeType of ['html', 'md', 'rst']) { cy.get(`.modal .swh-badge-${badgeType}`) .contains(`${urlPrefix}${td.browseUrl}`) .contains(`${urlPrefix}${td.badgeSWHIDUrl}`); } cy.get('.modal.show .close') .click() .wait(500); } }); it('should be possible to retrieve SWHIDs context from JavaScript', function() { cy.window().then(win => { const swhIdsContext = win.swh.webapp.getSwhIdsContext(); - for (let testData of testsData) { + for (const testData of testsData) { assert.isTrue(swhIdsContext.hasOwnProperty(testData.objectType)); assert.equal(swhIdsContext[testData.objectType].swhid, testData.objectSWHIDs.slice(-1)[0]); } }); }); }); diff --git a/cypress/integration/revision-diff.spec.js b/cypress/integration/revision-diff.spec.js index 12f45f0c..13852d31 100644 --- a/cypress/integration/revision-diff.spec.js +++ b/cypress/integration/revision-diff.spec.js @@ -1,492 +1,492 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const $ = Cypress.$; const origin = 'https://github.com/memononen/libtess2'; const revision = '98c65dad5e47ad888032b6cdf556f192e0e028d0'; const diffsHighlightingData = { 'unified': { diffId: '3d4c0797cf0e89430410e088339aac384dfa4d82', startLines: [913, 915], endLines: [0, 979] }, 'split-from': { diffId: '602cec77c3d3f41d396d9e1083a0bbce0796b505', startLines: [192, 0], endLines: [198, 0] }, 'split-to': { diffId: '602cec77c3d3f41d396d9e1083a0bbce0796b505', startLines: [0, 120], endLines: [0, 130] }, 'split-from-top-to-bottom': { diffId: 'a00c33990655a93aa2c821c4008bbddda812a896', startLines: [63, 0], endLines: [0, 68] }, 'split-to-top-from-bottom': { diffId: 'a00c33990655a93aa2c821c4008bbddda812a896', startLines: [0, 63], endLines: [67, 0] } }; let diffData; let swh; describe('Test Revision View', function() { it('should add/remove #swh-revision-changes url fragment when switching tab', function() { const url = this.Urls.browse_revision(revision) + `?origin=${origin}`; cy.visit(url); cy.get('a[data-toggle="tab"]') .contains('Changes') .click(); cy.hash().should('be.equal', '#swh-revision-changes'); cy.get('a[data-toggle="tab"]') .contains('Files') .click(); cy.hash().should('be.equal', ''); }); it('should display Changes tab by default when url ends with #swh-revision-changes', function() { const url = this.Urls.browse_revision(revision) + `?origin=${origin}`; cy.visit(url + '#swh-revision-changes'); cy.get('#swh-revision-changes-list') .should('be.visible'); }); }); describe('Test Diffs View', function() { beforeEach(function() { const url = this.Urls.browse_revision(revision) + `?origin=${origin}`; cy.visit(url); cy.window().then(win => { swh = win.swh; cy.request(win.diffRevUrl) .then(res => { diffData = res.body; }); }); cy.get('a[data-toggle="tab"]') .contains('Changes') .click(); }); it('should list all files with changes', function() { - let files = new Set([]); - for (let change of diffData.changes) { + const files = new Set([]); + for (const change of diffData.changes) { files.add(change.from_path); files.add(change.to_path); } - for (let file of files) { + for (const file of files) { cy.get('#swh-revision-changes-list a') .contains(file) .should('be.visible'); } }); it('should load diffs when scrolled down', function() { cy.get('#swh-revision-changes-list a') .each($el => { cy.get($el.attr('href')) .scrollIntoView() .find('.swh-content') .should('be.visible'); }); }); it('should compute all diffs when selected', function() { cy.get('#swh-compute-all-diffs') .click(); cy.get('#swh-revision-changes-list a') .each($el => { cy.get($el.attr('href')) .find('.swh-content') .should('be.visible'); }); }); it('should have correct links in diff file names', function() { - for (let change of diffData.changes) { + for (const change of diffData.changes) { cy.get(`#swh-revision-changes-list a[href="#diff_${change.id}"`) .should('be.visible'); } }); it('should load unified diff by default', function() { cy.get('#swh-compute-all-diffs') .click(); - for (let change of diffData.changes) { + for (const change of diffData.changes) { cy.get(`#${change.id}-unified-diff`) .should('be.visible'); cy.get(`#${change.id}-split-diff`) .should('not.be.visible'); } }); it('should switch between unified and side-by-side diff when selected', function() { // Test for first diff const id = diffData.changes[0].id; cy.get(`#diff_${id}`) .contains('label', 'Side-by-side') .click(); cy.get(`#${id}-split-diff`) .should('be.visible') .get(`#${id}-unified-diff`) .should('not.be.visible'); }); function checkDiffHighlighted(diffId, start, end) { cy.get(`#${diffId} .hljs-ln-line`) .then(lines => { let inHighlightedRange = false; - for (let line of lines) { + for (const line of lines) { const lnNumber = $(line).data('line-number'); if (lnNumber === start || lnNumber === end) { inHighlightedRange = true; } const backgroundColor = $(line).css('background-color'); const mixBlendMode = $(line).css('mix-blend-mode'); if (inHighlightedRange && parseInt(lnNumber)) { assert.equal(mixBlendMode, 'multiply'); assert.notEqual(backgroundColor, 'rgba(0, 0, 0, 0)'); } else { assert.equal(mixBlendMode, 'normal'); assert.equal(backgroundColor, 'rgba(0, 0, 0, 0)'); } if (lnNumber === end) { inHighlightedRange = false; } } }); } function unifiedDiffHighlightingTest(diffId, startLines, endLines) { // render diff cy.get(`#diff_${diffId}`) .scrollIntoView() .get(`#${diffId}-unified-diff`) .should('be.visible') // ensure all asynchronous treatments in the page have been performed // before testing diff highlighting .then(() => { let startLinesStr = swh.revision.formatDiffLineNumbers(diffId, startLines[0], startLines[1]); let endLinesStr = swh.revision.formatDiffLineNumbers(diffId, endLines[0], endLines[1]); // highlight a range of lines - let startElt = `#${diffId}-unified-diff .hljs-ln-numbers[data-line-number="${startLinesStr}"]`; - let endElt = `#${diffId}-unified-diff .hljs-ln-numbers[data-line-number="${endLinesStr}"]`; + const startElt = `#${diffId}-unified-diff .hljs-ln-numbers[data-line-number="${startLinesStr}"]`; + const endElt = `#${diffId}-unified-diff .hljs-ln-numbers[data-line-number="${endLinesStr}"]`; cy.get(startElt).click(); cy.get(endElt).click({shiftKey: true}); // check URL fragment has been updated const selectedLinesFragment = swh.revision.selectedDiffLinesToFragment(startLines, endLines, true); cy.hash().should('be.equal', `#diff_${diffId}+${selectedLinesFragment}`); if ($(endElt).position().top < $(startElt).position().top) { [startLinesStr, endLinesStr] = [endLinesStr, startLinesStr]; } // check lines range is highlighted checkDiffHighlighted(`${diffId}-unified-diff`, startLinesStr, endLinesStr); // check selected diff lines get highlighted when reloading page // with highlighting info in URL fragment cy.reload(); cy.get(`#diff_${diffId}`) .get(`#${diffId}-unified-diff`) .should('be.visible'); checkDiffHighlighted(`${diffId}-unified-diff`, startLinesStr, endLinesStr); }); } it('should highlight unified diff lines when selecting them from top to bottom', function() { const diffHighlightingData = diffsHighlightingData['unified']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; unifiedDiffHighlightingTest(diffId, startLines, endLines); }); it('should highlight unified diff lines when selecting them from bottom to top', function() { const diffHighlightingData = diffsHighlightingData['unified']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; unifiedDiffHighlightingTest(diffId, endLines, startLines); }); function singleSpitDiffHighlightingTest(diffId, startLines, endLines, to) { let singleDiffId = `${diffId}-from`; if (to) { singleDiffId = `${diffId}-to`; } let startLine = startLines[0] || startLines[1]; let endLine = endLines[0] || endLines[1]; // render diff cy.get(`#diff_${diffId}`) .scrollIntoView() .get(`#${diffId}-unified-diff`) .should('be.visible'); cy.get(`#diff_${diffId}`) .contains('label', 'Side-by-side') .click() // ensure all asynchronous treatments in the page have been performed // before testing diff highlighting .then(() => { // highlight a range of lines - let startElt = `#${singleDiffId} .hljs-ln-numbers[data-line-number="${startLine}"]`; - let endElt = `#${singleDiffId} .hljs-ln-numbers[data-line-number="${endLine}"]`; + const startElt = `#${singleDiffId} .hljs-ln-numbers[data-line-number="${startLine}"]`; + const endElt = `#${singleDiffId} .hljs-ln-numbers[data-line-number="${endLine}"]`; cy.get(startElt).click(); cy.get(endElt).click({shiftKey: true}); const selectedLinesFragment = swh.revision.selectedDiffLinesToFragment(startLines, endLines, false); // check URL fragment has been updated cy.hash().should('be.equal', `#diff_${diffId}+${selectedLinesFragment}`); if ($(endElt).position().top < $(startElt).position().top) { [startLine, endLine] = [endLine, startLine]; } // check lines range is highlighted checkDiffHighlighted(`${singleDiffId}`, startLine, endLine); // check selected diff lines get highlighted when reloading page // with highlighting info in URL fragment cy.reload(); cy.get(`#diff_${diffId}`) .get(`#${diffId}-split-diff`) .get(`#${singleDiffId}`) .should('be.visible'); checkDiffHighlighted(`${singleDiffId}`, startLine, endLine); }); } it('should highlight split diff from lines when selecting them from top to bottom', function() { const diffHighlightingData = diffsHighlightingData['split-from']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; singleSpitDiffHighlightingTest(diffId, startLines, endLines, false); }); it('should highlight split diff from lines when selecting them from bottom to top', function() { const diffHighlightingData = diffsHighlightingData['split-from']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; singleSpitDiffHighlightingTest(diffId, endLines, startLines, false); }); it('should highlight split diff to lines when selecting them from top to bottom', function() { const diffHighlightingData = diffsHighlightingData['split-to']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; singleSpitDiffHighlightingTest(diffId, startLines, endLines, true); }); it('should highlight split diff to lines when selecting them from bottom to top', function() { const diffHighlightingData = diffsHighlightingData['split-to']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; singleSpitDiffHighlightingTest(diffId, endLines, startLines, true); }); function checkSplitDiffHighlighted(diffId, startLines, endLines) { let left, right; if (startLines[0] && endLines[1]) { left = startLines[0]; right = endLines[1]; } else { left = endLines[0]; right = startLines[1]; } cy.get(`#${diffId}-from .hljs-ln-line`) .then(fromLines => { cy.get(`#${diffId}-to .hljs-ln-line`) .then(toLines => { const leftLine = $(`#${diffId}-from .hljs-ln-line[data-line-number="${left}"]`); const rightLine = $(`#${diffId}-to .hljs-ln-line[data-line-number="${right}"]`); const leftLineAbove = $(leftLine).position().top < $(rightLine).position().top; let inHighlightedRange = false; for (let i = 0; i < Math.max(fromLines.length, toLines.length); ++i) { const fromLn = fromLines[i]; const toLn = toLines[i]; const fromLnNumber = $(fromLn).data('line-number'); const toLnNumber = $(toLn).data('line-number'); if ((leftLineAbove && fromLnNumber === left) || (!leftLineAbove && toLnNumber === right) || (leftLineAbove && toLnNumber === right) || (!leftLineAbove && fromLnNumber === left)) { inHighlightedRange = true; } if (fromLn) { const fromBackgroundColor = $(fromLn).css('background-color'); const fromMixBlendMode = $(fromLn).css('mix-blend-mode'); if (inHighlightedRange && fromLnNumber) { assert.equal(fromMixBlendMode, 'multiply'); assert.notEqual(fromBackgroundColor, 'rgba(0, 0, 0, 0)'); } else { assert.equal(fromMixBlendMode, 'normal'); assert.equal(fromBackgroundColor, 'rgba(0, 0, 0, 0)'); } } if (toLn) { const toBackgroundColor = $(toLn).css('background-color'); const toMixBlendMode = $(toLn).css('mix-blend-mode'); if (inHighlightedRange && toLnNumber) { assert.equal(toMixBlendMode, 'multiply'); assert.notEqual(toBackgroundColor, 'rgba(0, 0, 0, 0)'); } else { assert.equal(toMixBlendMode, 'normal'); assert.equal(toBackgroundColor, 'rgba(0, 0, 0, 0)'); } } if ((leftLineAbove && toLnNumber === right) || (!leftLineAbove && fromLnNumber === left)) { inHighlightedRange = false; } } }); }); } function splitDiffHighlightingTest(diffId, startLines, endLines) { // render diff cy.get(`#diff_${diffId}`) .scrollIntoView() .find(`#${diffId}-unified-diff`) .should('be.visible'); cy.get(`#diff_${diffId}`) .contains('label', 'Side-by-side') .click() // ensure all asynchronous treatments in the page have been performed // before testing diff highlighting .then(() => { // select lines range in diff let startElt; if (startLines[0]) { startElt = `#${diffId}-from .hljs-ln-numbers[data-line-number="${startLines[0]}"]`; } else { startElt = `#${diffId}-to .hljs-ln-numbers[data-line-number="${startLines[1]}"]`; } let endElt; if (endLines[0]) { endElt = `#${diffId}-from .hljs-ln-numbers[data-line-number="${endLines[0]}"]`; } else { endElt = `#${diffId}-to .hljs-ln-numbers[data-line-number="${endLines[1]}"]`; } cy.get(startElt).click(); cy.get(endElt).click({shiftKey: true}); const selectedLinesFragment = swh.revision.selectedDiffLinesToFragment(startLines, endLines, false); // check URL fragment has been updated cy.hash().should('be.equal', `#diff_${diffId}+${selectedLinesFragment}`); // check lines range is highlighted checkSplitDiffHighlighted(diffId, startLines, endLines); // check selected diff lines get highlighted when reloading page // with highlighting info in URL fragment cy.reload(); cy.get(`#diff_${diffId}`) .get(`#${diffId}-split-diff`) .get(`#${diffId}-to`) .should('be.visible'); checkSplitDiffHighlighted(diffId, startLines, endLines); }); } it('should highlight split diff from and to lines when selecting them from top-left to bottom-right', function() { const diffHighlightingData = diffsHighlightingData['split-from-top-to-bottom']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; splitDiffHighlightingTest(diffId, startLines, endLines); }); it('should highlight split diff from and to lines when selecting them from bottom-right to top-left', function() { const diffHighlightingData = diffsHighlightingData['split-from-top-to-bottom']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; splitDiffHighlightingTest(diffId, endLines, startLines); }); it('should highlight split diff from and to lines when selecting them from top-right to bottom-left', function() { const diffHighlightingData = diffsHighlightingData['split-to-top-from-bottom']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; splitDiffHighlightingTest(diffId, startLines, endLines); }); it('should highlight split diff from and to lines when selecting them from bottom-left to top-right', function() { const diffHighlightingData = diffsHighlightingData['split-to-top-from-bottom']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; splitDiffHighlightingTest(diffId, endLines, startLines); }); it('should highlight diff lines properly when a content is browsed in the Files tab', function() { const url = this.Urls.browse_revision(revision) + `?origin=${origin}&path=README.md`; cy.visit(url); cy.get('a[data-toggle="tab"]') .contains('Changes') .click(); const diffHighlightingData = diffsHighlightingData['unified']; const diffId = diffHighlightingData.diffId; - let startLines = diffHighlightingData.startLines; - let endLines = diffHighlightingData.endLines; + const startLines = diffHighlightingData.startLines; + const endLines = diffHighlightingData.endLines; unifiedDiffHighlightingTest(diffId, startLines, endLines); }); }); diff --git a/cypress/integration/vault.spec.js b/cypress/integration/vault.spec.js index 2cfa97df..d2d6be7c 100644 --- a/cypress/integration/vault.spec.js +++ b/cypress/integration/vault.spec.js @@ -1,504 +1,504 @@ /** * Copyright (C) 2019-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ -let vaultItems = []; +const vaultItems = []; const progressbarColors = { 'new': 'rgba(128, 128, 128, 0.5)', 'pending': 'rgba(0, 0, 255, 0.5)', 'done': 'rgb(92, 184, 92)' }; function checkVaultCookingTask(objectType) { cy.contains('button', 'Download') .click(); cy.contains('.dropdown-item', objectType) .click(); cy.wait('@checkVaultCookingTask'); } function updateVaultItemList(vaultUrl, vaultItems) { cy.visit(vaultUrl) .then(() => { // Add uncooked task to localStorage // which updates it in vault items list window.localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultItems)); }); } // Mocks API response : /api/1/vault/(:objectType)/(:hash) // objectType : {'directory', 'revision'} function genVaultCookingResponse(objectType, objectId, status, message, fetchUrl) { return { 'obj_type': objectType, 'id': 1, 'progress_message': message, 'status': status, 'obj_id': objectId, 'fetch_url': fetchUrl }; }; // Tests progressbar color, status // And status in localStorage function testStatus(taskId, color, statusMsg, status) { cy.get(`.swh-vault-table #vault-task-${taskId}`) .should('be.visible') .find('.progress-bar') .should('be.visible') .and('have.css', 'background-color', color) .and('contain', statusMsg) .then(() => { // Vault item with object_id as taskId should exist in localStorage const currentVaultItems = JSON.parse(window.localStorage.getItem('swh-vault-cooking-tasks')); const vaultItem = currentVaultItems.find(obj => obj.object_id === taskId); assert.isNotNull(vaultItem); assert.strictEqual(vaultItem.status, status); }); } describe('Vault Cooking User Interface Tests', function() { before(function() { const dirInfo = this.origin[0].directory[0]; this.directory = dirInfo.id; this.directoryUrl = this.Urls.browse_origin_directory() + `?origin_url=${this.origin[0].url}&path=${dirInfo.path}`; this.vaultDirectoryUrl = this.Urls.api_1_vault_cook_directory(this.directory); this.vaultFetchDirectoryUrl = this.Urls.api_1_vault_fetch_directory(this.directory); this.revision = this.origin[1].revisions[0]; this.revisionUrl = this.Urls.browse_revision(this.revision); this.vaultRevisionUrl = this.Urls.api_1_vault_cook_revision_gitfast(this.revision); this.vaultFetchRevisionUrl = this.Urls.api_1_vault_fetch_revision_gitfast(this.revision); const release = this.origin[1].release; this.releaseUrl = this.Urls.browse_release(release.id) + `?origin_url=${this.origin[1].url}`; this.vaultReleaseDirectoryUrl = this.Urls.api_1_vault_cook_directory(release.directory); vaultItems[0] = { 'object_type': 'revision', 'object_id': this.revision, 'email': '', 'status': 'done', 'fetch_url': `/api/1/vault/revision/${this.revision}/gitfast/raw/`, 'progress_message': null }; }); beforeEach(function() { this.genVaultDirCookingResponse = (status, message = null) => { return genVaultCookingResponse('directory', this.directory, status, message, this.vaultFetchDirectoryUrl); }; this.genVaultRevCookingResponse = (status, message = null) => { return genVaultCookingResponse('revision', this.revision, status, message, this.vaultFetchRevisionUrl); }; }); it('should report an error when vault service is experiencing issues', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // an internal server error cy.intercept(this.vaultDirectoryUrl, { body: {'exception': 'APIError'}, statusCode: 500 }).as('checkVaultCookingTask'); cy.contains('button', 'Download') .click(); // Check error alert is displayed cy.get('.alert-danger') .should('be.visible') .should('contain', 'Archive cooking service is currently experiencing issues.'); }); it('should report an error when a cooking task creation failed', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // a task can not be created cy.intercept('GET', this.vaultDirectoryUrl, { body: {'exception': 'NotFoundExc'} }).as('checkVaultCookingTask'); cy.intercept('POST', this.vaultDirectoryUrl, { body: {'exception': 'ValueError'}, statusCode: 500 }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check error alert is displayed cy.get('.alert-danger') .should('be.visible') .should('contain', 'Archive cooking request submission failed.'); }); it('should create a directory cooking task and report the success', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub response to the vault API to simulate archive download cy.intercept('GET', this.vaultFetchDirectoryUrl, { fixture: `${this.directory}.tar.gz`, headers: { 'Content-disposition': `attachment; filename=${this.directory}.tar.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when checking vault task status const checkVaulResponses = [ {'exception': 'NotFoundExc'}, this.genVaultDirCookingResponse('new'), this.genVaultDirCookingResponse('pending', 'Processing...'), this.genVaultDirCookingResponse('done') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultDirectoryUrl, req => req.reply(checkVaulResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('new') }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); cy.window().then(win => { const swhIdsContext = win.swh.webapp.getSwhIdsContext(); const browseDirectoryUrl = swhIdsContext.directory.swhid_with_context_url; // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); // Go to Downloads page cy.visit(this.Urls.browse_vault()); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['new'], 'new', 'new'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['pending'], 'Processing...', 'pending'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['done'], 'done', 'done'); }); cy.get(`#vault-task-${this.directory} .vault-origin a`) .should('contain', this.origin[0].url) .should('have.attr', 'href', `${this.Urls.browse_origin()}?origin_url=${this.origin[0].url}`); cy.get(`#vault-task-${this.directory} .vault-object-info a`) .should('have.text', this.directory) .should('have.attr', 'href', browseDirectoryUrl); cy.get(`#vault-task-${this.directory} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then((xhr) => { assert.isNotNull(xhr.response.body); }); }); }); it('should create a revision cooking task and report its status', function() { cy.adminLogin(); // Browse a revision cy.visit(this.revisionUrl); // Stub response to the vault API indicating to simulate archive download cy.intercept({url: this.vaultFetchRevisionUrl}, { fixture: `${this.revision}.gitfast.gz`, headers: { 'Content-disposition': `attachment; filename=${this.revision}.gitfast.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when checking vault task status const checkVaultResponses = [ {'exception': 'NotFoundExc'}, this.genVaultRevCookingResponse('new'), this.genVaultRevCookingResponse('pending', 'Processing...'), this.genVaultRevCookingResponse('done') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultRevisionUrl, req => req.reply(checkVaultResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('createVaultCookingTask'); // Create a vault cooking task through the GUI checkVaultCookingTask('as git'); cy.window().then(win => { const swhIdsContext = win.swh.webapp.getSwhIdsContext(); const browseRevisionUrl = swhIdsContext.revision.swhid_url; // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); // Go to Downloads page cy.visit(this.Urls.browse_vault()); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['new'], 'new', 'new'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['pending'], 'Processing...', 'pending'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['done'], 'done', 'done'); }); cy.get(`#vault-task-${this.revision} .vault-origin`) .should('have.text', 'unknown'); cy.get(`#vault-task-${this.revision} .vault-object-info a`) .should('have.text', this.revision) .should('have.attr', 'href', browseRevisionUrl); cy.get(`#vault-task-${this.revision} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then((xhr) => { assert.isNotNull(xhr.response.body); }); }); }); it('should create a directory cooking task from the release view', function() { // Browse a directory cy.visit(this.releaseUrl); // Stub responses when checking vault task status const checkVaultResponses = [ {'exception': 'NotFoundExc'}, this.genVaultDirCookingResponse('new') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultReleaseDirectoryUrl, req => req.reply(checkVaultResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultReleaseDirectoryUrl, { body: this.genVaultDirCookingResponse('new') }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); }); it('should offer to recook an archive if no more available to download', function() { updateVaultItemList(this.Urls.browse_vault(), vaultItems); // Send 404 when fetching vault item cy.intercept({url: this.vaultFetchRevisionUrl}, { statusCode: 404, body: { 'exception': 'NotFoundExc', 'reason': `Revision with ID '${this.revision}' not found.` }, headers: { 'Content-Type': 'json' } }).as('fetchCookedArchive'); cy.get(`#vault-task-${this.revision} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then(() => { cy.intercept('POST', this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('createVaultCookingTask'); cy.intercept(this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('checkVaultCookingTask'); cy.get('#vault-recook-object-modal > .modal-dialog') .should('be.visible') .contains('button:visible', 'Ok') .click(); cy.wait('@checkVaultCookingTask') .then(() => { testStatus(this.revision, progressbarColors['new'], 'new', 'new'); }); }); }); it('should remove selected vault items', function() { updateVaultItemList(this.Urls.browse_vault(), vaultItems); cy.get(`#vault-task-${this.revision}`) .find('input[type="checkbox"]') .click({force: true}); cy.contains('button', 'Remove selected tasks') .click(); cy.get(`#vault-task-${this.revision}`) .should('not.exist'); }); it('should offer to immediately download a directory tarball if already cooked', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub response to the vault API to simulate archive download cy.intercept({url: this.vaultFetchDirectoryUrl}, { fixture: `${this.directory}.tar.gz`, headers: { 'Content-disposition': `attachment; filename=${this.directory}.tar.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when requesting the vault API to simulate // the directory tarball has already been cooked cy.intercept(this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('done') }).as('checkVaultCookingTask'); // Create a vault cooking task through the GUI cy.contains('button', 'Download') .click(); // Start archive download through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@fetchCookedArchive'); }); it('should offer to immediately download a revision gitfast archive if already cooked', function() { cy.adminLogin(); // Browse a directory cy.visit(this.revisionUrl); // Stub response to the vault API to simulate archive download cy.intercept({url: this.vaultFetchRevisionUrl}, { fixture: `${this.revision}.gitfast.gz`, headers: { 'Content-disposition': `attachment; filename=${this.revision}.gitfast.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when requesting the vault API to simulate // the directory tarball has already been cooked cy.intercept(this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('done') }).as('checkVaultCookingTask'); checkVaultCookingTask('as git'); // Start archive download through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@fetchCookedArchive'); }); it('should offer to recook an object if previous vault task failed', function() { cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // the last cooking of the directory tarball has failed cy.intercept(this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('failed') }).as('checkVaultCookingTask'); cy.contains('button', 'Download') .click(); // Check that recooking the directory is offered to user cy.get('.modal-dialog') .contains('button:visible', 'Ok') .should('be.visible'); }); }); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index 71ce678a..f21d0d0f 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -1,27 +1,27 @@ /** * Copyright (C) 2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const axios = require('axios'); const fs = require('fs'); module.exports = (on, config) => { require('@cypress/code-coverage/task')(on, config); // produce JSON files prior launching browser in order to dynamically generate tests on('before:browser:launch', function(browser, launchOptions) { return new Promise((resolve) => { - let p1 = axios.get(`${config.baseUrl}/tests/data/content/code/extensions/`); - let p2 = axios.get(`${config.baseUrl}/tests/data/content/code/filenames/`); + const p1 = axios.get(`${config.baseUrl}/tests/data/content/code/extensions/`); + const p2 = axios.get(`${config.baseUrl}/tests/data/content/code/filenames/`); Promise.all([p1, p2]) .then(function(responses) { fs.writeFileSync('cypress/fixtures/source-file-extensions.json', JSON.stringify(responses[0].data)); fs.writeFileSync('cypress/fixtures/source-file-names.json', JSON.stringify(responses[1].data)); resolve(); }); }); }); return config; }; diff --git a/cypress/support/index.js b/cypress/support/index.js index f5974994..58b45488 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -1,158 +1,158 @@ /** * Copyright (C) 2019-2020 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import 'cypress-hmr-restarter'; import '@cypress/code-coverage/support'; import {httpGetJson} from '../utils'; Cypress.Screenshot.defaults({ screenshotOnRunFailure: false }); Cypress.Commands.add('xhrShouldBeCalled', (alias, timesCalled) => { const testRoutes = cy.state('routes'); const aliasRoute = Cypress._.find(testRoutes, {alias}); expect(Object.keys(aliasRoute.requests || {})).to.have.length(timesCalled); }); function loginUser(username, password) { const url = '/admin/login/'; return cy.request({ url: url, method: 'GET' }).then(() => { cy.getCookie('sessionid').should('not.exist'); cy.getCookie('csrftoken').its('value').then((token) => { cy.request({ url: url, method: 'POST', form: true, followRedirect: false, body: { username: username, password: password, csrfmiddlewaretoken: token } }).then(() => { cy.getCookie('sessionid').should('exist'); return cy.getCookie('csrftoken').its('value'); }); }); }); } Cypress.Commands.add('adminLogin', () => { return loginUser('admin', 'admin'); }); Cypress.Commands.add('userLogin', () => { return loginUser('user', 'user'); }); Cypress.Commands.add('ambassadorLogin', () => { return loginUser('ambassador', 'ambassador'); }); before(function() { this.unarchivedRepo = { url: 'https://github.com/SoftwareHeritage/swh-web', type: 'git', revision: '7bf1b2f489f16253527807baead7957ca9e8adde', snapshot: 'd9829223095de4bb529790de8ba4e4813e38672d', rootDirectory: '7d887d96c0047a77e2e8c4ee9bb1528463677663', content: [{ sha1git: 'b203ec39300e5b7e97b6e20986183cbd0b797859' }] }; this.origin = [{ url: 'https://github.com/memononen/libtess2', type: 'git', content: [{ path: 'Source/tess.h' }, { path: 'premake4.lua' }], directory: [{ path: 'Source', id: 'cd19126d815470b28919d64b2a8e6a3e37f900dd' }], revisions: [], invalidSubDir: 'Source1' }, { url: 'https://github.com/wcoder/highlightjs-line-numbers.js', type: 'git', content: [{ path: 'src/highlightjs-line-numbers.js' }], directory: [], revisions: ['1c480a4573d2a003fc2630c21c2b25829de49972'], release: { name: 'v2.6.0', id: '6877028d6e5412780517d0bfa81f07f6c51abb41', directory: '5b61d50ef35ca9a4618a3572bde947b8cccf71ad' } }]; const getMetadataForOrigin = async originUrl => { const originVisitsApiUrl = this.Urls.api_1_origin_visits(originUrl); const originVisits = await httpGetJson(originVisitsApiUrl); const lastVisit = originVisits[0]; const snapshotApiUrl = this.Urls.api_1_snapshot(lastVisit.snapshot); const lastOriginSnapshot = await httpGetJson(snapshotApiUrl); let revision = lastOriginSnapshot.branches.HEAD.target; if (lastOriginSnapshot.branches.HEAD.target_type === 'alias') { revision = lastOriginSnapshot.branches[revision].target; } const revisionApiUrl = this.Urls.api_1_revision(revision); const lastOriginHeadRevision = await httpGetJson(revisionApiUrl); return { 'directory': lastOriginHeadRevision.directory, 'revision': lastOriginHeadRevision.id, 'snapshot': lastOriginSnapshot.id }; }; cy.visit('/').window().then(async win => { this.Urls = win.Urls; - for (let origin of this.origin) { + for (const origin of this.origin) { const metadata = await getMetadataForOrigin(origin.url); const directoryApiUrl = this.Urls.api_1_directory(metadata.directory); origin.dirContent = await httpGetJson(directoryApiUrl); origin.rootDirectory = metadata.directory; origin.revisions.push(metadata.revision); origin.snapshot = metadata.snapshot; - for (let content of origin.content) { + for (const content of origin.content) { const contentPathApiUrl = this.Urls.api_1_directory(origin.rootDirectory, content.path); const contentMetaData = await httpGetJson(contentPathApiUrl); content.name = contentMetaData.name.split('/').slice(-1)[0]; content.sha1git = contentMetaData.target; content.directory = contentMetaData.dir_id; content.rawFilePath = this.Urls.browse_content_raw(`sha1_git:${content.sha1git}`) + `?filename=${encodeURIComponent(content.name)}`; cy.request(content.rawFilePath) .then((response) => { const fileText = response.body; const fileLines = fileText.split('\n'); content.numberLines = fileLines.length; // If last line is empty its not shown if (!fileLines[content.numberLines - 1]) content.numberLines -= 1; }); } } }); });