diff --git a/docs/administration.md b/docs/administration.md index fbc636935..a925f11da 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -699,6 +699,7 @@ document_fuzzy_match [--ratio] [--processes N] | --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. | | --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes | | --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. | +| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. | !!! warning diff --git a/src/documents/management/commands/document_fuzzy_match.py b/src/documents/management/commands/document_fuzzy_match.py index d5f9b2ab7..dd488cbfd 100644 --- a/src/documents/management/commands/document_fuzzy_match.py +++ b/src/documents/management/commands/document_fuzzy_match.py @@ -55,7 +55,7 @@ class Command(PaperlessCommand): "--ratio", default=85.0, type=float, - help="Ratio to consider documents a match", + help="Ratio to consider documents a match (0.0 - 100.0)", ) parser.add_argument( "--delete", @@ -69,6 +69,17 @@ class Command(PaperlessCommand): action="store_true", help="Skip the confirmation prompt when used with --delete", ) + parser.add_argument( + "--url", + default=None, + type=str, + help=( + "Base URL of the Paperless instance (e.g. " + "http://localhost:8000 or https://paperless.local). If set, matched " + "documents are shown as clickable (usually ctrl+click) links to " + "/documents//details instead of by title." + ), + ) def _render_results( self, @@ -76,6 +87,7 @@ class Command(PaperlessCommand): *, opt_ratio: float, do_delete: bool, + base_url: str | None = None, ) -> list[int]: """Render match results as a Rich table. Returns list of PKs to delete.""" if not matches: @@ -88,13 +100,22 @@ class Command(PaperlessCommand): ) return [] - # Fetch titles for matched documents in a single query. - all_pks = {pk for m in matches for pk in (m.doc_one_pk, m.doc_two_pk)} - titles: dict[int, str] = dict( - Document.objects.filter(pk__in=all_pks) - .only("pk", "title") - .values_list("pk", "title"), - ) + # Fetch titles for matched documents in a single query, unless we're + # going to show URLs instead. + titles: dict[int, str] = {} + if not base_url: + all_pks = {pk for m in matches for pk in (m.doc_one_pk, m.doc_two_pk)} + titles = dict( + Document.objects.filter(pk__in=all_pks) + .only("pk", "title") + .values_list("pk", "title"), + ) + + def _cell(pk: int) -> str: + if base_url: + doc_url = f"{base_url.rstrip('/')}/documents/{pk}/details" + return f"[link={doc_url}]{doc_url}[/link]" + return f"[dim]#{pk}[/dim] {titles.get(pk, 'Unknown')}" table = Table( title=f"Fuzzy Matches (threshold: {opt_ratio:.1f}%)", @@ -124,8 +145,8 @@ class Command(PaperlessCommand): table.add_row( str(i), - f"[dim]#{pk_a}[/dim] {titles.get(pk_a, 'Unknown')}", - f"[dim]#{pk_b}[/dim] {titles.get(pk_b, 'Unknown')}", + _cell(pk_a), + _cell(pk_b), Text(f"{ratio:.1f}%", style=ratio_style), ) maybe_delete_ids.append(pk_b) @@ -208,6 +229,7 @@ class Command(PaperlessCommand): matches, opt_ratio=opt_ratio, do_delete=options["delete"], + base_url=options["url"], ) if options["delete"] and maybe_delete_ids: diff --git a/src/documents/tests/test_management_fuzzy.py b/src/documents/tests/test_management_fuzzy.py index fcd0a039a..a5f98a2ec 100644 --- a/src/documents/tests/test_management_fuzzy.py +++ b/src/documents/tests/test_management_fuzzy.py @@ -1,3 +1,4 @@ +import os from io import StringIO from unittest.mock import patch @@ -41,7 +42,7 @@ class TestFuzzyMatchCommand(TestCase): def test_invalid_ratio_upper_limit(self) -> None: """ - GIVEN:s + GIVEN: - Invalid ratio above upper WHEN: - Command is called @@ -108,6 +109,45 @@ class TestFuzzyMatchCommand(TestCase): stdout, _ = self.call_command("--processes", "1") self.assertIn("Found 1 matching pair(s)", stdout) + def test_with_matches_and_url(self) -> None: + """ + GIVEN: + - 2 documents exist + - Similarity between content is 86.667 + - --url is provided + WHEN: + - Command is called with --url + THEN: + - 1 match is returned from doc 1 to doc 2 + - No match from doc 2 to doc 1 reported + - Output contains clickable links to the documents instead of titles + """ + # Content similarity is 86.667 + Document.objects.create( + checksum="BEEFCAFE", + title="A", + content="first document scanned by bob", + mime_type="application/pdf", + filename="test.pdf", + ) + Document.objects.create( + checksum="DEADBEAF", + title="A", + content="first document scanned by alice", + mime_type="application/pdf", + filename="other_test.pdf", + ) + with patch.dict(os.environ, {"COLUMNS": "200"}): + stdout, _ = self.call_command( + "--processes", + "1", + "--url", + "http://localhost:8000", + ) + self.assertIn("Found 1 matching pair(s)", stdout) + self.assertIn("http://localhost:8000/documents/1/details", stdout) + self.assertIn("http://localhost:8000/documents/2/details", stdout) + def test_with_3_matches(self) -> None: """ GIVEN: