package utils import ( "fmt" "os" "path/filepath" "sort" "strings" "time" "gobsidian/internal/config" "gobsidian/internal/models" ) func BuildTreeStructure(rootDir string, hiddenDirs []string, cfg *config.Config) (*models.TreeNode, error) { root := &models.TreeNode{ Name: filepath.Base(rootDir), Path: "", Type: models.FileTypeDirectory, } err := buildTreeRecursive(rootDir, root, hiddenDirs, cfg) return root, err } func buildTreeRecursive(currentPath string, node *models.TreeNode, hiddenDirs []string, cfg *config.Config) error { entries, err := os.ReadDir(currentPath) if err != nil { return err } // Sort entries: directories first, then files sort.Slice(entries, func(i, j int) bool { if entries[i].IsDir() != entries[j].IsDir() { return entries[i].IsDir() } return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) }) for _, entry := range entries { // Skip hidden files if strings.HasPrefix(entry.Name(), ".") { continue } // Skip hidden directories if entry.IsDir() && contains(hiddenDirs, entry.Name()) { continue } childPath := filepath.Join(currentPath, entry.Name()) relativePath := getRelativePath(childPath, cfg.NotesDir) child := &models.TreeNode{ Name: entry.Name(), Path: relativePath, } if entry.IsDir() { child.Type = models.FileTypeDirectory // Recursively build children if err := buildTreeRecursive(childPath, child, hiddenDirs, cfg); err != nil { continue // Skip directories that can't be read } } else { child.Type = models.GetFileType(filepath.Ext(entry.Name()), cfg.AllowedImageExtensions, cfg.AllowedFileExtensions) // Only include markdown files and allowed file types in the tree if child.Type != models.FileTypeMarkdown && child.Type != models.FileTypeText { continue } } node.Children = append(node.Children, child) } return nil } func GetFolderContents(folderPath string, cfg *config.Config) ([]models.FileInfo, error) { fullPath := filepath.Join(cfg.NotesDir, folderPath) entries, err := os.ReadDir(fullPath) if err != nil { return nil, err } var contents []models.FileInfo // Sort entries: directories first, then files sort.Slice(entries, func(i, j int) bool { if entries[i].IsDir() != entries[j].IsDir() { return entries[i].IsDir() } return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) }) for _, entry := range entries { // Skip hidden files if strings.HasPrefix(entry.Name(), ".") { continue } info, err := entry.Info() if err != nil { continue } relativePath := filepath.Join(folderPath, entry.Name()) if folderPath == "" { relativePath = entry.Name() } fileInfo := models.FileInfo{ Name: entry.Name(), Path: relativePath, Size: info.Size(), ModTime: info.ModTime(), } if entry.IsDir() { fileInfo.Type = models.FileTypeDirectory fileInfo.DisplayName = entry.Name() } else { fileInfo.Type = models.GetFileType(filepath.Ext(entry.Name()), cfg.AllowedImageExtensions, cfg.AllowedFileExtensions) // Set display name based on file type if fileInfo.Type == models.FileTypeMarkdown { fileInfo.DisplayName = strings.TrimSuffix(entry.Name(), ".md") } else { fileInfo.DisplayName = entry.Name() } // Skip images if they should be hidden if cfg.ImagesHide && fileInfo.Type == models.FileTypeImage { continue } // Skip files that are not allowed if fileInfo.Type == models.FileTypeOther { continue } } contents = append(contents, fileInfo) } return contents, nil } func GenerateBreadcrumbs(path string) []models.Breadcrumb { var breadcrumbs []models.Breadcrumb // Add root breadcrumbs = append(breadcrumbs, models.Breadcrumb{ Name: "/", URL: "/", }) if path == "" { return breadcrumbs } parts := strings.Split(path, "/") currentPath := "" for _, part := range parts { if part == "" { continue } if currentPath == "" { currentPath = part } else { currentPath = filepath.Join(currentPath, part) } breadcrumbs = append(breadcrumbs, models.Breadcrumb{ Name: part, URL: "/folder/" + currentPath, }) } return breadcrumbs } func GetImageStorageInfo(notePath string, cfg *config.Config) models.ImageStorageInfo { var storageDir, markdownPath string switch cfg.ImageStorageMode { case 1: // Store directly in NOTES_DIR storageDir = cfg.NotesDir markdownPath = "" case 2: // Store in specific folder storageDir = cfg.ImageStoragePath markdownPath = "" case 3: // Store in same directory as note if notePath != "" { storageDir = filepath.Join(cfg.NotesDir, filepath.Dir(notePath)) } else { storageDir = cfg.NotesDir } markdownPath = "" case 4: // Store in subfolder of note's directory if notePath != "" { storageDir = filepath.Join(cfg.NotesDir, filepath.Dir(notePath), cfg.ImageSubfolderName) } else { storageDir = filepath.Join(cfg.NotesDir, cfg.ImageSubfolderName) } markdownPath = cfg.ImageSubfolderName default: storageDir = cfg.NotesDir markdownPath = "" } return models.ImageStorageInfo{ StorageDir: storageDir, MarkdownPath: markdownPath, } } func EnsureDir(dirPath string) error { return os.MkdirAll(dirPath, 0755) } func IsPathInSkippedDirs(path string, skippedDirs []string) bool { parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) for _, part := range parts { if contains(skippedDirs, part) { return true } } return false } func GetActivePath(currentPath string) []string { if currentPath == "" { return []string{} } return strings.Split(strings.Trim(currentPath, "/"), "/") } // Helper functions func contains(slice []string, item string) bool { for _, s := range slice { if strings.TrimSpace(s) == item { return true } } return false } func getRelativePath(fullPath, basePath string) string { rel, err := filepath.Rel(basePath, fullPath) if err != nil { return fullPath } return filepath.ToSlash(rel) } func FormatFileSize(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") }