package cli import ( "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" "unicode" ) func statusCommand(status string) string { if status == "published" { return "publish" } return "unpublish" } func uniquePath(path string) string { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return path } ext := filepath.Ext(path) base := strings.TrimSuffix(path, ext) for i := 2; i < 1000; i++ { candidate := fmt.Sprintf("%s-%d%s", base, i, ext) if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { return candidate } } return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext) } func mustRel(root string, path string) string { rel, err := filepath.Rel(root, path) if err != nil { return path } return rel } func stringPtrValue(value *string) string { if value == nil { return "" } return *value } func parseTime(value string) (time.Time, bool) { value = strings.TrimSpace(value) if value == "" { return time.Time{}, false } parsed, err := time.Parse(time.RFC3339, value) if err == nil { return parsed, true } parsed, err = time.Parse("2006-01-02 15:04:05", value) if err == nil { return parsed, true } return time.Time{}, false } func confirm(prompt string) bool { fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt) var answer string if _, err := fmt.Fscan(os.Stdin, &answer); err != nil { return false } answer = strings.ToLower(strings.TrimSpace(answer)) return answer == "y" || answer == "yes" } func resolveRootPath(root string, path string) string { if filepath.IsAbs(path) { return path } return filepath.Join(root, path) } func yesNo(value bool) string { if value { return "yes" } return "no" } func boolInt(value bool) int { if value { return 1 } return 0 } func contentHash(content string) string { sum := sha256.Sum256([]byte(content)) return "sha256:" + hex.EncodeToString(sum[:]) } func sortedKeys(values map[string]int) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys } func sortedBoolKeys(values map[string]bool) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys } func uniqueSlug(root string, slug string) (string, error) { base := sanitizeSlug(slug) if base == "" { return "", errors.New("empty slug") } if !slugExists(root, base) { return base, nil } for i := 2; i < 1000; i++ { candidate := fmt.Sprintf("%s-%d", base, i) if !slugExists(root, candidate) { return candidate, nil } } return "", fmt.Errorf("could not find available slug for %q", base) } func slugExists(root string, slug string) bool { posts, err := loadPosts(root) if err == nil { for _, post := range posts { fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md") if post.Frontmatter.Slug == slug || fileSlug == slug { return true } } } _, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md")) return err == nil } func uniquePostPath(postsDir string, slug string) (string, error) { candidate := filepath.Join(postsDir, slug+".md") if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { return candidate, nil } else if err != nil { return "", err } for i := 2; i < 1000; i++ { candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i)) if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { return candidate, nil } else if err != nil { return "", err } } return "", fmt.Errorf("could not find available filename for slug %q", slug) } func findProjectRoot() (string, error) { wd, err := os.Getwd() if err != nil { return "", err } for { if isProjectRoot(wd) { return wd, nil } parent := filepath.Dir(wd) if parent == wd { return "", errors.New("could not find project root") } wd = parent } } func isProjectRoot(dir string) bool { if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil { return true } } if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil { if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil { return true } } return false } func fallbackSlug(title string) string { var words []string var b strings.Builder flush := func() { if b.Len() > 0 { words = append(words, b.String()) b.Reset() } } for _, r := range strings.ToLower(title) { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/': flush() default: flush() } } flush() if len(words) == 0 { return "post-" + time.Now().Format("20060102150405") } return sanitizeSlug(strings.Join(words, "-")) } func sanitizeSlug(slug string) string { slug = strings.ToLower(strings.TrimSpace(slug)) re := regexp.MustCompile(`[^a-z0-9]+`) slug = re.ReplaceAllString(slug, "-") slug = strings.Trim(slug, "-") for strings.Contains(slug, "--") { slug = strings.ReplaceAll(slug, "--", "-") } if len(slug) > 80 { slug = strings.Trim(slug[:80], "-") } return slug } func randomID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("post-%d", time.Now().UnixNano()) } return hex.EncodeToString(b[:]) } func escapeYAML(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) return s } func formatYAMLStringList(values []string) string { if len(values) == 0 { return "[]" } var b strings.Builder b.WriteString("[") for i, value := range values { if i > 0 { b.WriteString(", ") } b.WriteString(`"`) b.WriteString(escapeYAML(value)) b.WriteString(`"`) } b.WriteString("]") return b.String() } func writeFileIfMissing(path string, content string) error { if _, err := os.Stat(path); err == nil { return nil } else if !errors.Is(err, os.ErrNotExist) { return err } return os.WriteFile(path, []byte(content), 0o644) }