feat: check for available updates
This commit is contained in:
+43
-2
@@ -10,7 +10,7 @@ import {
|
|||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus,
|
GetStartupStatus, CheckForUpdate,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
|
|
||||||
@@ -678,6 +678,15 @@ export default function App() {
|
|||||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
||||||
|
// Check GitHub for a newer release once at startup (unless disabled in
|
||||||
|
// General); surface a toast if one exists. Best effort — silent on failure.
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||||
|
CheckForUpdate().then((u: any) => {
|
||||||
|
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||||
@@ -2508,6 +2517,31 @@ export default function App() {
|
|||||||
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{updateInfo && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
|
||||||
|
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
|
||||||
|
className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showAbout && (
|
{showAbout && (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
|
||||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
|
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -2517,6 +2551,13 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
|
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
|
||||||
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
|
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
|
||||||
|
{updateInfo ? (
|
||||||
|
<button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
|
||||||
|
Update available: v{updateInfo.latest} — download
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[11px] text-emerald-600">You're up to date</p>
|
||||||
|
)}
|
||||||
<p className="mt-3 text-sm">
|
<p className="mt-3 text-sm">
|
||||||
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -593,6 +593,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
||||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||||
|
const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
|
||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
@@ -3285,6 +3286,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
|
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
|
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
||||||
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
|
|||||||
Vendored
+2
@@ -37,6 +37,8 @@ export function BrowseExecutable():Promise<string>;
|
|||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
|
|
||||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CheckForUpdate() {
|
||||||
|
return window['go']['main']['App']['CheckForUpdate']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ClearLookupCache() {
|
export function ClearLookupCache() {
|
||||||
return window['go']['main']['App']['ClearLookupCache']();
|
return window['go']['main']['App']['ClearLookupCache']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1591,6 +1591,24 @@ export namespace main {
|
|||||||
this.moving = source["moving"];
|
this.moving = source["moving"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class UpdateInfo {
|
||||||
|
current: string;
|
||||||
|
latest: string;
|
||||||
|
available: boolean;
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new UpdateInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.current = source["current"];
|
||||||
|
this.latest = source["latest"];
|
||||||
|
this.available = source["available"];
|
||||||
|
this.url = source["url"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
label: string;
|
label: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
-100
@@ -1,100 +0,0 @@
|
|||||||
# OpsLog release script — source → Gitea (origin), exe → Gitea + GitHub releases.
|
|
||||||
# Mirrors the DXHunter workflow, adapted for the Wails build and OpsLog's version
|
|
||||||
# files. Run from the repo root in PowerShell.
|
|
||||||
|
|
||||||
# Force UTF-8 throughout — prevents git log em-dashes / accents from corrupting the API body
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
|
|
||||||
# ── Config ────────────────────────────────────────────────────────────────────
|
|
||||||
$GitHubRepo = "GregTroar/OpsLog" # GitHub repo that hosts the public exe (adjust if different)
|
|
||||||
$ExePath = "build/bin/OpsLog.exe" # Wails build output
|
|
||||||
$Wails = Join-Path $HOME "go\bin\wails.exe" # the v2.11 wails (not the global one)
|
|
||||||
if (-not (Test-Path $Wails)) { $Wails = "wails" } # fall back to PATH
|
|
||||||
|
|
||||||
# Parse token, host, and repo path from the Gitea remote URL (origin)
|
|
||||||
$remoteUrl = git remote get-url origin
|
|
||||||
if ($remoteUrl -match 'https://([^@]+)@([^/]+)/(.+?)\.git') {
|
|
||||||
$token = $Matches[1]
|
|
||||||
$gitHost = $Matches[2]
|
|
||||||
$repo = $Matches[3]
|
|
||||||
} else {
|
|
||||||
Write-Host "Cannot parse Gitea remote URL (expected https://<token>@<host>/<repo>.git)." -ForegroundColor Red; exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
git add .
|
|
||||||
$msg = Read-Host "Commit message"
|
|
||||||
if ($msg) { git commit -m $msg }
|
|
||||||
|
|
||||||
$ver = Read-Host "Version (ex: 0.2)"
|
|
||||||
if (-not $ver) { Write-Host "Aborted." -ForegroundColor Red; exit 1 }
|
|
||||||
|
|
||||||
# ── Bump the version in the single sources of truth ─────────────────────────────
|
|
||||||
$lastMsg = git log -1 --pretty=format:"%s"
|
|
||||||
if ($lastMsg -ne "chore: release v$ver") {
|
|
||||||
# Frontend (UI header + About popup)
|
|
||||||
(Get-Content frontend/src/version.ts) -replace "APP_VERSION = '.*'", "APP_VERSION = '$ver'" | Set-Content frontend/src/version.ts -Encoding utf8
|
|
||||||
# Backend (telemetry heartbeat version)
|
|
||||||
(Get-Content telemetry.go) -replace 'appVersion = ".*"', "appVersion = `"$ver`"" | Set-Content telemetry.go -Encoding utf8
|
|
||||||
git add frontend/src/version.ts telemetry.go
|
|
||||||
git commit -m "chore: release v$ver"
|
|
||||||
} else {
|
|
||||||
Write-Host "Release commit already exists, skipping version bump..." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
git tag "v$ver" 2>$null
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "Tag v$ver already exists locally, continuing..." -ForegroundColor Yellow }
|
|
||||||
|
|
||||||
# Push source to Gitea (origin) — source code stays on Gitea only
|
|
||||||
git push
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "git push failed!" -ForegroundColor Red; exit 1 }
|
|
||||||
git push --tags
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "git push --tags failed!" -ForegroundColor Red; exit 1 }
|
|
||||||
|
|
||||||
# ── Release notes from commits since the previous tag ───────────────────────────
|
|
||||||
$prevTag = git describe --tags --abbrev=0 "v$ver^" 2>$null
|
|
||||||
$changelog = if ($prevTag) {
|
|
||||||
git log "$prevTag..v$ver" --pretty=format:"- %s" --no-merges
|
|
||||||
} else {
|
|
||||||
git log "v$ver" --pretty=format:"- %s" --no-merges
|
|
||||||
}
|
|
||||||
$body = "## Changelog`n`n$($changelog -join "`n")"
|
|
||||||
Write-Host "`nRelease notes:`n$body`n" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
# ── Build the Windows exe (Wails compiles frontend + Go) ─────────────────────────
|
|
||||||
Write-Host "Building OpsLog.exe v$ver ..." -ForegroundColor Cyan
|
|
||||||
& $Wails build
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "Build failed!" -ForegroundColor Red; exit 1 }
|
|
||||||
if (-not (Test-Path $ExePath)) { Write-Host "Built exe not found at $ExePath" -ForegroundColor Red; exit 1 }
|
|
||||||
|
|
||||||
# ── Gitea release — get existing or create new, then upload the exe ──────────────
|
|
||||||
$api = "https://$gitHost/api/v1/repos/$repo"
|
|
||||||
$headers = @{ Authorization = "token $token"; 'Content-Type' = 'application/json' }
|
|
||||||
try {
|
|
||||||
$release = Invoke-RestMethod "$api/releases/tags/v$ver" -Method GET -Headers $headers
|
|
||||||
Write-Host "Gitea: found existing release for v$ver (id=$($release.id)), uploading exe..." -ForegroundColor Yellow
|
|
||||||
} catch {
|
|
||||||
$payloadBytes = [System.Text.Encoding]::UTF8.GetBytes((@{ tag_name = "v$ver"; target_commitish = "main"; name = "OpsLog v$ver"; body = $body } | ConvertTo-Json))
|
|
||||||
try {
|
|
||||||
$release = Invoke-RestMethod "$api/releases" -Method POST -Headers $headers -Body $payloadBytes
|
|
||||||
} catch {
|
|
||||||
Write-Host "Gitea release creation failed: $_" -ForegroundColor Red; exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$uploadUri = "https://$gitHost/api/v1/repos/$repo/releases/$($release.id)/assets?name=OpsLog.exe"
|
|
||||||
curl.exe -s -H "Authorization: token $token" -F "attachment=@$ExePath" $uploadUri | Out-Null
|
|
||||||
Write-Host "Gitea: release v$ver published." -ForegroundColor Green
|
|
||||||
|
|
||||||
# ── GitHub release (requires gh CLI: https://cli.github.com) ────────────────────
|
|
||||||
if (Get-Command gh -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Host "Creating GitHub release v$ver ..." -ForegroundColor Cyan
|
|
||||||
$notesFile = [System.IO.Path]::GetTempFileName()
|
|
||||||
$body | Out-File -FilePath $notesFile -Encoding utf8
|
|
||||||
gh release create "v$ver" $ExePath `
|
|
||||||
--repo $GitHubRepo `
|
|
||||||
--title "OpsLog v$ver" `
|
|
||||||
--notes-file $notesFile
|
|
||||||
Remove-Item $notesFile
|
|
||||||
Write-Host "GitHub: release v$ver published." -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "gh CLI not found - skipping GitHub release (install from https://cli.github.com, then: gh auth login)" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateCheckURL is the GitHub Releases "latest" endpoint for the public OpsLog
|
||||||
|
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
||||||
|
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
||||||
|
|
||||||
|
// UpdateInfo is the result of the startup version check.
|
||||||
|
type UpdateInfo struct {
|
||||||
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
|
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||||
|
Available bool `json:"available"` // Latest > Current
|
||||||
|
URL string `json:"url"` // release page to open
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||||
|
// build. Best effort — on any failure it reports "no update" so the app never
|
||||||
|
// nags about a check it couldn't complete.
|
||||||
|
func (a *App) CheckForUpdate() UpdateInfo {
|
||||||
|
out := UpdateInfo{Current: appVersion}
|
||||||
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, updateCheckURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("update: check failed: %v", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return out // no release yet (404) or rate-limited — treat as up to date
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||||
|
out.URL = r.HTMLURL
|
||||||
|
out.Available = versionLess(appVersion, out.Latest)
|
||||||
|
if out.Available {
|
||||||
|
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionLess reports whether version a is older than b. Compares dot-separated
|
||||||
|
// numeric parts ("0.9" < "0.10" < "1.0"); non-numeric junk in a part counts as 0.
|
||||||
|
func versionLess(a, b string) bool {
|
||||||
|
pa := strings.Split(a, ".")
|
||||||
|
pb := strings.Split(b, ".")
|
||||||
|
n := len(pa)
|
||||||
|
if len(pb) > n {
|
||||||
|
n = len(pb)
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
ai, bi := 0, 0
|
||||||
|
if i < len(pa) {
|
||||||
|
ai = leadingInt(pa[i])
|
||||||
|
}
|
||||||
|
if i < len(pb) {
|
||||||
|
bi = leadingInt(pb[i])
|
||||||
|
}
|
||||||
|
if ai != bi {
|
||||||
|
return ai < bi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||||
|
func leadingInt(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
end := 0
|
||||||
|
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if end == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(s[:end])
|
||||||
|
return n
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user