first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
10
AGENTS.md
10
AGENTS.md
@@ -29,6 +29,16 @@ The local directory structure must be: `<semester>/<course>/<studip-folders>/<fi
|
|||||||
- `GET /users/{user_id}/courses` to list enrolled courses. [web:85]
|
- `GET /users/{user_id}/courses` to list enrolled courses. [web:85]
|
||||||
- Course-specific routes for folders and documents/file-refs, using the documented JSON:API routes for Stud.IP (e.g. `/courses/{course_id}/documents`). [web:88][web:93]
|
- Course-specific routes for folders and documents/file-refs, using the documented JSON:API routes for Stud.IP (e.g. `/courses/{course_id}/documents`). [web:88][web:93]
|
||||||
|
|
||||||
|
### Field notes (2025-02-16)
|
||||||
|
|
||||||
|
- `/users/me` returns the canonical user ID (`cbcee42edfea…`), full profile attributes, and relationship URLs (courses, folders, file-refs, etc.). Cache the `id` immediately so later runs can skip this discovery call unless credentials change.
|
||||||
|
- `/users/{id}/courses` is paginated via `meta.page { offset, limit, total }` and `links.first/last` (e.g. `/jsonapi.php/v1/users/.../courses?page[offset]=0&page[limit]=30`). Default limit is 30; loop by bumping `offset` until `offset >= total`. Each course provides `start-semester`/`end-semester` relationships to semester IDs, course numbers, and titles.
|
||||||
|
- `/semesters/{id}` exposes only human strings like `"WiSe 2024/25"` plus ISO start/end timestamps—no canonical short keys. Derive keys such as `ws2425` from the title or `start` year and cache the mapping `semester_id → key` in `state.toml`.
|
||||||
|
- `/courses/{id}/folders` lists folder nodes with attributes (`folder-type`, `is-empty`, mkdate/chdate) and nested relationships: follow `/folders/{folder_id}/folders` recursively for subfolders, because `meta.count` only reports a child count.
|
||||||
|
- `/folders/{id}/file-refs` is the primary listing for downloadable files. Each `file-ref` has attributes (`name`, `filesize`, `mkdate`, `chdate`, MIME, `is-downloadable`), relationships back to the parent folder/course, and a `meta.download-url` like `/sendfile.php?...`. Prepend the configured base URL before downloading.
|
||||||
|
- `/files/{id}` only repeats size/timestamp data and links back to `file-refs`; it does **not** expose checksums. Track change detection via `(file-ref id, filesize, chdate)` and/or compute local hashes.
|
||||||
|
- File/folder listings share the same JSON:API pagination scheme. Always honor the `meta.page` counts and `links.first/last/next` to avoid missing entries in large folders.
|
||||||
|
|
||||||
## Configuration (TOML, including paths)
|
## Configuration (TOML, including paths)
|
||||||
|
|
||||||
All configuration and state in this project must use **TOML**. [web:131]
|
All configuration and state in this project must use **TOML**. [web:131]
|
||||||
|
|||||||
2264
Cargo.lock
generated
Normal file
2264
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "studip-sync"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
base64 = "0.22"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
directories = "5.0"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "gzip", "brotli", "deflate", "rustls-tls"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
tokio = { version = "1.37", features = ["macros", "rt-multi-thread", "fs", "io-util"] }
|
||||||
|
toml = "0.8"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] }
|
||||||
|
url = "2.4"
|
||||||
|
atty = "0.2"
|
||||||
|
rpassword = "7.3"
|
||||||
|
walkdir = "2.5"
|
||||||
|
time = "0.3"
|
||||||
|
sha2 = "0.10"
|
||||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# studip-sync
|
||||||
|
|
||||||
|
Command-line tool written in Rust (edition 2024) to sync files from the Stud.IP JSON:API to a local filesystem tree.
|
||||||
|
The repository contains the cargo project (with CLI/config/state scaffolding) plus an offline copy of the JSON:API documentation (in `jsonapi/`).
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
- `cargo` binary crate scaffolded with name `studip-sync`, pinned to Rust edition 2024.
|
||||||
|
- CLI implemented with `auth`, `sync`, and `list-courses` subcommands plus logging/verbosity flags.
|
||||||
|
- Config/state loaders wired up with XDG path resolution, multi-profile support, and JSON/quiet/debug logging modes.
|
||||||
|
- `studip-sync auth` prompts for credentials (or reads `--username/--password` / `STUDIP_SYNC_USERNAME|PASSWORD`) and stores the base64 Basic auth token in the active profile.
|
||||||
|
- `studip-sync list-courses` now talks to the Stud.IP JSON:API, caches user/semester/course metadata, and prints a table of enrolled courses (with pagination + semester-key inference).
|
||||||
|
- `studip-sync sync` walks courses → folders → file refs via the JSON:API, downloads missing or changed files (streamed to disk), and supports `--dry-run` / `--prune` cleanup.
|
||||||
|
- Ready for further implementation of Stud.IP HTTP client, sync logic, and actual command behaviors.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
1. Add configurable download concurrency plus richer progress/logging (per-course summaries, ETA) while keeping memory usage low.
|
||||||
|
2. Implement smarter state usage (incremental `filter[since]` queries, resume checkpoints) and expand pruning to detect/cleanup orphaned state entries.
|
||||||
|
3. Add tests and ensure `cargo fmt` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo test` pass (and wire into CI if applicable).
|
||||||
1032
src/cli.rs
Normal file
1032
src/cli.rs
Normal file
File diff suppressed because it is too large
Load Diff
114
src/config.rs
Normal file
114
src/config.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
use crate::Result;
|
||||||
|
use anyhow::Context;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::Write,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE_NAME: &str = "default";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigProfile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[serde(default = "ConfigProfile::default_base_url")]
|
||||||
|
pub base_url: String,
|
||||||
|
#[serde(default = "ConfigProfile::default_jsonapi_path")]
|
||||||
|
pub jsonapi_path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub basic_auth_b64: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub download_root: Option<PathBuf>,
|
||||||
|
#[serde(default = "ConfigProfile::default_max_concurrent_downloads")]
|
||||||
|
pub max_concurrent_downloads: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConfigProfile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
username: None,
|
||||||
|
base_url: Self::default_base_url(),
|
||||||
|
jsonapi_path: Self::default_jsonapi_path(),
|
||||||
|
basic_auth_b64: None,
|
||||||
|
download_root: None,
|
||||||
|
max_concurrent_downloads: Self::default_max_concurrent_downloads(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigProfile {
|
||||||
|
fn default_base_url() -> String {
|
||||||
|
"https://studip.uni-trier.de".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_jsonapi_path() -> String {
|
||||||
|
"/jsonapi.php/v1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_concurrent_downloads() -> usize {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigFile {
|
||||||
|
#[serde(default = "default_profile_name")]
|
||||||
|
pub default_profile: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub profiles: HashMap<String, ConfigProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConfigFile {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut profiles = HashMap::new();
|
||||||
|
profiles.insert(DEFAULT_PROFILE_NAME.to_string(), ConfigProfile::default());
|
||||||
|
Self {
|
||||||
|
default_profile: DEFAULT_PROFILE_NAME.to_string(),
|
||||||
|
profiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_profile_name() -> String {
|
||||||
|
DEFAULT_PROFILE_NAME.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigFile {
|
||||||
|
pub fn load_or_default(path: &Path) -> Result<Self> {
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => {
|
||||||
|
let file: Self =
|
||||||
|
toml::from_str(&contents).context("Failed to parse config.toml")?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = toml::to_string_pretty(self)?;
|
||||||
|
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = file.metadata()?.permissions();
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
file.set_permissions(perms)?;
|
||||||
|
}
|
||||||
|
file.write_all(contents.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod cli;
|
||||||
|
pub mod config;
|
||||||
|
pub mod logging;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod semesters;
|
||||||
|
pub mod state;
|
||||||
|
pub mod studip_client;
|
||||||
|
|
||||||
|
pub type Result<T> = anyhow::Result<T>;
|
||||||
61
src/logging.rs
Normal file
61
src/logging.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use crate::Result;
|
||||||
|
use tracing::Level;
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct LogConfig {
|
||||||
|
pub quiet: bool,
|
||||||
|
pub debug: bool,
|
||||||
|
pub json: bool,
|
||||||
|
pub verbosity: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogConfig {
|
||||||
|
pub fn level(&self) -> Level {
|
||||||
|
if self.quiet {
|
||||||
|
return Level::ERROR;
|
||||||
|
}
|
||||||
|
if self.debug {
|
||||||
|
return Level::DEBUG;
|
||||||
|
}
|
||||||
|
match self.verbosity {
|
||||||
|
0 => Level::INFO,
|
||||||
|
1 => Level::DEBUG,
|
||||||
|
_ => Level::TRACE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_logging(cfg: &LogConfig) -> Result<()> {
|
||||||
|
let level = cfg.level();
|
||||||
|
|
||||||
|
let env_filter =
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level.to_string()));
|
||||||
|
|
||||||
|
let registry = tracing_subscriber::registry().with(env_filter);
|
||||||
|
|
||||||
|
let init_result = if cfg.json {
|
||||||
|
registry
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.with_target(false)
|
||||||
|
.with_level(true)
|
||||||
|
.with_ansi(atty::is(atty::Stream::Stdout))
|
||||||
|
.with_timer(fmt::time::SystemTime)
|
||||||
|
.json(),
|
||||||
|
)
|
||||||
|
.try_init()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.with_target(false)
|
||||||
|
.with_level(true)
|
||||||
|
.with_ansi(atty::is(atty::Stream::Stdout))
|
||||||
|
.with_timer(fmt::time::SystemTime),
|
||||||
|
)
|
||||||
|
.try_init()
|
||||||
|
};
|
||||||
|
|
||||||
|
init_result.map_err(|err| err.into())
|
||||||
|
}
|
||||||
8
src/main.rs
Normal file
8
src/main.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use studip_sync::{Result, cli::Cli};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
cli.run().await
|
||||||
|
}
|
||||||
58
src/paths.rs
Normal file
58
src/paths.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use crate::Result;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PathOverrides {
|
||||||
|
pub config_dir: Option<PathBuf>,
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppPaths {
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppPaths {
|
||||||
|
pub fn new(overrides: &PathOverrides) -> Result<Self> {
|
||||||
|
if let (Some(config_dir), Some(data_dir)) = (&overrides.config_dir, &overrides.data_dir) {
|
||||||
|
return Ok(Self {
|
||||||
|
config_dir: config_dir.clone(),
|
||||||
|
data_dir: data_dir.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_dirs = ProjectDirs::from("de", "UniTrier", "studip-sync")
|
||||||
|
.ok_or_else(|| anyhow!("Failed to determine XDG directories"))?;
|
||||||
|
|
||||||
|
let config_dir = overrides
|
||||||
|
.config_dir
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| project_dirs.config_dir().to_path_buf());
|
||||||
|
let data_dir = overrides
|
||||||
|
.data_dir
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| project_dirs.data_dir().to_path_buf());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config_dir,
|
||||||
|
data_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_dirs(&self) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(&self.config_dir)?;
|
||||||
|
std::fs::create_dir_all(&self.data_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_file(&self) -> PathBuf {
|
||||||
|
self.config_dir.join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_file(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("state.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/semesters.rs
Normal file
107
src/semesters.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
pub fn infer_key(title: &str) -> String {
|
||||||
|
use SemesterSeason::*;
|
||||||
|
|
||||||
|
let season = detect_season(title);
|
||||||
|
let numbers = extract_numbers(title);
|
||||||
|
|
||||||
|
match season {
|
||||||
|
Winter => {
|
||||||
|
let first = numbers
|
||||||
|
.get(0)
|
||||||
|
.map(|value| last_two_digits(value))
|
||||||
|
.unwrap_or_else(|| "00".into());
|
||||||
|
let second = numbers
|
||||||
|
.get(1)
|
||||||
|
.map(|value| last_two_digits(value))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// winter spans two years; if only one provided, assume +1
|
||||||
|
numbers
|
||||||
|
.get(0)
|
||||||
|
.map(|n| increment_two_digits(n))
|
||||||
|
.unwrap_or_else(|| "00".into())
|
||||||
|
});
|
||||||
|
format!("ws{first}{second}")
|
||||||
|
}
|
||||||
|
Summer => {
|
||||||
|
let year = numbers
|
||||||
|
.get(0)
|
||||||
|
.map(|value| last_two_digits(value))
|
||||||
|
.unwrap_or_else(|| "00".into());
|
||||||
|
format!("ss{year}")
|
||||||
|
}
|
||||||
|
Unknown => fallback_key(title),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
enum SemesterSeason {
|
||||||
|
Winter,
|
||||||
|
Summer,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_season(title: &str) -> SemesterSeason {
|
||||||
|
let lower = title.to_ascii_lowercase();
|
||||||
|
if lower.contains("wise")
|
||||||
|
|| lower.contains("winter")
|
||||||
|
|| lower.contains("ws ")
|
||||||
|
|| lower.starts_with("ws")
|
||||||
|
{
|
||||||
|
SemesterSeason::Winter
|
||||||
|
} else if lower.contains("sose")
|
||||||
|
|| lower.contains("sommer")
|
||||||
|
|| lower.contains("summer")
|
||||||
|
|| lower.contains("ss ")
|
||||||
|
|| lower.starts_with("ss")
|
||||||
|
{
|
||||||
|
SemesterSeason::Summer
|
||||||
|
} else {
|
||||||
|
SemesterSeason::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_numbers(title: &str) -> Vec<String> {
|
||||||
|
let mut numbers = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
for ch in title.chars() {
|
||||||
|
if ch.is_ascii_digit() {
|
||||||
|
current.push(ch);
|
||||||
|
} else if !current.is_empty() {
|
||||||
|
numbers.push(current.clone());
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
numbers.push(current);
|
||||||
|
}
|
||||||
|
numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_two_digits(value: &str) -> String {
|
||||||
|
if value.len() <= 2 {
|
||||||
|
format!("{:0>2}", value)
|
||||||
|
} else {
|
||||||
|
value[value.len() - 2..].to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_two_digits(value: &str) -> String {
|
||||||
|
let last_two = last_two_digits(value);
|
||||||
|
let parsed = last_two.parse::<u8>().unwrap_or(0);
|
||||||
|
format!("{:02}", (parsed + 1) % 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_key(title: &str) -> String {
|
||||||
|
let mut key = String::from("sem-");
|
||||||
|
for ch in title.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
key.push(ch.to_ascii_lowercase());
|
||||||
|
} else if ch.is_whitespace() && !key.ends_with('-') {
|
||||||
|
key.push('-');
|
||||||
|
}
|
||||||
|
if key.len() >= 32 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key.trim_end_matches('-').to_string()
|
||||||
|
}
|
||||||
85
src/state.rs
Normal file
85
src/state.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct StateFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub profiles: HashMap<String, ProfileState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub semesters: HashMap<String, SemesterState>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub courses: HashMap<String, CourseState>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: HashMap<String, FileState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct SemesterState {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct CourseState {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub semester_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_sync: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct FileState {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub checksum: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_downloaded: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub remote_modified: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path_hint: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateFile {
|
||||||
|
pub fn load_or_default(path: &Path) -> Result<Self> {
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => Ok(toml::from_str(&contents)?),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let contents = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(path, contents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profile_mut(&mut self, profile: &str) -> &mut ProfileState {
|
||||||
|
self.profiles
|
||||||
|
.entry(profile.to_string())
|
||||||
|
.or_insert_with(ProfileState::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profile(&self, profile: &str) -> Option<&ProfileState> {
|
||||||
|
self.profiles.get(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
307
src/studip_client.rs
Normal file
307
src/studip_client.rs
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
use crate::{Result, config::ConfigProfile};
|
||||||
|
use anyhow::{Context, anyhow, bail};
|
||||||
|
use reqwest::{
|
||||||
|
Client, Response,
|
||||||
|
header::{AUTHORIZATION, HeaderValue},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, de::DeserializeOwned};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StudipClient {
|
||||||
|
http: Client,
|
||||||
|
base: Url,
|
||||||
|
root: Url,
|
||||||
|
auth_header: HeaderValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StudipClient {
|
||||||
|
pub fn from_profile(profile: &ConfigProfile) -> Result<Self> {
|
||||||
|
let auth_b64 = profile.basic_auth_b64.as_ref().ok_or_else(|| {
|
||||||
|
anyhow!("No credentials configured for this profile. Run `studip-sync auth` first.")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut auth_header = HeaderValue::from_str(&format!("Basic {}", auth_b64))
|
||||||
|
.context("Invalid base64 credentials")?;
|
||||||
|
auth_header.set_sensitive(true);
|
||||||
|
|
||||||
|
let (base, root) = build_root_and_api_urls(profile)?;
|
||||||
|
|
||||||
|
let http = Client::builder()
|
||||||
|
.user_agent("studip-sync/0.1")
|
||||||
|
.build()
|
||||||
|
.context("Failed to build HTTP client")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
base,
|
||||||
|
root,
|
||||||
|
auth_header,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn current_user(&self) -> Result<UserResponse> {
|
||||||
|
let url = self.endpoint("users/me")?;
|
||||||
|
self.get_json(url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn semester(&self, semester_id: &str) -> Result<SemesterResponse> {
|
||||||
|
let url = self.endpoint(&format!("semesters/{semester_id}"))?;
|
||||||
|
self.get_json(url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_courses(&self, user_id: &str) -> Result<Vec<Course>> {
|
||||||
|
let path = format!("users/{user_id}/courses");
|
||||||
|
self.fetch_all_pages(&path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_course_folders(&self, course_id: &str) -> Result<Vec<Folder>> {
|
||||||
|
let path = format!("courses/{course_id}/folders");
|
||||||
|
self.fetch_all_pages(&path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_subfolders(&self, folder_id: &str) -> Result<Vec<Folder>> {
|
||||||
|
let path = format!("folders/{folder_id}/folders");
|
||||||
|
self.fetch_all_pages(&path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_file_refs(&self, folder_id: &str) -> Result<Vec<FileRef>> {
|
||||||
|
let path = format!("folders/{folder_id}/file-refs");
|
||||||
|
self.fetch_all_pages(&path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_file(&self, relative_path: &str) -> Result<Response> {
|
||||||
|
let url = self.download_endpoint(relative_path)?;
|
||||||
|
self.send_request(url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(&self, path: &str) -> Result<Url> {
|
||||||
|
let normalized = path.trim_start_matches('/');
|
||||||
|
self.root.join(normalized).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_json<T>(&self, url: Url) -> Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.send_request(url)
|
||||||
|
.await?
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_all_pages<T>(&self, path: &str) -> Result<Vec<T>>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let mut offset = 0usize;
|
||||||
|
let limit = 100usize;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut url = self.endpoint(path)?;
|
||||||
|
{
|
||||||
|
let mut pairs = url.query_pairs_mut();
|
||||||
|
pairs.append_pair("page[offset]", &offset.to_string());
|
||||||
|
pairs.append_pair("page[limit]", &limit.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ListResponse { data, meta, .. } = self.get_json(url).await?;
|
||||||
|
let count = data.len();
|
||||||
|
items.extend(data);
|
||||||
|
|
||||||
|
let total = meta
|
||||||
|
.and_then(|meta| meta.page)
|
||||||
|
.map(|page| page.total)
|
||||||
|
.unwrap_or(offset + count);
|
||||||
|
|
||||||
|
if offset + limit >= total || count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
fn download_endpoint(&self, path: &str) -> Result<Url> {
|
||||||
|
let normalized = path.trim_start_matches('/');
|
||||||
|
self.base.join(normalized).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_request(&self, url: Url) -> Result<Response> {
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(url.clone())
|
||||||
|
.header(AUTHORIZATION, self.auth_header.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("GET {}", url))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
bail!("Stud.IP request failed ({status}) - {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_root_and_api_urls(profile: &ConfigProfile) -> Result<(Url, Url)> {
|
||||||
|
let base = profile.base_url.trim_end_matches('/');
|
||||||
|
let jsonapi_path = profile.jsonapi_path.trim_start_matches('/');
|
||||||
|
|
||||||
|
let base_url = Url::parse(base).context("Invalid base_url")?;
|
||||||
|
let mut api_url = base_url.clone();
|
||||||
|
api_url.set_path(jsonapi_path);
|
||||||
|
if !api_url.path().ends_with('/') {
|
||||||
|
api_url.set_path(&format!("{}/", api_url.path().trim_end_matches('/')));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((base_url, api_url))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub data: UserData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UserData {
|
||||||
|
pub id: String,
|
||||||
|
pub attributes: UserAttributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UserAttributes {
|
||||||
|
pub username: String,
|
||||||
|
#[serde(rename = "formatted-name")]
|
||||||
|
pub formatted_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SemesterResponse {
|
||||||
|
pub data: SemesterData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SemesterData {
|
||||||
|
pub id: String,
|
||||||
|
pub attributes: SemesterAttributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SemesterAttributes {
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Course {
|
||||||
|
pub id: String,
|
||||||
|
pub attributes: CourseAttributes,
|
||||||
|
pub relationships: CourseRelationships,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CourseAttributes {
|
||||||
|
#[serde(rename = "course-number")]
|
||||||
|
pub course_number: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subtitle: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CourseRelationships {
|
||||||
|
#[serde(rename = "start-semester")]
|
||||||
|
pub start_semester: Option<Relationship>,
|
||||||
|
#[serde(rename = "end-semester")]
|
||||||
|
pub end_semester: Option<Relationship>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CourseRelationships {
|
||||||
|
pub fn semester_id(&self) -> Option<&str> {
|
||||||
|
self.start_semester
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|rel| rel.data.as_ref())
|
||||||
|
.map(|link| link.id.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Relationship {
|
||||||
|
pub data: Option<ResourceIdentifier>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ResourceIdentifier {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub resource_type: String,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ListResponse<T> {
|
||||||
|
pub data: Vec<T>,
|
||||||
|
pub meta: Option<ListMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ListMeta {
|
||||||
|
pub page: Option<PageMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PageMeta {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub offset: usize,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub limit: usize,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct Folder {
|
||||||
|
pub id: String,
|
||||||
|
pub attributes: FolderAttributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct FolderAttributes {
|
||||||
|
#[serde(rename = "folder-type")]
|
||||||
|
pub folder_type: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "is-readable")]
|
||||||
|
pub is_readable: bool,
|
||||||
|
#[serde(rename = "is-visible")]
|
||||||
|
pub is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct FileRef {
|
||||||
|
pub id: String,
|
||||||
|
pub attributes: FileRefAttributes,
|
||||||
|
pub meta: Option<FileRefMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct FileRefAttributes {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "mkdate")]
|
||||||
|
pub created: String,
|
||||||
|
#[serde(rename = "chdate")]
|
||||||
|
pub modified: String,
|
||||||
|
#[serde(rename = "filesize")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_size: Option<u64>,
|
||||||
|
#[serde(rename = "mime-type")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct FileRefMeta {
|
||||||
|
#[serde(rename = "download-url")]
|
||||||
|
pub download_url: String,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user