Align knowledge storage with real folders and add WebDAV import surface

Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface.

Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing
Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass
Confidence: medium
Scope-risk: moderate
Reversibility: messy
Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified
Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection
Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
This commit is contained in:
2026-04-09 17:26:37 +08:00
parent aa12c92a5a
commit 8c7cf0732b
18 changed files with 2776 additions and 26 deletions

View File

@@ -15,6 +15,7 @@ from starlette.datastructures import UploadFile
import app.models # noqa: F401
from app.database import Base
from app.models.document import Document, DocumentChunk
from app.models.folder import Folder
from app.models.user import User
from app.services.auth_service import get_password_hash
from app.services.document_service import DocumentService
@@ -199,6 +200,29 @@ async def test_upload_document_persists_structured_metadata_json(document_test_e
assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage'
@pytest.mark.asyncio
async def test_upload_document_stores_file_in_nested_folder_with_original_name(document_test_env):
session, user = document_test_env
service = DocumentService(session)
root = Folder(user_id=user.id, name='Projects')
session.add(root)
await session.flush()
child = Folder(user_id=user.id, name='Specs', parent_id=root.id)
session.add(child)
await session.commit()
await session.refresh(child)
upload = UploadFile(filename='system-design.md', file=BytesIO(b'# Design'))
document = await service.upload_document(user.id, upload, folder_id=child.id)
file_path = Path(document.file_path)
assert file_path.name == 'system-design.md'
assert file_path.parent.name == 'Specs'
assert file_path.parent.parent.name == 'Projects'
assert file_path.exists()
@pytest.mark.asyncio
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
session, user = document_test_env

View File

@@ -0,0 +1,39 @@
from app.models.remote_mount import RemoteMount
from app.services.secret_service import encrypt_secret
from app.services.webdav_service import WebDavService
def test_parse_propfind_returns_sorted_nodes():
mount = RemoteMount(
user_id='user-1',
name='Docs',
mount_type='webdav',
base_url='https://example.com/dav/',
username='alice',
password_encrypted=encrypt_secret('secret'),
root_path='/knowledge',
is_active=True,
)
payload = """<?xml version="1.0" encoding="utf-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/knowledge/</d:href>
<d:propstat><d:prop><d:displayname>knowledge</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/knowledge/specs/</d:href>
<d:propstat><d:prop><d:displayname>specs</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/knowledge/roadmap.md</d:href>
<d:propstat><d:prop><d:displayname>roadmap.md</d:displayname><d:getcontentlength>128</d:getcontentlength><d:getetag>"etag-1"</d:getetag><d:getlastmodified>Wed, 09 Apr 2026 10:00:00 GMT</d:getlastmodified><d:resourcetype /></d:prop></d:propstat>
</d:response>
</d:multistatus>"""
nodes = WebDavService(mount)._parse_propfind('/knowledge', payload)
assert [node.name for node in nodes] == ['specs', 'roadmap.md']
assert nodes[0].is_dir is True
assert nodes[1].is_dir is False
assert nodes[1].size == 128
assert nodes[1].etag == '"etag-1"'

View File

@@ -0,0 +1,90 @@
from httpx import ASGITransport, AsyncClient
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401
from app.database import Base, get_db
from app.main import app
from app.models.folder import Folder
from app.models.user import User
from app.routers.auth import get_current_user
from app.services.auth_service import get_password_hash
@pytest.fixture
async def folder_router_env(tmp_path, monkeypatch):
db_path = tmp_path / 'test_folders_router.db'
upload_dir = tmp_path / 'uploads'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as session:
user = User(
email='folders@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Folder Tester',
)
session.add(user)
await session.commit()
await session.refresh(user)
monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(upload_dir))
async def override_get_db():
async with session_factory() as session:
yield session
async def override_get_current_user():
return user
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
try:
yield user, upload_dir, session_factory
finally:
app.dependency_overrides.clear()
await engine.dispose()
@pytest.mark.asyncio
async def test_create_folder_creates_matching_local_directory(folder_router_env):
user, upload_dir, _session_factory = folder_router_env
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post('/api/folders', json={'name': 'Projects', 'parent_id': None})
assert response.status_code == 201
folder_id = response.json()['id']
expected_path = upload_dir / user.id / 'Projects'
assert expected_path.exists()
assert expected_path.is_dir()
assert folder_id
@pytest.mark.asyncio
async def test_rename_folder_moves_local_directory(folder_router_env):
user, upload_dir, session_factory = folder_router_env
async with session_factory() as session:
folder = Folder(user_id=user.id, name='Old', parent_id=None)
session.add(folder)
await session.commit()
await session.refresh(folder)
(upload_dir / user.id / 'Old').mkdir(parents=True, exist_ok=True)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.put(f'/api/folders/{folder.id}', json={'name': 'New'})
assert response.status_code == 200
assert not (upload_dir / user.id / 'Old').exists()
assert (upload_dir / user.id / 'New').exists()