This commit is contained in:
nahakubuilde
2025-08-25 08:48:52 +01:00
commit bfa0eaf68a
26 changed files with 4388 additions and 0 deletions

238
internal/config/config.go Normal file
View File

@@ -0,0 +1,238 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/ini.v1"
)
type Config struct {
// Flask equivalent settings
Host string
Port int
SecretKey string
Debug bool
MaxContentLength int64 // in bytes
// MD Notes App settings
AppName string
NotesDir string
NotesDirHideSidepane []string
NotesDirSkip []string
ImagesHide bool
ImageStorageMode int
ImageStoragePath string
ImageSubfolderName string
AllowedImageExtensions []string
AllowedFileExtensions []string
}
var defaultConfig = map[string]map[string]string{
"FLASK": {
"HOST": "0.0.0.0",
"PORT": "3000",
"SECRET_KEY": "change-this-secret-key",
"DEBUG": "false",
"MAX_CONTENT_LENGTH": "16", // in MB
},
"MD_NOTES_APP": {
"APP_NAME": "Gobsidian",
"NOTES_DIR": "notes",
"NOTES_DIR_HIDE_SIDEPANE": "attached, images",
"NOTES_DIR_SKIP": "secret, private",
"IMAGES_HIDE": "false",
"IMAGE_STORAGE_MODE": "1",
"IMAGE_STORAGE_PATH": "images",
"IMAGE_SUBFOLDER_NAME": "attached",
"ALLOWED_IMAGE_EXTENSIONS": "jpg, jpeg, png, webp, gif",
"ALLOWED_FILE_EXTENSIONS": "txt, pdf, html, json, yaml, yml, conf, csv, cmd, bat, sh",
},
}
func Load() (*Config, error) {
configPath := "settings.ini"
// Ensure config file exists
if err := ensureConfigFile(configPath); err != nil {
return nil, fmt.Errorf("failed to ensure config file: %w", err)
}
// Load configuration
cfg, err := ini.Load(configPath)
if err != nil {
return nil, fmt.Errorf("failed to load config file: %w", err)
}
config := &Config{}
// Load FLASK section
flaskSection := cfg.Section("FLASK")
config.Host = flaskSection.Key("HOST").String()
config.Port, _ = flaskSection.Key("PORT").Int()
config.SecretKey = flaskSection.Key("SECRET_KEY").String()
config.Debug, _ = flaskSection.Key("DEBUG").Bool()
maxContentMB, _ := flaskSection.Key("MAX_CONTENT_LENGTH").Int()
config.MaxContentLength = int64(maxContentMB) * 1024 * 1024 // Convert MB to bytes
// Load MD_NOTES_APP section
notesSection := cfg.Section("MD_NOTES_APP")
config.AppName = notesSection.Key("APP_NAME").String()
config.NotesDir = notesSection.Key("NOTES_DIR").String()
// Parse comma-separated lists
config.NotesDirHideSidepane = parseCommaSeparated(notesSection.Key("NOTES_DIR_HIDE_SIDEPANE").String())
config.NotesDirSkip = parseCommaSeparated(notesSection.Key("NOTES_DIR_SKIP").String())
config.AllowedImageExtensions = parseCommaSeparated(notesSection.Key("ALLOWED_IMAGE_EXTENSIONS").String())
config.AllowedFileExtensions = parseCommaSeparated(notesSection.Key("ALLOWED_FILE_EXTENSIONS").String())
config.ImagesHide, _ = notesSection.Key("IMAGES_HIDE").Bool()
config.ImageStorageMode, _ = notesSection.Key("IMAGE_STORAGE_MODE").Int()
config.ImageStoragePath = notesSection.Key("IMAGE_STORAGE_PATH").String()
config.ImageSubfolderName = notesSection.Key("IMAGE_SUBFOLDER_NAME").String()
// Convert relative paths to absolute
if !filepath.IsAbs(config.NotesDir) {
wd, _ := os.Getwd()
config.NotesDir = filepath.Join(wd, config.NotesDir)
}
if !filepath.IsAbs(config.ImageStoragePath) && config.ImageStorageMode == 2 {
wd, _ := os.Getwd()
config.ImageStoragePath = filepath.Join(wd, config.ImageStoragePath)
}
return config, nil
}
func ensureConfigFile(configPath string) error {
// Check if file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return createDefaultConfigFile(configPath)
}
// File exists, check if it has all required settings
return updateConfigFile(configPath)
}
func createDefaultConfigFile(configPath string) error {
cfg := ini.Empty()
for sectionName, settings := range defaultConfig {
section, err := cfg.NewSection(sectionName)
if err != nil {
return err
}
for key, value := range settings {
section.NewKey(key, value)
}
}
return cfg.SaveTo(configPath)
}
func updateConfigFile(configPath string) error {
cfg, err := ini.Load(configPath)
if err != nil {
return err
}
updated := false
for sectionName, settings := range defaultConfig {
section := cfg.Section(sectionName)
for key, defaultValue := range settings {
if !section.HasKey(key) {
section.NewKey(key, defaultValue)
updated = true
}
}
}
if updated {
return cfg.SaveTo(configPath)
}
return nil
}
func parseCommaSeparated(value string) []string {
if value == "" {
return []string{}
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func (c *Config) SaveSetting(section, key, value string) error {
configPath := "settings.ini"
cfg, err := ini.Load(configPath)
if err != nil {
return err
}
sec := cfg.Section(section)
sec.Key(key).SetValue(value)
// Update in-memory config based on section and key
switch section {
case "FLASK":
switch key {
case "HOST":
c.Host = value
case "PORT":
if port, err := strconv.Atoi(value); err == nil {
c.Port = port
}
case "SECRET_KEY":
c.SecretKey = value
case "DEBUG":
c.Debug = value == "true"
case "MAX_CONTENT_LENGTH":
if size, err := strconv.ParseInt(value, 10, 64); err == nil {
c.MaxContentLength = size * 1024 * 1024
}
}
case "MD_NOTES_APP":
switch key {
case "APP_NAME":
c.AppName = value
case "NOTES_DIR":
c.NotesDir = value
case "NOTES_DIR_HIDE_SIDEPANE":
c.NotesDirHideSidepane = parseCommaSeparated(value)
case "NOTES_DIR_SKIP":
c.NotesDirSkip = parseCommaSeparated(value)
case "IMAGES_HIDE":
c.ImagesHide = value == "true"
case "IMAGE_STORAGE_MODE":
if mode, err := strconv.Atoi(value); err == nil {
c.ImageStorageMode = mode
}
case "IMAGE_STORAGE_PATH":
c.ImageStoragePath = value
case "IMAGE_SUBFOLDER_NAME":
c.ImageSubfolderName = value
case "ALLOWED_IMAGE_EXTENSIONS":
c.AllowedImageExtensions = parseCommaSeparated(value)
case "ALLOWED_FILE_EXTENSIONS":
c.AllowedFileExtensions = parseCommaSeparated(value)
}
}
return cfg.SaveTo(configPath)
}

259
internal/handlers/editor.go Normal file
View File

@@ -0,0 +1,259 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"gobsidian/internal/utils"
)
func (h *Handlers) CreateNotePageHandler(c *gin.Context) {
folderPath := c.Query("folder")
if folderPath == "" {
folderPath = ""
}
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to build tree structure",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
c.HTML(http.StatusOK, "base.html", gin.H{
"app_name": h.config.AppName,
"folder_path": folderPath,
"notes_tree": notesTree,
"active_path": utils.GetActivePath(folderPath),
"current_note": nil,
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
})
}
func (h *Handlers) CreateNoteHandler(c *gin.Context) {
folderPath := strings.TrimSpace(c.PostForm("folder_path"))
title := strings.TrimSpace(c.PostForm("title"))
content := c.PostForm("content")
if title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required"})
return
}
// Security check
if strings.Contains(folderPath, "..") || strings.Contains(title, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path or title"})
return
}
// Check if path is in skipped directories
if utils.IsPathInSkippedDirs(folderPath, h.config.NotesDirSkip) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot create notes in this directory"})
return
}
// Ensure title ends with .md
if !strings.HasSuffix(title, ".md") {
title += ".md"
}
// Create full path
var notePath string
if folderPath == "" {
notePath = title
} else {
notePath = filepath.Join(folderPath, title)
}
fullPath := filepath.Join(h.config.NotesDir, notePath)
// Check if file already exists
if _, err := os.Stat(fullPath); !os.IsNotExist(err) {
c.JSON(http.StatusConflict, gin.H{"error": "A note with this title already exists"})
return
}
// Ensure directory exists
dir := filepath.Dir(fullPath)
if err := utils.EnsureDir(dir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create directory"})
return
}
// Write file
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create note"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Note created successfully",
"note_path": notePath,
"redirect": "/note/" + notePath,
})
}
func (h *Handlers) EditNotePageHandler(c *gin.Context) {
notePath := strings.TrimPrefix(c.Param("path"), "/")
if !strings.HasSuffix(notePath, ".md") {
c.HTML(http.StatusBadRequest, "base.html", gin.H{
"error": "Invalid note path",
"app_name": h.config.AppName,
"message": "Note path must end with .md",
})
return
}
// Security check
if strings.Contains(notePath, "..") {
c.HTML(http.StatusBadRequest, "base.html", gin.H{
"error": "Invalid path",
"app_name": h.config.AppName,
"message": "Path traversal is not allowed",
})
return
}
// Check if path is in skipped directories
if utils.IsPathInSkippedDirs(notePath, h.config.NotesDirSkip) {
c.HTML(http.StatusForbidden, "base.html", gin.H{
"error": "Access denied",
"app_name": h.config.AppName,
"message": "This note cannot be edited",
})
return
}
fullPath := filepath.Join(h.config.NotesDir, notePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.HTML(http.StatusNotFound, "base.html", gin.H{
"error": "Note not found",
"app_name": h.config.AppName,
"message": "The requested note does not exist",
})
return
}
content, err := os.ReadFile(fullPath)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to read note",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to build tree structure",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
title := strings.TrimSuffix(filepath.Base(notePath), ".md")
folderPath := filepath.Dir(notePath)
if folderPath == "." {
folderPath = ""
}
c.HTML(http.StatusOK, "base.html", gin.H{
"app_name": h.config.AppName,
"title": title,
"content": string(content),
"note_path": notePath,
"folder_path": folderPath,
"notes_tree": notesTree,
"active_path": utils.GetActivePath(folderPath),
"current_note": notePath,
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
})
}
func (h *Handlers) EditNoteHandler(c *gin.Context) {
notePath := strings.TrimPrefix(c.Param("path"), "/")
content := c.PostForm("content")
if !strings.HasSuffix(notePath, ".md") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid note path"})
return
}
// Security check
if strings.Contains(notePath, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"})
return
}
// Check if path is in skipped directories
if utils.IsPathInSkippedDirs(notePath, h.config.NotesDirSkip) {
c.JSON(http.StatusForbidden, gin.H{"error": "This note cannot be edited"})
return
}
fullPath := filepath.Join(h.config.NotesDir, notePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
return
}
// Write updated content
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save note"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Note saved successfully",
"redirect": "/note/" + notePath,
})
}
func (h *Handlers) DeleteHandler(c *gin.Context) {
filePath := strings.TrimPrefix(c.Param("path"), "/")
// Security check
if strings.Contains(filePath, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"})
return
}
// Check if path is in skipped directories
if utils.IsPathInSkippedDirs(filePath, h.config.NotesDirSkip) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete files in this directory"})
return
}
fullPath := filepath.Join(h.config.NotesDir, filePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
}
// Delete file or directory
if err := os.RemoveAll(fullPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "File deleted successfully",
})
}

View File

@@ -0,0 +1,483 @@
package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/h2non/filetype"
"gobsidian/internal/config"
"gobsidian/internal/markdown"
"gobsidian/internal/models"
"gobsidian/internal/utils"
)
type Handlers struct {
config *config.Config
store *sessions.CookieStore
renderer *markdown.Renderer
}
func New(cfg *config.Config, store *sessions.CookieStore) *Handlers {
return &Handlers{
config: cfg,
store: store,
renderer: markdown.NewRenderer(cfg),
}
}
func (h *Handlers) IndexHandler(c *gin.Context) {
fmt.Printf("DEBUG: IndexHandler called\n")
folderContents, err := utils.GetFolderContents("", h.config)
if err != nil {
fmt.Printf("DEBUG: Error getting folder contents: %v\n", err)
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to read directory",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
fmt.Printf("DEBUG: Found %d folder contents\n", len(folderContents))
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
fmt.Printf("DEBUG: Error building tree structure: %v\n", err)
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to build tree structure",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
fmt.Printf("DEBUG: Tree structure built, app_name: %s\n", h.config.AppName)
c.HTML(http.StatusOK, "base.html", gin.H{
"app_name": h.config.AppName,
"folder_path": "",
"folder_contents": folderContents,
"notes_tree": notesTree,
"active_path": []string{},
"current_note": nil,
"breadcrumbs": utils.GenerateBreadcrumbs(""),
"allowed_image_extensions": h.config.AllowedImageExtensions,
"allowed_file_extensions": h.config.AllowedFileExtensions,
})
}
func (h *Handlers) FolderHandler(c *gin.Context) {
folderPath := strings.TrimPrefix(c.Param("path"), "/")
// Security check - prevent path traversal
if strings.Contains(folderPath, "..") {
c.HTML(http.StatusBadRequest, "base.html", gin.H{
"error": "Invalid path",
"app_name": h.config.AppName,
"message": "Path traversal is not allowed",
})
return
}
// Check if path is in skipped directories
if utils.IsPathInSkippedDirs(folderPath, h.config.NotesDirSkip) {
c.HTML(http.StatusForbidden, "base.html", gin.H{
"error": "Access denied",
"app_name": h.config.AppName,
"message": "This directory is not accessible",
})
return
}
folderContents, err := utils.GetFolderContents(folderPath, h.config)
if err != nil {
c.HTML(http.StatusNotFound, "base.html", gin.H{
"error": "Folder not found",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to build tree structure",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
c.HTML(http.StatusOK, "base.html", gin.H{
"app_name": h.config.AppName,
"folder_path": folderPath,
"folder_contents": folderContents,
"notes_tree": notesTree,
"active_path": utils.GetActivePath(folderPath),
"current_note": nil,
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
"allowed_image_extensions": h.config.AllowedImageExtensions,
"allowed_file_extensions": h.config.AllowedFileExtensions,
})
}
func (h *Handlers) NoteHandler(c *gin.Context) {
notePath := strings.TrimPrefix(c.Param("path"), "/")
if !strings.HasSuffix(notePath, ".md") {
c.HTML(http.StatusBadRequest, "base.html", gin.H{
"error": "Invalid note path",
"app_name": h.config.AppName,
"message": "Note path must end with .md",
})
return
}
// Security check
if strings.Contains(notePath, "..") {
c.HTML(http.StatusBadRequest, "base.html", gin.H{
"error": "Invalid path",
"app_name": h.config.AppName,
"message": "Path traversal is not allowed",
})
return
}
// Check if path is in skipped directories
if utils.IsPathInSkippedDirs(notePath, h.config.NotesDirSkip) {
c.HTML(http.StatusForbidden, "base.html", gin.H{
"error": "Access denied",
"app_name": h.config.AppName,
"message": "This note is not accessible",
})
return
}
fullPath := filepath.Join(h.config.NotesDir, notePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.HTML(http.StatusNotFound, "base.html", gin.H{
"error": "Note not found",
"app_name": h.config.AppName,
"message": "The requested note does not exist",
})
return
}
content, err := os.ReadFile(fullPath)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to read note",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
htmlContent, err := h.renderer.RenderMarkdown(string(content), notePath)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to render markdown",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to build tree structure",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
title := strings.TrimSuffix(filepath.Base(notePath), ".md")
folderPath := filepath.Dir(notePath)
if folderPath == "." {
folderPath = ""
}
c.HTML(http.StatusOK, "base.html", gin.H{
"app_name": h.config.AppName,
"title": title,
"content": htmlContent,
"note_path": notePath,
"folder_path": folderPath,
"notes_tree": notesTree,
"active_path": utils.GetActivePath(folderPath),
"current_note": notePath,
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
})
}
func (h *Handlers) ServeAttachedImageHandler(c *gin.Context) {
imagePath := strings.TrimPrefix(c.Param("path"), "/")
// Security check
if strings.Contains(imagePath, "..") {
c.AbortWithStatus(http.StatusBadRequest)
return
}
var fullPath string
switch h.config.ImageStorageMode {
case 1: // Root directory
fullPath = filepath.Join(h.config.NotesDir, imagePath)
case 3: // Same as note directory
fullPath = filepath.Join(h.config.NotesDir, imagePath)
case 4: // Subfolder of note directory
fullPath = filepath.Join(h.config.NotesDir, imagePath)
default:
c.AbortWithStatus(http.StatusNotFound)
return
}
// Check if file exists and is an image
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.AbortWithStatus(http.StatusNotFound)
return
}
if !models.IsImageFile(filepath.Base(imagePath), h.config.AllowedImageExtensions) {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.File(fullPath)
}
func (h *Handlers) ServeStoredImageHandler(c *gin.Context) {
filename := c.Param("filename")
// Security check
if strings.Contains(filename, "..") || strings.Contains(filename, "/") {
c.AbortWithStatus(http.StatusBadRequest)
return
}
if h.config.ImageStorageMode != 2 {
c.AbortWithStatus(http.StatusNotFound)
return
}
fullPath := filepath.Join(h.config.ImageStoragePath, filename)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.AbortWithStatus(http.StatusNotFound)
return
}
if !models.IsImageFile(filename, h.config.AllowedImageExtensions) {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.File(fullPath)
}
func (h *Handlers) DownloadHandler(c *gin.Context) {
filePath := strings.TrimPrefix(c.Param("path"), "/")
// Security check
if strings.Contains(filePath, "..") {
c.AbortWithStatus(http.StatusBadRequest)
return
}
fullPath := filepath.Join(h.config.NotesDir, filePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.AbortWithStatus(http.StatusNotFound)
return
}
filename := filepath.Base(filePath)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.File(fullPath)
}
func (h *Handlers) ViewTextHandler(c *gin.Context) {
filePath := strings.TrimPrefix(c.Param("path"), "/")
// Security check
if strings.Contains(filePath, "..") {
c.HTML(http.StatusBadRequest, "base.html", gin.H{
"error": "Invalid path",
"app_name": h.config.AppName,
"message": "Path traversal is not allowed",
})
return
}
fullPath := filepath.Join(h.config.NotesDir, filePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.HTML(http.StatusNotFound, "base.html", gin.H{
"error": "File not found",
"app_name": h.config.AppName,
"message": "The requested file does not exist",
})
return
}
// Check if file extension is allowed
if !models.IsAllowedFile(filePath, h.config.AllowedFileExtensions) {
c.HTML(http.StatusForbidden, "base.html", gin.H{
"error": "File type not allowed",
"app_name": h.config.AppName,
"message": "This file type cannot be viewed",
})
return
}
content, err := os.ReadFile(fullPath)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to read file",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to build tree structure",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
folderPath := filepath.Dir(filePath)
if folderPath == "." {
folderPath = ""
}
c.HTML(http.StatusOK, "base.html", gin.H{
"app_name": h.config.AppName,
"file_name": filepath.Base(filePath),
"file_path": filePath,
"content": string(content),
"folder_path": folderPath,
"notes_tree": notesTree,
"active_path": utils.GetActivePath(folderPath),
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
})
}
func (h *Handlers) UploadHandler(c *gin.Context) {
// Parse multipart form
if err := c.Request.ParseMultipartForm(h.config.MaxContentLength); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "File too large or invalid form data"})
return
}
// Get the upload path
uploadPath := c.PostForm("path")
if uploadPath == "" {
uploadPath = ""
}
// Security check
if strings.Contains(uploadPath, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload path"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
return
}
defer file.Close()
// Validate file type
buffer := make([]byte, 512)
if _, err := file.Read(buffer); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read file"})
return
}
file.Seek(0, 0) // Reset file pointer
kind, _ := filetype.Match(buffer)
if kind == filetype.Unknown {
// Allow text files and other allowed extensions
if !models.IsAllowedFile(header.Filename, append(h.config.AllowedImageExtensions, h.config.AllowedFileExtensions...)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "File type not allowed"})
return
}
} else {
// Check if detected type is allowed
isImageType := strings.HasPrefix(kind.MIME.Value, "image/")
if !isImageType && !models.IsAllowedFile(header.Filename, h.config.AllowedFileExtensions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "File type not allowed"})
return
}
}
// Determine upload directory
var uploadDir string
isImage := models.IsImageFile(header.Filename, h.config.AllowedImageExtensions)
if isImage {
// For images, use the configured storage mode
storageInfo := utils.GetImageStorageInfo(uploadPath, h.config)
uploadDir = storageInfo.StorageDir
} else {
// For other files, upload to the current folder
uploadDir = filepath.Join(h.config.NotesDir, uploadPath)
}
// Ensure upload directory exists
if err := utils.EnsureDir(uploadDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
return
}
// Create destination file
destPath := filepath.Join(uploadDir, header.Filename)
dest, err := os.Create(destPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create destination file"})
return
}
defer dest.Close()
// Copy file content
if _, err := io.Copy(dest, file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "File uploaded successfully",
"filename": header.Filename,
"size": header.Size,
})
}
func (h *Handlers) TreeAPIHandler(c *gin.Context) {
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, notesTree)
}

View File

@@ -0,0 +1,171 @@
package handlers
import (
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gobsidian/internal/utils"
)
func (h *Handlers) SettingsPageHandler(c *gin.Context) {
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
if err != nil {
c.HTML(http.StatusInternalServerError, "base.html", gin.H{
"error": "Failed to build tree structure",
"app_name": h.config.AppName,
"message": err.Error(),
})
return
}
c.HTML(http.StatusOK, "base.html", gin.H{
"app_name": h.config.AppName,
"notes_tree": notesTree,
"active_path": []string{},
"current_note": nil,
"breadcrumbs": []gin.H{
{"name": "/", "url": "/"},
{"name": "Settings", "url": ""},
},
})
}
func (h *Handlers) GetImageStorageSettingsHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"mode": h.config.ImageStorageMode,
"path": h.config.ImageStoragePath,
"subfolder": h.config.ImageSubfolderName,
})
}
func (h *Handlers) PostImageStorageSettingsHandler(c *gin.Context) {
modeStr := c.PostForm("storage_mode")
path := strings.TrimSpace(c.PostForm("storage_path"))
subfolder := strings.TrimSpace(c.PostForm("subfolder_name"))
mode, err := strconv.Atoi(modeStr)
if err != nil || mode < 1 || mode > 4 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid storage mode"})
return
}
// Validate path for mode 2
if mode == 2 && path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Storage path is required for mode 2"})
return
}
// Validate subfolder name for mode 4
if mode == 4 && subfolder == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Subfolder name is required for mode 4"})
return
}
// Save settings
if err := h.config.SaveSetting("MD_NOTES_APP", "IMAGE_STORAGE_MODE", modeStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save storage mode"})
return
}
if mode == 2 {
if err := h.config.SaveSetting("MD_NOTES_APP", "IMAGE_STORAGE_PATH", path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save storage path"})
return
}
}
if mode == 4 {
if err := h.config.SaveSetting("MD_NOTES_APP", "IMAGE_SUBFOLDER_NAME", subfolder); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save subfolder name"})
return
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Image storage settings updated successfully",
"reload_required": true,
})
}
func (h *Handlers) GetNotesDirSettingsHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"notes_dir": h.config.NotesDir,
})
}
func (h *Handlers) PostNotesDirSettingsHandler(c *gin.Context) {
newDir := strings.TrimSpace(c.PostForm("notes_dir"))
if newDir == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Notes directory is required"})
return
}
// Convert to absolute path
if !filepath.IsAbs(newDir) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Please provide an absolute path"})
return
}
// Ensure directory exists
if err := utils.EnsureDir(newDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create directory: " + err.Error()})
return
}
// Save setting
if err := h.config.SaveSetting("MD_NOTES_APP", "NOTES_DIR", newDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save notes directory"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Notes directory updated successfully",
"reload_required": true,
})
}
func (h *Handlers) GetFileExtensionsSettingsHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"allowed_image_extensions": strings.Join(h.config.AllowedImageExtensions, ", "),
"allowed_file_extensions": strings.Join(h.config.AllowedFileExtensions, ", "),
"images_hide": h.config.ImagesHide,
})
}
func (h *Handlers) PostFileExtensionsSettingsHandler(c *gin.Context) {
imageExtensions := strings.TrimSpace(c.PostForm("allowed_image_extensions"))
fileExtensions := strings.TrimSpace(c.PostForm("allowed_file_extensions"))
imagesHide := c.PostForm("images_hide") == "true"
// Save settings
if err := h.config.SaveSetting("MD_NOTES_APP", "ALLOWED_IMAGE_EXTENSIONS", imageExtensions); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save image extensions"})
return
}
if err := h.config.SaveSetting("MD_NOTES_APP", "ALLOWED_FILE_EXTENSIONS", fileExtensions); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file extensions"})
return
}
imagesHideStr := "false"
if imagesHide {
imagesHideStr = "true"
}
if err := h.config.SaveSetting("MD_NOTES_APP", "IMAGES_HIDE", imagesHideStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save images hide setting"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "File extension settings updated successfully",
"reload_required": true,
})
}

View File

@@ -0,0 +1,133 @@
package markdown
import (
"bytes"
"fmt"
"path/filepath"
"regexp"
"strings"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"gobsidian/internal/config"
)
type Renderer struct {
md goldmark.Markdown
config *config.Config
}
func NewRenderer(cfg *config.Config) *Renderer {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.Strikethrough,
extension.TaskList,
highlighting.NewHighlighting(
highlighting.WithStyle("github-dark"),
highlighting.WithFormatOptions(
chromahtml.WithLineNumbers(true),
chromahtml.WithClasses(true),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(),
),
)
return &Renderer{
md: md,
config: cfg,
}
}
func (r *Renderer) RenderMarkdown(content string, notePath string) (string, error) {
// Process Obsidian image syntax
content = r.processObsidianImages(content, notePath)
// Process Obsidian links
content = r.processObsidianLinks(content)
var buf bytes.Buffer
if err := r.md.Convert([]byte(content), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
func (r *Renderer) processObsidianImages(content string, notePath string) string {
// Regex to match Obsidian image syntax: ![[image.png]] or ![[folder/image.png]]
obsidianImageRegex := regexp.MustCompile(`!\[\[([^\]]+\.(png|jpg|jpeg|gif|webp))\]\]`)
return obsidianImageRegex.ReplaceAllStringFunc(content, func(match string) string {
// Extract the image path from the match
submatch := obsidianImageRegex.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
imagePath := submatch[1]
// Get storage info based on current note path
// storageInfo := utils.GetImageStorageInfo(notePath, r.config)
// Clean up the path
cleanPath := filepath.Clean(imagePath)
cleanPath = strings.ReplaceAll(cleanPath, "\\", "/")
cleanPath = strings.Trim(cleanPath, "/")
// Generate the appropriate URL based on storage mode
var imageURL string
switch r.config.ImageStorageMode {
case 2: // Mode 2: Specific storage folder - just the filename
imageURL = fmt.Sprintf("/serve_stored_image/%s", filepath.Base(cleanPath))
default: // For modes 1, 3, and 4
imageURL = fmt.Sprintf("/serve_attached_image/%s", cleanPath)
}
// Convert to standard markdown image syntax
alt := filepath.Base(imagePath)
return fmt.Sprintf("![%s](%s)", alt, imageURL)
})
}
func (r *Renderer) processObsidianLinks(content string) string {
// Regex to match Obsidian wiki-links: [[Note Name]] or [[Note Name|Display Text]]
obsidianLinkRegex := regexp.MustCompile(`\[\[([^\]|]+)(\|([^\]]+))?\]\]`)
return obsidianLinkRegex.ReplaceAllStringFunc(content, func(match string) string {
submatch := obsidianLinkRegex.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
noteName := strings.TrimSpace(submatch[1])
displayText := noteName
// If there's custom display text (after |), use it
if len(submatch) >= 4 && submatch[3] != "" {
displayText = strings.TrimSpace(submatch[3])
}
// Convert note name to URL-friendly format
noteURL := strings.ReplaceAll(noteName, " ", "%20")
noteURL = fmt.Sprintf("/note/%s.md", noteURL)
// Convert to standard markdown link syntax
return fmt.Sprintf("[%s](%s)", displayText, noteURL)
})
}

93
internal/models/models.go Normal file
View File

@@ -0,0 +1,93 @@
package models
import (
"path/filepath"
"strings"
"time"
)
type FileType string
const (
FileTypeMarkdown FileType = "md"
FileTypeDirectory FileType = "dir"
FileTypeImage FileType = "image"
FileTypeText FileType = "text"
FileTypeOther FileType = "other"
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Type FileType `json:"type"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
DisplayName string `json:"display_name"`
}
type TreeNode struct {
Name string `json:"name"`
Path string `json:"path"`
Type FileType `json:"type"`
Children []*TreeNode `json:"children,omitempty"`
}
type Breadcrumb struct {
Name string `json:"name"`
URL string `json:"url"`
}
type ImageStorageInfo struct {
StorageDir string
MarkdownPath string
}
func GetFileType(extension string, allowedImageExts, allowedFileExts []string) FileType {
ext := strings.ToLower(strings.TrimPrefix(extension, "."))
if ext == "md" {
return FileTypeMarkdown
}
// Check if it's an allowed image extension
for _, allowedExt := range allowedImageExts {
if strings.ToLower(strings.TrimPrefix(allowedExt, ".")) == ext {
return FileTypeImage
}
}
// Check if it's an allowed file extension
for _, allowedExt := range allowedFileExts {
if strings.ToLower(strings.TrimPrefix(allowedExt, ".")) == ext {
return FileTypeText
}
}
return FileTypeOther
}
func IsImageFile(filename string, allowedImageExts []string) bool {
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
for _, allowedExt := range allowedImageExts {
if strings.ToLower(strings.TrimPrefix(allowedExt, ".")) == ext {
return true
}
}
return false
}
func IsAllowedFile(filename string, allowedExts []string) bool {
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
for _, allowedExt := range allowedExts {
if strings.ToLower(strings.TrimPrefix(allowedExt, ".")) == ext {
return true
}
}
return false
}

178
internal/server/server.go Normal file
View File

@@ -0,0 +1,178 @@
package server
import (
"fmt"
"html/template"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"gobsidian/internal/config"
"gobsidian/internal/handlers"
"gobsidian/internal/models"
"gobsidian/internal/utils"
)
type Server struct {
config *config.Config
router *gin.Engine
store *sessions.CookieStore
}
func New(cfg *config.Config) *Server {
if !cfg.Debug {
gin.SetMode(gin.ReleaseMode)
}
router := gin.Default()
store := sessions.NewCookieStore([]byte(cfg.SecretKey))
s := &Server{
config: cfg,
router: router,
store: store,
}
s.setupRoutes()
s.setupStaticFiles()
s.setupTemplates()
return s
}
func (s *Server) Start() error {
// Ensure notes directory exists
if err := utils.EnsureDir(s.config.NotesDir); err != nil {
return fmt.Errorf("failed to create notes directory: %w", err)
}
// Ensure image storage directory exists for mode 2
if s.config.ImageStorageMode == 2 {
if err := utils.EnsureDir(s.config.ImageStoragePath); err != nil {
return fmt.Errorf("failed to create image storage directory: %w", err)
}
}
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
fmt.Printf("Starting Gobsidian server on %s\n", addr)
fmt.Printf("Notes directory: %s\n", s.config.NotesDir)
return s.router.Run(addr)
}
func (s *Server) setupRoutes() {
h := handlers.New(s.config, s.store)
// Main routes
s.router.GET("/", h.IndexHandler)
s.router.GET("/folder/*path", h.FolderHandler)
s.router.GET("/note/*path", h.NoteHandler)
// File serving routes
s.router.GET("/serve_attached_image/*path", h.ServeAttachedImageHandler)
s.router.GET("/serve_stored_image/:filename", h.ServeStoredImageHandler)
s.router.GET("/download/*path", h.DownloadHandler)
s.router.GET("/view_text/*path", h.ViewTextHandler)
// Upload routes
s.router.POST("/upload", h.UploadHandler)
// Settings routes
s.router.GET("/settings", h.SettingsPageHandler)
s.router.GET("/settings/image_storage", h.GetImageStorageSettingsHandler)
s.router.POST("/settings/image_storage", h.PostImageStorageSettingsHandler)
s.router.GET("/settings/notes_dir", h.GetNotesDirSettingsHandler)
s.router.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler)
s.router.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler)
s.router.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler)
// Editor routes
s.router.GET("/create", h.CreateNotePageHandler)
s.router.POST("/create", h.CreateNoteHandler)
s.router.GET("/edit/*path", h.EditNotePageHandler)
s.router.POST("/edit/*path", h.EditNoteHandler)
s.router.DELETE("/delete/*path", h.DeleteHandler)
// API routes
s.router.GET("/api/tree", h.TreeAPIHandler)
}
func (s *Server) setupStaticFiles() {
s.router.Static("/static", "./web/static")
s.router.StaticFile("/favicon.ico", "./web/static/favicon.ico")
}
func (s *Server) setupTemplates() {
// Add template functions
funcMap := template.FuncMap{
"formatSize": utils.FormatFileSize,
"formatTime": utils.FormatTime,
"join": strings.Join,
"contains": func(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
},
"add": func(a, b int) int {
return a + b
},
"sub": func(a, b int) int {
return a - b
},
"fileTypeClass": func(fileType models.FileType) string {
switch fileType {
case models.FileTypeMarkdown:
return "text-blue-400"
case models.FileTypeDirectory:
return "text-yellow-400"
case models.FileTypeImage:
return "text-green-400"
case models.FileTypeText:
return "text-gray-400"
default:
return "text-gray-500"
}
},
"fileTypeIcon": func(fileType models.FileType) string {
switch fileType {
case models.FileTypeMarkdown:
return "📝"
case models.FileTypeDirectory:
return "📁"
case models.FileTypeImage:
return "🖼️"
case models.FileTypeText:
return "📄"
default:
return "📄"
}
},
"dict": func(values ...interface{}) map[string]interface{} {
if len(values)%2 != 0 {
return nil
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil
}
dict[key] = values[i+1]
}
return dict
},
"safeHTML": func(s string) template.HTML {
return template.HTML(s)
},
}
// Load templates - make sure base.html is loaded with all the other templates
templates := template.Must(template.New("").Funcs(funcMap).ParseGlob("web/templates/*.html"))
s.router.SetHTMLTemplate(templates)
fmt.Printf("DEBUG: Templates loaded successfully\n")
}

282
internal/utils/utils.go Normal file
View File

@@ -0,0 +1,282 @@
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")
}