Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions content/zh_tw/sessions/SessionsPageZh.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { data as submissions } from '#loaders/allSubmissions.zh-tw.data.ts'
</script>

<template>
<ol>
<li
v-for="submission in submissions"
:key="submission.code"
>
<ul>
<li>
標題:<a :href="`/sessions/${submission.code}`">
{{ submission.title }}
</a>
</li>

<li>
摘要:{{ submission.description }}
</li>

<li>
教室:{{ submission.room?.id }} {{ submission.room?.name }}
</li>

<li>
講者:{{ submission.speakers?.map((speaker) => speaker.name).join(', ') }}
</li>

<li>
議程軌:{{ submission.track?.id }} {{ submission.track?.name ?? '??' }}
</li>

<li>
Raw data:
<pre>{{ JSON.stringify(submission, null, 2) }}</pre>
</li>
</ul>
</li>
</ol>
</template>
5 changes: 5 additions & 0 deletions content/zh_tw/sessions/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script setup lang="ts">
import Page from './SessionsPageZh.vue'
</script>

<Page />
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default antfu(
// Ignore generated files
ignores: [
'.pnpm-store/', // Generated by CI workflow
'loaders/pretalx/oapi', // OpenAPI client generated by CI workflow
],
},
)
45 changes: 45 additions & 0 deletions loaders/allSubmissions.zh-tw.data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { LocalizedRoom, LocalizedSpeaker, LocalizedSubmission, LocalizedTrack } from './pretalx/types'
import { defineLoader } from 'vitepress'
import { extractLocalizedStructure, pretalxClient } from './pretalx'

export declare const data: SubmissionResponse[]

export interface SubmissionResponse extends Omit<LocalizedSubmission, 'room' | 'track' | 'speakers'> {
room?: LocalizedRoom
track?: LocalizedTrack
speakers?: LocalizedSpeaker[]
}

export default defineLoader({
async load(): Promise<typeof data> {
const submissions = await pretalxClient.getAllSubmissions()
const allRooms = toMapById(await pretalxClient.getRooms())
const allSpeakers = toMapByCode(await pretalxClient.getSpeakers())
const allTracks = toMapById(await pretalxClient.getTracks())

return submissions.map((submission) => {
const room = submission.room ? allRooms.get(submission.room) : undefined
const speakers = submission.speakers.map((speaker) => allSpeakers.get(speaker)).filter((speaker) => speaker !== undefined)
const track = submission.track ? allTracks.get(submission.track) : undefined

const localizedRoom = room ? extractLocalizedStructure(room, 'zh-tw') : undefined
const localizedSpeakers = extractLocalizedStructure(speakers, 'zh-tw')
const localizedTrack = track ? extractLocalizedStructure(track, 'zh-tw') : undefined

return ({
...extractLocalizedStructure(submission, 'zh-tw'),
room: localizedRoom,
speakers: localizedSpeakers,
track: localizedTrack,
} satisfies SubmissionResponse)
})
},
})

function toMapById<R extends { id: number }>(data: R[]): Map<number, R> {
return new Map(data.map((item) => [item.id, item]))
}

function toMapByCode<R extends { code: string }>(data: R[]): Map<string, R> {
return new Map(data.map((item) => [item.code, item]))
}
1 change: 1 addition & 0 deletions loaders/pretalx/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
oapi/
208 changes: 208 additions & 0 deletions loaders/pretalx/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import type { AnswerReadable, RoomReadable, RoomsListData, SpeakerReadable, SpeakersListData, SubmissionReadable, SubmissionsListData, SubmissionTypeReadable, SubmissionTypesListData, TalkSlotReadable, TrackReadable, TracksListData } from './oapi'
import type { MultiLingualString, OptionalMultiLingualString, Room, Speaker, Submission, Track } from './types'
import { BadServerSideDataException } from './exception'
import { createClient } from './oapi/client'
import { coscupSessionQuestionIdMap } from './pretalx-types'
import { formatMultiLingualString, getAnswer } from './utils'

interface PaginatedResponse<T> {
count: number
next: string | null
previous: string | null
results: T[]
}

export class PretalxApiClient {
#client: ReturnType<typeof createClient>
#year: number

constructor(
public readonly year: number,
token: string | undefined = undefined,
) {
this.#year = year
this.#client = createClient({
baseUrl: `https://pretalx.coscup.org`,
headers: {
...(token ? { Authorization: `Token ${token}` } : undefined),
'User-Agent': `coscup-website-client/${this.year}`,
'Pretalx-Version': 'v1',
},
})
}

get event() {
return `coscup-${this.#year}`
}

async #getPaginatedResources<T>(url: string): Promise<T[]> {
const resources: T[] = []
const baseUrl = this.#client.getConfig().baseUrl ?? ''
let next = url

while (true) {
const response = await this.#client.get<PaginatedResponse<T>>({
url: next,
})
if (!response.data) {
throw new BadServerSideDataException(`No data found for this URL: ${next}`)
}

resources.push(...response.data.results)

if (!response.data.next) {
break
}

next = response.data.next.replace(baseUrl, '')
}

return resources
}

async getRooms(): Promise<Room[]> {
const url = this.#client.buildUrl<RoomsListData>({
url: '/api/events/{event}/rooms/',
path: {
event: this.event,
},
query: {
limit: 100,
offset: 0,
},
})

const rooms = await this.#getPaginatedResources<RoomReadable>(url)

return rooms.map((room) => {
const name = formatMultiLingualString(room.name)
if (!name) {
throw new BadServerSideDataException(`Room ${room.id} has empty name.`)
}

return {
id: room.id,
name,
} satisfies Room
})
}

async getSpeakers(): Promise<Speaker[]> {
const url = this.#client.buildUrl<SpeakersListData>({
url: '/api/events/{event}/speakers/',
path: {
event: this.event,
},
query: {
page_size: 25,
page: 1,
},
})

const speakers = await this.#getPaginatedResources<SpeakerReadable>(url)

return speakers.map((speaker) => {
return {
code: speaker.code,
avatar: speaker.avatar_url ??
`https://avatar.iran.liara.run/username?username=${speaker.code}`,
name: speaker.name,
bio: speaker.biography ?? undefined,
} satisfies Speaker
})
}

async getTracks(): Promise<Track[]> {
const url = this.#client.buildUrl<TracksListData>({
url: '/api/events/{event}/tracks/',
path: {
event: this.event,
},
query: {
page_size: 25,
page: 1,
},
})

const pretalxTracks = await this.#getPaginatedResources<TrackReadable>(url)

return pretalxTracks.map((track) => ({
id: track.id,
name: formatMultiLingualString(track.name),
description: track.description ? formatMultiLingualString(track.description) : undefined,
} satisfies Track))
}

async getSubmissionsType(): Promise<Set<number>> {
const url = this.#client.buildUrl<SubmissionTypesListData>({
url: '/api/events/{event}/submission-types/',
path: {
event: this.event,
},
query: {
page_size: 25,
page: 1,
},
})

const submissionsTypes = await this.#getPaginatedResources<SubmissionTypeReadable>(url)
const submissionTypes = new Set<number>()

for (const submissionType of submissionsTypes) {
submissionTypes.add(submissionType.id)
}

return submissionTypes
}

async getSubmissionsOf(type: number): Promise<Submission[]> {
const url = this.#client.buildUrl<SubmissionsListData>({
url: '/api/events/{event}/submissions/',
path: {
event: this.event,
},
query: {
state: ['accepted', 'confirmed'],
expand: ['answers', 'slots'],
page_size: 25,
page: 1,
submission_type: type,
},
})

type ApiSubmissionResponse = Omit<SubmissionReadable, 'answers' | 'slots'> & {
answers: AnswerReadable[]
slots: TalkSlotReadable[]
}
const submissions = await this.#getPaginatedResources<ApiSubmissionResponse>(url)

return submissions.map((submission) => {
const enTitle = getAnswer(submission.answers, coscupSessionQuestionIdMap.EnTitle)
const enDesc = getAnswer(submission.answers, coscupSessionQuestionIdMap.EnDesc)

return {
code: submission.code,
title: {
'zh-tw': submission.title,
'en': enTitle ?? submission.title,
} satisfies MultiLingualString,
description: {
'zh-tw': submission.description ?? undefined,
'en': enDesc ?? submission.description ?? undefined,
} satisfies OptionalMultiLingualString,
speakers: submission.speakers,
track: submission.track ?? undefined,
room: submission.slots[0]?.room ?? undefined,
} satisfies Submission
})
}

async getAllSubmissions(): Promise<Submission[]> {
const submissionTypes = await this.getSubmissionsType()
const submissions = await Promise.all(
[...submissionTypes].map((type) => this.getSubmissionsOf(type)),
)

return submissions.flat()
}
}
6 changes: 6 additions & 0 deletions loaders/pretalx/exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class BadServerSideDataException extends Error {
constructor(message: string) {
super(message)
this.name = BadServerSideDataException.name
}
}
12 changes: 12 additions & 0 deletions loaders/pretalx/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { PretalxApiClient } from './client'

export * from './client'
export * from './exception'
export * from './pretalx-types'
export * from './types'
export * from './utils'

export const pretalxClient = new PretalxApiClient(
2025,
process.env.PRETALX_TOKEN,
)
25 changes: 25 additions & 0 deletions loaders/pretalx/pretalx-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { AnswerReadable, RoomReadable, SpeakerReadable, SubmissionReadable, TrackReadable } from './oapi'

export const coscupSpeakerQuestionIdMap = {
ZhName: 45,
EnName: 46,
ZhBio: 47,
EnBio: 48,
} as const

export type PretalxAnswer = AnswerReadable
export type PretalxTalk = SubmissionReadable
export type PretalxSpeaker = SpeakerReadable
export type PretalxRoom = RoomReadable
export type PretalxTrack = TrackReadable

export const coscupSessionQuestionIdMap = {
Language: 216,
ZhDesc: 0,
EnTitle: 257, // 翻譯成英文的標題
EnDesc: 259, // 翻譯成英文的摘要
Tags: 220,
Qa: 0,
Slide: 0,
Record: 2098,
} as const
Loading