Skip to content

Commit 7ae9f96

Browse files
committed
Add ability to sort extensions by downloads, using stats pulled from Tableau
1 parent 4925dc0 commit 7ae9f96

17 files changed

+638
-20
lines changed

.github/workflows/build_and_publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ jobs:
6262
NODE_ENV: production
6363
GATSBY_ACTIVE_ENV: production
6464
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65+
TABLEAU_PERSONAL_ACCESS_TOKEN: ${{ secrets.TABLEAU_PERSONAL_ACCESS_TOKEN }}
66+
TABLEAU_SITE: ${{ secrets.TABLEAU_SITE }}
6567
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
6668

6769
- name: Caching GitHub API results

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ nvm use 18
2727

2828
## Environment variables
2929

30-
Information is more complete if a GitHub access token is provided. It should only be granted read access.
31-
Set it as an environment variable called `GITHUB_TOKEN`. (In the CI, this will be provided by the platform.)
30+
The site pulls data from a range of sources, some of which need credentials. For the full build, set the following environment variables:
31+
32+
- `GITHUB_TOKEN` (this will be automatically set in a GitHub CI, and should only be granted read access)
33+
- `TABLEAU_PERSONAL_ACCESS_TOKEN`
34+
- `TABLEAU_SITE`
35+
- `SEGMENT_KEY` (used for anonymised analytics)
36+
37+
Information is more complete if a these tokens are provided, but the build should still succeed if they are missing. If it fails without them, please raise an issue.
38+
In PR builds, everything except the `GITHUB_TOKEN` will be missing.
3239

3340
## Caching
3441

gatsby-config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ module.exports = {
8787
},
8888
},
8989
},
90+
"download-data",
9091
{
9192
resolve: `gatsby-plugin-feed`,
9293
options: {

gatsby-node.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ exports.sourceNodes = async ({
202202

203203
}
204204

205+
if (node.metadata) {
206+
// Do the link to the download data
207+
node.metadata.downloads = node.metadata?.maven?.artifactId
208+
}
209+
205210
return createNode(node)
206211
})
207212
return Promise.all(secondPromises)
@@ -337,6 +342,7 @@ exports.createSchemaCustomization = ({ actions }) => {
337342
icon: File @link(by: "url")
338343
sponsors: [String]
339344
sponsor: String
345+
downloads: DownloadRanking @link(by: "artifactId")
340346
}
341347
342348
type MavenInfo {

package-lock.json

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"axios": "^1.6.7",
1616
"cacache": "^18.0.1",
1717
"compare-version": "^0.1.2",
18+
"csvtojson": "^2.0.10",
1819
"date-fns": "^3.3.1",
1920
"encodeurl": "^1.0.2",
2021
"eslint-plugin-gatsby": "^1.0.2",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const { getMostRecentData } = require("./tableau-fetcher")
2+
3+
const type = "DownloadRanking"
4+
5+
exports.sourceNodes = async ({ actions, createNodeId, createContentDigest }) => {
6+
const { createNode } = actions
7+
8+
const allData = await getMostRecentData()
9+
10+
if (allData) {
11+
12+
createNode({
13+
date: allData.date,
14+
id: createNodeId(allData.date.toString()),
15+
internal: { type: "DownloadDataDate", contentDigest: createContentDigest(allData.date) }
16+
})
17+
18+
const promises = allData.ranking.map(artifact =>
19+
createNode({
20+
...artifact,
21+
id: createNodeId(artifact.artifactId),
22+
internal: { type, contentDigest: createContentDigest(artifact.artifactId + artifact.rank) }
23+
})
24+
)
25+
// Return a promise to make sure we wait
26+
return Promise.all(promises)
27+
}
28+
}
29+
30+
exports.createSchemaCustomization = ({ actions }) => {
31+
const { createTypes } = actions
32+
const typeDefs = `
33+
type DownloadRanking implements Node {
34+
artifactId: String
35+
rank: Int
36+
}
37+
38+
type DownloadDataDate implements Node {
39+
date: String
40+
}
41+
`
42+
createTypes(typeDefs)
43+
44+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
const { sourceNodes } = require("./gatsby-node")
6+
7+
const dataFetcher = require("./tableau-fetcher")
8+
9+
jest.mock("./tableau-fetcher")
10+
11+
const stats = {
12+
date: new Date(),
13+
ranking: [
14+
{ artifactId: "quarkus-popular", rank: 1 },
15+
{ artifactId: "quarkus-soso", rank: 2 },
16+
{ artifactId: "tools", rank: 3 }]
17+
}
18+
dataFetcher.getMostRecentData.mockResolvedValue(stats)
19+
20+
const contentDigest = "some content digest"
21+
const createNode = jest.fn()
22+
const createNodeId = jest.fn()
23+
const createContentDigest = jest.fn().mockReturnValue(contentDigest)
24+
const actions = { createNode }
25+
26+
describe("the download data supplier", () => {
27+
28+
29+
beforeAll(async () => {
30+
await sourceNodes({ createContentDigest, createNodeId, actions })
31+
})
32+
33+
it("gets the download data", async () => {
34+
expect(dataFetcher.getMostRecentData).toHaveBeenCalled()
35+
})
36+
37+
it("creates a new node for each artifact", async () => {
38+
expect(createNode).toHaveBeenCalledWith(
39+
expect.objectContaining({ artifactId: "tools", rank: 3 })
40+
)
41+
expect(createNode).toHaveBeenCalledWith(
42+
expect.objectContaining({ artifactId: "quarkus-popular", rank: 1 },
43+
)
44+
)
45+
})
46+
47+
it("adds gatsby metadata to the nodes", async () => {
48+
const type = "DownloadRanking"
49+
expect(createNode).toHaveBeenCalledWith(expect.objectContaining({
50+
internal: { type, contentDigest: expect.anything() }
51+
}))
52+
})
53+
54+
})

plugins/download-data/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "download-data"
3+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
const axios = require("axios")
2+
const csv = require("csvtojson")
3+
4+
const serverUrl = "https://10ay.online.tableau.com/api/3.22"
5+
const site = process.env["TABLEAU_SITE"]
6+
7+
// https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_get_started_tutorial_part_1.htm is the useful docs for this
8+
async function getAccessToken() {
9+
const personalAccessToken = process.env["TABLEAU_PERSONAL_ACCESS_TOKEN"]
10+
11+
if (personalAccessToken) {
12+
const tokenUrl = `${serverUrl}/auth/signin`
13+
14+
const xml = `<tsRequest>
15+
<credentials
16+
personalAccessTokenName="extensions-site"
17+
personalAccessTokenSecret="${personalAccessToken}" >
18+
<site contentUrl="${site}" />
19+
</credentials>
20+
</tsRequest>`
21+
22+
const response = await axios.post(tokenUrl, xml)
23+
24+
return { token: response.data.credentials.token, siteId: response.data.credentials.site.id }
25+
} else {
26+
console.log("No TABLEAU_PERSONAL_ACCESS_TOKEN has been set. Not fetching download data.")
27+
}
28+
29+
}
30+
31+
async function getViewId(accessToken, siteId) {
32+
// This can almost be read off the web page, but note the extra 'sheets' in the middle
33+
// https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#filter-expressions has guidance on other ways of finding the right view
34+
// Other fields we could use are
35+
// name: 'Downloads Highlight table',
36+
// contentUrl: 'Quark-us-iverse-MavenNexusCommunityDownloads/sheets/DownloadsHighlighttable',
37+
// viewUrlName: 'DownloadsHighlighttable'
38+
// }
39+
const contentUrl = "Quark-us-iverse-MavenNexusCommunityDownloads/sheets/DownloadsHighlighttable"
40+
const downloadUrl = `${serverUrl}/sites/${siteId}/views?filter=contentUrl:eq:${contentUrl}`
41+
42+
const response = await axios.get(downloadUrl, {
43+
headers: {
44+
"X-Tableau-Auth": accessToken
45+
}
46+
})
47+
48+
if (!response.data?.views?.view[0]) {
49+
console.error("Could not find the view with content url", contentUrl)
50+
}
51+
return response.data.views.view[0].id
52+
}
53+
54+
async function downloadViewAsCsv(accessToken, siteId, viewId) {
55+
const downloadUrl = `${serverUrl}/sites/${siteId}/views/${viewId}/data`
56+
57+
const response = await axios.get(downloadUrl, {
58+
headers: {
59+
"X-Tableau-Auth": accessToken
60+
}
61+
})
62+
63+
return response.data
64+
}
65+
66+
const getCsv = async () => {
67+
const tokenData = await getAccessToken()
68+
69+
if (tokenData) {
70+
const { token, siteId } = tokenData
71+
const viewId = await getViewId(token, siteId)
72+
return await downloadViewAsCsv(token, siteId, viewId)
73+
}
74+
}
75+
76+
const convertToNumber = (s) => {
77+
// We can't call parseInt directly, because Tableau gives us comma-separated strings, and parse int, despite the name, can't handle them.
78+
return s ? parseInt(s.replaceAll(",", "")) : -1
79+
}
80+
81+
const getMostRecentData = async () => {
82+
83+
const csvData = await getCsv()
84+
85+
if (csvData) {
86+
const json = await csv({
87+
noheader: false,
88+
flatKeys: true
89+
})
90+
.fromString(csvData)
91+
92+
// Normalise the headers
93+
// We are expecting data.artifactId,Month of Data.Date,Year of Data.Date,Data.Timeline
94+
const withDates = json
95+
.map(entry => {
96+
return {
97+
artifactId: entry["data.artifactId"],
98+
month: entry["Month of Data.Date"],
99+
downloads: convertToNumber(entry["Data.Timeline"])
100+
}
101+
})
102+
.map(entry => {
103+
return { date: new Date(entry.month), ...entry }
104+
})
105+
106+
const mostRecentDate = withDates.map(entry => entry.date).filter(date => date.getTime() > 0).sort((a, b) => b - a)[0]
107+
108+
const onlyMostRecentDownloads = withDates.filter(entry => entry.date.getTime() === mostRecentDate.getTime())
109+
.sort((a, b) => b.downloads - a.downloads)
110+
111+
const ranking = onlyMostRecentDownloads.map((entry, i) => {
112+
return { artifactId: entry.artifactId, rank: i + 1 }
113+
})
114+
115+
return { date: mostRecentDate, ranking }
116+
}
117+
118+
}
119+
120+
// Exported for testing
121+
module.exports = { getCsv, getMostRecentData }

0 commit comments

Comments
 (0)