Files
code-utils/festival.go
2026-08-14 07:52:01 +08:00

216 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// festival.go 日历节日/节气自定义背景图。
// 管理员(云端账号 id=1上传后写入本地 festival_images 表并随同步分发:
// 云端存放在 sync_settings 的 fest_img:<节日名> 行(统一挂在 id=1 名下),
// 管理员推送本地修改,所有账号拉取,实现"管理员配置、全员可见"。
//
// 行 value 为 JSON {"mode":"photo|art","image":"data:..."}
// mode 决定日历格用照片还是动态插画art 模式仍保留图片,切回无需重传);
// 兼容旧格式value 直接是 dataURL视为 photo 模式);空值为删除墓碑。
import (
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
const (
festivalAdminID = 1
festivalImgEdge = 760 // 铺在日历格背景不需要原图精度,压小以控制同步体积
)
func parseFestivalValue(v string) (FestivalImage, bool) {
if v == "" {
return FestivalImage{}, false
}
if strings.HasPrefix(v, "data:") {
return FestivalImage{Mode: "photo", Image: v}, true // 旧格式:裸 dataURL
}
var f FestivalImage
if json.Unmarshal([]byte(v), &f) != nil || f.Image == "" {
return FestivalImage{}, false
}
if f.Mode != "art" {
f.Mode = "photo"
}
return f, true
}
// ListFestivalImages 返回节日名 → 背景配置的映射(本地缓存,含拉取到的云端配置)。
func (a *App) ListFestivalImages() (map[string]FestivalImage, error) {
if e := a.ready(); e != nil {
return nil, e
}
rows, e := a.store.db.Query(`SELECT fest_key,value FROM festival_images WHERE value!=''`)
if e != nil {
return nil, e
}
defer rows.Close()
m := map[string]FestivalImage{}
for rows.Next() {
var k, v string
if e := rows.Scan(&k, &v); e != nil {
return nil, e
}
if f, ok := parseFestivalValue(v); ok {
m[k] = f
}
}
return m, rows.Err()
}
func (a *App) festivalAdminGate() error {
if a.syncUserID() != festivalAdminID {
return errors.New("FESTIVAL_IMG_ADMIN_ONLY")
}
return nil
}
// PickFestivalImage 管理员为某个节日选择本地图片;处理保存后返回背景配置,取消返回空 image。
func (a *App) PickFestivalImage(key string) (FestivalImage, error) {
if e := a.ready(); e != nil {
return FestivalImage{}, e
}
if e := a.festivalAdminGate(); e != nil {
return FestivalImage{}, e
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择节日背景图", "Choose festival background")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return FestivalImage{}, e
}
info, e := os.Stat(p)
if e != nil {
return FestivalImage{}, errors.New("FILE_NOT_FOUND")
}
if info.Size() > contentImgMaxBytes {
return FestivalImage{}, errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(p)
if e != nil {
return FestivalImage{}, errors.New("FILE_READ_FAILED")
}
return a.saveFestivalImage(key, raw)
}
// SetFestivalImageData 管理员以 dataURL 形式设置节日图(自动化/粘贴场景)。
func (a *App) SetFestivalImageData(key, dataURL string) (FestivalImage, error) {
if e := a.ready(); e != nil {
return FestivalImage{}, e
}
if e := a.festivalAdminGate(); e != nil {
return FestivalImage{}, e
}
raw, e := dataURLBytes(dataURL)
if e != nil {
return FestivalImage{}, e
}
return a.saveFestivalImage(key, raw)
}
// SetFestivalImageMode 切换某节日的显示模式photo=自定义照片art=动态插画。
// 仅在已有图片的行上生效(无图时本就显示动态插画)。
func (a *App) SetFestivalImageMode(key, mode string) error {
if e := a.ready(); e != nil {
return e
}
if e := a.festivalAdminGate(); e != nil {
return e
}
key = strings.TrimSpace(key)
if key == "" || (mode != "photo" && mode != "art") {
return errors.New("FESTIVAL_IMG_BAD_KEY")
}
var v string
if e := a.store.db.QueryRow(`SELECT value FROM festival_images WHERE fest_key=?`, key).Scan(&v); e != nil {
return errors.New("FESTIVAL_IMG_NOT_FOUND")
}
f, ok := parseFestivalValue(v)
if !ok {
return errors.New("FESTIVAL_IMG_NOT_FOUND")
}
if f.Mode == mode {
return nil
}
f.Mode = mode
payload, e := json.Marshal(f)
if e != nil {
return e
}
if _, e := a.store.db.Exec(`UPDATE festival_images SET value=?,updated_at=?,dirty=1 WHERE fest_key=?`,
string(payload), a.festNextStamp(key), key); e != nil {
return e
}
a.store.Log("info", "日历", "节日背景模式已切换", key+" → "+mode)
go a.syncOnce(true)
return nil
}
// RemoveFestivalImage 移除自定义图(保留行并置空,作为删除标记同步给其他设备)。
func (a *App) RemoveFestivalImage(key string) error {
if e := a.ready(); e != nil {
return e
}
if e := a.festivalAdminGate(); e != nil {
return e
}
key = strings.TrimSpace(key)
if key == "" {
return errors.New("FESTIVAL_IMG_BAD_KEY")
}
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,'',?,1)
ON CONFLICT(fest_key) DO UPDATE SET value='',updated_at=excluded.updated_at,dirty=1`, key, a.festNextStamp(key)); e != nil {
return e
}
a.store.Log("info", "日历", "节日图片已移除", key)
go a.syncOnce(true)
return nil
}
// festNextStamp 生成该节日行的下一个时间戳,保证严格大于现有值:
// nowRFC 精度只有秒,同一秒内先设图再移除会得到相同时间戳,
// 云端 LWW 的严格大于比较会拒绝第二次写入,本地却已清 dirty造成永久分叉。
func (a *App) festNextStamp(key string) string {
now := nowRFC()
var prev string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key=?`, key).Scan(&prev)
if prev < now {
return now
}
if t, e := time.Parse(time.RFC3339, prev); e == nil {
return t.Add(time.Second).UTC().Format(time.RFC3339)
}
return now
}
func (a *App) saveFestivalImage(key string, raw []byte) (FestivalImage, error) {
key = strings.TrimSpace(key)
if key == "" || len(key) > 40 {
return FestivalImage{}, errors.New("FESTIVAL_IMG_BAD_KEY")
}
data, mime, e := encodeImageEdge(raw, festivalImgEdge)
if e != nil {
return FestivalImage{}, e
}
f := FestivalImage{Mode: "photo", Image: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)}
payload, e := json.Marshal(f)
if e != nil {
return FestivalImage{}, e
}
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,?,?,1)
ON CONFLICT(fest_key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at,dirty=1`, key, string(payload), a.festNextStamp(key)); e != nil {
return FestivalImage{}, e
}
a.store.Log("info", "日历", "节日图片已更新", key)
go a.syncOnce(true)
return f, nil
}