mirror of
https://github.com/Alexander-D-Karpov/about.git
synced 2026-03-16 22:06:08 +03:00
434 lines
11 KiB
Go
434 lines
11 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Alexander-D-Karpov/about/internal/storage"
|
|
"github.com/Alexander-D-Karpov/about/internal/stream"
|
|
)
|
|
|
|
type PhotosPlugin struct {
|
|
storage *storage.Storage
|
|
hub *stream.Hub
|
|
httpClient *http.Client
|
|
data *PhotosData
|
|
lastUpdate time.Time
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
type PhotosData struct {
|
|
Folders []PhotosFolder `json:"folders"`
|
|
TotalCount int `json:"total_count"`
|
|
}
|
|
|
|
type PhotosFolder struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
PhotoCount int `json:"photo_count"`
|
|
SubfolderCount int `json:"subfolder_count"`
|
|
TotalSize int64 `json:"total_size"`
|
|
CreatedAt string `json:"created_at"`
|
|
CoverPhotoID *int `json:"cover_photo_id"`
|
|
Photos []PhotosPhoto `json:"photos,omitempty"`
|
|
}
|
|
|
|
type PhotosPhoto struct {
|
|
ID int `json:"id"`
|
|
FolderID int `json:"folder_id"`
|
|
Filename string `json:"filename"`
|
|
Path string `json:"path"`
|
|
URL string `json:"url"`
|
|
Title *string `json:"title"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
Blurhash string `json:"blurhash"`
|
|
CreatedAt string `json:"created_at"`
|
|
TakenAt string `json:"taken_at"`
|
|
Thumbnails PhotosThumbnails `json:"thumbnails"`
|
|
}
|
|
|
|
type PhotosThumbnails struct {
|
|
Small string `json:"small"`
|
|
Medium string `json:"medium"`
|
|
Large string `json:"large"`
|
|
}
|
|
|
|
type PhotosAPIFoldersResponse struct {
|
|
Folders []PhotosFolder `json:"folders"`
|
|
}
|
|
|
|
type PhotosAPIPhotosResponse struct {
|
|
Photos []PhotosPhoto `json:"photos"`
|
|
Total int `json:"total"`
|
|
Page int `json:"page"`
|
|
Pages int `json:"pages"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
func NewPhotosPlugin(storage *storage.Storage, hub *stream.Hub) *PhotosPlugin {
|
|
return &PhotosPlugin{
|
|
storage: storage,
|
|
hub: hub,
|
|
httpClient: &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *PhotosPlugin) Name() string { return "photos" }
|
|
|
|
func (p *PhotosPlugin) Render(ctx context.Context) (string, error) {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
settings := config.Settings
|
|
|
|
sectionTitle := p.getConfigString(settings, "ui.sectionTitle", "Photos")
|
|
apiUrl := p.getConfigString(settings, "apiUrl", "https://photos.akarpov.ru")
|
|
apiUrl = strings.TrimRight(apiUrl, "/")
|
|
maxFolders := p.getConfigInt(settings, "ui.maxFolders", 4)
|
|
|
|
hiddenFolders := p.getHiddenFolders(settings)
|
|
|
|
p.mutex.RLock()
|
|
data := p.data
|
|
p.mutex.RUnlock()
|
|
|
|
if data == nil || len(data.Folders) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var folders []map[string]interface{}
|
|
for _, f := range data.Folders {
|
|
if hiddenFolders[f.ID] || hiddenFolders[0] && p.isFolderNameHidden(f.Name, settings) {
|
|
continue
|
|
}
|
|
|
|
if f.PhotoCount == 0 {
|
|
continue
|
|
}
|
|
|
|
folderUrl := fmt.Sprintf("%s/p/%s/", apiUrl, url.PathEscape(f.Path))
|
|
|
|
var photoThumbs []string
|
|
for i, photo := range f.Photos {
|
|
if i >= 4 {
|
|
break
|
|
}
|
|
thumbUrl := fmt.Sprintf("%s%s", apiUrl, photo.Thumbnails.Small)
|
|
photoThumbs = append(photoThumbs, thumbUrl)
|
|
}
|
|
|
|
for len(photoThumbs) < 4 && len(photoThumbs) > 0 {
|
|
photoThumbs = append(photoThumbs, photoThumbs[len(photoThumbs)-1])
|
|
}
|
|
|
|
folders = append(folders, map[string]interface{}{
|
|
"ID": f.ID,
|
|
"Name": f.Name,
|
|
"Url": folderUrl,
|
|
"PhotoCount": f.PhotoCount,
|
|
"Photos": photoThumbs,
|
|
"HasPhotos": len(photoThumbs) > 0,
|
|
})
|
|
|
|
if len(folders) >= maxFolders {
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(folders) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
tmpl := `
|
|
<section class="photos-section section plugin" data-w="2">
|
|
<header class="plugin-header">
|
|
<h3 class="plugin-title">{{.SectionTitle}}</h3>
|
|
<a href="{{.ApiUrl}}" target="_blank" rel="noopener" class="plugin-header-link" title="View Gallery">
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
|
<polyline points="15 3 21 3 21 9"></polyline>
|
|
<line x1="10" y1="14" x2="21" y2="3"></line>
|
|
</svg>
|
|
</a>
|
|
</header>
|
|
<div class="plugin__inner">
|
|
<div class="photos-grid">
|
|
{{range .Folders}}
|
|
<a href="{{.Url}}" target="_blank" rel="noopener" class="photos-card">
|
|
<div class="photos-cover">
|
|
{{if .HasPhotos}}
|
|
<div class="photos-preview-grid">
|
|
{{range .Photos}}
|
|
<div class="photos-preview-item">
|
|
<img src="{{.}}" alt="" loading="lazy">
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{else}}
|
|
<div class="photos-no-preview">
|
|
<svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor">
|
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
</svg>
|
|
</div>
|
|
{{end}}
|
|
<div class="photos-badge">{{.PhotoCount}}</div>
|
|
</div>
|
|
<div class="photos-info">
|
|
<span class="photos-name">{{.Name}}</span>
|
|
</div>
|
|
</a>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</section>`
|
|
|
|
templateData := struct {
|
|
SectionTitle string
|
|
ApiUrl string
|
|
Folders []map[string]interface{}
|
|
}{
|
|
SectionTitle: sectionTitle,
|
|
ApiUrl: apiUrl,
|
|
Folders: folders,
|
|
}
|
|
|
|
t, err := template.New("photos").Parse(tmpl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := t.Execute(&buf, templateData); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func (p *PhotosPlugin) UpdateData(ctx context.Context) error {
|
|
if !p.lastUpdate.IsZero() && time.Since(p.lastUpdate) < 5*time.Minute {
|
|
return nil
|
|
}
|
|
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
apiUrl := p.getConfigString(config.Settings, "apiUrl", "https://photos.akarpov.ru")
|
|
apiUrl = strings.TrimRight(apiUrl, "/")
|
|
|
|
foldersReq, err := http.NewRequestWithContext(ctx, "GET", apiUrl+"/api/folders", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
foldersReq.Header.Set("User-Agent", "AboutPage/1.0")
|
|
|
|
foldersResp, err := p.httpClient.Do(foldersReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer foldersResp.Body.Close()
|
|
|
|
if foldersResp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("photos api folders returned status: %d", foldersResp.StatusCode)
|
|
}
|
|
|
|
var foldersData PhotosAPIFoldersResponse
|
|
if err := json.NewDecoder(foldersResp.Body).Decode(&foldersData); err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := range foldersData.Folders {
|
|
folder := &foldersData.Folders[i]
|
|
if folder.PhotoCount > 0 {
|
|
photos, err := p.fetchFolderPhotos(ctx, apiUrl, folder.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
folder.Photos = photos
|
|
}
|
|
}
|
|
|
|
totalPhotos := 0
|
|
for _, f := range foldersData.Folders {
|
|
totalPhotos += f.PhotoCount
|
|
}
|
|
|
|
p.mutex.Lock()
|
|
p.data = &PhotosData{
|
|
Folders: foldersData.Folders,
|
|
TotalCount: totalPhotos,
|
|
}
|
|
p.lastUpdate = time.Now()
|
|
p.mutex.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *PhotosPlugin) fetchFolderPhotos(ctx context.Context, apiUrl string, folderID int) ([]PhotosPhoto, error) {
|
|
photosUrl := fmt.Sprintf("%s/api/photos?folder_id=%d&per_page=4", apiUrl, folderID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", photosUrl, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "AboutPage/1.0")
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("photos api returned status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var photosResp PhotosAPIPhotosResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&photosResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return photosResp.Photos, nil
|
|
}
|
|
|
|
func (p *PhotosPlugin) RenderText(ctx context.Context) (string, error) {
|
|
p.mutex.RLock()
|
|
data := p.data
|
|
p.mutex.RUnlock()
|
|
|
|
if data == nil {
|
|
return "Photos: No data", nil
|
|
}
|
|
return fmt.Sprintf("Photos: %d folders, %d photos total", len(data.Folders), data.TotalCount), nil
|
|
}
|
|
|
|
func (p *PhotosPlugin) GetSettings() map[string]interface{} {
|
|
return p.storage.GetPluginConfig(p.Name()).Settings
|
|
}
|
|
|
|
func (p *PhotosPlugin) SetSettings(settings map[string]interface{}) error {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
config.Settings = settings
|
|
p.lastUpdate = time.Time{}
|
|
return p.storage.SetPluginConfig(p.Name(), config)
|
|
}
|
|
|
|
func (p *PhotosPlugin) getConfigString(settings map[string]interface{}, key string, defaultValue string) string {
|
|
if settings == nil {
|
|
return defaultValue
|
|
}
|
|
|
|
if strings.Contains(key, ".") {
|
|
parts := strings.Split(key, ".")
|
|
current := settings
|
|
for i, part := range parts {
|
|
if i == len(parts)-1 {
|
|
if v, ok := current[part].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
return defaultValue
|
|
}
|
|
if next, ok := current[part].(map[string]interface{}); ok {
|
|
current = next
|
|
} else {
|
|
return defaultValue
|
|
}
|
|
}
|
|
}
|
|
|
|
if v, ok := settings[key].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func (p *PhotosPlugin) getConfigInt(settings map[string]interface{}, key string, defaultValue int) int {
|
|
if settings == nil {
|
|
return defaultValue
|
|
}
|
|
|
|
if strings.Contains(key, ".") {
|
|
parts := strings.Split(key, ".")
|
|
current := settings
|
|
for i, part := range parts {
|
|
if i == len(parts)-1 {
|
|
if v, ok := current[part].(float64); ok {
|
|
return int(v)
|
|
}
|
|
if v, ok := current[part].(int); ok {
|
|
return v
|
|
}
|
|
return defaultValue
|
|
}
|
|
if next, ok := current[part].(map[string]interface{}); ok {
|
|
current = next
|
|
} else {
|
|
return defaultValue
|
|
}
|
|
}
|
|
}
|
|
|
|
if v, ok := settings[key].(float64); ok {
|
|
return int(v)
|
|
}
|
|
if v, ok := settings[key].(int); ok {
|
|
return v
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func (p *PhotosPlugin) getHiddenFolders(settings map[string]interface{}) map[int]bool {
|
|
hidden := make(map[int]bool)
|
|
|
|
if hiddenList, ok := settings["hiddenFolders"].([]interface{}); ok {
|
|
for _, v := range hiddenList {
|
|
if id, ok := v.(float64); ok {
|
|
hidden[int(id)] = true
|
|
}
|
|
if id, ok := v.(int); ok {
|
|
hidden[id] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return hidden
|
|
}
|
|
|
|
func (p *PhotosPlugin) isFolderNameHidden(name string, settings map[string]interface{}) bool {
|
|
if hiddenNames, ok := settings["hiddenFolderNames"].([]interface{}); ok {
|
|
for _, v := range hiddenNames {
|
|
if hiddenName, ok := v.(string); ok {
|
|
if strings.EqualFold(name, hiddenName) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *PhotosPlugin) GetMetrics() map[string]interface{} {
|
|
p.mutex.RLock()
|
|
defer p.mutex.RUnlock()
|
|
|
|
metrics := map[string]interface{}{
|
|
"photos_folders_total": 0,
|
|
"photos_images_total": 0,
|
|
}
|
|
|
|
if p.data != nil {
|
|
metrics["photos_folders_total"] = len(p.data.Folders)
|
|
metrics["photos_images_total"] = p.data.TotalCount
|
|
}
|
|
|
|
return metrics
|
|
}
|