├── .dockerignore
├── .github
├── config
│ └── changelog.js
└── workflows
│ ├── production.yml
│ └── staging.yml
├── .gitignore
├── Dockerfile
├── Dockerfile-staging
├── LICENSE
├── README.md
├── client
├── .env
├── .env.production
├── .env.staging
├── .eslintrc
├── .gitignore
├── index.html
├── package.json
├── public
│ ├── favicon.ico
│ ├── fonts
│ │ ├── Marianne-Bold.woff
│ │ ├── Marianne-Bold.woff2
│ │ ├── Marianne-Bold_Italic.woff
│ │ ├── Marianne-Bold_Italic.woff2
│ │ ├── Marianne-Light.woff
│ │ ├── Marianne-Light.woff2
│ │ ├── Marianne-Light_Italic.woff
│ │ ├── Marianne-Light_Italic.woff2
│ │ ├── Marianne-Medium.woff
│ │ ├── Marianne-Medium.woff2
│ │ ├── Marianne-Medium_Italic.woff
│ │ ├── Marianne-Medium_Italic.woff2
│ │ ├── Marianne-Regular.woff
│ │ ├── Marianne-Regular.woff2
│ │ ├── Marianne-Regular_Italic.woff
│ │ ├── Marianne-Regular_Italic.woff2
│ │ ├── Spectral-ExtraBold.woff
│ │ ├── Spectral-ExtraBold.woff2
│ │ ├── Spectral-Regular.woff
│ │ └── Spectral-Regular.woff2
│ ├── robots.txt
│ └── vite.svg
├── src
│ ├── components
│ │ ├── button-dropdown
│ │ │ ├── index.jsx
│ │ │ └── index.scss
│ │ ├── file
│ │ │ └── index.jsx
│ │ ├── footer
│ │ │ └── index.jsx
│ │ ├── gauge
│ │ │ ├── index.jsx
│ │ │ └── index.scss
│ │ ├── mention-list
│ │ │ └── item.jsx
│ │ ├── ribbon
│ │ │ ├── index.jsx
│ │ │ └── index.scss
│ │ ├── switch-language
│ │ │ └── index.jsx
│ │ ├── tag-input
│ │ │ ├── index.jsx
│ │ │ └── index.scss
│ │ ├── tiles
│ │ │ ├── datasets.jsx
│ │ │ ├── mentions.jsx
│ │ │ ├── openalex.jsx
│ │ │ └── publications.jsx
│ │ └── toast
│ │ │ ├── index.jsx
│ │ │ └── index.scss
│ ├── config.js
│ ├── hooks
│ │ ├── useCopyToClipboard.jsx
│ │ ├── useLocalStorage.jsx
│ │ ├── usePausableTimer.js
│ │ └── useToast.jsx
│ ├── i18n
│ │ ├── en.json
│ │ └── fr.json
│ ├── layout
│ │ ├── footer.jsx
│ │ ├── header.jsx
│ │ └── index.jsx
│ ├── main.jsx
│ ├── pages
│ │ ├── about.jsx
│ │ ├── actions
│ │ │ ├── actionsAffiliations.jsx
│ │ │ ├── actionsDatasets.jsx
│ │ │ └── actionsPublications.jsx
│ │ ├── affiliationsTab.jsx
│ │ ├── affiliationsView.jsx
│ │ ├── datasets
│ │ │ ├── datasetsTab.jsx
│ │ │ ├── datasetsView.jsx
│ │ │ ├── datasetsYearlyDistribution.jsx
│ │ │ ├── results.jsx
│ │ │ └── search.jsx
│ │ ├── home.jsx
│ │ ├── mentions
│ │ │ ├── components
│ │ │ │ ├── custom-toggle
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ └── styles.scss
│ │ │ │ ├── mentions-list.tsx
│ │ │ │ └── search-utils.jsx
│ │ │ ├── index.jsx
│ │ │ ├── results.jsx
│ │ │ ├── search.jsx
│ │ │ └── styles.scss
│ │ ├── openalex-affiliations
│ │ │ ├── components
│ │ │ │ ├── export-errors-button.jsx
│ │ │ │ ├── modal-info.jsx
│ │ │ │ ├── ror-badge.jsx
│ │ │ │ ├── ror-name.jsx
│ │ │ │ ├── send-feedback-button.jsx
│ │ │ │ └── works-list.jsx
│ │ │ ├── corrections.jsx
│ │ │ ├── results
│ │ │ │ ├── index.jsx
│ │ │ │ └── list-view.jsx
│ │ │ └── search.jsx
│ │ ├── publications
│ │ │ ├── publicationsTab.jsx
│ │ │ ├── publicationsView.jsx
│ │ │ ├── results.jsx
│ │ │ └── search.jsx
│ │ └── views
│ │ │ ├── datasets.jsx
│ │ │ └── publications.jsx
│ ├── router.jsx
│ ├── styles
│ │ └── index.scss
│ └── utils
│ │ ├── curations.jsx
│ │ ├── files.jsx
│ │ ├── flags.jsx
│ │ ├── helpers.jsx
│ │ ├── ror.jsx
│ │ ├── strings.jsx
│ │ ├── tags.jsx
│ │ ├── templates.jsx
│ │ └── works.jsx
└── vite.config.js
├── doc
└── Works-magnet-20240412.pdf
├── notebooks
└── SuggestionsFromRoR.ipynb
├── package-lock.json
├── package.json
├── server
├── .eslintrc
├── .gitignore
├── index.js
├── package.json
└── src
│ ├── app.js
│ ├── commons
│ ├── errors
│ │ ├── bad-request.error.js
│ │ ├── forbidden.error.js
│ │ ├── http.error.js
│ │ ├── index.js
│ │ ├── not-found.error.js
│ │ ├── server.error.js
│ │ └── unauthorized.error.js
│ └── middlewares
│ │ └── handle-errors.js
│ ├── config.js
│ ├── openapi
│ └── api.yml
│ ├── router.js
│ ├── routes
│ ├── affiliations.routes.js
│ ├── files.routes.js
│ ├── mentions.routes.js
│ └── works.routes.js
│ ├── services
│ └── logger.js
│ ├── utils
│ ├── github.js
│ ├── openalex.js
│ ├── s3.js
│ ├── utils.js
│ └── works.js
│ └── webSocketServer.js
└── vite.config.js
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Github
2 | **/.git
3 | .github
4 |
5 | # Docker
6 | .dockerignore
7 | Dockerfile
8 |
9 | # NPM
10 | **/node_modules
11 | npm-debug.log
12 |
13 | # Misc
14 | **/.DS_Store
--------------------------------------------------------------------------------
/.github/config/changelog.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | types: [
3 | { types: ['feat', 'feature'], label: '🎉 New feature' },
4 | { types: ['fix', 'bugfix'], label: '🐛 Bug fix' },
5 | { types: ['improvements', 'enhancement'], label: '🔨 Improvement' },
6 | { types: ['build', 'ci'], label: '🏗️ Deployment' },
7 | { types: ['refactor'], label: '🪚 Refactor' },
8 | { types: ['perf'], label: '🏎️ Performance improvement' },
9 | { types: ['doc', 'docs'], label: '📚 Documentation' },
10 | { types: ['test', 'tests'], label: '🔍 Tests' },
11 | { types: ['style'], label: '💅 Style' },
12 | { types: ['chore'], label: '🧹 Cleaning' },
13 | { types: ['other'], label: 'Other' },
14 | ],
15 |
16 | excludeTypes: [
17 | 'other',
18 | 'perf',
19 | 'test',
20 | 'tests',
21 | 'style',
22 | 'chore',
23 | 'doc',
24 | 'docs',
25 | ],
26 | };
27 |
--------------------------------------------------------------------------------
/.github/workflows/production.yml:
--------------------------------------------------------------------------------
1 | name: Production deployment
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v[0-9]+.[0-9]+.[0-9]+"
7 |
8 | env:
9 | # Must match k8s deployment name
10 | DEPLOYMENT: works-magnet
11 | DEPLOYMENT_NAMESPACE: works-magnet
12 | DEPLOYMENT_URL: https://works-magnet.esr.gouv.fr
13 | MM_NOTIFICATION_CHANNEL: bots
14 |
15 | jobs:
16 | publish-ghcr:
17 | name: Build & publish Docker image
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: 🏁 Checkout
21 | uses: actions/checkout@v4
22 |
23 | - name: 🏷️ Get tag
24 | id: tag
25 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
26 |
27 | - name: 🔑 Login ghcr.io
28 | run: docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }}
29 |
30 | - name: 🏗️ Build front app
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: 20
34 | - run: npm ci --silent && npm run build --mode=production
35 |
36 | - name: 🤖 Delete robots.txt file for production
37 | run: rm server/dist/robots.txt
38 |
39 | - name: 🐋 Build Docker image
40 | run: |
41 | IMAGE_ID=ghcr.io/${{ github.repository }}
42 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
43 | docker build -t $IMAGE_ID:${{ steps.tag.outputs.tag }} -t $IMAGE_ID:latest .
44 |
45 | - name: 📦 Push Docker image
46 | run: |
47 | IMAGE_ID=ghcr.io/${{ github.repository }}
48 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
49 | docker push -a $IMAGE_ID
50 |
51 | release:
52 | name: Create new release
53 | runs-on: ubuntu-latest
54 | needs: publish-ghcr
55 | steps:
56 | - name: 🏁 Checkout
57 | uses: actions/checkout@v4
58 | with:
59 | fetch-depth: 0
60 |
61 | - name: 🏷️ Get tag
62 | id: tag
63 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
64 |
65 | - name: 📄 Create changelog
66 | id: changelog
67 | uses: loopwerk/tag-changelog@v1
68 | with:
69 | token: ${{ secrets.GITHUB_TOKEN }}
70 | config_file: .github/config/changelog.js
71 |
72 | - name: 📦 Create release
73 | uses: softprops/action-gh-release@v2
74 | env:
75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
76 | with:
77 | tag_name: ${{ steps.tag.outputs.tag }}
78 | name: ${{ steps.tag.outputs.tag }}
79 | body: "${{ steps.changelog.outputs.changes }}"
80 |
81 | deploy:
82 | name: Update production deployment
83 | runs-on: ubuntu-latest
84 | needs: release
85 | steps:
86 | - name: 🌥️ Deployment
87 | uses: dataesr/kubectl-deploy@v1.1
88 | env:
89 | KUBE_CONFIG: ${{ secrets.KUBE_CONFIG_DOAD_PROD }}
90 | with:
91 | namespace: ${{ env.DEPLOYMENT_NAMESPACE }}
92 | restart: ${{ env.DEPLOYMENT }}
93 |
94 | notify:
95 | needs: deploy
96 | if: always()
97 | runs-on: ubuntu-latest
98 | steps:
99 | - name: 📢 Notify
100 | uses: dataesr/mm-notifier-action@v1.0.2
101 | with:
102 | deployment_url: ${{ env.DEPLOYMENT_URL }}
103 | github_token: ${{ secrets.GITHUB_TOKEN }}
104 | mattermost_channel: ${{ env.MM_NOTIFICATION_CHANNEL}}
105 | mattermost_webhook_url: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
106 |
--------------------------------------------------------------------------------
/.github/workflows/staging.yml:
--------------------------------------------------------------------------------
1 | name: Staging deployment
2 |
3 | on:
4 | push:
5 | branches:
6 | - staging
7 |
8 | env:
9 | # Must match k8s deployment name
10 | DEPLOYMENT: works-magnet
11 | DEPLOYMENT_NAMESPACE: works-magnet
12 | DEPLOYMENT_URL: https://works-magnet.staging.dataesr.ovh
13 | MM_NOTIFICATION_CHANNEL: bots
14 |
15 | jobs:
16 | publish-ghcr:
17 | name: Build & publish Docker image
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: 🏁 Checkout
21 | uses: actions/checkout@v4
22 |
23 | - name: 🔑 Login ghcr.io
24 | run: docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }}
25 |
26 | - name: 🏗️ Build front app
27 | uses: actions/setup-node@v4
28 | with:
29 | node-version: 20
30 | - run: npm ci --silent && npm run build --mode=staging
31 |
32 | - name: 🐋 Build Docker image
33 | run: |
34 | IMAGE_ID=ghcr.io/${{ github.repository }}
35 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
36 | docker build -f Dockerfile-staging -t $IMAGE_ID:staging .
37 |
38 | - name: 📦 Push Docker image
39 | run: |
40 | IMAGE_ID=ghcr.io/${{ github.repository }}
41 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
42 | docker push $IMAGE_ID:staging
43 |
44 | deploy:
45 | name: Update staging deployment
46 | runs-on: ubuntu-latest
47 | needs: publish-ghcr
48 | steps:
49 | - name: 🌥️ Deployment
50 | uses: dataesr/kubectl-deploy@v1.1
51 | env:
52 | KUBE_CONFIG: ${{ secrets.KUBE_CONFIG_DOAD_STAGING }}
53 | with:
54 | namespace: ${{ env.DEPLOYMENT_NAMESPACE }}
55 | restart: ${{ env.DEPLOYMENT }}
56 |
57 | notify:
58 | needs: deploy
59 | if: always()
60 | runs-on: ubuntu-latest
61 | steps:
62 | - name: 📢 Notify
63 | uses: dataesr/mm-notifier-action@v1.0.2
64 | with:
65 | deployment_url: ${{ env.DEPLOYMENT_URL }}
66 | github_token: ${{ secrets.GITHUB_TOKEN }}
67 | mattermost_channel: ${{ env.MM_NOTIFICATION_CHANNEL}}
68 | mattermost_webhook_url: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 | WORKDIR /app
3 | COPY package*.json ./
4 | COPY server ./server
5 | RUN npm ci --silent
6 | CMD ["npm", "run", "-w", "server", "start", "--mode=production"]
7 | EXPOSE 3000 3001 443
--------------------------------------------------------------------------------
/Dockerfile-staging:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 | WORKDIR /app
3 | COPY package*.json ./
4 | COPY server ./server
5 | RUN npm ci --silent
6 | CMD ["npm", "run", "-w", "server", "start", "--mode=staging"]
7 | EXPOSE 3000 3001 443
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 #dataESR
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Works-magnet
2 |
3 | [](https://discord.gg/TudsqDqTqb)
4 | 
5 | 
6 | [](https://github.com/dataesr/works-magnet/actions/workflows/production.yml)
7 | 
8 | [](https://archive.softwareheritage.org/browse/origin/?origin_url=https://github.com/dataesr/works-magnet)
9 |
10 | Retrieve and promote the scholarly works of your institution.
11 |
12 | ## Build for production
13 |
14 | The react client app is served by the node server in production.
15 |
16 | ## Requirements
17 |
18 | node >= 20
19 |
20 | ## Install and run app
21 |
22 | Run
23 |
24 | ```sh
25 | npm i && npm start
26 | ```
27 |
28 | Web App available at http://localhost:5173/ and API at http://localhost:3000/.
29 |
30 | ## Build app
31 |
32 | ```sh
33 | npm run build
34 | ```
35 |
36 | Vite build creates a build in `/dist` folder. This folder has to be moved to the `/server` folder.
37 |
38 | ## Deployment
39 |
40 | The version number follows [semver](https://semver.org/).
41 |
42 | To deploy in production, simply run this command from your staging branch :
43 |
44 | ```sh
45 | npm run deploy --level=[patch|minor|major]
46 | ```
47 |
48 | :warning: Obviously, only members of the [dataesr organization](https://github.com/dataesr/) have rights to push on the repo.
--------------------------------------------------------------------------------
/client/.env:
--------------------------------------------------------------------------------
1 | VITE_API=/api
2 | VITE_APP_DEFAULT_YEAR=2024
3 | VITE_APP_MATOMO_BASE_URL=https://piwik.enseignementsup-recherche.pro
4 | VITE_APP_NAME=Works-magnet 🧲
5 | VITE_APP_START_YEAR=2010
6 | VITE_APP_TAG_LIMIT=3
7 | VITE_GIT_REPOSITORY_URL=https://github.com/dataesr/works-magnet
8 | VITE_HEADER_TAG=
9 | VITE_HEADER_TAG_COLOR=new
10 | VITE_MINISTER_NAME=Ministère chargé de l'enseignement supérieur et de la recherche
11 | VITE_VERSION=$npm_package_version
12 | VITE_WS_HOST=wss://works-magnet.esr.gouv.fr
13 |
--------------------------------------------------------------------------------
/client/.env.production:
--------------------------------------------------------------------------------
1 | VITE_APP_MATOMO_SITE_ID=50
2 | VITE_WS_HOST=wss://works-magnet.dataesr.ovh
--------------------------------------------------------------------------------
/client/.env.staging:
--------------------------------------------------------------------------------
1 | VITE_APP_MATOMO_SITE_ID=49
2 | VITE_HEADER_TAG=staging
3 | VITE_WS_HOST=wss://works-magnet.staging.dataesr.ovh
--------------------------------------------------------------------------------
/client/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "ecmaVersion": 13
4 | },
5 | "env": {
6 | "browser": true
7 | },
8 | "rules": {
9 | "jsx-a11y/anchor-is-valid": "off",
10 | "react/jsx-filename-extension": 0,
11 | "react/react-in-jsx-scope": 0,
12 | "react/forbid-prop-types": 0,
13 | "react/no-unescaped-entities": 0,
14 | "no-unused-vars": "warn",
15 | "template-curly-spacing": "off",
16 | "object-curly-newline": "off",
17 | "react/jsx-props-no-spreading": "off",
18 | "react-hooks/exhaustive-deps": "warn",
19 | "max-len": [
20 | "warn",
21 | 200
22 | ],
23 | "react/prop-types": "warn",
24 | "import/prefer-default-export": 0,
25 | "import/no-unresolved": "off",
26 | "no-underscore-dangle": 0,
27 | "no-console": "warn",
28 | "indent": [
29 | "warn",
30 | 2,
31 | {
32 | "ignoredNodes": [
33 | "TemplateLiteral"
34 | ]
35 | }
36 | ]
37 | },
38 | "extends": [
39 | "airbnb",
40 | "airbnb/hooks"
41 | ]
42 | }
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Works-magnet
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.10.5",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "vite build",
8 | "dev": "vite",
9 | "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@dataesr/dsfr-plus": "^0.5.1",
14 | "@m4tt72/matomo-tracker-react": "^0.6.2",
15 | "@tanstack/react-query": "^4.29.5",
16 | "@tanstack/react-query-devtools": "^4.29.6",
17 | "classnames": "^2.3.2",
18 | "highcharts": "^11.4.8",
19 | "highcharts-react-official": "^3.2.1",
20 | "intro.js": "^7.2.0",
21 | "js-cookie": "^3.0.5",
22 | "papaparse": "^5.4.1",
23 | "primereact": "^9.6.0",
24 | "prop-types": "^15.8.1",
25 | "react": "^18.2.0",
26 | "react-dom": "^18.2.0",
27 | "react-intl": "^6.8.7",
28 | "react-router-dom": "^6.11.1",
29 | "react-tooltip": "^5.18.1",
30 | "react-use-websocket": "^4.8.1",
31 | "remixicon": "^4.2.0"
32 | },
33 | "devDependencies": {
34 | "@types/react": "^18.0.28",
35 | "@types/react-dom": "^18.0.11",
36 | "@vitejs/plugin-react": "^4.0.0",
37 | "eslint": "^8.40.0",
38 | "eslint-config-airbnb": "^19.0.4",
39 | "eslint-plugin-import": "^2.27.5",
40 | "eslint-plugin-jsx-a11y": "^6.7.1",
41 | "eslint-plugin-react": "^7.32.2",
42 | "eslint-plugin-react-hooks": "^4.6.0",
43 | "sass": "^1.62.1",
44 | "vite": "^4.3.2"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Bold.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Bold.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Bold_Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Bold_Italic.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Bold_Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Bold_Italic.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Light.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Light.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Light_Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Light_Italic.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Light_Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Light_Italic.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Medium.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Medium.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Medium_Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Medium_Italic.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Medium_Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Medium_Italic.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Regular.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Regular.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Regular_Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Regular_Italic.woff
--------------------------------------------------------------------------------
/client/public/fonts/Marianne-Regular_Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Marianne-Regular_Italic.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Spectral-ExtraBold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Spectral-ExtraBold.woff
--------------------------------------------------------------------------------
/client/public/fonts/Spectral-ExtraBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Spectral-ExtraBold.woff2
--------------------------------------------------------------------------------
/client/public/fonts/Spectral-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Spectral-Regular.woff
--------------------------------------------------------------------------------
/client/public/fonts/Spectral-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/client/public/fonts/Spectral-Regular.woff2
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
--------------------------------------------------------------------------------
/client/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/components/button-dropdown/index.jsx:
--------------------------------------------------------------------------------
1 | // https://www.w3schools.com/css/css_dropdowns.asp
2 | import { Button } from '@dataesr/dsfr-plus';
3 | import classNames from 'classnames';
4 | import PropTypes from 'prop-types';
5 |
6 | import useToast from '../../hooks/useToast';
7 | import { export2Csv, export2FosmCsv, export2jsonl } from '../../utils/files';
8 | import { capitalize } from '../../utils/strings';
9 |
10 | import './index.scss';
11 |
12 | export default function ButtonDropdown({ className, data, label, searchParams, size, transformCsv }) {
13 | const { toast } = useToast();
14 |
15 | const _className = classNames(
16 | 'dropdown',
17 | data.length > 0 ? 'enabled' : 'disabled',
18 | className,
19 | );
20 |
21 | const toastExport = (numberOfLines) => {
22 | const _size = numberOfLines ?? data.length;
23 | toast({
24 | description: `${_size} ${label} have been saved`,
25 | id: 'saveWork',
26 | title: `${capitalize(label)} saved`,
27 | toastType: 'success',
28 | });
29 | };
30 |
31 | return (
32 |
33 |
39 | {`Export ${label} (${data.length})`}
40 |
41 |
42 | { export2Csv({ data, label, searchParams, transform: transformCsv }); toastExport(); }}
45 | size={size}
46 | >
47 | Export in CSV (minimal data)
48 |
49 | { export2jsonl({ data, label, searchParams }); toastExport(); }}
52 | size={size}
53 | >
54 | Export in JSONL (complete data)
55 |
56 | {['publications', 'datasets'].includes(label) && (
57 | {
60 | const numberOfLines = export2FosmCsv({ data, label, searchParams });
61 | toastExport(numberOfLines);
62 | }}
63 | size={size}
64 | >
65 | Custom export for French OSM
66 |
67 | )}
68 |
69 |
70 | );
71 | }
72 |
73 | ButtonDropdown.defaultProps = {
74 | className: '',
75 | size: 'md',
76 | transformCsv: (data) => data,
77 | };
78 |
79 | ButtonDropdown.propTypes = {
80 | className: PropTypes.string,
81 | data: PropTypes.array.isRequired,
82 | label: PropTypes.string.isRequired,
83 | searchParams: PropTypes.object.isRequired,
84 | size: PropTypes.string,
85 | transformCsv: PropTypes.func,
86 | };
87 |
--------------------------------------------------------------------------------
/client/src/components/button-dropdown/index.scss:
--------------------------------------------------------------------------------
1 | .dropdown {
2 | display: inline-block;
3 | position: relative;
4 | z-index: 20;
5 |
6 | .dropdown-content {
7 | display: none;
8 | min-width: 160px;
9 | position: absolute;
10 | z-index: 20;
11 | }
12 | }
13 |
14 | .dropdown.enabled:hover .dropdown-content {
15 | display: block;
16 | }
--------------------------------------------------------------------------------
/client/src/components/file/index.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import PropTypes from 'prop-types';
3 | import React from 'react';
4 |
5 | const getAll = (props) => {
6 | const newProps = {};
7 |
8 | Object.keys(props).forEach((key) => {
9 | if (key.startsWith('data-') || key === 'id') {
10 | newProps[key] = props[key];
11 | }
12 | });
13 |
14 | return newProps;
15 | };
16 |
17 | function File({
18 | accept,
19 | className,
20 | errorMessage,
21 | hint,
22 | label,
23 | multiple,
24 | onChange,
25 | ...remainingProps
26 | }) {
27 | const _className = classNames(
28 | 'fr-upload-group',
29 | className,
30 | {
31 | [`ds-fr--${label}`]: label,
32 | },
33 | );
34 |
35 | return (
36 |
37 |
38 | {label}
39 | {hint && {hint}
}
40 |
41 |
49 | {errorMessage && (
50 |
51 | {errorMessage}
52 |
53 | )}
54 |
55 | );
56 | }
57 |
58 | File.defaultProps = {
59 | className: '',
60 | hint: '',
61 | errorMessage: '',
62 | accept: undefined,
63 | multiple: false,
64 | onChange: () => { },
65 | };
66 |
67 | File.propTypes = {
68 | className: PropTypes.string,
69 | label: PropTypes.string.isRequired,
70 | multiple: PropTypes.bool,
71 | onChange: PropTypes.func,
72 | errorMessage: PropTypes.string,
73 | hint: PropTypes.string,
74 | accept: PropTypes.string,
75 | };
76 |
77 | export default File;
78 |
--------------------------------------------------------------------------------
/client/src/components/footer/index.jsx:
--------------------------------------------------------------------------------
1 | import { Container, Link, Logo } from '@dataesr/dsfr-plus';
2 | import PropTypes from 'prop-types';
3 | import React from 'react';
4 |
5 | export function FooterTop({ children }) {
6 | return {children}
;
7 | }
8 | FooterTop.propTypes = {
9 | children: PropTypes.object.isRequired,
10 | };
11 |
12 | export function Footer({ children, fluid = false }) {
13 | return (
14 |
19 | );
20 | }
21 | Footer.defaultProps = {
22 | fluid: false,
23 | };
24 | Footer.propTypes = {
25 | children: PropTypes.array.isRequired,
26 | fluid: PropTypes.bool,
27 | };
28 |
29 | export function FooterBottom({
30 | children,
31 | copy,
32 | }) {
33 | const childs = React.Children.toArray(children);
34 | return (
35 |
36 |
37 | {childs.map((child, i) => (
38 |
39 | {child}
40 |
41 | ))}
42 |
43 | {copy ? (
44 |
47 | ) : null}
48 |
49 | );
50 | }
51 | FooterBottom.defaultProps = {
52 | copy: undefined,
53 | };
54 | FooterBottom.propTypes = {
55 | children: PropTypes.array.isRequired,
56 | copy: PropTypes.string,
57 | };
58 |
59 | export function FooterBody({
60 | children,
61 | description,
62 | }) {
63 | const links = React.Children.toArray(children).filter(
64 | (child) => React.isValidElement(child) && child.type === Link,
65 | );
66 | const logo = React.Children.toArray(children).filter(
67 | (child) => React.isValidElement(child) && child.type === Logo,
68 | )?.[0];
69 |
70 | return (
71 |
72 | {logo ? (
73 |
{logo}
74 | ) : null}
75 |
76 | {description ? (
77 |
{description}
78 | ) : null}
79 | {links.length ? (
80 |
81 | {links.map((link, i) => (
82 |
83 | {link}
84 |
85 | ))}
86 |
87 | ) : null}
88 |
89 |
90 | );
91 | }
92 |
93 | FooterBody.defaultProps = {
94 | description: undefined,
95 | };
96 | FooterBody.propTypes = {
97 | children: PropTypes.array.isRequired,
98 | description: PropTypes.string,
99 | };
100 |
--------------------------------------------------------------------------------
/client/src/components/gauge/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-mixed-operators */
2 | import PropTypes from 'prop-types';
3 | import React from 'react';
4 |
5 | import { Tooltip } from 'react-tooltip';
6 |
7 | import './index.scss';
8 |
9 | export default function Gauge({ data }) {
10 | const dataWithPercent = data.map((item) => (
11 | { ...item, percentage: (item.value / data.reduce((acc, curr) => acc + curr.value, 0) * 100).toFixed(0) }
12 | ));
13 |
14 | return (
15 |
16 | {dataWithPercent.filter((item) => item.value > 0).map((item) => (
17 |
18 |
23 | {`${item.label} (${item.value} ie. ${item.percentage} %)`}
24 |
25 |
26 | {`${item.label} (${item.value} ie. ${item.percentage} %)`}
27 |
28 |
29 | ))}
30 |
31 | );
32 | }
33 |
34 | Gauge.propTypes = {
35 | data: PropTypes.array.isRequired,
36 | };
37 |
--------------------------------------------------------------------------------
/client/src/components/gauge/index.scss:
--------------------------------------------------------------------------------
1 | .gauge-container {
2 | align-items: center;
3 | display: flex;
4 | height: 100%;
5 | justify-content: center;
6 | width: 100%;
7 |
8 | .gauge-bar {
9 | color: white;
10 | font-size: small;
11 | height: 32px;
12 | margin-bottom: 10px;
13 | overflow: hidden;
14 | padding: 3px 8px;
15 | position: relative;
16 | text-overflow: ellipsis;
17 | white-space: nowrap;
18 |
19 | &:first-child {
20 | border-bottom-left-radius: 15px;
21 | border-top-left-radius: 15px;
22 | }
23 | &:last-child {
24 | border-bottom-right-radius: 15px;
25 | border-top-right-radius: 15px;
26 | }
27 |
28 | &.excluded {
29 | background-color: var(--background-contrast-error);
30 | color: var(--text-default-error);
31 | }
32 | &.tobedecided {
33 | background-color: var(--background-contrast-info);
34 | color: var(--text-default-info);
35 | }
36 | &.validated {
37 | background-color: var(--background-contrast-success);
38 | color: var(--text-default-success);
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/client/src/components/mention-list/item.jsx:
--------------------------------------------------------------------------------
1 | import { Badge, Button, Col, Row, Text } from '@dataesr/dsfr-plus';
2 | import PropTypes from 'prop-types';
3 | import { useState } from 'react';
4 |
5 | import { getIdLinkDisplay } from '../../utils/works';
6 |
7 | export default function MentionListItem({ mention }) {
8 | const [expanded, setExpanded] = useState(false);
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | {mention.rawForm}
19 |
20 | {mention.type}
21 |
22 |
23 |
24 |
25 | {
26 | !expanded && (
27 | setExpanded(!expanded)} variant="text">
28 | view details
29 |
30 | )
31 | }
32 | {expanded && (
33 |
34 |
35 |
36 | DOI
37 |
41 |
42 |
43 |
44 |
45 |
46 | {mention.authors.slice(0, 5).join(', ')}
47 | {mention.authors.length > 5 ? '...' : ''}
48 |
49 |
50 | {
51 | mention.affiliations && mention.affiliations.length > 0 && (
52 |
53 |
54 |
55 | {mention.affiliations.slice(0, 5).join(', ')}
56 | {mention.affiliations.length > 5 ? '...' : ''}
57 |
58 |
59 | )
60 | }
61 |
setExpanded(!expanded)} variant="text">
62 | hide details
63 |
64 |
65 | )}
66 |
67 |
68 | {(mention.mention_context.created) ? (
69 |
70 | created
71 |
72 | ) : (
73 |
74 | not created
75 |
76 | )}
77 |
78 |
79 | {(mention.mention_context.used) ? (
80 |
81 | used
82 |
83 | ) : (
84 |
85 | not used
86 |
87 | )}
88 |
89 |
90 | {(mention.mention_context.shared) ? (
91 |
92 | shared
93 |
94 | ) : (
95 |
96 | not shared
97 |
98 | )}
99 |
100 |
101 |
102 | );
103 | }
104 |
105 | MentionListItem.propTypes = {
106 | mention: PropTypes.object.isRequired,
107 | };
108 |
--------------------------------------------------------------------------------
/client/src/components/ribbon/index.jsx:
--------------------------------------------------------------------------------
1 | // https://frontendresource.com/css-badges/
2 | import './index.scss';
3 |
4 | export default function Ribbon() {
5 | return (
6 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/components/ribbon/index.scss:
--------------------------------------------------------------------------------
1 | .ribbon {
2 | left: -85px;
3 | position: absolute;
4 | top: 2px;
5 | z-index: calc(var(--ground) + 600);
6 |
7 | .badge {
8 | background: #e1000f;
9 | box-shadow: inset 0px 0px 0px 4px rgba(255, 255, 255, 0.34);
10 | color: #FFF;
11 | font-family: sans-serif;
12 | font-size: 10px;
13 | height: 25px;
14 | line-height: 25px;
15 | text-align: center;
16 | transform: rotate(-45deg);
17 | width: 200px;
18 | }
19 | }
20 |
21 | .expanded {
22 | .ribbon {
23 | left: -60px;
24 | top: 10px;
25 |
26 | .badge {
27 | font-size: 20px;
28 | height: 50px;
29 | line-height: 50px;
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/client/src/components/switch-language/index.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useId } from 'react';
3 |
4 | import useLocalStorage from '../../hooks/useLocalStorage';
5 |
6 | export default function SwitchLanguage({ languages }) {
7 | const id = useId();
8 | const defaultLocale = navigator?.language?.slice(0, 2)?.toLowerCase() ?? 'fr';
9 | const [locale, setLocale] = useLocalStorage('works-magnet-locale', defaultLocale);
10 | const currentLanguage = languages.find(({ key }) => key === locale);
11 |
12 | return (
13 |
14 |
15 |
22 | {currentLanguage.shortName}
23 |
24 | {' '}
25 | -
26 | {' '}
27 | {currentLanguage.fullName}
28 |
29 |
30 |
31 |
32 | {
33 | languages.map(({ key, shortName, fullName }) => (
34 |
35 | setLocale(key)}
40 | type="button"
41 | >
42 | {shortName}
43 | {' '}
44 | -
45 | {' '}
46 | {fullName}
47 |
48 |
49 | ))
50 | }
51 |
52 |
53 |
54 |
55 | );
56 | }
57 |
58 | SwitchLanguage.defaultProps = {
59 | languages: [],
60 | };
61 |
62 | SwitchLanguage.propTypes = {
63 | languages: PropTypes.array,
64 | };
65 |
--------------------------------------------------------------------------------
/client/src/components/tag-input/index.scss:
--------------------------------------------------------------------------------
1 | .scratched {
2 | text-decoration: line-through !important;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/components/tiles/datasets.jsx:
--------------------------------------------------------------------------------
1 | import { FormattedMessage } from 'react-intl';
2 |
3 | export default function DatasetsTile() {
4 | return (
5 |
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/tiles/mentions.jsx:
--------------------------------------------------------------------------------
1 | import { FormattedMessage } from 'react-intl';
2 |
3 | export default function MentionsTile() {
4 | return (
5 |
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/tiles/openalex.jsx:
--------------------------------------------------------------------------------
1 | import { FormattedMessage } from 'react-intl';
2 |
3 | export default function OpenalexTile() {
4 | return (
5 |
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/tiles/publications.jsx:
--------------------------------------------------------------------------------
1 | import { FormattedMessage } from 'react-intl';
2 |
3 | export default function PublicationsTile() {
4 | return (
5 |
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/toast/index.jsx:
--------------------------------------------------------------------------------
1 | import { Text, Row, Container } from '@dataesr/dsfr-plus';
2 | import PropTypes from 'prop-types';
3 | import { useCallback, useEffect } from 'react';
4 |
5 | import usePausableTimer from '../../hooks/usePausableTimer';
6 |
7 | import './index.scss';
8 |
9 | function Toast({
10 | autoDismissAfter,
11 | description,
12 | id,
13 | remove,
14 | title,
15 | toastType,
16 | }) {
17 | const removeSelf = useCallback(() => {
18 | document.getElementById(id).style.setProperty('animation', 'toast-unmount 1000ms');
19 | setTimeout(() => {
20 | remove(id);
21 | }, 1000);
22 | }, [id, remove]);
23 | const { pause, resume } = usePausableTimer(removeSelf, autoDismissAfter);
24 |
25 | useEffect(() => {
26 | const progressBar = document.getElementById(`progress-${id}`);
27 | if (progressBar) {
28 | progressBar.style.setProperty('animation-duration', `${autoDismissAfter}ms`);
29 | }
30 | }, [id, autoDismissAfter]);
31 |
32 | const icon = {
33 | info: 'ri-information-fill',
34 | warning: 'ri-error-warning-fill',
35 | success: 'ri-checkbox-circle-fill',
36 | error: 'ri-close-circle-fill',
37 | };
38 |
39 | return (
40 |
47 |
48 |
49 | {
50 | (autoDismissAfter !== 0)
51 | ? (
)
52 | : null
53 | }
54 |
55 |
remove(id)}
59 | type="button"
60 | >
61 |
62 |
63 |
64 |
65 | {title && {title} }
66 |
67 | {description && (
68 |
69 |
70 |
71 | )}
72 |
73 |
74 | );
75 | }
76 |
77 | Toast.propTypes = {
78 | autoDismissAfter: PropTypes.number,
79 | description: PropTypes.string,
80 | id: PropTypes.number.isRequired,
81 | remove: PropTypes.func,
82 | title: PropTypes.string,
83 | toastType: PropTypes.oneOf(['info', 'success', 'error', 'warning']),
84 | };
85 |
86 | Toast.defaultProps = {
87 | autoDismissAfter: 10000,
88 | description: null,
89 | remove: () => { },
90 | title: null,
91 | toastType: 'success',
92 | };
93 |
94 | export default Toast;
95 |
--------------------------------------------------------------------------------
/client/src/components/toast/index.scss:
--------------------------------------------------------------------------------
1 | @keyframes toast-mount {
2 | from {
3 | transform: translateY(100%);
4 | }
5 |
6 | to {
7 | transform: translateY(0);
8 | }
9 | }
10 |
11 | @keyframes toast-unmount {
12 | from {
13 | transform: translateX(0);
14 | }
15 |
16 | to {
17 | transform: translateX(300%);
18 | }
19 | }
20 |
21 | @keyframes toast-unfill {
22 | from {
23 | height: 100%;
24 | }
25 |
26 | to {
27 | height: 0%
28 | }
29 | }
30 |
31 | #toast-container {
32 | bottom: 4px;
33 | box-sizing: border-box;
34 | padding: 4px;
35 | position: fixed;
36 | z-index: 10000;
37 |
38 | > * {
39 | margin-top: 8px;
40 | }
41 |
42 | @media only screen and (min-width: 992px) {
43 | bottom: 30px;
44 | padding: 24px;
45 | right: 30px;
46 | }
47 |
48 | .toast-info {
49 | --active: var(--background-contrast-info-active) !important;
50 | --hover: var(--background-contrast-info-hover) !important;
51 | background-color: var(--background-contrast-info) !important;
52 | border-radius: 0.25rem;
53 | color: var(--text-default-info) !important;
54 |
55 | >.toast-colored-box {
56 | background-color: var(--info-main-525);
57 | }
58 | }
59 |
60 | .toast-warning {
61 | --active: var(--background-contrast-warning-active) !important;
62 | --hover: var(--background-contrast-warning-hover) !important;
63 | background-color: var(--background-contrast-warning) !important;
64 | color: var(--text-default-warning) !important;
65 |
66 | >.toast-colored-box {
67 | background-color: var(--warning-main-525);
68 | }
69 | }
70 |
71 | .toast-success {
72 | --active: var(--background-contrast-success-active) !important;
73 | --hover: var(--background-contrast-success-hover) !important;
74 | background-color: var(--background-contrast-success) !important;
75 | color: var(--text-default-success) !important;
76 |
77 | >.toast-colored-box {
78 | background-color: var(--success-main-525);
79 | }
80 | }
81 |
82 | .toast-error {
83 | --active: var(--background-contrast-error-active) !important;
84 | --hover: var(--background-contrast-error-hover) !important;
85 | background-color: var(--background-contrast-error) !important;
86 | color: var(--text-default-error) !important;
87 |
88 | >.toast-colored-box {
89 | background-color: var(--error-main-525);
90 | }
91 | }
92 |
93 | .toast {
94 | align-items: flex-start;
95 | animation: toast-mount .3s;
96 | border-radius: 0.25rem;
97 | box-shadow: 0 6px 18px 0 rgba(0, 0, 18, 0.16);
98 | display: flex;
99 | flex-wrap: nowrap;
100 | min-height: 40px;
101 | position: relative;
102 | right: 0;
103 | top: 0;
104 | width: calc(100vw - 8px);
105 |
106 | @media only screen and (min-width: 992px) {
107 | width: 400px;
108 | }
109 |
110 | &:hover {
111 | .toast-progress-bar {
112 | animation-play-state: paused;
113 | }
114 | }
115 |
116 | .toast-btn-close {
117 | border-radius: 100%;
118 | height: 32px;
119 | position: absolute;
120 | right: 2px;
121 | top: 2px;
122 | width: 32px;
123 |
124 | span {
125 | margin-right: 0px;
126 | padding-bottom: 6px;
127 | }
128 |
129 | &:hover {
130 | background-color: var(--active-tint);
131 | }
132 | }
133 |
134 | .toast-progress-bar {
135 | animation-name: toast-unfill;
136 | animation-play-state: running;
137 | animation-timing-function: linear;
138 | background: rgba(0, 0, 18, 0.16);
139 | bottom: 0;
140 | left: 0;
141 | position: absolute;
142 | width: 40px;
143 | }
144 |
145 | .toast-colored-box {
146 | align-items: center;
147 | border-bottom-left-radius: 0.25rem;
148 | border-top-left-radius: 0.25rem;
149 | bottom: 0;
150 | display: flex;
151 | justify-content: center;
152 | position: absolute;
153 | top: 0;
154 | width: 40px;
155 |
156 | span {
157 | color: var(--background-alt-grey);
158 | font-size: 1.8em;
159 | margin-left: 9px;
160 | z-index: 100;
161 | }
162 | }
163 |
164 | .toast-content {
165 | display: flex;
166 | flex-direction: column;
167 | justify-content: space-between;
168 | margin: 0 40px;
169 | padding: 8px;
170 | width: 100%;
171 | }
172 | }
173 | }
--------------------------------------------------------------------------------
/client/src/config.js:
--------------------------------------------------------------------------------
1 | const datasources = [{ key: 'fosm', label: 'French OSM' }, { key: 'openalex', label: 'OpenAlex' }];
2 |
3 | const status = {
4 | validated: {
5 | badgeType: 'success',
6 | buttonIcon: 'ri-checkbox-circle-line',
7 | buttonLabel: 'Validate',
8 | iconColor: '#8dc572',
9 | id: 'validated',
10 | label: 'Validated',
11 | },
12 | excluded: {
13 | badgeType: 'error',
14 | buttonIcon: 'ri-indeterminate-circle-line',
15 | buttonLabel: 'Exclude',
16 | iconColor: '#be6464',
17 | id: 'excluded',
18 | label: 'Excluded',
19 | },
20 | tobedecided: {
21 | badgeType: 'info',
22 | buttonIcon: 'ri-reply-fill',
23 | buttonLabel: 'Reset status',
24 | iconColor: '#337ab7',
25 | id: 'tobedecided',
26 | label: 'To be decided',
27 | },
28 | };
29 |
30 | const correction = {
31 | corrected: {
32 | badgeType: 'error',
33 | label: 'MODIFIED',
34 | },
35 | reset: {
36 | badgeType: 'info',
37 | label: 'CANCEL',
38 | },
39 | notcorrected: {
40 | badgeType: 'info',
41 | label: 'Already OK',
42 | },
43 | };
44 |
45 | export { correction, datasources, status };
46 |
--------------------------------------------------------------------------------
/client/src/hooks/useCopyToClipboard.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export default function useCopyToClipboard(resetTimeout = 1500) {
4 | const [copyStatus, setCopyStatus] = useState(null);
5 |
6 | async function copyToClipboard(text) {
7 | if ('clipboard' in navigator) return navigator.clipboard.writeText(text);
8 | return document.execCommand('copy', true, text);
9 | }
10 |
11 | const copy = (text) => {
12 | copyToClipboard(text)
13 | .then(() => { setCopyStatus('Copié'); })
14 | .catch(() => { setCopyStatus('Erreur'); });
15 | };
16 | useEffect(() => {
17 | let timeoutId;
18 | if (copyStatus) {
19 | timeoutId = setTimeout(() => setCopyStatus(null), resetTimeout);
20 | }
21 | return () => clearTimeout(timeoutId);
22 | }, [copyStatus, resetTimeout]);
23 |
24 | return [copyStatus, copy];
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/hooks/useLocalStorage.jsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 |
3 | export default function useLocalStorage(key, defaultValue) {
4 | const readValue = () => {
5 | const storedString = localStorage.getItem(key);
6 |
7 | if (storedString === null && defaultValue !== null) {
8 | localStorage.setItem(key, JSON.stringify(defaultValue));
9 | return readValue();
10 | }
11 | return JSON.parse(storedString);
12 | };
13 | const [storedValue, setStoredValue] = useState(() => readValue());
14 |
15 | const setValue = useCallback((newValue) => {
16 | localStorage.setItem(key, JSON.stringify(newValue));
17 | window.dispatchEvent(new StorageEvent('works-magnet-locale', { key }));
18 | }, [key]);
19 |
20 | useEffect(() => {
21 | const handleStorage = (e) => {
22 | if (e.key === key) {
23 | setStoredValue(readValue());
24 | }
25 | };
26 | window.addEventListener('works-magnet-locale', handleStorage);
27 | return () => {
28 | window.removeEventListener('works-magnet-locale', handleStorage);
29 | };
30 | });
31 |
32 | return [storedValue, setValue];
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/hooks/usePausableTimer.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 |
3 | export default function usePausableTimer(callback, delay) {
4 | const [paused, setPaused] = useState(false);
5 | const start = useRef(new Date());
6 | const remaining = useRef(delay);
7 | const timeoutId = useRef(null);
8 |
9 | const clear = () => clearTimeout(timeoutId.current);
10 |
11 | const pause = () => {
12 | setPaused(true);
13 | remaining.current -= (new Date() - start.current);
14 | clear();
15 | };
16 |
17 | const resume = () => {
18 | start.current = new Date();
19 | setPaused(false);
20 | };
21 |
22 | // Set up the interval.
23 | useEffect(() => {
24 | if (!paused && delay) {
25 | timeoutId.current = setTimeout(callback, remaining.current);
26 | }
27 | return clear;
28 | }, [remaining, paused, delay, callback]);
29 | return { paused, pause, resume };
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/hooks/useToast.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import {
3 | createContext,
4 | useState,
5 | useCallback,
6 | useContext,
7 | useMemo,
8 | } from 'react';
9 | import { createPortal } from 'react-dom';
10 |
11 | import Toast from '../components/toast';
12 |
13 | function ToastContainer({ children }) {
14 | return {children}
;
15 | }
16 |
17 | ToastContainer.propTypes = {
18 | children: PropTypes.node.isRequired,
19 | };
20 |
21 | const ToastContext = createContext();
22 |
23 | // Provider
24 | // ==============================
25 | let toastCount = 0;
26 |
27 | export function ToastContextProvider({ children }) {
28 | const [toasts, setToasts] = useState([]);
29 |
30 | const remove = useCallback((id) => {
31 | setToasts((toastList) => toastList.filter((t) => t.id !== id));
32 | }, []);
33 |
34 | const toast = useCallback((toastObject) => {
35 | toastCount += 1;
36 | setToasts((toastList) => [...toastList, { ...toastObject, id: toastCount }]);
37 | return toastCount;
38 | }, []);
39 | const value = useMemo(() => ({
40 | toast, remove, toasts,
41 | }), [toast, remove, toasts]);
42 | const content = (
43 |
44 | {
45 | toasts.map((toastOptions) => (
46 |
51 | ))
52 | }
53 |
54 | );
55 | return (
56 |
57 | {children}
58 | {createPortal(content, document.body)}
59 |
60 | );
61 | }
62 |
63 | ToastContextProvider.propTypes = {
64 | children: PropTypes.node.isRequired,
65 | };
66 |
67 | // Hook
68 | // ==============================
69 | const useToast = () => useContext(ToastContext);
70 |
71 | /* @component */
72 | export default useToast;
73 |
--------------------------------------------------------------------------------
/client/src/i18n/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "corpus-title": "Build up a corpus of publications or datasets",
3 | "feedback-description-1": "AI techniques are used on a large scale to analyse bibliographic corpora. In particular, OpenAlex uses them to construct affiliation links in publications (used for the Leiden ranking) and Grobid-Softcite to detect mentions of data and software in the full text. Helping to correct these data improves the reliability and representativeness of these analyses.",
4 | "feedback-description-2": "OpenAlex associates affiliation signatures with ROR identifiers. Errors and omissions can occur, distorting the results obtained from OpenAlex data, such as the (open) Leiden ranking. Similarly, tools are used to detect mentions of software and data sets in the full text of publications. Here too, these detections may contain errors. The Works-magnet allows you to explore all the mentions detected in the French corpus and to report any errors. These reports will be added to the learning database, helping to train new, more reliable detection models. In this way, helping to correct these data improves the reliability and representativeness of these analyses.",
5 | "feedback-title": "Improve the automatic detections made by AI",
6 | "datasets-tile-title": "🗃 Find the datasets affiliated to your institution",
7 | "datasets-tile-detail-1": "🔎 Explore the most frequent raw affiliation strings retrieved in the French Open Science Monitor data and in OpenAlex for your query (datasets only).",
8 | "datasets-tile-detail-2": "🤔 Validate ✅ or exclude ❌ each of them, whether it actually corresponds to your institution or not.",
9 | "datasets-tile-detail-3": "💾 Save (export to a file) those decisions and the datasets corpus you just built.",
10 | "mentions-tile-title": "📑 Find the mentions of your software or datasets",
11 | "mentions-tile-detail-1": "🔎 Explore the mentions of software and datasets found in the French publications full-text.",
12 | "mentions-tile-detail-2": "✏️ Correct the errors (type or characterizations)",
13 | "mentions-tile-detail-3": "✉️ Submit corrections",
14 | "openalex-tile-title": "✏️ Improve ROR matching in OpenAlex - Provide your feedback!",
15 | "openalex-tile-detail-1": "🔎 Analyze the most frequent raw affiliation strings retrieved in OpenAlex for your query.",
16 | "openalex-tile-detail-2": "🤖 Check the ROR automatically computed by OpenAlex. Sometimes, they can be inaccurate or missing.",
17 | "openalex-tile-detail-3": "✏️ Correct the errors (inaccurate or missing RORs) and send feedback to OpenAlex.",
18 | "publications-tile-title": "📑 Find the publications affiliated to your institution",
19 | "publications-tile-detail-1": "🔎 Explore the most frequent raw affiliation strings retrieved in the French Open Science Monitor data and in OpenAlex for your query.",
20 | "publications-tile-detail-2": "🤔 Validate ✅ or exclude ❌ each of them, whether it actually corresponds to your institution or not.",
21 | "publications-tile-detail-3": "💾 Save (export to a file) those decisions and the publications corpus you just built.",
22 | "tagline": "Retrieve and promote the scholarly works of your institution.",
23 | "about-title": "Works-magnet : a tool for metadata specialists",
24 | "about-1": "The Works-magnet offers a number of curation functions. The tool is therefore aimed at specialists who can judge the quality of metadata. The tool interrogates various massive databases (OpenAlex, BSO, Datacite) and formats the information to facilitate exploration and correction where necessary. These massive databases each use large-scale automatic processing tools. An expert curatorial eye is needed to continue to improve the quality of the metadata. As far as possible, the corrections resulting from these curations are fed back upstream to ensure that the same errors are not repeated. The aim is to build up a set of high-quality metadata that can be re-used by anyone who wishes to do so. The aim is to propose a new curation paradigm: curation is not carried out in a two-way relationship between a producer and a consumer of data. Instead, a group of data users propose corrections that are visible to all, and that can benefit everyone. As well as correcting the cases reported, in some cases the data collected can also be re-used as learning data for algorithms, so that the models used in the future are more effective.",
25 | "about-2": "One of the major features of the Works-magnet is the improvement of affiliation metadata. This is of vital importance in the bibliometric analyses carried out by institutions. They are also used to establish the Leiden ranking (open). Improving the quality of this data will enable us to benefit collectively from more reliable monitoring and analysis tools in the future.",
26 | "about-3": "The Works-magnet aims to fill a gap: at a time when ‘open research information’ (to use the terms of the Barcelona Declaration) is progressing rapidly, there is a clear need to draw on the knowledge of experts to raise the level of quality of metadata. It is highly likely that in the months and years to come, more structured networks for collecting information will be set up, for example by OpenAlex or following the work of the COMET working group initiated by the California Digital Library. It may take a long time to set up this kind of structure, and the Works-magnet is positioning itself to meet this need as of now.",
27 | "read-more": "Read more",
28 | "show-less": "Show less"
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/i18n/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "corpus-title": "Constituer un corpus de publications ou de jeux de données",
3 | "feedback-description-1": "Des techniques d'IA sont utilisées à grande échelle pour analyser des corpus bibliographiques. En particulier, OpenAlex les utilise pour construire des liens d'affiliations dans les publications (utilisés pour le classement de Leiden) ou encore Grobid-Softcite pour détecter les mentions de données et logiciel dans le texte intégral. Contribuer à corriger ces données améliore la fiabilité et la représentativité de ces analyses.",
4 | "feedback-description-2": "OpenAlex associe ainsi les signatures d'affiliation à des identifiants ROR. Des erreurs et oublis peuvent advenir, faussant les résultats obtenus à partir des données OpenAlex comme le classement (ouvert) de Leiden. De même, des outils de détection des mentions de logiciels et de jeux de données sont utilisées sur le texte intégral des publications. Là aussi, ces détections peuvent contenir des erreurs. Le Works-magnet permet d'explorer toutes les mentions détectées sur le corpus français et de signaler les erreurs. Ces signalements seront ajoutés à la base d'apprentissage, aidant ainsi à entraîner de nouveaux modèles de détection plus fiables. Ainsi, contribuer à corriger ces données améliore la fiabilité et la représentativité de ces analyses.",
5 | "feedback-title": "Améliorer les détections automatiques faites par IA",
6 | "datasets-tile-title" : "🗃 Les jeux de données de mon institution",
7 | "datasets-tile-detail-1" : "🔎 Explorer les signatures d'affiliation brutes les plus fréquentes récupérées dans les données françaises de l'Open Science Monitor et dans OpenAlex pour votre requête (jeux de données uniquement)",
8 | "datasets-tile-detail-2" : "🤔 Valider ✅ ou exclure ❌ chacun d'entre eux, qu'il corresponde effectivement à votre institution ou non.",
9 | "datasets-tile-detail-3" : "💾 Sauvegarder (exporter vers un fichier) ces décisions et le corpus de datasets que vous venez de construire.",
10 | "mentions-tile-title" : "📑 Les mentions de logiciels ou de données de la recherche",
11 | "mentions-tile-detail-1" : "🔎 Explorer les mentions de logiciels et jeux de données trouvés dans le texte intégral",
12 | "mentions-tile-detail-2" : "✏️ Corriger les erreurs (type ou caractérisations)",
13 | "mentions-tile-detail-3" : "✉️ Soumettre les corrections",
14 | "openalex-tile-title" : "✏️ Améliorer les affiliations ROR dans OpenAlex",
15 | "openalex-tile-detail-1" : "🔎 Analyser les chaînes d'affiliation brutes les plus fréquentes récupérées dans OpenAlex pour votre recherche.",
16 | "openalex-tile-detail-2" : "🤖 Vérifier les ROR calculés automatiquement par OpenAlex. Parfois, ils peuvent être inexacts ou manquants.",
17 | "openalex-tile-detail-3" : "✏️ Corrigez les erreurs (ROR inexacts ou manquants) et envoyez un retour d'information à OpenAlex.",
18 | "publications-tile-title" : "📑 Les publications de mon institution",
19 | "publications-tile-detail-1" : "🔎 Explorez les chaînes d'affiliation brutes les plus fréquentes récupérées dans les données françaises de l'Open Science Monitor et dans OpenAlex pour votre requête.",
20 | "publications-tile-detail-2" : "🤔 Valider ✅ ou exclure ❌ chacune d'entre elles, qu'elle corresponde effectivement à votre institution ou non",
21 | "publications-tile-detail-3" : "💾 Enregistrer (exporter vers un fichier) ces décisions et le corpus de publications que vous venez de construire.",
22 | "tagline" : "Retrouver et valoriser les travaux de votre institution",
23 | "about-title": "Works-magnet : un outil à destination des spécialistes des métadonnées",
24 | "about-1": "Le Works-magnet propose plusieurs fonctionnalités de curation. L'outil s'adresse donc à des spécialistes, à même de juger de la qualité des métadonnées. L'outil interroge différentes bases massives (OpenAlex, BSO, Datacite) et formatte l'information pour en faciliter l'exploration et la correction lorsque nécessaire. Ces bases massives utilisent chacune des outils de traitements automatiques à grande échelle. Un oeil expert de curateur est nécessaire pour continuer d'améliorer la qualité des métadonnées. Autant que possible, les corrections issues de ces curations sont reversées en amont pour que les mêmes erreurs ne soient pas reproduites. Le but est de constituer un ensemble de métadonnées de qualité, qui puisse être ré-utiliser par qui le souhaite. Il s'agit de proposer un nouveau paradigme de curation : celles-ci ne sont pas effectuées dans une relation bi-latérale entre un producteur et un consommateur de données. Au contraire, un ensemble d'utilisateurs des données propose des corrections, visibles de tous, et qui pourront bénéficier à tous. En plus de corriger les cas remontés, ces données collectées pourront aussi dans certains cas être ré-utilisées en tant que données d'apprentissage pour des algorithmes de sorte à ce que les modèles utilisés dans le futur soient plus efficaces.",
25 | "about-2": "Une des fonctionnalités majeures du Works-magnet porte sur l'amélioration des métadonnées d'affiliation. Celles-ci sont d'une importance capitale dans les analyses bibliométriques menées par les institutions. Elles sont aussi utilisées pour établir le classement de Leiden (ouvert). Améliorer leur qualité permettra donc de bénéficier, à l'avenir, et collectivement, d'outil de suivi et d'analyse plus fiables.",
26 | "about-3": "Le Works-magnet vise à combler un manque : alors que l' \"information de recherche ouverte\" (pour reprendre les termes de la Déclaration de Barcelone) progresse rapidement, la nécessité de s'appuyer sur la connaissance d'experts pour hausser le niveau de qualité des métadonnées est manifeste. Il est fort probable que dans les mois et années à venir, des réseaux plus structurés pour collecter l'information seront mis en place, par exemple par OpenAlex ou suite au travaux du groupe de travail COMET initié par la California Digital Library. Le temps de structuration pour ce genre de mise en place peut être long, et le Works-magnet se positionne pour répondre à ce besoin dès aujourd'hui.",
27 | "read-more": "En savoir plus",
28 | "show-less": "Afficher moins"
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/layout/header.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge,
3 | Container, Row, Col,
4 | Title,
5 | } from '@dataesr/dsfr-plus';
6 | import PropTypes from 'prop-types';
7 | import { FormattedMessage } from 'react-intl';
8 |
9 | import Ribbon from '../components/ribbon';
10 | import SwitchLanguage from '../components/switch-language';
11 |
12 | const {
13 | VITE_APP_NAME,
14 | VITE_HEADER_TAG_COLOR,
15 | VITE_HEADER_TAG,
16 | VITE_MINISTER_NAME,
17 | } = import.meta.env;
18 |
19 | // TODO : all, Link from dsfr-plus
20 | export default function Header({ isExpanded }) {
21 | const languages = [
22 | { shortName: 'FR', fullName: 'Français', key: 'fr' },
23 | { shortName: 'EN', fullName: 'English', key: 'en' },
24 | ];
25 |
26 | return isExpanded ? (
27 |
71 | ) : (
72 |
73 |
74 |
75 |
76 | ',
80 | ' ',
81 | )}`}
82 | >
83 |
84 | {VITE_APP_NAME}
85 | {VITE_HEADER_TAG && (
86 |
91 | {VITE_HEADER_TAG}
92 |
93 | )}
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | );
103 | }
104 |
105 | Header.defaultProps = {
106 | isExpanded: false,
107 | };
108 | Header.propTypes = {
109 | isExpanded: PropTypes.bool,
110 | };
111 |
--------------------------------------------------------------------------------
/client/src/layout/index.jsx:
--------------------------------------------------------------------------------
1 | import { Container } from '@dataesr/dsfr-plus';
2 | import { Outlet } from 'react-router-dom';
3 |
4 | import Footer from './footer';
5 |
6 | export default function Layout() {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 | >
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/main.jsx:
--------------------------------------------------------------------------------
1 | import { DSFRConfig } from '@dataesr/dsfr-plus';
2 | import {
3 | createInstance,
4 | MatomoProvider,
5 | useMatomo,
6 | } from '@m4tt72/matomo-tracker-react';
7 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
8 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
9 | import React, { useEffect } from 'react';
10 | import ReactDOM from 'react-dom/client';
11 | import { IntlProvider } from 'react-intl';
12 | import { BrowserRouter, Link, useLocation } from 'react-router-dom';
13 |
14 | import { ToastContextProvider } from './hooks/useToast';
15 | import useLocalStorage from './hooks/useLocalStorage';
16 | import messagesEn from './i18n/en.json';
17 | import messagesFr from './i18n/fr.json';
18 | import Router from './router';
19 |
20 | import 'react-tooltip/dist/react-tooltip.css';
21 | import './styles/index.scss';
22 |
23 | const { MODE, VITE_APP_MATOMO_BASE_URL, VITE_APP_MATOMO_SITE_ID } = import.meta
24 | .env;
25 |
26 | const queryClient = new QueryClient();
27 |
28 | const matomo = MODE === 'development'
29 | ? undefined
30 | : createInstance({
31 | urlBase: VITE_APP_MATOMO_BASE_URL,
32 | siteId: VITE_APP_MATOMO_SITE_ID,
33 | configurations: {
34 | disableCookies: true,
35 | },
36 | });
37 |
38 | function PageTracker() {
39 | const { pathname } = useLocation();
40 | const { trackPageView } = useMatomo();
41 |
42 | useEffect(() => {
43 | trackPageView({ documentTitle: pathname });
44 | }, [pathname, trackPageView]);
45 |
46 | return null;
47 | }
48 |
49 | // eslint-disable-next-line react/prop-types
50 | function RouterLink({ href, replace, target, ...props }) {
51 | // eslint-disable-next-line jsx-a11y/anchor-has-content
52 | if (target === '_blank') return ;
53 | return ;
54 | }
55 |
56 | function App() {
57 | const messages = {
58 | en: messagesEn,
59 | fr: messagesFr,
60 | };
61 | const [locale] = useLocalStorage('works-magnet-locale', 'en');
62 |
63 | useEffect(() => {
64 | document.documentElement.setAttribute('data-fr-scheme', 'light');
65 | document.documentElement.setAttribute('data-fr-theme', 'light');
66 | }, []);
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
87 | ReactDOM.createRoot(document.getElementById('root')).render(
88 |
89 |
90 | ,
91 | );
92 |
--------------------------------------------------------------------------------
/client/src/pages/about.jsx:
--------------------------------------------------------------------------------
1 | import { Container, Row, Title } from '@dataesr/dsfr-plus';
2 | import { FormattedMessage } from 'react-intl';
3 |
4 | import Header from '../layout/header';
5 |
6 | export default function Home() {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | >
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/client/src/pages/actions/actionsAffiliations.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Col,
4 | Container,
5 | Modal, ModalContent,
6 | Row,
7 | Title,
8 | } from '@dataesr/dsfr-plus';
9 | import PropTypes from 'prop-types';
10 | import { useState } from 'react';
11 | import { useSearchParams } from 'react-router-dom';
12 |
13 | import File from '../../components/file';
14 | import { status } from '../../config';
15 | import useToast from '../../hooks/useToast';
16 | import { export2json, importJson } from '../../utils/files';
17 |
18 | export default function ActionsAffiliations({
19 | allAffiliations,
20 | tagAffiliations,
21 | }) {
22 | const { toast } = useToast();
23 | const [searchParams] = useSearchParams();
24 | const decidedAffiliations = allAffiliations?.filter((affiliation) => affiliation.status !== status.tobedecided.id)?.map((affiliation) => {
25 | delete affiliation.id;
26 | return affiliation;
27 | }) || [];
28 | const [isModalOpen, setIsModalOpen] = useState(false);
29 |
30 | const onExport = () => {
31 | export2json({ data: decidedAffiliations, label: 'affiliations', searchParams });
32 | toast({
33 | description: `${decidedAffiliations.length} affiliations have been saved`,
34 | id: 'saveAffiliations',
35 | title: 'Affiliations saved',
36 | toastType: 'info',
37 | });
38 | };
39 | const onImport = (e) => {
40 | importJson(e, tagAffiliations);
41 | toast({
42 | description: `${decidedAffiliations.length} affiliations are now flagged`,
43 | id: 'importAffiliations',
44 | title: 'Affiliations imported',
45 | toastType: 'success',
46 | });
47 | };
48 |
49 | return (
50 |
51 |
setIsModalOpen(!isModalOpen)}>
52 |
53 |
54 |
55 | Save the decided affiliations
56 |
57 |
58 |
59 |
60 |
61 | Save the decided affiliations in order to restore it later
62 |
63 | onExport()}
67 | >
68 |
69 | Save decided affiliations
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | Restore affiliations
78 |
79 |
80 |
81 |
82 | onImport(e)}
86 | />
87 |
88 |
89 |
90 |
91 |
92 |
93 |
setIsModalOpen(!isModalOpen)}
96 | size="sm"
97 | >
98 | Save or restore
99 |
100 |
101 | );
102 | }
103 |
104 | ActionsAffiliations.propTypes = {
105 | allAffiliations: PropTypes.arrayOf(PropTypes.shape({
106 | name: PropTypes.string.isRequired,
107 | nameHtml: PropTypes.string.isRequired,
108 | key: PropTypes.string.isRequired,
109 | status: PropTypes.string.isRequired,
110 | works: PropTypes.arrayOf(PropTypes.string).isRequired,
111 | worksNumber: PropTypes.number.isRequired,
112 | })).isRequired,
113 | tagAffiliations: PropTypes.func.isRequired,
114 | };
115 |
--------------------------------------------------------------------------------
/client/src/pages/actions/actionsDatasets.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useSearchParams } from 'react-router-dom';
3 |
4 | import ButtonDropdown from '../../components/button-dropdown';
5 |
6 | export default function ActionsDatasets({
7 | allDatasets,
8 | }) {
9 | const [searchParams] = useSearchParams();
10 |
11 | return (
12 |
13 | );
14 | }
15 |
16 | ActionsDatasets.propTypes = {
17 | allDatasets: PropTypes.arrayOf(PropTypes.shape({
18 | affiliations: PropTypes.arrayOf(PropTypes.object),
19 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
20 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
21 | id: PropTypes.string.isRequired,
22 | status: PropTypes.string.isRequired,
23 | type: PropTypes.string.isRequired,
24 | })).isRequired,
25 | };
26 |
--------------------------------------------------------------------------------
/client/src/pages/actions/actionsPublications.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useSearchParams } from 'react-router-dom';
3 |
4 | import ButtonDropdown from '../../components/button-dropdown';
5 |
6 | export default function ActionsPublications({ allPublications, className }) {
7 | const [searchParams] = useSearchParams();
8 |
9 | return (
10 |
11 | );
12 | }
13 |
14 | ActionsPublications.defaultProps = {
15 | className: '',
16 | };
17 |
18 | ActionsPublications.propTypes = {
19 | allPublications: PropTypes.arrayOf(PropTypes.shape({
20 | affiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
21 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
22 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
23 | id: PropTypes.string.isRequired,
24 | status: PropTypes.string.isRequired,
25 | type: PropTypes.string.isRequired,
26 | })).isRequired,
27 | className: PropTypes.string,
28 | };
29 |
--------------------------------------------------------------------------------
/client/src/pages/affiliationsTab.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Col,
4 | Row,
5 | } from '@dataesr/dsfr-plus';
6 | import PropTypes from 'prop-types';
7 | import { useEffect, useState } from 'react';
8 |
9 | import Gauge from '../components/gauge';
10 | import { status } from '../config';
11 | import { removeDiacritics } from '../utils/strings';
12 | import { renderButtons } from '../utils/works';
13 | import AffiliationsView from './affiliationsView';
14 |
15 | export default function AffiliationsTab({ affiliations, selectedAffiliations, setSelectedAffiliations, tagAffiliations }) {
16 | const [filteredAffiliations, setFilteredAffiliations] = useState([]);
17 | const [filteredAffiliationName, setFilteredAffiliationName] = useState('');
18 | const [fixedMenu, setFixedMenu] = useState(false);
19 | const [timer, setTimer] = useState();
20 |
21 | useEffect(() => { // TODO : look for a better way to do this
22 | setFilteredAffiliations(affiliations);
23 | }, [affiliations]);
24 |
25 | useEffect(() => {
26 | if (timer) clearTimeout(timer);
27 | const timerTmp = setTimeout(() => {
28 | const filteredAffiliationsTmp = affiliations.filter((affiliation) => {
29 | const regex = new RegExp(removeDiacritics(filteredAffiliationName));
30 | return regex.test(affiliation.key.replace('[ source: ', '').replace(' ]', ''));
31 | });
32 | setFilteredAffiliations(filteredAffiliationsTmp);
33 | }, 500);
34 | setTimer(timerTmp);
35 | // The timer should not be tracked
36 | // eslint-disable-next-line react-hooks/exhaustive-deps
37 | }, [affiliations, filteredAffiliationName]);
38 |
39 | return (
40 | <>
41 |
42 |
43 |
44 | {selectedAffiliations.length}
45 |
46 | {` selected affiliation${selectedAffiliations.length === 1 ? '' : 's'}`}
47 |
48 | {renderButtons(selectedAffiliations, tagAffiliations)}
49 | {/*
50 |
51 | setFixedMenu(!fixedMenu)}
53 | size="sm"
54 | variant="tertiary"
55 | >
56 | {fixedMenu ? : }
57 |
58 |
59 | */}
60 |
61 |
62 |
63 | ({
65 | ...st,
66 | value: affiliations.filter((affiliation) => affiliation.status === st.id).length,
67 | }))}
68 | />
69 |
70 |
71 |
72 |
73 |
80 |
81 |
82 | >
83 | );
84 | }
85 |
86 | AffiliationsTab.propTypes = {
87 | affiliations: PropTypes.arrayOf(PropTypes.shape({
88 | name: PropTypes.string.isRequired,
89 | nameHtml: PropTypes.string.isRequired,
90 | status: PropTypes.string.isRequired,
91 | works: PropTypes.arrayOf(PropTypes.string).isRequired,
92 | worksNumber: PropTypes.number.isRequired,
93 | })).isRequired,
94 | selectedAffiliations: PropTypes.arrayOf(PropTypes.shape({
95 | name: PropTypes.string.isRequired,
96 | nameHtml: PropTypes.string.isRequired,
97 | status: PropTypes.string.isRequired,
98 | works: PropTypes.arrayOf(PropTypes.string).isRequired,
99 | worksNumber: PropTypes.number.isRequired,
100 | })).isRequired,
101 | setSelectedAffiliations: PropTypes.func.isRequired,
102 | tagAffiliations: PropTypes.func.isRequired,
103 | };
104 |
--------------------------------------------------------------------------------
/client/src/pages/affiliationsView.jsx:
--------------------------------------------------------------------------------
1 | import { Col, Row, Toggle } from '@dataesr/dsfr-plus';
2 | import { FilterMatchMode } from 'primereact/api';
3 | import { Column } from 'primereact/column';
4 | import { DataTable } from 'primereact/datatable';
5 | import PropTypes from 'prop-types';
6 | import { useState } from 'react';
7 |
8 | import {
9 | nameTemplate,
10 | rorTemplate,
11 | statusRowFilterTemplate,
12 | statusTemplate,
13 | worksExampleTemplate,
14 | } from '../utils/templates';
15 |
16 | export default function AffiliationsView({
17 | allAffiliations,
18 | filteredAffiliationName,
19 | selectedAffiliations,
20 | setFilteredAffiliationName,
21 | setSelectedAffiliations,
22 | }) {
23 | const [filters] = useState({ status: { matchMode: FilterMatchMode.IN, value: null } });
24 | const [selectionPageOnly, setSelectionPageOnly] = useState(true);
25 |
26 | const paginatorLeft = () => (
27 |
28 |
29 | Select all
30 |
31 |
32 | setSelectionPageOnly(e.target.checked)}
37 | />
38 |
39 |
40 |
41 | Search in affiliations name
42 | setFilteredAffiliationName(e.target.value)}
45 | style={{
46 | border: '1px solid #ced4da',
47 | borderRadius: '4px',
48 | padding: '0.375rem 0.75rem',
49 | width: '100%',
50 | }}
51 | value={filteredAffiliationName}
52 | />
53 |
54 |
55 | );
56 |
57 | return (
58 | setSelectedAffiliations(e.value)}
65 | paginator
66 | paginatorLeft={paginatorLeft}
67 | paginatorPosition="top bottom"
68 | paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
69 | rows={100}
70 | rowsPerPageOptions={[50, 100, 200, 500]}
71 | scrollable
72 | selection={selectedAffiliations}
73 | selectionPageOnly={selectionPageOnly}
74 | size="small"
75 | sortField="worksNumber"
76 | sortOrder={-1}
77 | stripedRows
78 | style={{ fontSize: '14px', lineHeight: '13px' }}
79 | tableStyle={{ minWidth: '50rem' }}
80 | value={allAffiliations}
81 | >
82 |
83 |
93 |
99 |
107 |
113 |
119 |
120 | );
121 | }
122 |
123 | AffiliationsView.propTypes = {
124 | allAffiliations: PropTypes.arrayOf(
125 | PropTypes.shape({
126 | name: PropTypes.string.isRequired,
127 | nameHtml: PropTypes.string.isRequired,
128 | status: PropTypes.string.isRequired,
129 | works: PropTypes.arrayOf(PropTypes.string).isRequired,
130 | worksNumber: PropTypes.number.isRequired,
131 | }),
132 | ).isRequired,
133 | filteredAffiliationName: PropTypes.string.isRequired,
134 | selectedAffiliations: PropTypes.arrayOf(
135 | PropTypes.shape({
136 | name: PropTypes.string.isRequired,
137 | nameHtml: PropTypes.string.isRequired,
138 | status: PropTypes.string.isRequired,
139 | works: PropTypes.arrayOf(PropTypes.string).isRequired,
140 | worksNumber: PropTypes.number.isRequired,
141 | }),
142 | ).isRequired,
143 | setFilteredAffiliationName: PropTypes.func.isRequired,
144 | setSelectedAffiliations: PropTypes.func.isRequired,
145 | };
146 |
--------------------------------------------------------------------------------
/client/src/pages/datasets/datasetsView.jsx:
--------------------------------------------------------------------------------
1 | import { Col, Row, Toggle } from '@dataesr/dsfr-plus';
2 | import { FilterMatchMode } from 'primereact/api';
3 | import { Column } from 'primereact/column';
4 | import { DataTable } from 'primereact/datatable';
5 | import PropTypes from 'prop-types';
6 | import { useState } from 'react';
7 |
8 | import {
9 | affiliationsTemplate,
10 | allIdsTemplate,
11 | frAuthorsTemplate,
12 | linkedDOITemplate,
13 | linkedORCIDTemplate,
14 | statusRowFilterTemplate,
15 | statusTemplate,
16 | } from '../../utils/templates';
17 |
18 | export default function DatasetsView({
19 | filteredAffiliationName,
20 | selectedWorks,
21 | setFilteredAffiliationName,
22 | setSelectedWorks,
23 | works,
24 | }) {
25 | const [filters] = useState({
26 | publisher: { value: null, matchMode: FilterMatchMode.IN },
27 | status: { value: null, matchMode: FilterMatchMode.IN },
28 | type: { value: null, matchMode: FilterMatchMode.IN },
29 | });
30 | const [selectionPageOnly, setSelectionPageOnly] = useState(true);
31 |
32 | const paginatorLeft = () => (
33 |
34 |
35 | Select all
36 |
37 |
38 | setSelectionPageOnly(e.target.checked)}
43 | />
44 |
45 |
46 |
47 | Search in any field
48 | setFilteredAffiliationName(e.target.value)}
51 | style={{
52 | border: '1px solid #ced4da',
53 | borderRadius: '4px',
54 | padding: '0.375rem 0.75rem',
55 | width: '100%',
56 | }}
57 | value={filteredAffiliationName}
58 | />
59 |
60 |
61 | );
62 |
63 | return (
64 | setSelectedWorks(e.value)}
71 | paginator
72 | paginatorLeft={paginatorLeft}
73 | paginatorPosition="top bottom"
74 | paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
75 | rows={100}
76 | rowsPerPageOptions={[50, 100, 200, 500]}
77 | scrollable
78 | selection={selectedWorks}
79 | selectionPageOnly={selectionPageOnly}
80 | size="small"
81 | sortOrder={1}
82 | stripedRows
83 | style={{ fontSize: '14px', lineHeight: '13px' }}
84 | value={works}
85 | >
86 |
87 |
96 |
102 |
109 |
115 |
121 |
129 |
137 |
145 |
153 |
154 | );
155 | }
156 |
157 | DatasetsView.propTypes = {
158 | selectedWorks: PropTypes.arrayOf(
159 | PropTypes.shape({
160 | affiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
161 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
162 | authors: PropTypes.arrayOf(PropTypes.string).isRequired,
163 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
164 | id: PropTypes.string.isRequired,
165 | publisher: PropTypes.string.isRequired,
166 | status: PropTypes.string.isRequired,
167 | type: PropTypes.string.isRequired,
168 | }),
169 | ).isRequired,
170 | setSelectedWorks: PropTypes.func.isRequired,
171 | works: PropTypes.arrayOf(
172 | PropTypes.shape({
173 | affiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
174 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
175 | authors: PropTypes.arrayOf(PropTypes.string).isRequired,
176 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
177 | id: PropTypes.string.isRequired,
178 | publisher: PropTypes.string.isRequired,
179 | status: PropTypes.string.isRequired,
180 | type: PropTypes.string.isRequired,
181 | }),
182 | ).isRequired,
183 | filteredAffiliationName: PropTypes.string.isRequired,
184 | setFilteredAffiliationName: PropTypes.func.isRequired,
185 | };
186 |
--------------------------------------------------------------------------------
/client/src/pages/datasets/datasetsYearlyDistribution.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Col,
3 | Row,
4 | } from '@dataesr/dsfr-plus';
5 | import Highcharts from 'highcharts';
6 | import HCExportingData from 'highcharts/modules/export-data';
7 | import HCExporting from 'highcharts/modules/exporting';
8 | import HighchartsReact from 'highcharts-react-official';
9 | import PropTypes from 'prop-types';
10 | import { useSearchParams } from 'react-router-dom';
11 |
12 | import { range } from '../../utils/works';
13 |
14 | HCExporting(Highcharts);
15 | HCExportingData(Highcharts);
16 |
17 | const { VITE_APP_DEFAULT_YEAR } = import.meta.env;
18 |
19 | export default function DatasetsYearlyDistribution({ allDatasets, field, subfield = undefined }) {
20 | const [searchParams] = useSearchParams();
21 |
22 | const categories = range(searchParams.get('startYear', VITE_APP_DEFAULT_YEAR), searchParams.get('endYear', VITE_APP_DEFAULT_YEAR));
23 | const allFields = {};
24 | allDatasets.filter((dataset) => dataset.status === 'validated').forEach((dataset) => {
25 | const publicationYear = dataset?.year;
26 | let currentValues = dataset[field];
27 | if (!Array.isArray(currentValues)) {
28 | currentValues = [currentValues];
29 | }
30 | currentValues.forEach((e) => {
31 | // eslint-disable-next-line no-nested-ternary
32 | const currentField = e ? (subfield ? e[subfield] : e) : `no ${field}`;
33 | if (!Object.keys(allFields).includes(currentField)) {
34 | allFields[currentField] = new Array(categories.length).fill(0);
35 | }
36 | const i = categories.indexOf(Number(publicationYear));
37 | allFields[currentField][i] += 1;
38 | });
39 | });
40 | // const colors = ['#ea5545', '#f46a9b', '#ef9b20', '#edbf33', '#ede15b', '#bdcf32', '#87bc45', '#27aeef', '#544fc5', '#b33dc6', '#d3d3d3']
41 | const colors = ['#5DA5DA', '#FAA43A', '#60BD68', '#F17CB0', '#B2912F', '#B276B2', '#DECF3F', '#F15854', '#265DAB', '#DF5C24', '#059748', '#E5126F', '#9D722A', '#7B3A96', '#C7B42E', '#CB2027'];
42 | const NB_TOP = 13;
43 | const series = Object.keys(allFields)
44 | .map((name) => ({
45 | name,
46 | data: allFields[name],
47 | total: allFields[name].reduce((accumulator, currentValue) => accumulator + currentValue, 0),
48 | }))
49 | .sort((a, b) => b.total - a.total)
50 | .map((item, index) => ({
51 | ...item,
52 | color: colors[index],
53 | }));
54 | const topSeries = series.slice(0, NB_TOP);
55 | const tailData = new Array(categories.length).fill(0);
56 | series.slice(NB_TOP).forEach((serie) => {
57 | serie.data.forEach((d, index) => {
58 | tailData[index] += d;
59 | });
60 | });
61 | topSeries.push({
62 | name: 'Others',
63 | data: tailData,
64 | color: colors[NB_TOP],
65 | });
66 | const options = {
67 | chart: {
68 | type: 'column',
69 | height: '600 px',
70 | },
71 | credits: { text: 'French Open Science Monitor - CC-BY MESR', enabled: true },
72 | legend: { reversed: true },
73 | plotOptions: {
74 | column: {
75 | stacking: 'normal',
76 | dataLabels: {
77 | enabled: true,
78 | },
79 | },
80 | },
81 | series: topSeries.reverse(),
82 | title: {
83 | text: `Yearly distribution of the number of datasets by ${field}`,
84 | },
85 | xAxis: {
86 | categories,
87 | title: {
88 | text: 'Publication year',
89 | },
90 | },
91 | yAxis: {
92 | title: {
93 | text: (subfield ? `Number of datasets x ${subfield}` : 'Number of datasets'),
94 | },
95 | stackLabels: {
96 | enabled: true,
97 | },
98 | },
99 | };
100 |
101 | return (
102 |
103 |
104 |
108 |
109 |
110 | );
111 | }
112 |
113 | DatasetsYearlyDistribution.propTypes = {
114 | allDatasets: PropTypes.arrayOf(PropTypes.shape({
115 | affiliations: PropTypes.arrayOf(PropTypes.object),
116 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
117 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
118 | id: PropTypes.string.isRequired,
119 | status: PropTypes.string.isRequired,
120 | type: PropTypes.string.isRequired,
121 | })).isRequired,
122 | field: PropTypes.string.isRequired,
123 | subfield: PropTypes.string,
124 | };
125 |
126 | DatasetsYearlyDistribution.defaultProps = {
127 | subfield: undefined,
128 | };
129 |
--------------------------------------------------------------------------------
/client/src/pages/datasets/results.jsx:
--------------------------------------------------------------------------------
1 | import { Col, Container, Row, Spinner } from '@dataesr/dsfr-plus';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { useEffect, useState } from 'react';
4 | import { useSearchParams } from 'react-router-dom';
5 |
6 | import { status } from '../../config';
7 | import useToast from '../../hooks/useToast';
8 | import Header from '../../layout/header';
9 | import { getRorData, isRor } from '../../utils/ror';
10 | import { normalize } from '../../utils/strings';
11 | import { getWorks } from '../../utils/works';
12 | import Datasets from '../views/datasets';
13 |
14 | import 'primereact/resources/primereact.min.css';
15 | import 'primereact/resources/themes/lara-light-indigo/theme.css';
16 |
17 | const { VITE_APP_DEFAULT_YEAR, VITE_APP_TAG_LIMIT } = import.meta.env;
18 |
19 | export default function Affiliations() {
20 | const [searchParams] = useSearchParams();
21 |
22 | const [affiliations, setAffiliations] = useState([]);
23 | const [options, setOptions] = useState({});
24 | const [selectedAffiliations, setSelectedAffiliations] = useState([]);
25 | const [selectedDatasets, setSelectedDatasets] = useState([]);
26 | const { toast } = useToast();
27 |
28 | const { data, error, isFetched, isFetching, refetch } = useQuery({
29 | queryKey: ['datasets', JSON.stringify(options)],
30 | queryFn: () => getWorks({ options, toast, type: 'datasets' }),
31 | enabled: false,
32 | });
33 |
34 | const tagDatasets = (datasets, action) => {
35 | const datasetsIds = datasets.map((dataset) => dataset.id);
36 | data?.datasets?.results
37 | ?.filter((dataset) => datasetsIds.includes(dataset.id))
38 | .map((dataset) => (dataset.status = action));
39 | setSelectedDatasets([]);
40 | };
41 |
42 | const tagAffiliations = (_affiliations, action) => {
43 | if (_affiliations.length > 0) {
44 | // If no affiliationIds, it means it comes from a restored file so d o a match on affiliation key
45 | if (_affiliations?.[0]?.id === undefined) {
46 | const affiliationKeys = _affiliations.map((affiliation) => affiliation?.key).filter((key) => !!key);
47 | // eslint-disable-next-line no-param-reassign
48 | _affiliations = affiliations.filter((aff) => affiliationKeys.includes(aff.key));
49 | }
50 | if (action !== status.excluded.id) {
51 | const worksIds = _affiliations
52 | .map((affiliation) => affiliation.works)
53 | .flat();
54 | data?.datasets?.results
55 | ?.filter((dataset) => worksIds.includes(dataset.id))
56 | .map((dataset) => (dataset.status = action));
57 | }
58 | // Filter non existing ids
59 | const affiliationIds = _affiliations.map((affiliation) => affiliation.id).filter((id) => !!id);
60 | setAffiliations(
61 | affiliations
62 | .map((affiliation) => {
63 | if (affiliationIds.includes(affiliation.id)) {
64 | affiliation.status = action;
65 | }
66 | return affiliation;
67 | }),
68 | );
69 | }
70 | setSelectedAffiliations([]);
71 | };
72 |
73 | useEffect(() => {
74 | const getData = async () => {
75 | const queryParams = {
76 | endYear: searchParams.get('endYear') ?? VITE_APP_DEFAULT_YEAR,
77 | startYear: searchParams.get('startYear') ?? VITE_APP_DEFAULT_YEAR,
78 | };
79 | queryParams.affiliationStrings = [];
80 | queryParams.deletedAffiliations = [];
81 | queryParams.rors = [];
82 | queryParams.rorExclusions = [];
83 | searchParams.getAll('affiliations').forEach((item) => {
84 | if (isRor(item)) {
85 | queryParams.rors.push(item);
86 | } else {
87 | queryParams.affiliationStrings.push(normalize(item));
88 | }
89 | });
90 | const rorData = await Promise.all(queryParams.rors.map((ror) => getRorData(ror, searchParams.get('getRorChildren') === '1')));
91 | const rorChildren = rorData
92 | .flat()
93 | .map((ror) => ror.rorId);
94 | queryParams.rors = queryParams.rors.concat(rorChildren);
95 | const rorNames = rorData
96 | .flat()
97 | .map((ror) => ror.names)
98 | .flat();
99 | queryParams.affiliationStrings = queryParams.affiliationStrings.concat(rorNames);
100 | if (
101 | queryParams.affiliationStrings.length === 0
102 | && queryParams.rors.length === 0
103 | ) {
104 | console.error(
105 | `You must provide at least one affiliation longer than ${VITE_APP_TAG_LIMIT} letters.`,
106 | );
107 | return;
108 | }
109 | setOptions(queryParams);
110 | };
111 |
112 | getData();
113 | }, [searchParams]);
114 |
115 | useEffect(() => {
116 | if (Object.keys(options).length > 0) refetch();
117 | }, [options, refetch]);
118 |
119 | useEffect(() => {
120 | setAffiliations(data?.affiliations ?? []);
121 | }, [data]);
122 |
123 | return (
124 | <>
125 |
126 |
127 | {isFetching && (
128 |
129 |
130 |
131 |
132 |
133 | )}
134 |
135 | {error && (
136 |
137 |
138 |
139 | Error while fetching data, please try again later or contact the
140 | team (see footer).
141 |
142 |
143 |
144 | )}
145 |
146 | {!isFetching && isFetched && (
147 |
159 | )}
160 |
161 | >
162 | );
163 | }
164 |
--------------------------------------------------------------------------------
/client/src/pages/home.jsx:
--------------------------------------------------------------------------------
1 | import { Col, Container, Row, Title } from '@dataesr/dsfr-plus';
2 | import { useState } from 'react';
3 | import { FormattedMessage } from 'react-intl';
4 |
5 | import DatasetsTile from '../components/tiles/datasets';
6 | import MentionsTile from '../components/tiles/mentions';
7 | import OpenalexTile from '../components/tiles/openalex';
8 | import PublicationsTile from '../components/tiles/publications';
9 | import Header from '../layout/header';
10 |
11 | export default function Home() {
12 | const [isExpanded, setIsExpanded] = useState(false);
13 |
14 | const toggleExpand = () => setIsExpanded(!isExpanded);
15 |
16 | return (
17 | <>
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {isExpanded && (
28 |
29 |
30 |
31 | )}
32 |
33 | {isExpanded ? : }
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | >
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/client/src/pages/mentions/components/custom-toggle/index.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import './styles.scss';
3 |
4 | export default function CustomToggle({ checked, disabled, label, onChange }) {
5 | return (
6 |
7 |
8 | {label}
9 |
10 |
11 | {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
12 |
13 |
21 |
22 |
23 |
24 | );
25 | }
26 |
27 | CustomToggle.propTypes = {
28 | checked: PropTypes.bool.isRequired,
29 | disabled: PropTypes.bool,
30 | label: PropTypes.string.isRequired,
31 | onChange: PropTypes.func.isRequired,
32 | };
33 |
34 | CustomToggle.defaultProps = {
35 | disabled: false,
36 | };
37 |
--------------------------------------------------------------------------------
/client/src/pages/mentions/components/custom-toggle/styles.scss:
--------------------------------------------------------------------------------
1 |
2 | /* The switch - the box around the slider */
3 | .switch {
4 | position: relative;
5 | display: inline-block;
6 | width: 50px;
7 | height: 24px;
8 | }
9 |
10 | /* Hide default HTML checkbox */
11 | .switch input {
12 | opacity: 0;
13 | width: 0;
14 | height: 0;
15 | }
16 |
17 |
18 | /* The slider */
19 | .disabled-text{
20 | color: #555 !important;
21 | font-style: italic;
22 | }
23 | .disabled {
24 | background-color: #ccc !important;
25 | cursor: not-allowed;
26 | }
27 | .slider {
28 | position: absolute;
29 | cursor: pointer;
30 | top: 0;
31 | left: 0;
32 | right: 0;
33 | bottom: 0;
34 | background-color: #cab16c;
35 | -webkit-transition: .4s;
36 | transition: .4s;
37 | }
38 |
39 | .slider:before {
40 | position: absolute;
41 | content: "";
42 | height: 16px;
43 | width: 16px;
44 | left: 4px;
45 | bottom: 4px;
46 | background-color: white;
47 | -webkit-transition: .4s;
48 | transition: .4s;
49 | }
50 |
51 | input:checked + .slider {
52 | background-color: #639F6A;
53 | }
54 |
55 | input:focus + .slider {
56 | box-shadow: 0 0 1px #639F6A;
57 | }
58 |
59 | input:checked + .slider:before {
60 | -webkit-transform: translateX(26px);
61 | -ms-transform: translateX(26px);
62 | transform: translateX(26px);
63 | }
64 |
65 | /* Rounded sliders */
66 | .slider.round {
67 | border-radius: 24px;
68 | }
69 |
70 | .slider.round:before {
71 | border-radius: 50%;
72 | }
--------------------------------------------------------------------------------
/client/src/pages/mentions/components/search-utils.jsx:
--------------------------------------------------------------------------------
1 | import { TextInput } from '@dataesr/dsfr-plus';
2 | import PropTypes from 'prop-types';
3 |
4 | import CustomToggle from './custom-toggle';
5 |
6 | function FieldFromKey({ term, setAdvancedSearchTermValues }) {
7 | switch (term.key) {
8 | case 'affiliation':
9 | return setAdvancedSearchTermValues(term, e.target.value)} type="text" value={term.value} message="" />;
10 | case 'all':
11 | return setAdvancedSearchTermValues(term, e.target.value)} type="text" value={term.value} message="" />;
12 | case 'author':
13 | return setAdvancedSearchTermValues(term, e.target.value)} type="text" value={term.value} message="" />;
14 | case 'created':
15 | return (
16 |
17 |
Charaterization is created
18 |
19 | setAdvancedSearchTermValues(term, e.target.checked)} />
20 |
21 |
22 | );
23 | case 'doi':
24 | return setAdvancedSearchTermValues(term, e.target.value)} type="text" value={term.value} message="" />;
25 | case 'mention':
26 | return setAdvancedSearchTermValues(term, e.target.value)} type="text" value={term.value} message="" />;
27 | case 'mentionType':
28 | return (
29 | setAdvancedSearchTermValues(term, e.target.value)} value={term.value} className="fr-select fr-mt-4w">
30 | Dataset
31 | Software
32 |
33 | );
34 | case 'shared':
35 | return (
36 |
37 |
Charaterization is shared
38 |
39 | setAdvancedSearchTermValues(term, e.target.checked)} />
40 |
41 |
42 | );
43 | case 'used':
44 | return (
45 |
46 |
Charaterization is used
47 |
48 | setAdvancedSearchTermValues(term, e.target.checked)} />
49 |
50 |
51 | );
52 | default:
53 | }
54 | }
55 |
56 | FieldFromKey.propTypes = {
57 | term: PropTypes.shape({
58 | key: PropTypes.string.isRequired,
59 | value: PropTypes.string.isRequired,
60 | }).isRequired,
61 | setAdvancedSearchTermValues: PropTypes.func.isRequired,
62 | };
63 |
64 | function FieldSelector({ term, index, setAdvancedSearchTermKeys }) {
65 | return (
66 | (setAdvancedSearchTermKeys(
69 | term,
70 | index,
71 | e.target.value,
72 | (e.target.value === 'used' || e.target.value === 'created' || e.target.value === 'shared') ? false : (e.target.value === 'mentionType' ? 'dataset' : term.value),
73 | ))}
74 | value={term.key}
75 | >
76 | All fields
77 | DOI
78 | Type of mention
79 | Mention
80 | Affiliation
81 | Author
82 | Charaterization - Used
83 | Charaterization - Created
84 | Charaterization - Shared
85 |
86 | );
87 | }
88 |
89 | FieldSelector.propTypes = {
90 | term: PropTypes.shape({
91 | key: PropTypes.string.isRequired,
92 | }).isRequired,
93 | index: PropTypes.number.isRequired,
94 | setAdvancedSearchTermKeys: PropTypes.func.isRequired,
95 | };
96 |
97 | export { FieldFromKey, FieldSelector };
98 |
--------------------------------------------------------------------------------
/client/src/pages/mentions/styles.scss:
--------------------------------------------------------------------------------
1 | .wm-mentions{
2 | .actions {
3 | border: #777 1px solid;
4 | background-color: #eee;
5 | // border-top-right-radius: 15px;
6 | // border-top-left-radius: 15px;
7 | padding: 10px;
8 |
9 | position: sticky;
10 | top: 0;
11 | z-index: 1000;
12 |
13 | .corrections-box{
14 | background-color: #A0B4A2;
15 | display: flex;
16 | align-items: center;
17 | justify-content: space-between;
18 | padding: 10px;
19 | select{
20 | width: 200px;
21 | }
22 | }
23 | }
24 |
25 | .results {
26 | border: #777 1px solid;
27 | background-color: #eee;
28 | font-size: 14px;
29 | .mentions-list {
30 | padding: 10px;
31 | thead{
32 | position: sticky;
33 | top: 114px;
34 | z-index: 1000;
35 | background-color: #eee;
36 | }
37 | th{
38 | text-align: left;
39 | vertical-align: bottom;
40 | }
41 |
42 | .mention {
43 | border-bottom: 1px solid #ddd;
44 | &:hover {
45 | background-color: #ddd;
46 | cursor: pointer;
47 | }
48 | &.selected {
49 | background-color: #ddd;
50 | }
51 | }
52 | .isCorrected {
53 | border-left: 6px solid #447049;
54 | }
55 | }
56 | }
57 | }
58 | .es-query {
59 | .title {
60 | font-size: 16px;
61 | font-weight: bold;
62 | margin-bottom: 5px;
63 | }
64 | .content {
65 | border: #777 1px solid;
66 | margin-top: 5px;
67 | padding: 10px;
68 | font-family: 'Courier New', Courier, monospace;
69 | }
70 | }
--------------------------------------------------------------------------------
/client/src/pages/openalex-affiliations/components/export-errors-button.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useSearchParams } from 'react-router-dom';
3 |
4 | import ButtonDropdown from '../../../components/button-dropdown';
5 |
6 | export default function ExportErrorsButton({
7 | className,
8 | corrections,
9 | }) {
10 | const [searchParams] = useSearchParams();
11 |
12 | return (
13 |
20 | );
21 | }
22 |
23 | ExportErrorsButton.propTypes = {
24 | className: PropTypes.string,
25 | corrections: PropTypes.arrayOf(PropTypes.shape({
26 | addList: PropTypes.arrayOf(PropTypes.object).isRequired,
27 | hasCorrection: PropTypes.bool.isRequired,
28 | name: PropTypes.string.isRequired,
29 | nameHtml: PropTypes.string.isRequired,
30 | removeList: PropTypes.arrayOf(PropTypes.string).isRequired,
31 | rors: PropTypes.arrayOf(PropTypes.shape({
32 | rorCountry: PropTypes.string.isRequired,
33 | rorId: PropTypes.string.isRequired,
34 | rorName: PropTypes.string.isRequired,
35 | })).isRequired,
36 | rorsNumber: PropTypes.number.isRequired,
37 | rorsToCorrect: PropTypes.arrayOf(PropTypes.shape({
38 | rorCountry: PropTypes.string.isRequired,
39 | rorId: PropTypes.string.isRequired,
40 | rorName: PropTypes.string.isRequired,
41 | })).isRequired,
42 | selected: PropTypes.bool.isRequired,
43 | source: PropTypes.string.isRequired,
44 | status: PropTypes.string.isRequired,
45 | works: PropTypes.arrayOf(PropTypes.string).isRequired,
46 | worksExample: PropTypes.arrayOf(PropTypes.shape({
47 | id_type: PropTypes.string.isRequired,
48 | id_value: PropTypes.string.isRequired,
49 | })).isRequired,
50 | worksNumber: PropTypes.number.isRequired,
51 | worksOpenAlex: PropTypes.arrayOf(PropTypes.string).isRequired,
52 | })).isRequired,
53 | };
54 |
55 | ExportErrorsButton.defaultProps = {
56 | className: '',
57 | };
58 |
--------------------------------------------------------------------------------
/client/src/pages/openalex-affiliations/components/modal-info.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Modal, ModalContent, ModalTitle,
4 | Text,
5 | } from '@dataesr/dsfr-plus';
6 | import { useState } from 'react';
7 |
8 | export default function ModalInfo() {
9 | const [isModalOpen, setIsModalOpen] = useState(true);
10 | const [step, setStep] = useState(1);
11 |
12 | return (
13 | <>
14 | setIsModalOpen(true)}
19 | style={{ borderRadius: '25px' }}
20 | />
21 |
22 | setIsModalOpen(false)} size="xl">
23 |
24 | Improve ROR matching in OpenAlex - Provide your feedback in 3 steps!
25 |
26 |
27 |
28 | {
29 | step === 1 && (
30 | <>
31 |
32 | Control information
33 | Step 1 of 3
34 |
35 |
36 |
37 |
38 | 🔎 The array below summarizes the most frequent raw affiliation
39 | strings retrieved in OpenAlex for your query.
40 |
41 | 🤖 The second column indicates the ROR automatically computed by
42 | OpenAlex. Sometimes, they can be inaccurate or missing.
43 |
44 |
45 | >
46 | )
47 | }
48 | {
49 | step === 2 && (
50 | <>
51 |
52 | Corrects incorrect ROR
53 | Step 2 of 3
54 |
55 |
56 |
57 |
58 | ✏️ Click the third column to edit and input the right RORs for
59 | this raw affiliation string. Use a ';' to input multiple RORs.
60 |
61 |
62 | >
63 | )
64 | }
65 | {
66 | step === 3 && (
67 | <>
68 |
69 | Send correction to OpenAlex
70 | Step 3 of 3
71 |
72 |
73 |
74 |
75 | 🗣 Once finished, you can use the Export button on the right to
76 | send this feedback to OpenAlex.
77 |
78 |
79 | >
80 | )
81 | }
82 |
83 |
84 | setStep(step - 1)}>Previous step
85 | setStep(step + 1)}>Next step
86 |
87 |
88 |
89 | >
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/client/src/pages/openalex-affiliations/components/ror-badge.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@dataesr/dsfr-plus';
2 | import PropTypes from 'prop-types';
3 |
4 | import useCopyToClipboard from '../../../hooks/useCopyToClipboard';
5 |
6 | export default function RorBadge({ className, isRemoved, removeRor, ror, rorColor, setFilteredAffiliationName }) {
7 | const [_, copy] = useCopyToClipboard();
8 |
9 | return (
10 |
11 |
12 |
13 |
19 |
20 | {isRemoved ? (
21 |
22 |
23 | {`https://ror.org/${ror.rorId}`}
24 |
25 |
26 | ) : (
27 |
28 | {`https://ror.org/${ror.rorId}`}
29 |
30 | )}
31 |
setFilteredAffiliationName(ror.rorId)}
35 | title="Filter on this ROR"
36 | type="button"
37 | />
38 | copy(ror.rorId)}
42 | title="Copier"
43 | type="button"
44 | />
45 | {
46 | isRemoved ? (
47 | removeRor()}
51 | title="Undo remove"
52 | type="button"
53 | />
54 | ) : (
55 | removeRor()}
59 | title="Remove this ROR"
60 | type="button"
61 | />
62 | )
63 | }
64 |
65 | );
66 | }
67 |
68 | RorBadge.defaultProps = {
69 | className: '',
70 | isRemoved: false,
71 | };
72 |
73 | RorBadge.propTypes = {
74 | className: PropTypes.string,
75 | isRemoved: PropTypes.bool,
76 | removeRor: PropTypes.func.isRequired,
77 | ror: PropTypes.shape({
78 | rorId: PropTypes.string.isRequired,
79 | }).isRequired,
80 | rorColor: PropTypes.string.isRequired,
81 | setFilteredAffiliationName: PropTypes.func.isRequired,
82 | };
83 |
--------------------------------------------------------------------------------
/client/src/pages/openalex-affiliations/components/ror-name.jsx:
--------------------------------------------------------------------------------
1 | import { Badge } from '@dataesr/dsfr-plus';
2 | import PropTypes from 'prop-types';
3 | import getFlagEmoji from '../../../utils/flags';
4 |
5 | export default function RorName({ isRemoved, ror }) {
6 | return (
7 |
8 |
9 |
19 | {isRemoved ? (
20 |
21 | {ror.rorName}
22 |
23 | ) : (
24 | ror.rorName
25 | )}
26 |
27 | {getFlagEmoji(ror?.rorCountry)}
28 | {isRemoved && (
29 |
33 | Removed
34 |
35 | )}
36 |
37 | );
38 | }
39 |
40 | RorName.defaultProps = {
41 | isRemoved: false,
42 | };
43 |
44 | RorName.propTypes = {
45 | isRemoved: PropTypes.bool,
46 | ror: PropTypes.shape({
47 | rorCountry: PropTypes.string,
48 | rorName: PropTypes.string.isRequired,
49 | }).isRequired,
50 | };
51 |
--------------------------------------------------------------------------------
/client/src/pages/openalex-affiliations/components/works-list.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Link, Text } from '@dataesr/dsfr-plus';
2 | import PropTypes from 'prop-types';
3 | import { useState } from 'react';
4 |
5 | const SEE_MORE_AFTER = 5;
6 |
7 | export default function WorksList({ works }) {
8 | const [showMore, setShowMore] = useState(false);
9 |
10 | const displayedWorks = showMore ? works : works.slice(0, SEE_MORE_AFTER);
11 |
12 | const getUrlFromWork = (work) => {
13 | if (work.startsWith('10.')) return `https://doi.org/${work}`;
14 | if (work.startsWith('hal-')) return `https://hal.science/${work}`;
15 | if (work.startsWith('W')) return `https://openalex.org/works/${work}`;
16 | return work;
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 | Works:
24 |
25 | {displayedWorks.map((work) => {
26 | const workUrl = getUrlFromWork(work);
27 | return { work, workUrl };
28 | }).map(({ work, workUrl }) => (
29 | (workUrl.startsWith('https') ? (
30 |
36 | {work}
37 |
38 | ) : (
39 |
43 | {work}
44 |
45 | ))
46 | ))}
47 | {
48 | works.length > 5 && (
49 | setShowMore(!showMore)} variant="text">
50 | {showMore ? 'show less works' : `show more works (${works.length - SEE_MORE_AFTER})`}
51 |
52 | )
53 | }
54 |
55 |
56 | );
57 | }
58 |
59 | WorksList.propTypes = {
60 | works: PropTypes.array.isRequired,
61 | };
62 |
--------------------------------------------------------------------------------
/client/src/pages/publications/publicationsTab.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import PropTypes from 'prop-types';
3 | import {
4 | Button,
5 | Col, Row,
6 | } from '@dataesr/dsfr-plus';
7 |
8 | import PublicationsView from './publicationsView';
9 | import Gauge from '../../components/gauge';
10 | import { datasources, status } from '../../config';
11 | import { normalizeName, renderButtons } from '../../utils/works';
12 |
13 | export default function PublicationsTab({ publications, publishers, selectedPublications, setSelectedPublications, tagPublications, types, years }) {
14 | const [filteredAffiliationName, setFilteredAffiliationName] = useState('');
15 | const [filteredDatasources] = useState(datasources.map((datasource) => datasource.key));
16 | const [filteredPublications, setFilteredPublications] = useState([]);
17 | const [filteredPublishers, setFilteredPublishers] = useState([]);
18 | const [filteredStatus] = useState([status.tobedecided.id, status.validated.id, status.excluded.id]);
19 | const [filteredTypes, setFilteredTypes] = useState([]);
20 | const [timer, setTimer] = useState();
21 | const [fixedMenu, setFixedMenu] = useState(false);
22 |
23 | useEffect(() => {
24 | setFilteredPublications(publications);
25 | setFilteredPublishers(Object.keys(publishers));
26 | setFilteredTypes(Object.keys(types));
27 | }, [publications, publishers, types, years]);
28 |
29 | useEffect(() => {
30 | if (timer) {
31 | clearTimeout(timer);
32 | }
33 | const timerTmp = setTimeout(() => {
34 | const filteredPublicationsTmp = publications.filter((publication) => normalizeName(publication.allInfos).includes(normalizeName(filteredAffiliationName))
35 | && filteredDatasources.filter((filteredDatasource) => publication.datasource.includes(filteredDatasource)).length
36 | && filteredPublishers.includes(publication.publisher)
37 | && filteredStatus.includes(publication.status)
38 | && filteredTypes.includes(publication.type));
39 | setFilteredPublications(filteredPublicationsTmp);
40 | }, 500);
41 | setTimer(timerTmp);
42 | // The timer should not be tracked
43 | // eslint-disable-next-line react-hooks/exhaustive-deps
44 | }, [publications, filteredAffiliationName, filteredDatasources, filteredPublishers, filteredStatus, filteredTypes]);
45 |
46 | return (
47 | <>
48 |
49 |
50 |
51 | {selectedPublications.length}
52 |
53 | {` selected publication${selectedPublications.length === 1 ? '' : 's'}`}
54 |
55 | {renderButtons(selectedPublications, tagPublications)}
56 | {/*
57 |
58 | setFixedMenu(!fixedMenu)}
60 | size="sm"
61 | variant="tertiary"
62 | >
63 | {fixedMenu ? : }
64 |
65 |
66 | */}
67 |
68 |
69 |
70 | ({
72 | ...st,
73 | value: publications.filter((publication) => publication.status === st.id).length,
74 | }))}
75 | />
76 |
77 |
78 |
86 |
87 |
88 | >
89 | );
90 | }
91 |
92 | PublicationsTab.propTypes = {
93 | publications: PropTypes.arrayOf(PropTypes.shape({
94 | affiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
95 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
96 | authors: PropTypes.arrayOf(PropTypes.string).isRequired,
97 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
98 | id: PropTypes.string.isRequired,
99 | publisher: PropTypes.string.isRequired,
100 | status: PropTypes.string.isRequired,
101 | type: PropTypes.string.isRequired,
102 | })).isRequired,
103 | publishers: PropTypes.object.isRequired,
104 | selectedPublications: PropTypes.arrayOf(PropTypes.shape({
105 | affiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
106 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
107 | authors: PropTypes.arrayOf(PropTypes.string).isRequired,
108 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
109 | id: PropTypes.string.isRequired,
110 | publisher: PropTypes.string.isRequired,
111 | status: PropTypes.string.isRequired,
112 | type: PropTypes.string.isRequired,
113 | })).isRequired,
114 | setSelectedPublications: PropTypes.func.isRequired,
115 | tagPublications: PropTypes.func.isRequired,
116 | types: PropTypes.object.isRequired,
117 | years: PropTypes.object.isRequired,
118 | };
119 |
--------------------------------------------------------------------------------
/client/src/pages/publications/publicationsView.jsx:
--------------------------------------------------------------------------------
1 | import { Col, Row, Toggle } from '@dataesr/dsfr-plus';
2 | import { FilterMatchMode } from 'primereact/api';
3 | import { Column } from 'primereact/column';
4 | import { DataTable } from 'primereact/datatable';
5 | import { MultiSelect } from 'primereact/multiselect';
6 | import PropTypes from 'prop-types';
7 | import { useState } from 'react';
8 |
9 | import {
10 | affiliationsTemplate,
11 | allIdsTemplate,
12 | authorsTemplate,
13 | datasourceTemplate,
14 | statusRowFilterTemplate,
15 | statusTemplate,
16 | } from '../../utils/templates';
17 |
18 | export default function PublicationsView({
19 | filteredAffiliationName,
20 | selectedWorks,
21 | setFilteredAffiliationName,
22 | setSelectedWorks,
23 | works,
24 | years,
25 | }) {
26 | const [filters] = useState({
27 | status: { value: null, matchMode: FilterMatchMode.IN },
28 | years: { value: null, matchMode: FilterMatchMode.EQUALS },
29 | });
30 | const [selectionPageOnly, setSelectionPageOnly] = useState(true);
31 |
32 | const yearRowFilterTemplate = (options) => (
33 | options.filterApplyCallback(e.value)}
37 | optionLabel="name"
38 | options={Object.keys(years).map((year) => ({
39 | name: `${year} (${years[year]})`,
40 | value: year,
41 | }))}
42 | placeholder="Any"
43 | style={{ maxWidth: '9rem', minWidth: '9rem' }}
44 | value={options.value}
45 | />
46 | );
47 |
48 | const paginatorLeft = () => (
49 |
50 |
51 | Select all
52 |
53 |
54 | setSelectionPageOnly(e.target.checked)}
59 | />
60 |
61 |
62 |
63 | Search in any field
64 | setFilteredAffiliationName(e.target.value)}
67 | style={{
68 | border: '1px solid #ced4da',
69 | borderRadius: '4px',
70 | padding: '0.375rem 0.75rem',
71 | width: '100%',
72 | }}
73 | value={filteredAffiliationName}
74 | />
75 |
76 |
77 | );
78 |
79 | return (
80 | setSelectedWorks(e.value)}
88 | paginator
89 | paginatorLeft={paginatorLeft}
90 | paginatorPosition="top bottom"
91 | paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
92 | rows={100}
93 | rowsPerPageOptions={[50, 100, 200, 500]}
94 | scrollable
95 | selection={selectedWorks}
96 | selectionPageOnly={selectionPageOnly}
97 | size="small"
98 | stripedRows
99 | style={{ fontSize: '14px', lineHeight: '13px' }}
100 | value={works}
101 | >
102 |
103 |
113 |
120 |
127 |
128 |
136 |
141 |
147 |
153 |
154 |
155 | );
156 | }
157 |
158 | PublicationsView.propTypes = {
159 | filteredAffiliationName: PropTypes.string.isRequired,
160 | selectedWorks: PropTypes.arrayOf(
161 | PropTypes.shape({
162 | affiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
163 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
164 | authors: PropTypes.arrayOf(PropTypes.string).isRequired,
165 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
166 | id: PropTypes.string.isRequired,
167 | publisher: PropTypes.string.isRequired,
168 | status: PropTypes.string.isRequired,
169 | type: PropTypes.string.isRequired,
170 | }),
171 | ).isRequired,
172 | setFilteredAffiliationName: PropTypes.func.isRequired,
173 | setSelectedWorks: PropTypes.func.isRequired,
174 | works: PropTypes.arrayOf(
175 | PropTypes.shape({
176 | affiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
177 | allIds: PropTypes.arrayOf(PropTypes.object).isRequired,
178 | authors: PropTypes.arrayOf(PropTypes.string).isRequired,
179 | datasource: PropTypes.arrayOf(PropTypes.string).isRequired,
180 | id: PropTypes.string.isRequired,
181 | publisher: PropTypes.string.isRequired,
182 | status: PropTypes.string.isRequired,
183 | type: PropTypes.string.isRequired,
184 | }),
185 | ).isRequired,
186 | years: PropTypes.object.isRequired,
187 | };
188 |
--------------------------------------------------------------------------------
/client/src/pages/publications/results.jsx:
--------------------------------------------------------------------------------
1 | import { Col, Container, Row, Spinner } from '@dataesr/dsfr-plus';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { useEffect, useState } from 'react';
4 | import { useSearchParams } from 'react-router-dom';
5 |
6 | import { status } from '../../config';
7 | import useToast from '../../hooks/useToast';
8 | import Header from '../../layout/header';
9 | import { getRorData, isRor } from '../../utils/ror';
10 | import { normalize } from '../../utils/strings';
11 | import { getWorks } from '../../utils/works';
12 | import Publications from '../views/publications';
13 |
14 | import 'primereact/resources/primereact.min.css';
15 | import 'primereact/resources/themes/lara-light-indigo/theme.css';
16 |
17 | const { VITE_APP_DEFAULT_YEAR, VITE_APP_TAG_LIMIT } = import.meta.env;
18 |
19 | export default function Affiliations() {
20 | const [searchParams] = useSearchParams();
21 |
22 | const [affiliations, setAffiliations] = useState([]);
23 | const [options, setOptions] = useState({});
24 | const [selectedAffiliations, setSelectedAffiliations] = useState([]);
25 | const [selectedPublications, setSelectedPublications] = useState([]);
26 | const { toast } = useToast();
27 |
28 | const { data, error, isFetched, isFetching, refetch } = useQuery({
29 | queryKey: ['publications', JSON.stringify(options)],
30 | queryFn: () => getWorks({ options, toast, type: 'publications' }),
31 | enabled: false,
32 | });
33 |
34 | const tagAffiliations = (_affiliations, action) => {
35 | if (_affiliations.length > 0) {
36 | // If no affiliationIds, it means it comes from a restored file so d o a match on affiliation key
37 | if (_affiliations?.[0]?.id === undefined) {
38 | const affiliationKeys = _affiliations.map((affiliation) => affiliation?.key).filter((key) => !!key);
39 | // eslint-disable-next-line no-param-reassign
40 | _affiliations = affiliations.filter((aff) => affiliationKeys.includes(aff.key));
41 | }
42 | if (action !== status.excluded.id) {
43 | const worksIds = _affiliations
44 | .map((affiliation) => affiliation.works)
45 | .flat();
46 | data?.publications?.results
47 | ?.filter((publication) => worksIds.includes(publication.id))
48 | .map((publication) => (publication.status = action));
49 | }
50 | // Filter non existing ids
51 | const affiliationIds = _affiliations.map((affiliation) => affiliation?.id).filter((id) => !!id);
52 | setAffiliations(
53 | affiliations
54 | .map((affiliation) => {
55 | if (affiliationIds.includes(affiliation.id)) {
56 | affiliation.status = action;
57 | }
58 | return affiliation;
59 | }),
60 | );
61 | }
62 | setSelectedAffiliations([]);
63 | };
64 |
65 | const tagPublications = (publications, action) => {
66 | const publicationsIds = publications.map((publication) => publication.id);
67 | data?.publications?.results
68 | ?.filter((publication) => publicationsIds.includes(publication.id))
69 | .map((publication) => (publication.status = action));
70 | setSelectedPublications([]);
71 | };
72 |
73 | useEffect(() => {
74 | const getData = async () => {
75 | const queryParams = {
76 | endYear: searchParams.get('endYear') ?? VITE_APP_DEFAULT_YEAR,
77 | startYear: searchParams.get('startYear') ?? VITE_APP_DEFAULT_YEAR,
78 | };
79 | queryParams.affiliationStrings = [];
80 | queryParams.deletedAffiliations = [];
81 | queryParams.rors = [];
82 | queryParams.rorExclusions = [];
83 | searchParams.getAll('affiliations').forEach((item) => {
84 | if (isRor(item)) {
85 | queryParams.rors.push(item);
86 | } else {
87 | queryParams.affiliationStrings.push(normalize(item));
88 | }
89 | });
90 | const rorData = await Promise.all(queryParams.rors.map((ror) => getRorData(ror, searchParams.get('getRorChildren') === '1')));
91 | const rorChildren = rorData
92 | .flat()
93 | .map((ror) => ror.rorId);
94 | queryParams.rors = queryParams.rors.concat(rorChildren);
95 | const rorNames = rorData
96 | .flat()
97 | .map((ror) => ror.names)
98 | .flat();
99 | queryParams.affiliationStrings = queryParams.affiliationStrings.concat(rorNames);
100 | if (
101 | queryParams.affiliationStrings.length === 0
102 | && queryParams.rors.length === 0
103 | ) {
104 | console.error(
105 | `You must provide at least one affiliation longer than ${VITE_APP_TAG_LIMIT} letters.`,
106 | );
107 | return;
108 | }
109 | setOptions(queryParams);
110 | };
111 |
112 | getData();
113 | }, [searchParams]);
114 |
115 | useEffect(() => {
116 | if (Object.keys(options).length > 0) refetch();
117 | }, [options, refetch]);
118 |
119 | useEffect(() => {
120 | setAffiliations(data?.affiliations ?? []);
121 | }, [data]);
122 |
123 | return (
124 | <>
125 |
126 |
127 | {isFetching && (
128 |
129 |
130 |
131 |
132 |
133 | )}
134 |
135 | {error && (
136 |
137 |
138 |
139 | Error while fetching data, please try again later or contact the
140 | team (see footer).
141 |
142 |
143 |
144 | )}
145 |
146 | {!isFetching && isFetched && (
147 |
158 | )}
159 |
160 | >
161 | );
162 | }
163 |
--------------------------------------------------------------------------------
/client/src/pages/views/publications.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len */
2 | import {
3 | Breadcrumb,
4 | Col,
5 | Link,
6 | Row,
7 | SegmentedControl,
8 | SegmentedElement,
9 | Title,
10 | } from '@dataesr/dsfr-plus';
11 | import PropTypes from 'prop-types';
12 | import { useState } from 'react';
13 |
14 | import ActionsAffiliations from '../actions/actionsAffiliations';
15 | import ActionsPublications from '../actions/actionsPublications';
16 | import AffiliationsTab from '../affiliationsTab';
17 | import PublicationsTab from '../publications/publicationsTab';
18 |
19 | export default function Publications({
20 | allAffiliations,
21 | allPublications,
22 | data,
23 | selectedAffiliations,
24 | selectedPublications,
25 | setSelectedAffiliations,
26 | setSelectedPublications,
27 | tagAffiliations,
28 | tagPublications,
29 | }) {
30 | const [tab, setTab] = useState('selectAffiliations');
31 |
32 | if (allPublications?.length === 0) {
33 | return No publications detected.
;
34 | }
35 |
36 | return (
37 | <>
38 |
39 |
40 |
41 | Home
42 |
43 |
44 | Build my corpus of publications
45 |
46 |
47 | Select the affiliations and build the corpus
48 |
49 |
50 |
51 |
52 |
53 |
54 | 📑 Find the publications affiliated to your institution
55 |
56 |
57 |
58 | setTab(value)}
62 | >
63 |
68 |
73 |
74 |
75 |
76 | {tab === 'selectAffiliations' && (
77 | <>
78 |
79 |
80 |
81 |
82 | Select the raw affiliations corresponding to your
83 | institution
84 |
85 |
86 | 🔎 The array below summarizes the most frequent raw
87 | affiliation strings retrieved in the French Open Science
88 | Monitor data and in OpenAlex for your query.
89 |
90 | 🤔 You can validate ✅ or exclude ❌ each of them, whether
91 | it actually corresponds to your institution or not. If an
92 | affiliation is validated, it will also validate all the
93 | works with that affiliation string.
94 |
95 | 🤖 The second column indicates the ROR automatically
96 | computed by OpenAlex. Sometimes, they can be inaccurate or
97 | missing. If any errors, please use the first tab to send
98 | feedback.
99 |
100 | 💾 You can save (export to a file) those decisions, and
101 | restore them later on.
102 |
103 |
104 |
105 |
106 |
107 |
108 |
112 |
113 |
114 | >
115 | )}
116 |
117 |
118 | {tab === 'selectAffiliations' && (
119 |
125 | )}
126 | {tab === 'listOfPublications' && (
127 | <>
128 |
132 |
141 | >
142 | )}
143 |
144 |
145 | >
146 | );
147 | }
148 |
149 | Publications.propTypes = {
150 | allAffiliations: PropTypes.arrayOf(
151 | PropTypes.shape({
152 | name: PropTypes.string.isRequired,
153 | nameHtml: PropTypes.string.isRequired,
154 | status: PropTypes.string.isRequired,
155 | works: PropTypes.arrayOf(PropTypes.string).isRequired,
156 | worksNumber: PropTypes.number.isRequired,
157 | }),
158 | ).isRequired,
159 | setSelectedAffiliations: PropTypes.func.isRequired,
160 | selectedAffiliations: PropTypes.arrayOf(PropTypes.object).isRequired,
161 | tagAffiliations: PropTypes.func.isRequired,
162 | allPublications: PropTypes.arrayOf(PropTypes.object).isRequired,
163 | data: PropTypes.object.isRequired,
164 | selectedPublications: PropTypes.arrayOf(PropTypes.object).isRequired,
165 | setSelectedPublications: PropTypes.func.isRequired,
166 | tagPublications: PropTypes.func.isRequired,
167 | };
168 |
--------------------------------------------------------------------------------
/client/src/router.jsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Route, Routes } from 'react-router-dom';
2 |
3 | import Layout from './layout';
4 | import About from './pages/about';
5 | import DatasetsResults from './pages/datasets/results';
6 | import DatasetsSearch from './pages/datasets/search';
7 | import Home from './pages/home';
8 | import MentionsResults from './pages/mentions/results';
9 | import MentionsSearch from './pages/mentions/search';
10 | import OpenalexAffiliationsCorrections from './pages/openalex-affiliations/corrections';
11 | import OpenalexaffiliationsResults from './pages/openalex-affiliations/results';
12 | import OpenalexAffiliationsSearch from './pages/openalex-affiliations/search';
13 | import PublicationsResults from './pages/publications/results';
14 | import PublicationsSearch from './pages/publications/search';
15 |
16 | export default function Router() {
17 | return (
18 |
19 | }>
20 | } />
21 | } />
22 |
26 | } />
27 | } />
28 | } />
29 |
30 | )}
31 | />
32 |
36 | } />
37 | } />
38 | } />
39 |
40 | )}
41 | />
42 |
46 | } />
47 | } />
48 | } />
49 | } />
50 |
51 | )}
52 | />
53 |
57 | } />
58 | } />
59 | } />
60 |
61 | )}
62 | />
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/client/src/styles/index.scss:
--------------------------------------------------------------------------------
1 | /* Common */
2 | .cursor-pointer {
3 | cursor: pointer;
4 | }
5 |
6 | .d-block {
7 | display: block;
8 | }
9 |
10 | .ellipsis {
11 | list-style-position: inside;
12 | overflow: hidden;
13 | text-overflow: ellipsis;
14 | white-space: nowrap;
15 | }
16 |
17 | .list-none {
18 | list-style: none;
19 | }
20 |
21 | .text-center {
22 | text-align: center;
23 | }
24 |
25 | .text-left {
26 | text-align: left;
27 | }
28 |
29 | .text-right {
30 | text-align: right;
31 | }
32 |
33 | .vertical-middle {
34 | vertical-align: middle;
35 | }
36 |
37 |
38 | /* General */
39 | html,
40 | body {
41 | height: 100%;
42 | }
43 |
44 | .filters {
45 | background-color: white;
46 | border: 1px solid #aaa;
47 | border-left: 8px solid #c7eeea;
48 | box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
49 | }
50 |
51 | .mentions .actions-menu {
52 | &:hover {
53 | width: 420px !important;
54 | }
55 |
56 | &.action-menu-fixed {
57 | width: 420px;
58 | }
59 | }
60 |
61 |
62 | /* DataTable - PaginatorLeft */
63 | .p-paginator-left-content {
64 | width: 60%;
65 |
66 | .before-toggle {
67 | align-items: center;
68 | color: #161616;
69 | display: flex;
70 | font-size: 17px;
71 | height: 100%;
72 | justify-content: right;
73 | padding-bottom: 5px;
74 | padding-right: 5px;
75 | }
76 | }
77 |
78 | .fr-toggle
79 | input[type="checkbox"]:checked
80 | ~ .fr-toggle__label[data-fr-unchecked-label][data-fr-checked-label]:before {
81 | content: "";
82 | margin-right: 5px;
83 | }
84 |
85 | .fr-toggle label[data-fr-unchecked-label][data-fr-checked-label]:before {
86 | margin: 0;
87 | }
88 |
89 |
90 | /* Page Mentions */
91 | .mentions {
92 | .hint {
93 | color: grey;
94 | font-size: 0.8em;
95 | font-style: italic;
96 | text-align: end;
97 | }
98 |
99 | .label {
100 | font-size: 1em;
101 | font-weight: bold;
102 | text-align: end;
103 | }
104 | }
105 |
106 | .box-info {
107 | background-color: #eee;
108 | border: 1px solid #000;
109 | box-shadow: 5px 5px 15px 0px rgba(50, 50, 50, 0.1);
110 | margin: 10px 0;
111 | padding: 10px;
112 | }
113 |
114 | .wm-font {
115 | font-family: "Courier New", Courier, monospace;
116 | }
117 |
118 | .wm-bg {
119 | background-color: #fff;
120 | }
121 |
122 | .wm-message {
123 | background-color: #eee;
124 | padding: 15px;
125 | border: 1px solid #000;
126 | box-shadow: 5px 5px 15px 0px rgba(50, 50, 50, 0.1);
127 | }
128 |
129 | .wm-fit-content {
130 | min-height: 100%; /* real browsers */
131 | height: auto !important; /* real browsers */
132 | height: 100%;
133 | }
134 |
135 | .wm-menu {
136 | min-height: fit-content;
137 | padding-top: 10px;
138 |
139 | .wm-title {
140 | background-color: #eeeeee;
141 | color: #222;
142 | padding: 5px;
143 | margin-bottom: 10px;
144 | margin-top: 10px;
145 | border-top-right-radius: 15px;
146 | border-bottom-right-radius: 15px;
147 | border: #777 1px solid;
148 | }
149 |
150 | .wm-content {
151 | min-height: 80px;
152 | }
153 |
154 | .wm-text {
155 | color: white;
156 | padding: 5px;
157 | & span {
158 | font-size: 1.5em;
159 | font-weight: bold;
160 | }
161 | }
162 |
163 | .wm-button {
164 | width: 90%;
165 | border-top-right-radius: 15px;
166 | border-bottom-right-radius: 15px;
167 | }
168 | }
169 |
170 | .wm-dot {
171 | border-radius: 10px;
172 | display: inline-block;
173 | width: 15px;
174 | height: 15px;
175 | margin-left: 5px;
176 | }
177 |
178 | .wm-content {
179 | padding: 10px;
180 | border-bottom-right-radius: 15px;
181 | border-bottom-left-radius: 15px;
182 |
183 | .wm-external-actions {
184 | border: #777 1px solid;
185 | background-color: #eee;
186 | padding: 10px 0 0 10px;
187 | border-top-right-radius: 15px;
188 | border-top-left-radius: 15px;
189 | border-bottom: 1px solid #ddd;
190 | }
191 | .wm-internal-actions {
192 | border-bottom: #777 1px solid;
193 | border-left: #777 1px solid;
194 | border-right: #777 1px solid;
195 | padding: 10px;
196 | margin-bottom: 5px;
197 | background-color: #eee;
198 | }
199 |
200 | .wm-list {
201 | background-color: #eee;
202 | list-style: none;
203 | border: #777 1px solid;
204 | padding-left: 1px;
205 |
206 | li {
207 | padding: 10px;
208 | border-bottom: 1px solid #ddd;
209 | font-size: 0.9rem;
210 | }
211 | .selected {
212 | background-color: #eae2e2;
213 | }
214 | }
215 |
216 | .wm-ror-badge {
217 | display: inline-flex;
218 | align-items: center;
219 | background-color: #fff;
220 | border-radius: 10px;
221 | border-top-left-radius: 10px;
222 | span {
223 | border-radius: 10px;
224 | display: inline-block;
225 | width: 15px;
226 | height: 15px;
227 | margin-left: 5px;
228 | }
229 | div {
230 | margin-left: 5px;
231 | display: inline-block;
232 | background-color: #fff;
233 | }
234 | }
235 | }
236 |
237 | :root {
238 | --green-archipel: #00a99d;
239 | --purple-glycine: #8a2be2;
240 | --pink-tuile: #ff69b4;
241 | --green-menthe: #00ff7f;
242 | --brown-cafe-creme: #6f4e37;
243 | --beige-gris-galet: #d3d3d3;
244 | }
245 |
246 | .loader {
247 | display: inline-block;
248 | text-align: center;
249 | line-height: 86px;
250 | text-align: center;
251 | position: relative;
252 | padding: 0 48px;
253 | font-size: 28px;
254 | font-family: "Courier New", Courier, monospace;
255 | color: #222;
256 | }
257 | .loader:before,
258 | .loader:after {
259 | content: "";
260 | display: block;
261 | width: 15px;
262 | height: 15px;
263 | background: currentColor;
264 | position: absolute;
265 | animation: load 0.7s infinite alternate ease-in-out;
266 | top: 0;
267 | }
268 | .loader:after {
269 | top: auto;
270 | bottom: 0;
271 | }
272 | @keyframes load {
273 | 0% {
274 | left: 0;
275 | height: 43px;
276 | width: 15px;
277 | transform: translateX(0);
278 | }
279 | 50% {
280 | height: 10px;
281 | width: 40px;
282 | }
283 | 100% {
284 | left: 100%;
285 | height: 43px;
286 | width: 15px;
287 | transform: translateX(-100%);
288 | }
289 | }
290 |
291 | // Intro.js tooltips
292 | .introjs-tooltip .introjs-dontShowAgain {
293 | display: none;
294 | }
--------------------------------------------------------------------------------
/client/src/utils/curations.jsx:
--------------------------------------------------------------------------------
1 | const getMentionsCorrections = (mentions) => mentions
2 | .filter((mention) => mention.hasCorrection)
3 | .map((mention) => {
4 | const corrections = [];
5 | if (mention.type !== mention.type_original) {
6 | corrections.push({
7 | id: mention.id,
8 | doi: mention.doi,
9 | text: mention.context,
10 | type: mention.type,
11 | previousType: mention.type_original,
12 | });
13 | }
14 | if (
15 | mention.mention_context.used
16 | !== mention.mention_context_original.used
17 | || mention.mention_context.created
18 | !== mention.mention_context_original.created
19 | || mention.mention_context.shared
20 | !== mention.mention_context_original.shared
21 | ) {
22 | corrections.push({
23 | id: mention.id,
24 | doi: mention.doi,
25 | texts: [
26 | {
27 | text: mention.context,
28 | class_attributes: {
29 | classification: {
30 | used: {
31 | previousValue: mention.mention_context_original.used,
32 | score: 1.0,
33 | value: mention.mention_context.used,
34 | },
35 | created: {
36 | previousValue: mention.mention_context_original.created,
37 | score: 1.0,
38 | value: mention.mention_context.created,
39 | },
40 | shared: {
41 | previousValue: mention.mention_context_original.shared,
42 | score: 1.0,
43 | value: mention.mention_context.shared,
44 | },
45 | },
46 | },
47 | },
48 | ],
49 | });
50 | }
51 | return corrections;
52 | })
53 | .flat();
54 |
55 | export { getMentionsCorrections };
56 |
--------------------------------------------------------------------------------
/client/src/utils/files.jsx:
--------------------------------------------------------------------------------
1 | import Papa from 'papaparse';
2 |
3 | import { status } from '../config';
4 |
5 | const hashCode = (str) => {
6 | let hash = 0;
7 | if (str.length === 0) return hash;
8 | for (let i = 0; i < str.length; i += 1) {
9 | const chr = str.charCodeAt(i);
10 | // eslint-disable-next-line no-bitwise
11 | hash = ((hash << 5) - hash) + chr;
12 | // eslint-disable-next-line no-bitwise
13 | hash |= 0; // Convert to 32bit integer
14 | }
15 | // return positive numbers only
16 | return hash + 2147483647 + 1;
17 | };
18 |
19 | const getFileName = ({ extension, label, searchParams }) => {
20 | let fileName = 'works_magnet';
21 | fileName += label ? `_${label.replace(' ', '')}` : '';
22 | fileName += `_${Date.now()}`;
23 | if (searchParams.get('startYear')) fileName += `_${searchParams.get('startYear')}`;
24 | if (searchParams.get('endYear')) fileName += `_${searchParams.get('endYear')}`;
25 | if (searchParams.get('affiliations')) fileName += `_${hashCode(searchParams.get('affiliations'))}`;
26 | fileName += `.${extension}`;
27 | return fileName;
28 | };
29 |
30 | const export2FosmCsv = ({ data, label, searchParams }) => {
31 | // For publications, DOI from Datacite will be ignored as it is not supported in the FOSM
32 | let idsToExport = ['doi', 'hal_id', 'nnt_id', 'openalex'];
33 | // For datasets
34 | if (label === 'datasets') {
35 | idsToExport = ['doi'];
36 | }
37 | const rows = data
38 | .filter((publication) => publication.status === status.validated.id)
39 | .map((publication) => {
40 | const row = [];
41 | idsToExport.forEach((currentId) => {
42 | const elt = publication.allIds.filter((id) => id.id_type === currentId);
43 | if (elt.length) {
44 | row.push(elt[0].id_value);
45 | } else {
46 | row.push('');
47 | }
48 | });
49 | return row;
50 | });
51 | const csvFile = Papa.unparse([idsToExport, ...rows], { skipEmptyLines: 'greedy' });
52 | const link = document.createElement('a');
53 | link.href = URL.createObjectURL(new Blob([csvFile], { type: 'text/csv;charset=utf-8' }));
54 | link.setAttribute('download', getFileName({ extension: 'csv', label: label.concat('_bso'), searchParams }));
55 | document.body.appendChild(link);
56 | link.click();
57 | document.body.removeChild(link);
58 | // Count lines in the CSV file
59 | return csvFile.split('\n').length;
60 | };
61 |
62 | const export2Csv = ({ data, label, searchParams, transform }) => {
63 | // Deep copy of data
64 | let dataCopy = JSON.parse(JSON.stringify(data));
65 | dataCopy = transform(dataCopy);
66 | const deletedFields = ['affiliations', 'affiliationsHtml', 'affiliationsTooltip', 'allIds', 'allInfos', 'authors', 'datasource', 'id', 'hasCorrection', 'key', 'nameHtml', 'selected'];
67 | const stringifiedFields = ['addList', 'fr_authors_orcid', 'fr_publications_linked', 'removeList', 'rors', 'rorsInOpenAlex', 'rorsToCorrect', 'worksExample'];
68 | dataCopy.forEach((work) => {
69 | work.allIds?.forEach((id) => {
70 | work[id.id_type] = id.id_value;
71 | });
72 | deletedFields.forEach((field) => delete work[field]);
73 | stringifiedFields.forEach((field) => {
74 | if ((work?.[field] ?? []).length > 0) {
75 | work[field] = JSON.stringify(work[field]);
76 | }
77 | });
78 | });
79 | const csvFile = Papa.unparse(dataCopy, { skipEmptyLines: 'greedy' });
80 | const link = document.createElement('a');
81 | link.href = URL.createObjectURL(new Blob([csvFile], { type: 'text/csv;charset=utf-8' }));
82 | const fileName = getFileName({ extension: 'csv', label, searchParams });
83 | link.setAttribute('download', fileName);
84 | document.body.appendChild(link);
85 | link.click();
86 | document.body.removeChild(link);
87 | };
88 |
89 | const export2json = ({ data, label, searchParams }) => {
90 | // Deep copy of data
91 | const dataCopy = JSON.parse(JSON.stringify(data));
92 | dataCopy.forEach((work) => {
93 | delete work.allInfos;
94 | });
95 | const link = document.createElement('a');
96 | link.href = URL.createObjectURL(new Blob([JSON.stringify(dataCopy, null, 2)], { type: 'application/json' }));
97 | const fileName = getFileName({ extension: 'json', label, searchParams });
98 | link.setAttribute('download', fileName);
99 | document.body.appendChild(link);
100 | link.click();
101 | document.body.removeChild(link);
102 | };
103 |
104 | const export2jsonl = ({ data, label, searchParams }) => {
105 | // Deep copy of data
106 | const dataCopy = JSON.parse(JSON.stringify(data));
107 | dataCopy.forEach((work) => {
108 | delete work.allInfos;
109 | });
110 | const link = document.createElement('a');
111 | link.href = URL.createObjectURL(new Blob([dataCopy.map(JSON.stringify).join('\n')], { type: 'application/jsonl+json' }));
112 | const fileName = getFileName({ extension: 'jsonl', label, searchParams });
113 | link.setAttribute('download', fileName);
114 | document.body.appendChild(link);
115 | link.click();
116 | document.body.removeChild(link);
117 | };
118 |
119 | const importJson = (e, tagAffiliations) => {
120 | const fileReader = new FileReader();
121 | fileReader.readAsText(e.target.files[0], 'UTF-8');
122 | fileReader.onload = (f) => {
123 | const affiliations = JSON.parse(f.target.result);
124 | const validatedAffiliations = affiliations.filter((decidedAffiliation) => decidedAffiliation.status === status.validated.id);
125 | if (validatedAffiliations.length > 0) tagAffiliations(validatedAffiliations, status.validated.id);
126 | const excludedAffiliations = affiliations.filter((decidedAffiliation) => decidedAffiliation.status === status.excluded.id);
127 | if (excludedAffiliations.length > 0) tagAffiliations(excludedAffiliations, status.excluded.id);
128 | };
129 | };
130 |
131 | export {
132 | export2Csv,
133 | export2FosmCsv,
134 | export2json,
135 | export2jsonl,
136 | importJson,
137 | };
138 |
--------------------------------------------------------------------------------
/client/src/utils/flags.jsx:
--------------------------------------------------------------------------------
1 | export default function getFlagEmoji(countryCode) {
2 | if (!countryCode) return '';
3 | const codePoints = countryCode
4 | .toUpperCase()
5 | .split('')
6 | .map((char) => 127397 + char.charCodeAt());
7 | return String.fromCodePoint(...codePoints);
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/utils/helpers.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Return true if the app in running in production mode
3 | * @returns boolean
4 | */
5 | const isInProduction = () => import.meta.env.MODE === 'production';
6 |
7 | export {
8 | isInProduction,
9 | };
10 |
--------------------------------------------------------------------------------
/client/src/utils/ror.jsx:
--------------------------------------------------------------------------------
1 | // https://ror.readme.io/docs/ror-identifier-pattern
2 | const rorRegex = /^0[a-hj-km-np-tv-z|0-9]{6}[0-9]{2}$/;
3 |
4 | const cleanRor = (ror) => ror
5 | .replace('https://ror.org/', '')
6 | .replace('http://ror.org/', '')
7 | .replace('ror.org/', '');
8 |
9 | const isRor = (affiliation) => (affiliation ? rorRegex.test(cleanRor(affiliation)) : false);
10 |
11 | const getRorData = async (affiliation, getChildren = false) => {
12 | const affiliationId = cleanRor(affiliation);
13 | if (!isRor(affiliationId)) return [];
14 | let response = await fetch(
15 | `https://api.ror.org/v1/organizations/${affiliationId}`,
16 | { cache: 'force-cache' },
17 | );
18 | response = await response.json();
19 | const topLevel = [
20 | {
21 | names: [
22 | response.name,
23 | ...response.acronyms,
24 | ...response.aliases,
25 | ...response.labels.map((item) => item.label),
26 | ],
27 | rorCountry: response?.country?.country_code,
28 | rorId: affiliationId,
29 | rorName: response.name,
30 | },
31 | ];
32 | if (!getChildren) {
33 | return topLevel;
34 | }
35 | const children = response.relationships.filter(
36 | (relationship) => relationship.type === 'Child',
37 | );
38 | let childrenRes = [];
39 | if (getChildren) {
40 | const childrenQueries = [];
41 | children.forEach((child) => {
42 | childrenQueries.push(getRorData(child.id, getChildren));
43 | });
44 | if (childrenQueries.length > 0) {
45 | childrenRes = await Promise.all(childrenQueries);
46 | }
47 | }
48 | return topLevel.concat(childrenRes.flat());
49 | };
50 |
51 | export {
52 | cleanRor,
53 | getRorData,
54 | isRor,
55 | };
56 |
--------------------------------------------------------------------------------
/client/src/utils/tags.jsx:
--------------------------------------------------------------------------------
1 | const getTagColor = (tag) => {
2 | if (tag.isDisabled) return 'beige-gris-galet';
3 | if (tag.source === 'ror') return 'green-menthe';
4 | return 'green-archipel';
5 | };
6 |
7 | export {
8 | getTagColor,
9 | };
10 |
--------------------------------------------------------------------------------
/client/vite.config.js:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | server: {
7 | proxy: {
8 | '/api': {
9 | target: 'http://localhost:3000',
10 | changeOrigin: true,
11 | secure: false,
12 | ws: true,
13 | },
14 | },
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/doc/Works-magnet-20240412.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dataesr/works-magnet/6b700a1007b43a5b25eda46bdb5ee8754ca25fff/doc/Works-magnet-20240412.pdf
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "works-magnet",
3 | "version": "0.10.5",
4 | "private": true,
5 | "workspaces": [
6 | "client",
7 | "server"
8 | ],
9 | "dependencies": {
10 | "concurrently": "^8.0.1",
11 | "extensionless": "^1.9.9"
12 | },
13 | "scripts": {
14 | "build": "rm -rf server/dist && npm -w client run build -- --mode ${npm_config_mode} --emptyOutDir --outDir ../server/dist",
15 | "client": "npm -w client run dev",
16 | "dev": "concurrently \"npm run server\" \"npm run client\"",
17 | "deploy": "git switch main && git pull origin main --rebase --tags && git merge origin staging && npm version $npm_config_level -ws && git add **/package.json package-lock.json && npm version $npm_config_level --include-workspace-root --force && git push origin main --tags && git switch staging && git merge origin main && git push",
18 | "preview": "npm run build --mode=${npm_config_mode} && npm -w server start",
19 | "server": "npm -w server run dev",
20 | "start": "npm run dev"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb-base"],
3 | "parserOptions": {
4 | "ecmaVersion": 13
5 | },
6 | "rules": {
7 | "import/prefer-default-export": 0,
8 | "max-len": [
9 | "error",
10 | {
11 | "code": 150
12 | }
13 | ],
14 | "no-underscore-dangle": 0,
15 | "object-curly-newline": [
16 | "error",
17 | {
18 | "ObjectPattern": {
19 | "multiline": true,
20 | "minProperties": 6
21 | }
22 | }
23 | ]
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .env
12 | dist
13 |
14 | # Editor directories and files
15 | .vscode/*
16 | !.vscode/extensions.json
17 | .idea
18 | .DS_Store
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | import logger from './src/services/logger';
4 |
5 | import app from './src/app';
6 |
7 | let httpServer;
8 |
9 | async function cleanup() {
10 | app.isReady = false;
11 | logger.info('SIGTERM/SIGINT signal received');
12 | if (httpServer) {
13 | await httpServer.close();
14 | }
15 | logger.info('HTTP server stopped');
16 | process.exit(1);
17 | }
18 |
19 | process.on('SIGINT', cleanup);
20 | process.on('SIGTERM', cleanup);
21 |
22 | function createAPIServer(port) {
23 | httpServer = app.listen(port, () => {
24 | logger.info(`Server started at http://localhost:${port}`);
25 | app.isReady = true;
26 | });
27 | }
28 |
29 | createAPIServer(3000);
30 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.10.5",
4 | "private": true,
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "NODE_ENV=development nodemon --max-old-space-size=4096 --experimental-specifier-resolution=node --experimental-loader=extensionless index.js",
9 | "start": "NODE_ENV=${npm_config_mode} node --max-old-space-size=4096 --experimental-specifier-resolution=node --experimental-loader=extensionless index.js"
10 | },
11 | "author": "doad",
12 | "license": "MIT",
13 | "dependencies": {
14 | "@aws-sdk/client-s3": "^3.787.0",
15 | "@octokit/core": "^6.1.2",
16 | "@octokit/plugin-throttling": "^9.3.1",
17 | "@octokit/rest": "^21.0.2",
18 | "cors": "^2.8.5",
19 | "dotenv": "^16.0.3",
20 | "express": "^4.18.2",
21 | "express-async-errors": "^3.1.1",
22 | "express-openapi-validator": "^5.0.4",
23 | "node-ovh-objectstorage": "^2.0.5",
24 | "winston": "^3.8.2",
25 | "ws": "^8.18.0",
26 | "yamljs": "^0.3.0"
27 | },
28 | "devDependencies": {
29 | "eslint": "^8.30.0",
30 | "eslint-config-airbnb-base": "^15.0.0",
31 | "eslint-plugin-import": "^2.26.0",
32 | "nodemon": "^3.1.7"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/src/app.js:
--------------------------------------------------------------------------------
1 | import cors from 'cors';
2 | import express from 'express';
3 | import 'express-async-errors';
4 | import * as OAV from 'express-openapi-validator';
5 | import path from 'path';
6 | import YAML from 'yamljs';
7 |
8 | import { handleErrors } from './commons/middlewares/handle-errors';
9 | import router from './router';
10 | import webSocketServer from './webSocketServer';
11 |
12 | const apiSpec = 'src/openapi/api.yml';
13 | const apiDocument = YAML.load(apiSpec);
14 | const app = express();
15 |
16 | const expressServer = app.listen(process.env.WS_PORT, () => {
17 | console.log(`WebSocket server is running on port ${process.env.WS_PORT}`);
18 | });
19 | expressServer.on('upgrade', (request, socket, head) => {
20 | webSocketServer.handleUpgrade(request, socket, head, (websocket) => {
21 | webSocketServer.emit('connection', websocket, request);
22 | });
23 | });
24 |
25 | app.use(express.json({ limit: 52428800 }));
26 | app.use(express.urlencoded({ extended: false }));
27 | app.disable('x-powered-by');
28 | if (process.env.NODE_ENV === 'development') {
29 | app.use(
30 | cors({
31 | origin: '*',
32 | methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
33 | }),
34 | );
35 | } else {
36 | app.use(express.static(path.join(path.resolve(), 'dist')));
37 | }
38 |
39 | app.get('/api/docs/specs.json', (_, res) => {
40 | res.status(200).json(apiDocument);
41 | });
42 |
43 | app.use(
44 | OAV.middleware({
45 | apiSpec,
46 | validateRequests: { removeAdditional: true },
47 | validateResponses: true,
48 | ignoreUndocumented: true,
49 | }),
50 | );
51 |
52 | app.use('/api', router);
53 |
54 | app.use(handleErrors);
55 |
56 | app.get('/*', (_, res) => {
57 | res.sendFile(path.join(path.resolve(), 'dist', 'index.html'));
58 | });
59 |
60 | export default app;
61 |
--------------------------------------------------------------------------------
/server/src/commons/errors/bad-request.error.js:
--------------------------------------------------------------------------------
1 | import HTTPError from './http.error';
2 |
3 | class BadRequestError extends HTTPError {
4 | constructor(message, errors = []) {
5 | super(message || 'Bad request', errors);
6 | this.statusCode = 400;
7 | }
8 | }
9 |
10 | export default BadRequestError;
11 |
--------------------------------------------------------------------------------
/server/src/commons/errors/forbidden.error.js:
--------------------------------------------------------------------------------
1 | import HTTPError from './http.error';
2 |
3 | class ForbiddenError extends HTTPError {
4 | constructor(message, errors = []) {
5 | super(message || 'Forbidden', errors);
6 | this.statusCode = 403;
7 | }
8 | }
9 |
10 | export default ForbiddenError;
11 |
--------------------------------------------------------------------------------
/server/src/commons/errors/http.error.js:
--------------------------------------------------------------------------------
1 | class HTTPError extends Error {
2 | constructor(message, errors = []) {
3 | super(message);
4 | this.statusCode = 500;
5 | this.errors = errors;
6 | }
7 | }
8 |
9 | export default HTTPError;
10 |
--------------------------------------------------------------------------------
/server/src/commons/errors/index.js:
--------------------------------------------------------------------------------
1 | import BadRequestError from './bad-request.error';
2 | import ForbiddenError from './forbidden.error';
3 | import HTTPError from './http.error';
4 | import NotFoundError from './not-found.error';
5 | import ServerError from './server.error';
6 | import UnauthorizedError from './unauthorized.error';
7 |
8 | export {
9 | BadRequestError,
10 | ForbiddenError,
11 | HTTPError,
12 | NotFoundError,
13 | ServerError,
14 | UnauthorizedError,
15 | };
16 |
--------------------------------------------------------------------------------
/server/src/commons/errors/not-found.error.js:
--------------------------------------------------------------------------------
1 | import HTTPError from './http.error';
2 |
3 | class NotFoundError extends HTTPError {
4 | constructor(message, errors = []) {
5 | super(message || 'Not found', errors);
6 | this.statusCode = 404;
7 | }
8 | }
9 |
10 | export default NotFoundError;
11 |
--------------------------------------------------------------------------------
/server/src/commons/errors/server.error.js:
--------------------------------------------------------------------------------
1 | import HTTPError from './http.error';
2 |
3 | class ServerError extends HTTPError {
4 | constructor(message, errors = []) {
5 | super(message || 'Server error', errors);
6 | this.statusCode = 500;
7 | }
8 | }
9 |
10 | export default ServerError;
11 |
--------------------------------------------------------------------------------
/server/src/commons/errors/unauthorized.error.js:
--------------------------------------------------------------------------------
1 | import HTTPError from './http.error';
2 |
3 | class UnauthorizedError extends HTTPError {
4 | constructor(message, errors = []) {
5 | super(message || 'Unauthorized', errors);
6 | this.statusCode = 401;
7 | }
8 | }
9 |
10 | export default UnauthorizedError;
11 |
--------------------------------------------------------------------------------
/server/src/commons/middlewares/handle-errors.js:
--------------------------------------------------------------------------------
1 | import { error as OAVError } from 'express-openapi-validator';
2 | import { HTTPError } from '../errors';
3 | import logger from '../../services/logger';
4 |
5 | export function handleErrors(err, req, res, next) {
6 | const { path, method } = req;
7 | if (err instanceof HTTPError) {
8 | if (err.statusCode !== 500) { logger.info(err, { path, method }); }
9 | if (err.statusCode === 500) { logger.error(err, { path, method }); }
10 | return res.status(err.statusCode).json({
11 | error: err.message,
12 | details: err.errors,
13 | });
14 | }
15 | if (err instanceof OAVError.BadRequest) {
16 | logger.info(err, { path, method });
17 | return res.status(400).json({
18 | error: 'Validation failed',
19 | details: err.errors,
20 | });
21 | }
22 | if (err instanceof OAVError.NotFound) {
23 | logger.info(err, { path, method });
24 | return res.status(404).json({
25 | error: 'NotFound',
26 | details: err.errors,
27 | });
28 | }
29 | if (err instanceof OAVError.InternalServerError) {
30 | logger.error(err, { path, method });
31 | return res.status(500).json({
32 | error: 'Something went wrong',
33 | details: err.errors,
34 | });
35 | }
36 | if (err instanceof OAVError.Unauthorized) {
37 | logger.info(err, { path, method });
38 | return res.status(401).json({
39 | error: 'User must be logged in',
40 | details: err.errors,
41 | });
42 | }
43 | if (err) {
44 | logger.error(err);
45 | return res.status(err.status || 500).json({
46 | message: 'Something went wrong', details: [],
47 | });
48 | }
49 | return next();
50 | }
51 |
--------------------------------------------------------------------------------
/server/src/config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | logger: {
3 | logLevel: process.env.LOG_LEVEL || 'info',
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/server/src/openapi/api.yml:
--------------------------------------------------------------------------------
1 | openapi: 3.0.1
2 | info:
3 | title: Doadify API
4 | description: Openapi specs for the doadify api.
5 | version: 1.0.0
6 | servers:
7 | - url: 'http://localhost:3000'
8 | description: Development server
9 |
10 | tags:
11 | - name: Hello
12 |
13 | paths:
14 | '/api/hello':
15 | get:
16 | tags:
17 | - Hello
18 | summary: Get greetings from doadify
19 | operationId: getGreetingsFromDoadify
20 | responses:
21 | 200:
22 | description: Success
23 | content:
24 | application/json:
25 | schema:
26 | $ref: '#/components/schemas/Hello'
27 | 401:
28 | $ref: '#/components/responses/Unauthorized'
29 | 403:
30 | $ref: '#/components/responses/Forbidden'
31 | 500:
32 | $ref: '#/components/responses/ServerError'
33 |
34 |
35 | components:
36 | responses:
37 | BadRequest:
38 | description: Illegal input for operation.
39 | Forbidden:
40 | description: Permission denied.
41 | NotFound:
42 | description: Not found.
43 | ServerError:
44 | description: Server error.
45 | Unauthorized:
46 | description: Authentication needed.
47 |
48 | schemas:
49 | Error:
50 | type: object
51 | required:
52 | - error
53 | properties:
54 | error:
55 | type: string
56 | details:
57 | type: array
58 | items:
59 | type: object
60 | properties:
61 | message:
62 | type: string
63 | path:
64 | type: string
65 | errorCode:
66 | type: string
67 |
68 | Hello:
69 | type: object
70 | additionalProperties: false
71 | required:
72 | - hello
73 | properties:
74 | hello:
75 | type: string
--------------------------------------------------------------------------------
/server/src/router.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import affiliationsRouter from './routes/affiliations.routes';
4 | import filesRouter from './routes/files.routes';
5 | import mentionsRouter from './routes/mentions.routes';
6 | import worksRouter from './routes/works.routes';
7 |
8 | const router = new express.Router();
9 |
10 | router.use(affiliationsRouter);
11 | router.use(filesRouter);
12 | router.use(mentionsRouter);
13 | router.use(worksRouter);
14 |
15 | export default router;
16 |
--------------------------------------------------------------------------------
/server/src/routes/affiliations.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import { getInstitutionIdFromRor } from '../utils/openalex';
4 | import { getCache, saveCache } from '../utils/s3';
5 | import { chunkArray, getSha1, range } from '../utils/utils';
6 | import {
7 | deduplicateWorks,
8 | getOpenAlexWorks,
9 | groupByAffiliations,
10 | } from '../utils/works';
11 |
12 | const SEED_MAX = 2048;
13 | const USE_CACHE = true;
14 |
15 | const router = new express.Router();
16 |
17 | const arrayBufferToBase64 = (buffer) => {
18 | let binary = '';
19 | const bytes = new Uint8Array(buffer);
20 | const len = bytes.byteLength;
21 | for (let i = 0; i < len; i += 1) {
22 | binary += String.fromCharCode(bytes[i]);
23 | }
24 | return btoa(binary);
25 | };
26 |
27 | const compressData = async (result) => {
28 | // Convert JSON to Stream
29 | const stream = new Blob([JSON.stringify(result)], {
30 | type: 'application/json',
31 | }).stream();
32 | const compressedReadableStream = stream.pipeThrough(
33 | new CompressionStream('gzip'),
34 | );
35 | // create Response
36 | const compressedResponse = new Response(compressedReadableStream);
37 | const blob = await compressedResponse.blob();
38 | // Get the ArrayBuffer
39 | const buffer = await blob.arrayBuffer();
40 | // convert ArrayBuffer to base64 encoded string
41 | return arrayBufferToBase64(buffer);
42 | };
43 |
44 | const chunkAndCompress = (data) => {
45 | const chunks = chunkArray({ array: data, perChunk: 1000 });
46 | return Promise.all(chunks.map((c) => compressData(c)));
47 | };
48 |
49 | const getOpenAlexAffiliations = async ({ options, resetCache = false }) => {
50 | const searchId = getSha1({ text: { ...options, type: 'openalex-affiliations' } });
51 | const start = new Date();
52 | const queryId = start
53 | .toISOString()
54 | .concat(' - ', Math.floor(Math.random() * SEED_MAX).toString());
55 | let cache = false;
56 | if (USE_CACHE) {
57 | console.time(
58 | `0. Query ${queryId} | Retrieve cache if exists ${options.affiliationStrings}`,
59 | );
60 | cache = await getCache({ searchId });
61 | console.timeEnd(
62 | `0. Query ${queryId} | Retrieve cache if exists ${options.affiliationStrings}`,
63 | );
64 | }
65 | if (cache && !resetCache) {
66 | const extractionDate = new Date(cache.extractionDate);
67 | console.log(
68 | `0. Query ${queryId} | Returning cached data from ${extractionDate}`,
69 | );
70 | return cache;
71 | }
72 | console.time(`1. Query ${queryId} | Requests ${options.affiliationStrings}`);
73 | // eslint-disable-next-line no-param-reassign
74 | options.years = range(options.startYear, options.endYear);
75 | // eslint-disable-next-line no-param-reassign
76 | options.openAlexExclusions = await Promise.all(
77 | options.rorExclusions.map((ror) => getInstitutionIdFromRor(ror)),
78 | );
79 | const queries = [];
80 | const affiliationStringsChunks = chunkArray({
81 | array: options.affiliationStrings,
82 | });
83 | const rorsChunks = chunkArray({ array: options.rors });
84 | // Separate RoRs from Affiliations strings to query OpenAlex
85 | affiliationStringsChunks.forEach((affiliationStrings) => {
86 | queries.push(
87 | getOpenAlexWorks({
88 | options: { ...options, affiliationStrings, rors: [] },
89 | }),
90 | );
91 | });
92 | rorsChunks.forEach((rors) => {
93 | queries.push(
94 | getOpenAlexWorks({
95 | options: { ...options, rors, affiliationStrings: [] },
96 | }),
97 | );
98 | });
99 | const responses = await Promise.all(queries);
100 | const warnings = {};
101 | const MAX_OPENALEX = Number(process.env.OPENALEX_MAX_SIZE);
102 | if (
103 | MAX_OPENALEX > 0
104 | && responses.length > 1
105 | && responses[1].length >= MAX_OPENALEX
106 | ) {
107 | warnings.isMaxOpenalexReached = true;
108 | warnings.maxOpenalexValue = MAX_OPENALEX;
109 | }
110 | console.timeEnd(
111 | `1. Query ${queryId} | Requests ${options.affiliationStrings}`,
112 | );
113 | const works = responses.flat();
114 | console.time(`2. Query ${queryId} | Dedup ${options.affiliationStrings}`);
115 | // Deduplicate publications by ids
116 | const deduplicatedWorks = deduplicateWorks(works);
117 | console.timeEnd(`2. Query ${queryId} | Dedup ${options.affiliationStrings}`);
118 | // Compute distinct affiliations of works
119 | console.time(`3. Query ${queryId} | GroupBy ${options.affiliationStrings}`);
120 | const uniqueAffiliations = groupByAffiliations({
121 | options,
122 | works: deduplicatedWorks,
123 | });
124 | console.timeEnd(
125 | `3. Query ${queryId} | GroupBy ${options.affiliationStrings}`,
126 | );
127 | // Build and serialize response
128 | console.time(
129 | `4. Query ${queryId} | Serialization ${options.affiliationStrings}`,
130 | );
131 | uniqueAffiliations.sort((a, b) => b.worksNumber - a.worksNumber);
132 | const affiliations = await chunkAndCompress(uniqueAffiliations);
133 | console.log(
134 | 'serialization',
135 | `${uniqueAffiliations.length} affiliations serialized`,
136 | );
137 | const result = {
138 | affiliations,
139 | extractionDate: Date.now(),
140 | warnings,
141 | };
142 | console.timeEnd(
143 | `4. Query ${queryId} | Serialization ${options.affiliationStrings}`,
144 | );
145 | console.time(
146 | `5. Query ${queryId} | Save cache ${options.affiliationStrings}`,
147 | );
148 | await saveCache({ queryId, result, searchId });
149 | console.timeEnd(
150 | `5. Query ${queryId} | Save cache ${options.affiliationStrings}`,
151 | );
152 | return result;
153 | };
154 |
155 | router.route('/openalex-affiliations').post(async (req, res) => {
156 | try {
157 | const options = req?.body ?? {};
158 | if (!options?.affiliationStrings && !options?.rors) {
159 | res.status(400).json({
160 | message: 'You must provide at least one affiliation string or RoR.',
161 | });
162 | } else {
163 | const compressedResult = await getOpenAlexAffiliations({ options });
164 | res.status(200).json(compressedResult);
165 | }
166 | } catch (error) {
167 | console.error(error);
168 | res.status(500).json({ message: 'Internal Server Error.' });
169 | }
170 | });
171 |
172 | export default router;
173 |
--------------------------------------------------------------------------------
/server/src/routes/files.routes.js:
--------------------------------------------------------------------------------
1 | import { execSync } from 'child_process';
2 | import express from 'express';
3 |
4 | const router = new express.Router();
5 |
6 | const key = process.env.OS_PASSWORD;
7 | const projectName = process.env.OS_PROJECT_NAME;
8 | const projectId = process.env.OS_TENANT_ID;
9 |
10 | const user = `${process.env.OS_TENANT_NAME}:${process.env.OS_USERNAME}`;
11 |
12 | router.route('/download')
13 | .get(async (_, res) => {
14 | const container = 'works-magnet';
15 | // eslint-disable-next-line max-len
16 | const initCmd = `swift --os-auth-url https://auth.cloud.ovh.net/v3 --auth-version 3 --key ${key} --user ${user} --os-project-domain-name Default --os-project-id ${projectId} --os-project-name ${projectName} --os-region-name GRA`;
17 | const cmd = `${initCmd} list ${container} > list_files_${container}`;
18 | execSync(cmd);
19 | res.status(200).send('DONE');
20 | });
21 |
22 | export default router;
23 |
--------------------------------------------------------------------------------
/server/src/routes/mentions.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | const router = new express.Router();
4 |
5 | const getMentionContext = (mention) => {
6 | if (mention?.highlight?.context) {
7 | return mention.highlight.context;
8 | }
9 | try {
10 | return decodeURIComponent(escape(mention._source.context));
11 | } catch (_) {
12 | return mention._source.context;
13 | }
14 | };
15 |
16 | const getMentionsQuery = ({ options }) => {
17 | const { from, search, size, sortBy, sortOrder } = options;
18 | const body = {
19 | from,
20 | size,
21 | _source: [
22 | 'authors',
23 | 'affiliations',
24 | 'context',
25 | 'dataset-name',
26 | 'doi',
27 | 'mention_context',
28 | 'rawForm',
29 | 'software-name',
30 | 'type',
31 | ],
32 | highlight: {
33 | number_of_fragments: 0,
34 | fragment_size: 100,
35 | require_field_match: 'true',
36 | fields: [
37 | {
38 | context: { pre_tags: [''], post_tags: [' '] },
39 | },
40 | ],
41 | },
42 | };
43 | if (search?.length > 0) {
44 | body.query = { bool: { must: [{ query_string: { query: search.replaceAll('*', '\\*') } }] } };
45 | }
46 | if (sortBy && sortOrder) {
47 | let sortFields = sortBy;
48 | switch (sortBy) {
49 | case 'doi':
50 | sortFields = ['doi.keyword'];
51 | break;
52 | case 'rawForm':
53 | sortFields = [
54 | 'dataset-name.rawForm.keyword',
55 | 'software-name.rawForm.keyword',
56 | ];
57 | break;
58 | case 'mention.mention_context.used':
59 | sortFields = ['mention_context.used'];
60 | break;
61 | case 'mention.mention_context.created':
62 | sortFields = ['mention_context.created'];
63 | break;
64 | case 'mention.mention_context.shared':
65 | sortFields = ['mention_context.shared'];
66 | break;
67 | default:
68 | console.error(`This "sortBy" field is not mapped : ${sortBy}`);
69 | }
70 | body.sort = [];
71 | sortFields.map((sortField) => body.sort.push({ [sortField]: sortOrder }));
72 | }
73 | return body;
74 | };
75 |
76 | const getMentions = async ({ query }) => {
77 | const url = `${process.env.ES_URL}/${process.env.ES_INDEX_MENTIONS}/_search`;
78 | const params = {
79 | body: JSON.stringify(query),
80 | method: 'POST',
81 | headers: {
82 | Authorization: process.env.ES_AUTH,
83 | 'content-type': 'application/json',
84 | },
85 | };
86 | const response = await fetch(url, params);
87 | const data = await response.json();
88 | const mentions = (data?.hits?.hits ?? []).map((mention) => ({
89 | ...mention._source,
90 | affiliations: [
91 | ...new Set(
92 | mention._source?.affiliations
93 | ?.map((_affiliation) => _affiliation.name)
94 | .flat()
95 | .filter((item) => !!item) ?? [],
96 | ),
97 | ],
98 | authors:
99 | mention._source?.authors
100 | ?.map((_author) => _author.full_name)
101 | .filter((_author) => !!_author) ?? [],
102 | context: getMentionContext(mention),
103 | id: mention._id,
104 | mention_context_original: mention._source.mention_context,
105 | rawForm:
106 | mention._source?.['software-name']?.rawForm
107 | ?? mention._source?.['dataset-name']?.rawForm,
108 | type: mention._source?.type === 'software' ? 'software' : 'dataset',
109 | type_original:
110 | mention._source?.type === 'software' ? 'software' : 'dataset',
111 | }));
112 | return mentions;
113 | };
114 |
115 | const getMentionsCount = async ({ query }) => {
116 | ['_source', 'from', 'highlight', 'size', 'sort'].forEach((item) => {
117 | // eslint-disable-next-line no-param-reassign
118 | delete query?.[item];
119 | });
120 | const url = `${process.env.ES_URL}/${process.env.ES_INDEX_MENTIONS}/_count`;
121 | const params = {
122 | body: JSON.stringify(query),
123 | method: 'POST',
124 | headers: {
125 | Authorization: process.env.ES_AUTH,
126 | 'content-type': 'application/json',
127 | },
128 | };
129 | const response = await fetch(url, params);
130 | const data = await response.json();
131 | return data?.count ?? 0;
132 | };
133 |
134 | router.route('/mentions').post(async (req, res) => {
135 | try {
136 | const options = req?.body ?? {};
137 | const query = getMentionsQuery({ options });
138 | const mentions = await getMentions({ query });
139 | const count = await getMentionsCount({ query });
140 | res.status(200).json({ count, mentions });
141 | } catch (err) {
142 | console.error(err);
143 | res.status(500).json({ message: 'Internal Server Error.' });
144 | }
145 | });
146 |
147 | export default router;
148 |
--------------------------------------------------------------------------------
/server/src/services/logger.js:
--------------------------------------------------------------------------------
1 | import winston from 'winston';
2 | import config from '../config';
3 |
4 | const { combine, printf, colorize, timestamp, errors } = winston.format;
5 | const { Console } = winston.transports;
6 | const { logLevel } = config.logger;
7 |
8 | const format = combine(
9 | colorize({ all: true }),
10 | timestamp({ format: 'YY-MM-DD HH:MM:SS' }),
11 | printf((info) => {
12 | const {
13 | level, message, timestamp: ts, service, stack, method, path,
14 | } = info;
15 | const requestInfo = (method) ? `(${method} ${path})` : '';
16 | const stacked = (stack) ? `\n${stack}` : '';
17 | return ` [${ts}][${service}][${level}]${requestInfo}: ${message} ${stacked}`;
18 | }),
19 | );
20 |
21 | const logger = winston.createLogger({
22 | defaultMeta: { service: 'API' },
23 | format: errors({ stack: true }),
24 | level: logLevel,
25 | transports: [new Console({ format })],
26 | });
27 |
28 | export default logger;
29 |
--------------------------------------------------------------------------------
/server/src/utils/github.js:
--------------------------------------------------------------------------------
1 | import { throttling } from '@octokit/plugin-throttling';
2 | import { Octokit } from '@octokit/rest';
3 | import crypto from 'crypto';
4 |
5 | const MyOctokit = Octokit.plugin(throttling);
6 | const auths = process.env.GITHUB_PATS.split(', ');
7 |
8 | const ALGORITHM = 'aes-256-ctr';
9 | const IV_LENGTH = 16;
10 |
11 | const encrypt = (text) => {
12 | const iv = crypto.randomBytes(IV_LENGTH);
13 | const cipher = crypto.createCipheriv(
14 | ALGORITHM,
15 | Buffer.from(process.env.SECRET_KEY, 'utf8'),
16 | iv,
17 | );
18 | let encrypted = cipher.update(text);
19 | encrypted = Buffer.concat([encrypted, cipher.final()]);
20 | return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
21 | };
22 |
23 | const formatIssueOpenAlexAffiliations = ({ email, issue }) => {
24 | const {
25 | endYear = '',
26 | name,
27 | rors = [],
28 | rorsToCorrect = [],
29 | startYear = '',
30 | worksExample = [],
31 | worksOpenAlex = [],
32 | } = issue;
33 | let title = `Correction for raw affiliation ${name}`;
34 | // Github issue title is maximum 256 characters long
35 | if (title.length > 250) {
36 | title = `${title.slice(0, 250)}...`;
37 | }
38 | let body = `Correction needed for raw affiliation ${name}\n`;
39 | body += `raw_affiliation_name: ${name}\n`;
40 | body += `new_rors: ${rors.map((ror) => ror.rorId).join(';')}\n`;
41 | body += `previous_rors: ${rorsToCorrect.map((ror) => ror.rorId).join(';')}\n`;
42 | let workIds = '';
43 | if (worksExample) {
44 | workIds = worksExample
45 | .filter((e) => e.id_type === 'openalex')
46 | .map((e) => e.id_value)
47 | .join(';');
48 | }
49 | if (worksOpenAlex) {
50 | workIds = worksOpenAlex.join(';');
51 | }
52 | body += `works_examples: ${workIds}\n`;
53 | body += `searched between: ${startYear} - ${endYear}\n`;
54 | body += `contact: ${encrypt(email.split('@')[0])} @ ${email.split('@')[1]}\n`;
55 | body += `version: ${process.env.npm_package_version}-${process.env.NODE_ENV}`;
56 | return { body, title };
57 | };
58 |
59 | const formatIssueMentionsCharacterizations = ({ email, issue }) => {
60 | let title = `Correction for mention ${issue.id}`;
61 | // Github issue title is maximum 256 characters long
62 | if (title.length > 250) {
63 | title = `${title.slice(0, 250)}...`;
64 | }
65 | // eslint-disable-next-line no-param-reassign
66 | issue.user = `${encrypt(email.split('@')[0])} @ ${email.split('@')[1]}`;
67 | // eslint-disable-next-line no-param-reassign
68 | issue.version = `${process.env.npm_package_version}-${process.env.NODE_ENV}`;
69 | const body = `\`\`\`\n${JSON.stringify(issue, null, 4)}\n\`\`\``;
70 | return { body, title };
71 | };
72 |
73 | const getOctokitConnection = (auth) => {
74 | const octokit = new MyOctokit({
75 | // Randomly pick one of the Github PATs
76 | auth,
77 | request: { retryAfter: 10 },
78 | throttle: {
79 | onRateLimit: (_, options) => {
80 | octokit.log.warn(
81 | `Request quota exhausted for request ${options.method} ${options.url}`,
82 | );
83 | },
84 | onSecondaryRateLimit: (_, options) => {
85 | // Retry 5 times after hitting a rate limit error after 5 seconds
86 | if (options.request.retryCount <= 5) {
87 | return true;
88 | }
89 | // Then logs a warning
90 | octokit.log.warn(
91 | `Secondary quota detected for request ${options.method} ${options.url}`,
92 | );
93 | return false;
94 | },
95 | },
96 | });
97 | return octokit;
98 | };
99 |
100 | const createGithubIssue = ({ body, title, type }) => {
101 | // Create a new octokit for each issue in order to randomly choose a Github PAT to workaround Github API limitations
102 | const octokit = getOctokitConnection(auths[Math.floor(Math.random() * auths.length)]);
103 | if (['mentions-characterizations', 'openalex-affiliations'].includes(type)) {
104 | return octokit.rest.issues.create({ body, owner: 'dataesr', repo: type, title });
105 | }
106 | console.error(
107 | `Error wile creating Github issue as "type" should be one of ["mentions-characterizations", "openalex-affiliations"] instead of "${type}".`,
108 | );
109 | return false;
110 | };
111 |
112 | export { createGithubIssue, formatIssueMentionsCharacterizations, formatIssueOpenAlexAffiliations };
113 |
--------------------------------------------------------------------------------
/server/src/utils/openalex.js:
--------------------------------------------------------------------------------
1 | const getInstitutionIdFromRor = (ror) => {
2 | const cleanRor = ror.replace('https://ror.org/', '').replace('ror.org/', '');
3 | let url = `https://api.openalex.org/institutions?filter=ror:${cleanRor}`;
4 | // Polite mode https://docs.openalex.org/how-to-use-the-api/rate-limits-and-authentication#the-polite-pool
5 | if (process?.env?.OPENALEX_KEY) {
6 | url += `&api_key=${process.env.OPENALEX_KEY}`;
7 | } else {
8 | url += '&mailto=bso@recherche.gouv.fr';
9 | }
10 | return fetch(url)
11 | .then((response) => {
12 | if (response.ok) return response.json();
13 | console.error(`Error while fetching ${url} :`);
14 | console.error(`${response.status} | ${response.statusText}`);
15 | return [];
16 | }).then((response) => response?.results?.[0]?.id);
17 | };
18 |
19 | export {
20 | getInstitutionIdFromRor,
21 | };
22 |
--------------------------------------------------------------------------------
/server/src/utils/s3.js:
--------------------------------------------------------------------------------
1 | import {
2 | PutObjectCommand,
3 | S3Client,
4 | S3ServiceException,
5 | } from '@aws-sdk/client-s3';
6 | import fs from 'fs';
7 | import OVHStorage from 'node-ovh-objectstorage';
8 |
9 | const {
10 | OS_ACCESS_KEY_ID,
11 | OS_CONTAINER_CACHE,
12 | OS_CONTAINER_CORRECTIONS_AFFILIATIONS,
13 | OS_PASSWORD,
14 | OS_REGION,
15 | OS_SECRET_ACCESS_KEY,
16 | OS_TENANT_ID,
17 | OS_TENANT_NAME,
18 | OS_USERNAME,
19 | } = process.env;
20 |
21 | const getStorage = async () => {
22 | const config = {
23 | username: OS_USERNAME,
24 | password: OS_PASSWORD,
25 | authURL: 'https://auth.cloud.ovh.net/v3/auth',
26 | tenantId: OS_TENANT_ID,
27 | region: OS_REGION,
28 | };
29 | const storage = new OVHStorage(config);
30 | await storage.connection();
31 | return storage;
32 | };
33 |
34 | const getFileName = (searchId) => `${searchId}.json`;
35 |
36 | const getCache = async ({ searchId }) => {
37 | const fileName = getFileName(searchId);
38 | const storage = await getStorage();
39 | const files = await storage.containers().list(OS_CONTAINER_CACHE);
40 | const filteredFiles = files.filter((file) => file?.name === fileName);
41 | if (filteredFiles.length > 0) {
42 | const remotePath = `/${OS_CONTAINER_CACHE}/${fileName}`;
43 | const localPath = `/tmp/${fileName}`;
44 | await storage.objects().download(remotePath, localPath);
45 | const data = fs.readFileSync(localPath, { encoding: 'utf8', flag: 'r' });
46 | // Delete local path
47 | fs.unlinkSync(localPath);
48 | return JSON.parse(data);
49 | }
50 | return false;
51 | };
52 |
53 | const saveCache = async ({ result, searchId, queryId }) => {
54 | console.log(queryId, 'start saving cache');
55 | const fileName = getFileName(searchId);
56 | const remotePath = `${OS_CONTAINER_CACHE}/${fileName}`;
57 |
58 | const body = {
59 | auth: {
60 | identity: {
61 | methods: ['password'],
62 | password: {
63 | user: {
64 | name: OS_USERNAME,
65 | domain: { id: 'default' },
66 | password: OS_PASSWORD,
67 | },
68 | },
69 | },
70 | scope: {
71 | project: { name: OS_TENANT_NAME, domain: { id: 'default' } },
72 | },
73 | },
74 | };
75 |
76 | const response = await fetch('https://auth.cloud.ovh.net/v3/auth/tokens', {
77 | body: JSON.stringify(body),
78 | headers: { 'Content-Type': 'application/json' },
79 | method: 'POST',
80 | });
81 | const token = response?.headers?.get('x-subject-token');
82 | if (token) {
83 | const resultJson = JSON.stringify(result);
84 | console.time(
85 | `7b. Query ${queryId} | Uploading data to cloud`,
86 | );
87 | await fetch(
88 | `https://storage.gra.cloud.ovh.net/v1/AUTH_${OS_TENANT_ID}/${remotePath}`,
89 | {
90 | body: resultJson,
91 | headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token, 'X-Delete-After': '604800' }, // 7 days
92 | method: 'PUT',
93 | },
94 | );
95 | console.timeEnd(
96 | `7b. Query ${queryId} | Uploading data to cloud`,
97 | );
98 | }
99 | };
100 |
101 | const saveIssue = async ({ fileContent, fileName }) => {
102 | const s3ClientConfig = {
103 | region: OS_REGION.toLowerCase(),
104 | credentials: {
105 | accessKeyId: OS_ACCESS_KEY_ID,
106 | secretAccessKey: OS_SECRET_ACCESS_KEY,
107 | },
108 | endpoint: {
109 | url: `https://s3.${OS_REGION.toLowerCase()}.io.cloud.ovh.net`,
110 | },
111 | };
112 | const client = new S3Client(s3ClientConfig);
113 |
114 | const command = new PutObjectCommand({
115 | Bucket: OS_CONTAINER_CORRECTIONS_AFFILIATIONS,
116 | Key: fileName,
117 | Body: fileContent,
118 | });
119 |
120 | try {
121 | return client.send(command);
122 | } catch (caught) {
123 | if (
124 | caught instanceof S3ServiceException
125 | && caught.name === 'EntityTooLarge'
126 | ) {
127 | console.error(
128 | `Error from S3 while uploading object to ${OS_CONTAINER_CORRECTIONS_AFFILIATIONS}. \
129 | The object was too large. To upload objects larger than 5GB, use the S3 console (160GB max) \
130 | or the multipart upload API (5TB max).`,
131 | );
132 | } else if (caught instanceof S3ServiceException) {
133 | console.error(
134 | `Error from S3 while uploading object to ${OS_CONTAINER_CORRECTIONS_AFFILIATIONS}. ${caught.name}: ${caught.message}`,
135 | );
136 | } else {
137 | throw caught;
138 | }
139 | return false;
140 | }
141 | };
142 |
143 | export { getCache, saveCache, saveIssue };
144 |
--------------------------------------------------------------------------------
/server/src/webSocketServer.js:
--------------------------------------------------------------------------------
1 | import { WebSocketServer } from 'ws';
2 |
3 | import { createGithubIssue, formatIssueMentionsCharacterizations, formatIssueOpenAlexAffiliations } from './utils/github';
4 | import { saveIssue } from './utils/s3';
5 | import { chunkArray, getSha1 } from './utils/utils';
6 |
7 | const webSocketServer = new WebSocketServer({ noServer: true, path: '/ws' });
8 |
9 | webSocketServer.on('connection', (webSocket) => {
10 | webSocket.on('error', console.error);
11 | webSocket.on('message', async (json) => {
12 | const { data, email, options, type } = JSON.parse(json);
13 | if (!['mentions-characterizations', 'openalex-affiliations'].includes(type)) {
14 | throw new Error('Issue type should be one of "mentions-characterizations" of "openalex-affiliations"');
15 | }
16 | const searchId = getSha1({ text: options });
17 | const promises = [];
18 | // Parse all issues and save them into OVH OS
19 | const issues = data.map((issue, index) => {
20 | let body = '';
21 | let title = '';
22 | if (type === 'openalex-affiliations') {
23 | ({ body, title } = formatIssueOpenAlexAffiliations({ email, issue }));
24 | } else if (type === 'mentions-characterizations') {
25 | ({ body, title } = formatIssueMentionsCharacterizations({ email, issue }));
26 | }
27 | if ((body?.length ?? 0) > 0) {
28 | promises.push(saveIssue({ fileContent: body, fileName: `${Date.now()}_${searchId}_${index}.txt` }));
29 | return { body, title };
30 | }
31 | return {};
32 | });
33 | await Promise.all(promises);
34 | const perChunk = 10;
35 | const results = [];
36 | let toast = {};
37 | // For each issue, open it to Github, 10 by 10
38 | // eslint-disable-next-line no-restricted-syntax
39 | for (const [index1, d] of chunkArray({ array: issues, perChunk }).entries()) {
40 | const promises2 = d.map(({ body, title }) => (body?.length ? createGithubIssue({ body, title, type }) : Promise.resolve()));
41 | const r = await Promise.all(promises2);
42 | results.push(...r);
43 | if (data.length > perChunk) {
44 | toast = {
45 | description: `${Math.min(data.length, (index1 + 1) * perChunk)} / ${
46 | data.length
47 | } issue${data.length > 1 ? 's' : ''} submitted`,
48 | id: `processCorrections${index1}`,
49 | title: `${type.replace('-', ' ')} corrections are being processed`,
50 | };
51 | webSocket.send(JSON.stringify(toast));
52 | }
53 | }
54 |
55 | const firstError = results.find(
56 | (result) => !(result?.status?.toString()?.startsWith('2') ?? result?.$metadata?.httpStatusCode?.toString()?.startsWith('2')),
57 | );
58 | if (firstError?.status) {
59 | toast = {
60 | description: `Error while submitting Github issues - Error ${firstError.status} - ${firstError?.message}`,
61 | id: 'errorCorrections',
62 | title: `Error ${firstError.status}`,
63 | toastType: 'error',
64 | };
65 | webSocket.send(JSON.stringify(toast));
66 | } else {
67 | toast = {
68 | description: `${data.length} correction${
69 | data.length > 1 ? 's' : ''
70 | } to ${type.replace('-', ' ')} have been saved -
71 | see https://github.com/dataesr/${type}/issues `,
72 | id: 'successCorrections',
73 | title: `${type.replace('-', ' ')} correction${
74 | data.length > 1 ? 's' : ''
75 | } sent`,
76 | toastType: 'success',
77 | };
78 | webSocket.send(JSON.stringify(toast));
79 | }
80 | });
81 | });
82 |
83 | export default webSocketServer;
84 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import { defineConfig } from 'vite'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | host: true,
9 | strictPort: true,
10 | port: 3000,
11 | }
12 | })
13 |
--------------------------------------------------------------------------------