├── apps ├── desktop │ ├── src │ │ ├── vite-env.d.ts │ │ ├── components │ │ │ ├── ui │ │ │ │ ├── tabs.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── field.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ ├── text.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── radio-group.tsx │ │ │ │ ├── icon-button.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── styled │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── spinner.tsx │ │ │ │ │ ├── text.tsx │ │ │ │ │ ├── icon-button.tsx │ │ │ │ │ ├── toast.tsx │ │ │ │ │ ├── tabs.tsx │ │ │ │ │ ├── tooltip.tsx │ │ │ │ │ ├── checkbox.tsx │ │ │ │ │ ├── field.tsx │ │ │ │ │ ├── radio-group.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ ├── progress.tsx │ │ │ │ │ ├── number-input.tsx │ │ │ │ │ └── popover.tsx │ │ │ │ ├── spinner.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── number-input.tsx │ │ │ │ └── tree-view.tsx │ │ │ └── providers │ │ │ │ ├── jotai.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tanstack-router.tsx │ │ │ │ └── theme.tsx │ │ ├── utils │ │ │ ├── types.ts │ │ │ ├── store.ts │ │ │ ├── cache.ts │ │ │ └── atom.ts │ │ ├── recipes │ │ │ ├── index.ts │ │ │ ├── progress.ts │ │ │ └── checkbox.ts │ │ ├── index.css │ │ ├── features │ │ │ ├── settings │ │ │ │ ├── atoms │ │ │ │ │ ├── theme.ts │ │ │ │ │ └── settings.ts │ │ │ │ ├── services │ │ │ │ │ ├── purge-index.ts │ │ │ │ │ └── settings.ts │ │ │ │ ├── schemas │ │ │ │ │ └── settings.ts │ │ │ │ ├── pages │ │ │ │ │ └── settings.tsx │ │ │ │ └── components │ │ │ │ │ └── dir-selector.tsx │ │ │ ├── course │ │ │ │ ├── atoms │ │ │ │ │ ├── year.ts │ │ │ │ │ ├── course.ts │ │ │ │ │ ├── page.ts │ │ │ │ │ └── lecture.ts │ │ │ │ ├── schemas │ │ │ │ │ ├── course.ts │ │ │ │ │ ├── page.ts │ │ │ │ │ └── lecture.ts │ │ │ │ ├── services │ │ │ │ │ ├── courses.ts │ │ │ │ │ ├── pages.ts │ │ │ │ │ └── lectures.ts │ │ │ │ ├── components │ │ │ │ │ ├── page-list.tsx │ │ │ │ │ ├── course-list.tsx │ │ │ │ │ ├── column.tsx │ │ │ │ │ ├── lecture-list.tsx │ │ │ │ │ ├── year-select.tsx │ │ │ │ │ └── list-item.tsx │ │ │ │ └── pages │ │ │ │ │ └── course.tsx │ │ │ ├── download │ │ │ │ ├── services │ │ │ │ │ └── download-slides.ts │ │ │ │ ├── components │ │ │ │ │ ├── empty.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── completed.tsx │ │ │ │ │ └── in-queue.tsx │ │ │ │ ├── pages │ │ │ │ │ └── download.tsx │ │ │ │ └── atoms │ │ │ │ │ └── queue.ts │ │ │ ├── auth │ │ │ │ ├── atoms │ │ │ │ │ └── authenticated.ts │ │ │ │ └── pages │ │ │ │ │ └── login.tsx │ │ │ └── search │ │ │ │ ├── pages │ │ │ │ └── search.tsx │ │ │ │ ├── services │ │ │ │ └── search.ts │ │ │ │ ├── components │ │ │ │ └── search-results.tsx │ │ │ │ └── atoms │ │ │ │ └── search.ts │ │ ├── command │ │ │ ├── purge-index.ts │ │ │ ├── get-archive-years.ts │ │ │ ├── get-credential.ts │ │ │ ├── login.ts │ │ │ ├── get-courses.ts │ │ │ ├── download-slides.ts │ │ │ ├── get-recorded-courses.ts │ │ │ ├── get-pages.ts │ │ │ ├── get-lectures.ts │ │ │ ├── utils.ts │ │ │ └── search-slides.ts │ │ ├── routes │ │ │ ├── login.tsx │ │ │ ├── _authenticated │ │ │ │ ├── index.tsx │ │ │ │ ├── search.tsx │ │ │ │ ├── download.tsx │ │ │ │ └── settings.tsx │ │ │ ├── __root.tsx │ │ │ └── _authenticated.tsx │ │ └── main.tsx │ ├── postcss.config.cjs │ ├── src-tauri │ │ ├── icons │ │ │ ├── icon.ico │ │ │ ├── icon.png │ │ │ ├── 128x128.png │ │ │ ├── 32x32.png │ │ │ ├── icon.icns │ │ │ ├── StoreLogo.png │ │ │ ├── 128x128@2x.png │ │ │ ├── Square107x107Logo.png │ │ │ ├── Square142x142Logo.png │ │ │ ├── Square150x150Logo.png │ │ │ ├── Square284x284Logo.png │ │ │ ├── Square30x30Logo.png │ │ │ ├── Square310x310Logo.png │ │ │ ├── Square44x44Logo.png │ │ │ ├── Square71x71Logo.png │ │ │ └── Square89x89Logo.png │ │ ├── .gitignore │ │ ├── src │ │ │ ├── main.rs │ │ │ ├── search │ │ │ │ ├── mod.rs │ │ │ │ ├── types.rs │ │ │ │ ├── query.rs │ │ │ │ ├── index.rs │ │ │ │ └── highlighter.rs │ │ │ ├── command │ │ │ │ ├── get_credential.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── get_archive_years.rs │ │ │ │ ├── purge_index.rs │ │ │ │ ├── get_recorded_courses.rs │ │ │ │ ├── get_courses.rs │ │ │ │ ├── login.rs │ │ │ │ ├── get_pages.rs │ │ │ │ └── get_lectures.rs │ │ │ ├── state.rs │ │ │ ├── lib.rs │ │ │ └── db │ │ │ │ ├── mod.rs │ │ │ │ └── migrator.rs │ │ ├── capabilities │ │ │ ├── desktop.json │ │ │ └── default.json │ │ ├── migrations │ │ │ ├── meta │ │ │ │ └── _journal.json │ │ │ └── 0000_superb_monster_badoon.sql │ │ ├── tauri.conf.json │ │ └── Cargo.toml │ ├── tsconfig.json │ ├── park-ui.json │ ├── index.html │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ ├── panda.config.ts │ ├── vite.config.ts │ ├── package.json │ └── .gitignore ├── website │ ├── postcss.config.mjs │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── framework │ │ │ ├── shared.tsx │ │ │ ├── entry.ssr.tsx │ │ │ └── entry.rsc.tsx │ │ ├── types │ │ │ └── release.ts │ │ ├── index.css │ │ ├── utils │ │ │ ├── get-release.ts │ │ │ └── detect-os.ts │ │ ├── components │ │ │ └── download-button.tsx │ │ └── window.d.ts │ ├── README.md │ ├── biome.jsonc │ ├── tsconfig.json │ ├── package.json │ └── vite.config.ts ├── merge │ └── Cargo.toml └── cli │ └── Cargo.toml ├── src ├── domain │ ├── mod.rs │ ├── repository │ │ ├── mod.rs │ │ ├── page.rs │ │ ├── slide.rs │ │ ├── lecture.rs │ │ ├── course.rs │ │ └── auth.rs │ ├── service │ │ ├── mod.rs │ │ ├── page.rs │ │ ├── course.rs │ │ ├── auth.rs │ │ ├── lecture.rs │ │ └── slide.rs │ └── models │ │ ├── mod.rs │ │ └── credentials.rs ├── repository │ └── mod.rs ├── service │ ├── mod.rs │ ├── auth.rs │ ├── page.rs │ ├── slide.rs │ ├── course.rs │ └── lecture.rs ├── pdf │ ├── error.rs │ └── mime.rs └── utils.rs ├── assets ├── desktop-main.png └── desktop-search.png ├── clippy.toml ├── pnpm-workspace.yaml ├── .editorconfig ├── package.json ├── .gitignore ├── LICENSE ├── Cargo.toml ├── biome.jsonc └── .github └── workflows ├── publish-cli.yml ├── publish-app.yml ├── ci.yml └── pages.yml /apps/desktop/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | export * as Tabs from "./styled/tabs"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type MaybePromise = V | Promise; 2 | -------------------------------------------------------------------------------- /src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod models; 2 | pub mod repository; 3 | pub mod service; 4 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | export * as Dialog from "./styled/dialog"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/field.tsx: -------------------------------------------------------------------------------- 1 | export * as Field from "./styled/field"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | export * as Toast from "./styled/toast"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | export * as Popover from "./styled/popover"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | export * as Tooltip from "./styled/tooltip"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/text.tsx: -------------------------------------------------------------------------------- 1 | export { Text, type TextProps } from "./styled/text"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | export { Input, type InputProps } from "./styled/input"; 2 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | export * as RadioGroup from "./styled/radio-group"; 2 | -------------------------------------------------------------------------------- /assets/desktop-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/assets/desktop-main.png -------------------------------------------------------------------------------- /assets/desktop-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/assets/desktop-search.png -------------------------------------------------------------------------------- /src/repository/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod course; 3 | pub mod lecture; 4 | pub mod page; 5 | pub mod slide; 6 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/icon-button.tsx: -------------------------------------------------------------------------------- 1 | export { IconButton, type IconButtonProps } from "./styled/icon-button"; 2 | -------------------------------------------------------------------------------- /apps/website/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/website/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/website/public/favicon.ico -------------------------------------------------------------------------------- /apps/desktop/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@pandacss/dev/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | allow-unwrap-in-tests = true 2 | allow-expect-in-tests = true 3 | allow-panic-in-tests = true 4 | allow-print-in-tests = true 5 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/* 3 | 4 | patchedDependencies: 5 | '@park-ui/panda-preset': patches/@park-ui__panda-preset.patch 6 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | export { createListCollection } from "@ark-ui/react/select"; 2 | export * as Select from "./styled/select"; 3 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /apps/desktop/src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yu7400ki/moocs-collect/HEAD/apps/desktop/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /apps/desktop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /apps/desktop/park-ui.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://park-ui.com/registry/latest/schema.json", 3 | "jsFramework": "react", 4 | "outputPath": "./src/components/ui" 5 | } 6 | -------------------------------------------------------------------------------- /apps/website/src/framework/shared.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | export const RSC_POSTFIX = "_.rsc"; 4 | 5 | export type RscPayload = { 6 | root: React.ReactNode; 7 | }; 8 | -------------------------------------------------------------------------------- /apps/desktop/src/recipes/index.ts: -------------------------------------------------------------------------------- 1 | import { checkbox } from "./checkbox"; 2 | import { progress } from "./progress"; 3 | 4 | export const slotRecipes = { 5 | checkbox, 6 | progress, 7 | }; 8 | -------------------------------------------------------------------------------- /apps/desktop/src/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { load } from "@tauri-apps/plugin-store"; 2 | import { memoizeAsync } from "./cache"; 3 | 4 | export const getStore = memoizeAsync(() => load("store.json")); 5 | -------------------------------------------------------------------------------- /apps/desktop/src/index.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; 2 | 3 | @layer base { 4 | html, 5 | body, 6 | #root { 7 | height: 100%; 8 | overflow: hidden; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/desktop/src/features/settings/atoms/theme.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { settingsAtom } from "./settings"; 3 | 4 | export const themeAtom = atom((get) => get(settingsAtom)?.theme ?? "system"); 5 | -------------------------------------------------------------------------------- /src/service/mod.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | mod course; 3 | mod lecture; 4 | mod page; 5 | mod slide; 6 | 7 | pub use auth::*; 8 | pub use course::*; 9 | pub use lecture::*; 10 | pub use page::*; 11 | pub use slide::*; 12 | -------------------------------------------------------------------------------- /apps/desktop/src/features/settings/services/purge-index.ts: -------------------------------------------------------------------------------- 1 | import { purgeIndex as purgeIndexCommand } from "@/command/purge-index"; 2 | 3 | export async function purgeIndex() { 4 | return await purgeIndexCommand(); 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/repository/mod.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | mod course; 3 | mod lecture; 4 | mod page; 5 | mod slide; 6 | 7 | pub use auth::*; 8 | pub use course::*; 9 | pub use lecture::*; 10 | pub use page::*; 11 | pub use slide::*; 12 | -------------------------------------------------------------------------------- /src/domain/service/mod.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | mod course; 3 | mod lecture; 4 | mod page; 5 | mod slide; 6 | 7 | pub use auth::*; 8 | pub use course::*; 9 | pub use lecture::*; 10 | pub use page::*; 11 | pub use slide::*; 12 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | app_lib::run(); 6 | } 7 | -------------------------------------------------------------------------------- /apps/desktop/src/command/purge-index.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = undefined; 4 | 5 | export type Output = undefined; 6 | 7 | export const purgeIndex = createCommand("purge_index"); 8 | -------------------------------------------------------------------------------- /apps/desktop/src/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { Login } from "@/features/auth/pages/login"; 3 | 4 | export const Route = createFileRoute("/login")({ 5 | component: Login, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/search/mod.rs: -------------------------------------------------------------------------------- 1 | mod analyzers; 2 | mod filter; 3 | mod highlighter; 4 | mod index; 5 | mod query; 6 | mod schema; 7 | mod service; 8 | pub mod types; 9 | 10 | pub use service::{SearchError, SearchService}; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_size = 2 9 | indent_style = space 10 | 11 | [*.{rs,toml}] 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /apps/desktop/src/command/get-archive-years.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = undefined; 4 | 5 | export type Output = number[]; 6 | 7 | export const getArchiveYears = createCommand("get_archive_years"); 8 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": ["macOS", "windows", "linux"], 4 | "windows": ["main"], 5 | "permissions": ["updater:default", "process:default", "process:allow-restart"] 6 | } 7 | -------------------------------------------------------------------------------- /apps/website/README.md: -------------------------------------------------------------------------------- 1 | # SSG + MDX example 2 | 3 | This example demonstrates: 4 | 5 | - Client component inside MDX 6 | - MDX HMR 7 | - Static site generation 8 | 9 | ## usage 10 | 11 | ```js 12 | pnpm dev 13 | pnpm build 14 | pnpm preview 15 | ``` 16 | -------------------------------------------------------------------------------- /apps/desktop/src/routes/_authenticated/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { CoursePage } from "@/features/course/pages/course"; 3 | 4 | export const Route = createFileRoute("/_authenticated/")({ 5 | component: CoursePage, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/desktop/src/routes/_authenticated/search.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { SearchPage } from "@/features/search/pages/search"; 3 | 4 | export const Route = createFileRoute("/_authenticated/search")({ 5 | component: SearchPage, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/website/src/types/release.ts: -------------------------------------------------------------------------------- 1 | export interface Platform { 2 | signature: string; 3 | url: string; 4 | } 5 | 6 | export interface LatestRelease { 7 | version: string; 8 | notes: string; 9 | pub_date: string; 10 | platforms: Record; 11 | } 12 | -------------------------------------------------------------------------------- /apps/desktop/src/command/get-credential.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = { 4 | username: string; 5 | }; 6 | 7 | export type Output = string | undefined; 8 | 9 | export const getCredential = createCommand("get_credential"); 10 | -------------------------------------------------------------------------------- /apps/desktop/src/routes/_authenticated/download.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { DownloadPage } from "@/features/download/pages/download"; 3 | 4 | export const Route = createFileRoute("/_authenticated/download")({ 5 | component: DownloadPage, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/desktop/src/routes/_authenticated/settings.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { SettingsPage } from "@/features/settings/pages/settings"; 3 | 4 | export const Route = createFileRoute("/_authenticated/settings")({ 5 | component: SettingsPage, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/website/biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/@biomejs/biome/configuration_schema.json", 3 | "root": false, 4 | "extends": "//", 5 | "linter": { 6 | "rules": { 7 | "suspicious": { 8 | "noExplicitAny": "off" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/desktop/src/command/login.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = { 4 | username: string; 5 | password: string; 6 | remember: boolean; 7 | }; 8 | 9 | export type Output = boolean; 10 | 11 | export const login = createCommand("login"); 12 | -------------------------------------------------------------------------------- /src/domain/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod course; 2 | mod credentials; 3 | mod keys; 4 | mod lecture; 5 | mod page; 6 | mod slide; 7 | mod urls; 8 | 9 | pub use course::*; 10 | pub use credentials::*; 11 | pub use keys::*; 12 | pub use lecture::*; 13 | pub use page::*; 14 | pub use slide::*; 15 | pub use urls::*; 16 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1758043275560, 9 | "tag": "0000_superb_monster_badoon", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/desktop/src/command/get-courses.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = { 4 | year?: number; 5 | }; 6 | 7 | export type Output = { 8 | year: number; 9 | slug: string; 10 | name: string; 11 | }[]; 12 | 13 | export const getCourses = createCommand("get_courses"); 14 | -------------------------------------------------------------------------------- /apps/desktop/src/features/settings/schemas/settings.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const settingsSchema = z.object({ 4 | version: z.literal(1).default(1), 5 | theme: z.enum(["system", "light", "dark"]), 6 | downloadDir: z.string(), 7 | }); 8 | 9 | export type Settings = z.infer; 10 | -------------------------------------------------------------------------------- /apps/desktop/src/components/providers/jotai.tsx: -------------------------------------------------------------------------------- 1 | import { createStore, Provider } from "jotai"; 2 | 3 | export const store = createStore(); 4 | 5 | type Props = { 6 | children?: React.ReactNode; 7 | }; 8 | 9 | export function JotaiProvider({ children }: Props) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/input.tsx: -------------------------------------------------------------------------------- 1 | import { ark } from "@ark-ui/react/factory"; 2 | import { styled } from "styled-system/jsx"; 3 | import { input } from "styled-system/recipes"; 4 | import type { ComponentProps } from "styled-system/types"; 5 | 6 | export type InputProps = ComponentProps; 7 | export const Input = styled(ark.input, input); 8 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/button.tsx: -------------------------------------------------------------------------------- 1 | import { ark } from "@ark-ui/react/factory"; 2 | import { styled } from "styled-system/jsx"; 3 | import { button } from "styled-system/recipes"; 4 | import type { ComponentProps } from "styled-system/types"; 5 | 6 | export type ButtonProps = ComponentProps; 7 | export const Button = styled(ark.button, button); 8 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { ark } from "@ark-ui/react/factory"; 2 | import { styled } from "styled-system/jsx"; 3 | import { spinner } from "styled-system/recipes"; 4 | import type { ComponentProps } from "styled-system/types"; 5 | 6 | export type SpinnerProps = ComponentProps; 7 | export const Spinner = styled(ark.div, spinner); 8 | -------------------------------------------------------------------------------- /apps/merge/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mcmerge" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | name = "mcmerge" 8 | path = "src/main.rs" 9 | 10 | [dependencies] 11 | clap = { version = "4.5", features = ["derive"] } 12 | lopdf = "0.35" 13 | anyhow = "1.0" 14 | regex = "1.11" 15 | walkdir = "2.5" 16 | dialoguer = "0.11" 17 | indicatif = "0.17" 18 | -------------------------------------------------------------------------------- /apps/desktop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | moocs collect 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/desktop/src/command/download-slides.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = { 4 | params: { 5 | year: number; 6 | courseSlug: string; 7 | lectureSlug: string; 8 | pageSlug: string; 9 | }; 10 | }; 11 | 12 | export type Output = string | undefined; 13 | 14 | export const downloadSlides = createCommand("download_slides"); 15 | -------------------------------------------------------------------------------- /apps/website/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @layer base { 4 | html { 5 | font-size: 14px; 6 | } 7 | 8 | @media (width >= 40rem) { 9 | html { 10 | font-size: 16px; 11 | } 12 | } 13 | 14 | body { 15 | font-family: 16 | "Hiragino Kaku Gothic ProN", "Hiragino Sans", "Noto Sans JP", 17 | "BIZ UDPGothic", "Meiryo", sans-serif; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/get_credential.rs: -------------------------------------------------------------------------------- 1 | use keyring::Entry; 2 | 3 | #[tauri::command] 4 | pub async fn get_credential(username: String) -> Result, ()> { 5 | if username.is_empty() { 6 | return Ok(None); 7 | } 8 | let entry = Entry::new("me.yu7400ki.moocs-collect", &username).map_err(|_| ())?; 9 | let password = entry.get_password().ok(); 10 | Ok(password) 11 | } 12 | -------------------------------------------------------------------------------- /apps/desktop/src/command/get-recorded-courses.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = undefined; 4 | 5 | export type RecordedCourse = { 6 | year: number; 7 | slug: string; 8 | name: string; 9 | sortIndex: number; 10 | }; 11 | 12 | export type Output = RecordedCourse[]; 13 | 14 | export const getRecordedCourses = createCommand( 15 | "get_recorded_courses", 16 | ); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moocs-collect", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "commonjs", 6 | "scripts": { 7 | "lint": "biome check", 8 | "format": "run-p format:*", 9 | "format:biome": "biome check --write", 10 | "format:cargo": "cargo fmt --all" 11 | }, 12 | "devDependencies": { 13 | "@biomejs/biome": "2.2.4", 14 | "npm-run-all2": "^8.0.4" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/desktop/src/command/get-pages.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = { 4 | year: number; 5 | courseSlug: string; 6 | lectureSlug: string; 7 | }; 8 | 9 | export type Output = { 10 | year: number; 11 | courseSlug: string; 12 | lectureSlug: string; 13 | slug: string; 14 | name: string; 15 | }[]; 16 | 17 | export const getPages = createCommand("get_pages"); 18 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/text.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "styled-system/jsx"; 2 | import { type TextVariantProps, text } from "styled-system/recipes"; 3 | import type { ComponentProps, StyledComponent } from "styled-system/types"; 4 | 5 | type ParagraphProps = TextVariantProps & { as?: React.ElementType }; 6 | 7 | export type TextProps = ComponentProps; 8 | export const Text = styled("p", text) as StyledComponent<"p", ParagraphProps>; 9 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/atoms/year.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { getArchiveYears } from "@/command/get-archive-years"; 3 | import { unwrapPromise } from "@/utils/atom"; 4 | 5 | const internalAvailableYearsAtom = atom(async () => { 6 | return await getArchiveYears(); 7 | }); 8 | 9 | export const availableYearsAtom = unwrapPromise(internalAvailableYearsAtom); 10 | 11 | export const yearAtom = atom(undefined); 12 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/schemas/course.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const courseSchema = z.object({ 4 | year: z.number(), 5 | slug: z.string().brand("CourseSlug"), 6 | name: z.string(), 7 | }); 8 | 9 | export function castCourseSlug( 10 | slug: string, 11 | ): z.infer["slug"] { 12 | return slug as z.infer["slug"]; 13 | } 14 | 15 | export type Course = z.infer; 16 | -------------------------------------------------------------------------------- /apps/desktop/src/components/providers/index.tsx: -------------------------------------------------------------------------------- 1 | import { JotaiProvider } from "./jotai"; 2 | import { TanstackRouterProvider } from "./tanstack-router"; 3 | import { ThemeProvider } from "./theme"; 4 | import { Updater } from "./updater"; 5 | 6 | export function Providers() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { ark } from "@ark-ui/react/factory"; 2 | import { styled } from "styled-system/jsx"; 3 | import { type ButtonVariantProps, button } from "styled-system/recipes"; 4 | import type { ComponentProps } from "styled-system/types"; 5 | 6 | export type IconButtonProps = ComponentProps; 7 | export const IconButton = styled(ark.button, button, { 8 | defaultProps: { px: "0" } as ButtonVariantProps, 9 | }); 10 | -------------------------------------------------------------------------------- /apps/desktop/src/features/settings/pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "styled-system/css"; 2 | import { SettingsForm } from "../components/settings-form"; 3 | 4 | export function SettingsPage() { 5 | return ( 6 |
7 |

13 | 設定 14 |

15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/desktop/src/features/download/services/download-slides.ts: -------------------------------------------------------------------------------- 1 | import { downloadSlides as downloadSlidesCommand } from "@/command/download-slides"; 2 | import type { Page } from "@/features/course/schemas/page"; 3 | 4 | export async function downloadSlides(page: Page) { 5 | return await downloadSlidesCommand({ 6 | params: { 7 | year: page.year, 8 | courseSlug: page.courseSlug, 9 | lectureSlug: page.lectureSlug, 10 | pageSlug: page.slug, 11 | }, 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /apps/desktop/src/features/auth/atoms/authenticated.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from "jotai"; 2 | 3 | export type AuthState = true | false | "offline"; 4 | 5 | export const authenticatedAtom = atom(false); 6 | 7 | export function useAuth() { 8 | const [auth, setAuth] = useAtom(authenticatedAtom); 9 | 10 | const login = () => setAuth(true); 11 | const logout = () => setAuth(false); 12 | const goOffline = () => setAuth("offline"); 13 | 14 | return { auth, login, logout, goOffline }; 15 | } 16 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/schemas/page.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const pageSchema = z.object({ 4 | year: z.number(), 5 | courseSlug: z.string().brand("CourseSlug"), 6 | lectureSlug: z.string().brand("LectureSlug"), 7 | slug: z.string().brand("PageSlug"), 8 | name: z.string(), 9 | }); 10 | 11 | export function castPageSlug(slug: string): z.infer["slug"] { 12 | return slug as z.infer["slug"]; 13 | } 14 | 15 | export type Page = z.infer; 16 | -------------------------------------------------------------------------------- /src/domain/service/page.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::{LectureKey, LecturePage, PageKey}; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Page service trait for business logic operations 6 | #[async_trait] 7 | pub trait PageService: Send + Sync { 8 | /// Get pages for a specific lecture 9 | async fn get_pages(&self, lecture_key: &LectureKey) -> Result>; 10 | 11 | /// Get a specific page by its key 12 | async fn get_page(&self, page_key: &PageKey) -> Result; 13 | } 14 | -------------------------------------------------------------------------------- /src/domain/repository/page.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::{LectureKey, LecturePage, PageKey}; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Repository trait for page data access 6 | #[async_trait] 7 | pub trait PageRepository: Send + Sync { 8 | /// Fetch pages for a given lecture 9 | async fn fetch_pages(&self, lecture_key: &LectureKey) -> Result>; 10 | 11 | /// Fetch a specific page by key 12 | async fn fetch_page(&self, page_key: &PageKey) -> Result>; 13 | } 14 | -------------------------------------------------------------------------------- /src/domain/repository/slide.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::{PageKey, Slide, SlideContent}; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Repository trait for slide data access 6 | #[async_trait] 7 | pub trait SlideRepository: Send + Sync { 8 | /// Fetch slides for a given page 9 | async fn fetch_slides(&self, page_key: &PageKey) -> Result>; 10 | 11 | /// Fetch slide content (SVGs) for a given slide 12 | async fn fetch_slide_content(&self, slide: &Slide) -> Result; 13 | } 14 | -------------------------------------------------------------------------------- /apps/desktop/src/command/get-lectures.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type Args = { 4 | year: number; 5 | courseSlug: string; 6 | }; 7 | 8 | export type Lecture = { 9 | year: number; 10 | courseSlug: string; 11 | slug: string; 12 | name: string; 13 | index: number; 14 | }; 15 | 16 | export type Output = { 17 | year: number; 18 | courseSlug: string; 19 | name: string; 20 | lectures: Lecture[]; 21 | index: number; 22 | }[]; 23 | 24 | export const getLectures = createCommand("get_lectures"); 25 | -------------------------------------------------------------------------------- /apps/cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "collect-cli" 3 | version = "1.0.1" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | collect = { path = "../.." } 8 | anyhow = { workspace = true } 9 | reqwest = { workspace = true } 10 | tokio = { workspace = true } 11 | futures = { workspace = true } 12 | rayon = { workspace = true } 13 | clap = { version = "4.5.4", features = ["derive"] } 14 | dialoguer = "0.11.0" 15 | indicatif = "0.17.8" 16 | keyring = { version = "3.6.2", features = [ 17 | "apple-native", 18 | "windows-native", 19 | "sync-secret-service", 20 | ] } 21 | -------------------------------------------------------------------------------- /apps/website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "erasableSyntaxOnly": true, 4 | "allowImportingTsExtensions": true, 5 | "strict": true, 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "skipLibCheck": true, 9 | "verbatimModuleSyntax": true, 10 | "noEmit": true, 11 | "moduleResolution": "Bundler", 12 | "module": "ESNext", 13 | "target": "ESNext", 14 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 15 | "types": ["vite/client", "@vitejs/plugin-rsc/types"], 16 | "jsx": "react-jsx" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/domain/repository/lecture.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::{CourseKey, Lecture, LectureGroup, LectureKey}; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Repository trait for lecture data access 6 | #[async_trait] 7 | pub trait LectureRepository: Send + Sync { 8 | /// Fetch lecture groups for a given course 9 | async fn fetch_lecture_groups(&self, course_key: &CourseKey) -> Result>; 10 | 11 | /// Fetch a specific lecture by key 12 | async fn fetch_lecture(&self, lecture_key: &LectureKey) -> Result>; 13 | } 14 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "store:default", 9 | "dialog:default", 10 | "process:default", 11 | "process:allow-restart", 12 | "opener:allow-open-path", 13 | { 14 | "identifier": "opener:allow-open-path", 15 | "allow": [ 16 | { 17 | "path": "**/*.pdf" 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod download_slides; 2 | pub mod get_archive_years; 3 | pub mod get_courses; 4 | pub mod get_credential; 5 | pub mod get_lectures; 6 | pub mod get_pages; 7 | pub mod get_recorded_courses; 8 | pub mod login; 9 | pub mod purge_index; 10 | pub mod search_slides; 11 | 12 | pub use download_slides::*; 13 | pub use get_archive_years::*; 14 | pub use get_courses::*; 15 | pub use get_credential::*; 16 | pub use get_lectures::*; 17 | pub use get_pages::*; 18 | pub use get_recorded_courses::*; 19 | pub use login::*; 20 | pub use purge_index::*; 21 | pub use search_slides::*; 22 | -------------------------------------------------------------------------------- /src/domain/repository/course.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::{Course, CourseKey, Year}; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | #[async_trait] 6 | pub trait CourseRepository: Send + Sync { 7 | /// Fetch list of courses for a given year 8 | async fn fetch_course_list(&self, year: Option) -> Result>; 9 | 10 | /// Fetch course details by key 11 | async fn fetch_course(&self, course_key: &CourseKey) -> Result>; 12 | 13 | /// Fetch list of available archive years 14 | async fn fetch_archive_years(&self) -> Result>; 15 | } 16 | -------------------------------------------------------------------------------- /apps/desktop/src/command/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type InvokeArgs, 3 | type InvokeOptions, 4 | invoke, 5 | } from "@tauri-apps/api/core"; 6 | 7 | type Command = Args extends undefined 8 | ? (args?: Args, options?: InvokeOptions) => Promise 9 | : (args: Args, options?: InvokeOptions) => Promise; 10 | 11 | export function createCommand( 12 | command: string, 13 | ): Command { 14 | return (async (input?: Args, options?: InvokeOptions) => { 15 | return await invoke(command, input, options); 16 | }) as Command; 17 | } 18 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/services/courses.ts: -------------------------------------------------------------------------------- 1 | import { getCourses as getCourseCommand } from "@/command/get-courses"; 2 | import { memoizeAsync } from "@/utils/cache"; 3 | import { type Course, courseSchema } from "../schemas/course"; 4 | 5 | export function uniqueKey(course: Course) { 6 | return `${course.year}-${course.slug}`; 7 | } 8 | 9 | async function _getCourses(args: { year?: number } = {}) { 10 | const courses = await getCourseCommand(args); 11 | return courses.map((course) => courseSchema.parse(course)); 12 | } 13 | 14 | export const getCourses = memoizeAsync(_getCourses, { 15 | getCacheKey: (args) => args?.year, 16 | }); 17 | -------------------------------------------------------------------------------- /apps/desktop/src/features/download/components/empty.tsx: -------------------------------------------------------------------------------- 1 | import { PackageOpenIcon } from "lucide-react"; 2 | import { css } from "styled-system/css"; 3 | 4 | export function Empty() { 5 | return ( 6 |
16 |

21 | ここにはまだ何もありません 22 |

23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/domain/service/course.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::{Course, CourseKey, Year}; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Course service trait for business logic operations 6 | #[async_trait] 7 | pub trait CourseService: Send + Sync { 8 | /// Get list of courses for a specific year 9 | async fn get_courses(&self, year: Option) -> Result>; 10 | 11 | /// Get a specific course by its key 12 | async fn get_course(&self, course_key: &CourseKey) -> Result; 13 | 14 | /// Get list of available archive years 15 | async fn get_archive_years(&self) -> Result>; 16 | } 17 | -------------------------------------------------------------------------------- /src/domain/service/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::Credentials; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Authentication service trait 6 | #[async_trait] 7 | pub trait AuthenticationService: Send + Sync { 8 | /// Login to MOOCs system 9 | async fn login_moocs(&self, credentials: &Credentials) -> Result<()>; 10 | 11 | /// Login to Google SAML system 12 | async fn login_google(&self, credentials: &Credentials) -> Result<()>; 13 | 14 | /// Check if logged into MOOCs 15 | async fn is_logged_in_moocs(&self) -> Result; 16 | 17 | /// Check if logged into Google 18 | async fn is_logged_in_google(&self) -> Result; 19 | } 20 | -------------------------------------------------------------------------------- /src/domain/repository/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::Credentials; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Authentication repository trait 6 | #[async_trait] 7 | pub trait AuthenticationRepository: Send + Sync { 8 | /// Login to MOOCs system 9 | async fn login_moocs(&self, credentials: &Credentials) -> Result<()>; 10 | 11 | /// Login to Google SAML system 12 | async fn login_google(&self, credentials: &Credentials) -> Result<()>; 13 | 14 | /// Check if logged into MOOCs 15 | async fn is_logged_in_moocs(&self) -> Result; 16 | 17 | /// Check if logged into Google 18 | async fn is_logged_in_google(&self) -> Result; 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | pnpm-debug.log* 19 | lerna-debug.log* 20 | 21 | node_modules 22 | dist 23 | dist-ssr 24 | *.local 25 | 26 | # Editor directories and files 27 | .vscode/* 28 | !.vscode/extensions.json 29 | .idea 30 | .DS_Store 31 | *.suo 32 | *.ntvs* 33 | *.njsproj 34 | *.sln 35 | *.sw? 36 | 37 | ## Panda 38 | styled-system 39 | styled-system-studio 40 | -------------------------------------------------------------------------------- /apps/desktop/src/features/search/pages/search.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "styled-system/css"; 2 | import { Box, Divider } from "styled-system/jsx"; 3 | import { SearchForm } from "../components/search-form"; 4 | import { SearchResults } from "../components/search-results"; 5 | 6 | export function SearchPage() { 7 | return ( 8 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/desktop/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /apps/desktop/src/components/providers/tanstack-router.tsx: -------------------------------------------------------------------------------- 1 | import { createRouter, RouterProvider } from "@tanstack/react-router"; 2 | import { routeTree } from "@/routeTree.gen"; 3 | 4 | // Create a new router instance 5 | export const router = createRouter({ routeTree }); 6 | 7 | // Register the router instance for type safety 8 | declare module "@tanstack/react-router" { 9 | interface Register { 10 | router: typeof router; 11 | } 12 | } 13 | 14 | // Register the router instance for type safety 15 | declare module "@tanstack/react-router" { 16 | interface Register { 17 | router: typeof router; 18 | } 19 | } 20 | 21 | export function TanstackRouterProvider() { 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /apps/desktop/src/features/search/services/search.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRecordedCourses as getRecordedCoursesCommand, 3 | type RecordedCourse, 4 | } from "@/command/get-recorded-courses"; 5 | import { 6 | type HighlightedText, 7 | type SearchSlidesArgs, 8 | type SlideSearchEntry, 9 | searchSlides as searchSlidesCommand, 10 | } from "@/command/search-slides"; 11 | 12 | export const searchSlides = async ( 13 | args: SearchSlidesArgs, 14 | ): Promise => { 15 | return await searchSlidesCommand(args); 16 | }; 17 | 18 | export type { SlideSearchEntry, HighlightedText }; 19 | 20 | export const getRecordedCourses = async (): Promise => { 21 | return await getRecordedCoursesCommand(); 22 | }; 23 | -------------------------------------------------------------------------------- /src/domain/models/credentials.rs: -------------------------------------------------------------------------------- 1 | /// User credentials for authentication 2 | #[derive(Debug, Clone)] 3 | pub struct Credentials { 4 | pub username: String, 5 | pub password: String, 6 | } 7 | 8 | impl Credentials { 9 | pub fn new(username: impl Into, password: impl Into) -> Self { 10 | Self { 11 | username: username.into(), 12 | password: password.into(), 13 | } 14 | } 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | use super::*; 20 | 21 | #[test] 22 | fn test_credentials() { 23 | let creds = Credentials::new("user", "pass"); 24 | assert_eq!(creds.username, "user"); 25 | assert_eq!(creds.password, "pass"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/domain/service/lecture.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::{CourseKey, Lecture, LectureGroup, LectureKey}; 2 | use crate::error::Result; 3 | use async_trait::async_trait; 4 | 5 | /// Lecture service trait for business logic operations 6 | #[async_trait] 7 | pub trait LectureService: Send + Sync { 8 | /// Get lecture groups for a specific course 9 | async fn get_lecture_groups(&self, course_key: &CourseKey) -> Result>; 10 | 11 | /// Get lectures for a specific course (flattened from groups) 12 | async fn get_lectures(&self, course_key: &CourseKey) -> Result>; 13 | 14 | /// Get a specific lecture by its key 15 | async fn get_lecture(&self, lecture_key: &LectureKey) -> Result; 16 | } 17 | -------------------------------------------------------------------------------- /apps/desktop/src/features/download/components/error.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { css } from "styled-system/css"; 3 | import { uniqueKey } from "@/features/course/services/pages"; 4 | import { queueAtom } from "../atoms/queue"; 5 | import { Empty } from "./empty"; 6 | import { ListItem } from "./list-item"; 7 | 8 | export function Errors() { 9 | const { error } = useAtomValue(queueAtom); 10 | 11 | if (error.size === 0) { 12 | return ; 13 | } 14 | 15 | return ( 16 |
17 |
18 | {[...error].reverse().map((item) => ( 19 | 20 | ))} 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/website/src/utils/get-release.ts: -------------------------------------------------------------------------------- 1 | import type { LatestRelease } from "../types/release"; 2 | 3 | export async function getLatestRelease(): Promise { 4 | if (import.meta.env.DEV) { 5 | const response = await fetch( 6 | "https://yu7400ki.github.io/moocs-collect/latest.json", 7 | ); 8 | if (!response.ok) { 9 | throw new Error("Failed to fetch latest release"); 10 | } 11 | return response.json(); 12 | } 13 | const glob = import.meta.glob("../../public/latest.json", { eager: true }); 14 | const module = glob["../../public/latest.json"] as 15 | | { default: LatestRelease } 16 | | undefined; 17 | if (!module) { 18 | throw new Error("Failed to load latest release"); 19 | } 20 | return module.default; 21 | } 22 | -------------------------------------------------------------------------------- /apps/desktop/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRoute, Outlet } from "@tanstack/react-router"; 2 | import { lazy, Suspense } from "react"; 3 | 4 | const TanStackRouterDevtools = import.meta.env.PROD 5 | ? () => null // Render nothing in production 6 | : lazy(() => 7 | // Lazy load in development 8 | import("@tanstack/router-devtools").then((res) => ({ 9 | default: res.TanStackRouterDevtools, 10 | // For Embedded Mode 11 | // default: res.TanStackRouterDevtoolsPanel 12 | })), 13 | ); 14 | 15 | export const Route = createRootRoute({ 16 | component: () => ( 17 | <> 18 | 19 | 20 | 21 | 22 | 23 | ), 24 | }); 25 | -------------------------------------------------------------------------------- /apps/desktop/src/features/download/components/completed.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { css } from "styled-system/css"; 3 | import { uniqueKey } from "@/features/course/services/pages"; 4 | import { queueAtom } from "../atoms/queue"; 5 | import { Empty } from "./empty"; 6 | import { ListItem } from "./list-item"; 7 | 8 | export function Completed() { 9 | const { completed } = useAtomValue(queueAtom); 10 | 11 | if (completed.size === 0) { 12 | return ; 13 | } 14 | 15 | return ( 16 |
17 |
18 | {[...completed].reverse().map((item) => ( 19 | 20 | ))} 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/desktop/src/command/search-slides.ts: -------------------------------------------------------------------------------- 1 | import { createCommand } from "./utils"; 2 | 3 | export type SearchSlidesArgs = { 4 | query: string; 5 | filters: string[]; 6 | }; 7 | 8 | export type HighlightedText = { 9 | text: string; 10 | isHighlighted: boolean; 11 | }; 12 | 13 | type SearchResult = { 14 | pageKey: string; 15 | facet: string; 16 | contentSnippet: string; 17 | highlightedContent: HighlightedText[]; 18 | score: number; 19 | }; 20 | 21 | export type SlideSearchEntry = { 22 | searchResult: SearchResult; 23 | year: number; 24 | courseName: string; 25 | lectureName: string; 26 | pageName: string; 27 | downloadPath?: string; 28 | }; 29 | 30 | export const searchSlides = createCommand( 31 | "search_slides", 32 | ); 33 | -------------------------------------------------------------------------------- /src/domain/service/slide.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | domain::models::{PageKey, Slide, SlideContent}, 3 | error::Result, 4 | }; 5 | use async_trait::async_trait; 6 | use futures::future::try_join_all; 7 | 8 | /// Slide service trait for business logic operations 9 | #[async_trait] 10 | pub trait SlideService: Send + Sync { 11 | /// Get slides for a specific page 12 | async fn get_slides(&self, page_key: &PageKey) -> Result>; 13 | 14 | /// Get slide content (SVG data) for a specific slide 15 | async fn get_slide_content(&self, slide: &Slide) -> Result; 16 | 17 | async fn get_slides_content(&self, slides: &[Slide]) -> Result> { 18 | let futures = slides.iter().map(|slide| self.get_slide_content(slide)); 19 | try_join_all(futures).await 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/schemas/lecture.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const lectureSchema = z.object({ 4 | year: z.number(), 5 | courseSlug: z.string().brand("CourseSlug"), 6 | slug: z.string().brand("LectureSlug"), 7 | name: z.string(), 8 | index: z.number(), 9 | }); 10 | 11 | export const lectureGroupSchema = z.object({ 12 | year: z.number(), 13 | courseSlug: z.string().brand("CourseSlug"), 14 | name: z.string(), 15 | lectures: z.array(lectureSchema), 16 | index: z.number(), 17 | }); 18 | 19 | export function castLectureSlug( 20 | slug: string, 21 | ): z.infer["slug"] { 22 | return slug as z.infer["slug"]; 23 | } 24 | 25 | export type Lecture = z.infer; 26 | export type LectureGroup = z.infer; 27 | -------------------------------------------------------------------------------- /apps/desktop/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Providers } from "./components/providers"; 4 | import { store } from "./components/providers/jotai"; 5 | import { getSettings } from "./features/settings/services/settings"; 6 | import "./index.css"; 7 | import "unfonts.css"; 8 | import { settingsAtom } from "./features/settings/atoms/settings"; 9 | 10 | async function initSettings() { 11 | const settings = await getSettings(); 12 | await store.set(settingsAtom, settings); 13 | } 14 | 15 | initSettings().then(() => { 16 | const rootElement = document.getElementById("root"); 17 | if (rootElement && !rootElement.innerHTML) { 18 | const root = createRoot(rootElement); 19 | root.render( 20 | 21 | 22 | , 23 | ); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/services/pages.ts: -------------------------------------------------------------------------------- 1 | import { getPages as getPagesCommand } from "@/command/get-pages"; 2 | import { memoizeAsync } from "@/utils/cache"; 3 | import type { Lecture } from "../schemas/lecture"; 4 | import { type Page, pageSchema } from "../schemas/page"; 5 | import { uniqueKey as lectureUniqueKey } from "./lectures"; 6 | 7 | export function uniqueKey(page: Page) { 8 | return `${page.year}-${page.courseSlug}-${page.lectureSlug}-${page.slug}`; 9 | } 10 | 11 | async function _getPages(lecture: Lecture) { 12 | const pages = await getPagesCommand({ 13 | year: lecture.year, 14 | courseSlug: lecture.courseSlug, 15 | lectureSlug: lecture.slug, 16 | }); 17 | return pages.map((page) => pageSchema.parse(page)); 18 | } 19 | 20 | export const getPages = memoizeAsync(_getPages, { 21 | getCacheKey: lectureUniqueKey, 22 | }); 23 | 24 | getPages.cache; 25 | -------------------------------------------------------------------------------- /apps/desktop/src/features/settings/atoms/settings.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { Settings } from "../schemas/settings"; 3 | import { getSettings, setSettings } from "../services/settings"; 4 | 5 | type Update = T | ((prev: T) => T); 6 | 7 | const internalSettingsAtom = atom(null); 8 | internalSettingsAtom.onMount = (setSettings) => { 9 | (async () => { 10 | const settings = await getSettings(); 11 | setSettings(settings); 12 | })(); 13 | }; 14 | 15 | export const settingsAtom = atom( 16 | (get) => get(internalSettingsAtom), 17 | async (get, set, update: Update) => { 18 | const settings = 19 | typeof update === "function" ? update(get(settingsAtom)) : update; 20 | if (settings) { 21 | const result = await setSettings(settings); 22 | set(internalSettingsAtom, result); 23 | } 24 | }, 25 | ); 26 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/get_archive_years.rs: -------------------------------------------------------------------------------- 1 | use crate::state::CollectState; 2 | use collect::error::CollectError; 3 | use tauri::State; 4 | 5 | #[derive(Debug, thiserror::Error)] 6 | pub enum ArchiveYearsError { 7 | #[error("Core library error: {0}")] 8 | Core(#[from] CollectError), 9 | } 10 | 11 | impl serde::Serialize for ArchiveYearsError { 12 | fn serialize(&self, serializer: S) -> Result 13 | where 14 | S: serde::Serializer, 15 | { 16 | serializer.serialize_str(&self.to_string()) 17 | } 18 | } 19 | 20 | #[tauri::command] 21 | pub async fn get_archive_years( 22 | state: State<'_, CollectState>, 23 | ) -> Result, ArchiveYearsError> { 24 | let collect = &state.collect; 25 | 26 | let years = collect.get_archive_years().await?; 27 | 28 | Ok(years.into_iter().map(|year| year.value()).collect()) 29 | } 30 | -------------------------------------------------------------------------------- /apps/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc --noEmit && vite build", 10 | "preview": "vite preview", 11 | "lint": "biome check", 12 | "format": "biome check --write" 13 | }, 14 | "dependencies": { 15 | "@lucide/lab": "^0.1.2", 16 | "lucide-react": "^0.479.0", 17 | "react": "^19.1.1", 18 | "react-dom": "^19.1.1", 19 | "tailwindcss": "^4.1.13" 20 | }, 21 | "devDependencies": { 22 | "@tailwindcss/postcss": "^4.1.13", 23 | "@types/react": "^19.1.13", 24 | "@types/react-dom": "^19.1.9", 25 | "@vitejs/plugin-react": "latest", 26 | "@vitejs/plugin-rsc": "latest", 27 | "postcss": "^8.5.6", 28 | "rsc-html-stream": "^0.0.7", 29 | "typescript": "^5.8.2", 30 | "vite": "^7.1.6" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/desktop/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | 25 | /* Paths */ 26 | "baseUrl": ".", 27 | "paths": { 28 | "@/*": ["src/*"] 29 | } 30 | }, 31 | "include": ["src", "styled-system"] 32 | } 33 | -------------------------------------------------------------------------------- /apps/desktop/src/features/download/components/in-queue.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { css } from "styled-system/css"; 3 | import { uniqueKey } from "@/features/course/services/pages"; 4 | import { queueAtom } from "../atoms/queue"; 5 | import { Empty } from "./empty"; 6 | import { ListItem } from "./list-item"; 7 | 8 | export function InQueue() { 9 | const { running, pending } = useAtomValue(queueAtom); 10 | 11 | if (running.size === 0 && pending.size === 0) { 12 | return ; 13 | } 14 | 15 | return ( 16 |
17 |
18 | {[...running].map((item) => ( 19 | 20 | ))} 21 | {[...pending].map((item) => ( 22 | 23 | ))} 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/pdf/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum PdfConversionError { 5 | #[error("SVG parsing failed: {0}")] 6 | SvgParsing(String), 7 | #[error("PDF generation failed: {0}")] 8 | PdfGeneration(String), 9 | #[error("Font loading failed")] 10 | FontLoading, 11 | #[error("Document merge failed: {0}")] 12 | DocumentMerge(String), 13 | #[error("Image processing failed: {0}")] 14 | ImageProcessing(#[from] reqwest::Error), 15 | #[error("HTML rewriting failed: {0}")] 16 | HtmlRewriting(#[from] lol_html::errors::RewritingError), 17 | #[error("UTF-8 conversion failed: {0}")] 18 | Utf8Conversion(#[from] std::string::FromUtf8Error), 19 | #[error("PDF document loading failed: {0}")] 20 | PdfLoading(#[from] lopdf::Error), 21 | } 22 | 23 | #[derive(Error, Debug)] 24 | pub enum ImageConvertError { 25 | #[error("Unsupported image format")] 26 | UnsupportedFormat, 27 | } 28 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { styled } from "styled-system/jsx"; 3 | import { 4 | Spinner as StyledSpinner, 5 | type SpinnerProps as StyledSpinnerProps, 6 | } from "./styled/spinner"; 7 | 8 | export interface SpinnerProps extends StyledSpinnerProps { 9 | /** 10 | * For accessibility, it is important to add a fallback loading text. 11 | * This text will be visible to screen readers. 12 | * @default "Loading..." 13 | */ 14 | label?: string; 15 | } 16 | 17 | export const Spinner = forwardRef( 18 | (props, ref) => { 19 | const { label = "Loading...", ...rest } = props; 20 | 21 | return ( 22 | 28 | {label && {label}} 29 | 30 | ); 31 | }, 32 | ); 33 | 34 | Spinner.displayName = "Spinner"; 35 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/components/page-list.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useAtomValue, useSetAtom } from "jotai"; 2 | import { pageChecksAtom, togglePageCheckAtom } from "../atoms/check"; 3 | import { pageSelectSlugAtom, pagesAtom } from "../atoms/page"; 4 | import { uniqueKey } from "../services/pages"; 5 | import { ListItem } from "./list-item"; 6 | 7 | export function PageList() { 8 | const pages = useAtomValue(pagesAtom); 9 | const [selectedPageSlug, setSelectedPageSlug] = useAtom(pageSelectSlugAtom); 10 | const pageChecks = useAtomValue(pageChecksAtom); 11 | const setPageChecks = useSetAtom(togglePageCheckAtom); 12 | 13 | return ( 14 |
15 | {pages?.map((page) => ( 16 | 24 | {page.name} 25 | 26 | ))} 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Yuki Natori 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 | -------------------------------------------------------------------------------- /apps/desktop/src/components/providers/theme.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { useLayoutEffect } from "react"; 3 | import { themeAtom } from "@/features/settings/atoms/theme"; 4 | 5 | function applyTheme(theme: "dark" | "light") { 6 | document.documentElement.classList.remove("dark", "light"); 7 | document.documentElement.classList.add(theme); 8 | document.documentElement.style.setProperty("color-scheme", theme); 9 | } 10 | 11 | export function ThemeProvider({ children }: { children: React.ReactNode }) { 12 | const theme = useAtomValue(themeAtom); 13 | 14 | useLayoutEffect(() => { 15 | if (theme === "system") { 16 | const media = window.matchMedia("(prefers-color-scheme: dark)"); 17 | applyTheme(media.matches ? "dark" : "light"); 18 | const listener = (e: MediaQueryListEvent) => { 19 | applyTheme(e.matches ? "dark" : "light"); 20 | }; 21 | media.addEventListener("change", listener); 22 | return () => { 23 | media.removeEventListener("change", listener); 24 | }; 25 | } 26 | applyTheme(theme); 27 | }, [theme]); 28 | 29 | return <>{children}; 30 | } 31 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/components/course-list.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useAtomValue, useSetAtom } from "jotai"; 2 | import { courseChecksAtom, toggleCourseCheckAtom } from "../atoms/check"; 3 | import { courseSelectSlugAtom, coursesAtom } from "../atoms/course"; 4 | import { uniqueKey } from "../services/courses"; 5 | import { ListItem } from "./list-item"; 6 | 7 | export function CourseList() { 8 | const courses = useAtomValue(coursesAtom); 9 | const [selectedCourseSlug, setSelectedCourseSlug] = 10 | useAtom(courseSelectSlugAtom); 11 | const courseChecks = useAtomValue(courseChecksAtom); 12 | const toggleChecks = useSetAtom(toggleCourseCheckAtom); 13 | 14 | return ( 15 |
16 | {courses.map((course) => ( 17 | 25 | {course.name} 26 | 27 | ))} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import * as StyledProgress from "./styled/progress"; 3 | 4 | export interface ProgressProps extends StyledProgress.RootProps { 5 | /** 6 | * The type of progress to render. 7 | * @default linear 8 | */ 9 | type?: "linear" | "circular"; 10 | } 11 | 12 | export const Progress = forwardRef( 13 | (props, ref) => { 14 | const { children, type = "linear", ...rootProps } = props; 15 | 16 | return ( 17 | 18 | {children && {children}} 19 | {type === "linear" && ( 20 | 21 | 22 | 23 | )} 24 | {type === "circular" && ( 25 | 26 | 27 | 28 | 29 | 30 | )} 31 | 32 | ); 33 | }, 34 | ); 35 | 36 | Progress.displayName = "Progress"; 37 | -------------------------------------------------------------------------------- /apps/desktop/panda.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@pandacss/dev"; 2 | import { createPreset } from "@park-ui/panda-preset"; 3 | import cyan from "@park-ui/panda-preset/colors/cyan"; 4 | import slate from "@park-ui/panda-preset/colors/slate"; 5 | import { slotRecipes } from "./src/recipes"; 6 | 7 | export default defineConfig({ 8 | preflight: true, 9 | presets: [ 10 | createPreset({ accentColor: cyan, grayColor: slate, radius: "md" }), 11 | ], 12 | include: ["./src/**/*.{js,jsx,ts,tsx,vue}"], 13 | jsxFramework: "react", // or 'solid' or 'vue' 14 | outdir: "styled-system", 15 | 16 | theme: { 17 | extend: { 18 | tokens: { 19 | fonts: { 20 | japanese: { 21 | value: 22 | "Inter, 'IBM Plex Sans JP', 'Hiragino Sans', 'BIZ UDPGothic', 'sans-serif'", 23 | }, 24 | latin: { 25 | value: 26 | "Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif", 27 | }, 28 | }, 29 | }, 30 | slotRecipes, 31 | }, 32 | }, 33 | 34 | globalCss: { 35 | body: { 36 | fontFamily: "japanese", 37 | fontVariantNumeric: "tabular-nums", 38 | }, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/search/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize, Deserialize, Clone)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct HighlightedText { 6 | pub text: String, 7 | pub is_highlighted: bool, 8 | } 9 | 10 | #[derive(Debug, Serialize, Deserialize, Clone)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct SearchResult { 13 | pub page_key: String, 14 | pub facet: String, 15 | pub content_snippet: String, 16 | pub highlighted_content: Vec, 17 | pub score: f32, 18 | } 19 | 20 | #[derive(Debug, Clone)] 21 | pub struct SearchOptions { 22 | pub limit: usize, 23 | pub facet_filters: Vec, 24 | } 25 | 26 | impl SearchOptions { 27 | pub fn with_limit(mut self, limit: usize) -> Self { 28 | self.limit = limit; 29 | self 30 | } 31 | 32 | pub fn with_facet_filters(mut self, facet_filters: Vec) -> Self { 33 | self.facet_filters = facet_filters; 34 | self 35 | } 36 | } 37 | 38 | impl Default for SearchOptions { 39 | fn default() -> Self { 40 | Self { 41 | limit: 50, 42 | facet_filters: Vec::new(), 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/desktop/src/features/settings/services/settings.ts: -------------------------------------------------------------------------------- 1 | import * as path from "@tauri-apps/api/path"; 2 | import { getStore } from "@/utils/store"; 3 | import { type Settings, settingsSchema } from "../schemas/settings"; 4 | 5 | const key = "settings"; 6 | 7 | async function getDefaultSettings(): Promise { 8 | const document = await path.documentDir(); 9 | const downloadDir = await path.join(document, "moocs-collect"); 10 | return { 11 | version: 1, 12 | theme: "system", 13 | downloadDir, 14 | }; 15 | } 16 | 17 | export async function getSettings() { 18 | const store = await getStore(); 19 | const settings = await store.get(key); 20 | const result = settingsSchema.safeParse(settings); 21 | if (result.success) { 22 | return result.data; 23 | } 24 | const defaultSettings = await getDefaultSettings(); 25 | await store.set(key, defaultSettings); 26 | return defaultSettings; 27 | } 28 | 29 | export async function setSettings(settings: Settings) { 30 | const store = await getStore(); 31 | const result = settingsSchema.safeParse(settings); 32 | if (result.success) { 33 | await store.set(key, result.data); 34 | return result.data; 35 | } 36 | return await getDefaultSettings(); 37 | } 38 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/services/lectures.ts: -------------------------------------------------------------------------------- 1 | import { getLectures as getLecturesCommand } from "@/command/get-lectures"; 2 | import { memoizeAsync } from "@/utils/cache"; 3 | import type { Course } from "../schemas/course"; 4 | import { 5 | type Lecture, 6 | type LectureGroup, 7 | lectureGroupSchema, 8 | } from "../schemas/lecture"; 9 | import { uniqueKey as courseUniqueKey } from "./courses"; 10 | 11 | export function uniqueKey(lecture: Lecture) { 12 | return `${lecture.year}-${lecture.courseSlug}-${lecture.slug}`; 13 | } 14 | 15 | export function lectureGroupUniqueKey(group: LectureGroup) { 16 | return `${group.year}-${group.courseSlug}-${group.name}`; 17 | } 18 | 19 | async function _getLectureGroups(course: Course) { 20 | const lectureGroups = await getLecturesCommand({ 21 | year: course.year, 22 | courseSlug: course.slug, 23 | }); 24 | return lectureGroups.map((group) => lectureGroupSchema.parse(group)); 25 | } 26 | 27 | export const getLectureGroups = memoizeAsync(_getLectureGroups, { 28 | getCacheKey: courseUniqueKey, 29 | }); 30 | 31 | export async function getAllLectures(course: Course): Promise { 32 | const groups = await getLectureGroups(course); 33 | return groups.flatMap((group) => group.lectures); 34 | } 35 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/purge_index.rs: -------------------------------------------------------------------------------- 1 | use tauri::{AppHandle, State}; 2 | use thiserror::Error; 3 | 4 | use crate::db; 5 | use crate::search::{SearchError, SearchService}; 6 | use crate::state::{DbState, SearchState}; 7 | 8 | #[derive(Debug, Error)] 9 | pub enum PurgeError { 10 | #[error("Database error: {0}")] 11 | Database(#[from] db::DbError), 12 | #[error("Search error: {0}")] 13 | Search(#[from] SearchError), 14 | } 15 | 16 | impl serde::Serialize for PurgeError { 17 | fn serialize(&self, serializer: S) -> Result 18 | where 19 | S: serde::Serializer, 20 | { 21 | serializer.serialize_str(&self.to_string()) 22 | } 23 | } 24 | 25 | #[tauri::command] 26 | pub async fn purge_index( 27 | app: AppHandle, 28 | search_state: State<'_, SearchState>, 29 | db_state: State<'_, DbState>, 30 | ) -> Result<(), PurgeError> { 31 | { 32 | let mut db_pool = db_state.0.write().await; 33 | db_pool.close().await; 34 | *db_pool = db::purge_database(&app).await?; 35 | } 36 | 37 | { 38 | let mut search_service = search_state.0.write().await; 39 | search_service.purge_index()?; 40 | *search_service = SearchService::try_from(&app)?; 41 | } 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /apps/desktop/src/features/search/components/search-results.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { Box, Flex, VStack } from "styled-system/jsx"; 3 | import { Text } from "@/components/ui/text"; 4 | import { searchResultsAtom } from "../atoms/search"; 5 | import { SearchResultItem } from "./search-result-item"; 6 | 7 | export function SearchResults() { 8 | const entries = useAtomValue(searchResultsAtom); 9 | 10 | if (entries.length === 0) { 11 | return ( 12 | 13 |

検索結果が見つかりませんでした

14 |
15 | ); 16 | } 17 | 18 | return ( 19 | 20 | 28 | 29 | 検索結果 30 | 31 | 32 | {entries.length}件見つかりました 33 | 34 | 35 | 36 | {entries.map((entry) => ( 37 | 38 | ))} 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/state.rs: -------------------------------------------------------------------------------- 1 | use crate::search::{SearchError, SearchService}; 2 | use collect::Collect; 3 | use reqwest::Client; 4 | use std::sync::Arc; 5 | use tokio::sync::RwLock; 6 | 7 | pub struct CollectState { 8 | pub collect: Arc, 9 | pub client: Arc, 10 | } 11 | 12 | impl CollectState { 13 | pub fn new() -> reqwest::Result { 14 | let client = Client::builder() 15 | .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0") 16 | .cookie_store(true) 17 | .build()?; 18 | let client = Arc::new(client); 19 | Ok(Self { 20 | collect: Arc::new(Collect::from(client.clone())), 21 | client, 22 | }) 23 | } 24 | } 25 | 26 | pub struct SearchState(pub RwLock); 27 | 28 | impl SearchState { 29 | pub fn new(app: &tauri::App) -> Result { 30 | let handle = app.handle(); 31 | let search_service = SearchService::try_from(handle)?; 32 | Ok(Self(RwLock::new(search_service))) 33 | } 34 | } 35 | 36 | pub struct DbState(pub RwLock); 37 | 38 | impl DbState { 39 | pub fn new(pool: sqlx::SqlitePool) -> Self { 40 | Self(RwLock::new(pool)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "collect", 4 | "version": "1.0.1", 5 | "identifier": "me.yu7400ki.moocs-collect", 6 | "build": { 7 | "frontendDist": "../dist", 8 | "devUrl": "http://localhost:5173", 9 | "beforeDevCommand": "pnpm dev", 10 | "beforeBuildCommand": "pnpm build" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "moocs collect", 16 | "width": 800, 17 | "height": 600, 18 | "resizable": true, 19 | "fullscreen": false 20 | } 21 | ], 22 | "security": { 23 | "csp": null 24 | } 25 | }, 26 | "bundle": { 27 | "active": true, 28 | "targets": "all", 29 | "icon": [ 30 | "icons/32x32.png", 31 | "icons/128x128.png", 32 | "icons/128x128@2x.png", 33 | "icons/icon.icns", 34 | "icons/icon.ico" 35 | ], 36 | "macOS": { 37 | "signingIdentity": "-" 38 | }, 39 | "createUpdaterArtifacts": true 40 | }, 41 | "plugins": { 42 | "updater": { 43 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDI3MUYyRDdDRTFDMDM5MTIKUldRU09jRGhmQzBmSjUyZE9LZlRYQnd3T0M1dk1pZG45Vnk0eVVnRnlFNWxiSm9Pc1JnZTEwbTAK", 44 | "endpoints": ["https://yu7400ki.github.io/moocs-collect/latest.json"] 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/desktop/src/routes/_authenticated.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; 2 | import { css } from "styled-system/css"; 3 | import { store } from "@/components/providers/jotai"; 4 | import { authenticatedAtom } from "@/features/auth/atoms/authenticated"; 5 | import { Sidebar } from "./-components/sidebar"; 6 | 7 | export const Route = createFileRoute("/_authenticated")({ 8 | beforeLoad: () => { 9 | const authenticated = store.get(authenticatedAtom); 10 | if (authenticated === false) { 11 | throw redirect({ 12 | to: "/login", 13 | }); 14 | } 15 | }, 16 | component: Component, 17 | }); 18 | 19 | function Component() { 20 | return ( 21 |
31 | 32 |
45 | 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/pages/course.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { WifiOffIcon } from "lucide-react"; 3 | import { css } from "styled-system/css"; 4 | import { Divider, Flex } from "styled-system/jsx"; 5 | import { authenticatedAtom } from "@/features/auth/atoms/authenticated"; 6 | import { Column } from "../components/column"; 7 | import { Download } from "../components/download"; 8 | import { YearSelect } from "../components/year-select"; 9 | 10 | export function CoursePage() { 11 | const authenticated = useAtomValue(authenticatedAtom); 12 | const isOffline = authenticated === "offline"; 13 | 14 | if (isOffline) { 15 | return ( 16 |
26 | 27 | オフラインモードでは科目一覧は利用できません 28 |
29 | ); 30 | } 31 | 32 | return ( 33 |
39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Toast } from "@ark-ui/react/toast"; 4 | import { toast } from "styled-system/recipes"; 5 | import type { HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withProvider, withContext } = createStyleContext(toast); 9 | 10 | export const Root = withProvider< 11 | HTMLDivElement, 12 | Assign, Toast.ActionTriggerProps> 13 | >(Toast.Root, "root"); 14 | 15 | export const ActionTrigger = withContext< 16 | HTMLButtonElement, 17 | Assign, Toast.ActionTriggerProps> 18 | >(Toast.ActionTrigger, "actionTrigger"); 19 | 20 | export const CloseTrigger = withContext< 21 | HTMLDivElement, 22 | Assign, Toast.CloseTriggerProps> 23 | >(Toast.CloseTrigger, "closeTrigger"); 24 | 25 | export const Description = withContext< 26 | HTMLDivElement, 27 | Assign, Toast.DescriptionProps> 28 | >(Toast.Description, "description"); 29 | 30 | export const Title = withContext< 31 | HTMLDivElement, 32 | Assign, Toast.TitleProps> 33 | >(Toast.Title, "title"); 34 | 35 | export { 36 | createToaster, 37 | ToastContext as Context, 38 | type ToastContextProps as ContextProps, 39 | Toaster, 40 | type ToasterProps, 41 | } from "@ark-ui/react/toast"; 42 | -------------------------------------------------------------------------------- /apps/website/src/framework/entry.ssr.tsx: -------------------------------------------------------------------------------- 1 | import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; 2 | import React from "react"; 3 | import { renderToReadableStream } from "react-dom/server.edge"; 4 | import { prerender } from "react-dom/static.edge"; 5 | import { injectRSCPayload } from "rsc-html-stream/server"; 6 | import type { RscPayload } from "./shared"; 7 | 8 | export async function renderHtml( 9 | rscStream: ReadableStream, 10 | options?: { 11 | ssg?: boolean; 12 | }, 13 | ) { 14 | const [rscStream1, rscStream2] = rscStream.tee(); 15 | 16 | let payload: Promise; 17 | function SsrRoot() { 18 | payload ??= createFromReadableStream(rscStream1); 19 | const root = React.use(payload).root; 20 | return root; 21 | } 22 | const bootstrapScriptContent = 23 | await import.meta.viteRsc.loadBootstrapScriptContent("index"); 24 | 25 | let htmlStream: ReadableStream; 26 | if (options?.ssg) { 27 | const prerenderResult = await prerender(, { 28 | bootstrapScriptContent, 29 | }); 30 | htmlStream = prerenderResult.prelude; 31 | } else { 32 | htmlStream = await renderToReadableStream(, { 33 | bootstrapScriptContent, 34 | }); 35 | } 36 | 37 | let responseStream: ReadableStream = htmlStream; 38 | responseStream = responseStream.pipeThrough(injectRSCPayload(rscStream2)); 39 | return responseStream; 40 | } 41 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{CollectError, Result}; 2 | use scraper::{ElementRef, Selector}; 3 | 4 | pub fn extract_element_attribute(elm: &ElementRef, query: &str, attribute: &str) -> Result { 5 | let selector = Selector::parse(query) 6 | .map_err(|_| CollectError::parse("Invalid CSS selector", Some(query.to_string())))?; 7 | 8 | elm.select(&selector) 9 | .next() 10 | .and_then(|element| { 11 | element 12 | .value() 13 | .attr(attribute) 14 | .map(|value| value.to_string()) 15 | }) 16 | .ok_or_else(|| { 17 | CollectError::parse( 18 | "Element or attribute not found", 19 | Some(format!("query: {query}, attribute: {attribute}")), 20 | ) 21 | }) 22 | } 23 | 24 | pub fn extract_text_content(elm: &ElementRef, query: &str) -> Result { 25 | let selector = Selector::parse(query) 26 | .map_err(|_| CollectError::parse("Invalid CSS selector", Some(query.to_string())))?; 27 | 28 | elm.select(&selector) 29 | .next() 30 | .map(|element| element.text().collect()) 31 | .ok_or_else(|| CollectError::parse("Element not found", Some(format!("query: {query}")))) 32 | } 33 | 34 | pub fn parse_selector(query: &str) -> Result { 35 | Selector::parse(query) 36 | .map_err(|_| CollectError::parse("Invalid CSS selector", Some(query.to_string()))) 37 | } 38 | -------------------------------------------------------------------------------- /src/service/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::Credentials; 2 | use crate::domain::repository::AuthenticationRepository; 3 | use crate::domain::service::AuthenticationService; 4 | use crate::error::Result; 5 | use async_trait::async_trait; 6 | use std::sync::Arc; 7 | 8 | /// Authentication service implementation 9 | pub struct AuthenticationServiceImpl { 10 | auth_repository: Arc, 11 | } 12 | 13 | impl AuthenticationServiceImpl { 14 | /// Create a new authentication service 15 | pub fn new(auth_repository: Arc) -> Self { 16 | Self { auth_repository } 17 | } 18 | } 19 | 20 | #[async_trait] 21 | impl AuthenticationService for AuthenticationServiceImpl { 22 | /// Login to MOOCs system 23 | async fn login_moocs(&self, credentials: &Credentials) -> Result<()> { 24 | self.auth_repository.login_moocs(credentials).await 25 | } 26 | 27 | /// Login to Google SAML system 28 | async fn login_google(&self, credentials: &Credentials) -> Result<()> { 29 | self.auth_repository.login_google(credentials).await 30 | } 31 | 32 | /// Check if logged into MOOCs 33 | async fn is_logged_in_moocs(&self) -> Result { 34 | self.auth_repository.is_logged_in_moocs().await 35 | } 36 | 37 | /// Check if logged into Google 38 | async fn is_logged_in_google(&self) -> Result { 39 | self.auth_repository.is_logged_in_google().await 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/desktop/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import Unfonts from "unplugin-fonts/vite"; 4 | import { defineConfig } from "vite"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | const host = process.env.TAURI_DEV_HOST; 8 | 9 | // https://vite.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | TanStackRouterVite({ autoCodeSplitting: false }), 13 | react(), 14 | tsconfigPaths(), 15 | Unfonts({ 16 | fontsource: { 17 | families: ["IBM Plex Sans JP", "Inter"], 18 | }, 19 | }), 20 | ], 21 | 22 | clearScreen: false, 23 | server: { 24 | // Tauri expects a fixed port, fail if that port is not available 25 | strictPort: true, 26 | // if the host Tauri is expecting is set, use it 27 | host: host ?? false, 28 | port: 5173, 29 | }, 30 | // Env variables starting with the item of `envPrefix` will be exposed in tauri's source code through `import.meta.env`. 31 | envPrefix: ["VITE_", "TAURI_ENV_*"], 32 | build: { 33 | // Tauri uses Chromium on Windows and WebKit on macOS and Linux 34 | target: 35 | process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13", 36 | // don't minify for debug builds 37 | minify: process.env.TAURI_ENV_DEBUG ? false : "esbuild", 38 | // produce sourcemaps for debug builds 39 | sourcemap: !!process.env.TAURI_ENV_DEBUG, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/search/query.rs: -------------------------------------------------------------------------------- 1 | use tantivy::query::{BooleanQuery, Occur, QueryParser, TermSetQuery}; 2 | use tantivy::schema::Facet; 3 | use tantivy::{Index, Term}; 4 | 5 | use super::schema::SlideSchema; 6 | use super::types::SearchOptions; 7 | 8 | pub fn build_query( 9 | index: &Index, 10 | schema: &SlideSchema, 11 | query_str: &str, 12 | opts: &SearchOptions, 13 | ) -> Result, tantivy::query::QueryParserError> { 14 | let mut parser = QueryParser::for_index(index, vec![schema.content_ja, schema.content_bi]); 15 | parser.set_field_boost(schema.content_ja, 5.0); 16 | parser.set_field_boost(schema.content_bi, 1.0); 17 | parser.set_conjunction_by_default(); 18 | 19 | let base_query = parser.parse_query(query_str)?; 20 | 21 | let mut clauses: Vec<(Occur, Box)> = vec![(Occur::Must, base_query)]; 22 | 23 | let facet_filters = opts 24 | .facet_filters 25 | .iter() 26 | .filter(|f| !f.is_empty() && f.starts_with('/')) 27 | .collect::>(); 28 | 29 | if !facet_filters.is_empty() { 30 | let terms: Vec = facet_filters 31 | .iter() 32 | .map(|raw| Facet::from(raw.as_str())) // 入力は "/a/b" 形式を想定 33 | .map(|facet| Term::from_facet(schema.facet, &facet)) 34 | .collect(); 35 | let facet_query = Box::new(TermSetQuery::new(terms)); 36 | clauses.push((Occur::Must, facet_query)); 37 | } 38 | 39 | Ok(Box::new(BooleanQuery::new(clauses))) 40 | } 41 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/get_recorded_courses.rs: -------------------------------------------------------------------------------- 1 | use sqlx::Row; 2 | use tauri::State; 3 | 4 | use crate::state::DbState; 5 | 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum RecordedCoursesError { 8 | #[error("Database error: {0}")] 9 | Database(#[from] sqlx::Error), 10 | } 11 | 12 | impl serde::Serialize for RecordedCoursesError { 13 | fn serialize(&self, serializer: S) -> Result 14 | where 15 | S: serde::Serializer, 16 | { 17 | serializer.serialize_str(&self.to_string()) 18 | } 19 | } 20 | 21 | #[derive(Debug, serde::Serialize)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct RecordedCourse { 24 | pub year: u32, 25 | pub slug: String, 26 | pub name: String, 27 | pub sort_index: i64, 28 | } 29 | 30 | #[tauri::command] 31 | pub async fn get_recorded_courses( 32 | db_state: State<'_, DbState>, 33 | ) -> Result, RecordedCoursesError> { 34 | let db_pool = db_state.0.read().await; 35 | let rows = sqlx::query( 36 | "SELECT year, slug, name, sort_index FROM courses ORDER BY year DESC, sort_index ASC", 37 | ) 38 | .fetch_all(&*db_pool) 39 | .await?; 40 | 41 | let courses = rows 42 | .into_iter() 43 | .map(|row| RecordedCourse { 44 | year: row.get::("year") as u32, 45 | slug: row.get::("slug"), 46 | name: row.get::("name"), 47 | sort_index: row.get::("sort_index"), 48 | }) 49 | .collect(); 50 | 51 | Ok(courses) 52 | } 53 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { Center, styled } from "styled-system/jsx"; 3 | import { Spinner } from "./spinner"; 4 | import { 5 | Button as StyledButton, 6 | type ButtonProps as StyledButtonProps, 7 | } from "./styled/button"; 8 | 9 | interface ButtonLoadingProps { 10 | loading?: boolean; 11 | loadingText?: React.ReactNode; 12 | } 13 | 14 | export interface ButtonProps extends StyledButtonProps, ButtonLoadingProps {} 15 | 16 | export const Button = forwardRef( 17 | (props, ref) => { 18 | const { loading, disabled, loadingText, children, ...rest } = props; 19 | 20 | const trulyDisabled = loading || disabled; 21 | 22 | return ( 23 | 24 | {loading && !loadingText ? ( 25 | <> 26 | 27 | {children} 28 | 29 | ) : loadingText ? ( 30 | loadingText 31 | ) : ( 32 | children 33 | )} 34 | 35 | ); 36 | }, 37 | ); 38 | 39 | Button.displayName = "Button"; 40 | 41 | const ButtonSpinner = () => ( 42 |
49 | 56 |
57 | ); 58 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "1.0.1" 4 | edition = "2021" 5 | rust-version = "1.77.2" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [lib] 10 | name = "app_lib" 11 | crate-type = ["staticlib", "cdylib", "rlib"] 12 | 13 | [build-dependencies] 14 | tauri-build = { version = "2.0.5", features = [] } 15 | serde = { version = "1.0", features = ["derive"] } 16 | serde_json = "1.0" 17 | sha2 = "0.10" 18 | 19 | [dependencies] 20 | collect = { path = "../../.." } 21 | reqwest = { workspace = true } 22 | futures = { workspace = true } 23 | rayon = { workspace = true } 24 | thiserror = { workspace = true } 25 | sqlx = { version = "0.8", default-features = false, features = ["macros", "runtime-tokio", "sqlite"] } 26 | serde_json = "1.0" 27 | serde = { version = "1.0", features = ["derive"] } 28 | log = "0.4" 29 | tauri = { version = "2.3.1", features = [] } 30 | tauri-plugin-log = "2.0.0-rc" 31 | tauri-plugin-store = "2" 32 | tauri-plugin-dialog = "2" 33 | keyring = { version = "3.6.2", features = [ 34 | "apple-native", 35 | "windows-native", 36 | "sync-secret-service", 37 | ] } 38 | tauri-plugin-process = "2" 39 | tantivy = "0.25" 40 | lindera = { version = "1.2", features = ["embedded-unidic"] } 41 | lindera-tantivy = { version = "1.0", features = ["embedded-unidic"] } 42 | unicode-normalization = "0.1.24" 43 | tauri-plugin-opener = "2" 44 | tokio = { version = "1", features = ["sync"] } 45 | 46 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 47 | tauri-plugin-updater = "2" 48 | -------------------------------------------------------------------------------- /apps/desktop/src/features/settings/components/dir-selector.tsx: -------------------------------------------------------------------------------- 1 | import { open } from "@tauri-apps/plugin-dialog"; 2 | import { useControllableValue } from "ahooks"; 3 | import { useCallback, useTransition } from "react"; 4 | import { css } from "styled-system/css"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | 8 | type DirSelectorProps = Omit, "onChange"> & { 9 | value?: string; 10 | defaultValue?: string; 11 | onChange?: (value: string) => void; 12 | }; 13 | 14 | export function DirSelector({ 15 | value, 16 | defaultValue, 17 | onChange, 18 | ...props 19 | }: DirSelectorProps) { 20 | const [dir, setDir] = useControllableValue({ 21 | value, 22 | defaultValue, 23 | onChange, 24 | }); 25 | const [isPending, startTransition] = useTransition(); 26 | 27 | const handleSelectDir = useCallback(async () => { 28 | const result = await open({ 29 | directory: true, 30 | multiple: false, 31 | defaultPath: dir, 32 | }); 33 | 34 | if (result) { 35 | setDir(result); 36 | } 37 | }, [dir, setDir]); 38 | 39 | return ( 40 |
47 | 48 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /apps/desktop/src/features/auth/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { WifiOffIcon } from "lucide-react"; 2 | import { css } from "styled-system/css"; 3 | import { Container, Flex } from "styled-system/jsx"; 4 | import { router } from "@/components/providers/tanstack-router"; 5 | import { Button } from "@/components/ui/button"; 6 | import { useAuth } from "../atoms/authenticated"; 7 | import { LoginForm } from "../components/login-form"; 8 | 9 | export function Login() { 10 | const { goOffline } = useAuth(); 11 | 12 | const handleOfflineMode = async () => { 13 | goOffline(); 14 | await router.navigate({ 15 | to: "/", 16 | }); 17 | }; 18 | 19 | return ( 20 |
28 | 35 |
41 | 42 |
43 |
44 | 45 | 54 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/get_courses.rs: -------------------------------------------------------------------------------- 1 | use crate::state::CollectState; 2 | use collect::{error::CollectError, Course as DomainCourse, Year}; 3 | use tauri::State; 4 | 5 | #[derive(Debug, thiserror::Error)] 6 | pub enum CourseError { 7 | #[error("Invalid input: {0}")] 8 | InvalidInput(String), 9 | #[error("Core library error: {0}")] 10 | Core(#[from] CollectError), 11 | } 12 | 13 | impl serde::Serialize for CourseError { 14 | fn serialize(&self, serializer: S) -> Result 15 | where 16 | S: serde::Serializer, 17 | { 18 | serializer.serialize_str(&self.to_string()) 19 | } 20 | } 21 | 22 | #[derive(serde::Serialize)] 23 | pub struct Course { 24 | pub year: u32, 25 | pub slug: String, 26 | pub name: String, 27 | } 28 | 29 | impl From for Course { 30 | fn from(course: DomainCourse) -> Self { 31 | Self { 32 | year: course.key.year.value(), 33 | slug: course.key.slug.value().to_string(), 34 | name: course.display_name().to_string(), 35 | } 36 | } 37 | } 38 | 39 | #[tauri::command] 40 | pub async fn get_courses( 41 | year: Option, 42 | state: State<'_, CollectState>, 43 | ) -> Result, CourseError> { 44 | let collect = &state.collect; 45 | 46 | let year_param = year 47 | .map(|y| { 48 | Year::new(y).map_err(|e| CourseError::InvalidInput(format!("Invalid year {y}: {e}"))) 49 | }) 50 | .transpose()?; 51 | 52 | let courses = collect.get_courses(year_param).await?; 53 | 54 | Ok(courses.into_iter().map(Course::from).collect()) 55 | } 56 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Tabs } from "@ark-ui/react/tabs"; 4 | import { type TabsVariantProps, tabs } from "styled-system/recipes"; 5 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withProvider, withContext } = createStyleContext(tabs); 9 | 10 | export type RootProviderProps = ComponentProps; 11 | export const RootProvider = withProvider< 12 | HTMLDivElement, 13 | Assign< 14 | Assign, Tabs.RootProviderBaseProps>, 15 | TabsVariantProps 16 | > 17 | >(Tabs.RootProvider, "root"); 18 | 19 | export type RootProps = ComponentProps; 20 | export const Root = withProvider< 21 | HTMLDivElement, 22 | Assign, Tabs.RootBaseProps>, TabsVariantProps> 23 | >(Tabs.Root, "root"); 24 | 25 | export const Content = withContext< 26 | HTMLDivElement, 27 | Assign, Tabs.ContentBaseProps> 28 | >(Tabs.Content, "content"); 29 | 30 | export const Indicator = withContext< 31 | HTMLDivElement, 32 | Assign, Tabs.IndicatorBaseProps> 33 | >(Tabs.Indicator, "indicator"); 34 | 35 | export const List = withContext< 36 | HTMLDivElement, 37 | Assign, Tabs.ListBaseProps> 38 | >(Tabs.List, "list"); 39 | 40 | export const Trigger = withContext< 41 | HTMLButtonElement, 42 | Assign, Tabs.TriggerBaseProps> 43 | >(Tabs.Trigger, "trigger"); 44 | 45 | export { TabsContext as Context } from "@ark-ui/react/tabs"; 46 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/atoms/course.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { derive } from "jotai-derive"; 3 | import { unwrapPromise } from "@/utils/atom"; 4 | import type { Course } from "../schemas/course"; 5 | import { getCourses } from "../services/courses"; 6 | import { yearAtom } from "./year"; 7 | 8 | const internalCoursesAtom = atom((get) => { 9 | const year = get(yearAtom); 10 | return getCourses({ year }); 11 | }); 12 | 13 | export const coursesAtom = unwrapPromise(internalCoursesAtom); 14 | 15 | export const courseMapAtom = derive([coursesAtom], (courses) => { 16 | return new Map(courses.map((course) => [course.slug, course])); 17 | }); 18 | 19 | const internalCourseSelectAtom = atom>( 20 | new Map(), 21 | ); 22 | 23 | export const courseSelectAtom = atom( 24 | (get) => { 25 | const year = get(yearAtom); 26 | const map = get(internalCourseSelectAtom); 27 | return map.get(year) ?? null; 28 | }, 29 | async (get, set, course: Course | null) => { 30 | const courseMap = await get(courseMapAtom); 31 | const year = get(yearAtom); 32 | if (!course || courseMap.has(course.slug)) { 33 | set(internalCourseSelectAtom, (old) => { 34 | const map = new Map(old); 35 | map.set(year, course); 36 | return map; 37 | }); 38 | } 39 | }, 40 | ); 41 | 42 | export const courseSelectSlugAtom = atom( 43 | (get) => get(courseSelectAtom)?.slug ?? null, 44 | async (get, set, slug: Course["slug"] | null) => { 45 | const map = await get(courseMapAtom); 46 | const course = slug ? map.get(slug) : null; 47 | set(courseSelectAtom, course ?? null); 48 | }, 49 | ); 50 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/components/column.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, Suspense } from "react"; 2 | import { css } from "styled-system/css"; 3 | import { Divider } from "styled-system/jsx"; 4 | import { Spinner } from "@/components/ui/spinner"; 5 | import { CourseList } from "./course-list"; 6 | import { LectureList } from "./lecture-list"; 7 | import { PageList } from "./page-list"; 8 | 9 | function Loading() { 10 | return ( 11 |
19 | 20 |
21 | ); 22 | } 23 | 24 | function Section({ children }: { children: React.ReactNode }) { 25 | return ( 26 |
33 | }>{children} 34 |
35 | ); 36 | } 37 | 38 | const components = [CourseList, LectureList, PageList]; 39 | 40 | export function Column() { 41 | return ( 42 |
48 | {components.map((Component, index) => ( 49 | // biome-ignore lint/suspicious/noArrayIndexKey: This is a static list 50 | 51 |
52 | 53 |
54 | {index < components.length - 1 && } 55 |
56 | ))} 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import * as StyledCheckbox from "./styled/checkbox"; 3 | 4 | export interface CheckboxProps extends StyledCheckbox.RootProps {} 5 | 6 | export const Checkbox = forwardRef( 7 | (props, ref) => { 8 | const { children, ...rootProps } = props; 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {children && {children}} 21 | 22 | 23 | ); 24 | }, 25 | ); 26 | 27 | Checkbox.displayName = "Checkbox"; 28 | 29 | const CheckIcon = () => ( 30 | 31 | Check Icon 32 | 39 | 40 | ); 41 | 42 | const MinusIcon = () => ( 43 | 44 | Minus Icon 45 | 52 | 53 | ); 54 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "collect" 3 | version = "1.0.1" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | reqwest = { workspace = true } 8 | tokio = { workspace = true } 9 | futures = { workspace = true } 10 | rayon = { workspace = true } 11 | bitflags = { workspace = true } 12 | thiserror = { workspace = true } 13 | base64 = "0.22.1" 14 | lol_html = "2.2.0" 15 | regex = "1.10.4" 16 | scraper = { version = "0.23.1", features = ["atomic"] } 17 | unicode_escape = "0.1.0" 18 | bytes = "1.10.0" 19 | svg2pdf = { version = "0.13" } 20 | lopdf = "0.35.0" 21 | async-trait = "0.1.88" 22 | image = "0.25.6" 23 | infer = "0.19.0" 24 | html-escape = "0.2" 25 | 26 | [workspace] 27 | members = [ 28 | "apps/cli", 29 | "apps/desktop/src-tauri", 30 | "apps/merge", 31 | ] 32 | resolver = "2" 33 | 34 | [workspace.dependencies] 35 | reqwest = { version = "0.12.13", features = ["cookies", "gzip"] } 36 | anyhow = "1.0.97" 37 | tokio = { version = "1.37.0", features = ["rt", "net", "macros", "rt-multi-thread"] } 38 | futures = "0.3.31" 39 | rayon = "1.10.0" 40 | bitflags = "2.9.0" 41 | thiserror = "2.0" 42 | 43 | [workspace.lints.clippy] 44 | all = "warn" 45 | 46 | pedantic = { level = "warn", priority = 0 } 47 | nursery = { level = "warn", priority = 0 } 48 | 49 | restriction = "allow" 50 | 51 | unwrap_used = { level = "deny", priority = 2 } 52 | expect_used = { level = "deny", priority = 2 } 53 | panic = { level = "deny", priority = 2 } 54 | todo = { level = "deny", priority = 2 } 55 | unimplemented = { level = "deny", priority = 2 } 56 | dbg_macro = { level = "deny", priority = 2 } 57 | print_stdout = { level = "deny", priority = 2 } 58 | print_stderr = { level = "deny", priority = 2 } 59 | -------------------------------------------------------------------------------- /apps/desktop/src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { type MemoizeCache, memoize } from "es-toolkit/function"; 2 | 3 | // biome-ignore lint/suspicious/noExplicitAny: assertion is safe here 4 | export function memoizeAsync Promise>( 5 | fn: F, 6 | options?: { 7 | getCacheKey?: (args: Parameters[0]) => unknown; 8 | }, 9 | ): ((...args: Parameters) => ReturnType | Awaited>) & { 10 | // biome-ignore lint/suspicious/noExplicitAny: assertion is safe here 11 | cache: MemoizeCache | Promise>>; 12 | } { 13 | // @ts-expect-error 14 | return memoize(fn, { 15 | cache: new PromiseCache(), 16 | ...options, 17 | }); 18 | } 19 | 20 | export class PromiseCache implements MemoizeCache> { 21 | private cache = new Map>(); 22 | 23 | set(key: K, value: T | Promise): void { 24 | this.cache.set(key, value); 25 | if (value instanceof Promise) { 26 | value 27 | .then((resolved) => { 28 | const current = this.cache.get(key); 29 | current === value && this.cache.set(key, resolved); 30 | }) 31 | .catch(() => { 32 | const current = this.cache.get(key); 33 | current === value && this.cache.delete(key); 34 | }); 35 | } 36 | } 37 | get(key: K): T | Promise | undefined { 38 | return this.cache.get(key); 39 | } 40 | has(key: K): boolean { 41 | return this.cache.has(key); 42 | } 43 | delete(key: K): boolean { 44 | return this.cache.delete(key); 45 | } 46 | clear(): void { 47 | this.cache.clear(); 48 | } 49 | get size(): number { 50 | return this.cache.size; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/desktop/src/features/download/pages/download.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { useState } from "react"; 3 | import { Tabs } from "@/components/ui/tabs"; 4 | import { queueAtom } from "../atoms/queue"; 5 | import { Completed } from "../components/completed"; 6 | import { Errors } from "../components/error"; 7 | import { InQueue } from "../components/in-queue"; 8 | 9 | const panels = { 10 | "in-queue": InQueue, 11 | completed: Completed, 12 | error: Errors, 13 | }; 14 | 15 | export function DownloadPage() { 16 | const options = [ 17 | { id: "in-queue", label: "処理待ち" }, 18 | { id: "completed", label: "完了" }, 19 | ]; 20 | const { error } = useAtomValue(queueAtom); 21 | if (error.size > 0) { 22 | options.push({ id: "error", label: "エラー" }); 23 | } 24 | const [_panel, setPanel] = useState("in-queue"); 25 | 26 | let panel = _panel; 27 | if (!options.some((option) => option.id === panel)) { 28 | panel = options[0].id; 29 | } 30 | 31 | return ( 32 | setPanel(e.value)}> 33 | 34 | {options.map((option) => ( 35 | 42 | {option.label} 43 | 44 | ))} 45 | 46 | 47 | {Object.entries(panels).map(([key, Component]) => ( 48 | 49 | 50 | 51 | ))} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | use tauri::{async_runtime, Manager}; 2 | 3 | mod command; 4 | mod db; 5 | mod search; 6 | mod state; 7 | 8 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 9 | pub fn run() { 10 | tauri::Builder::default() 11 | .plugin(tauri_plugin_opener::init()) 12 | .plugin(tauri_plugin_process::init()) 13 | .plugin(tauri_plugin_updater::Builder::new().build()) 14 | .plugin(tauri_plugin_store::Builder::new().build()) 15 | .plugin(tauri_plugin_dialog::init()) 16 | .setup(|app| { 17 | let handle = app.handle(); 18 | 19 | if cfg!(debug_assertions) { 20 | handle.plugin( 21 | tauri_plugin_log::Builder::default() 22 | .level(log::LevelFilter::Info) 23 | .build(), 24 | )?; 25 | } 26 | 27 | let db_pool = async_runtime::block_on(db::init(handle.clone()))?; 28 | 29 | app.manage(state::DbState::new(db_pool)); 30 | app.manage(state::CollectState::new()?); 31 | app.manage(state::SearchState::new(app)?); 32 | Ok(()) 33 | }) 34 | .invoke_handler(tauri::generate_handler![ 35 | command::download_slides, 36 | command::login, 37 | command::get_courses, 38 | command::get_credential, 39 | command::get_lectures, 40 | command::get_pages, 41 | command::get_archive_years, 42 | command::search_slides, 43 | command::get_recorded_courses, 44 | command::purge_index, 45 | ]) 46 | .run(tauri::generate_context!()) 47 | .expect("error while running tauri application"); 48 | } 49 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Tooltip } from "@ark-ui/react/tooltip"; 4 | import { type TooltipVariantProps, tooltip } from "styled-system/recipes"; 5 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withRootProvider, withContext } = createStyleContext(tooltip); 9 | 10 | export type RootProviderProps = ComponentProps; 11 | export const RootProvider = withRootProvider< 12 | Assign 13 | >(Tooltip.RootProvider); 14 | 15 | export type RootProps = ComponentProps; 16 | export const Root = withRootProvider< 17 | Assign 18 | >(Tooltip.Root); 19 | 20 | export const Arrow = withContext< 21 | HTMLDivElement, 22 | Assign, Tooltip.ArrowBaseProps> 23 | >(Tooltip.Arrow, "arrow"); 24 | 25 | export const ArrowTip = withContext< 26 | HTMLDivElement, 27 | Assign, Tooltip.ArrowTipBaseProps> 28 | >(Tooltip.ArrowTip, "arrowTip"); 29 | 30 | export const Content = withContext< 31 | HTMLDivElement, 32 | Assign, Tooltip.ContentBaseProps> 33 | >(Tooltip.Content, "content"); 34 | 35 | export const Positioner = withContext< 36 | HTMLDivElement, 37 | Assign, Tooltip.PositionerBaseProps> 38 | >(Tooltip.Positioner, "positioner"); 39 | 40 | export const Trigger = withContext< 41 | HTMLButtonElement, 42 | Assign, Tooltip.TriggerBaseProps> 43 | >(Tooltip.Trigger, "trigger"); 44 | 45 | export { TooltipContext as Context } from "@ark-ui/react/tooltip"; 46 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/atoms/page.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { derive } from "jotai-derive"; 3 | import { unwrapPromise } from "@/utils/atom"; 4 | import type { Page } from "../schemas/page"; 5 | import { uniqueKey } from "../services/lectures"; 6 | import { getPages } from "../services/pages"; 7 | import { lectureSelectAtom } from "./lecture"; 8 | 9 | const internalPagesAtom = atom((get) => { 10 | const lecture = get(lectureSelectAtom); 11 | return lecture ? getPages(lecture) : null; 12 | }); 13 | 14 | export const pagesAtom = unwrapPromise(internalPagesAtom); 15 | 16 | export const pageMapAtom = derive([pagesAtom], (pages) => { 17 | return pages ? new Map(pages.map((page) => [page.slug, page])) : null; 18 | }); 19 | 20 | const internalPageSelectAtom = atom>(new Map()); 21 | 22 | export const pageSelectAtom = atom( 23 | (get) => { 24 | const lecture = get(lectureSelectAtom); 25 | const map = get(internalPageSelectAtom); 26 | return lecture ? (map.get(uniqueKey(lecture)) ?? null) : null; 27 | }, 28 | async (get, set, page: Page | null) => { 29 | const pageMap = await get(pageMapAtom); 30 | const lecture = get(lectureSelectAtom); 31 | if (lecture && (!page || pageMap?.has(page.slug))) { 32 | set(internalPageSelectAtom, (old) => { 33 | const map = new Map(old); 34 | map.set(uniqueKey(lecture), page); 35 | return map; 36 | }); 37 | } 38 | }, 39 | ); 40 | 41 | export const pageSelectSlugAtom = atom( 42 | (get) => get(pageSelectAtom)?.slug ?? null, 43 | async (get, set, slug: Page["slug"] | null) => { 44 | const map = await get(pageMapAtom); 45 | const page = slug ? map?.get(slug) : null; 46 | set(pageSelectAtom, page ?? null); 47 | }, 48 | ); 49 | -------------------------------------------------------------------------------- /src/service/page.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{ 2 | models::{LectureKey, LecturePage, PageKey}, 3 | repository::{AuthenticationRepository, PageRepository}, 4 | service::PageService, 5 | }; 6 | use crate::error::Result; 7 | use async_trait::async_trait; 8 | use std::sync::Arc; 9 | 10 | pub struct PageServiceImpl { 11 | page_repository: Arc, 12 | auth_repository: Arc, 13 | } 14 | 15 | impl PageServiceImpl { 16 | pub fn new( 17 | page_repository: Arc, 18 | auth_repository: Arc, 19 | ) -> Self { 20 | Self { 21 | page_repository, 22 | auth_repository, 23 | } 24 | } 25 | } 26 | 27 | #[async_trait] 28 | impl PageService for PageServiceImpl { 29 | async fn get_pages(&self, lecture_key: &LectureKey) -> Result> { 30 | // Check authentication before fetching pages 31 | if !self.auth_repository.is_logged_in_moocs().await? { 32 | return Err(crate::error::CollectError::authentication( 33 | "Not logged into MOOCs system. Please authenticate first.", 34 | )); 35 | } 36 | 37 | self.page_repository.fetch_pages(lecture_key).await 38 | } 39 | 40 | async fn get_page(&self, page_key: &PageKey) -> Result { 41 | // Get all pages for the lecture and find the one matching the key 42 | let pages = self.get_pages(&page_key.lecture_key).await?; 43 | pages 44 | .into_iter() 45 | .find(|page| page.key == *page_key) 46 | .ok_or_else(|| { 47 | crate::error::CollectError::not_found(format!("Page not found: {page_key}")) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/search/index.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use tantivy::{Index, IndexWriter, TantivyError}; 4 | 5 | use super::analyzers::register_analyzers; 6 | use super::schema::SlideSchema; 7 | 8 | pub struct IndexManager { 9 | pub index: Index, 10 | pub schema: SlideSchema, 11 | pub index_path: PathBuf, 12 | } 13 | 14 | impl IndexManager { 15 | pub fn new(index_path: PathBuf) -> Result { 16 | if index_path.exists() { 17 | let index = Index::open_in_dir(&index_path)?; 18 | if let Some(schema) = SlideSchema::from_existing(&index.schema()) { 19 | register_analyzers(&index)?; 20 | return Ok(Self { 21 | index, 22 | schema, 23 | index_path, 24 | }); 25 | } 26 | 27 | log::warn!( 28 | "Search index schema mismatch detected. Recreating index at {}", 29 | index_path.display() 30 | ); 31 | std::fs::remove_dir_all(&index_path)?; 32 | } 33 | 34 | let schema = SlideSchema::new(); 35 | std::fs::create_dir_all(&index_path)?; 36 | let index = Index::create_in_dir(&index_path, schema.schema.clone())?; 37 | 38 | register_analyzers(&index)?; 39 | 40 | Ok(Self { 41 | index, 42 | schema, 43 | index_path, 44 | }) 45 | } 46 | 47 | pub fn writer(&self, heap_size: usize) -> Result { 48 | self.index.writer(heap_size) 49 | } 50 | 51 | pub fn purge_index(&self) -> Result<(), TantivyError> { 52 | if self.index_path.exists() { 53 | std::fs::remove_dir_all(&self.index_path)?; 54 | } 55 | Ok(()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/number-input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import * as StyledNumberInput from "./styled/number-input"; 3 | 4 | export interface NumberInputProps extends StyledNumberInput.RootProps {} 5 | 6 | export const NumberInput = forwardRef( 7 | (props, ref) => { 8 | const { children, ...rootProps } = props; 9 | return ( 10 | 11 | {children && ( 12 | {children} 13 | )} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }, 26 | ); 27 | 28 | NumberInput.displayName = "NumberInput"; 29 | 30 | const ChevronUpIcon = () => ( 31 | 32 | Chevron Up Icon 33 | 41 | 42 | ); 43 | 44 | const ChevronDownIcon = () => ( 45 | 46 | Chevron Down Icon 47 | 55 | 56 | ); 57 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/search/highlighter.rs: -------------------------------------------------------------------------------- 1 | use tantivy::snippet::Snippet; 2 | 3 | use super::types::HighlightedText; 4 | 5 | pub fn extract_highlights(snippet: &Snippet) -> Vec { 6 | fn merge_ranges(ranges: &[std::ops::Range]) -> Vec> { 7 | let mut sorted: Vec<_> = ranges.to_vec(); 8 | sorted.sort_by_key(|r| r.start); 9 | let mut merged: Vec> = Vec::new(); 10 | for r in sorted { 11 | if let Some(last) = merged.last_mut() { 12 | if r.start <= last.end { 13 | last.end = last.end.max(r.end); 14 | continue; 15 | } 16 | } 17 | merged.push(r); 18 | } 19 | merged 20 | } 21 | 22 | let fragment_text = snippet.fragment(); 23 | let mut highlighted_parts = Vec::new(); 24 | let mut current_pos = 0; 25 | 26 | let merged_ranges = merge_ranges(snippet.highlighted()); 27 | for fragment_range in merged_ranges { 28 | if current_pos < fragment_range.start { 29 | highlighted_parts.push(HighlightedText { 30 | text: fragment_text[current_pos..fragment_range.start].to_string(), 31 | is_highlighted: false, 32 | }); 33 | } 34 | 35 | highlighted_parts.push(HighlightedText { 36 | text: fragment_text[fragment_range.clone()].to_string(), 37 | is_highlighted: true, 38 | }); 39 | 40 | current_pos = fragment_range.end; 41 | } 42 | 43 | if current_pos < fragment_text.len() { 44 | highlighted_parts.push(HighlightedText { 45 | text: fragment_text[current_pos..].to_string(), 46 | is_highlighted: false, 47 | }); 48 | } 49 | 50 | highlighted_parts 51 | } 52 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 4 | use sqlx::SqlitePool; 5 | use tauri::AppHandle; 6 | use tauri::Manager; 7 | use thiserror::Error; 8 | 9 | mod migrator; 10 | 11 | #[derive(Debug, Error)] 12 | pub enum DbError { 13 | #[error("failed to resolve application data directory: {0}")] 14 | Path(String), 15 | #[error("failed to prepare database directory: {0}")] 16 | Io(#[from] std::io::Error), 17 | #[error("database error: {0}")] 18 | Sqlx(#[from] sqlx::Error), 19 | #[error(transparent)] 20 | Migration(#[from] migrator::MigrationError), 21 | } 22 | 23 | pub async fn init(handle: AppHandle) -> Result { 24 | let db_path = resolve_db_path(&handle)?; 25 | if let Some(parent) = db_path.parent() { 26 | std::fs::create_dir_all(parent)?; 27 | } 28 | 29 | let connect_options = SqliteConnectOptions::new() 30 | .filename(&db_path) 31 | .create_if_missing(true); 32 | 33 | let pool = SqlitePoolOptions::new() 34 | .max_connections(5) 35 | .connect_with(connect_options) 36 | .await?; 37 | 38 | migrator::run_pending_migrations(&pool).await?; 39 | 40 | Ok(pool) 41 | } 42 | 43 | pub async fn purge_database(handle: &AppHandle) -> Result { 44 | let db_path = resolve_db_path(handle)?; 45 | 46 | if db_path.exists() { 47 | std::fs::remove_file(&db_path)?; 48 | } 49 | 50 | let pool = init(handle.clone()).await?; 51 | 52 | Ok(pool) 53 | } 54 | 55 | fn resolve_db_path(handle: &AppHandle) -> Result { 56 | let base_dir = handle 57 | .path() 58 | .app_data_dir() 59 | .map_err(|err| DbError::Path(err.to_string()))?; 60 | Ok(base_dir.join("db.sqlite")) 61 | } 62 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Checkbox } from "@ark-ui/react/checkbox"; 4 | import { type CheckboxVariantProps, checkbox } from "styled-system/recipes"; 5 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withProvider, withContext } = createStyleContext(checkbox); 9 | 10 | export type RootProviderProps = ComponentProps; 11 | export const RootProvider = withProvider< 12 | HTMLLabelElement, 13 | Assign< 14 | Assign, Checkbox.RootProviderBaseProps>, 15 | CheckboxVariantProps 16 | > 17 | >(Checkbox.RootProvider, "root"); 18 | 19 | export type RootProps = ComponentProps; 20 | export const Root = withProvider< 21 | HTMLLabelElement, 22 | Assign< 23 | Assign, Checkbox.RootBaseProps>, 24 | CheckboxVariantProps 25 | > 26 | >(Checkbox.Root, "root"); 27 | 28 | export const Control = withContext< 29 | HTMLDivElement, 30 | Assign, Checkbox.ControlBaseProps> 31 | >(Checkbox.Control, "control"); 32 | 33 | export const Group = withContext< 34 | HTMLDivElement, 35 | Assign, Checkbox.GroupBaseProps> 36 | >(Checkbox.Group, "group"); 37 | 38 | export const Indicator = withContext< 39 | HTMLDivElement, 40 | Assign, Checkbox.IndicatorBaseProps> 41 | >(Checkbox.Indicator, "indicator"); 42 | 43 | export const Label = withContext< 44 | HTMLSpanElement, 45 | Assign, Checkbox.LabelBaseProps> 46 | >(Checkbox.Label, "label"); 47 | 48 | export { 49 | CheckboxContext as Context, 50 | CheckboxHiddenInput as HiddenInput, 51 | } from "@ark-ui/react/checkbox"; 52 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/components/lecture-list.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useAtomValue, useSetAtom } from "jotai"; 2 | import { Fragment } from "react"; 3 | import { css } from "styled-system/css"; 4 | import { lectureChecksAtom, toggleLectureCheckAtom } from "../atoms/check"; 5 | import { lectureGroupsAtom, lectureSelectSlugAtom } from "../atoms/lecture"; 6 | import { uniqueKey } from "../services/lectures"; 7 | import { ListItem } from "./list-item"; 8 | 9 | export function LectureList() { 10 | const lectureGroups = useAtomValue(lectureGroupsAtom); 11 | const [selectedLectureSlug, setSelectedLectureSlug] = useAtom( 12 | lectureSelectSlugAtom, 13 | ); 14 | const lectureChecks = useAtomValue(lectureChecksAtom); 15 | const toggleChecks = useSetAtom(toggleLectureCheckAtom); 16 | 17 | return ( 18 |
19 | {lectureGroups?.map((group) => ( 20 | 21 | 32 | {group.name} 33 | 34 | {group.lectures.map((lecture) => ( 35 | 43 | {lecture.name} 44 | 45 | ))} 46 | 47 | ))} 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /apps/website/src/utils/detect-os.ts: -------------------------------------------------------------------------------- 1 | export type DetectedOS = 2 | | { 3 | os: "Windows" | "macOS" | "Linux"; 4 | arch: "arm" | "x86" | null; 5 | } 6 | | { 7 | os: null; 8 | arch: null; 9 | }; 10 | 11 | export async function detectUserOS(): Promise { 12 | if (typeof window === "undefined") return null; 13 | 14 | if (navigator?.userAgentData?.getHighEntropyValues) { 15 | const platform = navigator.userAgentData.platform; 16 | const hi = await navigator.userAgentData.getHighEntropyValues([ 17 | "architecture", 18 | ]); 19 | if (/win/i.test(platform)) { 20 | return { 21 | os: "Windows", 22 | arch: hi.architecture === "x86" ? "x86" : null, 23 | }; 24 | } 25 | if (/mac/i.test(platform)) { 26 | return { 27 | os: "macOS", 28 | arch: 29 | hi.architecture === "arm" || hi.architecture === "arm64" 30 | ? "arm" 31 | : hi.architecture === "x86" || hi.architecture === "x86_64" 32 | ? "x86" 33 | : null, 34 | }; 35 | } 36 | if (/linux/i.test(platform)) { 37 | return { 38 | os: "Linux", 39 | arch: hi.architecture === "x86" ? "x86" : null, 40 | }; 41 | } 42 | } 43 | 44 | const userAgent = window.navigator.userAgent; 45 | 46 | // Windows 47 | if (userAgent.includes("Windows")) { 48 | return { 49 | os: "Windows", 50 | arch: "x86", 51 | }; 52 | } 53 | 54 | // macOS 55 | if (userAgent.includes("Mac")) { 56 | return { 57 | os: "macOS", 58 | arch: null, 59 | }; 60 | } 61 | 62 | // Linux 63 | if (userAgent.includes("Linux")) { 64 | return { 65 | os: "Linux", 66 | arch: "x86", 67 | }; 68 | } 69 | 70 | return { 71 | os: null, 72 | arch: null, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /apps/desktop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "desktop", 3 | "private": true, 4 | "version": "1.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "prepare": "panda codegen", 8 | "dev": "vite", 9 | "build": "tsc -b && vite build", 10 | "preview": "vite preview", 11 | "lint": "biome check", 12 | "format": "biome check --write", 13 | "tauri": "tauri", 14 | "migration": "drizzle-kit generate" 15 | }, 16 | "dependencies": { 17 | "@ark-ui/react": "^5.0.1", 18 | "@conform-to/react": "^1.2.2", 19 | "@conform-to/zod": "^1.2.2", 20 | "@tanstack/react-router": "^1.112.0", 21 | "@tauri-apps/api": "^2.3.0", 22 | "@tauri-apps/plugin-dialog": "~2", 23 | "@tauri-apps/plugin-opener": "~2.5.0", 24 | "@tauri-apps/plugin-process": "~2", 25 | "@tauri-apps/plugin-store": "~2", 26 | "@tauri-apps/plugin-updater": "~2", 27 | "@zag-js/checkbox": "^1.24.2", 28 | "ahooks": "^3.8.4", 29 | "es-toolkit": "^1.32.0", 30 | "jotai": "^2.12.1", 31 | "jotai-derive": "^0.1.2", 32 | "lucide-react": "^0.479.0", 33 | "p-queue": "^8.1.0", 34 | "react": "^19.0.0", 35 | "react-dom": "^19.0.0", 36 | "zod": "^3.24.2" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "^1.9.4", 40 | "@fontsource/ibm-plex-sans-jp": "^5.2.5", 41 | "@fontsource/inter": "^5.2.5", 42 | "@pandacss/dev": "^0.53.0", 43 | "@park-ui/panda-preset": "^0.43.1", 44 | "@tanstack/router-devtools": "^1.112.0", 45 | "@tanstack/router-plugin": "^1.112.3", 46 | "@tauri-apps/cli": "^2.3.1", 47 | "@types/node": "^22.13.8", 48 | "@types/react": "^19.0.10", 49 | "@types/react-dom": "^19.0.4", 50 | "@vitejs/plugin-react": "^4.3.4", 51 | "drizzle-kit": "^0.31.4", 52 | "drizzle-orm": "^0.44.5", 53 | "react-scan": "^0.2.9", 54 | "typescript": "^5.8.2", 55 | "unplugin-fonts": "^1.3.1", 56 | "vite": "^6.2.0", 57 | "vite-tsconfig-paths": "^5.1.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/components/year-select.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useAtomValue } from "jotai"; 2 | import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; 3 | import { createListCollection, Select } from "@/components/ui/select"; 4 | import { availableYearsAtom, yearAtom } from "../atoms/year"; 5 | 6 | export function YearSelect( 7 | props: Omit, "defaultValue">, 8 | ) { 9 | const years = useAtomValue(availableYearsAtom); 10 | const [selectedYear, setSelectedYear] = useAtom(yearAtom); 11 | 12 | const collection = createListCollection({ 13 | items: years.map((year) => ({ 14 | value: year.toString(), 15 | label: `${year}年度`, 16 | })), 17 | }); 18 | 19 | return ( 20 | { 28 | const year = detail.value[0] 29 | ? Number.parseInt(detail.value[0], 10) 30 | : undefined; 31 | setSelectedYear(year); 32 | }} 33 | > 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {collection.items.map((item) => ( 44 | 45 | {item.label} 46 | 47 | 48 | 49 | 50 | ))} 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/login.rs: -------------------------------------------------------------------------------- 1 | use crate::state::CollectState; 2 | use collect::{error::CollectError, Credentials}; 3 | use keyring::Entry; 4 | use tauri::State; 5 | 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum LoginError { 8 | #[error("Core library error: {0}")] 9 | Core(#[from] CollectError), 10 | #[error("Keyring error: {0}")] 11 | Keyring(String), 12 | } 13 | 14 | impl serde::Serialize for LoginError { 15 | fn serialize(&self, serializer: S) -> Result 16 | where 17 | S: serde::Serializer, 18 | { 19 | serializer.serialize_str(&self.to_string()) 20 | } 21 | } 22 | 23 | #[tauri::command] 24 | pub async fn login( 25 | username: String, 26 | password: String, 27 | remember: bool, 28 | state: State<'_, CollectState>, 29 | ) -> Result { 30 | let credentials = Credentials { username, password }; 31 | let collect = &state.collect; 32 | 33 | let authentication_result = collect.authenticate(&credentials).await; 34 | let logged_in = authentication_result.is_ok(); 35 | 36 | if let Err(auth_error) = authentication_result { 37 | if remember { 38 | // Even if authentication failed, we might want to clear any stored credentials 39 | if let Ok(entry) = Entry::new("me.yu7400ki.moocs-collect", &credentials.username) { 40 | let _ = entry.delete_credential(); // Ignore error if credential doesn't exist 41 | } 42 | } 43 | return Err(LoginError::Core(auth_error)); 44 | } 45 | 46 | if logged_in && remember { 47 | let entry = Entry::new("me.yu7400ki.moocs-collect", &credentials.username) 48 | .map_err(|e| LoginError::Keyring(format!("Failed to create keyring entry: {e}")))?; 49 | entry 50 | .set_password(&credentials.password) 51 | .map_err(|e| LoginError::Keyring(format!("Failed to store password: {e}")))?; 52 | } 53 | 54 | Ok(logged_in) 55 | } 56 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/field.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Field } from "@ark-ui/react/field"; 4 | import { styled } from "styled-system/jsx"; 5 | import { 6 | type FieldVariantProps, 7 | field, 8 | input, 9 | textarea, 10 | } from "styled-system/recipes"; 11 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 12 | import { createStyleContext } from "./utils/create-style-context"; 13 | 14 | const { withProvider, withContext } = createStyleContext(field); 15 | 16 | export type RootProviderProps = ComponentProps; 17 | export const RootProvider = withProvider< 18 | HTMLDivElement, 19 | Assign< 20 | Assign, Field.RootProviderBaseProps>, 21 | FieldVariantProps 22 | > 23 | >(Field.RootProvider, "root"); 24 | 25 | export type RootProps = ComponentProps; 26 | export const Root = withProvider< 27 | HTMLDivElement, 28 | Assign, Field.RootBaseProps>, FieldVariantProps> 29 | >(Field.Root, "root"); 30 | 31 | export const ErrorText = withContext< 32 | HTMLSpanElement, 33 | Assign, Field.ErrorTextBaseProps> 34 | >(Field.ErrorText, "errorText"); 35 | 36 | export const HelperText = withContext< 37 | HTMLSpanElement, 38 | Assign, Field.HelperTextBaseProps> 39 | >(Field.HelperText, "helperText"); 40 | 41 | export const Label = withContext< 42 | HTMLLabelElement, 43 | Assign, Field.LabelBaseProps> 44 | >(Field.Label, "label"); 45 | 46 | export const Select = withContext< 47 | HTMLSelectElement, 48 | Assign, Field.SelectBaseProps> 49 | >(Field.Select, "select"); 50 | 51 | export type InputProps = ComponentProps; 52 | export const Input = styled(Field.Input, input); 53 | 54 | export type TextareaProps = ComponentProps; 55 | export const Textarea = styled(Field.Textarea, textarea); 56 | 57 | export { FieldContext as Context } from "@ark-ui/react/field"; 58 | -------------------------------------------------------------------------------- /apps/website/src/framework/entry.rsc.tsx: -------------------------------------------------------------------------------- 1 | import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc"; 2 | import { getStaticPaths, Root } from "../root"; 3 | import { RSC_POSTFIX, type RscPayload } from "./shared"; 4 | 5 | export { getStaticPaths }; 6 | 7 | export default async function handler(request: Request): Promise { 8 | const url = new URL(request.url); 9 | let isRscRequest = false; 10 | if (url.pathname.endsWith(RSC_POSTFIX)) { 11 | isRscRequest = true; 12 | url.pathname = url.pathname.slice(0, -RSC_POSTFIX.length); 13 | } 14 | 15 | const rscPayload: RscPayload = { root: }; 16 | const rscStream = renderToReadableStream(rscPayload); 17 | 18 | if (isRscRequest) { 19 | return new Response(rscStream, { 20 | headers: { 21 | "content-type": "text/x-component;charset=utf-8", 22 | vary: "accept", 23 | }, 24 | }); 25 | } 26 | 27 | const ssr = await import.meta.viteRsc.loadModule< 28 | typeof import("./entry.ssr") 29 | >("ssr", "index"); 30 | const htmlStream = await ssr.renderHtml(rscStream); 31 | 32 | return new Response(htmlStream, { 33 | headers: { 34 | "content-type": "text/html;charset=utf-8", 35 | vary: "accept", 36 | }, 37 | }); 38 | } 39 | 40 | // return both rsc and html streams at once for ssg 41 | export async function handleSsg(request: Request): Promise<{ 42 | html: ReadableStream; 43 | rsc: ReadableStream; 44 | }> { 45 | const url = new URL(request.url); 46 | const rscPayload: RscPayload = { root: }; 47 | const rscStream = renderToReadableStream(rscPayload); 48 | const [rscStream1, rscStream2] = rscStream.tee(); 49 | 50 | const ssr = await import.meta.viteRsc.loadModule< 51 | typeof import("./entry.ssr") 52 | >("ssr", "index"); 53 | const htmlStream = await ssr.renderHtml(rscStream1, { 54 | ssg: true, 55 | }); 56 | 57 | return { html: htmlStream, rsc: rscStream2 }; 58 | } 59 | 60 | if (import.meta.hot) { 61 | import.meta.hot.accept(); 62 | } 63 | -------------------------------------------------------------------------------- /src/service/slide.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{ 2 | models::{PageKey, Slide, SlideContent}, 3 | repository::{AuthenticationRepository, SlideRepository}, 4 | service::SlideService, 5 | }; 6 | use crate::error::Result; 7 | use async_trait::async_trait; 8 | use std::sync::Arc; 9 | 10 | pub struct SlideServiceImpl { 11 | slide_repository: Arc, 12 | auth_repository: Arc, 13 | } 14 | 15 | impl SlideServiceImpl { 16 | pub fn new( 17 | slide_repository: Arc, 18 | auth_repository: Arc, 19 | ) -> Self { 20 | Self { 21 | slide_repository, 22 | auth_repository, 23 | } 24 | } 25 | } 26 | 27 | #[async_trait] 28 | impl SlideService for SlideServiceImpl { 29 | async fn get_slides(&self, page_key: &PageKey) -> Result> { 30 | // Check MOOCs authentication before fetching slides 31 | if !self.auth_repository.is_logged_in_moocs().await? { 32 | return Err(crate::error::CollectError::authentication( 33 | "Not logged into MOOCs system. Please authenticate first.", 34 | )); 35 | } 36 | 37 | self.slide_repository.fetch_slides(page_key).await 38 | } 39 | 40 | async fn get_slide_content(&self, slide: &Slide) -> Result { 41 | // Check both MOOCs and Google authentication for slide content 42 | if !self.auth_repository.is_logged_in_moocs().await? { 43 | return Err(crate::error::CollectError::authentication( 44 | "Not logged into MOOCs system. Please authenticate first.", 45 | )); 46 | } 47 | 48 | if !self.auth_repository.is_logged_in_google().await? { 49 | return Err(crate::error::CollectError::authentication( 50 | "Not logged into Google system. Google authentication is required to access slide content.", 51 | )); 52 | } 53 | 54 | self.slide_repository.fetch_slide_content(slide).await 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { RadioGroup } from "@ark-ui/react/radio-group"; 4 | import { type RadioGroupVariantProps, radioGroup } from "styled-system/recipes"; 5 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withProvider, withContext } = createStyleContext(radioGroup); 9 | 10 | export type RootProviderProps = ComponentProps; 11 | export const RootProvider = withProvider< 12 | HTMLDivElement, 13 | Assign< 14 | Assign, RadioGroup.RootProviderBaseProps>, 15 | RadioGroupVariantProps 16 | > 17 | >(RadioGroup.RootProvider, "root"); 18 | 19 | export type RootProps = ComponentProps; 20 | export const Root = withProvider< 21 | HTMLDivElement, 22 | Assign< 23 | Assign, RadioGroup.RootBaseProps>, 24 | RadioGroupVariantProps 25 | > 26 | >(RadioGroup.Root, "root"); 27 | 28 | export const Indicator = withContext< 29 | HTMLDivElement, 30 | Assign, RadioGroup.IndicatorBaseProps> 31 | >(RadioGroup.Indicator, "indicator"); 32 | 33 | export const ItemControl = withContext< 34 | HTMLDivElement, 35 | Assign, RadioGroup.ItemControlBaseProps> 36 | >(RadioGroup.ItemControl, "itemControl"); 37 | 38 | export const Item = withContext< 39 | HTMLLabelElement, 40 | Assign, RadioGroup.ItemBaseProps> 41 | >(RadioGroup.Item, "item"); 42 | 43 | export const ItemText = withContext< 44 | HTMLSpanElement, 45 | Assign, RadioGroup.ItemTextBaseProps> 46 | >(RadioGroup.ItemText, "itemText"); 47 | 48 | export const Label = withContext< 49 | HTMLLabelElement, 50 | Assign, RadioGroup.LabelBaseProps> 51 | >(RadioGroup.Label, "label"); 52 | 53 | export { 54 | RadioGroupContext as Context, 55 | RadioGroupItemHiddenInput as ItemHiddenInput, 56 | } from "@ark-ui/react/radio-group"; 57 | -------------------------------------------------------------------------------- /apps/website/src/components/download-button.tsx: -------------------------------------------------------------------------------- 1 | import { penguin } from "@lucide/lab"; 2 | import { Apple, Download, Grid2X2, Icon } from "lucide-react"; 3 | 4 | interface DownloadButtonProps { 5 | platform: string; 6 | url: string; 7 | isPrimary?: boolean; 8 | } 9 | 10 | export function DownloadButton({ 11 | platform, 12 | url, 13 | isPrimary = false, 14 | }: DownloadButtonProps) { 15 | const baseClass = 16 | "inline-flex items-center gap-3 rounded-full px-5 py-2.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"; 17 | const variantClass = isPrimary 18 | ? "bg-sky-400 text-slate-950 hover:bg-sky-300 focus-visible:outline-sky-200" 19 | : "border border-slate-600 text-slate-200 hover:border-slate-400 hover:text-slate-100 focus-visible:outline-slate-300"; 20 | 21 | return ( 22 | 23 | 24 | {getPlatformDisplayName(platform)} 25 | 26 | 27 | ); 28 | } 29 | 30 | function PlatformIcon({ platform }: { platform: string }) { 31 | const iconClass = "h-5 w-5"; 32 | 33 | if (platform.startsWith("windows-")) { 34 | return ; 35 | } 36 | if (platform.startsWith("darwin-")) { 37 | return ; 38 | } 39 | if (platform.startsWith("linux-")) { 40 | return ; 41 | } 42 | return ; 43 | } 44 | 45 | function getPlatformDisplayName(platform: string): string { 46 | const names: Record = { 47 | "windows-x86_64-msi": "Windows (MSI)", 48 | "windows-x86_64-nsis": "Windows (EXE)", 49 | "darwin-x86_64-app": "macOS (Intel)", 50 | "darwin-aarch64-app": "macOS (Apple Silicon)", 51 | "linux-x86_64-appimage": "Linux (AppImage)", 52 | "linux-x86_64-deb": "Linux (DEB)", 53 | "linux-x86_64-rpm": "Linux (RPM)", 54 | }; 55 | 56 | return names[platform] || platform; 57 | } 58 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Dialog } from "@ark-ui/react/dialog"; 4 | import { type DialogVariantProps, dialog } from "styled-system/recipes"; 5 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withRootProvider, withContext } = createStyleContext(dialog); 9 | 10 | export type RootProviderProps = ComponentProps; 11 | export const RootProvider = withRootProvider< 12 | Assign 13 | >(Dialog.RootProvider); 14 | 15 | export type RootProps = ComponentProps; 16 | export const Root = withRootProvider< 17 | Assign 18 | >(Dialog.Root); 19 | 20 | export const Backdrop = withContext< 21 | HTMLDivElement, 22 | Assign, Dialog.BackdropBaseProps> 23 | >(Dialog.Backdrop, "backdrop"); 24 | 25 | export const CloseTrigger = withContext< 26 | HTMLButtonElement, 27 | Assign, Dialog.CloseTriggerBaseProps> 28 | >(Dialog.CloseTrigger, "closeTrigger"); 29 | 30 | export const Content = withContext< 31 | HTMLDivElement, 32 | Assign, Dialog.ContentBaseProps> 33 | >(Dialog.Content, "content"); 34 | 35 | export const Description = withContext< 36 | HTMLDivElement, 37 | Assign, Dialog.DescriptionBaseProps> 38 | >(Dialog.Description, "description"); 39 | 40 | export const Positioner = withContext< 41 | HTMLDivElement, 42 | Assign, Dialog.PositionerBaseProps> 43 | >(Dialog.Positioner, "positioner"); 44 | 45 | export const Title = withContext< 46 | HTMLHeadingElement, 47 | Assign, Dialog.TitleBaseProps> 48 | >(Dialog.Title, "title"); 49 | 50 | export const Trigger = withContext< 51 | HTMLButtonElement, 52 | Assign, Dialog.TriggerBaseProps> 53 | >(Dialog.Trigger, "trigger"); 54 | 55 | export { DialogContext as Context } from "@ark-ui/react/dialog"; 56 | -------------------------------------------------------------------------------- /apps/desktop/src/recipes/progress.ts: -------------------------------------------------------------------------------- 1 | import { progressAnatomy } from "@ark-ui/react"; 2 | import { defineSlotRecipe } from "@pandacss/dev"; 3 | 4 | export const progress = defineSlotRecipe({ 5 | className: "progress", 6 | slots: progressAnatomy.keys(), 7 | base: { 8 | root: { 9 | alignItems: "center", 10 | display: "flex", 11 | flexDirection: "column", 12 | gap: "1.5", 13 | width: "full", 14 | }, 15 | label: { 16 | color: "fg.default", 17 | fontWeight: "medium", 18 | textStyle: "sm", 19 | }, 20 | track: { 21 | backgroundColor: "bg.emphasized", 22 | borderRadius: "l2", 23 | overflow: "hidden", 24 | width: "100%", 25 | }, 26 | range: { 27 | backgroundColor: "colorPalette.default", 28 | height: "100%", 29 | transition: "width 0.2s ease-in-out", 30 | "--translate-x": "-100%", 31 | }, 32 | circleTrack: { 33 | stroke: "bg.emphasized", 34 | }, 35 | circleRange: { 36 | stroke: "colorPalette.default", 37 | transitionProperty: "stroke-dasharray, stroke", 38 | transitionDuration: "0.6s", 39 | }, 40 | valueText: { 41 | textStyle: "sm", 42 | }, 43 | }, 44 | defaultVariants: { 45 | size: "md", 46 | }, 47 | variants: { 48 | size: { 49 | xs: { 50 | circle: { 51 | "--size": "28px", 52 | "--thickness": "3px", 53 | }, 54 | track: { 55 | height: "1", 56 | }, 57 | }, 58 | sm: { 59 | circle: { 60 | "--size": "36px", 61 | "--thickness": "4px", 62 | }, 63 | track: { 64 | height: "1.5", 65 | }, 66 | }, 67 | md: { 68 | track: { 69 | height: "2", 70 | }, 71 | circle: { 72 | "--size": "40px", 73 | "--thickness": "4px", 74 | }, 75 | }, 76 | lg: { 77 | track: { 78 | height: "2.5", 79 | }, 80 | circle: { 81 | "--size": "44px", 82 | "--thickness": "4px", 83 | }, 84 | }, 85 | }, 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /apps/desktop/src/features/course/components/list-item.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { css } from "styled-system/css"; 3 | import { Checkbox } from "@/components/ui/checkbox"; 4 | 5 | export type ListItemProps = { 6 | children?: React.ReactNode; 7 | value: T; 8 | selected?: boolean; 9 | onSelect?: (value: NoInfer) => void; 10 | checked?: boolean; 11 | onToggleCheck?: (value: NoInfer) => void; 12 | }; 13 | 14 | function ListItemComponent({ 15 | children, 16 | value, 17 | selected, 18 | onSelect, 19 | checked, 20 | onToggleCheck, 21 | }: ListItemProps) { 22 | return ( 23 | 70 | ); 71 | } 72 | 73 | export const ListItem = memo(ListItemComponent) as typeof ListItemComponent; 74 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/db/migrator.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use sqlx::sqlite::SqliteRow; 4 | use sqlx::{Row, SqlitePool}; 5 | use thiserror::Error; 6 | 7 | #[derive(Debug, Error)] 8 | pub enum MigrationError { 9 | #[error("database error: {0}")] 10 | Sqlx(#[from] sqlx::Error), 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct Migration { 15 | pub statements: &'static [&'static str], 16 | pub folder_millis: i64, 17 | pub hash: &'static str, 18 | pub tag: &'static str, 19 | } 20 | 21 | include!(concat!(env!("OUT_DIR"), "/migrations.rs")); 22 | 23 | pub async fn run_pending_migrations(pool: &SqlitePool) -> Result<(), MigrationError> { 24 | sqlx::query( 25 | "CREATE TABLE IF NOT EXISTS __drizzle_migrations ( 26 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 27 | hash TEXT NOT NULL, 28 | created_at INTEGER 29 | )", 30 | ) 31 | .execute(pool) 32 | .await?; 33 | 34 | let mut applied_hashes = sqlx::query("SELECT hash FROM __drizzle_migrations") 35 | .map(|row: SqliteRow| row.get::("hash")) 36 | .fetch_all(pool) 37 | .await? 38 | .into_iter() 39 | .collect::>(); 40 | 41 | for migration in MIGRATIONS.iter() { 42 | if applied_hashes.contains(migration.hash) { 43 | continue; 44 | } 45 | 46 | log::info!("Applying migration {}", migration.tag); 47 | apply_migration(pool, migration).await?; 48 | applied_hashes.insert(migration.hash.to_owned()); 49 | } 50 | 51 | Ok(()) 52 | } 53 | 54 | async fn apply_migration(pool: &SqlitePool, migration: &Migration) -> Result<(), MigrationError> { 55 | let mut tx = pool.begin().await?; 56 | 57 | for statement in migration.statements { 58 | sqlx::query(statement).execute(&mut *tx).await?; 59 | } 60 | 61 | sqlx::query("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)") 62 | .bind(migration.hash) 63 | .bind(migration.folder_millis) 64 | .execute(&mut *tx) 65 | .await?; 66 | 67 | tx.commit().await?; 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/pdf/mime.rs: -------------------------------------------------------------------------------- 1 | use super::error::ImageConvertError; 2 | 3 | #[derive(Debug, Clone)] 4 | pub enum Mime { 5 | Svg, 6 | Png, 7 | Jpeg, 8 | Gif, 9 | Webp, 10 | Bmp, 11 | Avif, 12 | } 13 | 14 | impl From for &'static str { 15 | fn from(val: Mime) -> Self { 16 | match val { 17 | Mime::Svg => "image/svg+xml", 18 | Mime::Png => "image/png", 19 | Mime::Jpeg => "image/jpeg", 20 | Mime::Gif => "image/gif", 21 | Mime::Webp => "image/webp", 22 | Mime::Bmp => "image/bmp", 23 | Mime::Avif => "image/avif", 24 | } 25 | } 26 | } 27 | 28 | impl TryFrom<&'static str> for Mime { 29 | type Error = ImageConvertError; 30 | 31 | fn try_from(mime_str: &'static str) -> Result { 32 | match mime_str { 33 | "image/svg+xml" => Ok(Mime::Svg), 34 | "image/png" => Ok(Mime::Png), 35 | "image/jpeg" => Ok(Mime::Jpeg), 36 | "image/gif" => Ok(Mime::Gif), 37 | "image/webp" => Ok(Mime::Webp), 38 | "image/bmp" => Ok(Mime::Bmp), 39 | "image/avif" => Ok(Mime::Avif), 40 | _ => Err(ImageConvertError::UnsupportedFormat), 41 | } 42 | } 43 | } 44 | 45 | impl TryFrom<&[u8]> for Mime { 46 | type Error = ImageConvertError; 47 | 48 | fn try_from(bytes: &[u8]) -> Result { 49 | // まずSVGかどうかをチェック 50 | if Mime::is_svg(bytes) { 51 | return Ok(Mime::Svg); 52 | } 53 | 54 | // SVGでなければinferクレートに判定を委譲 55 | match infer::get(bytes) { 56 | Some(info) => info.mime_type().try_into(), 57 | None => Err(ImageConvertError::UnsupportedFormat), 58 | } 59 | } 60 | } 61 | 62 | impl Mime { 63 | /// SVGかどうかを判定(パブリック関数) 64 | pub fn is_svg(bytes: &[u8]) -> bool { 65 | let content = std::str::from_utf8(bytes).unwrap_or(""); 66 | let trimmed = content.trim_start(); 67 | 68 | // XMLヘッダー + SVGルート要素、または直接SVGタグ 69 | (trimmed.starts_with(" { 10 | const course = get(courseSelectAtom); 11 | return course ? getLectureGroups(course) : null; 12 | }); 13 | 14 | export const lectureGroupsAtom = unwrapPromise(internalLectureGroupsAtom); 15 | 16 | // 後方互換性のために、全てのlectureを平坦化したatom 17 | export const internalLecturesAtom = atom((get) => { 18 | const course = get(courseSelectAtom); 19 | return course ? getAllLectures(course) : null; 20 | }); 21 | 22 | export const lecturesAtom = unwrapPromise(internalLecturesAtom); 23 | 24 | export const lectureMapAtom = derive([lecturesAtom], (lectures) => { 25 | return lectures 26 | ? new Map(lectures.map((lecture) => [lecture.slug, lecture])) 27 | : null; 28 | }); 29 | 30 | const internalLectureSelectAtom = atom>(new Map()); 31 | 32 | export const lectureSelectAtom = atom( 33 | (get) => { 34 | const course = get(courseSelectAtom); 35 | const map = get(internalLectureSelectAtom); 36 | return course ? (map.get(uniqueKey(course)) ?? null) : null; 37 | }, 38 | async (get, set, lecture: Lecture | null) => { 39 | const lectureMap = await get(lectureMapAtom); 40 | const course = get(courseSelectAtom); 41 | if (course && (!lecture || lectureMap?.has(lecture.slug))) { 42 | set(internalLectureSelectAtom, (old) => { 43 | const map = new Map(old); 44 | map.set(uniqueKey(course), lecture); 45 | return map; 46 | }); 47 | } 48 | }, 49 | ); 50 | 51 | export const lectureSelectSlugAtom = atom( 52 | (get) => get(lectureSelectAtom)?.slug ?? null, 53 | async (get, set, slug: Lecture["slug"] | null) => { 54 | const map = await get(lectureMapAtom); 55 | const lecture = slug ? map?.get(slug) : null; 56 | set(lectureSelectAtom, lecture ?? null); 57 | }, 58 | ); 59 | -------------------------------------------------------------------------------- /src/service/course.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{ 2 | models::{Course, CourseKey, Year}, 3 | repository::{AuthenticationRepository, CourseRepository}, 4 | service::CourseService, 5 | }; 6 | use crate::error::Result; 7 | use async_trait::async_trait; 8 | use std::sync::Arc; 9 | 10 | pub struct CourseServiceImpl { 11 | course_repository: Arc, 12 | auth_repository: Arc, 13 | } 14 | 15 | impl CourseServiceImpl { 16 | pub fn new( 17 | course_repository: Arc, 18 | auth_repository: Arc, 19 | ) -> Self { 20 | Self { 21 | course_repository, 22 | auth_repository, 23 | } 24 | } 25 | } 26 | 27 | #[async_trait] 28 | impl CourseService for CourseServiceImpl { 29 | async fn get_courses(&self, year: Option) -> Result> { 30 | // Check authentication before fetching courses 31 | if !self.auth_repository.is_logged_in_moocs().await? { 32 | return Err(crate::error::CollectError::authentication( 33 | "Not logged into MOOCs system. Please authenticate first.", 34 | )); 35 | } 36 | 37 | self.course_repository.fetch_course_list(year).await 38 | } 39 | 40 | async fn get_course(&self, course_key: &CourseKey) -> Result { 41 | // Get all courses and find the one matching the key 42 | let courses = self.get_courses(Some(course_key.year.clone())).await?; 43 | courses 44 | .into_iter() 45 | .find(|course| course.key == *course_key) 46 | .ok_or_else(|| { 47 | crate::error::CollectError::not_found(format!("Course not found: {course_key}")) 48 | }) 49 | } 50 | 51 | async fn get_archive_years(&self) -> Result> { 52 | // Check authentication before fetching archive years 53 | if !self.auth_repository.is_logged_in_moocs().await? { 54 | return Err(crate::error::CollectError::authentication( 55 | "Not logged into MOOCs system. Please authenticate first.", 56 | )); 57 | } 58 | 59 | self.course_repository.fetch_archive_years().await 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "formatter": { "enabled": true, "useEditorconfig": true }, 9 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 10 | "files": { 11 | "includes": [ 12 | "**", 13 | "!**/public", 14 | "!**/create-style-context.tsx", 15 | "!**/routeTree.gen.ts" 16 | ] 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "correctness": { 22 | "useImportExtensions": "off", 23 | "noUndeclaredDependencies": "off", 24 | "noUnusedVariables": "off", 25 | "noUndeclaredVariables": "off" 26 | }, 27 | "performance": { 28 | "noBarrelFile": "off", 29 | "noReExportAll": "off", 30 | "noNamespaceImport": "off" 31 | }, 32 | "style": { 33 | "noNamespace": "off", 34 | "noDefaultExport": "off", 35 | "useFilenamingConvention": { 36 | "level": "error", 37 | "options": { 38 | "filenameCases": ["kebab-case"] 39 | } 40 | }, 41 | "noImplicitBoolean": "off", 42 | "useNamingConvention": "off", 43 | "noParameterAssign": "error", 44 | "useAsConstAssertion": "error", 45 | "useDefaultParameterLast": "error", 46 | "useEnumInitializers": "error", 47 | "useSelfClosingElements": "error", 48 | "useSingleVarDeclarator": "error", 49 | "noUnusedTemplateLiteral": "error", 50 | "useNumberNamespace": "error", 51 | "noInferrableTypes": "error", 52 | "noUselessElse": "error" 53 | }, 54 | "suspicious": { 55 | "noReactSpecificProps": "off" 56 | } 57 | } 58 | }, 59 | "javascript": { 60 | "formatter": { "quoteStyle": "double" }, 61 | "globals": ["React"] 62 | }, 63 | "json": { 64 | "parser": { "allowComments": true } 65 | }, 66 | "overrides": [ 67 | { 68 | "includes": ["**/src/routes/**/*"], 69 | "linter": { 70 | "rules": { 71 | "style": { 72 | "useFilenamingConvention": { 73 | "level": "off" 74 | } 75 | } 76 | } 77 | } 78 | } 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/publish-cli.yml: -------------------------------------------------------------------------------- 1 | name: "publish-cli" 2 | on: 3 | push: 4 | tags: 5 | - "cli-v*" 6 | 7 | env: 8 | PROJECT_NAME: collect-cli 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | publish: 15 | strategy: 16 | matrix: 17 | include: 18 | - name: linux-amd64 19 | runner: ubuntu-22.04 20 | target: x86_64-unknown-linux-gnu 21 | - name: win-amd64 22 | runner: windows-latest 23 | target: x86_64-pc-windows-msvc 24 | - name: macos-amd64 25 | runner: macos-latest 26 | target: x86_64-apple-darwin 27 | - name: macos-arm64 28 | runner: macos-latest 29 | target: aarch64-apple-darwin 30 | 31 | runs-on: ${{ matrix.runner }} 32 | 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Install Rust stable 38 | uses: dtolnay/rust-toolchain@stable 39 | with: 40 | targets: ${{ matrix.target }} 41 | 42 | - name: Setup Cache 43 | uses: Swatinem/rust-cache@v2 44 | 45 | - name: Install Dependencies (ubuntu only) 46 | if: matrix.runner == 'ubuntu-22.04' 47 | run: | 48 | sudo apt-get update 49 | sudo apt-get install -y libdbus-1-dev pkg-config 50 | 51 | - name: Build 52 | run: cargo build --verbose --locked --release --target ${{ matrix.target }} -p ${{ env.PROJECT_NAME }} 53 | 54 | - name: Bin Suffix 55 | shell: bash 56 | id: bin_suffix 57 | run: | 58 | case ${{ matrix.target }} in 59 | *-windows-*) SUFFIX=".exe" ;; 60 | *) SUFFIX="" ;; 61 | esac 62 | echo "SUFFIX=$SUFFIX" >> $GITHUB_OUTPUT 63 | 64 | - name: Rename Bin 65 | shell: bash 66 | run: | 67 | mv target/${{ matrix.target }}/release/${{ env.PROJECT_NAME }}${{ steps.bin_suffix.outputs.SUFFIX }} \ 68 | target/${{ matrix.target }}/release/${{ env.PROJECT_NAME }}-${{ matrix.name }}${{ steps.bin_suffix.outputs.SUFFIX }} 69 | 70 | - name: Release 71 | uses: softprops/action-gh-release@v2 72 | with: 73 | files: | 74 | target/${{ matrix.target }}/release/${{ env.PROJECT_NAME }}-${{ matrix.name }}${{ steps.bin_suffix.outputs.SUFFIX }} 75 | draft: true 76 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/tree-view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | CheckSquareIcon, 4 | ChevronRightIcon, 5 | FileIcon, 6 | FolderIcon, 7 | } from "lucide-react"; 8 | import { forwardRef } from "react"; 9 | import * as StyledTreeView from "./styled/tree-view"; 10 | 11 | export const TreeView = forwardRef( 12 | (props, ref) => { 13 | return ( 14 | 15 | 16 | {/* @ts-expect-error */} 17 | {props.collection.rootNode.children.map((node, index) => ( 18 | 19 | ))} 20 | 21 | 22 | ); 23 | }, 24 | ); 25 | 26 | TreeView.displayName = "TreeView"; 27 | 28 | const TreeNode = (props: StyledTreeView.NodeProviderProps) => { 29 | const { node, indexPath } = props; 30 | return ( 31 | 36 | {node.children ? ( 37 | 38 | 39 | 40 | {node.name} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {/* @ts-expect-error */} 49 | {node.children.map((child, index) => ( 50 | 55 | ))} 56 | 57 | 58 | ) : ( 59 | 60 | 61 | 62 | 63 | 64 | 65 | {node.name} 66 | 67 | 68 | )} 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/get_pages.rs: -------------------------------------------------------------------------------- 1 | use crate::state::CollectState; 2 | use collect::{ 3 | error::CollectError, CourseKey, CourseSlug, LectureKey, LecturePage as DomainPage, LectureSlug, 4 | Year, 5 | }; 6 | use tauri::State; 7 | 8 | #[derive(Debug, thiserror::Error)] 9 | pub enum PageError { 10 | #[error("Invalid input: {0}")] 11 | InvalidInput(String), 12 | #[error("Core library error: {0}")] 13 | Core(#[from] CollectError), 14 | } 15 | 16 | impl serde::Serialize for PageError { 17 | fn serialize(&self, serializer: S) -> Result 18 | where 19 | S: serde::Serializer, 20 | { 21 | serializer.serialize_str(&self.to_string()) 22 | } 23 | } 24 | 25 | #[derive(serde::Serialize)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct Page { 28 | pub year: u32, 29 | pub course_slug: String, 30 | pub lecture_slug: String, 31 | pub slug: String, 32 | pub name: String, 33 | } 34 | 35 | impl From for Page { 36 | fn from(page: DomainPage) -> Self { 37 | Self { 38 | year: page.key.lecture_key.course_key.year.value(), 39 | course_slug: page.key.lecture_key.course_key.slug.value().to_string(), 40 | lecture_slug: page.key.lecture_key.slug.value().to_string(), 41 | slug: page.key.slug.value().to_string(), 42 | name: page.display_name().to_string(), 43 | } 44 | } 45 | } 46 | 47 | #[tauri::command] 48 | pub async fn get_pages( 49 | year: u32, 50 | course_slug: String, 51 | lecture_slug: String, 52 | state: State<'_, CollectState>, 53 | ) -> Result, PageError> { 54 | let collect = &state.collect; 55 | 56 | let year_obj = Year::new(year) 57 | .map_err(|e| PageError::InvalidInput(format!("Invalid year {year}: {e}")))?; 58 | let course_slug_obj = CourseSlug::new(course_slug.clone()).map_err(|e| { 59 | PageError::InvalidInput(format!("Invalid course slug '{course_slug}': {e}")) 60 | })?; 61 | let lecture_slug_obj = LectureSlug::new(lecture_slug.clone()).map_err(|e| { 62 | PageError::InvalidInput(format!("Invalid lecture slug '{lecture_slug}': {e}")) 63 | })?; 64 | 65 | let course_key = CourseKey::new(year_obj, course_slug_obj); 66 | let lecture_key = LectureKey::new(course_key, lecture_slug_obj); 67 | 68 | let pages = collect.get_pages(&lecture_key).await?; 69 | 70 | Ok(pages.into_iter().map(Page::from).collect()) 71 | } 72 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Progress } from "@ark-ui/react/progress"; 4 | import { type ProgressVariantProps, progress } from "styled-system/recipes"; 5 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withProvider, withContext } = createStyleContext(progress); 9 | 10 | export type RootProviderProps = ComponentProps; 11 | export const RootProvider = withProvider< 12 | HTMLDivElement, 13 | Assign< 14 | Assign, Progress.RootProviderBaseProps>, 15 | ProgressVariantProps 16 | > 17 | >(Progress.RootProvider, "root"); 18 | 19 | export type RootProps = ComponentProps; 20 | export const Root = withProvider< 21 | HTMLDivElement, 22 | Assign< 23 | Assign, Progress.RootBaseProps>, 24 | ProgressVariantProps 25 | > 26 | >(Progress.Root, "root"); 27 | 28 | export const Circle = withContext< 29 | SVGSVGElement, 30 | Assign, Progress.CircleBaseProps> 31 | >(Progress.Circle, "circle"); 32 | 33 | export const CircleRange = withContext< 34 | SVGCircleElement, 35 | Assign, Progress.CircleRangeBaseProps> 36 | >(Progress.CircleRange, "circleRange"); 37 | 38 | export const CircleTrack = withContext< 39 | SVGCircleElement, 40 | Assign, Progress.CircleTrackBaseProps> 41 | >(Progress.CircleTrack, "circleTrack"); 42 | 43 | export const Label = withContext< 44 | HTMLLabelElement, 45 | Assign, Progress.LabelBaseProps> 46 | >(Progress.Label, "label"); 47 | 48 | export const Range = withContext< 49 | HTMLDivElement, 50 | Assign, Progress.RangeBaseProps> 51 | >(Progress.Range, "range"); 52 | 53 | export const Track = withContext< 54 | HTMLDivElement, 55 | Assign, Progress.TrackBaseProps> 56 | >(Progress.Track, "track"); 57 | 58 | export const ValueText = withContext< 59 | HTMLSpanElement, 60 | Assign, Progress.ValueTextBaseProps> 61 | >(Progress.ValueText, "valueText"); 62 | 63 | export const View = withContext< 64 | HTMLSpanElement, 65 | Assign, Progress.ViewBaseProps> 66 | >(Progress.View, "view"); 67 | 68 | export { ProgressContext as Context } from "@ark-ui/react/progress"; 69 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/number-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { NumberInput } from "@ark-ui/react/number-input"; 4 | import { 5 | type NumberInputVariantProps, 6 | numberInput, 7 | } from "styled-system/recipes"; 8 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 9 | import { createStyleContext } from "./utils/create-style-context"; 10 | 11 | const { withProvider, withContext } = createStyleContext(numberInput); 12 | 13 | export type RootProviderProps = ComponentProps; 14 | export const RootProvider = withProvider< 15 | HTMLDivElement, 16 | Assign< 17 | Assign, NumberInput.RootProviderBaseProps>, 18 | NumberInputVariantProps 19 | > 20 | >(NumberInput.RootProvider, "root"); 21 | 22 | export type RootProps = ComponentProps; 23 | export const Root = withProvider< 24 | HTMLDivElement, 25 | Assign< 26 | Assign, NumberInput.RootBaseProps>, 27 | NumberInputVariantProps 28 | > 29 | >(NumberInput.Root, "root"); 30 | 31 | export const Control = withContext< 32 | HTMLDivElement, 33 | Assign, NumberInput.ControlBaseProps> 34 | >(NumberInput.Control, "control"); 35 | 36 | export const DecrementTrigger = withContext< 37 | HTMLButtonElement, 38 | Assign, NumberInput.DecrementTriggerBaseProps> 39 | >(NumberInput.DecrementTrigger, "decrementTrigger"); 40 | 41 | export const IncrementTrigger = withContext< 42 | HTMLButtonElement, 43 | Assign, NumberInput.IncrementTriggerBaseProps> 44 | >(NumberInput.IncrementTrigger, "incrementTrigger"); 45 | 46 | export const Input = withContext< 47 | HTMLInputElement, 48 | Assign, NumberInput.InputBaseProps> 49 | >(NumberInput.Input, "input"); 50 | 51 | export const Label = withContext< 52 | HTMLLabelElement, 53 | Assign, NumberInput.LabelBaseProps> 54 | >(NumberInput.Label, "label"); 55 | 56 | export const Scrubber = withContext< 57 | HTMLDivElement, 58 | Assign, NumberInput.ScrubberBaseProps> 59 | >(NumberInput.Scrubber, "scrubber"); 60 | 61 | export const ValueText = withContext< 62 | HTMLSpanElement, 63 | Assign, NumberInput.ValueTextBaseProps> 64 | >(NumberInput.ValueText, "valueText"); 65 | 66 | export { NumberInputContext as Context } from "@ark-ui/react/number-input"; 67 | -------------------------------------------------------------------------------- /apps/desktop/src/utils/atom.ts: -------------------------------------------------------------------------------- 1 | import { type Atom, atom, type SetStateAction } from "jotai"; 2 | import { loadable } from "jotai/utils"; 3 | 4 | export function unwrapPromise(promiseAtom: Atom) { 5 | const loadableAtom = loadable(promiseAtom); 6 | return atom>((get) => { 7 | const value = get(promiseAtom); 8 | const loadedValue = get(loadableAtom); 9 | return loadedValue.state === "hasData" ? loadedValue.data : value; 10 | }); 11 | } 12 | 13 | export function atomWithDebounce( 14 | initialValue: T, 15 | delayMilliseconds = 500, 16 | shouldDebounceOnReset = false, 17 | ) { 18 | const prevTimeoutAtom = atom | undefined>( 19 | undefined, 20 | ); 21 | 22 | // DO NOT EXPORT currentValueAtom as using this atom to set state can cause 23 | // inconsistent state between currentValueAtom and debouncedValueAtom 24 | const _currentValueAtom = atom(initialValue); 25 | const isDebouncingAtom = atom(false); 26 | 27 | const debouncedValueAtom = atom( 28 | initialValue, 29 | (get, set, update: SetStateAction) => { 30 | clearTimeout(get(prevTimeoutAtom)); 31 | 32 | const prevValue = get(_currentValueAtom); 33 | const nextValue = 34 | typeof update === "function" 35 | ? (update as (prev: T) => T)(prevValue) 36 | : update; 37 | 38 | const onDebounceStart = () => { 39 | set(_currentValueAtom, nextValue); 40 | set(isDebouncingAtom, true); 41 | }; 42 | 43 | const onDebounceEnd = () => { 44 | set(debouncedValueAtom, nextValue); 45 | set(isDebouncingAtom, false); 46 | }; 47 | 48 | onDebounceStart(); 49 | 50 | if (!shouldDebounceOnReset && nextValue === initialValue) { 51 | onDebounceEnd(); 52 | return; 53 | } 54 | 55 | const nextTimeoutId = setTimeout(() => { 56 | onDebounceEnd(); 57 | }, delayMilliseconds); 58 | 59 | // set previous timeout atom in case it needs to get cleared 60 | set(prevTimeoutAtom, nextTimeoutId); 61 | }, 62 | ); 63 | 64 | // exported atom setter to clear timeout if needed 65 | const clearTimeoutAtom = atom(null, (get, set, _arg) => { 66 | clearTimeout(get(prevTimeoutAtom)); 67 | set(isDebouncingAtom, false); 68 | }); 69 | 70 | return { 71 | currentValueAtom: atom((get) => get(_currentValueAtom)), 72 | isDebouncingAtom, 73 | clearTimeoutAtom, 74 | debouncedValueAtom, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/migrations/0000_superb_monster_badoon.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `courses` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `year` integer NOT NULL, 4 | `slug` text NOT NULL, 5 | `name` text DEFAULT '' NOT NULL, 6 | `sort_index` integer NOT NULL, 7 | `created_at` integer DEFAULT (unixepoch()) NOT NULL, 8 | `updated_at` integer DEFAULT (unixepoch()) NOT NULL 9 | ); 10 | --> statement-breakpoint 11 | CREATE INDEX `idx_courses_year` ON `courses` (`year`);--> statement-breakpoint 12 | CREATE UNIQUE INDEX `uq_courses_year_slug` ON `courses` (`year`,`slug`);--> statement-breakpoint 13 | CREATE TABLE `lectures` ( 14 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 15 | `course_id` integer NOT NULL, 16 | `slug` text NOT NULL, 17 | `name` text DEFAULT '' NOT NULL, 18 | `sort_index` integer NOT NULL, 19 | `created_at` integer DEFAULT (unixepoch()) NOT NULL, 20 | `updated_at` integer DEFAULT (unixepoch()) NOT NULL, 21 | FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON UPDATE no action ON DELETE cascade 22 | ); 23 | --> statement-breakpoint 24 | CREATE INDEX `idx_lectures_course` ON `lectures` (`course_id`);--> statement-breakpoint 25 | CREATE UNIQUE INDEX `uq_lectures_course_slug` ON `lectures` (`course_id`,`slug`);--> statement-breakpoint 26 | CREATE TABLE `pages` ( 27 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 28 | `lecture_id` integer NOT NULL, 29 | `slug` text NOT NULL, 30 | `name` text DEFAULT '' NOT NULL, 31 | `sort_index` integer NOT NULL, 32 | `key` text NOT NULL, 33 | `created_at` integer DEFAULT (unixepoch()) NOT NULL, 34 | `updated_at` integer DEFAULT (unixepoch()) NOT NULL, 35 | FOREIGN KEY (`lecture_id`) REFERENCES `lectures`(`id`) ON UPDATE no action ON DELETE cascade 36 | ); 37 | --> statement-breakpoint 38 | CREATE INDEX `idx_pages_lecture` ON `pages` (`lecture_id`);--> statement-breakpoint 39 | CREATE UNIQUE INDEX `uq_pages_lecture_slug` ON `pages` (`lecture_id`,`slug`);--> statement-breakpoint 40 | CREATE UNIQUE INDEX `uq_pages_key` ON `pages` (`key`);--> statement-breakpoint 41 | CREATE TABLE `slides` ( 42 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 43 | `page_id` integer NOT NULL, 44 | `idx` integer NOT NULL, 45 | `url` text NOT NULL, 46 | `pdf_path` text, 47 | `downloaded_at` integer DEFAULT (unixepoch()) NOT NULL, 48 | FOREIGN KEY (`page_id`) REFERENCES `pages`(`id`) ON UPDATE no action ON DELETE cascade 49 | ); 50 | --> statement-breakpoint 51 | CREATE INDEX `idx_slides_page` ON `slides` (`page_id`);--> statement-breakpoint 52 | CREATE UNIQUE INDEX `uq_slides_page_idx` ON `slides` (`page_id`,`idx`); -------------------------------------------------------------------------------- /src/service/lecture.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{ 2 | models::{CourseKey, Lecture, LectureGroup, LectureKey}, 3 | repository::{AuthenticationRepository, LectureRepository}, 4 | service::LectureService, 5 | }; 6 | use crate::error::Result; 7 | use async_trait::async_trait; 8 | use std::sync::Arc; 9 | 10 | pub struct LectureServiceImpl { 11 | lecture_repository: Arc, 12 | auth_repository: Arc, 13 | } 14 | 15 | impl LectureServiceImpl { 16 | pub fn new( 17 | lecture_repository: Arc, 18 | auth_repository: Arc, 19 | ) -> Self { 20 | Self { 21 | lecture_repository, 22 | auth_repository, 23 | } 24 | } 25 | } 26 | 27 | #[async_trait] 28 | impl LectureService for LectureServiceImpl { 29 | async fn get_lecture_groups(&self, course_key: &CourseKey) -> Result> { 30 | // Check authentication before fetching lectures 31 | if !self.auth_repository.is_logged_in_moocs().await? { 32 | return Err(crate::error::CollectError::authentication( 33 | "Not logged into MOOCs system. Please authenticate first.", 34 | )); 35 | } 36 | 37 | self.lecture_repository 38 | .fetch_lecture_groups(course_key) 39 | .await 40 | } 41 | 42 | async fn get_lectures(&self, course_key: &CourseKey) -> Result> { 43 | // Check authentication before fetching lectures 44 | if !self.auth_repository.is_logged_in_moocs().await? { 45 | return Err(crate::error::CollectError::authentication( 46 | "Not logged into MOOCs system. Please authenticate first.", 47 | )); 48 | } 49 | 50 | Ok(self 51 | .lecture_repository 52 | .fetch_lecture_groups(course_key) 53 | .await? 54 | .into_iter() 55 | .flat_map(|group| group.lectures) 56 | .collect()) 57 | } 58 | 59 | async fn get_lecture(&self, lecture_key: &LectureKey) -> Result { 60 | // Get all lectures for the course and find the one matching the key 61 | let lectures = self.get_lectures(&lecture_key.course_key).await?; 62 | lectures 63 | .into_iter() 64 | .find(|lecture| lecture.key == *lecture_key) 65 | .ok_or_else(|| { 66 | crate::error::CollectError::not_found(format!("Lecture not found: {lecture_key}")) 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /apps/desktop/src/components/ui/styled/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { Assign } from "@ark-ui/react"; 3 | import { Popover } from "@ark-ui/react/popover"; 4 | import { type PopoverVariantProps, popover } from "styled-system/recipes"; 5 | import type { ComponentProps, HTMLStyledProps } from "styled-system/types"; 6 | import { createStyleContext } from "./utils/create-style-context"; 7 | 8 | const { withRootProvider, withContext } = createStyleContext(popover); 9 | 10 | export type RootProviderProps = ComponentProps; 11 | export const RootProvider = withRootProvider< 12 | Assign 13 | >(Popover.RootProvider); 14 | 15 | export type RootProps = ComponentProps; 16 | export const Root = withRootProvider< 17 | Assign 18 | >(Popover.Root); 19 | 20 | export const Anchor = withContext< 21 | HTMLDivElement, 22 | Assign, Popover.AnchorBaseProps> 23 | >(Popover.Anchor, "anchor"); 24 | 25 | export const Arrow = withContext< 26 | HTMLDivElement, 27 | Assign, Popover.ArrowBaseProps> 28 | >(Popover.Arrow, "arrow"); 29 | 30 | export const ArrowTip = withContext< 31 | HTMLDivElement, 32 | Assign, Popover.ArrowTipBaseProps> 33 | >(Popover.ArrowTip, "arrowTip"); 34 | 35 | export const CloseTrigger = withContext< 36 | HTMLButtonElement, 37 | Assign, Popover.CloseTriggerBaseProps> 38 | >(Popover.CloseTrigger, "closeTrigger"); 39 | 40 | export const Content = withContext< 41 | HTMLDivElement, 42 | Assign, Popover.ContentBaseProps> 43 | >(Popover.Content, "content"); 44 | 45 | export const Description = withContext< 46 | HTMLDivElement, 47 | Assign, Popover.DescriptionBaseProps> 48 | >(Popover.Description, "description"); 49 | 50 | export const Indicator = withContext< 51 | HTMLDivElement, 52 | Assign, Popover.IndicatorBaseProps> 53 | >(Popover.Indicator, "indicator"); 54 | 55 | export const Positioner = withContext< 56 | HTMLDivElement, 57 | Assign, Popover.PositionerBaseProps> 58 | >(Popover.Positioner, "positioner"); 59 | 60 | export const Title = withContext< 61 | HTMLDivElement, 62 | Assign, Popover.TitleBaseProps> 63 | >(Popover.Title, "title"); 64 | 65 | export const Trigger = withContext< 66 | HTMLButtonElement, 67 | Assign, Popover.TriggerBaseProps> 68 | >(Popover.Trigger, "trigger"); 69 | 70 | export { PopoverContext as Context } from "@ark-ui/react/popover"; 71 | -------------------------------------------------------------------------------- /.github/workflows/publish-app.yml: -------------------------------------------------------------------------------- 1 | name: "publish-app" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "app-v*" 7 | 8 | jobs: 9 | publish-tauri: 10 | permissions: 11 | contents: write 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - platform: "macos-latest" # for Arm based macs (M1 and above). 17 | args: "--target aarch64-apple-darwin" 18 | - platform: "macos-latest" # for Intel based macs. 19 | args: "--target x86_64-apple-darwin" 20 | - platform: "ubuntu-22.04" # for Tauri v1 you could replace this with ubuntu-20.04. 21 | args: "" 22 | - platform: "windows-latest" 23 | args: "" 24 | 25 | runs-on: ${{ matrix.platform }} 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Node.js 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: lts/* 35 | 36 | - name: Setup pnpm 37 | uses: pnpm/action-setup@v4 38 | with: 39 | version: 10 40 | 41 | - name: Install Rust Stable 42 | uses: dtolnay/rust-toolchain@stable 43 | with: 44 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. 45 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 46 | 47 | - name: Install Dependencies (ubuntu only) 48 | if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above. 49 | run: | 50 | sudo apt-get update 51 | sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 52 | # webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2. 53 | # You can remove the one that doesn't apply to your app to speed up the workflow a bit. 54 | 55 | - name: Install Frontend Dependencies 56 | run: pnpm install --frozen-lockfile --filter=desktop 57 | 58 | - name: Release 59 | uses: tauri-apps/tauri-action@v0 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 63 | TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} 64 | with: 65 | projectPath: apps/desktop 66 | tagName: ${{ github.ref_name }} 67 | releaseName: ${{ github.ref_name }} 68 | releaseDraft: true 69 | prerelease: false 70 | args: ${{ matrix.args }} 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | RUSTFLAGS: -D warnings 13 | 14 | jobs: 15 | rust-check: 16 | runs-on: ubuntu-22.04 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v5 20 | 21 | - name: Install system dependencies 22 | run: | 23 | sudo apt-get update 24 | sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 25 | 26 | - name: Setup pnpm 27 | uses: pnpm/action-setup@v4 28 | with: 29 | version: 10 30 | 31 | - name: Setup Node.js 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: lts/* 35 | cache: pnpm 36 | 37 | - name: Setup Rust toolchain 38 | uses: dtolnay/rust-toolchain@stable 39 | with: 40 | components: rustfmt, clippy 41 | 42 | - name: Install Node dependencies 43 | run: pnpm install --frozen-lockfile --filter=desktop 44 | 45 | - name: Check Rust formatting 46 | run: cargo fmt --all -- --check 47 | 48 | - name: Run Clippy 49 | run: cargo clippy --workspace --all-targets --all-features -- -D warnings 50 | 51 | - name: Run core tests 52 | run: cargo test --locked --manifest-path Cargo.toml 53 | 54 | - name: Build CLI 55 | run: cargo build --locked -p collect-cli 56 | 57 | - name: Build desktop app 58 | uses: tauri-apps/tauri-action@v0 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | projectPath: apps/desktop 63 | includeRelease: false 64 | includeDebug: true 65 | args: --no-bundle 66 | 67 | frontend-check: 68 | runs-on: ubuntu-22.04 69 | steps: 70 | - name: Checkout repository 71 | uses: actions/checkout@v5 72 | 73 | - name: Setup pnpm 74 | uses: pnpm/action-setup@v4 75 | with: 76 | version: 10 77 | 78 | - name: Setup Node.js 79 | uses: actions/setup-node@v4 80 | with: 81 | node-version: lts/* 82 | cache: pnpm 83 | 84 | - name: Install Node dependencies 85 | run: pnpm install --frozen-lockfile 86 | 87 | - name: Run Biome 88 | run: pnpm exec biome ci 89 | 90 | - name: Build Desktop 91 | run: pnpm --filter=desktop run build 92 | 93 | - name: Typecheck Website 94 | run: pnpm --filter=website exec tsc --noEmit 95 | -------------------------------------------------------------------------------- /apps/website/src/window.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2021 Luke Warlow 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | // WICG Spec: https://wicg.github.io/ua-client-hints 26 | 27 | declare interface Navigator extends NavigatorUA {} 28 | declare interface WorkerNavigator extends NavigatorUA {} 29 | 30 | // https://wicg.github.io/ua-client-hints/#navigatorua 31 | declare interface NavigatorUA { 32 | readonly userAgentData?: NavigatorUAData; 33 | } 34 | 35 | // https://wicg.github.io/ua-client-hints/#dictdef-navigatoruabrandversion 36 | interface NavigatorUABrandVersion { 37 | readonly brand: string; 38 | readonly version: string; 39 | } 40 | 41 | // https://wicg.github.io/ua-client-hints/#dictdef-uadatavalues 42 | interface UADataValues { 43 | readonly brands?: NavigatorUABrandVersion[]; 44 | readonly mobile?: boolean; 45 | readonly platform?: string; 46 | readonly architecture?: string; 47 | readonly bitness?: string; 48 | readonly formFactor?: string[]; 49 | readonly model?: string; 50 | readonly platformVersion?: string; 51 | /** @deprecated in favour of fullVersionList */ 52 | readonly uaFullVersion?: string; 53 | readonly fullVersionList?: NavigatorUABrandVersion[]; 54 | readonly wow64?: boolean; 55 | } 56 | 57 | // https://wicg.github.io/ua-client-hints/#dictdef-ualowentropyjson 58 | interface UALowEntropyJSON { 59 | readonly brands: NavigatorUABrandVersion[]; 60 | readonly mobile: boolean; 61 | readonly platform: string; 62 | } 63 | 64 | // https://wicg.github.io/ua-client-hints/#navigatoruadata 65 | interface NavigatorUAData extends UALowEntropyJSON { 66 | getHighEntropyValues(hints: string[]): Promise; 67 | toJSON(): UALowEntropyJSON; 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: pages 2 | 3 | permissions: 4 | contents: read 5 | pages: write 6 | id-token: write 7 | 8 | on: 9 | release: 10 | types: [published] 11 | push: 12 | branches: [main] 13 | paths: 14 | - 'apps/website/**' 15 | workflow_dispatch: 16 | 17 | jobs: 18 | build-and-deploy: 19 | if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'app-v') || github.event_name == 'push' || github.event_name == 'workflow_dispatch' 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | # Download latest.json from release assets if this is a release event 27 | - name: Download release assets 28 | if: github.event_name == 'release' 29 | uses: robinraju/release-downloader@v1 30 | with: 31 | tag: ${{ github.event.release.tag_name }} 32 | fileName: "*.json" 33 | out-file-path: "out" 34 | 35 | - name: Setup pnpm 36 | uses: pnpm/action-setup@v4 37 | with: 38 | version: 10 39 | 40 | - name: Setup Node.js 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: lts/* 44 | cache: 'pnpm' 45 | 46 | - name: Install dependencies 47 | run: pnpm install --frozen-lockfile --filter=website 48 | 49 | # Prepare latest.json for website build 50 | - name: Prepare latest.json for website 51 | run: | 52 | if [ -d "out" ]; then 53 | # Use latest.json from release assets 54 | cp out/latest.json apps/website/public/latest.json 55 | else 56 | # Download existing latest.json for website-only builds 57 | curl --retry 10 --retry-delay 5 --retry-all-errors -f -s \ 58 | -o apps/website/public/latest.json \ 59 | https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/latest.json \ 60 | || echo "No existing latest.json found" 61 | fi 62 | 63 | - name: Configure GitHub Pages 64 | id: pages 65 | uses: actions/configure-pages@v5 66 | 67 | - name: Build website 68 | run: pnpm --filter=website build --base=${{ steps.pages.outputs.base_path }} 69 | 70 | - name: Upload to GitHub Pages 71 | uses: actions/upload-pages-artifact@v3 72 | with: 73 | path: apps/website/dist/client 74 | 75 | deploy: 76 | environment: 77 | name: github-pages 78 | url: ${{ steps.deployment.outputs.page_url }} 79 | runs-on: ubuntu-latest 80 | needs: build-and-deploy 81 | steps: 82 | - name: Deploy to GitHub Pages 83 | id: deployment 84 | uses: actions/deploy-pages@v4 85 | -------------------------------------------------------------------------------- /apps/website/vite.config.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { Readable } from "node:stream"; 4 | import { pathToFileURL } from "node:url"; 5 | import react from "@vitejs/plugin-react"; 6 | import rsc from "@vitejs/plugin-rsc"; 7 | import { defineConfig, type Plugin, type ResolvedConfig } from "vite"; 8 | // import inspect from 'vite-plugin-inspect' 9 | import { RSC_POSTFIX } from "./src/framework/shared"; 10 | 11 | export default defineConfig({ 12 | plugins: [ 13 | // inspect(), 14 | react(), 15 | rsc({ 16 | entries: { 17 | client: "./src/framework/entry.browser.tsx", 18 | rsc: "./src/framework/entry.rsc.tsx", 19 | ssr: "./src/framework/entry.ssr.tsx", 20 | }, 21 | }), 22 | rscSsgPlugin(), 23 | ], 24 | server: { 25 | port: 5174, 26 | }, 27 | }); 28 | 29 | function rscSsgPlugin(): Plugin[] { 30 | return [ 31 | { 32 | name: "rsc-ssg", 33 | config: { 34 | order: "pre", 35 | handler(_config, env) { 36 | return { 37 | appType: env.isPreview ? "mpa" : undefined, 38 | rsc: { 39 | serverHandler: env.isPreview ? false : undefined, 40 | }, 41 | }; 42 | }, 43 | }, 44 | buildApp: { 45 | async handler(builder) { 46 | await renderStatic(builder.config); 47 | }, 48 | }, 49 | }, 50 | ]; 51 | } 52 | 53 | async function renderStatic(config: ResolvedConfig) { 54 | // import server entry 55 | const entryPath = path.join(config.environments.rsc.build.outDir, "index.js"); 56 | const entry: typeof import("./src/framework/entry.rsc") = await import( 57 | pathToFileURL(entryPath).href 58 | ); 59 | 60 | // entry provides a list of static paths 61 | const staticPaths = await entry.getStaticPaths(); 62 | 63 | // render rsc and html 64 | const baseDir = config.environments.client.build.outDir; 65 | for (const staticPatch of staticPaths) { 66 | config.logger.info(`[vite-rsc:ssg] -> ${staticPatch}`); 67 | const { html, rsc } = await entry.handleSsg( 68 | new Request(new URL(staticPatch, "http://ssg.local")), 69 | ); 70 | await writeFileStream( 71 | path.join(baseDir, normalizeHtmlFilePath(staticPatch)), 72 | html, 73 | ); 74 | await writeFileStream(path.join(baseDir, staticPatch + RSC_POSTFIX), rsc); 75 | } 76 | } 77 | 78 | async function writeFileStream(filePath: string, stream: ReadableStream) { 79 | await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); 80 | await fs.promises.writeFile(filePath, Readable.fromWeb(stream as any)); 81 | } 82 | 83 | function normalizeHtmlFilePath(p: string) { 84 | if (p.endsWith("/")) { 85 | return `${p}index.html`; 86 | } 87 | return `${p}.html`; 88 | } 89 | -------------------------------------------------------------------------------- /apps/desktop/src/features/search/atoms/search.ts: -------------------------------------------------------------------------------- 1 | import { atom, type SetStateAction } from "jotai"; 2 | import { loadable, unwrap } from "jotai/utils"; 3 | import { derive } from "jotai-derive"; 4 | import { atomWithDebounce } from "@/utils/atom"; 5 | import { getRecordedCourses, searchSlides } from "../services/search"; 6 | 7 | const searchQueryDebounced = atomWithDebounce("", 300); 8 | 9 | export const searchQueryAtom = atom( 10 | (get) => get(searchQueryDebounced.currentValueAtom), 11 | (_, set, value: SetStateAction) => { 12 | set(searchQueryDebounced.debouncedValueAtom, value); 13 | }, 14 | ); 15 | 16 | export const facetFilterAtom = atom([]); 17 | 18 | const searchParamsAtom = atom((get) => { 19 | const query = get(searchQueryDebounced.debouncedValueAtom); 20 | const filters = get(facetFilterAtom); 21 | 22 | return { 23 | query: query.trim().replace(/\s+/g, " "), 24 | filters, 25 | }; 26 | }); 27 | 28 | const internalSearchResultsAtom = atom(async (get) => { 29 | const params = get(searchParamsAtom); 30 | 31 | try { 32 | const value = await searchSlides(params); 33 | return { 34 | ok: true as const, 35 | value, 36 | }; 37 | } catch (error) { 38 | return { 39 | ok: false as const, 40 | reason: 41 | error instanceof Error 42 | ? error.message 43 | : typeof error === "string" 44 | ? error 45 | : "Unknown error", 46 | }; 47 | } 48 | }); 49 | 50 | export const searchResultsAtom = unwrap( 51 | derive([internalSearchResultsAtom], (r) => (r.ok ? r.value : [])), 52 | (prev) => prev ?? [], 53 | ); 54 | 55 | export const searchErrorAtom = unwrap( 56 | derive([internalSearchResultsAtom], (r) => (r.ok ? null : r.reason)), 57 | (prev) => prev ?? null, 58 | ); 59 | 60 | export const isSearchingAtom = atom((get) => { 61 | const loadableAtom = loadable(internalSearchResultsAtom); 62 | const loadableState = get(loadableAtom); 63 | return loadableState.state === "loading"; 64 | }); 65 | 66 | const refreshTriggerAtom = atom({}); 67 | const internalRecordedCoursesAtom = atom( 68 | (get) => { 69 | get(refreshTriggerAtom); 70 | return getRecordedCourses(); 71 | }, 72 | (_, set) => { 73 | set(refreshTriggerAtom, {}); 74 | }, 75 | ); 76 | 77 | export const recordedCoursesAtom = unwrap( 78 | internalRecordedCoursesAtom, 79 | (prev) => prev ?? [], 80 | ); 81 | 82 | export const groupedRecordedCoursesAtom = atom( 83 | (get) => { 84 | const courses = get(recordedCoursesAtom); 85 | const grouped = new Map(); 86 | for (const course of courses) { 87 | if (!grouped.has(course.year)) { 88 | grouped.set(course.year, []); 89 | } 90 | grouped.get(course.year)?.push(course); 91 | } 92 | return grouped; 93 | }, 94 | (_, set) => set(recordedCoursesAtom), 95 | ); 96 | -------------------------------------------------------------------------------- /apps/desktop/src/features/download/atoms/queue.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import PQueue from "p-queue"; 3 | import type { Course } from "@/features/course/schemas/course"; 4 | import type { Lecture } from "@/features/course/schemas/lecture"; 5 | import type { Page } from "@/features/course/schemas/page"; 6 | import { recordedCoursesAtom } from "@/features/search/atoms/search"; 7 | import { downloadSlides } from "../services/download-slides"; 8 | 9 | export type DownloadItem = Page & { 10 | course: Course; 11 | lecture: Lecture; 12 | }; 13 | 14 | const queue = new PQueue({ concurrency: 5 }); 15 | 16 | const pendingQueue = atom(new Set()); 17 | const runningQueue = atom(new Set()); 18 | const completedQueue = atom(new Set()); 19 | const errorQueue = atom(new Set()); 20 | 21 | export const queueAtom = atom( 22 | (get) => { 23 | return { 24 | pending: get(pendingQueue), 25 | running: get(runningQueue), 26 | completed: get(completedQueue), 27 | error: get(errorQueue), 28 | }; 29 | }, 30 | (_, set, downloadItem: DownloadItem) => { 31 | set(pendingQueue, (prev) => { 32 | const next = new Set(prev); 33 | next.add(downloadItem); 34 | return next; 35 | }); 36 | queue.add(async () => { 37 | set(pendingQueue, (prev) => { 38 | const next = new Set(prev); 39 | next.delete(downloadItem); 40 | return next; 41 | }); 42 | set(runningQueue, (prev) => { 43 | const next = new Set(prev); 44 | next.add(downloadItem); 45 | return next; 46 | }); 47 | try { 48 | const path = await downloadSlides(downloadItem); 49 | set(completedQueue, (prev) => { 50 | const next = new Set(prev); 51 | next.add({ ...downloadItem, path }); 52 | return next; 53 | }); 54 | set(recordedCoursesAtom); 55 | } catch (error) { 56 | const reason = 57 | error instanceof Error 58 | ? error.message 59 | : typeof error === "string" 60 | ? error 61 | : "Unknown error"; 62 | console.error("Download error:", reason); 63 | set(errorQueue, (prev) => { 64 | const next = new Set(prev); 65 | next.add({ ...downloadItem, reason }); 66 | return next; 67 | }); 68 | } 69 | set(runningQueue, (prev) => { 70 | const next = new Set(prev); 71 | next.delete(downloadItem); 72 | return next; 73 | }); 74 | }); 75 | }, 76 | ); 77 | 78 | export const retryAtom = atom(null, (_, set, downloadItem: DownloadItem) => { 79 | set(errorQueue, (prev) => { 80 | const next = new Set(prev); 81 | next.delete(downloadItem); 82 | return next; 83 | }); 84 | set(queueAtom, downloadItem); 85 | }); 86 | -------------------------------------------------------------------------------- /apps/desktop/src-tauri/src/command/get_lectures.rs: -------------------------------------------------------------------------------- 1 | use crate::state::CollectState; 2 | use collect::{ 3 | error::CollectError, CourseKey, CourseSlug, LectureGroup as DomainLectureGroup, Year, 4 | }; 5 | use tauri::State; 6 | 7 | #[derive(Debug, thiserror::Error)] 8 | pub enum LectureError { 9 | #[error("Invalid input: {0}")] 10 | InvalidInput(String), 11 | #[error("Core library error: {0}")] 12 | Core(#[from] CollectError), 13 | } 14 | 15 | impl serde::Serialize for LectureError { 16 | fn serialize(&self, serializer: S) -> Result 17 | where 18 | S: serde::Serializer, 19 | { 20 | serializer.serialize_str(&self.to_string()) 21 | } 22 | } 23 | 24 | #[derive(serde::Serialize)] 25 | #[serde(rename_all = "camelCase")] 26 | pub struct Lecture { 27 | pub year: u32, 28 | pub course_slug: String, 29 | pub slug: String, 30 | pub name: String, 31 | pub index: usize, 32 | } 33 | 34 | #[derive(serde::Serialize)] 35 | pub struct LectureGroup { 36 | pub year: u32, 37 | #[serde(rename = "courseSlug")] 38 | pub course_slug: String, 39 | pub name: String, 40 | pub lectures: Vec, 41 | pub index: usize, 42 | } 43 | 44 | impl From<&collect::Lecture> for Lecture { 45 | fn from(lecture: &collect::Lecture) -> Self { 46 | Self { 47 | year: lecture.key.course_key.year.value(), 48 | course_slug: lecture.key.course_key.slug.value().to_string(), 49 | slug: lecture.key.slug.value().to_string(), 50 | name: lecture.display_name().to_string(), 51 | index: lecture.index, 52 | } 53 | } 54 | } 55 | 56 | impl From<&DomainLectureGroup> for LectureGroup { 57 | fn from(group: &DomainLectureGroup) -> Self { 58 | Self { 59 | year: group.course_key.year.value(), 60 | course_slug: group.course_key.slug.value().to_string(), 61 | name: group.display_name().to_string(), 62 | lectures: group.lectures.iter().map(Lecture::from).collect(), 63 | index: group.index, 64 | } 65 | } 66 | } 67 | 68 | #[tauri::command] 69 | pub async fn get_lectures( 70 | year: u32, 71 | course_slug: String, 72 | state: State<'_, CollectState>, 73 | ) -> Result, LectureError> { 74 | let collect = &state.collect; 75 | 76 | let year_obj = Year::new(year) 77 | .map_err(|e| LectureError::InvalidInput(format!("Invalid year {year}: {e}")))?; 78 | let course_slug_obj = CourseSlug::new(course_slug.clone()).map_err(|e| { 79 | LectureError::InvalidInput(format!("Invalid course slug '{course_slug}': {e}")) 80 | })?; 81 | let course_key = CourseKey::new(year_obj, course_slug_obj); 82 | 83 | let lecture_groups = collect.get_lecture_groups(&course_key).await?; 84 | 85 | Ok(lecture_groups.iter().map(LectureGroup::from).collect()) 86 | } 87 | -------------------------------------------------------------------------------- /apps/desktop/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional stylelint cache 57 | .stylelintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variable files 69 | .env 70 | .env.* 71 | !.env.example 72 | 73 | # parcel-bundler cache (https://parceljs.org/) 74 | .cache 75 | .parcel-cache 76 | 77 | # Next.js build output 78 | .next 79 | out 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | .output 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # vuepress v2.x temp and cache directory 96 | .temp 97 | .cache 98 | 99 | # Sveltekit cache directory 100 | .svelte-kit/ 101 | 102 | # vitepress build output 103 | **/.vitepress/dist 104 | 105 | # vitepress cache directory 106 | **/.vitepress/cache 107 | 108 | # Docusaurus cache and generated files 109 | .docusaurus 110 | 111 | # Serverless directories 112 | .serverless/ 113 | 114 | # FuseBox cache 115 | .fusebox/ 116 | 117 | # DynamoDB Local files 118 | .dynamodb/ 119 | 120 | # Firebase cache directory 121 | .firebase/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v3 130 | .pnp.* 131 | .yarn/* 132 | !.yarn/patches 133 | !.yarn/plugins 134 | !.yarn/releases 135 | !.yarn/sdks 136 | !.yarn/versions 137 | 138 | # Vite files 139 | vite.config.js.timestamp-* 140 | vite.config.ts.timestamp-* 141 | .vite/ 142 | -------------------------------------------------------------------------------- /apps/desktop/src/recipes/checkbox.ts: -------------------------------------------------------------------------------- 1 | import { checkboxAnatomy } from "@ark-ui/react"; 2 | import { defineSlotRecipe } from "@pandacss/dev"; 3 | 4 | export const checkbox = defineSlotRecipe({ 5 | className: "checkbox", 6 | slots: checkboxAnatomy.keys(), 7 | base: { 8 | root: { 9 | alignItems: "center", 10 | display: "flex", 11 | }, 12 | label: { 13 | color: "fg.default", 14 | fontWeight: "medium", 15 | }, 16 | control: { 17 | alignItems: "center", 18 | borderColor: "border.default", 19 | borderWidth: "1px", 20 | color: "colorPalette.fg", 21 | cursor: "pointer", 22 | display: "flex", 23 | justifyContent: "center", 24 | transitionDuration: "normal", 25 | transitionProperty: "border-color, background", 26 | transitionTimingFunction: "default", 27 | background: "bg.default", 28 | _hover: { 29 | background: "bg.subtle", 30 | }, 31 | _checked: { 32 | background: "colorPalette.default", 33 | borderColor: "colorPalette.default", 34 | _hover: { 35 | background: "colorPalette.default", 36 | }, 37 | }, 38 | _indeterminate: { 39 | background: "colorPalette.default", 40 | borderColor: "colorPalette.default", 41 | _hover: { 42 | background: "colorPalette.default", 43 | }, 44 | }, 45 | "&:has(+ :focus-visible)": { 46 | outlineOffset: "2px", 47 | outline: "2px solid", 48 | outlineColor: "border.outline", 49 | _checked: { 50 | outlineColor: "colorPalette.default", 51 | }, 52 | }, 53 | }, 54 | }, 55 | defaultVariants: { 56 | size: "md", 57 | }, 58 | variants: { 59 | size: { 60 | sm: { 61 | root: { 62 | gap: "2", 63 | }, 64 | control: { 65 | width: "4", 66 | height: "4", 67 | borderRadius: "l1", 68 | "& svg": { 69 | width: "3", 70 | height: "3", 71 | }, 72 | }, 73 | label: { 74 | textStyle: "sm", 75 | }, 76 | }, 77 | md: { 78 | root: { 79 | gap: "3", 80 | }, 81 | control: { 82 | width: "5", 83 | height: "5", 84 | borderRadius: "l1", 85 | "& svg": { 86 | width: "3.5", 87 | height: "3.5", 88 | }, 89 | }, 90 | label: { 91 | textStyle: "md", 92 | }, 93 | }, 94 | lg: { 95 | root: { 96 | gap: "4", 97 | }, 98 | control: { 99 | width: "6", 100 | height: "6", 101 | borderRadius: "l1", 102 | "& svg": { 103 | width: "4", 104 | height: "4", 105 | }, 106 | }, 107 | label: { 108 | textStyle: "lg", 109 | }, 110 | }, 111 | }, 112 | }, 113 | }); 114 | --------------------------------------------------------------------------------