init
This commit is contained in:
238
internal/config/config.go
Normal file
238
internal/config/config.go
Normal 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
259
internal/handlers/editor.go
Normal 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",
|
||||
})
|
||||
}
|
||||
483
internal/handlers/handlers.go
Normal file
483
internal/handlers/handlers.go
Normal 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)
|
||||
}
|
||||
171
internal/handlers/settings.go
Normal file
171
internal/handlers/settings.go
Normal 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,
|
||||
})
|
||||
}
|
||||
133
internal/markdown/renderer.go
Normal file
133
internal/markdown/renderer.go
Normal 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("", 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
93
internal/models/models.go
Normal 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
178
internal/server/server.go
Normal 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
282
internal/utils/utils.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user