feat: add course summary search
This commit is contained in:
+165
@@ -42,6 +42,8 @@ CLASSNOTE_RE = re.compile(
|
||||
)
|
||||
PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P<hours>\d+(?:\.\d+)?)$")
|
||||
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
||||
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
|
||||
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})")
|
||||
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
|
||||
BACKUP_KEEP_COUNT = 50
|
||||
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
||||
@@ -1059,6 +1061,169 @@ def list_operation_logs(path: Path, limit: int = 100, operation: str = "", statu
|
||||
return {"count": len(rows), "items": rows}
|
||||
|
||||
|
||||
def normalize_filter_date(value: str) -> str:
|
||||
text = value.strip().replace(".", "-")
|
||||
if not text:
|
||||
return ""
|
||||
parts = text.split("-")
|
||||
if len(parts) == 3:
|
||||
text = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}"
|
||||
return date.fromisoformat(text).isoformat()
|
||||
|
||||
|
||||
def parse_course_summary_file_identity(root: Path, path: Path) -> dict:
|
||||
relative = path.relative_to(root)
|
||||
student = canonical_name(relative.parts[0]) if relative.parts else ""
|
||||
stem = path.stem
|
||||
parts = stem.split("_")
|
||||
teacher = ""
|
||||
subject = ""
|
||||
if len(parts) >= 3:
|
||||
student = canonical_name(parts[0])
|
||||
teacher = canonical_name("_".join(parts[1:-1]))
|
||||
subject = parts[-1]
|
||||
return {
|
||||
"student": student,
|
||||
"teacher": teacher,
|
||||
"subject": normalize_subject(subject),
|
||||
"source_path": str(path),
|
||||
"relative_path": str(relative),
|
||||
}
|
||||
|
||||
|
||||
def parse_course_summary_title_date(title: str) -> str:
|
||||
match = COURSE_SUMMARY_DATE_RE.search(title)
|
||||
if not match:
|
||||
return ""
|
||||
raw = match.group("date").replace(".", "-")
|
||||
parts = raw.split("-")
|
||||
if len(parts) != 3:
|
||||
return ""
|
||||
normalized = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}"
|
||||
try:
|
||||
return date.fromisoformat(normalized).isoformat()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
|
||||
if not root.exists():
|
||||
return
|
||||
for path in sorted(root.rglob("*.md")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
|
||||
if not matches:
|
||||
continue
|
||||
identity = parse_course_summary_file_identity(root, path)
|
||||
group = ""
|
||||
group_match = re.search(r"^##\s+(.+)$", text[: matches[0].start()], flags=re.M)
|
||||
if group_match:
|
||||
group = group_match.group(1).strip()
|
||||
for index, match in enumerate(matches):
|
||||
title = match.group("title").strip()
|
||||
start = match.end()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
body = text[start:end].strip()
|
||||
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
|
||||
body_text = body_without_meta or body
|
||||
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
|
||||
yield {
|
||||
**identity,
|
||||
"id": item_id,
|
||||
"title": title,
|
||||
"date_iso": parse_course_summary_title_date(title),
|
||||
"group": group,
|
||||
"body": body_text,
|
||||
"body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""),
|
||||
}
|
||||
|
||||
|
||||
def course_summary_matches(item: dict, q: str, student: str, teacher: str, subject: str, date_from: str, date_to: str) -> bool:
|
||||
if student and student not in str(item.get("student", "")):
|
||||
return False
|
||||
if teacher and teacher not in str(item.get("teacher", "")):
|
||||
return False
|
||||
if subject and normalize_subject(subject) not in str(item.get("subject", "")):
|
||||
return False
|
||||
item_date = str(item.get("date_iso") or "")
|
||||
if date_from and (not item_date or item_date < date_from):
|
||||
return False
|
||||
if date_to and (not item_date or item_date > date_to):
|
||||
return False
|
||||
if q:
|
||||
haystack = "\n".join(
|
||||
str(item.get(key, ""))
|
||||
for key in ("student", "teacher", "subject", "title", "group", "body", "relative_path")
|
||||
)
|
||||
return q in haystack
|
||||
return True
|
||||
|
||||
|
||||
def course_summary_matched_fields(item: dict, q: str) -> list[str]:
|
||||
if not q:
|
||||
return []
|
||||
labels = {
|
||||
"student": "学生",
|
||||
"teacher": "老师",
|
||||
"subject": "科目",
|
||||
"title": "标题",
|
||||
"group": "群名",
|
||||
"body": "正文",
|
||||
"relative_path": "来源",
|
||||
}
|
||||
return [label for key, label in labels.items() if q in str(item.get(key, ""))]
|
||||
|
||||
|
||||
def query_course_summaries(
|
||||
root: Path,
|
||||
q: str = "",
|
||||
student: str = "",
|
||||
teacher: str = "",
|
||||
subject: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
limit: int = 200,
|
||||
) -> dict:
|
||||
normalized_from = normalize_filter_date(date_from)
|
||||
normalized_to = normalize_filter_date(date_to)
|
||||
if normalized_from and normalized_to and normalized_from > normalized_to:
|
||||
raise ValueError("开始日期不能晚于结束日期")
|
||||
keyword = q.strip()
|
||||
matched = [
|
||||
item
|
||||
for item in iter_course_summary_markdown(root)
|
||||
if course_summary_matches(
|
||||
item,
|
||||
keyword,
|
||||
canonical_name(student.strip()),
|
||||
canonical_name(teacher.strip()),
|
||||
subject.strip(),
|
||||
normalized_from,
|
||||
normalized_to,
|
||||
)
|
||||
]
|
||||
for item in matched:
|
||||
item["matched_fields"] = course_summary_matched_fields(item, keyword)
|
||||
matched.sort(
|
||||
key=lambda item: (
|
||||
str(item.get("date_iso") or "0000-00-00"),
|
||||
str(item.get("student") or ""),
|
||||
str(item.get("teacher") or ""),
|
||||
str(item.get("subject") or ""),
|
||||
str(item.get("title") or ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
limited = matched[:limit]
|
||||
return {
|
||||
"count": len(matched),
|
||||
"returned": len(limited),
|
||||
"items": limited,
|
||||
}
|
||||
|
||||
|
||||
def course_summary_path(root: Path, summary: dict) -> Path:
|
||||
student = safe_filename_part(summary["student"])
|
||||
teacher = safe_filename_part(summary["teacher"] or "待核对老师")
|
||||
|
||||
Reference in New Issue
Block a user