Files
code-utils/festival.go

216 lines
6.7 KiB
Go
Raw Permalink Normal View History

2026-08-14 07:52:01 +08:00
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
}