Apply a re-upload's tags to the file already in the archive

1df1ebf · gmorell · 2026-08-30 03:22

4 files +81 -20
Message
{commit_body(@commit)}

Files changed

modified README.md
+7 −0
@@ -166,6 +166,13 @@ Three ways in, all converging on the same tail — content-address the bytes,
166 166 dedupe per user against `(file_hash, user_id)`, insert the row, attach any
167 167 user tags, enqueue the default job set.
168 168
169 +A duplicate short-circuits before storing anything, but still takes the
170 +request's tags. Re-uploading a set with a tag in the box is therefore a way to
171 +label an archive you already have — the obvious move after a partly-failed
172 +import — and `attach_tags` is idempotent, so doing it twice does not double a
173 +tag up. Only tags carry over; `item_name` and `item_description` on an existing
174 +row are left alone. The zip response reports the count as `retagged`.
175 +
169 176 | Path | Web | API |
170 177 |---|---|---|
171 178 | One file | `POST /uploads` (multipart `file`) | `POST /api/uploads` |
modified src/handlers/uploads.rs
+74 −20
@@ -532,6 +532,7 @@ pub async fn api_upload_zip(
532 532 "imported": summary.imported,
533 533 "imported_count": summary.imported.len(),
534 534 "duplicates": summary.duplicates,
535 + "retagged": summary.retagged,
535 536 "skipped_unsupported": summary.skipped_unsupported,
536 537 "skipped_too_large": summary.skipped_too_large,
537 538 "failed": summary.failed,
@@ -726,21 +727,51 @@ async fn store_upload(
726 727 user: &UserContext,
727 728 data: StoredUpload,
728 729 ) -> Result<Uuid, IngestError> {
729 − let (upload, duplicate) = persist_upload(state, user, data).await?;
730 − if !duplicate {
731 − enqueue_default_jobs(state, &upload).await;
730 + let result = persist_upload(state, user, data).await?;
731 + if !result.duplicate {
732 + enqueue_default_jobs(state, &result.upload).await;
733 + }
734 + Ok(result.upload.id)
735 +}
736 +
737 +/// Outcome of persisting one file.
738 +struct Persisted {
739 + upload: uploads::Model,
740 + /// These bytes were already in this user's archive.
741 + duplicate: bool,
742 + /// Tags from this request were attached to a row that already existed.
743 + retagged: bool,
744 +}
745 +
746 +/// Attach the request's user tags to an upload. Returns whether anything was
747 +/// actually attached, which is only interesting when the row already existed.
748 +/// `attach_tags` is idempotent, so re-tagging never doubles a tag up.
749 +async fn attach_request_tags(state: &AppState, upload_id: Uuid, tags_raw: Option<&str>) -> bool {
750 + let Some(raw) = tags_raw else { return false };
751 + let parsed = tagsvc::parse_tags(raw);
752 + if parsed.is_empty() {
753 + return false;
754 + }
755 + match tagsvc::attach_tags(state.db.as_ref(), upload_id, &parsed, None).await {
756 + Ok(()) => true,
757 + Err(e) => {
758 + error!(error = %e, "attach user tags failed");
759 + false
760 + }
732 761 }
733 − Ok(upload.id)
734 762 }
735 763
736 764 /// Persist to storage, dedupe per user, insert the row and attach tags.
737 −/// Returns the row plus whether it was an existing duplicate — callers decide
738 −/// how to dispatch jobs (one-at-a-time, or batched for a zip import).
765 +///
766 +/// A duplicate short-circuits without re-storing anything, but still takes the
767 +/// request's tags: re-uploading a set with a tag in the box is a reasonable way
768 +/// to label an archive you already have, and it is the obvious move after a
769 +/// partly-failed import.
739 770 async fn persist_upload(
740 771 state: &AppState,
741 772 user: &UserContext,
742 773 data: StoredUpload,
743 −) -> Result<(uploads::Model, bool), IngestError> {
774 +) -> Result<Persisted, IngestError> {
744 775 let StoredUpload {
745 776 bytes,
746 777 original_name,
@@ -777,8 +808,9 @@ async fn persist_upload(
777 808 .await
778 809 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
779 810 {
780 − info!(upload_id = %existing.id, "duplicate upload short-circuited");
781 − return Ok((existing, true));
811 + let retagged = attach_request_tags(state, existing.id, tags_raw.as_deref()).await;
812 + info!(upload_id = %existing.id, retagged, "duplicate upload short-circuited");
813 + return Ok(Persisted { upload: existing, duplicate: true, retagged });
782 814 }
783 815
784 816 let active = uploads::ActiveModel {
@@ -807,14 +839,9 @@ async fn persist_upload(
807 839 StatusCode::INTERNAL_SERVER_ERROR
808 840 })?;
809 841
810 − if let Some(raw) = tags_raw.as_deref() {
811 − let parsed = tagsvc::parse_tags(raw);
812 − if let Err(e) = tagsvc::attach_tags(state.db.as_ref(), inserted.id, &parsed, None).await {
813 − error!(error = %e, "attach user tags failed");
814 − }
815 − }
842 + attach_request_tags(state, inserted.id, tags_raw.as_deref()).await;
816 843
817 − Ok((inserted, false))
844 + Ok(Persisted { upload: inserted, duplicate: false, retagged: false })
818 845 }
819 846
820 847 /// Fetch a remote URL server-side and hand the bytes to [`store_upload`].
@@ -999,6 +1026,8 @@ pub struct ZipImportSummary {
999 1026 pub imported: Vec<Uuid>,
1000 1027 /// Entries whose bytes this user had already uploaded.
1001 1028 pub duplicates: usize,
1029 + /// Of those duplicates, how many picked up this request's tags.
1030 + pub retagged: usize,
1002 1031 pub skipped_unsupported: usize,
1003 1032 pub skipped_too_large: usize,
1004 1033 pub failed: usize,
@@ -1020,6 +1049,9 @@ impl ZipImportSummary {
1020 1049 if self.duplicates > 0 {
1021 1050 notes.push(format!("{} already in the archive", self.duplicates));
1022 1051 }
1052 + if self.retagged > 0 {
1053 + notes.push(format!("{} retagged", self.retagged));
1054 + }
1023 1055 if self.skipped_unsupported > 0 {
1024 1056 notes.push(format!("{} unsupported entries skipped", self.skipped_unsupported));
1025 1057 }
@@ -1237,10 +1269,15 @@ async fn ingest_zip(
1237 1269 )
1238 1270 .await;
1239 1271 match result {
1240 − Ok((_, true)) => summary.duplicates += 1,
1241 − Ok((upload, false)) => {
1242 − summary.imported.push(upload.id);
1243 − stored.push(upload);
1272 + Ok(p) if p.duplicate => {
1273 + summary.duplicates += 1;
1274 + if p.retagged {
1275 + summary.retagged += 1;
1276 + }
1277 + }
1278 + Ok(p) => {
1279 + summary.imported.push(p.upload.id);
1280 + stored.push(p.upload);
1244 1281 }
1245 1282 // One bad entry shouldn't abort the import — count it and carry on.
1246 1283 Err(_) => {
@@ -1491,6 +1528,7 @@ mod zip_unit_tests {
1491 1528 let summary = ZipImportSummary {
1492 1529 imported: vec![Uuid::now_v7()],
1493 1530 duplicates: 2,
1531 + retagged: 2,
1494 1532 skipped_unsupported: 3,
1495 1533 skipped_too_large: 1,
1496 1534 failed: 1,
@@ -1499,12 +1537,28 @@ mod zip_unit_tests {
1499 1537 let msg = summary.message();
1500 1538 assert!(msg.starts_with("Imported 1 file from the zip ("), "got: {msg}");
1501 1539 assert!(msg.contains("2 already in the archive"));
1540 + assert!(msg.contains("2 retagged"));
1502 1541 assert!(msg.contains("3 unsupported entries skipped"));
1503 1542 assert!(msg.contains("1 over the size limit"));
1504 1543 assert!(msg.contains("1 failed"));
1505 1544 assert!(msg.contains("the rest of the zip was not read"));
1506 1545 }
1507 1546
1547 + #[test]
1548 + fn summary_message_reports_a_pure_retag() {
1549 + // Re-importing an archive purely to label it: nothing new, everything
1550 + // tagged. The message has to make that legible.
1551 + let summary = ZipImportSummary {
1552 + duplicates: 469,
1553 + retagged: 469,
1554 + ..Default::default()
1555 + };
1556 + assert_eq!(
1557 + summary.message(),
1558 + "Imported 0 files from the zip (469 already in the archive, 469 retagged)."
1559 + );
1560 + }
1561 +
1508 1562 #[test]
1509 1563 fn summary_message_stays_plain_when_clean() {
1510 1564 let summary = ZipImportSummary {

Parents: 07a12d4