feat: cards come from articles, and Nextcloud is gone
Two removals the user asked for.
No deck from a PDF section. A card should be written from an article —
text a person has read, edited and published — not from whatever
happened to be on pages 40-58 of a source document. POST /flashcards/
and the generate_flashcard_deck task are gone, with the Create Cards
button on the document page. What remains: POST /articles/{id}/ai-cards,
and POST /flashcards/manual for writing a deck by hand.
And no Nextcloud. It was a per-person cloud integration for a corpus one
person loads: a settings panel asking every educator for an app
password, a second tab on the upload page, and three endpoints. The
upload page now has one way in, which is the one anybody used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
e2919e73c2
commit
99570fb433
10 changed files with 18 additions and 649 deletions
|
|
@ -12,7 +12,7 @@ setup_logging(settings.LOG_LEVEL)
|
|||
from app.database import engine, Base, SessionLocal
|
||||
from app.api import errors as api_errors
|
||||
from app.api.versioning import VERSIONED_ROOT, VersionAlias
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams
|
||||
from app.routers import access
|
||||
from app.routers import feedback
|
||||
from app.routers import folders
|
||||
|
|
@ -626,7 +626,6 @@ app.include_router(quizzes.router, prefix=f"{VERSIONED_ROOT}/quizzes", tags=["qu
|
|||
app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"])
|
||||
app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
|
||||
app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"])
|
||||
app.include_router(nextcloud.router, prefix=f"{VERSIONED_ROOT}/nextcloud", tags=["nextcloud"])
|
||||
app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"])
|
||||
app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
|
||||
app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])
|
||||
|
|
|
|||
|
|
@ -28,14 +28,6 @@ router = APIRouter()
|
|||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class FlashcardDeckCreate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
section_id: int
|
||||
title: str
|
||||
model_id: str | None = None
|
||||
|
||||
|
||||
class FlashcardDeckUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
category_id: int | None = None
|
||||
|
|
@ -133,50 +125,17 @@ def _own_deck_to_edit(deck_id: int, current_user: User, db: Session) -> Flashcar
|
|||
|
||||
# ── Deck endpoints ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/")
|
||||
def create_flashcard_deck(
|
||||
data: FlashcardDeckCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Start async flashcard generation from a section. Returns {job_id} immediately."""
|
||||
import uuid
|
||||
|
||||
section = db.query(Section).filter(Section.id == data.section_id).first()
|
||||
if not section:
|
||||
raise HTTPException(status_code=404, detail="Section not found")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
from app.tasks.quiz_tasks import generate_flashcard_deck
|
||||
import redis as redis_lib
|
||||
from app.config import settings
|
||||
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
|
||||
r.lpush(f"extraction:user_jobs:{current_user.id}", job_id)
|
||||
r.expire(f"extraction:user_jobs:{current_user.id}", 86400)
|
||||
r.set(f"extraction:job_title:{job_id}", data.title, ex=3600)
|
||||
|
||||
generate_flashcard_deck.delay(
|
||||
job_id=job_id,
|
||||
user_id=current_user.id,
|
||||
section_id=data.section_id,
|
||||
title=data.title,
|
||||
model_id=data.model_id,
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=503, detail="Task queue unavailable")
|
||||
|
||||
return {"job_id": job_id, "status": "pending"}
|
||||
|
||||
|
||||
class ManualDeckCreate(BaseModel):
|
||||
title: str
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
# No deck from a PDF section. Cards are written from an article — the text a
|
||||
# person has already read, edited and published — rather than from whatever
|
||||
# happened to be on pages 40-58 of a source document. The article path is
|
||||
# POST /articles/{id}/ai-cards; a deck by hand is POST /flashcards/manual.
|
||||
|
||||
|
||||
@router.post("/manual")
|
||||
def create_deck_manually(
|
||||
data: ManualDeckCreate,
|
||||
|
|
|
|||
|
|
@ -1,158 +0,0 @@
|
|||
"""Nextcloud WebDAV proxy — avoids browser CORS issues."""
|
||||
import io
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.user import User
|
||||
from app.utils.auth import require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DAV_PROPFIND = b"""<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontenttype/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
|
||||
class NCRequest(BaseModel):
|
||||
server: str
|
||||
username: str
|
||||
password: str
|
||||
path: str = "/"
|
||||
|
||||
|
||||
def _dav_url(server: str, username: str, path: str) -> str:
|
||||
import posixpath
|
||||
base = server.rstrip("/")
|
||||
# Normalize to collapse any ../ sequences before building URL
|
||||
p = posixpath.normpath("/" + path).lstrip("/")
|
||||
return f"{base}/remote.php/dav/files/{username}/{p}"
|
||||
|
||||
|
||||
def _parse_propfind(xml_bytes: bytes, base_path: str) -> list[dict]:
|
||||
"""Parse WebDAV PROPFIND response into a list of file/folder dicts."""
|
||||
ns = {"d": "DAV:"}
|
||||
tree = ET.fromstring(xml_bytes)
|
||||
items = []
|
||||
for response in tree.findall("d:response", ns):
|
||||
href = (response.findtext("d:href", "", ns) or "").rstrip("/")
|
||||
# Skip the directory itself
|
||||
props = response.find("d:propstat/d:prop", ns)
|
||||
if props is None:
|
||||
continue
|
||||
name = props.findtext("d:displayname", "", ns) or href.split("/")[-1]
|
||||
content_type = props.findtext("d:getcontenttype", "", ns) or ""
|
||||
size = props.findtext("d:getcontentlength", "0", ns) or "0"
|
||||
is_dir = props.find("d:resourcetype/d:collection", ns) is not None
|
||||
|
||||
# Build clean path from href
|
||||
dav_prefix = "/remote.php/dav/files/"
|
||||
if dav_prefix in href:
|
||||
clean = href[href.index(dav_prefix) + len(dav_prefix):]
|
||||
# Remove username prefix
|
||||
parts = clean.split("/", 1)
|
||||
item_path = "/" + (parts[1] if len(parts) > 1 else "")
|
||||
else:
|
||||
item_path = "/" + name
|
||||
|
||||
if is_dir:
|
||||
items.append({"name": name, "path": item_path, "type": "dir", "size": 0})
|
||||
elif "pdf" in content_type.lower() or name.lower().endswith(".pdf"):
|
||||
items.append({"name": name, "path": item_path, "type": "pdf", "size": int(size)})
|
||||
|
||||
# Sort: dirs first, then PDFs
|
||||
items.sort(key=lambda x: (0 if x["type"] == "dir" else 1, x["name"].lower()))
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def test_connection(req: NCRequest, _: User = Depends(require_moderator)):
|
||||
"""Test Nextcloud credentials by listing the root."""
|
||||
url = _dav_url(req.server, req.username, "/")
|
||||
try:
|
||||
resp = httpx.request(
|
||||
"PROPFIND", url,
|
||||
auth=(req.username, req.password),
|
||||
headers={"Depth": "0", "Content-Type": "application/xml"},
|
||||
content=DAV_PROPFIND,
|
||||
timeout=10,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code in (200, 207):
|
||||
return {"ok": True, "message": "Connected successfully"}
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
raise HTTPException(status_code=400, detail=f"Nextcloud returned {resp.status_code}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Connection failed: {e}")
|
||||
|
||||
|
||||
@router.post("/files")
|
||||
def list_files(req: NCRequest, _: User = Depends(require_moderator)):
|
||||
"""List PDFs and folders at the given path."""
|
||||
url = _dav_url(req.server, req.username, req.path)
|
||||
try:
|
||||
resp = httpx.request(
|
||||
"PROPFIND", url,
|
||||
auth=(req.username, req.password),
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=DAV_PROPFIND,
|
||||
timeout=15,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if resp.status_code not in (200, 207):
|
||||
raise HTTPException(status_code=400, detail=f"Nextcloud error {resp.status_code}")
|
||||
|
||||
items = _parse_propfind(resp.content, req.path)
|
||||
# Remove the current directory entry itself
|
||||
items = [i for i in items if i["path"] != req.path]
|
||||
return {"path": req.path, "items": items}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to list files: {e}")
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
def download_file(req: NCRequest, _: User = Depends(require_moderator)):
|
||||
"""Download a file from Nextcloud and stream it back for upload."""
|
||||
if not req.path.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Only PDF files can be imported")
|
||||
url = _dav_url(req.server, req.username, req.path)
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
auth=(req.username, req.password),
|
||||
timeout=120,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail=f"Download failed: {resp.status_code}")
|
||||
raw_name = req.path.split("/")[-1]
|
||||
# Strip characters that would break the Content-Disposition header
|
||||
safe_name = raw_name.replace('"', '').replace('\\', '').replace('\n', '').replace('\r', '') or "document.pdf"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(resp.content),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}"',
|
||||
"Content-Length": str(len(resp.content))},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Download failed: {e}")
|
||||
|
|
@ -831,123 +831,6 @@ def regenerate_embeddings(self, job_id: str, user_id: int, stale_only: bool = Tr
|
|||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="generate_flashcard_deck", bind=True)
|
||||
def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
||||
title: str, model_id: str | None = None):
|
||||
"""Generate flashcards from a document section using AI."""
|
||||
r = _redis()
|
||||
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.section import Section
|
||||
from app.models.pdf_document import PDFDocument
|
||||
from app.services import vector_service
|
||||
from app.services import extraction_modes
|
||||
|
||||
section = db.query(Section).filter(Section.id == section_id).first()
|
||||
if not section:
|
||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||
_push_step(r, job_id, "error", "Section not found")
|
||||
return
|
||||
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
|
||||
|
||||
from app.services.ai_service import get_model_for_task
|
||||
ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
|
||||
if model_id:
|
||||
ai_model_id = model_id
|
||||
|
||||
total_pages = section.end_page - section.start_page + 1
|
||||
_push_step(r, job_id, "start", f"Generating flashcards from {total_pages} pages…")
|
||||
|
||||
all_cards = []
|
||||
|
||||
if total_pages <= CHUNK_PAGES:
|
||||
content = vector_service.get_pages_text(section.document_id, section.start_page, section.end_page)
|
||||
if content:
|
||||
_push_step(r, job_id, "ai", f"Generating flashcards from pages {section.start_page}–{section.end_page}…")
|
||||
cards = extraction_modes.generate_flashcards(
|
||||
content, f"{section.start_page}–{section.end_page}",
|
||||
section.start_page, ai_model_id, ai_api_key,
|
||||
)
|
||||
all_cards.extend(cards)
|
||||
_push_step(r, job_id, "ai", f"Generated {len(cards)} cards")
|
||||
else:
|
||||
n_chunks = (total_pages + CHUNK_PAGES - 1) // CHUNK_PAGES
|
||||
_push_step(r, job_id, "ai", f"Large section: splitting into {n_chunks} chunks")
|
||||
for chunk_idx in range(1, n_chunks + 1):
|
||||
start_p = section.start_page + (chunk_idx - 1) * CHUNK_PAGES
|
||||
end_p = min(start_p + CHUNK_PAGES - 1, section.end_page)
|
||||
content = vector_service.get_pages_text(section.document_id, start_p, end_p)
|
||||
if not content or len(content.strip()) < 100:
|
||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: no text, skipping")
|
||||
continue
|
||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p}…")
|
||||
cards = extraction_modes.generate_flashcards(
|
||||
content, f"{start_p}–{end_p}", start_p, ai_model_id, ai_api_key,
|
||||
)
|
||||
all_cards.extend(cards)
|
||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
|
||||
|
||||
if not all_cards:
|
||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||
_push_step(r, job_id, "error", "No flashcards could be generated")
|
||||
return
|
||||
|
||||
# Refresh DB connection for save phase
|
||||
from sqlalchemy import text as _text
|
||||
try:
|
||||
db.execute(_text("SELECT 1"))
|
||||
except Exception:
|
||||
db.rollback()
|
||||
db.close()
|
||||
db = SessionLocal()
|
||||
|
||||
_push_step(r, job_id, "save", f"Saving {len(all_cards)} flashcards…")
|
||||
|
||||
from app.models.flashcard import FlashcardDeck, Flashcard
|
||||
deck = FlashcardDeck(
|
||||
title=title,
|
||||
section_id=section_id,
|
||||
user_id=user_id,
|
||||
card_count=len(all_cards),
|
||||
)
|
||||
db.add(deck)
|
||||
db.flush()
|
||||
|
||||
for c in all_cards:
|
||||
card = Flashcard(
|
||||
deck_id=deck.id,
|
||||
front=c["front"],
|
||||
back=c["back"],
|
||||
page_reference=c.get("page_reference"),
|
||||
)
|
||||
db.add(card)
|
||||
|
||||
db.commit()
|
||||
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
|
||||
r.set(f"extraction:deck_id:{job_id}", str(deck.id), ex=EXPIRE_SECONDS)
|
||||
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Flashcard generation failed: {e}")
|
||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
|
||||
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
#: The three readings of a topic the reader offers, and what each one is for.
|
||||
#: Written into the prompt because a model that is not told about them writes
|
||||
#: one article and the other two tabs stay empty — which is what happened to
|
||||
#: every generated article until now.
|
||||
#: Room for the whole thing. An article with a long view, a high-yield view and
|
||||
#: a clinical one runs well past four thousand tokens, and a reply that stops
|
||||
#: mid-string is not a JSON document.
|
||||
ARTICLE_MAX_TOKENS = 16000
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2308,14 +2308,6 @@
|
|||
"422"
|
||||
]
|
||||
},
|
||||
"POST /api/v1/flashcards/": {
|
||||
"body": true,
|
||||
"params": [],
|
||||
"responses": [
|
||||
"200",
|
||||
"422"
|
||||
]
|
||||
},
|
||||
"POST /api/v1/flashcards/cards/{card_id}/review": {
|
||||
"body": true,
|
||||
"params": [
|
||||
|
|
@ -2408,30 +2400,6 @@
|
|||
"422"
|
||||
]
|
||||
},
|
||||
"POST /api/v1/nextcloud/download": {
|
||||
"body": true,
|
||||
"params": [],
|
||||
"responses": [
|
||||
"200",
|
||||
"422"
|
||||
]
|
||||
},
|
||||
"POST /api/v1/nextcloud/files": {
|
||||
"body": true,
|
||||
"params": [],
|
||||
"responses": [
|
||||
"200",
|
||||
"422"
|
||||
]
|
||||
},
|
||||
"POST /api/v1/nextcloud/test": {
|
||||
"body": true,
|
||||
"params": [],
|
||||
"responses": [
|
||||
"200",
|
||||
"422"
|
||||
]
|
||||
},
|
||||
"POST /api/v1/question-categories/": {
|
||||
"body": true,
|
||||
"params": [],
|
||||
|
|
|
|||
|
|
@ -182,29 +182,6 @@ export default function DocumentDetailPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const generateFlashcards = async (sectionId, sectionName) => {
|
||||
setGenerating(sectionId)
|
||||
setError('')
|
||||
try {
|
||||
const title = quizTitle || `Cards: ${sectionName}`
|
||||
const res = await api.post('/flashcards/', {
|
||||
section_id: sectionId,
|
||||
title,
|
||||
model_id: selectedModelId || null,
|
||||
})
|
||||
if (res.data.job_id) {
|
||||
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
|
||||
stored.unshift({ jobId: res.data.job_id, title, status: 'running', lastStep: 'Starting…', ts: Date.now() })
|
||||
localStorage.setItem('pedquiz_jobs', JSON.stringify(stored.slice(0, 10)))
|
||||
setActiveJob({ jobId: res.data.job_id, sectionName, type: 'flashcard' })
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to start card generation. Check AI model config.')
|
||||
} finally {
|
||||
setGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const generateQuiz = async (sectionId, sectionName) => {
|
||||
setGenerating(sectionId)
|
||||
setError('')
|
||||
|
|
@ -499,13 +476,6 @@ export default function DocumentDetailPage() {
|
|||
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
|
||||
) : 'Extract Quiz'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => generateFlashcards(section.id, section.name)}
|
||||
disabled={generating === section.id}
|
||||
>
|
||||
Create Cards
|
||||
</button>
|
||||
<ConfirmButton
|
||||
onConfirm={() => deleteSection(section.id)}
|
||||
label="Delete section"
|
||||
|
|
|
|||
|
|
@ -129,101 +129,6 @@ function StudySection() {
|
|||
)
|
||||
}
|
||||
|
||||
function NextcloudSection() {
|
||||
const [server, setServer] = useState('https://cloud.danvics.com')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [status, setStatus] = useState(null)
|
||||
const [statusMsg, setStatusMsg] = useState('')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// Load from server on mount
|
||||
useEffect(() => {
|
||||
api.get('/auth/me/settings').then(res => {
|
||||
const s = res.data
|
||||
if (s.nc_server) setServer(s.nc_server)
|
||||
if (s.nc_username) setUsername(s.nc_username)
|
||||
if (s.nc_password) setPassword(s.nc_password)
|
||||
// Also sync to localStorage for UploadPage
|
||||
if (s.nc_server) localStorage.setItem('nc_server', s.nc_server)
|
||||
if (s.nc_username) localStorage.setItem('nc_username', s.nc_username)
|
||||
if (s.nc_password) localStorage.setItem('nc_password', s.nc_password)
|
||||
}).catch(() => {}).finally(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const save = async () => {
|
||||
// Save to both server (cross-browser) and localStorage (for UploadPage)
|
||||
localStorage.setItem('nc_server', server)
|
||||
localStorage.setItem('nc_username', username)
|
||||
localStorage.setItem('nc_password', password)
|
||||
try {
|
||||
await api.put('/auth/me/settings', { nc_server: server, nc_username: username, nc_password: password })
|
||||
} catch { }
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus(null), 2000)
|
||||
}
|
||||
|
||||
const test = async () => {
|
||||
setStatus('testing')
|
||||
setStatusMsg('')
|
||||
try {
|
||||
const res = await api.post('/nextcloud/test', { server, username, password })
|
||||
setStatus('ok')
|
||||
setStatusMsg(res.data.message)
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setStatusMsg(err.response?.data?.detail || 'Connection failed')
|
||||
}
|
||||
}
|
||||
|
||||
const clear = async () => {
|
||||
localStorage.removeItem('nc_server')
|
||||
localStorage.removeItem('nc_username')
|
||||
localStorage.removeItem('nc_password')
|
||||
try { await api.put('/auth/me/settings', {}) } catch { }
|
||||
setServer('https://cloud.danvics.com'); setUsername(''); setPassword('')
|
||||
setStatus(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Nextcloud Integration">
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
|
||||
Connect your Nextcloud to import PDFs directly from Upload page.
|
||||
</p>
|
||||
{status === 'ok' && <div className="alert alert-success">✓ {statusMsg}</div>}
|
||||
{status === 'error' && <div className="alert alert-error">✗ {statusMsg}</div>}
|
||||
{status === 'saved' && <div className="alert alert-success">Settings saved</div>}
|
||||
<div className="form-group">
|
||||
<label>Server URL</label>
|
||||
<input type="url" value={server} onChange={e => setServer(e.target.value)} placeholder="https://cloud.example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="your-username" autoComplete="off" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>App Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={save}>Save</button>
|
||||
<button className="btn btn-secondary" onClick={test} disabled={!username || !password || status === 'testing'}>
|
||||
{status === 'testing' ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{username && <button className="btn btn-secondary" onClick={clear}>Disconnect</button>}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 10 }}>
|
||||
Use an App Password (Nextcloud → Settings → Security), not your account password.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The places that are their own pages for good reason — a taxonomy tree, an
|
||||
* editorial queue, a job log. Everything that is a *setting* now lives in this
|
||||
* page's own sections rather than behind a link to a second dashboard.
|
||||
*/
|
||||
function ToolsSection() {
|
||||
// Editorial is not here: it has its own entry in the section bar, and a card
|
||||
// pointing at it would be a second door to the same room.
|
||||
|
|
@ -399,7 +304,7 @@ function SitePolicySection() {
|
|||
* Settings as a set of places, each with its own address.
|
||||
*
|
||||
* It was one 600px column holding the account form, the theme picker, a
|
||||
* Nextcloud integration, a document list and a grid of links — one of which
|
||||
* A document list and a grid of links — one of which
|
||||
* went to a second dashboard with a second row of tabs and a second visual
|
||||
* language. There is one place to configure the site now: the admin sections
|
||||
* are rendered here, under headings that say who they are for, and the section
|
||||
|
|
@ -436,7 +341,6 @@ export default function SettingsPage() {
|
|||
<DocumentsSection />
|
||||
{/* One person loads the corpus. This was offered to every learner
|
||||
as though each had a cloud to connect. */}
|
||||
{isAdmin && <NextcloudSection />}
|
||||
</>
|
||||
) },
|
||||
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',
|
||||
|
|
|
|||
|
|
@ -185,20 +185,6 @@ export default function ToolsPage() {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<section className="tools-section">
|
||||
<div className="tools-section-head">
|
||||
<h2>Import</h2>
|
||||
</div>
|
||||
<p className="tools-note">
|
||||
{/* One person loads the corpus. It was in everyone's settings as
|
||||
though each learner had a cloud to connect, which none of them
|
||||
has and none of them needs. */}
|
||||
Nextcloud is an import path for whoever loads the corpus, not a
|
||||
per-learner integration. <Link to="/settings?s=library">Connect it</Link>.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,130 +2,15 @@ import { useState, useRef } from 'react'
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
function NextcloudBrowser({ onFile }) {
|
||||
const ncServer = localStorage.getItem('nc_server') || ''
|
||||
const ncUser = localStorage.getItem('nc_username') || ''
|
||||
const ncPass = localStorage.getItem('nc_password') || ''
|
||||
|
||||
const [path, setPath] = useState('/')
|
||||
const [items, setItems] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [downloading, setDownloading] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const browse = async (p = path) => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const res = await api.post('/nextcloud/files', { server: ncServer, username: ncUser, password: ncPass, path: p })
|
||||
setItems(res.data.items)
|
||||
setPath(p)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to load files')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const importFile = async (item) => {
|
||||
setDownloading(item.path)
|
||||
try {
|
||||
const res = await api.post('/nextcloud/download',
|
||||
{ server: ncServer, username: ncUser, password: ncPass, path: item.path },
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const file = new File([res.data], item.name, { type: 'application/pdf' })
|
||||
onFile(file, true)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Download failed')
|
||||
} finally { setDownloading(null) }
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
const parts = path.replace(/\/$/, '').split('/')
|
||||
parts.pop()
|
||||
browse(parts.join('/') || '/')
|
||||
}
|
||||
|
||||
if (!ncUser) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}>☁️</div>
|
||||
No Nextcloud account configured.{' '}
|
||||
<Link to="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</Link> to add one.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: '0.85rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>☁️ {ncServer}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>→</span>
|
||||
<code style={{ color: 'var(--text)', fontSize: '0.82rem' }}>{path}</code>
|
||||
{path !== '/' && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={goUp}>↑ Up</button>
|
||||
)}
|
||||
{items === null && (
|
||||
<button className="btn btn-sm btn-primary" onClick={() => browse('/')} disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Browse'}
|
||||
</button>
|
||||
)}
|
||||
{items !== null && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => browse(path)} disabled={loading}>
|
||||
{loading ? '...' : '↻'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items !== null && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', maxHeight: 300, overflowY: 'auto' }}>
|
||||
{items.length === 0 && (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
No PDFs in this folder
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
|
||||
borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
background: 'var(--card-bg)',
|
||||
}}>
|
||||
<span style={{ fontSize: '1rem' }}>{item.type === 'dir' ? '📁' : '📄'}</span>
|
||||
<span style={{ flex: 1, fontSize: '0.875rem', color: 'var(--text)' }}>
|
||||
{item.name}
|
||||
{item.type === 'pdf' && (
|
||||
<span style={{ marginLeft: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
{(item.size / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.type === 'dir' ? (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => browse(item.path)}>Open</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-primary"
|
||||
onClick={() => importFile(item)}
|
||||
disabled={downloading === item.path}>
|
||||
{downloading === item.path ? 'Downloading...' : 'Import'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [tab, setTab] = useState('local') // 'local' | 'nextcloud'
|
||||
const fileRef = useRef()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const hasNextcloud = !!localStorage.getItem('nc_username')
|
||||
|
||||
const [uploadName, setUploadName] = useState('')
|
||||
const [stage, setStage] = useState('uploading') // 'uploading' | 'processing'
|
||||
|
|
@ -154,11 +39,9 @@ export default function UploadPage() {
|
|||
} finally { clearInterval(timer); setUploading(false) }
|
||||
}
|
||||
|
||||
const handleFile = (f, fromNextcloud = false) => {
|
||||
const handleFile = (f) => {
|
||||
if (f && f.type === 'application/pdf') {
|
||||
setFile(f); setError('')
|
||||
if (!fromNextcloud) setTab('local')
|
||||
if (fromNextcloud) doUpload(f)
|
||||
} else {
|
||||
setError('Please select a PDF file')
|
||||
}
|
||||
|
|
@ -174,22 +57,9 @@ export default function UploadPage() {
|
|||
Upload a PDF file (up to 500MB) to generate interactive quizzes.
|
||||
</p>
|
||||
|
||||
{/* Tab selector */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20 }}>
|
||||
<button className={`btn btn-sm ${tab === 'local' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTab('local')}>
|
||||
💻 Local File
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === 'nextcloud' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTab('nextcloud')}>
|
||||
☁️ Nextcloud{!hasNextcloud && ' (not set up)'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{tab === 'local' && (
|
||||
<>
|
||||
<>
|
||||
<div
|
||||
className={`upload-area ${dragging ? 'dragging' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
|
|
@ -211,20 +81,7 @@ export default function UploadPage() {
|
|||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept=".pdf" hidden onChange={(e) => handleFile(e.target.files[0])} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'nextcloud' && (
|
||||
<NextcloudBrowser onFile={handleFile} />
|
||||
)}
|
||||
|
||||
{/* Selected file from Nextcloud */}
|
||||
{tab === 'nextcloud' && file && (
|
||||
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)', fontSize: '0.875rem' }}>
|
||||
Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(1)} MB)
|
||||
<button onClick={() => setFile(null)} style={{ marginLeft: 12, background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
{uploading && (
|
||||
<div style={{ marginTop: 16, padding: '16px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)' }}>
|
||||
|
|
|
|||
|
|
@ -16,16 +16,17 @@ describe('UploadPage', () => {
|
|||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('uses client-side navigation for the settings link', async () => {
|
||||
it('offers one way in: a file from this machine', async () => {
|
||||
// The Nextcloud tab is gone. It was a per-person cloud integration on a
|
||||
// page only an educator reaches, for a corpus one person loads — and it
|
||||
// asked every one of them for an app password.
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UploadPage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '☁️ Nextcloud (not set up)' }))
|
||||
|
||||
const settingsLink = screen.getByRole('link', { name: 'Go to Settings' })
|
||||
expect(settingsLink).toHaveAttribute('href', '/settings')
|
||||
expect(screen.queryByRole('button', { name: /Nextcloud/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Local File/ })).toBeNull()
|
||||
expect(screen.getByText(/drag a PDF file here/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue