package workspace import ( "fmt" "os" "path/filepath" "strings" "unicode" ) // ─── Path helpers ───────────────────────────────────────────────────────────── // PostPath returns the post root directory: // func PostPath(workspaceRoot, categoria, slug string) string { return filepath.Join(workspaceRoot, categoria, slug) } // InputPath returns the input/ subdirectory of a post. func InputPath(postPath string) string { return filepath.Join(postPath, "input") } // WorkPath returns the work/ subdirectory of a post. func WorkPath(postPath string) string { return filepath.Join(postPath, "work") } // OutputPath returns the output/ subdirectory of a post. func OutputPath(postPath string) string { return filepath.Join(postPath, "output") } // ArtRawPath returns the work/art-raw/ subdirectory. func ArtRawPath(postPath string) string { return filepath.Join(postPath, "work", "art-raw") } // InputTextoPath returns the path to input/texto.md. func InputTextoPath(postPath string) string { return filepath.Join(InputPath(postPath), "texto.md") } // OutputPostPath returns the path to output/post.md. func OutputPostPath(postPath string) string { return filepath.Join(OutputPath(postPath), "post.md") } // ArtPromptsPath returns work/art-prompts.json. func ArtPromptsPath(postPath string) string { return filepath.Join(WorkPath(postPath), "art-prompts.json") } // InboxSugestoes returns the inbox sugestões file path. func InboxSugestoes(workspaceRoot string) string { return filepath.Join(workspaceRoot, "_inbox", "_sugestoes.md") } // VersionedFile returns the path for a versioned file like redator-v1.md. func VersionedFile(postPath, prefix string, n int) string { return filepath.Join(WorkPath(postPath), fmt.Sprintf("%s-v%d.md", prefix, n)) } // ─── Discovery ──────────────────────────────────────────────────────────────── // FindVariantsByBase returns all postPaths whose slug is exactly baseSlug OR // starts with baseSlug+"--" (recycled variants like "rag-csharp--porque"). // Only paths that have a work/ subdirectory are included. func FindVariantsByBase(workspaceRoot, baseSlug string) []string { var found []string cats, err := os.ReadDir(workspaceRoot) if err != nil { return nil } prefix := baseSlug + "--" for _, cat := range cats { if !cat.IsDir() || strings.HasPrefix(cat.Name(), "_") || strings.HasPrefix(cat.Name(), ".") { continue } catPath := filepath.Join(workspaceRoot, cat.Name()) slugs, err := os.ReadDir(catPath) if err != nil { continue } for _, s := range slugs { if !s.IsDir() { continue } name := s.Name() if name == baseSlug || strings.HasPrefix(name, prefix) { candidate := filepath.Join(catPath, name) if _, err := os.Stat(WorkPath(candidate)); err == nil { found = append(found, candidate) } } } } return found } // FindPostBySlug searches all category subdirectories for a post with the given // slug. Returns the postPath on success. func FindPostBySlug(workspaceRoot, slug string) (string, error) { entries, err := os.ReadDir(workspaceRoot) if err != nil { return "", fmt.Errorf("ler workspace %s: %w", workspaceRoot, err) } for _, e := range entries { if !e.IsDir() || strings.HasPrefix(e.Name(), "_") || strings.HasPrefix(e.Name(), ".") { continue } candidate := PostPath(workspaceRoot, e.Name(), slug) if _, err := os.Stat(WorkPath(candidate)); err == nil { return candidate, nil } } return "", fmt.Errorf("slug %q não encontrado em %s", slug, workspaceRoot) } // LatestVersionFile scans work/ for files matching -vN.md and returns // the path and version number of the highest version, or ("", 0) if none. func LatestVersionFile(postPath, prefix string) (string, int) { maxN := 0 for n := 1; n <= 99; n++ { path := VersionedFile(postPath, prefix, n) if _, err := os.Stat(path); err == nil { maxN = n } else { break } } if maxN == 0 { return "", 0 } return VersionedFile(postPath, prefix, maxN), maxN } // ─── Directory setup ────────────────────────────────────────────────────────── // EnsureDirs creates input/, work/, output/, and work/art-raw/ for a post. func EnsureDirs(postPath string) error { dirs := []string{ InputPath(postPath), WorkPath(postPath), OutputPath(postPath), ArtRawPath(postPath), } for _, d := range dirs { if err := os.MkdirAll(d, 0755); err != nil { return fmt.Errorf("criar %s: %w", d, err) } } return nil } // ─── Slug ───────────────────────────────────────────────────────────────────── // SlugFromTitle converts a human-readable title to a lowercase ASCII slug. // "Como criar RAG com C#: guia completo" → "como-criar-rag-com-csharp-guia-completo" func SlugFromTitle(title string) string { // Replace common non-ASCII sequences before lowercasing replacements := map[string]string{ "C#": "csharp", "C++": "cpp", ".NET": "dotnet", "@": "at", } for old, newVal := range replacements { title = strings.ReplaceAll(title, old, newVal) } title = strings.ToLower(title) var result []rune for _, r := range title { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): result = append(result, r) case r == ' ' || r == '-' || r == '_' || r == ':' || r == '/': if len(result) > 0 && result[len(result)-1] != '-' { result = append(result, '-') } } // All other chars (accents, punctuation) dropped } slug := strings.Trim(string(result), "-") // Normalize accented chars with simple replacements slug = strings.NewReplacer( "ã", "a", "á", "a", "â", "a", "à", "a", "é", "e", "ê", "e", "è", "e", "í", "i", "î", "i", "ó", "o", "ô", "o", "õ", "o", "ú", "u", "û", "u", "ç", "c", "ñ", "n", ).Replace(slug) return slug }