Compare commits

..

1 Commits

Author SHA1 Message Date
GitLink Course Creator a868372806 feat(course): add course +create command for creating course projects from teaching materials
Add a new 'course +create' shortcut that:
- Parses PPTX/PDF/MD teaching materials to extract course structure
- Optionally enhances content via LLM (DeepSeek/OpenAI)
- Creates GitLink repository with course content
- Generates README.md and chapter docs
- Creates learning check-in issues for each chapter

Usage: gitlink-cli course +create --source course.pptx --owner myorg
2026-07-16 15:03:16 +08:00
3 changed files with 714 additions and 0 deletions

685
shortcuts/course/create.go Executable file
View File

@ -0,0 +1,685 @@
package course
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// coursePlan holds the parsed course structure.
type coursePlan struct {
RepoName string // English repo name (URL-safe)
Title string // Chinese course title
Description string // Course description
Chapters []string // Chapter titles
}
func runCreate(ctx *common.RuntimeContext) error {
source := ctx.Arg("source")
if source == "" {
return fmt.Errorf("--source is required")
}
// 1. Parse material
plan, err := parseMaterial(source, ctx)
if err != nil {
return fmt.Errorf("parse material: %w", err)
}
// 2. Try LLM enhancement if configured
if err := tryLLMEnhance(plan, ctx); err != nil {
// LLM is optional — continue with parsed content
fmt.Fprintf(os.Stderr, "[warn] LLM enhancement skipped: %v\n", err)
}
// 3. Get current user
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ := userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
}
userID, _ := userData["user_id"].(float64)
owner := ctx.Arg("owner")
if owner == "" {
owner = login
}
repoName := plan.RepoName
// 4. Create repository
fmt.Fprintf(os.Stderr, "[info] Creating repository: %s/%s\n", owner, repoName)
createBody := map[string]interface{}{
"name": repoName,
"repository_name": repoName,
"user_id": int(userID),
"description": plan.Description,
}
if ctx.Arg("private") == "true" {
createBody["private"] = true
}
_, err = ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, repoName), createBody)
if err != nil {
// Repository might already exist — continue
fmt.Fprintf(os.Stderr, "[warn] Repository creation (may already exist): %v\n", err)
}
// 5. Prepare local git repo
tmpDir, err := os.MkdirTemp("", "course-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
token := os.Getenv("GITLINK_TOKEN")
cloneURL := fmt.Sprintf("https://%s@www.gitlink.org.cn/%s/%s.git", token, owner, repoName)
runCmd := func(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = tmpDir
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stderr
return cmd.Run()
}
fmt.Fprintf(os.Stderr, "[info] Cloning repository...\n")
if err := exec.Command("git", "clone", cloneURL, tmpDir).Run(); err != nil {
// Try without token
cloneURL2 := fmt.Sprintf("https://www.gitlink.org.cn/%s/%s.git", owner, repoName)
if err2 := exec.Command("git", "clone", cloneURL2, tmpDir).Run(); err2 != nil {
return fmt.Errorf("clone repository: %w (also tried: %v)", err, err2)
}
}
// Check default branch
branch := "master"
branchBytes, _ := exec.Command("git", "-C", tmpDir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if strings.TrimSpace(string(branchBytes)) != "" {
branch = strings.TrimSpace(string(branchBytes))
}
// 6. Generate course files
fmt.Fprintf(os.Stderr, "[info] Generating course content...\n")
if err := generateCourseFiles(tmpDir, plan); err != nil {
return fmt.Errorf("generate course files: %w", err)
}
// 7. Git commit and push
fmt.Fprintf(os.Stderr, "[info] Pushing to repository...\n")
cmds := [][]string{
{"-C", tmpDir, "add", "-A"},
{"-C", tmpDir, "-c", "user.name=GitLink Course Creator", "-c", "user.email=course@gitlink.org.cn", "commit", "--allow-empty", "-m", "feat: initialize course - " + plan.Title},
{"-C", tmpDir, "push", "origin", branch},
}
for _, args := range cmds {
cmd := exec.Command("git", args...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
}
// 8. Create learning issues
fmt.Fprintf(os.Stderr, "[info] Creating learning check-in issues...\n")
ghostURL := fmt.Sprintf("https://www.gitlink.org.cn/%s/%s/tree/%s", owner, repoName, branch)
for i, chapter := range plan.Chapters {
chNum := i + 1
title := fmt.Sprintf("学习打卡 #%d%s", chNum, chapter)
docURL := fmt.Sprintf("%s/docs/chapter%d.md", ghostURL, chNum)
body := fmt.Sprintf(`## 📖 本章学习任务
阅读第%d章 [%s](%s)完成以下任务
### 学习目标
- [ ] 掌握本章核心知识点
- [ ] 完成章节对应的实操练习
### 思考题
1. 阅读%s后你最大的收获是什么
2. 有哪些知识点你还想深入了解
### 打卡要求
完成学习后在评论区回复
- 你学到了什么至少3点
- 思考题的回答选答一题即可
`, chNum, chapter, docURL, chapter)
issueBody := map[string]interface{}{
"subject": title,
"description": body,
}
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/issues", owner, repoName), issueBody)
if err != nil {
fmt.Fprintf(os.Stderr, "[warn] Failed to create issue #%d: %v\n", chNum, err)
}
}
// 9. Output result
repoURL := fmt.Sprintf("https://www.gitlink.org.cn/%s/%s", owner, repoName)
fmt.Printf("\n✅ Course project created successfully!\n")
fmt.Printf(" Repository: %s\n", repoURL)
fmt.Printf(" Chapters: %d\n", len(plan.Chapters))
fmt.Printf("\nRun 'gitlink-cli issue +list --owner %s --repo %s' to view issues.\n", owner, repoName)
return nil
}
// parseMaterial parses the source file and extracts course structure.
func parseMaterial(source string, ctx *common.RuntimeContext) (*coursePlan, error) {
ext := strings.ToLower(filepath.Ext(source))
baseName := strings.TrimSuffix(filepath.Base(source), ext)
switch ext {
case ".pptx":
return parsePPTX(source, baseName)
case ".pdf":
return parsePDF(source, baseName)
case ".md", ".txt":
return parseText(source, baseName)
default:
return nil, fmt.Errorf("unsupported file format: %s (supported: .pptx, .pdf, .md, .txt)", ext)
}
}
// parsePPTX extracts text and structure from a PPTX file.
func parsePPTX(path, baseName string) (*coursePlan, error) {
r, err := zip.OpenReader(path)
if err != nil {
return nil, fmt.Errorf("open pptx: %w", err)
}
defer r.Close()
// Collect slide texts in order
var slides []string
for _, f := range r.File {
if strings.HasPrefix(f.Name, "ppt/slides/slide") && strings.HasSuffix(f.Name, ".xml") {
rc, err := f.Open()
if err != nil {
continue
}
buf := new(bytes.Buffer)
io.Copy(buf, rc)
rc.Close()
text := extractTextFromXML(buf.String())
if strings.TrimSpace(text) != "" {
slides = append(slides, text)
}
}
}
return buildPlanFromSlides(slides, baseName)
}
// extractTextFromXML removes XML tags and extracts text content.
func extractTextFromXML(xml string) string {
// Remove XML tags
re := regexp.MustCompile(`<[^>]*>`)
text := re.ReplaceAllString(xml, " ")
// Collapse whitespace
re = regexp.MustCompile(`\s+`)
text = re.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
// buildPlanFromSlides extracts course structure from slide text.
func buildPlanFromSlides(slides []string, baseName string) (*coursePlan, error) {
plan := &coursePlan{
RepoName: toRepoName(baseName),
Title: baseName,
Description: "基于课程材料创建的开源实践项目",
Chapters: []string{},
}
// Try to find title from the first slide
if len(slides) > 0 {
first := slides[0]
// Title is usually the first line of the first slide
lines := strings.SplitN(first, "\n", 2)
if len(lines) > 0 && len(lines[0]) > 2 && len(lines[0]) < 100 {
plan.Title = strings.TrimSpace(lines[0])
}
}
// Try to extract chapter structure from slide content
// Look for patterns like "第X章", "Chapter X", "1.", "2." etc.
chapterPattern := regexp.MustCompile(`(?:第[一二三四五六七八九十\d]+[章节]|Chapter\s+\d+|^\d+[\.、])\s*(.+)`)
seen := make(map[string]bool)
for _, slide := range slides {
lines := strings.Split(slide, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
matches := chapterPattern.FindStringSubmatch(line)
if matches != nil {
ch := strings.TrimSpace(matches[1])
if ch != "" && !seen[ch] && len(ch) < 80 {
plan.Chapters = append(plan.Chapters, ch)
seen[ch] = true
}
}
}
}
// If no chapters found, generate default ones
if len(plan.Chapters) == 0 {
// Try to extract from slide sequence
for i, slide := range slides {
if i == 0 {
continue // Skip title slide
}
lines := strings.Split(slide, "\n")
firstLine := strings.TrimSpace(lines[0])
if firstLine != "" && len(firstLine) < 80 && !strings.HasPrefix(firstLine, "目") && !strings.HasPrefix(firstLine, "内") {
plan.Chapters = append(plan.Chapters, firstLine)
}
}
}
// Limit to reasonable number
if len(plan.Chapters) > 12 {
plan.Chapters = plan.Chapters[:12]
}
if len(plan.Chapters) == 0 {
plan.Chapters = []string{"课程概述", "基础知识", "核心概念", "实践应用", "总结与展望"}
}
return plan, nil
}
// parsePDF extracts text from a PDF file (basic text extraction).
func parsePDF(path, baseName string) (*coursePlan, error) {
// Read file and try to extract text
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read pdf: %w", err)
}
// Basic PDF text extraction — look for text between parentheses in PDF streams
content := string(data)
// Extract text between parentheses (PDF text objects)
textRe := regexp.MustCompile(`\(([^)]*)\)`)
matches := textRe.FindAllStringSubmatch(content, -1)
var texts []string
for _, m := range matches {
t := strings.TrimSpace(m[1])
if len(t) > 2 {
texts = append(texts, t)
}
}
// Group into "slides" (every ~20 lines as a pseudo-slide)
var slides []string
var buf []string
for i, t := range texts {
buf = append(buf, t)
if (i+1)%20 == 0 {
slides = append(slides, strings.Join(buf, "\n"))
buf = nil
}
}
if len(buf) > 0 {
slides = append(slides, strings.Join(buf, "\n"))
}
if len(slides) == 0 {
slides = []string{baseName}
}
return buildPlanFromSlides(slides, baseName)
}
// parseText extracts structure from a markdown or text file.
func parseText(path, baseName string) (*coursePlan, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read text: %w", err)
}
content := string(data)
lines := strings.Split(content, "\n")
plan := &coursePlan{
RepoName: toRepoName(baseName),
Title: baseName,
Description: "基于课程材料创建的开源实践项目",
Chapters: []string{},
}
// First line might be title (if it's a heading)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
plan.Title = strings.TrimPrefix(trimmed, "# ")
break
}
}
// Extract chapter headings (## heading or # heading after first line)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## ") {
ch := strings.TrimPrefix(trimmed, "## ")
if ch != "" && len(ch) < 80 {
plan.Chapters = append(plan.Chapters, ch)
}
}
}
if len(plan.Chapters) == 0 {
plan.Chapters = []string{"课程概述", "基础知识", "核心概念", "实践应用", "总结与展望"}
}
return plan, nil
}
// tryLLMEnhance attempts to enhance the course plan using an LLM.
func tryLLMEnhance(plan *coursePlan, ctx *common.RuntimeContext) error {
provider := ctx.Arg("llm-provider")
key := ctx.Arg("llm-key")
if key == "" {
key = os.Getenv("GITLINK_LLM_KEY")
}
if provider == "" || key == "" {
// Try to read from ~/.gc/settings.json
home, _ := os.UserHomeDir()
gcConfig := filepath.Join(home, ".gc", "settings.json")
if data, err := os.ReadFile(gcConfig); err == nil {
var cfg struct {
Providers map[string]struct {
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl,omitempty"`
} `json:"providers"`
}
if json.Unmarshal(data, &cfg) == nil {
for p, c := range cfg.Providers {
if provider == "" {
provider = p
}
if key == "" {
key = c.APIKey
}
}
}
}
}
if provider == "" || key == "" {
return fmt.Errorf("no LLM configured — skipping enhancement")
}
// Build prompt
prompt := fmt.Sprintf(`你是一个课程设计专家请根据以下课程信息生成一个高质量的课程方案
课程标题%s
课程简介%s
章节列表%s
请返回JSON格式包含
1. title: 优化后的课程标题中文
2. description: 课程简介50-100
3. chapters: 优化后的章节名称列表每个章节名称10-20保持原数量
只返回JSON不要其他内容`, plan.Title, plan.Description, strings.Join(plan.Chapters, "、"))
// Call LLM API
result, err := callLLM(provider, key, prompt)
if err != nil {
return err
}
var enhanced struct {
Title string `json:"title"`
Description string `json:"description"`
Chapters []string `json:"chapters"`
}
if err := json.Unmarshal([]byte(result), &enhanced); err != nil {
return fmt.Errorf("parse LLM response: %w", err)
}
if enhanced.Title != "" {
plan.Title = enhanced.Title
}
if enhanced.Description != "" {
plan.Description = enhanced.Description
}
if len(enhanced.Chapters) > 0 {
plan.Chapters = enhanced.Chapters
}
return nil
}
// callLLM calls the specified LLM provider's API.
func callLLM(provider, key, prompt string) (string, error) {
var apiURL, model string
switch provider {
case "deepseek":
apiURL = "https://api.deepseek.com/v1/chat/completions"
model = "deepseek-chat"
case "openai":
apiURL = "https://api.openai.com/v1/chat/completions"
model = "gpt-4o-mini"
default:
return "", fmt.Errorf("unsupported LLM provider: %s", provider)
}
body := map[string]interface{}{
"model": model,
"messages": []map[string]string{
{"role": "system", "content": "你是一个课程设计专家,擅长从教学材料中提取课程结构并优化课程方案。"},
{"role": "user", "content": prompt},
},
"temperature": 0.3,
"max_tokens": 2048,
}
bodyBytes, _ := json.Marshal(body)
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("LLM API returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("LLM returned no choices")
}
return result.Choices[0].Message.Content, nil
}
// generateCourseFiles creates README.md and chapter docs in the repo directory.
func generateCourseFiles(repoDir string, plan *coursePlan) error {
// Create docs directory
docsDir := filepath.Join(repoDir, "docs")
if err := os.MkdirAll(docsDir, 0755); err != nil {
return err
}
// Build chapter table for README
var chapterRows []string
tableHeader := "| 章节 | 章节名称 | 主要内容 | 学习目标 |\n|------|----------|----------|----------|"
chapterRows = append(chapterRows, tableHeader)
for i, ch := range plan.Chapters {
num := i + 1
link := fmt.Sprintf("[%s](docs/chapter%d.md)", ch, num)
row := fmt.Sprintf("| %d | %s | 待补充 | 待补充 |", num, link)
chapterRows = append(chapterRows, row)
}
// Generate README.md
readme := fmt.Sprintf(`# %s
## 1. 课程概述
%s
## 2. 学习目标
通过学习本课程您将
1. 掌握核心知识点
2. 了解相关技术和方法
3. 能够将所学知识应用于实践
## 3. 适合的学习者
对相关内容感兴趣的学习者
## 4. 课程结构
%s
## 5. 实操演练
本课程秉持"做中学 (Learning by Doing)"原则实验演练贯穿全章节
## 6. 课程打卡指引
本项目采用 **Issue 评论打卡** 的方式完成每项任务后在对应 Issue 下提交您的学习成果
## 7. 共创与贡献
欢迎所有学习者参与贡献
**贡献方式**
- 提交 Issue 反馈问题或建议
- 提交 Pull Request 完善内容
- 分享您的学习经验
## 8. 许可说明
本课程采用 **CC BY-SA 4.0** 开源许可证
`, plan.Title, plan.Description, strings.Join(chapterRows, "\n"))
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte(readme), 0644); err != nil {
return err
}
// Generate chapter docs
for i, ch := range plan.Chapters {
num := i + 1
chapterContent := fmt.Sprintf(`# %d章%s
## 本章概述
本章将介绍 %s 的核心概念和基础知识
## 学习目标
完成本章学习后您将能够
1. 理解 %s 的基本概念
2. 掌握相关知识和技能
3. 完成对应的实践练习
## 内容要点
### 1. 核心概念
- 概念一待补充
- 概念二待补充
- 概念三待补充
### 2. 实践操作
待补充具体操作步骤和练习内容
## 本章小结
本章主要介绍了 %s 的核心内容为后续学习打下基础
## 课后练习
1. 练习一待补充
2. 练习二待补充
3. 练习三待补充
`, num, ch, ch, ch, ch)
filePath := filepath.Join(docsDir, fmt.Sprintf("chapter%d.md", num))
if err := os.WriteFile(filePath, []byte(chapterContent), 0644); err != nil {
return err
}
}
return nil
}
// toRepoName converts a base name to a URL-safe repository name.
func toRepoName(name string) string {
// Remove extension and special chars
name = strings.TrimSuffix(name, filepath.Ext(name))
// Replace Chinese/CJK characters with English equivalents
// Simple approach: replace non-alphanumeric with hyphens
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
name = re.ReplaceAllString(name, "-")
// Collapse multiple hyphens
re = regexp.MustCompile(`-+`)
name = re.ReplaceAllString(name, "-")
// Trim leading/trailing hyphens
name = strings.Trim(name, "-")
// Limit length
if len(name) > 100 {
name = name[:100]
}
// If name is empty after sanitization, use a default
if name == "" {
name = "course-project"
}
return name
}
// init registers the env var for LLM key.
func init() {
// GITLINK_LLM_KEY is read in tryLLMEnhance
}
// Ensure unused imports are used
var _ = strconv.Itoa
var _ = url.Values{}

26
shortcuts/course/shortcuts.go Executable file
View File

@ -0,0 +1,26 @@
package course
import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns the course command group.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "create",
Description: "Create a course project from teaching materials (PPTX/PDF/MD)",
Flags: []common.Flag{
{Name: "source", Short: "s", Usage: "Path to course material file (.pptx/.pdf/.md)", Required: true},
{Name: "owner", Short: "o", Usage: "Repository owner (default: current user)"},
{Name: "private", Usage: "Make repository private", Default: "false", Bool: true},
{Name: "name", Short: "n", Usage: "Repository name (auto-generated from material if not set)"},
{Name: "title", Short: "t", Usage: "Course title (auto-extracted from material if not set)"},
{Name: "chapters", Short: "c", Usage: "Number of chapters", Default: "5"},
{Name: "llm-provider", Usage: "LLM provider for AI-enhanced content (deepseek/openai, optional)"},
{Name: "llm-key", Usage: "LLM API key (or set GITLINK_LLM_KEY env var)"},
},
Run: runCreate,
},
}
}

View File

@ -5,6 +5,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/course"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
@ -27,6 +28,7 @@ func RegisterAll(root *cobra.Command) {
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"course": course.Shortcuts(),
}
descriptions := map[string]string{
@ -39,6 +41,7 @@ func RegisterAll(root *cobra.Command) {
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"course": "Course project operations",
}
for name, shortcuts := range groups {