Import historical ratings from arbitrary URLs as a background job
The Ratings page gains an "Import ratings" form: paste URLs (one per line), choose a verdict and an optional note. Rows land in the new rating_imports table and the new import-ratings job (same systemd job template as the rest of the catalogue) canonicalizes each URL, reuses or fetches + extracts the article, embeds it with Voyage when enabled, and appends an explicit rating event with source "import". Per-URL status shows on the Ratings page; the job page's journal is the live log. Imported articles have no entry row (best_entry_id NULL, feed "Imported") and no sources, so they act as rated neighbours without touching the feed prior. The CLI's rating-event construction moves to rate::record_explicit and the dashboard job start path is shared as jobs::start_job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
1 parent
142a8d9905
commit
d94cefcbd7
14 files changed
+1107
-102
No files matched your search
+78
-53
@@ -196,7 +196,7 @@ fn form_date(body: &[u8]) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
async fn set_flash(session: &Session, kind: &str, text: String) -> Result<(), WebError> {
|
||||
pub(crate) async fn set_flash(session: &Session, kind: &str, text: String) -> Result<(), WebError> {
|
||||
session
|
||||
.insert(
|
||||
"flash",
|
||||
@@ -209,6 +209,57 @@ async fn set_flash(session: &Session, kind: &str, text: String) -> Result<(), We
|
||||
.map_err(|error| WebError::Internal(error.into()))
|
||||
}
|
||||
|
||||
pub(crate) enum StartJob {
|
||||
Disabled,
|
||||
Active(i64),
|
||||
Started(i64),
|
||||
Failed(i64, String),
|
||||
}
|
||||
|
||||
/// Request one catalogue job through the configured runner.
|
||||
pub(crate) async fn start_job(
|
||||
state: &AppState,
|
||||
viewer: &Viewer,
|
||||
job: Job,
|
||||
) -> Result<StartJob, WebError> {
|
||||
if !state.config().server.jobs_enabled {
|
||||
return Ok(StartJob::Disabled);
|
||||
}
|
||||
let unit = job.unit();
|
||||
if let Some(active) = jobs::active_for_unit(&state.db, &unit)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
{
|
||||
return Ok(StartJob::Active(active));
|
||||
}
|
||||
if let Err(error) = crate::web::WebState::reload_if_changed(state) {
|
||||
tracing::warn!(%error, "config.toml on disk does not load; keeping the previous config");
|
||||
}
|
||||
let id = jobs::insert_requested(&state.db, &job, Some(viewer.id), Timestamp::now())
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
match state.web.jobs.start(&unit).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(user = %viewer.username, job = %job.name(), job_id = id, %unit, "job requested");
|
||||
Ok(StartJob::Started(id))
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(user = %viewer.username, job = %job.name(), job_id = id, %unit, %error, "job start failed");
|
||||
jobs::finish(
|
||||
&state.db,
|
||||
id,
|
||||
jobs::Outcome::Failed,
|
||||
&format!("could not start {unit}: {error}"),
|
||||
None,
|
||||
Timestamp::now(),
|
||||
)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(StartJob::Failed(id, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /dashboard/jobs/{name}` (admin, origin-checked): parse the name,
|
||||
/// refuse a duplicate `requested`/`running` unit (409), insert `requested`,
|
||||
/// start the unit; a failed start marks the row `failed`.
|
||||
@@ -233,67 +284,41 @@ async fn start(
|
||||
.map_err(|_| WebError::BadRequest(format!("invalid date {date:?}")))?;
|
||||
job = Job::Generate { date: Some(parsed) };
|
||||
}
|
||||
let config = state.config();
|
||||
if !config.server.jobs_enabled {
|
||||
set_flash(
|
||||
&session,
|
||||
"error",
|
||||
"Jobs are disabled on this server (server.jobs_enabled = false).".into(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(Redirect::to("/dashboard/jobs").into_response());
|
||||
}
|
||||
let unit = job.unit();
|
||||
if let Some(active) = jobs::active_for_unit(&state.db, &unit)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
{
|
||||
let flash = Flash {
|
||||
kind: "error".into(),
|
||||
text: format!(
|
||||
"{} is already requested or running (job {active}).",
|
||||
job.name()
|
||||
),
|
||||
};
|
||||
let template = jobs_template(&state, Some(viewer), Some(flash)).await?;
|
||||
return Ok((StatusCode::CONFLICT, Html(template)).into_response());
|
||||
}
|
||||
|
||||
// Pick up a hand-edited config.toml before the unit starts (§4.2); a file
|
||||
// that no longer loads keeps the previous config live and is only logged.
|
||||
if let Err(error) = crate::web::WebState::reload_if_changed(&state) {
|
||||
tracing::warn!(%error, "config.toml on disk does not load; keeping the previous config");
|
||||
}
|
||||
let now = Timestamp::now();
|
||||
let id = jobs::insert_requested(&state.db, &job, Some(viewer.id), now)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
match state.web.jobs.start(&unit).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(user = %viewer.username, job = %job.name(), job_id = id, %unit, "job requested");
|
||||
set_flash(&session, "success", format!("Started {}.", job.name())).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(user = %viewer.username, job = %job.name(), job_id = id, %unit, %error, "job start failed");
|
||||
jobs::finish(
|
||||
&state.db,
|
||||
id,
|
||||
jobs::Outcome::Failed,
|
||||
&format!("could not start {unit}: {error}"),
|
||||
None,
|
||||
Timestamp::now(),
|
||||
match start_job(&state, &viewer, job).await? {
|
||||
StartJob::Disabled => {
|
||||
set_flash(
|
||||
&session,
|
||||
"error",
|
||||
"Jobs are disabled on this server (server.jobs_enabled = false).".into(),
|
||||
)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
.await?;
|
||||
Ok(Redirect::to("/dashboard/jobs").into_response())
|
||||
}
|
||||
StartJob::Active(active) => {
|
||||
let flash = Flash {
|
||||
kind: "error".into(),
|
||||
text: format!(
|
||||
"{} is already requested or running (job {active}).",
|
||||
job.name()
|
||||
),
|
||||
};
|
||||
let template = jobs_template(&state, Some(viewer), Some(flash)).await?;
|
||||
Ok((StatusCode::CONFLICT, Html(template)).into_response())
|
||||
}
|
||||
StartJob::Started(id) => {
|
||||
set_flash(&session, "success", format!("Started {}.", job.name())).await?;
|
||||
Ok(Redirect::to(&format!("/dashboard/jobs/{id}")).into_response())
|
||||
}
|
||||
StartJob::Failed(id, error) => {
|
||||
set_flash(
|
||||
&session,
|
||||
"error",
|
||||
format!("Could not start {}: {error}", job.name()),
|
||||
)
|
||||
.await?;
|
||||
Ok(Redirect::to(&format!("/dashboard/jobs/{id}")).into_response())
|
||||
}
|
||||
}
|
||||
Ok(Redirect::to(&format!("/dashboard/jobs/{id}")).into_response())
|
||||
}
|
||||
|
||||
/// `GET /dashboard/jobs/{id}`: the row, the live unit status and the journal
|
||||
|
||||
Reference in new issue
Block a user