⭐
Log in to mark this repository.
Apply a re-upload's tags to the file already in the archive
1df1ebf · gmorell · 2026-08-30 03:22
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