forked from Gitlink/gitlink-cli
Compare commits
No commits in common. "feat/course-create" and "master" have entirely different histories.
feat/cours
...
master
|
|
@ -1,685 +0,0 @@
|
|||
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{}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ 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"
|
||||
|
|
@ -28,7 +27,6 @@ func RegisterAll(root *cobra.Command) {
|
|||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"course": course.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
|
|
@ -41,7 +39,6 @@ func RegisterAll(root *cobra.Command) {
|
|||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"course": "Course project operations",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
Loading…
Reference in New Issue