diff --git a/.pnp.cjs b/.pnp.cjs index 0269c96db0..5fbe6b1a4a 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -65,7 +65,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"],\ ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.14.1"],\ ["file-saver", "npm:2.0.5"],\ - ["highcharts", "npm:11.0.1"],\ + ["highcharts", "npm:11.1.0"],\ ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:7.18.1"],\ ["ical.js", "npm:1.5.0"],\ ["jquery", "npm:3.7.0"],\ @@ -84,7 +84,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["pinia", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.1.3"],\ ["pinia-plugin-persist", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:1.0.0"],\ ["pug", "npm:3.0.2"],\ - ["sass", "npm:1.62.1"],\ + ["sass", "npm:1.63.4"],\ ["seedrandom", "npm:3.0.5"],\ ["select2", "npm:4.1.0-rc.0"],\ ["select2-bootstrap-5-theme", "npm:1.3.0"],\ @@ -5487,10 +5487,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["highcharts", [\ - ["npm:11.0.1", {\ - "packageLocation": "./.yarn/cache/highcharts-npm-11.0.1-05a14e3887-773a7b8765.zip/node_modules/highcharts/",\ + ["npm:11.1.0", {\ + "packageLocation": "./.yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip/node_modules/highcharts/",\ "packageDependencies": [\ - ["highcharts", "npm:11.0.1"]\ + ["highcharts", "npm:11.1.0"]\ ],\ "linkType": "HARD"\ }]\ @@ -7895,7 +7895,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"],\ ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.14.1"],\ ["file-saver", "npm:2.0.5"],\ - ["highcharts", "npm:11.0.1"],\ + ["highcharts", "npm:11.1.0"],\ ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:7.18.1"],\ ["ical.js", "npm:1.5.0"],\ ["jquery", "npm:3.7.0"],\ @@ -7914,7 +7914,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["pinia", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.1.3"],\ ["pinia-plugin-persist", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:1.0.0"],\ ["pug", "npm:3.0.2"],\ - ["sass", "npm:1.62.1"],\ + ["sass", "npm:1.63.4"],\ ["seedrandom", "npm:3.0.5"],\ ["select2", "npm:4.1.0-rc.0"],\ ["select2-bootstrap-5-theme", "npm:1.3.0"],\ @@ -7998,10 +7998,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["npm:1.62.1", {\ - "packageLocation": "./.yarn/cache/sass-npm-1.62.1-c16d65fd28-1b1b3584b3.zip/node_modules/sass/",\ + ["npm:1.63.4", {\ + "packageLocation": "./.yarn/cache/sass-npm-1.63.4-bf5f3496c2-12bde5beff.zip/node_modules/sass/",\ "packageDependencies": [\ - ["sass", "npm:1.62.1"],\ + ["sass", "npm:1.63.4"],\ ["chokidar", "npm:3.5.3"],\ ["immutable", "npm:4.0.0"],\ ["source-map-js", "npm:1.0.2"]\ @@ -8716,7 +8716,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["less", null],\ ["postcss", "npm:8.4.23"],\ ["rollup", "npm:3.21.6"],\ - ["sass", "npm:1.62.1"],\ + ["sass", "npm:1.63.4"],\ ["stylus", null],\ ["sugarss", null],\ ["terser", null]\ diff --git a/.yarn/cache/highcharts-npm-11.0.1-05a14e3887-773a7b8765.zip b/.yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip similarity index 70% rename from .yarn/cache/highcharts-npm-11.0.1-05a14e3887-773a7b8765.zip rename to .yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip index c953a64563..ccf9aece97 100644 Binary files a/.yarn/cache/highcharts-npm-11.0.1-05a14e3887-773a7b8765.zip and b/.yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip differ diff --git a/.yarn/cache/sass-npm-1.62.1-c16d65fd28-1b1b3584b3.zip b/.yarn/cache/sass-npm-1.62.1-c16d65fd28-1b1b3584b3.zip deleted file mode 100644 index 745da7a176..0000000000 Binary files a/.yarn/cache/sass-npm-1.62.1-c16d65fd28-1b1b3584b3.zip and /dev/null differ diff --git a/.yarn/cache/sass-npm-1.63.4-bf5f3496c2-12bde5beff.zip b/.yarn/cache/sass-npm-1.63.4-bf5f3496c2-12bde5beff.zip new file mode 100644 index 0000000000..adadea9538 Binary files /dev/null and b/.yarn/cache/sass-npm-1.63.4-bf5f3496c2-12bde5beff.zip differ diff --git a/dev/coverage-action/package-lock.json b/dev/coverage-action/package-lock.json index 4b9633e11d..77373cc973 100644 --- a/dev/coverage-action/package-lock.json +++ b/dev/coverage-action/package-lock.json @@ -17,8 +17,8 @@ "luxon": "3.3.0" }, "devDependencies": { - "eslint": "8.41.0", - "eslint-config-standard": "17.0.0", + "eslint": "8.42.0", + "eslint-config-standard": "17.1.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "6.1.1", @@ -111,9 +111,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", - "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.42.0.tgz", + "integrity": "sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -126,9 +126,9 @@ "dev": true }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", + "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==", "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", @@ -1718,16 +1718,16 @@ } }, "node_modules/eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", - "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.42.0.tgz", + "integrity": "sha512-ulg9Ms6E1WPf67PHaEY4/6E2tEn5/f7FXGzr3t9cBMugOmf1INYvuUwwh1aXQN4MfJ6a5K2iNwP3w4AColvI9A==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.41.0", - "@humanwhocodes/config-array": "^0.11.8", + "@eslint/js": "8.42.0", + "@humanwhocodes/config-array": "^0.11.10", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.10.0", @@ -1774,9 +1774,9 @@ } }, "node_modules/eslint-config-standard": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz", - "integrity": "sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", "dev": true, "funding": [ { @@ -1792,10 +1792,13 @@ "url": "https://feross.org/support" } ], + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "eslint": "^8.0.1", "eslint-plugin-import": "^2.25.2", - "eslint-plugin-n": "^15.0.0", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", "eslint-plugin-promise": "^6.0.0" } }, @@ -6326,9 +6329,9 @@ } }, "@eslint/js": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", - "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.42.0.tgz", + "integrity": "sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw==", "dev": true }, "@gar/promisify": { @@ -6338,9 +6341,9 @@ "dev": true }, "@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", + "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==", "dev": true, "requires": { "@humanwhocodes/object-schema": "^1.2.1", @@ -7531,16 +7534,16 @@ "dev": true }, "eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", - "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.42.0.tgz", + "integrity": "sha512-ulg9Ms6E1WPf67PHaEY4/6E2tEn5/f7FXGzr3t9cBMugOmf1INYvuUwwh1aXQN4MfJ6a5K2iNwP3w4AColvI9A==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.41.0", - "@humanwhocodes/config-array": "^0.11.8", + "@eslint/js": "8.42.0", + "@humanwhocodes/config-array": "^0.11.10", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.10.0", @@ -7589,9 +7592,9 @@ } }, "eslint-config-standard": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz", - "integrity": "sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", "dev": true, "requires": {} }, diff --git a/dev/coverage-action/package.json b/dev/coverage-action/package.json index b05f079a63..37d84bf1c4 100644 --- a/dev/coverage-action/package.json +++ b/dev/coverage-action/package.json @@ -14,8 +14,8 @@ "luxon": "3.3.0" }, "devDependencies": { - "eslint": "8.41.0", - "eslint-config-standard": "17.0.0", + "eslint": "8.42.0", + "eslint-config-standard": "17.1.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "6.1.1", diff --git a/dev/del-old-packages/package-lock.json b/dev/del-old-packages/package-lock.json index 682d088ff4..68268cbaa2 100644 --- a/dev/del-old-packages/package-lock.json +++ b/dev/del-old-packages/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@octokit/core": "^4.2.1", + "@octokit/core": "^4.2.4", "luxon": "^3.3.0" } }, @@ -25,9 +25,9 @@ } }, "node_modules/@octokit/core": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.1.tgz", - "integrity": "sha512-tEDxFx8E38zF3gT7sSMDrT1tGumDgsw5yPG6BBh/X+5ClIQfMH/Yqocxz1PnHx6CHyF6pxmovUTOfZAUvQ0Lvw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.4.tgz", + "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", "dependencies": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", @@ -215,9 +215,9 @@ } }, "@octokit/core": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.1.tgz", - "integrity": "sha512-tEDxFx8E38zF3gT7sSMDrT1tGumDgsw5yPG6BBh/X+5ClIQfMH/Yqocxz1PnHx6CHyF6pxmovUTOfZAUvQ0Lvw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.4.tgz", + "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", "requires": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", diff --git a/dev/del-old-packages/package.json b/dev/del-old-packages/package.json index 068b569611..97b0e02f6a 100644 --- a/dev/del-old-packages/package.json +++ b/dev/del-old-packages/package.json @@ -10,7 +10,7 @@ "author": "", "license": "ISC", "dependencies": { - "@octokit/core": "^4.2.1", + "@octokit/core": "^4.2.4", "luxon": "^3.3.0" } } diff --git a/docker/configs/nginx-proxy.conf b/docker/configs/nginx-proxy.conf index d5681fb239..3068cc71d7 100644 --- a/docker/configs/nginx-proxy.conf +++ b/docker/configs/nginx-proxy.conf @@ -1,6 +1,9 @@ server { listen 8000 default_server; listen [::]:8000 default_server; + + proxy_read_timeout 1d; + proxy_send_timeout 1d; root /var/www/html; index index.html index.htm index.nginx-debian.html; diff --git a/docker/scripts/app-init.sh b/docker/scripts/app-init.sh index 73469ae20f..e15aed38bb 100755 --- a/docker/scripts/app-init.sh +++ b/docker/scripts/app-init.sh @@ -22,7 +22,6 @@ echo "Fix chromedriver /dev/shm permissions..." sudo chmod 1777 /dev/shm # Run nginx - echo "Starting nginx..." sudo nginx @@ -30,6 +29,9 @@ sudo nginx echo "Compiling native node packages..." yarn rebuild +# Silence Browserlist warnings +export BROWSERSLIST_IGNORE_OLD_DATA=1 + # Generate static assets echo "Building static assets... (this could take a minute or two)" yarn build diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py index b4c6203d98..54b4b7424b 100644 --- a/ietf/api/__init__.py +++ b/ietf/api/__init__.py @@ -12,12 +12,11 @@ import debug # pyflakes:ignore -import tastypie import tastypie.resources +import tastypie.serializers from tastypie.api import Api from tastypie.bundle import Bundle from tastypie.exceptions import ApiFieldError -from tastypie.serializers import Serializer # pyflakes:ignore (we're re-exporting this) from tastypie.fields import ApiField _api_list = [] @@ -152,3 +151,8 @@ def dehydrate(self, bundle, for_list=True): dehydrated = self.dehydrate_related(fk_bundle, fk_resource, for_list=for_list) fk_resource._meta.cache.set(cache_key, dehydrated) return dehydrated + + +class Serializer(tastypie.serializers.Serializer): + def format_datetime(self, data): + return data.astimezone(datetime.timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds") + "Z" diff --git a/ietf/doc/feeds.py b/ietf/doc/feeds.py index 92871efc33..c5bb467e9b 100644 --- a/ietf/doc/feeds.py +++ b/ietf/doc/feeds.py @@ -1,6 +1,7 @@ # Copyright The IETF Trust 2007-2020, All Rights Reserved # -*- coding: utf-8 -*- +import debug # pyflakes:ignore import datetime import unicodedata @@ -8,8 +9,12 @@ from django.contrib.syndication.views import Feed, FeedDoesNotExist from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed from django.urls import reverse as urlreverse -from django.template.defaultfilters import truncatewords, truncatewords_html, date as datefilter -from django.template.defaultfilters import linebreaks # type: ignore +from django.template.defaultfilters import ( + truncatewords, + truncatewords_html, + date as datefilter, +) +from django.template.defaultfilters import linebreaks # type: ignore from django.utils import timezone from django.utils.html import strip_tags @@ -21,12 +26,12 @@ def strip_control_characters(s): """Remove Unicode control / non-printing characters from a string""" - replacement_char = unicodedata.lookup('REPLACEMENT CHARACTER') - return ''.join( - replacement_char if unicodedata.category(c)[0] == 'C' else c - for c in s + replacement_char = unicodedata.lookup("REPLACEMENT CHARACTER") + return "".join( + replacement_char if unicodedata.category(c)[0] == "C" else c for c in s ) + class DocumentChangesFeed(Feed): feed_type = Atom1Feed @@ -39,25 +44,37 @@ def title(self, obj): def link(self, obj): if obj is None: raise FeedDoesNotExist - return urlreverse('ietf.doc.views_doc.document_history', kwargs=dict(name=obj.canonical_name())) + return urlreverse( + "ietf.doc.views_doc.document_history", + kwargs=dict(name=obj.canonical_name()), + ) def subtitle(self, obj): return "History of change entries for %s." % obj.display_name() def items(self, obj): - events = obj.docevent_set.all().order_by("-time","-id").select_related("by", "newrevisiondocevent", "submissiondocevent") + events = ( + obj.docevent_set.all() + .order_by("-time", "-id") + .select_related("by", "newrevisiondocevent", "submissiondocevent") + ) augment_events_with_revision(obj, events) return events def item_title(self, item): - return strip_control_characters("[%s] %s [rev. %s]" % ( - item.by, - truncatewords(strip_tags(item.desc), 15), - item.rev, - )) + return strip_control_characters( + "[%s] %s [rev. %s]" + % ( + item.by, + truncatewords(strip_tags(item.desc), 15), + item.rev, + ) + ) def item_description(self, item): - return strip_control_characters(truncatewords_html(format_textarea(item.desc), 20)) + return strip_control_characters( + truncatewords_html(format_textarea(item.desc), 20) + ) def item_pubdate(self, item): return item.time @@ -66,17 +83,28 @@ def item_author_name(self, item): return str(item.by) def item_link(self, item): - return urlreverse('ietf.doc.views_doc.document_history', kwargs=dict(name=item.doc.canonical_name())) + "#history-%s" % item.pk + return ( + urlreverse( + "ietf.doc.views_doc.document_history", + kwargs=dict(name=item.doc.canonical_name()), + ) + + "#history-%s" % item.pk + ) + class InLastCallFeed(Feed): title = "Documents in Last Call" subtitle = "Announcements for documents in last call." feed_type = Atom1Feed - author_name = 'IESG Secretary' + author_name = "IESG Secretary" link = "/doc/iesg/last-call/" def items(self): - docs = list(Document.objects.filter(type="draft", states=State.objects.get(type="draft-iesg", slug="lc"))) + docs = list( + Document.objects.filter( + type="draft", states=State.objects.get(type="draft-iesg", slug="lc") + ) + ) for d in docs: d.lc_event = d.latest_event(LastCallDocEvent, type="sent_last_call") @@ -86,9 +114,11 @@ def items(self): return docs def item_title(self, item): - return "%s (%s - %s)" % (item.name, - datefilter(item.lc_event.time, "F j"), - datefilter(item.lc_event.expires, "F j, Y")) + return "%s (%s - %s)" % ( + item.name, + datefilter(item.lc_event.time, "F j"), + datefilter(item.lc_event.expires, "F j, Y"), + ) def item_description(self, item): return strip_control_characters(linebreaks(item.lc_event.desc)) @@ -96,33 +126,55 @@ def item_description(self, item): def item_pubdate(self, item): return item.lc_event.time + class Rss201WithNamespacesFeed(Rss201rev2Feed): def root_attributes(self): attrs = super(Rss201WithNamespacesFeed, self).root_attributes() - attrs['xmlns:dcterms'] = 'http://purl.org/dc/terms/' - attrs['xmlns:media'] = 'http://search.yahoo.com/mrss/' - attrs['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance' + attrs["xmlns:dcterms"] = "http://purl.org/dc/terms/" + attrs["xmlns:media"] = "http://search.yahoo.com/mrss/" + attrs["xmlns:xsi"] = "http://www.w3.org/2001/XMLSchema-instance" return attrs def add_item_elements(self, handler, item): super(Rss201WithNamespacesFeed, self).add_item_elements(handler, item) - for element_name in ['abstract','accessRights', 'format', 'publisher',]: - dc_item_name = 'dcterms_%s' % element_name - dc_element_name = 'dcterms:%s' % element_name - attrs= {'xsi:type':'dcterms:local'} if element_name == 'publisher' else {} + for element_name in [ + "abstract", + "accessRights", + "format", + "publisher", + ]: + dc_item_name = "dcterms_%s" % element_name + dc_element_name = "dcterms:%s" % element_name + attrs = {"xsi:type": "dcterms:local"} if element_name == "publisher" else {} if dc_item_name in item and item[dc_item_name] is not None: - handler.addQuickElement(dc_element_name,item[dc_item_name],attrs) + handler.addQuickElement(dc_element_name, item[dc_item_name], attrs) + + if "doi" in item and item["doi"] is not None: + handler.addQuickElement( + "dcterms:identifier", item["doi"], {"xsi:type": "dcterms:doi"} + ) + if "doiuri" in item and item["doiuri"] is not None: + handler.addQuickElement( + "dcterms:identifier", item["doiuri"], {"xsi:type": "dcterms:uri"} + ) + + # TODO: consider using media:group + if "media_contents" in item and item["media_contents"] is not None: + for media_content in item["media_contents"]: + handler.startElement( + "media:content", + { + "url": media_content["url"], + "type": media_content["media_type"], + }, + ) + if "is_format_of" in media_content: + handler.addQuickElement( + "dcterms:isFormatOf", media_content["is_format_of"] + ) + handler.endElement("media:content") - if 'doi' in item and item['doi'] is not None: - handler.addQuickElement('dcterms:identifier',item['doi'],{'xsi:type':'dcterms:doi'}) - if 'doiuri' in item and item['doiuri'] is not None: - handler.addQuickElement('dcterms:identifier',item['doiuri'],{'xsi:type':'dcterms:uri'}) - - if 'media_content' in item and item['media_content'] is not None: - handler.startElement('media:content',{'url':item['media_content']['url'],'type':'text/plain'}) - handler.addQuickElement('dcterms:isFormatOf',item['media_content']['link_url']) - handler.endElement('media:content') class RfcFeed(Feed): feed_type = Rss201WithNamespacesFeed @@ -130,55 +182,96 @@ class RfcFeed(Feed): author_name = "RFC Editor" link = "https://www.rfc-editor.org/rfc-index2.html" - def get_object(self,request,year=None): + def get_object(self, request, year=None): self.year = year - + def items(self): if self.year: # Find published RFCs based on their official publication year start_of_year = datetime.datetime(int(self.year), 1, 1, tzinfo=RPC_TZINFO) - start_of_next_year = datetime.datetime(int(self.year) + 1, 1, 1, tzinfo=RPC_TZINFO) + start_of_next_year = datetime.datetime( + int(self.year) + 1, 1, 1, tzinfo=RPC_TZINFO + ) rfc_events = DocEvent.objects.filter( - type='published_rfc', + type="published_rfc", time__gte=start_of_year, time__lt=start_of_next_year, - ).order_by('-time') + ).order_by("-time") else: cutoff = timezone.now() - datetime.timedelta(days=8) - rfc_events = DocEvent.objects.filter(type='published_rfc',time__gte=cutoff).order_by('-time') + rfc_events = DocEvent.objects.filter( + type="published_rfc", time__gte=cutoff + ).order_by("-time") results = [(e.doc, e.time) for e in rfc_events] - for doc,time in results: + for doc, time in results: doc.publication_time = time - return [doc for doc,time in results] - + return [doc for doc, time in results] + def item_title(self, item): - return "%s : %s" % (item.canonical_name(),item.title) + return "%s : %s" % (item.canonical_name(), item.title) def item_description(self, item): return item.abstract def item_link(self, item): - return "https://rfc-editor.org/info/%s"%item.canonical_name() + return "https://rfc-editor.org/info/%s" % item.canonical_name() def item_pubdate(self, item): return item.publication_time def item_extra_kwargs(self, item): extra = super(RfcFeed, self).item_extra_kwargs(item) - extra.update({'dcterms_accessRights': 'gratis'}) - extra.update({'dcterms_format': 'text/html'}) - extra.update({'media_content': {'url': 'https://rfc-editor.org/rfc/%s.txt' % item.canonical_name(), - 'link_url': self.item_link(item) - } - }) - extra.update({'doi':'10.17487/%s' % item.canonical_name().upper()}) - extra.update({'doiuri':'http://dx.doi.org/10.17487/%s' % item.canonical_name().upper()}) - - #TODO + extra.update({"dcterms_accessRights": "gratis"}) + extra.update({"dcterms_format": "text/html"}) + media_contents = [] + if int(item.rfc_number()) < 8650: + if int(item.rfc_number()) not in [8, 9, 51, 418, 500, 530, 589]: + for fmt, media_type in [("txt", "text/plain"), ("html", "text/html")]: + media_contents.append( + { + "url": f"https://rfc-editor.org/rfc/{item.canonical_name()}.{fmt}", + "media_type": media_type, + "is_format_of": self.item_link(item), + } + ) + if int(item.rfc_number()) not in [571, 587]: + media_contents.append( + { + "url": f"https://www.rfc-editor.org/rfc/pdfrfc/{item.canonical_name()}.txt.pdf", + "media_type": "application/pdf", + "is_format_of": self.item_link(item), + } + ) + else: + media_contents.append( + { + "url": f"https://www.rfc-editor.org/rfc/{item.canonical_name()}.xml", + "media_type": "application/rfc+xml", + } + ) + for fmt, media_type in [ + ("txt", "text/plain"), + ("html", "text/html"), + ("pdf", "application/pdf"), + ]: + media_contents.append( + { + "url": f"https://rfc-editor.org/rfc/{item.canonical_name()}.{fmt}", + "media_type": media_type, + "is_format_of": f"https://www.rfc-editor.org/rfc/{item.canonical_name()}.xml", + } + ) + extra.update({"media_contents": media_contents}) + + extra.update({"doi": "10.17487/%s" % item.canonical_name().upper()}) + extra.update( + {"doiuri": "http://dx.doi.org/10.17487/%s" % item.canonical_name().upper()} + ) + # R104 Publisher (Mandatory - but we need a string from them first) - extra.update({'dcterms_publisher':'rfc-editor.org'}) + extra.update({"dcterms_publisher": "rfc-editor.org"}) - #TODO MAYBE (Optional stuff) + # TODO MAYBE (Optional stuff) # R108 License # R115 Creator/Contributor (which would we use?) # F305 Checksum (do they use it?) (or should we put the our digital signature in here somewhere?) @@ -188,4 +281,3 @@ def item_extra_kwargs(self, item): # R118 Keyword return extra - diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 47c4e146c3..5a949b091c 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -1911,11 +1911,31 @@ def test_last_call_feed(self): self.assertContains(r, doc.name) def test_rfc_feed(self): - WgRfcFactory() + rfc = WgRfcFactory(alias2__name="rfc9000") + DocEventFactory(doc=rfc, type="published_rfc") r = self.client.get("/feed/rfc/") self.assertTrue(r.status_code, 200) + q = PyQuery(r.content[39:]) # Strip off the xml declaration + self.assertEqual(len(q("item")), 1) + item = q("item")[0] + media_content = item.findall("{http://search.yahoo.com/mrss/}content") + self.assertEqual(len(media_content),4) + types = set([m.attrib["type"] for m in media_content]) + self.assertEqual(types, set(["application/rfc+xml", "text/plain", "text/html", "application/pdf"])) + rfcs_2016 = WgRfcFactory.create_batch(3) # rfc numbers will be well below v3 + for rfc in rfcs_2016: + e = DocEventFactory(doc=rfc, type="published_rfc") + e.time = e.time.replace(year=2016) + e.save() r = self.client.get("/feed/rfc/2016") self.assertTrue(r.status_code, 200) + q = PyQuery(r.content[39:]) + self.assertEqual(len(q("item")), 3) + item = q("item")[0] + media_content = item.findall("{http://search.yahoo.com/mrss/}content") + self.assertEqual(len(media_content), 3) + types = set([m.attrib["type"] for m in media_content]) + self.assertEqual(types, set(["text/plain", "text/html", "application/pdf"])) def test_state_help(self): url = urlreverse('ietf.doc.views_help.state_help', kwargs=dict(type="draft-iesg")) diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py index 00046ed304..31aedda0d7 100644 --- a/ietf/doc/utils_search.py +++ b/ietf/doc/utils_search.py @@ -140,17 +140,36 @@ def fill_in_document_table_attributes(docs, have_telechat_date=False): d.obsoleted_by_list = [] d.updated_by_list = [] - xed_by = RelatedDocument.objects.filter(target__name__in=list(rfc_aliases.values()), - relationship__in=("obs", "updates")).select_related('target') - rel_rfc_aliases = dict([ (a.document.id, re.sub(r"rfc(\d+)", r"RFC \1", a.name, flags=re.IGNORECASE)) for a in DocAlias.objects.filter(name__startswith="rfc", docs__id__in=[rel.source_id for rel in xed_by]) ]) + # Revisit this block after RFCs become first-class Document objects + xed_by = list( + RelatedDocument.objects.filter( + target__name__in=list(rfc_aliases.values()), + relationship__in=("obs", "updates"), + ).select_related("target") + ) + rel_rfc_aliases = { + a.document.id: re.sub(r"rfc(\d+)", r"RFC \1", a.name, flags=re.IGNORECASE) + for a in DocAlias.objects.filter( + name__startswith="rfc", docs__id__in=[rel.source_id for rel in xed_by] + ) + } + xed_by.sort( + key=lambda rel: int( + re.sub( + r"rfc\s*(\d+)", + r"\1", + rel_rfc_aliases[rel.source_id], + flags=re.IGNORECASE, + ) + ) + ) for rel in xed_by: d = doc_dict[rel.target.document.id] + s = rel_rfc_aliases[rel.source_id] if rel.relationship_id == "obs": - l = d.obsoleted_by_list + d.obsoleted_by_list.append(s) elif rel.relationship_id == "updates": - l = d.updated_by_list - l.append(rel_rfc_aliases[rel.source_id]) - l.sort() + d.updated_by_list.append(s) def augment_docs_with_related_docs_info(docs): """Augment all documents with related documents information. diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 39c8bf19be..c87602a9fc 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -114,13 +114,46 @@ def render_document_top(request, doc, tab, name): rsab_ballot, None if rsab_ballot else "RSAB Evaluation Ballot has not been created yet" )) - if doc.type_id in ("draft","conflrev", "statchg"): - tabs.append(("IESG Evaluation Record", "ballot", urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=name)), iesg_ballot, None if iesg_ballot else "IESG Evaluation Ballot has not been created yet")) - elif doc.type_id == "charter" and doc.group.type_id == "wg": - tabs.append(("IESG Review", "ballot", urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=name)), iesg_ballot, None if iesg_ballot else "IESG Review Ballot has not been created yet")) - - if doc.type_id == "draft" or (doc.type_id == "charter" and doc.group.type_id == "wg"): - tabs.append(("IESG Writeups", "writeup", urlreverse('ietf.doc.views_doc.document_writeup', kwargs=dict(name=name)), True, None)) + + if iesg_ballot or (doc.group and doc.group.type_id == "wg"): + if doc.type_id in ("draft", "conflrev", "statchg"): + tabs.append( + ( + "IESG Evaluation Record", + "ballot", + urlreverse( + "ietf.doc.views_doc.document_ballot", kwargs=dict(name=name) + ), + iesg_ballot, + None, + ) + ) + elif doc.type_id == "charter" and doc.group and doc.group.type_id == "wg": + tabs.append( + ( + "IESG Review", + "ballot", + urlreverse( + "ietf.doc.views_doc.document_ballot", kwargs=dict(name=name) + ), + iesg_ballot, + None, + ) + ) + if doc.type_id == "draft" or ( + doc.type_id == "charter" and doc.group and doc.group.type_id == "wg" + ): + tabs.append( + ( + "IESG Writeups", + "writeup", + urlreverse( + "ietf.doc.views_doc.document_writeup", kwargs=dict(name=name) + ), + True, + None, + ) + ) tabs.append(("Email expansions","email",urlreverse('ietf.doc.views_doc.document_email', kwargs=dict(name=name)), True, None)) tabs.append(("History", "history", urlreverse('ietf.doc.views_doc.document_history', kwargs=dict(name=name)), True, None)) @@ -151,6 +184,7 @@ def interesting_doc_relations(doc): that_doc_relationships = ('replaces', 'possibly_replaces', 'updates', 'obs') + # TODO: This returns the relationships in database order, which may not be the order we want to display them in. interesting_relations_that = cls.objects.filter(target__docs=target, relationship__in=that_relationships).select_related('source') interesting_relations_that_doc = cls.objects.filter(source=doc, relationship__in=that_doc_relationships).prefetch_related('target__docs') diff --git a/ietf/nomcom/forms.py b/ietf/nomcom/forms.py index 20bf508e8e..ad7bc67c23 100644 --- a/ietf/nomcom/forms.py +++ b/ietf/nomcom/forms.py @@ -343,7 +343,7 @@ def save(self, commit=True): 'year': self.nomcom.year(), } path = nomcom_template_path + NOMINATION_RECEIPT_TEMPLATE - send_mail(None, to_email, from_email, subject, path, context, cc=cc) + send_mail(None, to_email, from_email, subject, path, context, cc=cc, copy=False, save=False) return nomination @@ -458,7 +458,7 @@ def save(self, commit=True): 'year': self.nomcom.year(), } path = nomcom_template_path + NOMINATION_RECEIPT_TEMPLATE - send_mail(None, to_email, from_email, subject, path, context, cc=cc) + send_mail(None, to_email, from_email, subject, path, context, cc=cc, copy=False, save=False) return nomination @@ -551,7 +551,7 @@ def save(self, commit=True): } path = nomcom_template_path + FEEDBACK_RECEIPT_TEMPLATE # TODO - make the thing above more generic - send_mail(None, to_email, from_email, subject, path, context, cc=cc, copy=False) + send_mail(None, to_email, from_email, subject, path, context, cc=cc, copy=False, save=False) class Meta: model = Feedback diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index dff5fb9500..216984776c 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -122,7 +122,7 @@ def access_member_url(self, url): self.check_url_status(url, 200) self.client.logout() login_testing_unauthorized(self, MEMBER_USER, url) - return self.check_url_status(url, 200) + self.check_url_status(url, 200) def access_chair_url(self, url): login_testing_unauthorized(self, COMMUNITY_USER, url) @@ -134,7 +134,7 @@ def access_secretariat_url(self, url): login_testing_unauthorized(self, COMMUNITY_USER, url) login_testing_unauthorized(self, CHAIR_USER, url) login_testing_unauthorized(self, SECRETARIAT_USER, url) - return self.check_url_status(url, 200) + self.check_url_status(url, 200) def test_private_index_view(self): """Verify private home view""" @@ -599,6 +599,8 @@ def test_public_nominate(self): self.nominate_view(public=True,confirmation=True) self.assertEqual(len(outbox), messages_before + 3) + self.assertEqual(Message.objects.count(), 2) + self.assertFalse(Message.objects.filter(subject="Nomination receipt").exists()) self.assertEqual('IETF Nomination Information', outbox[-3]['Subject']) self.assertEqual(self.email_from, outbox[-3]['From']) @@ -625,8 +627,7 @@ def test_public_nominate(self): def test_private_nominate(self): self.access_member_url(self.private_nominate_url) - return self.nominate_view(public=False) - self.client.logout() + self.nominate_view(public=False) def test_public_nominate_newperson(self): login_testing_unauthorized(self, COMMUNITY_USER, self.public_nominate_url) @@ -666,13 +667,13 @@ def test_public_nominate_newperson(self): def test_private_nominate_newperson(self): self.access_member_url(self.private_nominate_url) - return self.nominate_newperson_view(public=False) - self.client.logout() + self.nominate_newperson_view(public=False, confirmation=True) + self.assertFalse(Message.objects.filter(subject="Nomination receipt").exists()) def test_private_nominate_newperson_who_already_exists(self): EmailFactory(address='nominee@example.com') self.access_member_url(self.private_nominate_newperson_url) - return self.nominate_newperson_view(public=False) + self.nominate_newperson_view(public=False) def test_public_nominate_with_automatic_questionnaire(self): nomcom = get_nomcom_by_year(self.year) @@ -844,8 +845,7 @@ def nominate_newperson_view(self, *args, **kwargs): def test_add_questionnaire(self): self.access_chair_url(self.add_questionnaire_url) - return self.add_questionnaire() - self.client.logout() + self.add_questionnaire() def add_questionnaire(self, *args, **kwargs): public = kwargs.pop('public', False) @@ -906,6 +906,8 @@ def test_public_feedback(self): # We're interested in the confirmation receipt here self.assertEqual(len(outbox),3) self.assertEqual('NomCom comment confirmation', outbox[2]['Subject']) + self.assertEqual(Message.objects.count(), 2) + self.assertFalse(Message.objects.filter(subject="NomCom comment confirmation").exists()) email_body = get_payload_text(outbox[2]) self.assertIn(position, email_body) self.assertNotIn('$', email_body) @@ -920,7 +922,7 @@ def test_public_feedback(self): def test_private_feedback(self): self.access_member_url(self.private_feedback_url) - return self.feedback_view(public=False) + self.feedback_view(public=False) def feedback_view(self, *args, **kwargs): public = kwargs.pop('public', True) diff --git a/ietf/templates/nomcom/feedback.html b/ietf/templates/nomcom/feedback.html index 92109456e6..bbdc279727 100644 --- a/ietf/templates/nomcom/feedback.html +++ b/ietf/templates/nomcom/feedback.html @@ -9,13 +9,20 @@

{% if nomcom.group.state_id == 'conclude' %} Feedback to this NomCom is closed. + {% elif positions|length != 0 and topics|length != 0 %} + Select a nominee or topic from the list on the right to obtain a new feedback form. + {% elif positions|length != 0 %} + Select a nominee from the list on the right to obtain a new feedback form. + {% elif topics|length != 0 %} + Select a topic from the list on the right to obtain a new feedback form. {% else %} - Select a nominee from the list of nominees on the right to obtain a new feedback form. + This NomCom is not accepting feedback at this time. {% endif %}

{% if nomcom|has_publickey %}
+ {% if positions|length != 0 %}

A number after a name indicates @@ -43,6 +50,8 @@

{% endif %} {% endfor %} + {% endif %} + {% if topics|length != 0 %}
{% for t in topics %} @@ -58,6 +67,7 @@ {% endfor %}
+ {% endif %}
{% if form %} diff --git a/package.json b/package.json index 148c3bcc30..c14c501736 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "caniuse-lite": "1.0.30001495", "d3": "7.8.5", "file-saver": "2.0.5", - "highcharts": "11.0.1", + "highcharts": "11.1.0", "ical.js": "1.5.0", "jquery": "3.7.0", "js-cookie": "3.0.5", @@ -70,7 +70,7 @@ "jquery-migrate": "3.4.1", "parcel": "2.9.1", "pug": "3.0.2", - "sass": "1.62.1", + "sass": "1.63.4", "seedrandom": "3.0.5", "vite": "4.3.9" }, diff --git a/yarn.lock b/yarn.lock index be61bf2839..7f625a22f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4429,10 +4429,10 @@ browserlist@latest: languageName: node linkType: hard -"highcharts@npm:11.0.1": - version: 11.0.1 - resolution: "highcharts@npm:11.0.1" - checksum: 773a7b876502d616b7c5f522610b061cc714233a6ecdd7f7d073151fdf53bc597c01a758fbda5bba9069a0ee487e7defddfbdb1de73a78db6267a9257250137c +"highcharts@npm:11.1.0": + version: 11.1.0 + resolution: "highcharts@npm:11.1.0" + checksum: f9b8cdc38b3b41bcc4c3a2331d9b1c769400639e2d0094484a0f5274aaba619551b95b442a69f7f4e47c2c8445681e3651f6036207fe1928a1a982f5278ae85e languageName: node linkType: hard @@ -6651,7 +6651,7 @@ browserlist@latest: eslint-plugin-promise: 6.1.1 eslint-plugin-vue: 9.14.1 file-saver: 2.0.5 - highcharts: 11.0.1 + highcharts: 11.1.0 html-validate: 7.18.1 ical.js: 1.5.0 jquery: 3.7.0 @@ -6670,7 +6670,7 @@ browserlist@latest: pinia: 2.1.3 pinia-plugin-persist: 1.0.0 pug: 3.0.2 - sass: 1.62.1 + sass: 1.63.4 seedrandom: 3.0.5 select2: 4.1.0-rc.0 select2-bootstrap-5-theme: 1.3.0 @@ -6734,16 +6734,16 @@ browserlist@latest: languageName: node linkType: hard -"sass@npm:1.62.1": - version: 1.62.1 - resolution: "sass@npm:1.62.1" +"sass@npm:1.63.4": + version: 1.63.4 + resolution: "sass@npm:1.63.4" dependencies: chokidar: ">=3.0.0 <4.0.0" immutable: ^4.0.0 source-map-js: ">=0.6.2 <2.0.0" bin: sass: sass.js - checksum: 1b1b3584b38a63dd94156b65f13b90e3f84b170a38c3d5e3fa578b7a32a37aeb349b4926b0eaf9448d48e955e86b1ee01b13993f19611dad8068af07a607c13b + checksum: 12bde5beff85a7018157d90c8b9d5aec8b56832f89fcfeca146f10936eecf97e669d22fd41f812b3407ed259bbb114d69c9ecbfc7ee9b15308211fb910cdf5eb languageName: node linkType: hard