ac7157cdca
Introduces a new crate `doctate-client-core` to house shared client-side logic. This includes: - `case_store`: Manages local case marker files and merging with server snapshots. - `snapshot_cache`: Placeholder for caching server responses. - `oneliner_poller`: Placeholder for the background polling task. The workspace configuration and `Cargo.lock` have been updated to include the new crate.
195 lines
6.5 KiB
Rust
195 lines
6.5 KiB
Rust
//! GET /api/oneliners — list oneliners for the authenticated user.
|
|
//!
|
|
//! Time window is caller-controlled via `?hours=N` (default 16, clamped
|
|
//! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner
|
|
//! text (or null if not yet generated), and UTC timestamps for when the
|
|
//! case started and when its oneliner was last written.
|
|
//!
|
|
//! Conditional-GET: the response carries a weak ETag `W/"<unix>-<hours>"`
|
|
//! derived from the per-user watermark. Clients that send back a matching
|
|
//! `If-None-Match` get `304 Not Modified` without touching the filesystem.
|
|
//! The `hours` component is included so a client swapping window sizes
|
|
//! does not get a stale 304.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::SystemTime;
|
|
|
|
use axum::extract::{Query, State};
|
|
use axum::http::{header, HeaderMap, StatusCode};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse};
|
|
use serde::Deserialize;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use time::{Duration, OffsetDateTime};
|
|
use tracing::warn;
|
|
|
|
use crate::auth::AuthenticatedUser;
|
|
use crate::error::AppError;
|
|
use crate::OnelinerWatermark;
|
|
|
|
const DEFAULT_HOURS: u32 = 16;
|
|
const MAX_HOURS: u32 = 168;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct OnelinersQuery {
|
|
hours: Option<u32>,
|
|
}
|
|
|
|
pub async fn handle_oneliners(
|
|
user: AuthenticatedUser,
|
|
State(watermark): State<OnelinerWatermark>,
|
|
Query(params): Query<OnelinersQuery>,
|
|
headers: HeaderMap,
|
|
) -> Result<Response, AppError> {
|
|
let hours = params.hours.unwrap_or(DEFAULT_HOURS).clamp(1, MAX_HOURS);
|
|
let now = OffsetDateTime::now_utc();
|
|
let since = now - Duration::hours(hours as i64);
|
|
|
|
let current_watermark = watermark.read().await.get(&user.slug).copied();
|
|
let etag = build_etag(current_watermark, hours);
|
|
|
|
if client_has_matching_etag(&headers, &etag) {
|
|
return Ok(not_modified(&etag));
|
|
}
|
|
|
|
let oneliners = enumerate_recent_oneliners(&user.data_dir, since).await;
|
|
let body = OnelinersResponse {
|
|
as_of: now.format(&Rfc3339).unwrap_or_default(),
|
|
window_hours: hours,
|
|
oneliners,
|
|
};
|
|
|
|
let mut response = Json(body).into_response();
|
|
if let Ok(value) = etag.parse() {
|
|
response.headers_mut().insert(header::ETAG, value);
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
fn build_etag(watermark: Option<OffsetDateTime>, hours: u32) -> String {
|
|
let seconds = watermark.map(|t| t.unix_timestamp()).unwrap_or(0);
|
|
format!("W/\"{seconds}-{hours}\"")
|
|
}
|
|
|
|
/// Case-sensitive match against the raw `If-None-Match` header value.
|
|
/// No wildcard or comma-split logic — the client is our own code sending
|
|
/// back the exact ETag we handed it.
|
|
fn client_has_matching_etag(headers: &HeaderMap, etag: &str) -> bool {
|
|
headers
|
|
.get(header::IF_NONE_MATCH)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|sent| sent == etag)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn not_modified(etag: &str) -> Response {
|
|
let mut response = StatusCode::NOT_MODIFIED.into_response();
|
|
if let Ok(value) = etag.parse() {
|
|
response.headers_mut().insert(header::ETAG, value);
|
|
}
|
|
response
|
|
}
|
|
|
|
/// Scan `<user_data_dir>/<case_id>/` for cases whose earliest recording
|
|
/// sits inside the `[since, now]` window. Returns entries sorted by
|
|
/// `created_at` descending (newest first). Soft-deleted cases and
|
|
/// non-UUID directories are filtered out.
|
|
async fn enumerate_recent_oneliners(
|
|
user_data_dir: &Path,
|
|
since: OffsetDateTime,
|
|
) -> Vec<OnelinerEntry> {
|
|
let mut entries: Vec<OnelinerEntry> = Vec::new();
|
|
let Ok(mut cases) = tokio::fs::read_dir(user_data_dir).await else {
|
|
return entries;
|
|
};
|
|
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
|
let case_dir = case_entry.path();
|
|
if !case_dir.is_dir() {
|
|
continue;
|
|
}
|
|
let Some(case_id) = case_dir.file_name().and_then(|s| s.to_str()) else {
|
|
continue;
|
|
};
|
|
if uuid::Uuid::parse_str(case_id).is_err() {
|
|
continue;
|
|
}
|
|
if crate::paths::is_deleted(&case_dir).await {
|
|
continue;
|
|
}
|
|
|
|
let Some(created_mtime) = earliest_m4a_mtime(&case_dir).await else {
|
|
continue;
|
|
};
|
|
let created_at = OffsetDateTime::from(created_mtime);
|
|
if created_at < since {
|
|
continue;
|
|
}
|
|
|
|
let (oneliner_text, updated_at) = read_oneliner(&case_dir).await;
|
|
let Ok(created_at_str) = created_at.format(&Rfc3339) else {
|
|
warn!(case = %case_dir.display(), "format created_at failed");
|
|
continue;
|
|
};
|
|
let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok());
|
|
|
|
entries.push(OnelinerEntry {
|
|
case_id: case_id.to_owned(),
|
|
oneliner: oneliner_text,
|
|
created_at: created_at_str,
|
|
updated_at: updated_at_str,
|
|
});
|
|
}
|
|
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
entries
|
|
}
|
|
|
|
/// Return the earliest `.m4a` mtime in `case_dir`. `.m4a.failed` counts —
|
|
/// a failed recording still marks the birth of the case. Returns None on
|
|
/// read errors or empty directories.
|
|
async fn earliest_m4a_mtime(case_dir: &Path) -> Option<SystemTime> {
|
|
let mut files = tokio::fs::read_dir(case_dir).await.ok()?;
|
|
let mut earliest: Option<SystemTime> = None;
|
|
while let Ok(Some(entry)) = files.next_entry().await {
|
|
let path: PathBuf = entry.path();
|
|
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
|
continue;
|
|
};
|
|
if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) {
|
|
continue;
|
|
}
|
|
let Ok(meta) = entry.metadata().await else {
|
|
continue;
|
|
};
|
|
let Ok(mtime) = meta.modified() else {
|
|
continue;
|
|
};
|
|
earliest = match earliest {
|
|
None => Some(mtime),
|
|
Some(e) if mtime < e => Some(mtime),
|
|
other => other,
|
|
};
|
|
}
|
|
earliest
|
|
}
|
|
|
|
/// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file
|
|
/// yields `(None, None)`.
|
|
async fn read_oneliner(case_dir: &Path) -> (Option<String>, Option<OffsetDateTime>) {
|
|
let path = case_dir.join("oneliner.txt");
|
|
let text = tokio::fs::read_to_string(&path)
|
|
.await
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty());
|
|
if text.is_none() {
|
|
return (None, None);
|
|
}
|
|
let mtime = tokio::fs::metadata(&path)
|
|
.await
|
|
.ok()
|
|
.and_then(|m| m.modified().ok())
|
|
.map(OffsetDateTime::from);
|
|
(text, mtime)
|
|
}
|