Files
yetone e5f8ff6026 site, gui: release notes survive GitHub limiting usemagpie.ai, and a failure to get them says so, apart from a release without any (#661) — /api/notes answered 503 {"error":"no releases"} with Cache-Control public, max-age=300 whenever GitHub's API limited the worker's shared IP, and /api/latest fell back to the release page's redirect with notes "" (kept 300 s too), so the update window had nothing to show. The worker now: never keeps an error (503 is no-store); takes the notes from GitHub's releases feed (releases.atom, not API-limited) when the API says no, turning its HTML back into the markdown the app draws and putting back the <!-- lang:zh --> marker GitHub strips (before the first Chinese heading after an English one); keeps the last whole answer for 30 days in the edge cache and serves it (merged with the feed's newer releases) when GitHub fails; keeps a degraded answer 60 s instead of 300; and sends a GITHUB_TOKEN secret to the API where one is set (none is yet). In the app, /api/whatsnew says error (and the release page's url) when the notes couldn't be had, and Settings' What's new opens a dialog saying "Couldn't load the release notes" with Try again and "Open the release page" (through the app), or "No release notes provided." with the release page and no retry for a version with none, instead of a passing toast; en/zh. Tests that fail on the old code and pass: site/worker.test.mjs (API limited: notes from the feed in either language, briefly kept; all of GitHub down: 503 no-store, then the last whole answer; the token sent), TestWhatsNewTellsFailureFromNone, and whats-new-failed.test.cjs (WebKit, en/zh: failure with retry and link, retry showing the notes, none without retry, no click moves the page). The feed parser was tried on the real releases.atom (v0.1.730–739: English and Chinese split as the API's bodies are). go vet, builds (darwin, GOOS=linux, GOOS=windows), go test -tags nogui -count=1 ./... under a temp HOME, node --test site/ and whats-new.test.cjs pass.
2026-10-03 17:12:48 +08:00

195 lines
5.7 KiB
Go

package update
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"slices"
"sort"
"strings"
)
// Note is one release's notes, as the app shows them after an update.
type Note struct {
Version string `json:"version"` // no v
Notes string `json:"notes"` // markdown, its Install section taken out
URL string `json:"url,omitempty"`
}
// NotesFeed is where the notes of past releases are listed: beside the
// update feed, on magpie's site. MAGPIE_NOTES_FEED points it elsewhere.
func NotesFeed() string {
if f := os.Getenv("MAGPIE_NOTES_FEED"); f != "" {
return f
}
return Site + "/api/notes"
}
// ReleasePage is version's page on GitHub, where its notes are when the
// site can't give them (#661).
func ReleasePage(version string) string {
return "https://github.com/yetone/magpie-releases/releases/tag/v" + strings.TrimPrefix(strings.TrimSpace(version), "v")
}
// NotesBetween asks the site for the notes of every release after after
// (none when "") up to and including upto, newest first, in lang (see
// InLang).
func NotesBetween(ctx context.Context, after, upto, lang string) ([]Note, error) {
q := url.Values{"upto": {upto}}
if after != "" {
q.Set("after", after)
}
if lang != "" {
q.Set("lang", lang)
}
req, err := http.NewRequestWithContext(ctx, "GET", NotesFeed()+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("release notes: %s", res.Status)
}
var out struct {
Releases []Note `json:"releases"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("release notes: %w", err)
}
for i := range out.Releases {
out.Releases[i].Notes = InLang(out.Releases[i].Notes, lang)
}
return Between(out.Releases, after, upto), nil
}
// zhMarker is where a release's notes turn from English to Chinese: the
// release workflow translates them and puts the Chinese below it.
const zhMarker = "<!-- lang:zh -->"
// InLang is a release's notes in lang, as the site cuts them (a site from
// before it did, or a feed elsewhere, hands them whole): "zh" the Chinese
// where there is some, else the English, everything above the marker.
// Notes from before the marker are English alone, in any language.
func InLang(md, lang string) string {
i := strings.Index(md, zhMarker)
if i < 0 {
return md
}
if lang == "zh" {
if zh := strings.TrimSpace(md[i+len(zhMarker):]); zh != "" {
return zh
}
}
return strings.TrimSpace(md[:i])
}
// withLang adds lang to a feed's address, as ?lang= (or &lang=).
func withLang(feed, lang string) string {
if lang == "" {
return feed
}
u, err := url.Parse(feed)
if err != nil {
return feed
}
q := u.Query()
q.Set("lang", lang)
u.RawQuery = q.Encode()
return u.String()
}
// MaxNotes is how many releases' notes are shown at most: magpie releases
// often, and a long-unopened one would have pages of them.
const MaxNotes = 40
// Between keeps the notes of the releases after after up to and including
// upto, newest first, at most MaxNotes; with after "" only upto's. Each
// one's Install section is taken out, and one left empty is dropped.
func Between(notes []Note, after, upto string) []Note {
var out []Note
seen := map[string]bool{}
for _, n := range notes {
v := strings.TrimPrefix(strings.TrimSpace(n.Version), "v")
if parse(v) == nil || seen[v] || Newer(v, upto) {
continue
}
if after == "" {
if Newer(upto, v) {
continue
}
} else if !Newer(v, after) {
continue
}
n.Version, n.Notes = v, StripInstall(n.Notes)
if strings.TrimSpace(n.Notes) == "" {
continue
}
seen[v] = true
out = append(out, n)
}
sort.SliceStable(out, func(i, j int) bool { return Newer(out[i].Version, out[j].Version) })
if len(out) > MaxNotes {
out = out[:MaxNotes]
}
return out
}
var heading = regexp.MustCompile(`^(#{1,6})\s+(.*?)\s*#*\s*$`)
// installHeadings are the Install section's heading in each language the
// notes come in: the Chinese notes call it 安装, and every release's own
// section would otherwise repeat in What's new.
var installHeadings = []string{"Install", "安装", "安裝", "インストール"}
// StripInstall takes a release's "Install" section (in any language) out of
// its notes: the download links, which mean nothing to a magpie that just
// updated itself.
// The section runs to the next heading of its level or above.
func StripInstall(md string) string {
var out []string
skip := 0 // the level of the Install heading being skipped, 0 when not
for _, line := range strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n") {
if m := heading.FindStringSubmatch(line); m != nil {
level := len(m[1])
if skip > 0 && level <= skip {
skip = 0
}
if slices.ContainsFunc(installHeadings, func(h string) bool { return strings.EqualFold(m[2], h) }) {
skip = level
}
}
if skip == 0 {
out = append(out, line)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
// ShowNotes decides, as magpie starts on current having last run as last
// ("" when it never said), whether to show what changed since, and from
// which version on (exclusive). Only an upgrade between releases shows:
// not a fresh install, the same version again, a downgrade, or a build
// from source. A magpie from before versions were kept has no last; when
// it was already in use (existed) its own version's notes are shown, from
// "", which Between takes as upto's alone.
func ShowNotes(last, current string, existed bool) (after string, show bool) {
if !Released(current) {
return "", false
}
if last == "" {
return "", existed
}
if !Newer(current, last) {
return "", false
}
return strings.TrimPrefix(strings.TrimSpace(last), "v"), true
}