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

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
notes/**/**
.github
mynotes_*
_python_example/
notes/
settings.ini
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.db
*.sqlite3
*.log
*.bak

173
README.md Normal file
View File

@@ -0,0 +1,173 @@
# Gobsidian
A modern, responsive web interface for viewing and editing Obsidian markdown notes, built with Go and TailwindCSS.
## Features
- **Responsive Design**: Works seamlessly on desktop and mobile devices
- **Dark Theme**: Beautiful dark interface optimized for extended use
- **Multiple Image Storage Modes**: Flexible image storage options to match Obsidian's behavior
- **File Management**: Upload, download, edit, and delete files and notes
- **Syntax Highlighting**: Code blocks with syntax highlighting for multiple languages
- **Search Functionality**: Quick search through your notes
- **Settings Management**: Web-based configuration interface
- **Markdown Rendering**: Full markdown support with Obsidian-style features
## Image Storage Modes
1. **Root Directory**: Store images directly in the notes root directory
2. **Specific Folder**: Store all images in a designated folder
3. **Same as Note**: Store images in the same directory as the note
4. **Subfolder of Note**: Store images in a subfolder within the note's directory
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/gobsidian.git
cd gobsidian
```
2. Install dependencies:
```bash
go mod download
```
3. Run the application:
```bash
go run cmd/main.go
```
4. Open your browser and navigate to `http://localhost:8080`
## Configuration
On first run, Gobsidian will create a `settings.ini` file with default configurations. You can modify these settings through the web interface at `/settings` or by editing the file directly.
### Default Configuration
```ini
[FLASK]
HOST = 0.0.0.0
PORT = 8080
SECRET_KEY = change-this-secret-key
DEBUG = false
MAX_CONTENT_LENGTH = 16
[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
```
## Usage
### Viewing Notes
- Navigate through your notes using the sidebar tree structure
- Click on any `.md` file to view its rendered content
- Images and links are automatically processed and displayed
### Creating Notes
- Click "New Note" or use Ctrl+N
- Enter a title and content using Markdown syntax
- Save with Ctrl+S or click the Save button
### Editing Notes
- Click the "Edit" button on any note
- Use the built-in editor with toolbar for common Markdown formatting
- Auto-save functionality and keyboard shortcuts available
### File Management
- Upload files by dragging and dropping or using the upload button
- Download any file by clicking the download icon
- Delete files and folders with confirmation dialogs
### Settings
- Access settings via the gear icon in the sidebar
- Configure image storage modes, file extensions, and directories
- Changes take effect immediately or after restart as indicated
## Development
### Project Structure
```
gobsidian/
├── cmd/
│ └── main.go # Application entry point
├── internal/
│ ├── config/ # Configuration management
│ ├── handlers/ # HTTP route handlers
│ ├── markdown/ # Markdown rendering
│ ├── models/ # Data models
│ ├── server/ # HTTP server setup
│ └── utils/ # Utility functions
├── web/
│ ├── static/ # CSS, JS, and static assets
│ └── templates/ # HTML templates
├── notes/ # Default notes directory
├── go.mod # Go module definition
└── settings.ini # Configuration file
```
### Building
To build a standalone binary:
```bash
go build -o gobsidian cmd/main.go
```
### Docker
To run with Docker:
```bash
docker build -t gobsidian .
docker run -p 8080:8080 -v /path/to/your/notes:/app/notes gobsidian
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Acknowledgments
- Inspired by Obsidian's excellent note-taking capabilities
- Built with Go, Gin, TailwindCSS, and highlight.js
- Thanks to the open source community for the excellent libraries used
## Comparison with Python Flask Version
This Go implementation provides the same functionality as the original Python Flask version with the following improvements:
- **Better Performance**: Go's compiled nature and efficient concurrency
- **Single Binary**: No need for Python interpreter or virtual environments
- **Lower Memory Usage**: More efficient resource utilization
- **Modern UI**: Updated with TailwindCSS for better responsiveness
- **Enhanced Mobile Support**: Better touch interface and responsive design
## Future Enhancements
- [ ] Real-time collaborative editing
- [ ] Plugin system for extensions
- [ ] Full-text search with indexing
- [ ] Note linking and backlinks
- [ ] Export functionality (PDF, HTML)
- [ ] Themes and customization options
- [ ] REST API for external integrations

21
cmd/main.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"gobsidian/internal/config"
"gobsidian/internal/server"
"log"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Start server
srv := server.New(cfg)
if err := srv.Start(); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

42
go.mod Normal file
View File

@@ -0,0 +1,42 @@
module gobsidian
go 1.21
require (
github.com/alecthomas/chroma/v2 v2.8.0
github.com/gin-gonic/gin v1.9.1
github.com/gorilla/sessions v1.2.1
github.com/h2non/filetype v1.1.3
github.com/yuin/goldmark v1.6.0
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gopkg.in/ini.v1 v1.67.0
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

113
go.sum Normal file
View File

@@ -0,0 +1,113 @@
github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink=
github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264=
github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

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")
}

247
web/static/app.js Normal file
View File

@@ -0,0 +1,247 @@
// Additional JavaScript functionality for Gobsidian
// Mobile responsiveness
function initMobileSupport() {
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle');
// Check if we're on mobile
function isMobile() {
return window.innerWidth < 768;
}
// Handle sidebar toggle on mobile
function handleMobileToggle() {
if (isMobile()) {
sidebar.classList.toggle('mobile-open');
// Add/remove overlay
let overlay = document.querySelector('.sidebar-overlay');
if (sidebar.classList.contains('mobile-open')) {
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'sidebar-overlay';
document.body.appendChild(overlay);
overlay.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
overlay.remove();
});
}
} else if (overlay) {
overlay.remove();
}
}
}
// Override sidebar toggle for mobile
if (isMobile()) {
sidebarToggle.removeEventListener('click', toggleSidebar);
sidebarToggle.addEventListener('click', handleMobileToggle);
}
// Handle window resize
window.addEventListener('resize', () => {
if (!isMobile()) {
sidebar.classList.remove('mobile-open');
const overlay = document.querySelector('.sidebar-overlay');
if (overlay) overlay.remove();
}
});
}
// Enhanced file upload with progress
function initEnhancedUpload() {
const uploadElements = {
area: document.getElementById('upload-area'),
input: document.getElementById('file-input'),
progress: document.getElementById('upload-progress'),
progressBar: document.getElementById('progress-bar'),
status: document.getElementById('upload-status')
};
if (!uploadElements.area) return;
// Enhanced upload function with progress tracking
function uploadFilesWithProgress(files) {
const folderPath = window.location.pathname.includes('/folder/')
? window.location.pathname.replace('/folder/', '')
: '';
uploadElements.progress.classList.remove('hidden');
uploadElements.progressBar.style.width = '0%';
uploadElements.status.textContent = 'Starting upload...';
const formData = new FormData();
formData.append('path', folderPath);
Array.from(files).forEach(file => {
formData.append('file', file);
});
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
uploadElements.progressBar.style.width = percentComplete + '%';
uploadElements.status.textContent = `Uploading... ${Math.round(percentComplete)}%`;
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
uploadElements.progressBar.style.width = '100%';
uploadElements.status.textContent = 'Upload complete!';
showNotification('Files uploaded successfully', 'success');
setTimeout(() => window.location.reload(), 1000);
} else {
throw new Error(response.error || 'Upload failed');
}
} catch (e) {
throw new Error('Invalid server response');
}
} else {
throw new Error(`HTTP ${xhr.status}: ${xhr.statusText}`);
}
});
xhr.addEventListener('error', () => {
uploadElements.status.textContent = 'Upload failed: Network error';
showNotification('Upload failed: Network error', 'error');
});
xhr.open('POST', '/upload');
xhr.send(formData);
}
// Replace the existing upload function if it exists
if (window.uploadFiles) {
window.uploadFiles = uploadFilesWithProgress;
}
}
// Search functionality
function initSearch() {
// Create search input if it doesn't exist
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
const searchContainer = document.createElement('div');
searchContainer.className = 'p-4 border-b border-gray-700';
searchContainer.innerHTML = `
<div class="relative">
<input type="text" id="search-input" placeholder="Search notes..."
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 pl-10 text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
</div>
`;
// Insert after the header
const header = sidebar.querySelector('.p-4.border-b');
if (header) {
header.after(searchContainer);
}
const searchInput = document.getElementById('search-input');
searchInput.addEventListener('input', debounce((e) => {
const query = e.target.value.toLowerCase().trim();
const treeNodes = sidebar.querySelectorAll('.tree-node a');
treeNodes.forEach(node => {
const text = node.textContent.toLowerCase();
const match = !query || text.includes(query);
node.closest('.tree-node').style.display = match ? 'block' : 'none';
// Show parent folders if child matches
if (match && query) {
let parent = node.closest('.tree-children');
while (parent) {
parent.style.display = 'block';
parent.classList.remove('hidden');
const toggle = parent.previousElementSibling;
if (toggle && toggle.classList.contains('tree-toggle')) {
toggle.querySelector('.tree-chevron').classList.add('rotate-90');
}
parent = parent.parentElement.closest('.tree-children');
}
}
});
}, 300));
}
// Debounce utility
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Keyboard shortcuts
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Global shortcuts (when not in input/textarea)
if (!e.target.matches('input, textarea')) {
switch(e.key) {
case '/':
e.preventDefault();
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.focus();
}
break;
case 'n':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
window.location.href = '/create';
}
break;
case 's':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
window.location.href = '/settings';
}
break;
}
}
// Escape key to close modals
if (e.key === 'Escape') {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => {
if (!modal.classList.contains('hidden')) {
modal.classList.add('hidden');
}
});
}
});
}
// Initialize all enhancements
document.addEventListener('DOMContentLoaded', () => {
initMobileSupport();
initEnhancedUpload();
initSearch();
initKeyboardShortcuts();
});
// Export for use in other scripts
window.GobsidianUtils = {
initMobileSupport,
initEnhancedUpload,
initSearch,
initKeyboardShortcuts,
debounce
};

BIN
web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

78
web/static/styles.css Normal file
View File

@@ -0,0 +1,78 @@
/* Additional custom styles for Gobsidian */
/* Dark scrollbar for Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #475569 #1e293b;
}
/* Focus states */
.focus-ring:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Loading spinner */
.spinner {
border: 2px solid #374151;
border-top: 2px solid #3b82f6;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive adjustments */
@media (max-width: 768px) {
#sidebar {
position: fixed;
height: 100vh;
z-index: 40;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
}
#sidebar.mobile-open {
transform: translateX(0);
}
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 30;
}
}
/* File upload drag styles */
.drag-over {
border-color: #3b82f6 !important;
background-color: rgba(59, 130, 246, 0.1) !important;
}
/* Syntax highlighting overrides for dark theme */
.hljs {
background: #111827 !important;
color: #e5e7eb !important;
}
/* Print styles */
@media print {
#sidebar {
display: none;
}
.no-print {
display: none;
}
body {
background: white !important;
color: black !important;
}
}

405
web/templates/base.html Normal file
View File

@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}{{.app_name}}{{end}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'obsidian': {
'50': '#f8fafc',
'100': '#f1f5f9',
'200': '#e2e8f0',
'300': '#cbd5e1',
'400': '#94a3b8',
'500': '#64748b',
'600': '#475569',
'700': '#334155',
'800': '#1e293b',
'900': '#0f172a',
}
}
}
}
}
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/sql.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
<style>
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Content markdown styles */
.prose-dark {
color: #d1d5db;
}
.prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4, .prose-dark h5, .prose-dark h6 {
color: white;
}
.prose-dark a {
color: #60a5fa;
}
.prose-dark a:hover {
color: #93c5fd;
}
.prose-dark code {
background-color: #1f2937;
color: #10b981;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.prose-dark pre {
background-color: #111827;
border: 1px solid #374151;
}
.prose-dark blockquote {
border-left: 4px solid #3b82f6;
background-color: #1f2937;
padding-left: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
margin: 1rem 0;
font-style: italic;
}
.prose-dark table {
border-collapse: collapse;
border: 1px solid #4b5563;
}
.prose-dark th, .prose-dark td {
border: 1px solid #4b5563;
padding: 0.75rem;
}
.prose-dark th {
background-color: #1f2937;
font-weight: 600;
}
.prose-dark img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
/* Sidebar styles */
.sidebar-item {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
cursor: pointer;
}
.sidebar-item:hover {
background-color: #374151;
}
.sidebar-item.active {
background-color: #2563eb;
color: white;
}
/* Custom button styles */
.btn-primary {
background-color: #2563eb;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary:hover {
background-color: #1d4ed8;
}
.btn-secondary {
background-color: #4b5563;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-secondary:hover {
background-color: #374151;
}
.btn-danger {
background-color: #dc2626;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-danger:hover {
background-color: #b91c1c;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-content {
background-color: #1f2937;
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 32rem;
width: 100%;
margin: 1rem;
max-height: 24rem;
overflow-y: auto;
}
/* Editor styles */
.editor-textarea {
width: 100%;
min-height: 24rem;
background-color: #1f2937;
color: #d1d5db;
border: 1px solid #4b5563;
border-radius: 0.5rem;
padding: 1rem;
font-family: monospace;
font-size: 0.875rem;
resize: vertical;
}
.editor-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
/* Form input styles */
.form-input, .form-textarea {
width: 100%;
background-color: #374151;
border: 1px solid #4b5563;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: white;
}
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
.hidden {
display: none;
}
.rotate-90 {
transform: rotate(90deg);
}
</style>
</head>
<body class="bg-slate-900 text-gray-300 min-h-screen">
<div class="flex h-screen">
<!-- Sidebar -->
<div id="sidebar" class="w-80 bg-slate-800 border-r border-gray-700 flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-gray-700">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-white">{{.app_name}}</h1>
<div class="flex items-center space-x-2">
<a href="/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<i class="fas fa-cog"></i>
</a>
</div>
</div>
</div>
<!-- Navigation -->
<div class="px-4 py-4">
<a href="/create" class="btn-primary text-sm w-full text-center">
<i class="fas fa-plus mr-2"></i>New Note
</a>
</div>
<!-- File Tree -->
<div class="flex-1 overflow-y-auto px-4 pb-4">
{{if .notes_tree}}
{{template "tree_node" dict "node" .notes_tree "active_path" .active_path "current_note" .current_note}}
{{end}}
</div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Breadcrumbs -->
{{if .breadcrumbs}}
<div class="bg-slate-800 border-b border-gray-700 px-6 py-3">
<nav class="flex items-center space-x-2 text-sm">
{{range $i, $crumb := .breadcrumbs}}
{{if $i}}<i class="fas fa-chevron-right text-gray-500 text-xs"></i>{{end}}
{{if $crumb.URL}}
<a href="{{$crumb.URL}}" class="text-blue-400 hover:text-blue-300 transition-colors">{{$crumb.Name}}</a>
{{else}}
<span class="text-gray-300">{{$crumb.Name}}</span>
{{end}}
{{end}}
</nav>
</div>
{{end}}
<!-- Content Area -->
<div class="flex-1 overflow-y-auto">
{{block "content" .}}{{end}}
</div>
</div>
</div>
<!-- Scripts -->
<script>
// Initialize syntax highlighting
hljs.highlightAll();
// Tree functionality
document.addEventListener('click', function(e) {
if (e.target.closest('.tree-toggle')) {
const toggle = e.target.closest('.tree-toggle');
const children = toggle.nextElementSibling;
const chevron = toggle.querySelector('.tree-chevron');
if (children && children.classList.contains('tree-children')) {
children.classList.toggle('hidden');
if (chevron) {
chevron.classList.toggle('rotate-90');
}
}
}
});
// Auto-expand active path
function expandActivePath() {
const activeItem = document.querySelector('.sidebar-item.active');
if (activeItem) {
let parent = activeItem.parentElement;
while (parent) {
if (parent.classList.contains('tree-children')) {
parent.classList.remove('hidden');
const toggle = parent.previousElementSibling;
if (toggle && toggle.classList.contains('tree-toggle')) {
const chevron = toggle.querySelector('.tree-chevron');
if (chevron) {
chevron.classList.add('rotate-90');
}
}
}
parent = parent.parentElement;
}
}
}
// Notification system
function showNotification(message, type = 'info', duration = 3000) {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg max-w-sm`;
if (type === 'success') {
notification.className += ' bg-green-600 text-white';
} else if (type === 'error') {
notification.className += ' bg-red-600 text-white';
} else {
notification.className += ' bg-blue-600 text-white';
}
notification.innerHTML = `
<div class="flex items-center justify-between">
<span>${message}</span>
<button class="ml-4 text-white hover:text-gray-300" onclick="this.closest('div').remove()">
<i class="fas fa-times"></i>
</button>
</div>
`;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentElement) {
notification.remove();
}
}, duration);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
expandActivePath();
});
</script>
{{block "scripts" .}}{{end}}
</body>
</html>
<!-- Tree Node Template -->
{{define "tree_node"}}
<div class="tree-node">
{{if .node.Children}}
<div class="tree-toggle flex items-center py-1 hover:bg-gray-700 rounded px-2 cursor-pointer" data-path="{{.node.Path}}">
<i class="fas fa-chevron-right transform transition-transform duration-200 mr-2 text-xs tree-chevron"></i>
<span class="mr-2">📁</span>
<span class="flex-1">{{.node.Name}}</span>
</div>
<div class="tree-children ml-4 hidden">
{{range .node.Children}}
{{template "tree_node" dict "node" . "active_path" $.active_path "current_note" $.current_note}}
{{end}}
</div>
{{else}}
{{if eq .node.Type "md"}}
<a href="/note/{{.node.Path}}" class="sidebar-item {{if eq .current_note .node.Path}}active{{end}}">
<span class="mr-2">📝</span>
<span>{{.node.Name}}</span>
</a>
{{else}}
<a href="/view_text/{{.node.Path}}" class="sidebar-item">
<span class="mr-2">📄</span>
<span>{{.node.Name}}</span>
</a>
{{end}}
{{end}}
</div>
{{end}}

325
web/templates/base_new.html Normal file
View File

@@ -0,0 +1,325 @@
{{define "base.html"}}
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}{{.app_name}}{{end}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'obsidian': {
'50': '#f8fafc',
'100': '#f1f5f9',
'200': '#e2e8f0',
'300': '#cbd5e1',
'400': '#94a3b8',
'500': '#64748b',
'600': '#475569',
'700': '#334155',
'800': '#1e293b',
'900': '#0f172a',
}
}
}
}
}
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
<style>
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Content markdown styles */
.prose-dark {
color: #d1d5db;
}
.prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4, .prose-dark h5, .prose-dark h6 {
color: white;
}
.prose-dark a {
color: #60a5fa;
}
.prose-dark a:hover {
color: #93c5fd;
}
.prose-dark code {
background-color: #1f2937;
color: #10b981;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.prose-dark pre {
background-color: #111827;
border: 1px solid #374151;
}
.prose-dark blockquote {
border-left: 4px solid #3b82f6;
background-color: #1f2937;
padding-left: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
margin: 1rem 0;
font-style: italic;
}
/* Custom button styles */
.btn-primary {
background-color: #2563eb;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: #1d4ed8;
}
.btn-secondary {
background-color: #4b5563;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #374151;
}
.btn-danger {
background-color: #dc2626;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.btn-danger:hover {
background-color: #b91c1c;
}
/* Sidebar styles */
.sidebar-item {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
cursor: pointer;
}
.sidebar-item:hover {
background-color: #374151;
}
.sidebar-item.active {
background-color: #2563eb;
color: white;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-content {
background-color: #1f2937;
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 32rem;
width: 100%;
margin: 1rem;
max-height: 24rem;
overflow-y: auto;
}
/* Editor styles */
.editor-textarea {
width: 100%;
min-height: 24rem;
background-color: #1f2937;
color: #d1d5db;
border: 1px solid #4b5563;
border-radius: 0.5rem;
padding: 1rem;
font-family: monospace;
font-size: 0.875rem;
resize: vertical;
}
.editor-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
/* Form input styles */
.form-input, .form-textarea {
width: 100%;
background-color: #374151;
border: 1px solid #4b5563;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: white;
}
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
.hidden {
display: none;
}
</style>
</head>
<body class="bg-slate-900 text-gray-300 min-h-screen">
<div class="flex h-screen">
<!-- Sidebar -->
<div id="sidebar" class="w-80 bg-slate-800 border-r border-gray-700 flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-gray-700">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-white">{{.app_name}}</h1>
<div class="flex items-center space-x-2">
<a href="/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<i class="fas fa-cog"></i>
</a>
</div>
</div>
</div>
<!-- Search -->
<div class="p-4">
<input type="text" id="search-input" placeholder="Search notes..."
class="form-input text-sm">
</div>
<!-- Navigation -->
<div class="px-4 pb-4">
<a href="/create" class="btn-primary text-sm w-full text-center block">
<i class="fas fa-plus mr-2"></i>New Note
</a>
</div>
<!-- File Tree -->
<div class="flex-1 overflow-y-auto px-4 pb-4">
{{if .notes_tree}}
{{template "tree_node" dict "node" .notes_tree "active_path" .active_path "current_note" .current_note}}
{{end}}
</div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Breadcrumbs -->
{{if .breadcrumbs}}
<div class="bg-slate-800 border-b border-gray-700 px-6 py-3">
<nav class="flex items-center space-x-2 text-sm">
{{range $i, $crumb := .breadcrumbs}}
{{if $i}}<i class="fas fa-chevron-right text-gray-500 text-xs"></i>{{end}}
{{if $crumb.URL}}
<a href="{{$crumb.URL}}" class="text-blue-400 hover:text-blue-300 transition-colors">{{$crumb.Name}}</a>
{{else}}
<span class="text-gray-300">{{$crumb.Name}}</span>
{{end}}
{{end}}
</nav>
</div>
{{end}}
<!-- Content Area -->
<div class="flex-1 overflow-y-auto">
{{block "content" .}}{{end}}
</div>
</div>
</div>
<!-- Scripts -->
<script>
// Initialize syntax highlighting
hljs.highlightAll();
// Search functionality
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', function() {
// TODO: Implement search functionality
});
}
// Tree functionality
document.querySelectorAll('.tree-toggle').forEach(toggle => {
toggle.addEventListener('click', function() {
const children = this.nextElementSibling;
if (children && children.classList.contains('tree-children')) {
children.classList.toggle('hidden');
}
});
});
// Notification system
function showNotification(message, type = 'info', duration = 3000) {
// Simple notification for now
alert(message);
}
</script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}
<!-- Tree Node Template -->
{{define "tree_node"}}
<div class="tree-node">
{{if .node.Children}}
<div class="tree-toggle flex items-center py-1 hover:bg-gray-700 rounded px-2 cursor-pointer" data-path="{{.node.Path}}">
<i class="fas fa-chevron-right transform transition-transform duration-200 mr-2 text-xs"></i>
<span class="mr-2">📁</span>
<span class="flex-1">{{.node.Name}}</span>
</div>
<div class="tree-children ml-4 hidden">
{{range .node.Children}}
{{template "tree_node" dict "node" . "active_path" $.active_path "current_note" $.current_note}}
{{end}}
</div>
{{else}}
{{if eq .node.Type "md"}}
<a href="/note/{{.node.Path}}" class="sidebar-item {{if eq .current_note .node.Path}}active{{end}}">
<span class="mr-2">📝</span>
<span>{{.node.Name}}</span>
</a>
{{else}}
<a href="/view_text/{{.node.Path}}" class="sidebar-item">
<span class="mr-2">📄</span>
<span>{{.node.Name}}</span>
</a>
{{end}}
{{end}}
</div>
{{end}}

145
web/templates/create.html Normal file
View File

@@ -0,0 +1,145 @@
{{template "base.html" .}}
{{define "content"}}
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-4">Create New Note</h1>
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
Creating in: <span class="text-blue-400">{{.folder_path}}</span>
</p>
{{end}}
</div>
<!-- Create Form -->
<form id="create-form" class="space-y-6">
<div class="bg-gray-800 rounded-lg p-6">
<!-- Title Input -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-300 mb-2">
Note Title
</label>
<input type="text" id="title" name="title" required
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter note title (e.g., My New Note)">
<p class="text-xs text-gray-500 mt-1">The .md extension will be added automatically</p>
</div>
<!-- Content Editor -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-300 mb-2">
Content
</label>
<textarea id="content" name="content" rows="20"
class="editor-textarea"
placeholder="# Your Note Title
Start writing your note here using Markdown syntax...
## Examples
- **Bold text**
- *Italic text*
- [Links](https://example.com)
- ![Images](image.png)
- `Code snippets`
```javascript
// Code blocks
console.log('Hello, World!');
```
> Blockquotes
| Tables | Work | Too |
|--------|------|-----|
| Cell 1 | Cell 2 | Cell 3 |
"></textarea>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<a href="{{if .folder_path}}/folder/{{.folder_path}}{{else}}/{{end}}" class="btn-secondary">
<i class="fas fa-arrow-left mr-2"></i>Cancel
</a>
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Create Note
</button>
</div>
</div>
</form>
</div>
{{end}}
{{define "scripts"}}
<script>
const createForm = document.getElementById('create-form');
const titleInput = document.getElementById('title');
const contentTextarea = document.getElementById('content');
createForm.addEventListener('submit', function(e) {
e.preventDefault();
const title = titleInput.value.trim();
const content = contentTextarea.value;
const folderPath = '{{.folder_path}}';
if (!title) {
showNotification('Please enter a note title', 'error');
return;
}
const formData = new FormData();
formData.append('title', title);
formData.append('content', content);
formData.append('folder_path', folderPath);
fetch('/create', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Note created successfully', 'success');
if (data.redirect) {
window.location.href = data.redirect;
}
} else {
throw new Error(data.error || 'Failed to create note');
}
})
.catch(error => {
showNotification('Error: ' + error.message, 'error');
});
});
// Auto-resize textarea
contentTextarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
// Keyboard shortcuts
contentTextarea.addEventListener('keydown', function(e) {
// Ctrl+S or Cmd+S to save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
createForm.dispatchEvent(new Event('submit'));
}
// Tab for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
const value = this.value;
this.value = value.substring(0, start) + '\t' + value.substring(end);
this.selectionStart = this.selectionEnd = start + 1;
}
});
</script>
{{end}}

190
web/templates/edit.html Normal file
View File

@@ -0,0 +1,190 @@
{{template "base.html" .}}
{{define "content"}}
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">Edit: {{.title}}</h1>
<div class="flex items-center space-x-3">
<a href="/note/{{.note_path}}" class="btn-secondary">
<i class="fas fa-eye mr-2"></i>Preview
</a>
<button type="submit" form="edit-form" class="btn-primary">
<i class="fas fa-save mr-2"></i>Save
</button>
</div>
</div>
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
<a href="/folder/{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
</p>
{{end}}
</div>
<!-- Edit Form -->
<form id="edit-form" class="space-y-6">
<div class="bg-gray-800 rounded-lg p-6">
<!-- Content Editor -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-300 mb-2">
Content
</label>
<textarea id="content" name="content" rows="25"
class="editor-textarea">{{.content}}</textarea>
</div>
<!-- Editor Toolbar -->
<div class="flex items-center justify-between border-t border-gray-700 pt-4">
<div class="flex items-center space-x-2">
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('**', '**')" title="Bold">
<i class="fas fa-bold"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('*', '*')" title="Italic">
<i class="fas fa-italic"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('`', '`')" title="Code">
<i class="fas fa-code"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('[', '](url)')" title="Link">
<i class="fas fa-link"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('![', '](image.png)')" title="Image">
<i class="fas fa-image"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertHeading()" title="Heading">
<i class="fas fa-heading"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertList()" title="List">
<i class="fas fa-list"></i>
</button>
</div>
<div class="text-xs text-gray-500">
Press Ctrl+S to save
</div>
</div>
</div>
</form>
</div>
{{end}}
{{define "scripts"}}
<script>
const editForm = document.getElementById('edit-form');
const contentTextarea = document.getElementById('content');
editForm.addEventListener('submit', function(e) {
e.preventDefault();
const content = contentTextarea.value;
const notePath = '{{.note_path}}';
const formData = new FormData();
formData.append('content', content);
fetch('/edit/' + notePath, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Note saved successfully', 'success');
if (data.redirect) {
setTimeout(() => {
window.location.href = data.redirect;
}, 1000);
}
} else {
throw new Error(data.error || 'Failed to save note');
}
})
.catch(error => {
showNotification('Error: ' + error.message, 'error');
});
});
// Auto-resize textarea
function autoResize() {
contentTextarea.style.height = 'auto';
contentTextarea.style.height = (contentTextarea.scrollHeight) + 'px';
}
contentTextarea.addEventListener('input', autoResize);
// Initial resize
autoResize();
// Keyboard shortcuts
contentTextarea.addEventListener('keydown', function(e) {
// Ctrl+S or Cmd+S to save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
editForm.dispatchEvent(new Event('submit'));
}
// Tab for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
const value = this.value;
this.value = value.substring(0, start) + '\t' + value.substring(end);
this.selectionStart = this.selectionEnd = start + 1;
}
});
// Markdown insertion functions
function insertMarkdown(before, after) {
const start = contentTextarea.selectionStart;
const end = contentTextarea.selectionEnd;
const text = contentTextarea.value;
const selectedText = text.substring(start, end);
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
contentTextarea.value = newText;
// Position cursor
const newPos = start + before.length + selectedText.length;
contentTextarea.focus();
contentTextarea.setSelectionRange(newPos, newPos);
autoResize();
}
function insertHeading() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const lineText = text.substring(lineStart, start);
if (lineText.startsWith('# ')) {
return; // Already a heading
}
const newText = text.substring(0, lineStart) + '# ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 2, start + 2);
autoResize();
}
function insertList() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 2, start + 2);
autoResize();
}
</script>
{{end}}

38
web/templates/error.html Normal file
View File

@@ -0,0 +1,38 @@
{{template "base.html" .}}
{{define "content"}}
<div class="flex items-center justify-center min-h-screen">
<div class="max-w-md w-full mx-4">
<div class="bg-gray-800 rounded-lg p-8 text-center">
<div class="mb-6">
<i class="fas fa-exclamation-triangle text-6xl text-red-500 mb-4"></i>
<h1 class="text-2xl font-bold text-white mb-2">Error</h1>
{{if .error}}
<h2 class="text-lg text-gray-300 mb-4">{{.error}}</h2>
{{end}}
</div>
{{if .message}}
<div class="bg-gray-700 rounded-lg p-4 mb-6">
<p class="text-gray-300 text-sm">{{.message}}</p>
</div>
{{end}}
<div class="space-y-3">
<a href="/" class="block btn-primary">
<i class="fas fa-home mr-2"></i>Go Home
</a>
<button onclick="history.back()" class="block btn-secondary w-full">
<i class="fas fa-arrow-left mr-2"></i>Go Back
</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "error_scripts"}}
<script>
// No additional scripts needed for error page
</script>
{{end}}

256
web/templates/folder.html Normal file
View File

@@ -0,0 +1,256 @@
{{template "base.html" .}}
{{define "content"}}
<div class="p-6">
<!-- Header with upload button -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white mb-2">
{{if .folder_path}}
{{.folder_path}}
{{else}}
Welcome to {{.app_name}}
{{end}}
</h1>
<p class="text-gray-400">
{{if .folder_contents}}
{{len .folder_contents}} items
{{else}}
No items found
{{end}}
</p>
</div>
<div class="flex items-center space-x-3">
<button id="upload-btn" class="btn-primary">
<i class="fas fa-upload mr-2"></i>Upload File
</button>
<a href="/create?folder={{.folder_path}}" class="btn-secondary">
<i class="fas fa-plus mr-2"></i>New Note
</a>
</div>
</div>
<!-- Upload Area (hidden by default) -->
<div id="upload-area" class="upload-area mb-6 hidden">
<div class="flex flex-col items-center">
<i class="fas fa-cloud-upload text-4xl text-gray-500 mb-4"></i>
<p class="text-gray-400 mb-2">Drag and drop files here or click to select</p>
<input type="file" id="file-input" multiple class="hidden">
<button id="select-files" class="btn-secondary">Select Files</button>
</div>
<div id="upload-progress" class="mt-4 hidden">
<div class="w-full bg-gray-700 rounded-full h-2">
<div id="progress-bar" class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p id="upload-status" class="text-sm text-gray-400 mt-2"></p>
</div>
</div>
<!-- Content Grid -->
<div class="grid gap-4">
{{if .folder_contents}}
{{range .folder_contents}}
<div class="bg-gray-800 rounded-lg p-4 hover:bg-gray-700 transition-colors duration-200 cursor-pointer item-card"
data-path="{{.Path}}" data-type="{{.Type}}">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="text-2xl">{{fileTypeIcon .Type}}</span>
<div>
<h3 class="font-medium text-white">{{.DisplayName}}</h3>
<p class="text-sm text-gray-400">
{{if eq .Type "dir"}}
Folder
{{else}}
{{formatSize .Size}} • {{formatTime .ModTime}}
{{end}}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
{{if eq .Type "md"}}
<a href="/edit/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
{{end}}
{{if ne .Type "dir"}}
<a href="/download/{{.Path}}" class="text-green-400 hover:text-green-300 p-2" title="Download">
<i class="fas fa-download"></i>
</a>
{{end}}
<button class="text-red-400 hover:text-red-300 p-2 delete-btn" data-path="{{.Path}}" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
{{end}}
{{else}}
<div class="text-center py-12">
<i class="fas fa-folder-open text-6xl text-gray-600 mb-4"></i>
<p class="text-xl text-gray-400 mb-2">This folder is empty</p>
<p class="text-gray-500">Create a new note or upload files to get started</p>
</div>
{{end}}
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3 class="text-lg font-medium text-white mb-4">Confirm Delete</h3>
<p class="text-gray-300 mb-6">Are you sure you want to delete this item? This action cannot be undone.</p>
<div class="flex justify-end space-x-3">
<button id="cancel-delete" class="btn-secondary">Cancel</button>
<button id="confirm-delete" class="btn-danger">Delete</button>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
let uploadArea = document.getElementById('upload-area');
let fileInput = document.getElementById('file-input');
let uploadBtn = document.getElementById('upload-btn');
let selectFilesBtn = document.getElementById('select-files');
let uploadProgress = document.getElementById('upload-progress');
let progressBar = document.getElementById('progress-bar');
let uploadStatus = document.getElementById('upload-status');
let deleteModal = document.getElementById('delete-modal');
let deleteTarget = null;
// Toggle upload area
uploadBtn.addEventListener('click', function() {
uploadArea.classList.toggle('hidden');
});
// File selection
selectFilesBtn.addEventListener('click', function() {
fileInput.click();
});
fileInput.addEventListener('change', function() {
if (this.files.length > 0) {
uploadFiles(this.files);
}
});
// Drag and drop
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('dragover');
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
});
// Upload files function
function uploadFiles(files) {
uploadProgress.classList.remove('hidden');
progressBar.style.width = '0%';
uploadStatus.textContent = 'Preparing upload...';
const formData = new FormData();
formData.append('path', '{{.folder_path}}');
for (let file of files) {
formData.append('file', file);
}
fetch('/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
progressBar.style.width = '100%';
uploadStatus.textContent = 'Upload complete!';
showNotification('Files uploaded successfully', 'success');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
throw new Error(data.error || 'Upload failed');
}
})
.catch(error => {
uploadStatus.textContent = 'Upload failed: ' + error.message;
showNotification('Upload failed: ' + error.message, 'error');
});
}
// Item click handlers
document.addEventListener('click', function(e) {
const itemCard = e.target.closest('.item-card');
if (itemCard && !e.target.closest('a') && !e.target.closest('button')) {
const path = itemCard.dataset.path;
const type = itemCard.dataset.type;
if (type === 'dir') {
window.location.href = '/folder/' + path;
} else if (type === 'md') {
window.location.href = '/note/' + path;
} else if (type === 'image') {
window.open('/serve_attached_image/' + path, '_blank');
} else {
window.location.href = '/view_text/' + path;
}
}
});
// Delete functionality
document.addEventListener('click', function(e) {
if (e.target.closest('.delete-btn')) {
e.stopPropagation();
deleteTarget = e.target.closest('.delete-btn').dataset.path;
deleteModal.classList.remove('hidden');
}
});
document.getElementById('cancel-delete').addEventListener('click', function() {
deleteModal.classList.add('hidden');
deleteTarget = null;
});
document.getElementById('confirm-delete').addEventListener('click', function() {
if (deleteTarget) {
fetch('/delete/' + deleteTarget, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Item deleted successfully', 'success');
window.location.reload();
} else {
throw new Error(data.error || 'Delete failed');
}
})
.catch(error => {
showNotification('Delete failed: ' + error.message, 'error');
});
}
deleteModal.classList.add('hidden');
deleteTarget = null;
});
// Close modal when clicking outside
deleteModal.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.add('hidden');
deleteTarget = null;
}
});
</script>
{{end}}

93
web/templates/note.html Normal file
View File

@@ -0,0 +1,93 @@
{{define "content"}}
<div class="max-w-4xl mx-auto p-6">
<!-- Note Header -->
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">{{.title}}</h1>
<div class="flex items-center space-x-3">
<a href="/edit/{{.note_path}}" class="btn-primary">
<i class="fas fa-edit mr-2"></i>Edit
</a>
<a href="/download/{{.note_path}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
<button class="btn-danger delete-note-btn" data-path="{{.note_path}}">
<i class="fas fa-trash mr-2"></i>Delete
</button>
</div>
</div>
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
<a href="/folder/{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
</p>
{{end}}
</div>
<!-- Note Content -->
<div class="bg-gray-800 rounded-lg p-6">
<div class="prose prose-dark max-w-none">
{{.content | safeHTML}}
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3 class="text-lg font-medium text-white mb-4">Confirm Delete</h3>
<p class="text-gray-300 mb-6">Are you sure you want to delete this note? This action cannot be undone.</p>
<div class="flex justify-end space-x-3">
<button id="cancel-delete" class="btn-secondary">Cancel</button>
<button id="confirm-delete" class="btn-danger">Delete</button>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
document.addEventListener('DOMContentLoaded', function() {
const deleteBtn = document.querySelector('.delete-note-btn');
const modal = document.getElementById('delete-modal');
const cancelBtn = document.getElementById('cancel-delete');
const confirmBtn = document.getElementById('confirm-delete');
if (deleteBtn) {
deleteBtn.addEventListener('click', function() {
modal.classList.remove('hidden');
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', function() {
modal.classList.add('hidden');
});
}
if (confirmBtn) {
confirmBtn.addEventListener('click', function() {
const path = deleteBtn.dataset.path;
fetch(`/delete/${path}`, {
method: 'DELETE'
})
.then(response => {
if (response.ok) {
window.location.href = '/';
} else {
alert('Error deleting note');
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting note');
});
});
}
// Re-highlight code blocks that might have been added dynamically
hljs.highlightAll();
});
</script>
{{end}}

289
web/templates/settings.html Normal file
View File

@@ -0,0 +1,289 @@
{{template "base.html" .}}
{{define "content"}}
<div class="max-w-6xl mx-auto p-6">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Settings</h1>
<p class="text-gray-400">Configure your {{.app_name}} instance</p>
</div>
<!-- Settings Sections -->
<div class="space-y-8">
<!-- Image Storage Settings -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-images mr-2"></i>Image Storage
</h2>
<p class="text-gray-400 mb-6">Configure how images are stored and referenced in your notes</p>
<form id="image-storage-form" class="space-y-6">
<!-- Storage Mode -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-3">Storage Mode</label>
<div class="space-y-3">
<label class="flex items-start space-x-3">
<input type="radio" name="storage_mode" value="1" class="mt-1 text-blue-600">
<div>
<div class="text-white font-medium">Root Directory</div>
<div class="text-sm text-gray-400">Store images directly in the notes root directory</div>
</div>
</label>
<label class="flex items-start space-x-3">
<input type="radio" name="storage_mode" value="2" class="mt-1 text-blue-600">
<div>
<div class="text-white font-medium">Specific Folder</div>
<div class="text-sm text-gray-400">Store all images in a specific folder</div>
</div>
</label>
<label class="flex items-start space-x-3">
<input type="radio" name="storage_mode" value="3" class="mt-1 text-blue-600">
<div>
<div class="text-white font-medium">Same as Note</div>
<div class="text-sm text-gray-400">Store images in the same directory as the note</div>
</div>
</label>
<label class="flex items-start space-x-3">
<input type="radio" name="storage_mode" value="4" class="mt-1 text-blue-600">
<div>
<div class="text-white font-medium">Subfolder of Note</div>
<div class="text-sm text-gray-400">Store images in a subfolder within the note's directory</div>
</div>
</label>
</div>
</div>
<!-- Storage Path (for mode 2) -->
<div id="storage-path-section" class="hidden">
<label for="storage_path" class="block text-sm font-medium text-gray-300 mb-2">
Storage Path
</label>
<input type="text" id="storage_path" name="storage_path"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., images">
</div>
<!-- Subfolder Name (for mode 4) -->
<div id="subfolder-section" class="hidden">
<label for="subfolder_name" class="block text-sm font-medium text-gray-300 mb-2">
Subfolder Name
</label>
<input type="text" id="subfolder_name" name="subfolder_name"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., attached">
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Save Image Settings
</button>
</div>
</form>
</div>
<!-- Notes Directory Settings -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-folder mr-2"></i>Notes Directory
</h2>
<p class="text-gray-400 mb-6">Set the root directory where your notes are stored</p>
<form id="notes-dir-form" class="space-y-6">
<div>
<label for="notes_dir" class="block text-sm font-medium text-gray-300 mb-2">
Notes Directory Path
</label>
<input type="text" id="notes_dir" name="notes_dir"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="/path/to/your/notes">
<p class="text-xs text-gray-500 mt-1">Provide the absolute path to your notes directory</p>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Save Directory Settings
</button>
</div>
</form>
</div>
<!-- File Extensions Settings -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-file mr-2"></i>File Extensions
</h2>
<p class="text-gray-400 mb-6">Configure which file types are allowed and visible</p>
<form id="file-extensions-form" class="space-y-6">
<div>
<label for="allowed_image_extensions" class="block text-sm font-medium text-gray-300 mb-2">
Allowed Image Extensions
</label>
<input type="text" id="allowed_image_extensions" name="allowed_image_extensions"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="jpg, jpeg, png, webp, gif">
<p class="text-xs text-gray-500 mt-1">Comma-separated list of image file extensions</p>
</div>
<div>
<label for="allowed_file_extensions" class="block text-sm font-medium text-gray-300 mb-2">
Allowed File Extensions
</label>
<input type="text" id="allowed_file_extensions" name="allowed_file_extensions"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="txt, pdf, html, json, yaml, yml, conf, csv">
<p class="text-xs text-gray-500 mt-1">Comma-separated list of viewable file extensions</p>
</div>
<div class="flex items-center">
<input type="checkbox" id="images_hide" name="images_hide"
class="h-4 w-4 text-blue-600 rounded border-gray-600 bg-gray-700">
<label for="images_hide" class="ml-2 text-sm text-gray-300">
Hide images from main folder view
</label>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Save Extension Settings
</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
// Load current settings
function loadSettings() {
// Load image storage settings
fetch('/settings/image_storage')
.then(response => response.json())
.then(data => {
document.querySelector(`input[name="storage_mode"][value="${data.mode}"]`).checked = true;
document.getElementById('storage_path').value = data.path || '';
document.getElementById('subfolder_name').value = data.subfolder || '';
toggleStorageOptions();
})
.catch(error => console.error('Error loading image storage settings:', error));
// Load notes directory settings
fetch('/settings/notes_dir')
.then(response => response.json())
.then(data => {
document.getElementById('notes_dir').value = data.notes_dir || '';
})
.catch(error => console.error('Error loading notes directory settings:', error));
// Load file extensions settings
fetch('/settings/file_extensions')
.then(response => response.json())
.then(data => {
document.getElementById('allowed_image_extensions').value = data.allowed_image_extensions || '';
document.getElementById('allowed_file_extensions').value = data.allowed_file_extensions || '';
document.getElementById('images_hide').checked = data.images_hide || false;
})
.catch(error => console.error('Error loading file extensions settings:', error));
}
// Toggle storage mode options
function toggleStorageOptions() {
const mode = document.querySelector('input[name="storage_mode"]:checked')?.value;
const pathSection = document.getElementById('storage-path-section');
const subfolderSection = document.getElementById('subfolder-section');
pathSection.classList.toggle('hidden', mode !== '2');
subfolderSection.classList.toggle('hidden', mode !== '4');
}
// Event listeners
document.addEventListener('change', function(e) {
if (e.target.name === 'storage_mode') {
toggleStorageOptions();
}
});
// Image storage form
document.getElementById('image-storage-form').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/settings/image_storage', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Image storage settings saved successfully', 'success');
if (data.reload_required) {
setTimeout(() => window.location.reload(), 1500);
}
} else {
throw new Error(data.error || 'Failed to save settings');
}
})
.catch(error => {
showNotification('Error: ' + error.message, 'error');
});
});
// Notes directory form
document.getElementById('notes-dir-form').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/settings/notes_dir', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Notes directory settings saved successfully', 'success');
if (data.reload_required) {
setTimeout(() => window.location.reload(), 1500);
}
} else {
throw new Error(data.error || 'Failed to save settings');
}
})
.catch(error => {
showNotification('Error: ' + error.message, 'error');
});
});
// File extensions form
document.getElementById('file-extensions-form').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/settings/file_extensions', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('File extension settings saved successfully', 'success');
if (data.reload_required) {
setTimeout(() => window.location.reload(), 1500);
}
} else {
throw new Error(data.error || 'Failed to save settings');
}
})
.catch(error => {
showNotification('Error: ' + error.message, 'error');
});
});
// Load settings on page load
document.addEventListener('DOMContentLoaded', loadSettings);
</script>
{{end}}

18
web/templates/test.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Test Page Works!</h1>
<p>App Name: {{.app_name}}</p>
<p>Folder Contents Count: {{len .folder_contents}}</p>
{{if .folder_contents}}
<ul>
{{range .folder_contents}}
<li>{{.DisplayName}} ({{.Type}})</li>
{{end}}
</ul>
{{end}}
</body>
</html>

View File

@@ -0,0 +1,103 @@
{{template "base.html" .}}
{{define "content"}}
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">{{.file_name}}</h1>
<div class="flex items-center space-x-3">
<a href="/download/{{.file_path}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
<button class="btn-danger delete-file-btn" data-path="{{.file_path}}">
<i class="fas fa-trash mr-2"></i>Delete
</button>
</div>
</div>
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
<a href="/folder/{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
</p>
{{end}}
</div>
<!-- File Content -->
<div class="bg-gray-800 rounded-lg p-6">
<pre class="text-sm text-gray-300 whitespace-pre-wrap overflow-x-auto"><code>{{.content}}</code></pre>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3 class="text-lg font-medium text-white mb-4">Confirm Delete</h3>
<p class="text-gray-300 mb-6">Are you sure you want to delete this file? This action cannot be undone.</p>
<div class="flex justify-end space-x-3">
<button id="cancel-delete" class="btn-secondary">Cancel</button>
<button id="confirm-delete" class="btn-danger">Delete</button>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
let deleteModal = document.getElementById('delete-modal');
let deleteTarget = null;
// Delete functionality
document.addEventListener('click', function(e) {
if (e.target.closest('.delete-file-btn')) {
deleteTarget = e.target.closest('.delete-file-btn').dataset.path;
deleteModal.classList.remove('hidden');
}
});
document.getElementById('cancel-delete').addEventListener('click', function() {
deleteModal.classList.add('hidden');
deleteTarget = null;
});
document.getElementById('confirm-delete').addEventListener('click', function() {
if (deleteTarget) {
fetch('/delete/' + deleteTarget, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('File deleted successfully', 'success');
// Redirect to folder or root
const folderPath = '{{.folder_path}}';
if (folderPath) {
window.location.href = '/folder/' + folderPath;
} else {
window.location.href = '/';
}
} else {
throw new Error(data.error || 'Delete failed');
}
})
.catch(error => {
showNotification('Delete failed: ' + error.message, 'error');
});
}
deleteModal.classList.add('hidden');
deleteTarget = null;
});
// Close modal when clicking outside
deleteModal.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.add('hidden');
deleteTarget = null;
}
});
// Highlight code if possible
hljs.highlightAll();
</script>
{{end}}