[GH-ISSUE #890] Feature: Preserve subfolder structure when scanning external folders. #611

Closed
opened 2026-05-06 12:31:22 +02:00 by BreizhHardware · 1 comment

Originally created by @SMAW on GitHub (Apr 4, 2026).
Original GitHub issue: https://github.com/maziggy/bambuddy/issues/890

Originally assigned to: @maziggy on GitHub.

Problem or Use Case

Currently, when scanning an external folder with scan_external_folder, all files (including those in subdirectories) are imported flat into the root LibraryFolder in Bambuddy. The subfolder structure as found on disk is ignored, and the UI shows a single large folder with all files. This reduces organization and makes navigating large or sorted external sources difficult.

Proposed Solution

Improve scan_external_folder to automatically mirror the real folder structure into Bambuddy's LibraryFolder tree. For each directory/subdirectory under the external path, create corresponding LibraryFolder(s) with correct parent/child relationships (is_external=True) and assign imported files to the correct folder according to their disk location. Subfolders no longer present on disk should be automatically removed from the database if empty.

IMPORTANT: All code below was generated by AI (Claude Opus 4.6). I am NOT a developer, so please carefully review and validate before adopting!


1. Add a folder cache and resolve/create subfolders during the walk

In scan_external_folder in backend/app/api/routes/library.py, add a folder_cache and resolve subdirectories into LibraryFolder children:

folder_cache: dict[str, int] = {"": folder_id}  # "" = root of the external path

for dirpath, _dirnames, filenames in os.walk(ext_path):
    rel_dir = str(Path(dirpath).relative_to(ext_path))
    if rel_dir == ".":
        rel_dir = ""

    # Resolve or create the folder chain for this subdirectory
    if rel_dir and rel_dir not in folder_cache:
        parts = rel_dir.split(os.sep)
        current_path = ""
        current_parent = folder_id

        for part in parts:
            current_path = f"{current_path}/{part}" if current_path else part

            if current_path in folder_cache:
                current_parent = folder_cache[current_path]
            else:
                existing = await db.execute(
                    select(LibraryFolder).where(
                        LibraryFolder.name == part,
                        LibraryFolder.parent_id == current_parent,
                        LibraryFolder.is_external.is_(True),
                    )
                )
                existing_folder = existing.scalar_one_or_none()

                if existing_folder:
                    current_parent = existing_folder.id
                else:
                    new_folder = LibraryFolder(
                        name=part,
                        parent_id=current_parent,
                        is_external=True,
                        external_path=str(ext_path / current_path),
                        external_readonly=folder.external_readonly,
                        external_show_hidden=folder.external_show_hidden,
                    )
                    db.add(new_folder)
                    await db.flush()
                    current_parent = new_folder.id

                folder_cache[current_path] = current_parent

    target_folder_id = folder_cache.get(rel_dir, folder_id)

    for filename in filenames:
        # ... existing file processing ...

        db_file = LibraryFile(
            folder_id=target_folder_id,  # <-- was: folder_id
            is_external=True,
            # ... rest unchanged ...
        )

2. Update existing file tracking to include subfolders

The current existing_files query only checks folder_id == folder_id. It needs to include files in all child external subfolders:

all_folder_ids = [folder_id]

async def collect_child_folder_ids(parent_id: int):
    result = await db.execute(
        select(LibraryFolder.id).where(
            LibraryFolder.parent_id == parent_id,
            LibraryFolder.is_external.is_(True),
        )
    )
    for (child_id,) in result.all():
        all_folder_ids.append(child_id)
        await collect_child_folder_ids(child_id)

await collect_child_folder_ids(folder_id)

existing_result = await db.execute(
    select(LibraryFile).where(
        LibraryFile.folder_id.in_(all_folder_ids),
        LibraryFile.is_external.is_(True),
    )
)

3. Clean up empty subfolders no longer on disk

After removing orphaned files, also prune child external folders whose directories are gone:

for fid in reversed(all_folder_ids):
    if fid == folder_id:
        continue  # never remove the root external folder
    sub_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == fid))
    sub_folder = sub_result.scalar_one_or_none()
    if sub_folder and sub_folder.external_path:
        if not Path(sub_folder.external_path).exists():
            file_count = await db.execute(
                select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == fid)
            )
            if (file_count.scalar() or 0) == 0:
                await db.delete(sub_folder)

4. Tests to update

  • test_scan_discovers_files — the nested subfolder/nested.stl should now be in a child folder, not the root folder.
  • Add a new test verifying subfolder LibraryFolder creation and correct file assignment.
  • The frontend should need no changes — the folder tree UI already renders nested LibraryFolder children.

Alternatives Considered

Continue importing everything flat into one folder, but this makes file management much harder.

Feature Category

File Manager

Priority

Would improve my workflow

Mockups or Examples

N/A

Contribution

  • I would be willing to help implement this feature

Checklist

  • I have searched existing issues to ensure this feature hasn't already been requested
Originally created by @SMAW on GitHub (Apr 4, 2026). Original GitHub issue: https://github.com/maziggy/bambuddy/issues/890 Originally assigned to: @maziggy on GitHub. ### Problem or Use Case Currently, when scanning an external folder with `scan_external_folder`, all files (including those in subdirectories) are imported flat into the root LibraryFolder in Bambuddy. The subfolder structure as found on disk is ignored, and the UI shows a single large folder with all files. This reduces organization and makes navigating large or sorted external sources difficult. ### Proposed Solution Improve `scan_external_folder` to automatically mirror the real folder structure into Bambuddy's LibraryFolder tree. For each directory/subdirectory under the external path, create corresponding LibraryFolder(s) with correct parent/child relationships (`is_external=True`) and assign imported files to the correct folder according to their disk location. Subfolders no longer present on disk should be automatically removed from the database if empty. **IMPORTANT:** All code below was generated by AI (Claude Opus 4.6). I am NOT a developer, so please carefully review and validate before adopting! --- ### 1. Add a folder cache and resolve/create subfolders during the walk In `scan_external_folder` in `backend/app/api/routes/library.py`, add a `folder_cache` and resolve subdirectories into LibraryFolder children: ```python folder_cache: dict[str, int] = {"": folder_id} # "" = root of the external path for dirpath, _dirnames, filenames in os.walk(ext_path): rel_dir = str(Path(dirpath).relative_to(ext_path)) if rel_dir == ".": rel_dir = "" # Resolve or create the folder chain for this subdirectory if rel_dir and rel_dir not in folder_cache: parts = rel_dir.split(os.sep) current_path = "" current_parent = folder_id for part in parts: current_path = f"{current_path}/{part}" if current_path else part if current_path in folder_cache: current_parent = folder_cache[current_path] else: existing = await db.execute( select(LibraryFolder).where( LibraryFolder.name == part, LibraryFolder.parent_id == current_parent, LibraryFolder.is_external.is_(True), ) ) existing_folder = existing.scalar_one_or_none() if existing_folder: current_parent = existing_folder.id else: new_folder = LibraryFolder( name=part, parent_id=current_parent, is_external=True, external_path=str(ext_path / current_path), external_readonly=folder.external_readonly, external_show_hidden=folder.external_show_hidden, ) db.add(new_folder) await db.flush() current_parent = new_folder.id folder_cache[current_path] = current_parent target_folder_id = folder_cache.get(rel_dir, folder_id) for filename in filenames: # ... existing file processing ... db_file = LibraryFile( folder_id=target_folder_id, # <-- was: folder_id is_external=True, # ... rest unchanged ... ) ``` ### 2. Update existing file tracking to include subfolders The current `existing_files` query only checks `folder_id == folder_id`. It needs to include files in all child external subfolders: ```python all_folder_ids = [folder_id] async def collect_child_folder_ids(parent_id: int): result = await db.execute( select(LibraryFolder.id).where( LibraryFolder.parent_id == parent_id, LibraryFolder.is_external.is_(True), ) ) for (child_id,) in result.all(): all_folder_ids.append(child_id) await collect_child_folder_ids(child_id) await collect_child_folder_ids(folder_id) existing_result = await db.execute( select(LibraryFile).where( LibraryFile.folder_id.in_(all_folder_ids), LibraryFile.is_external.is_(True), ) ) ``` ### 3. Clean up empty subfolders no longer on disk After removing orphaned files, also prune child external folders whose directories are gone: ```python for fid in reversed(all_folder_ids): if fid == folder_id: continue # never remove the root external folder sub_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == fid)) sub_folder = sub_result.scalar_one_or_none() if sub_folder and sub_folder.external_path: if not Path(sub_folder.external_path).exists(): file_count = await db.execute( select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == fid) ) if (file_count.scalar() or 0) == 0: await db.delete(sub_folder) ``` ### 4. Tests to update - `test_scan_discovers_files` — the nested `subfolder/nested.stl` should now be in a child folder, not the root folder. - Add a new test verifying subfolder LibraryFolder creation and correct file assignment. - The frontend should need no changes — the folder tree UI already renders nested LibraryFolder children. ### Alternatives Considered Continue importing everything flat into one folder, but this makes file management much harder. ### Feature Category File Manager ### Priority Would improve my workflow ### Mockups or Examples N/A ### Contribution - [ ] I would be willing to help implement this feature ### Checklist - [x] I have searched existing issues to ensure this feature hasn't already been requested
BreizhHardware 2026-05-06 12:31:22 +02:00
Author
Owner

@maziggy commented on GitHub (Apr 5, 2026):

Available/Fixed in branch dev and available with the next release or daily build.


If you find Bambuddy useful, please consider giving it a on GitHub — it helps others discover the project!

<!-- gh-comment-id:4188463417 --> @maziggy commented on GitHub (Apr 5, 2026): Available/Fixed in branch dev and available with the next release or daily build. ----- If you find Bambuddy useful, please consider giving it a ⭐ on [GitHub](https://github.com/maziggy/bambuddy) — it helps others discover the project!
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/bambuddy#611
No description provided.