Kaynağa Gözat

Fix external-folder scan deleting README.md records; index markdown (#2520)

.md was missing from _SCANNABLE_EXTENSIONS, so scanning an external
folder skipped markdown during the walk and the cleanup pass deleted
its LibraryFile row (assuming it was gone from disk), 404ing the Folder
Readme panel. Add .md to the scannable set so pre-existing markdown is
indexed, and gate cleanup deletion on actual disk presence rather than
absence from the extension-filtered found_paths, so any non-scannable
upload still on disk survives a scan.
maziggy 1 ay önce
ebeveyn
işleme
d03b108965

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
CHANGELOG.md


+ 11 - 2
backend/app/api/routes/library.py

@@ -1353,6 +1353,7 @@ _SCANNABLE_EXTENSIONS = {
     ".gif",
     ".webp",
     ".svg",
+    ".md",
 }
 
 
@@ -1720,9 +1721,17 @@ async def scan_external_folder(
             db.add(db_file)
             added += 1
 
-    # Remove DB entries for files that no longer exist on disk
+    # Remove DB entries for files that no longer exist on disk.
+    #
+    # Gate on actual disk presence, NOT merely absence from found_paths:
+    # found_paths only collects extensions in _SCANNABLE_EXTENSIONS, so a
+    # record for any other file the upload path admitted (e.g. a .md README,
+    # #2520) would otherwise be treated as "deleted from disk" and purged on
+    # every scan even though the file is still there. os.path.exists keeps
+    # such records; genuinely-deleted files (absent from disk) are still
+    # cleaned up. External file_path is the absolute on-disk path.
     for path_str, db_file in existing_files.items():
-        if path_str not in found_paths:
+        if path_str not in found_paths and not os.path.exists(path_str):
             # Clean up thumbnail if we generated one
             if db_file.thumbnail_path:
                 try:

+ 98 - 0
backend/tests/integration/test_external_folders_api.py

@@ -295,6 +295,104 @@ class TestExternalFolderScan:
         assert result["removed"] == 1
         assert result["added"] == 0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_indexes_pre_existing_markdown(
+        self, async_client: AsyncClient, db_session, external_folder, external_dir
+    ):
+        """Scan should index a README.md already on disk (#2520 item 1).
+
+        Markdown dropped into the folder by external tools (not the Upload
+        dialog) must be picked up so the Folder Readme panel can show it.
+        """
+        (external_dir / "README.md").write_text("# Fishing Floats\n\nDescription.")
+
+        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response.status_code == 200
+        # 4 supported files from the fixture + the new README.md
+        assert response.json()["added"] == 5
+
+        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
+        root_filenames = {f["filename"] for f in response.json()}
+        assert "README.md" in root_filenames
+
+        # Readme panel can now resolve it.
+        response = await async_client.get(f"/api/v1/library/folders/{external_folder['id']}/readme")
+        assert response.status_code == 200
+        assert response.json()["filename"] == "README.md"
+        assert "Fishing Floats" in response.json()["content"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_preserves_uploaded_markdown(self, async_client: AsyncClient, db_session, tmp_path):
+        """Scanning must not delete an uploaded README.md (#2520 destructive-cleanup bug).
+
+        Before the fix, .md was absent from _SCANNABLE_EXTENSIONS, so an
+        uploaded markdown record was never re-found during the walk and the
+        cleanup pass purged it — the Readme panel then 404'd and hid.
+        """
+        import io
+
+        writable_dir = tmp_path / "writable"
+        writable_dir.mkdir()
+        response = await async_client.post(
+            "/api/v1/library/folders/external",
+            json={"name": "Writable", "external_path": str(writable_dir), "readonly": False},
+        )
+        folder = response.json()
+
+        upload = await async_client.post(
+            f"/api/v1/library/files?folder_id={folder['id']}",
+            files={"file": ("README.md", io.BytesIO(b"# Model\n\nHello"), "text/markdown")},
+        )
+        assert upload.status_code in (200, 201)
+
+        # Panel works before the scan.
+        readme = await async_client.get(f"/api/v1/library/folders/{folder['id']}/readme")
+        assert readme.status_code == 200
+
+        # The scan that used to nuke the record.
+        scan = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        assert scan.status_code == 200
+        assert scan.json()["removed"] == 0
+
+        # Record and panel survive.
+        readme = await async_client.get(f"/api/v1/library/folders/{folder['id']}/readme")
+        assert readme.status_code == 200
+        assert readme.json()["filename"] == "README.md"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_preserves_non_scannable_file_on_disk(self, async_client: AsyncClient, db_session, tmp_path):
+        """Cleanup must gate on disk presence, not scannable-extension membership (#2520).
+
+        Any uploaded file whose extension is outside _SCANNABLE_EXTENSIONS
+        (here a .txt) stays on disk, so its DB record must survive a scan
+        rather than being treated as deleted.
+        """
+        import io
+
+        writable_dir = tmp_path / "writable_txt"
+        writable_dir.mkdir()
+        response = await async_client.post(
+            "/api/v1/library/folders/external",
+            json={"name": "Writable Txt", "external_path": str(writable_dir), "readonly": False},
+        )
+        folder = response.json()
+
+        upload = await async_client.post(
+            f"/api/v1/library/files?folder_id={folder['id']}",
+            files={"file": ("notes.txt", io.BytesIO(b"keep me"), "text/plain")},
+        )
+        assert upload.status_code in (200, 201)
+
+        scan = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        assert scan.status_code == 200
+        assert scan.json()["removed"] == 0
+
+        files = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
+        assert "notes.txt" in {f["filename"] for f in files.json()}
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor