diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ae90f70..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -ignore-workspace-root-check=true diff --git a/README.md b/README.md index 2051913..37f3e50 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ CONTRIBKIT_GITLAB_CONTRIBUTORS_REPO_ID= ; Token requires the `read:user` and `read:org` scopes. CONTRIBKIT_GITHUB_TOKEN= CONTRIBKIT_GITHUB_LOGIN= +; Optional data mode: +; - sponsors: people sponsoring you (default) +; - sponsees: people you have sponsored, including past sponsorships +CONTRIBKIT_MODE=sponsors ; Patreon provider. ; Create v2 API key at https://www.patreon.com/portal/registration/register-clients @@ -144,6 +148,11 @@ Create `contribkit.config.js` file with: import { defineConfig, tierPresets } from '@lizardbyte/contribkit' export default defineConfig({ + // Data mode: + // - sponsors: people sponsoring you (default) + // - sponsees: people you have sponsored, including past sponsorships + mode: 'sponsors', + // Providers configs github: { login: 'antfu', @@ -243,7 +252,7 @@ export default defineConfig({

- + Sponsors

@@ -258,7 +267,7 @@ export default defineConfig({

- + Sponsors

diff --git a/package-lock.json b/package-lock.json index b353f8c..1145803 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,42 +10,42 @@ "license": "MIT", "dependencies": { "@crowdin/crowdin-api-client": "^1.41.2", - "ansis": "^4.2.0", - "cac": "^6.7.14", - "consola": "^3.4.0", - "dotenv": "^17.3.1", - "ofetch": "^1.4.1", + "ansis": "^4.3.0", + "cac": "^7.0.0", + "consola": "^3.4.2", + "dotenv": "^17.4.2", + "ofetch": "^1.5.1", "sharp": "^0.34.5", - "unconfig": "^7.3.0" + "unconfig": "^7.5.0" }, "bin": { "contribkit": "bin/contribkit.mjs" }, "devDependencies": { - "@antfu/ni": "^30.0.0", - "@antfu/utils": "^9.1.0", + "@antfu/ni": "^30.1.0", + "@antfu/utils": "^9.3.0", "@babel/core": "7.29.7", "@babel/preset-env": "7.29.7", "@codecov/webpack-plugin": "2.0.1", "@eslint/js": "^10.0.1", - "@fast-csv/parse": "^5.0.2", + "@fast-csv/parse": "^5.0.7", "@types/d3-hierarchy": "^3.1.7", - "@types/node": "^25.2.3", - "bumpp": "^11.0.1", + "@types/node": "^25.9.1", + "bumpp": "^11.1.0", "d3-hierarchy": "^3.1.2", - "eslint": "^10.0.0", + "eslint": "^10.4.0", "eslint-plugin-jest": "29.15.2", "globals": "17.6.0", "jest": "30.4.2", "jest-environment-jsdom": "30.4.1", "jest-junit": "17.0.0", - "jiti": "^2.4.2", + "jiti": "^2.7.0", "normalize-url": "^9.0.0", "npm-run-all2": "9.0.1", "p-limit": "^7.3.0", - "tsx": "^4.19.3", - "typescript": "^5.8.2", - "unbuild": "^3.5.0" + "tsx": "^4.22.3", + "typescript": "^5.9.2", + "unbuild": "^3.6.1" }, "funding": { "url": "https://app.lizardbyte.dev" @@ -5997,16 +5997,6 @@ "node": ">=20.19.0" } }, - "node_modules/bumpp/node_modules/cac": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", - "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/bumpp/node_modules/semver": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", @@ -6021,12 +6011,12 @@ } }, "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20.19.0" } }, "node_modules/call-bind-apply-helpers": { diff --git a/package.json b/package.json index 465ad96..3b4beac 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "scripts": { "build": "unbuild", "stub": "unbuild --stub", - "dev": "esno src/cli.ts", + "dev": "tsx src/cli.ts", "test": "npm-run-all test:unit test:report test:lint test:typecheck", "test:unit": "jest --coverage", "test:report": "jest --reporters=jest-junit", @@ -47,39 +47,39 @@ }, "dependencies": { "@crowdin/crowdin-api-client": "^1.41.2", - "ansis": "^4.2.0", - "cac": "^6.7.14", - "consola": "^3.4.0", - "dotenv": "^17.3.1", - "ofetch": "^1.4.1", + "ansis": "^4.3.0", + "cac": "^7.0.0", + "consola": "^3.4.2", + "dotenv": "^17.4.2", + "ofetch": "^1.5.1", "sharp": "^0.34.5", - "unconfig": "^7.3.0" + "unconfig": "^7.5.0" }, "devDependencies": { - "@antfu/ni": "^30.0.0", - "@antfu/utils": "^9.1.0", + "@antfu/ni": "^30.1.0", + "@antfu/utils": "^9.3.0", "@babel/core": "7.29.7", "@babel/preset-env": "7.29.7", "@codecov/webpack-plugin": "2.0.1", "@eslint/js": "^10.0.1", - "@fast-csv/parse": "^5.0.2", + "@fast-csv/parse": "^5.0.7", "@types/d3-hierarchy": "^3.1.7", - "@types/node": "^25.2.3", - "bumpp": "^11.0.1", + "@types/node": "^25.9.1", + "bumpp": "^11.1.0", "d3-hierarchy": "^3.1.2", - "eslint": "^10.0.0", + "eslint": "^10.4.0", "eslint-plugin-jest": "29.15.2", "globals": "17.6.0", "jest": "30.4.2", "jest-environment-jsdom": "30.4.1", "jest-junit": "17.0.0", - "jiti": "^2.4.2", + "jiti": "^2.7.0", "normalize-url": "^9.0.0", "npm-run-all2": "9.0.1", "p-limit": "^7.3.0", - "tsx": "^4.19.3", - "typescript": "^5.8.2", - "unbuild": "^3.5.0" + "tsx": "^4.22.3", + "typescript": "^5.9.2", + "unbuild": "^3.6.1" }, "jest": { "collectCoverageFrom": [ diff --git a/src/cli.ts b/src/cli.ts index 8e2b082..bb38559 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import cac from 'cac' import { version } from '../package.json' import { run } from './run' +const RE_FILTER = /([<>=]+)(\d+)/ const cli = cac('contributors-svg') .version(version) .help() @@ -36,7 +37,7 @@ cli.parse() * @param template */ function createFilterFromString(template: string): ContribkitConfig['filter'] { - const [_, op, value] = template.split(/([<>=]+)/) + const [_, op, value] = template.split(RE_FILTER) const num = Number.parseInt(value) if (op === '<') return s => s.monthlyDollars < num diff --git a/src/configs/defaults.ts b/src/configs/defaults.ts index 5267fe4..6b4b4f9 100644 --- a/src/configs/defaults.ts +++ b/src/configs/defaults.ts @@ -45,12 +45,13 @@ text { ` export const defaultConfig: ContribkitConfig = { + mode: 'sponsors', width: 800, outputDir: './contribkit', cacheFile: '.cache.json', + cacheFileSponsees: '.cache.sponsees.json', formats: ['json', 'svg', 'png'], tiers: defaultTiers, - name: 'sponsors', includePrivate: false, svgInlineCSS: defaultInlineCSS, } diff --git a/src/configs/env.ts b/src/configs/env.ts index f74a317..5a9a151 100644 --- a/src/configs/env.ts +++ b/src/configs/env.ts @@ -10,9 +10,10 @@ function getDeprecatedEnv(name: string, replacement: string) { } export function loadEnv(): Partial { - dotenv.config() + dotenv.config({ quiet: true }) const config: Partial = { + mode: process.env.CONTRIBKIT_MODE as ContribkitConfig['mode'] | undefined, github: { login: process.env.CONTRIBKIT_GITHUB_LOGIN || process.env.GITHUB_LOGIN, token: process.env.CONTRIBKIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN, diff --git a/src/configs/index.ts b/src/configs/index.ts index 537cd20..f690982 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -64,6 +64,11 @@ export async function loadConfig(inlineConfig: ContribkitConfig = {}): Promise + if (!['sponsors', 'sponsees'].includes(resolved.mode)) + throw new Error(`Invalid mode: ${resolved.mode}. Expected "sponsors" or "sponsees".`) + + resolved.name = inlineConfig.name || config.name || env.name || resolved.mode + return resolved } diff --git a/src/processing/svg.ts b/src/processing/svg.ts index 2f7177b..94a297c 100644 --- a/src/processing/svg.ts +++ b/src/processing/svg.ts @@ -113,7 +113,7 @@ export class SvgComposer { generateSvg() { return ` - + ${this.body} diff --git a/src/providers/afdian.ts b/src/providers/afdian.ts index 5b12554..2c47edc 100644 --- a/src/providers/afdian.ts +++ b/src/providers/afdian.ts @@ -7,6 +7,11 @@ import { $fetch } from 'ofetch' export const AfdianProvider: Provider = { name: 'afdian', fetchSponsors(config) { + if (config.mode === 'sponsees') { + console.warn('[contribkit] Afdian provider does not support `mode: "sponsees"` yet') + return Promise.resolve([]) + } + return fetchAfdianSponsors(config.afdian) }, } @@ -29,7 +34,7 @@ export async function fetchAfdianSponsors(options: ContribkitConfig['afdian'] = let pages = 1 do { const params = JSON.stringify({ page }) - const ts = Math.round(+new Date() / 1000) + const ts = Math.round(Date.now() / 1000) const sign = md5(token, params, ts, userId) const sponsorshipData = await $fetch(sponsorshipApi, { method: 'POST', diff --git a/src/providers/github.ts b/src/providers/github.ts index ec11518..dcf6c1c 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -40,6 +40,14 @@ const graphql = String.raw export const GitHubProvider: Provider = { name: 'github', fetchSponsors(config) { + if (config.mode === 'sponsees') { + return fetchGitHubSponsoringAsSponsorships( + config.github?.token || config.token!, + config.github?.login || config.login!, + config.github?.type || 'user', + ) + } + return fetchGitHubSponsors( config.github?.token || config.token!, config.github?.login || config.login!, @@ -176,3 +184,309 @@ export function makeQuery( } }` } + +export interface GitHubSponsoringRecord { + sponsorable: { + type: 'User' | 'Organization' + login: string + name: string + avatarUrl: string + websiteUrl?: string + linkUrl: string + } + monthlyDollars: number + monthlyCents: number + tierName: string + isOneTime: boolean + privacyLevel?: 'PUBLIC' | 'PRIVATE' + createdAt: string + isActive: boolean + raw: any +} + +export interface GitHubSponsoringTotalOptions { + since?: string + until?: string + sponsorableLogins?: string[] +} + +function assertGitHubSponsoringParams( + token: string, + login: string, + type: GitHubAccountType, +) { + if (!token) + throw new Error('GitHub token is required') + if (!login) + throw new Error('GitHub login is required') + if (!['user', 'organization'].includes(type)) + throw new Error('GitHub type must be either `user` or `organization`') +} + +async function requestGitHubSponsoringGraphQL(token: string, query: string): Promise { + const data = await $fetch(API, { + method: 'POST', + body: { query }, + headers: { + 'Authorization': `bearer ${token}`, + 'Content-Type': 'application/json', + }, + }) as any + + if (!data) + throw new Error(`Get no response on requesting ${API}`) + else if (data.errors?.[0]?.type === 'INSUFFICIENT_SCOPES') + throw new Error('Token is missing the `read:user` and/or `read:org` scopes') + else if (data.errors?.length) + throw new Error(`GitHub API error:\n${JSON.stringify(data.errors, null, 2)}`) + + return data +} + +async function fetchGitHubSponsoringNodes( + token: string, + login: string, + type: GitHubAccountType, + activeOnly: boolean, +): Promise { + const nodes: any[] = [] + let cursor: string | undefined + + do { + const query = makeSponsoringQuery(login, type, activeOnly, cursor) + const data = await requestGitHubSponsoringGraphQL(token, query) + const page = data.data?.[type]?.sponsorshipsAsSponsor + if (!page) + throw new Error('Invalid GitHub response: `sponsorshipsAsSponsor` is missing') + + nodes.push(...(page.nodes || [])) + cursor = page.pageInfo?.hasNextPage + ? page.pageInfo.endCursor + : undefined + } while (cursor) + + return nodes +} + +function toGitHubSponsoringRecord(raw: any): GitHubSponsoringRecord { + return { + sponsorable: { + type: raw.sponsorable.__typename, + login: raw.sponsorable.login, + name: raw.sponsorable.name || raw.sponsorable.login, + avatarUrl: raw.sponsorable.avatarUrl, + websiteUrl: normalizeUrl(raw.sponsorable.websiteUrl), + linkUrl: `https://github.com/${raw.sponsorable.login}`, + }, + monthlyDollars: raw.tier.monthlyPriceInDollars, + monthlyCents: raw.tier.monthlyPriceInCents, + tierName: raw.tier.name, + isOneTime: raw.tier.isOneTime, + privacyLevel: raw.privacyLevel, + createdAt: raw.createdAt, + isActive: raw.isActive, + raw, + } +} + +function groupSponsoringRecordsByLogin(records: GitHubSponsoringRecord[]) { + const recordsBySponsorable = new Map() + for (const record of records) { + const list = recordsBySponsorable.get(record.sponsorable.login) + if (list) + list.push(record) + else + recordsBySponsorable.set(record.sponsorable.login, [record]) + } + return recordsBySponsorable +} + +async function fetchTotalCentsBySponsorable( + token: string, + login: string, + type: GitHubAccountType, + sponsorableLogins: string[], +) { + return new Map( + await Promise.all( + sponsorableLogins.map(async (sponsorableLogin) => { + const totalInCents = await fetchGitHubTotalSponsorshipAmountAsSponsor( + token, + login, + type, + { sponsorableLogins: [sponsorableLogin] }, + ) + return [sponsorableLogin, totalInCents] as const + }), + ), + ) +} + +function summarizeSponsoringRecords(records: GitHubSponsoringRecord[]) { + const [first, ...rest] = records + let latest = first + let firstCreatedAt = first.createdAt + let isOneTime = first.isOneTime + const raws = [first.raw] + + for (const record of rest) { + raws.push(record.raw) + if (Date.parse(record.createdAt) > Date.parse(latest.createdAt)) + latest = record + if (record.createdAt.localeCompare(firstCreatedAt) < 0) + firstCreatedAt = record.createdAt + isOneTime &&= record.isOneTime + } + + return { + latest, + firstCreatedAt, + isOneTime, + raws, + } +} + +export async function fetchGitHubSponsoring( + token: string, + login: string, + type: GitHubAccountType, + activeOnly = true, +): Promise { + assertGitHubSponsoringParams(token, login, type) + + return (await fetchGitHubSponsoringNodes(token, login, type, activeOnly)) + .filter((raw: any) => !!raw.tier && !!raw.sponsorable) + .map(toGitHubSponsoringRecord) +} + +export async function fetchGitHubSponsoringAsSponsorships( + token: string, + login: string, + type: GitHubAccountType, +): Promise { + // Sponsees mode always loads full history regardless of active status. + const records = await fetchGitHubSponsoring(token, login, type, false) + const recordsBySponsorable = groupSponsoringRecordsByLogin(records) + const totalBySponsorable = await fetchTotalCentsBySponsorable( + token, + login, + type, + [...recordsBySponsorable.keys()], + ) + + return [...recordsBySponsorable.entries()].map(([sponsorableLogin, list]) => { + const summary = summarizeSponsoringRecords(list) + const totalInCents = totalBySponsorable.get(sponsorableLogin) || 0 + + return { + sponsor: { + type: summary.latest.sponsorable.type, + login: summary.latest.sponsorable.login, + name: summary.latest.sponsorable.name, + avatarUrl: summary.latest.sponsorable.avatarUrl, + websiteUrl: summary.latest.sponsorable.websiteUrl, + linkUrl: summary.latest.sponsorable.linkUrl, + socialLogins: { + github: summary.latest.sponsorable.login, + }, + }, + isOneTime: summary.isOneTime, + // In sponsees mode, ranking/tiers are based on lifetime sponsored amount. + monthlyDollars: totalInCents / 100, + privacyLevel: summary.latest.privacyLevel, + tierName: summary.latest.tierName, + createdAt: summary.firstCreatedAt, + raw: { + records: summary.raws, + totalSponsoredCents: totalInCents, + }, + } + }) +} + +export async function fetchGitHubTotalSponsorshipAmountAsSponsor( + token: string, + login: string, + type: GitHubAccountType, + options: GitHubSponsoringTotalOptions = {}, +): Promise { + assertGitHubSponsoringParams(token, login, type) + + const query = makeSponsoringTotalAmountQuery(login, type, options) + const data = await requestGitHubSponsoringGraphQL(token, query) + + const totalInCents = data.data?.[type]?.totalSponsorshipAmountAsSponsorInCents + if (typeof totalInCents !== 'number') + throw new Error('Invalid GitHub response: `totalSponsorshipAmountAsSponsorInCents` is missing') + + return totalInCents +} + +export function makeSponsoringQuery( + login: string, + type: GitHubAccountType, + activeOnly = true, + cursor?: string, +) { + return graphql`{ + ${type}(login: "${login}") { + sponsorshipsAsSponsor(activeOnly: ${Boolean(activeOnly)}, first: 100${cursor ? ` after: "${cursor}"` : ''}) { + totalCount + pageInfo { + endCursor + hasNextPage + } + nodes { + createdAt + privacyLevel + isActive + tier { + name + isOneTime + monthlyPriceInCents + monthlyPriceInDollars + } + sponsorable { + __typename + ...on Organization { + login + name + avatarUrl + websiteUrl + } + ...on User { + login + name + avatarUrl + websiteUrl + } + } + } + } + } +}` +} + +export function makeSponsoringTotalAmountQuery( + login: string, + type: GitHubAccountType, + options: GitHubSponsoringTotalOptions = {}, +) { + const args: string[] = [] + if (options.since) + args.push(`since: ${JSON.stringify(options.since)}`) + if (options.until) + args.push(`until: ${JSON.stringify(options.until)}`) + if (options.sponsorableLogins?.length) + args.push(`sponsorableLogins: [${options.sponsorableLogins.map(v => JSON.stringify(v)).join(', ')}]`) + + const parameters = args.length + ? `(${args.join(', ')})` + : '' + + return graphql`{ + ${type}(login: "${login}") { + totalSponsorshipAmountAsSponsorInCents${parameters} + } +}` +} diff --git a/src/providers/liberapay.ts b/src/providers/liberapay.ts index fd0bfb0..7093988 100644 --- a/src/providers/liberapay.ts +++ b/src/providers/liberapay.ts @@ -4,6 +4,11 @@ import { $fetch } from 'ofetch' export const LiberapayProvider: Provider = { name: 'liberapay', fetchSponsors(config) { + if (config.mode === 'sponsees') { + console.warn('[contribkit] Liberapay provider does not support `mode: "sponsees"` yet') + return Promise.resolve([]) + } + return fetchLiberapaySponsors(config.liberapay?.login) }, } diff --git a/src/providers/opencollective.ts b/src/providers/opencollective.ts index 016ac48..373bed7 100644 --- a/src/providers/opencollective.ts +++ b/src/providers/opencollective.ts @@ -10,6 +10,18 @@ interface SocialLink { export const OpenCollectiveProvider: Provider = { name: 'opencollective', fetchSponsors(config) { + if (config.mode === 'sponsees') { + const slug = config.opencollective?.slug || config.opencollective?.id + return fetchOpenCollectiveSponsors( + config.opencollective?.key, + undefined, + slug, + config.opencollective?.githubHandle, + true, + true, + ) + } + return fetchOpenCollectiveSponsors( config.opencollective?.key, config.opencollective?.id, @@ -29,40 +41,43 @@ export async function fetchOpenCollectiveSponsors( slug?: string, githubHandle?: string, includePastSponsors?: boolean, + sponseesMode = false, ): Promise { if (!key) throw new Error('OpenCollective api key is required') if (!slug && !id && !githubHandle) throw new Error('OpenCollective collective id or slug or GitHub handle is required') + includePastSponsors ||= sponseesMode const sponsors: any[] = [] const monthlyTransactions: any[] = [] let offset - offset = 0 - - do { - const query = makeSubscriptionsQuery(id, slug, githubHandle, offset, !includePastSponsors) - const data = await $fetch(API, { - method: 'POST', - body: { query }, - headers: { - 'Api-Key': `${key}`, - 'Content-Type': 'application/json', - }, - }) as any - const nodes = data.data.account.orders.nodes - const totalCount = data.data.account.orders.totalCount - - sponsors.push(...(nodes || [])) - - if ((nodes.length) !== 0) { - if (totalCount > offset + nodes.length) - offset += nodes.length - else - offset = undefined - } - else { offset = undefined } - } while (offset) + if (!sponseesMode) { + offset = 0 + do { + const query = makeSubscriptionsQuery(id, slug, githubHandle, offset, !includePastSponsors) + const data = await $fetch(API, { + method: 'POST', + body: { query }, + headers: { + 'Api-Key': `${key}`, + 'Content-Type': 'application/json', + }, + }) as any + const nodes = data.data.account.orders.nodes + const totalCount = data.data.account.orders.totalCount + + sponsors.push(...(nodes || [])) + + if ((nodes.length) !== 0) { + if (totalCount > offset + nodes.length) + offset += nodes.length + else + offset = undefined + } + else { offset = undefined } + } while (offset) + } offset = 0 do { @@ -70,7 +85,7 @@ export async function fetchOpenCollectiveSponsors( const dateFrom: Date | undefined = includePastSponsors ? undefined : new Date(now.getFullYear(), now.getMonth(), 1) - const query = makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom) + const query = makeTransactionsQuery(id, slug, githubHandle, offset, dateFrom, undefined, sponseesMode) const data = await $fetch(API, { method: 'POST', body: { query }, @@ -97,9 +112,29 @@ export async function fetchOpenCollectiveSponsors( .filter((sponsorship): sponsorship is [string, Sponsorship] => sponsorship !== null) const monthlySponsorships: [string, Sponsorship][] = monthlyTransactions - .map(t => createSponsorFromTransaction(t, sponsorships.map(i => i[1].raw.id))) + .map(t => createSponsorFromTransaction(t, sponsorships.map(i => i[1].raw.id), sponseesMode)) .filter((sponsorship): sponsorship is [string, Sponsorship] => sponsorship !== null && sponsorship !== undefined) + if (sponseesMode) { + const processed: Map = sponsorships + .concat(monthlySponsorships) + .reduce((map, [id, sponsor]) => { + const existingSponsor = map.get(id) + if (existingSponsor) { + existingSponsor.monthlyDollars += sponsor.monthlyDollars + existingSponsor.isOneTime = Boolean(existingSponsor.isOneTime && sponsor.isOneTime) + if (sponsor.createdAt && (!existingSponsor.createdAt || sponsor.createdAt.localeCompare(existingSponsor.createdAt) < 0)) + existingSponsor.createdAt = sponsor.createdAt + } + else { + map.set(id, sponsor) + } + return map + }, new Map()) + + return Array.from(processed.values()) + } + const transactionsBySponsorId: Map = monthlySponsorships.reduce((map, [id, sponsor]) => { const existingSponsor = map.get(id) if (existingSponsor) { @@ -165,15 +200,19 @@ function createSponsorFromOrder(order: any): [string, Sponsorship] | undefined { monthlyDollars, privacyLevel: order.fromAccount.isIncognito ? 'PRIVATE' : 'PUBLIC', tierName: order.tier?.name, - createdAt: order.frequency === 'ONETIME' ? order.createdAt : order.order?.createdAt, + createdAt: order.createdAt, raw: order, } return [order.fromAccount.id, sponsor] } -function createSponsorFromTransaction(transaction: any, excludeOrders: string[]): [string, Sponsorship] | undefined { - const slug = transaction.fromAccount.slug +function createSponsorFromTransaction(transaction: any, excludeOrders: string[], sponseesMode = false): [string, Sponsorship] | undefined { + const account = sponseesMode ? transaction.toAccount : transaction.fromAccount + if (!account?.slug) + return undefined + + const slug = account.slug if (slug === 'github-sponsors') // ignore github sponsors return undefined @@ -181,7 +220,10 @@ function createSponsorFromTransaction(transaction: any, excludeOrders: string[]) return undefined let monthlyDollars: number = transaction.amount.value - if (transaction.order?.status !== 'ACTIVE') { + if (sponseesMode) { + monthlyDollars = Math.abs(transaction.amount.value) + } + else if (transaction.order?.status !== 'ACTIVE') { const firstDayOfCurrentMonth = new Date(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1) if (new Date(transaction.createdAt) < firstDayOfCurrentMonth) monthlyDollars = -1 @@ -195,23 +237,27 @@ function createSponsorFromTransaction(transaction: any, excludeOrders: string[]) const sponsor: Sponsorship = { sponsor: { - name: transaction.fromAccount.name, - type: getAccountType(transaction.fromAccount.type), + name: account.name, + type: getAccountType(account.type), login: slug, - avatarUrl: transaction.fromAccount.imageUrl, - websiteUrl: normalizeUrl(getBestUrl(transaction.fromAccount.socialLinks)), + avatarUrl: account.imageUrl, + websiteUrl: normalizeUrl(getBestUrl(account.socialLinks || [])), linkUrl: `https://opencollective.com/${slug}`, - socialLogins: getSocialLogins(transaction.fromAccount.socialLinks, slug), + socialLogins: getSocialLogins(account.socialLinks || [], slug), }, isOneTime: transaction.order?.frequency === 'ONETIME', monthlyDollars, - privacyLevel: transaction.fromAccount.isIncognito ? 'PRIVATE' : 'PUBLIC', - tierName: transaction.tier?.name, - createdAt: transaction.order?.frequency === 'ONETIME' ? transaction.createdAt : transaction.order?.createdAt, + privacyLevel: sponseesMode ? 'PUBLIC' : (account.isIncognito ? 'PRIVATE' : 'PUBLIC'), + tierName: transaction.order?.tier?.name || transaction.tier?.name, + createdAt: sponseesMode + ? transaction.createdAt + : transaction.order?.frequency === 'ONETIME' + ? transaction.createdAt + : transaction.order?.createdAt, raw: transaction, } - return [transaction.fromAccount.id, sponsor] + return [account.id || slug, sponsor] } /** @@ -247,13 +293,16 @@ function makeTransactionsQuery( offset?: number, dateFrom?: Date, dateTo?: Date, + sponseesMode = false, ) { const accountQueryPartial = makeAccountQueryPartial(id, slug, githubHandle) const dateFromParam = dateFrom ? `, dateFrom: "${dateFrom.toISOString()}"` : '' const dateToParam = dateTo ? `, dateTo: "${dateTo.toISOString()}"` : '' + const type = sponseesMode ? 'DEBIT' : 'CREDIT' + const accountField = sponseesMode ? 'toAccount' : 'fromAccount' return graphql`{ account(${accountQueryPartial}) { - transactions(limit: 1000, offset:${offset}, type: CREDIT ${dateFromParam} ${dateToParam}) { + transactions(limit: 1000, offset:${offset}, type: ${type} ${dateFromParam} ${dateToParam}) { offset limit totalCount @@ -268,6 +317,7 @@ function makeTransactionsQuery( tier { name } + createdAt amount { value } @@ -276,7 +326,7 @@ function makeTransactionsQuery( amount { value } - fromAccount { + ${accountField} { name id slug @@ -320,7 +370,6 @@ function makeSubscriptionsQuery( totalDonations { value } - createdAt fromAccount { name id @@ -382,11 +431,13 @@ function getBestUrl(socialLinks: SocialLink[]): string | undefined { return urls[0] } +const RE_GITHUB_URL = /github\.com\/([^/]*)/ + function getSocialLogins(socialLinks: SocialLink[] = [], opencollectiveLogin: string): Record { const socialLogins: Record = {} for (const link of socialLinks) { if (link.type === 'GITHUB') { - const login = link.url.match(/github\.com\/([^/]*)/)?.[1] + const login = link.url.match(RE_GITHUB_URL)?.[1] if (login) socialLogins.github = login } diff --git a/src/providers/patreon.ts b/src/providers/patreon.ts index 9966fbb..f165915 100644 --- a/src/providers/patreon.ts +++ b/src/providers/patreon.ts @@ -4,6 +4,11 @@ import { $fetch } from 'ofetch' export const PatreonProvider: Provider = { name: 'patreon', fetchSponsors(config) { + if (config.mode === 'sponsees') { + console.warn('[contribkit] Patreon provider does not support `mode: "sponsees"` yet') + return Promise.resolve([]) + } + return fetchPatreonSponsors(config.patreon?.token || config.token!) }, } diff --git a/src/providers/polar.ts b/src/providers/polar.ts index 40919df..8ea687a 100644 --- a/src/providers/polar.ts +++ b/src/providers/polar.ts @@ -4,6 +4,11 @@ import { ofetch } from 'ofetch' export const PolarProvider: Provider = { name: 'polar', fetchSponsors(config) { + if (config.mode === 'sponsees') { + console.warn('[contribkit] Polar provider does not support `mode: "sponsees"` yet') + return Promise.resolve([]) + } + return fetchPolarSponsors(config.polar?.token || config.token!, config.polar?.organization) }, } diff --git a/src/run.ts b/src/run.ts index cb780f6..41f8e48 100644 --- a/src/run.ts +++ b/src/run.ts @@ -34,14 +34,19 @@ export async function run(inlineConfig?: ContribkitConfig, t = consola) { const fullConfig = await loadConfig(inlineConfig) const config = fullConfig as Required const dir = resolve(process.cwd(), config.outputDir) - const cacheFile = resolve(dir, config.cacheFile) + const cacheFile = resolve( + dir, + config.mode === 'sponsees' + ? config.cacheFileSponsees + : config.cacheFile, + ) const providers = resolveProviders(config.providers || guessProviders(config)) if (config.renders?.length) { const names = new Set() config.renders.forEach((renderOptions, idx) => { - const name = renderOptions.name || 'sponsors' + const name = renderOptions.name || fullConfig.name || fullConfig.mode if (names.has(name)) throw new Error(`Duplicate render name: ${name} at index ${idx}`) names.add(name) diff --git a/src/types.ts b/src/types.ts index 0aea45f..96392fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,7 @@ export type OutputFormat = typeof outputFormats[number] export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' | 'githubContributions' export type GitHubAccountType = 'user' | 'organization' +export type SponsorshipMode = 'sponsors' | 'sponsees' export interface ProvidersConfig { github?: { @@ -319,7 +320,7 @@ export interface ContribkitRenderOptions { /** * Name of exported files * - * @default 'sponsors' + * @default config.mode */ name?: string @@ -415,6 +416,15 @@ export interface ContribkitRenderOptions { } export interface ContribkitConfig extends ProvidersConfig, ContribkitRenderOptions { + /** + * Data mode: + * - `sponsors`: people sponsoring you + * - `sponsees`: people you have sponsored, including past sponsorships + * + * @default 'sponsors' + */ + mode?: SponsorshipMode + /** * @deprecated use `github.login` instead */ @@ -442,6 +452,13 @@ export interface ContribkitConfig extends ProvidersConfig, ContribkitRenderOptio */ cacheFile?: string + /** + * Path to cache file used when `mode` is `sponsees`. + * + * @default './contribkit/.cache.sponsees.json' + */ + cacheFileSponsees?: string + /** * Directory of output files. * diff --git a/tsconfig.json b/tsconfig.json index 44c2f05..fb3f9a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { - "target": "es2017", + "target": "esnext", "lib": ["esnext"], "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "paths": { "contribkit": [ "./src/index.ts"