2025-08-25 08:48:52 +01:00
|
|
|
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)
|
2025-08-25 18:15:51 +01:00
|
|
|
// Apply visibility for tree (left navigation)
|
|
|
|
|
switch child.Type {
|
|
|
|
|
case models.FileTypeMarkdown:
|
|
|
|
|
// always show
|
|
|
|
|
case models.FileTypeImage:
|
|
|
|
|
if !cfg.ShowImagesInTree {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
case models.FileTypeText:
|
|
|
|
|
if !cfg.ShowFilesInTree {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
default:
|
2025-08-25 08:48:52 +01:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-25 18:15:51 +01:00
|
|
|
// Visibility for folder view
|
|
|
|
|
if fileInfo.Type == models.FileTypeImage {
|
|
|
|
|
// prefer new flag; fallback to legacy ImagesHide (handled by default in config load)
|
|
|
|
|
if !cfg.ShowImagesInFolder {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if fileInfo.Type == models.FileTypeText {
|
|
|
|
|
if !cfg.ShowFilesInFolder {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-08-25 08:48:52 +01:00
|
|
|
}
|
|
|
|
|
// 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")
|
|
|
|
|
}
|