first commit

This commit is contained in:
Matthias Puchstein
2025-11-14 21:37:55 +01:00
parent 86a5de420c
commit 2464da9f7d
14 changed files with 4100 additions and 0 deletions

1032
src/cli.rs Normal file

File diff suppressed because it is too large Load Diff

114
src/config.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}