diff --git a/cmd/hold/scan_backfill.go b/cmd/hold/scan_backfill.go new file mode 100644 index 0000000..0999f2e --- /dev/null +++ b/cmd/hold/scan_backfill.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "atcr.io/pkg/atproto" + "atcr.io/pkg/hold" + + "github.com/spf13/cobra" +) + +// Media-type fragments that identify artifact types the scanner intentionally +// skips. Keep this list in sync with scanner/internal/scan/worker.go's +// unscannableConfigTypes — that map keys on config media types; here we look +// at *layer* media types because the backfill walks the hold's layer index +// (which has manifest AT-URIs we can join against scan records). +// +// Detection by layer media type is reliable: helm charts always have a single +// layer with media type application/vnd.cncf.helm.chart.content.v1.tar+gzip; +// in-toto / DSSE attestations use distinct layer types too. +var unscannableLayerMediaSubstrings = []string{ + "helm.chart.content", + "in-toto", + "dsse.envelope", +} + +var scanBackfillConfigFile string + +var scanBackfillCmd = &cobra.Command{ + Use: "scan-backfill", + Short: "Rewrite legacy scan records to use the status field", + Long: `Walks every io.atcr.hold.scan record on this hold and assigns a status +("skipped" or "failed") to records that pre-date the status field. + +A legacy record is one with an empty status, no SBOM blob, and zero vulnerability +counts. The tool inspects each record's manifest's layers to decide: + + - layer media type matches helm/in-toto/DSSE → status="skipped" + - everything else → status="failed" + +The tool is idempotent: records that already have a status are left alone. +Run once per hold after upgrading.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := hold.LoadConfig(scanBackfillConfigFile) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + ctx := context.Background() + holdPDS, cleanup, err := openHoldPDS(ctx, cfg) + if err != nil { + return err + } + defer cleanup() + + ri := holdPDS.RecordsIndex() + if ri == nil { + return fmt.Errorf("records index not available") + } + + const batchSize = 200 + var ( + cursor string + scanned int + rewritten int + markSkipped int + markFailed int + alreadyOK int + ) + + for { + records, nextCursor, err := ri.ListRecords(atproto.ScanCollection, batchSize, cursor, true) + if err != nil { + return fmt.Errorf("list scan records: %w", err) + } + + for _, rec := range records { + scanned++ + manifestDigest := "sha256:" + rec.Rkey + + _, scanRecord, err := holdPDS.GetScanRecord(ctx, manifestDigest) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " skip rkey=%s: get failed: %v\n", rec.Rkey, err) + continue + } + + // Already classified — nothing to do. + if scanRecord.Status != "" { + alreadyOK++ + continue + } + + // Only legacy records that signal failure (nil blob + zero + // counts) are candidates. Records with real data don't need + // rewriting; their absent status will be treated as "ok". + if scanRecord.SbomBlob != nil || scanRecord.Total != 0 { + alreadyOK++ + continue + } + + // Determine artifact type from layer media types. + layers, err := holdPDS.ListLayerRecordsForManifest(ctx, scanRecord.Manifest) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " skip rkey=%s: list layers failed: %v\n", rec.Rkey, err) + continue + } + + skipped := false + for _, l := range layers { + for _, frag := range unscannableLayerMediaSubstrings { + if strings.Contains(l.MediaType, frag) { + skipped = true + break + } + } + if skipped { + break + } + } + + var rewrite *atproto.ScanRecord + if skipped { + rewrite = atproto.NewSkippedScanRecord( + manifestDigest, + scanRecord.Repository, + scanRecord.UserDID, + "backfilled: unscannable artifact type", + scanRecord.ScannerVersion, + ) + markSkipped++ + } else { + rewrite = atproto.NewFailedScanRecord( + manifestDigest, + scanRecord.Repository, + scanRecord.UserDID, + "backfilled: legacy record (no SBOM and zero counts)", + scanRecord.ScannerVersion, + ) + markFailed++ + } + // Preserve the original ScannedAt — rewriting it would either + // reset the rescan timer or invalidate audit signals. + if scanRecord.ScannedAt != "" { + rewrite.ScannedAt = scanRecord.ScannedAt + } + + if _, _, err := holdPDS.CreateScanRecord(ctx, rewrite); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " rewrite rkey=%s failed: %v\n", rec.Rkey, err) + continue + } + rewritten++ + } + + if nextCursor == "" || len(records) == 0 { + break + } + cursor = nextCursor + } + + fmt.Fprintf(cmd.OutOrStdout(), "Backfill complete:\n") + fmt.Fprintf(cmd.OutOrStdout(), " scanned: %d\n", scanned) + fmt.Fprintf(cmd.OutOrStdout(), " already-tagged: %d\n", alreadyOK) + fmt.Fprintf(cmd.OutOrStdout(), " → skipped: %d\n", markSkipped) + fmt.Fprintf(cmd.OutOrStdout(), " → failed: %d\n", markFailed) + fmt.Fprintf(cmd.OutOrStdout(), " rewritten: %d\n", rewritten) + return nil + }, +} + +func init() { + scanBackfillCmd.Flags().StringVarP(&scanBackfillConfigFile, "config", "c", "", "path to YAML configuration file") + rootCmd.AddCommand(scanBackfillCmd) +} diff --git a/deploy/upcloud/configs/appview.yaml.tmpl b/deploy/upcloud/configs/appview.yaml.tmpl index 7280ce1..9a323c6 100644 --- a/deploy/upcloud/configs/appview.yaml.tmpl +++ b/deploy/upcloud/configs/appview.yaml.tmpl @@ -45,3 +45,5 @@ auth: legal: company_name: Seamark jurisdiction: State of Texas, United States +ai: + api_key: "" diff --git a/docs/SBOM_SCANNING.md b/docs/SBOM_SCANNING.md index 171ce55..dc140be 100644 --- a/docs/SBOM_SCANNING.md +++ b/docs/SBOM_SCANNING.md @@ -202,6 +202,52 @@ SBOM results are stored in two places: - Record key: SBOM manifest digest - Contains reference to subject image +## Scan Record Status + +Every scan attempt produces an `io.atcr.hold.scan` record. The `status` field +tells the appview how to render the result: + +| Status | Meaning | Stale-loop behavior | +|-------------|--------------------------------------------------------------------------------------------------|--------------------------------------------------| +| `ok` (or empty) | Scanner produced an SBOM. Vulnerability counts populated; SBOM blob populated. | Re-scanned on the rescan interval (default 7d). | +| `failed` | Scanner ran but errored (network, OOM, parse failure). No SBOM, no counts. | Re-scanned on the rescan interval — failures may be transient. | +| `skipped` | Scanner intentionally bypassed the artifact (helm chart, in-toto attestation, DSSE envelope). The `reason` field explains why. | **Never re-queued.** A skipped record won't change without a code change in the scanner. | + +Records written before the `status` field existed have an empty status. The +appview treats empty + nil-blob + zero-count as failed (legacy fallback). + +### Unscannable artifact types + +The scanner skips artifacts whose config media type appears in +`unscannableConfigTypes` (`scanner/internal/scan/worker.go`). Currently: + +- `application/vnd.cncf.helm.config.v1+json` — Helm charts. Rendered with a + helm-aware digest page (`pkg/appview/handlers/digest.go`) that shows + Chart.yaml metadata instead of layers / vulns / SBOM. +- `application/vnd.in-toto+json` — in-toto attestations. +- `application/vnd.dsse.envelope.v1+json` — DSSE envelopes (SLSA provenance). + +For these types the appview's vuln/SBOM tabs render +*"Vulnerability scanning isn't applied to this artifact type."* — no retry hint. + +To add a new unscannable type: append the media type to +`unscannableConfigTypes`. Existing records won't auto-rewrite — run +`atcr-hold scan-backfill` once to convert any pre-existing failure records +into skipped records (see below). + +### Backfill tool + +`atcr-hold scan-backfill --config ` walks every scan record on the +hold and rewrites legacy ones (empty status + nil blob + zero counts) using +the manifest's layer media types as a signal: + +- Layer media type contains `helm.chart.content`, `in-toto`, or + `dsse.envelope` → `status="skipped"`. +- Otherwise → `status="failed"`. + +The tool is idempotent and preserves the original `scannedAt`, so it can be +re-run safely. Run once per hold after upgrading. + ## Accessing SBOMs Multiple methods for discovering and retrieving SBOM data. diff --git a/lexicons/io/atcr/hold/scan.json b/lexicons/io/atcr/hold/scan.json index 698ff0c..0ff1110 100644 --- a/lexicons/io/atcr/hold/scan.json +++ b/lexicons/io/atcr/hold/scan.json @@ -69,6 +69,17 @@ "type": "string", "format": "datetime", "description": "RFC3339 timestamp of when the scan completed" + }, + "status": { + "type": "string", + "knownValues": ["ok", "failed", "skipped"], + "description": "Outcome of the scan attempt. 'ok' (or omitted, for back-compat) means the scanner produced an SBOM. 'failed' means the scanner ran but errored. 'skipped' means the scanner intentionally bypassed this artifact type (e.g. helm charts).", + "maxLength": 32 + }, + "reason": { + "type": "string", + "description": "Optional human-readable explanation for non-ok status (e.g. 'unscannable artifact type application/vnd.cncf.helm.config.v1+json').", + "maxLength": 256 } } } diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 890c14e..c1714c8 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -30,20 +30,29 @@ const accessibleHoldsSubquery = `( OR hold_did IN (SELECT hold_did FROM hold_crew_members WHERE member_did = ?) )` +// Artifact type values stored in manifests.artifact_type and returned by +// GetArtifactType. Container-image is the default for OCI/Docker images and +// manifest lists; helm-chart is for OCI helm chart artifacts. +const ( + ArtifactTypeContainerImage = "container-image" + ArtifactTypeHelmChart = "helm-chart" + ArtifactTypeUnknown = "unknown" +) + // GetArtifactType determines the artifact type based on config media type // Returns: "helm-chart", "container-image", or "unknown" func GetArtifactType(configMediaType string) string { switch { case strings.Contains(configMediaType, "helm.config"): - return "helm-chart" + return ArtifactTypeHelmChart case strings.Contains(configMediaType, "oci.image.config") || strings.Contains(configMediaType, "docker.container.image"): - return "container-image" + return ArtifactTypeContainerImage case configMediaType == "": // Manifest lists don't have a config - treat as container-image - return "container-image" + return ArtifactTypeContainerImage default: - return "unknown" + return ArtifactTypeUnknown } } diff --git a/pkg/appview/handlers/digest.go b/pkg/appview/handlers/digest.go index 452edcc..6e17990 100644 --- a/pkg/appview/handlers/digest.go +++ b/pkg/appview/handlers/digest.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "log/slog" "net/http" "strings" @@ -21,6 +22,53 @@ type LayerDetail struct { EmptyLayer bool // ENV, LABEL, etc. — no actual layer blob } +// HelmChartContent is the data the helm-aware digest content needs: parsed +// Chart.yaml metadata + a single chart-tarball "layer" pulled from the DB. +type HelmChartContent struct { + Meta *holdclient.HelmChartMeta + Tarball *LayerDetail + MetaFetchFailed bool // hold reachable but config blob couldn't be parsed + HoldUnreachable bool +} + +// buildHelmContent fetches helm chart metadata + the single chart-tarball layer. +// Returns a populated HelmChartContent even when the meta fetch fails so the +// page can still render the artifact card. +func buildHelmContent(ctx context.Context, holdURL string, digest string, dbLayers []db.Layer) *HelmChartContent { + content := &HelmChartContent{} + if holdURL == "" { + content.HoldUnreachable = true + } else { + meta, err := holdclient.FetchHelmChartMeta(ctx, holdURL, digest) + if err != nil { + slog.Warn("Failed to fetch helm chart meta", "error", err, "digest", digest) + content.MetaFetchFailed = true + } else { + content.Meta = meta + } + } + if len(dbLayers) > 0 { + // Helm charts are always single-layer (the chart tarball). If somehow + // multiple are present, pick the one with helm chart content media + // type, falling back to the first. + chosen := 0 + for i, l := range dbLayers { + if strings.Contains(l.MediaType, "helm.chart.content") { + chosen = i + break + } + } + l := dbLayers[chosen] + content.Tarball = &LayerDetail{ + Index: l.LayerIndex + 1, + Digest: l.Digest, + Size: l.Size, + MediaType: l.MediaType, + } + } + return content +} + // DigestDetailHandler renders the digest detail page with layers + vulnerabilities. type DigestDetailHandler struct { BaseUIHandler @@ -66,10 +114,28 @@ func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) var layers []LayerDetail var vulnData *vulnDetailsData var sbomData *sbomDetailsData + var helmContent *HelmChartContent if manifest.IsManifestList { // Manifest list: no layers, show platform picker // Platforms are already populated by GetManifestDetail + } else if manifest.ArtifactType == db.ArtifactTypeHelmChart { + // Helm chart: skip OCI history / vuln / SBOM entirely. Fetch helm + // chart metadata from the same config blob and the single tarball + // layer from the DB. + dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID) + if err != nil { + slog.Warn("Failed to fetch layers", "error", err) + } + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint) + holdURL := "" + if holdErr == nil { + holdURL = hold.URL + } + helmContent = buildHelmContent(r.Context(), holdURL, digest, dbLayers) + if holdErr != nil { + helmContent.HoldUnreachable = true + } } else { // Single manifest: fetch layers from DB dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID) @@ -124,6 +190,7 @@ func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) WithCanonical("https://" + h.SiteURL + "/d/" + owner.Handle + "/" + repository + "/" + digest). WithSiteName(h.ClientShortName) + pageData := NewPageData(r, &h.BaseUIHandler) data := struct { PageData Meta *PageMeta @@ -133,9 +200,12 @@ func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) Layers []LayerDetail VulnData *vulnDetailsData SbomData *sbomDetailsData + HelmContent *HelmChartContent SelectedPlatform string + RegistryURL string + OciClient string }{ - PageData: NewPageData(r, &h.BaseUIHandler), + PageData: pageData, Meta: meta, Owner: owner, Repository: repository, @@ -143,7 +213,10 @@ func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) Layers: layers, VulnData: vulnData, SbomData: sbomData, + HelmContent: helmContent, SelectedPlatform: selectedPlatform, + RegistryURL: h.RegistryURL, + OciClient: pageData.OciClient, } if err := h.Templates.ExecuteTemplate(w, "digest", data); err != nil { diff --git a/pkg/appview/handlers/digest_content.go b/pkg/appview/handlers/digest_content.go index 43bd0e2..d64fac3 100644 --- a/pkg/appview/handlers/digest_content.go +++ b/pkg/appview/handlers/digest_content.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "log/slog" "net/http" "strings" @@ -8,6 +9,7 @@ import ( "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/holdclient" + "atcr.io/pkg/appview/middleware" "atcr.io/pkg/atproto" "github.com/go-chi/chi/v5" ) @@ -49,6 +51,60 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint) holdReachable := holdErr == nil + // Helm charts have no scannable layers / vulns / SBOM. Render helm-aware + // content for the default + "chart" sections, and a not-applicable + // placeholder for the legacy layers / vulns / sbom sections (which + // shouldn't be requested for helm but might be if a stale tab fires). + if manifest.ArtifactType == db.ArtifactTypeHelmChart { + holdURL := "" + if holdReachable { + holdURL = hold.URL + } + helm := buildHelmContent(r.Context(), holdURL, digest, dbLayers) + if !holdReachable { + helm.HoldUnreachable = true + } + helmData := struct { + Manifest *db.ManifestWithMetadata + HelmContent *HelmChartContent + RegistryURL string + OwnerHandle string + RepoName string + OciClient string + IsLoggedIn bool + }{ + Manifest: manifest, + HelmContent: helm, + RegistryURL: h.RegistryURL, + OwnerHandle: identifier, + RepoName: repository, + OciClient: "", // helm switcher ignores this field + IsLoggedIn: middleware.GetUser(r) != nil, + } + w.Header().Set("Content-Type", "text/html") + section := r.URL.Query().Get("section") + switch section { + case "chart": + // Used by the repo page's chart tab — no install card here + // because repo-tag-section already renders one at the top. + if err := h.Templates.ExecuteTemplate(w, "helm-chart-info", helmData); err != nil { + slog.Warn("Failed to render helm chart info", "error", err) + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render helm chart", err) + } + case "layers", "vulns", "sbom": + // Defensive fallback if a stale tab somehow fires. The repo page + // hides these tabs for helm; this should be unreachable. + fmt.Fprint(w, `

Helm charts don't have layers, vulnerabilities, or SBOMs.

`) + default: + // Digest detail page (full helm view, with install card). + if err := h.Templates.ExecuteTemplate(w, "helm-digest-content", helmData); err != nil { + slog.Warn("Failed to render helm digest content", "error", err) + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render helm chart", err) + } + } + return + } + // Parallelize the three hold fetches. They're independent and each // takes a network round-trip; serial runs add up on slow links. var ( @@ -93,17 +149,21 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } // VulnReason / SbomReason let the template branch distinctly on why - // data is missing instead of collapsing three causes into a generic - // "not available" message. - // ok — data is present + // data is missing instead of collapsing causes into a generic message. + // ok — data is present // hold-unreachable — we couldn't reach the hold - // not-scanned — hold is up but no scan record exists - // fetch-failed — scan record fetch failed on the hold + // not-scanned — hold is up but no scan record exists + // not-applicable — scan record exists with status="skipped" (artifact + // type isn't scanned, e.g. in-toto, DSSE — helm + // charts go through a separate code path) + // fetch-failed — scan record fetch failed on the hold vulnReason := "ok" if !holdReachable { vulnReason = "hold-unreachable" } else if vulnData == nil || vulnData.Error == "never-scanned" { vulnReason = "not-scanned" + } else if vulnData.Status == atproto.ScanStatusSkipped { + vulnReason = "not-applicable" } else if vulnData.Error != "" { vulnReason = "fetch-failed" } @@ -113,6 +173,8 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) sbomReason = "hold-unreachable" } else if sbomData == nil || sbomData.Error == "never-scanned" { sbomReason = "not-scanned" + } else if sbomData.Status == atproto.ScanStatusSkipped { + sbomReason = "not-applicable" } else if sbomData.Error != "" { sbomReason = "fetch-failed" } diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index 7d5aaa7..04abc28 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -192,59 +192,76 @@ type DeleteUntaggedManifestsHandler struct { func (h *DeleteUntaggedManifestsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, map[string]string{"error": "Unauthorized"}) return } var req deleteUntaggedRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "Invalid request body"}) return } digests, err := db.GetAllUntaggedManifestDigests(h.DB, user.DID, req.Repo) if err != nil { - http.Error(w, fmt.Sprintf("Failed to query untagged manifests: %v", err), http.StatusInternalServerError) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]any{ + "error": fmt.Sprintf("Failed to query untagged manifests: %v", err), + "deleted": 0, + "failed": 0, + "total": 0, + }) return } if len(digests) == 0 { - render.JSON(w, r, map[string]int{"deleted": 0}) + render.JSON(w, r, map[string]any{"deleted": 0, "failed": 0, "total": 0}) return } pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) deleted := 0 + type failure struct { + Digest string `json:"digest"` + Error string `json:"error"` + } + var failures []failure + for _, digest := range digests { rkey := strings.TrimPrefix(digest, "sha256:") if err := pdsClient.DeleteRecord(r.Context(), atproto.ManifestCollection, rkey); err != nil { if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { - http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, map[string]any{ + "error": "Authentication failed, please log in again", + "deleted": deleted, + "failed": len(failures), + "total": len(digests), + }) return } - render.Status(r, http.StatusInternalServerError) - render.JSON(w, r, map[string]any{ - "error": fmt.Sprintf("Failed to delete manifest %s from PDS: %v", digest, err), - "deleted": deleted, - }) - return + failures = append(failures, failure{Digest: digest, Error: fmt.Sprintf("PDS: %v", err)}) + continue } if err := db.DeleteManifest(h.DB, user.DID, req.Repo, digest); err != nil { - render.Status(r, http.StatusInternalServerError) - render.JSON(w, r, map[string]any{ - "error": fmt.Sprintf("Failed to delete manifest %s from cache: %v", digest, err), - "deleted": deleted, - }) - return + failures = append(failures, failure{Digest: digest, Error: fmt.Sprintf("cache: %v", err)}) + continue } deleted++ } - render.JSON(w, r, map[string]int{"deleted": deleted}) + render.JSON(w, r, map[string]any{ + "deleted": deleted, + "failed": len(failures), + "total": len(digests), + "failures": failures, + }) } // UploadAvatarHandler handles uploading/updating a repository avatar diff --git a/pkg/appview/handlers/sbom_details.go b/pkg/appview/handlers/sbom_details.go index b7e73b7..05e72e9 100644 --- a/pkg/appview/handlers/sbom_details.go +++ b/pkg/appview/handlers/sbom_details.go @@ -39,6 +39,8 @@ type sbomDetailsData struct { Packages []sbomPackage Total int Error string + Status string // scan record's status field (ok | failed | skipped); empty for legacy records + Reason string // scan record's reason field (only meaningful when Status != ok) ScannedAt string Digest string // image digest (for download URLs) HoldEndpoint string // hold DID (for download URLs) @@ -130,10 +132,20 @@ func FetchSbomDetails(ctx context.Context, holdEndpoint, digest string) sbomDeta return sbomDetailsData{Error: "Failed to parse scan record"} } + if scanRecord.Status == atproto.ScanStatusSkipped { + return sbomDetailsData{ + Status: scanRecord.Status, + Reason: scanRecord.Reason, + ScannedAt: scanRecord.ScannedAt, + } + } + // Fetch the SBOM blob if scanRecord.SbomBlob == nil || scanRecord.SbomBlob.Ref.String() == "" { return sbomDetailsData{ ScannedAt: scanRecord.ScannedAt, + Status: scanRecord.Status, + Reason: scanRecord.Reason, Error: "No SBOM data available", } } @@ -203,6 +215,7 @@ func FetchSbomDetails(ctx context.Context, holdEndpoint, digest string) sbomDeta return sbomDetailsData{ Packages: packages, Total: len(packages), + Status: scanRecord.Status, ScannedAt: scanRecord.ScannedAt, Digest: digest, HoldEndpoint: holdEndpoint, diff --git a/pkg/appview/handlers/scan_result.go b/pkg/appview/handlers/scan_result.go index 7da82a5..c0d19d2 100644 --- a/pkg/appview/handlers/scan_result.go +++ b/pkg/appview/handlers/scan_result.go @@ -25,14 +25,16 @@ type ScanResultHandler struct { } // vulnBadgeData is the template data for the vuln-badge partial. -// The badge renders one of four states, in priority order: +// The badge renders one of five states, in priority order: // 1. Error — we couldn't reach the hold at all (network/5xx) // 2. NotScanned — hold reachable, no scan record for this digest (404) -// 3. ScanFailed — scan record exists but the scanner didn't produce an SBOM -// 4. Found — scan succeeded; render tier counts (or "Clean" when zero) +// 3. Skipped — scan record explicitly marks this artifact as not-scannable +// 4. ScanFailed — scan record exists but the scanner errored +// 5. Found — scan succeeded; render tier counts (or "Clean" when zero) // // These states must stay distinct so users can tell "hold is down" from -// "this hasn't been scanned yet" from "scanner errored on this image". +// "this hasn't been scanned yet" from "scanner errored on this image" from +// "this artifact type is intentionally not scanned". type vulnBadgeData struct { Critical int64 High int64 @@ -43,11 +45,33 @@ type vulnBadgeData struct { Found bool // true if scan record exists and succeeded Error bool // true if hold unreachable (network/5xx) NotScanned bool // true if hold is up but no scan record (404) - ScanFailed bool // true if scan record exists but scan failed (no SBOM) + ScanFailed bool // true if scan record exists but scan failed + Skipped bool // true if scan record marks the artifact as intentionally not scanned (helm, in-toto, etc.) Digest string // for the detail modal link HoldEndpoint string // for the detail modal link } +// classifyScanRecord maps a scan record's Status field to badge data flags. +// An empty Status is treated as a legacy record from before the status field +// existed: nil-blob + zero-counts = treat as failed (preserves the prior badge +// for un-backfilled holds); otherwise treat as success. +func classifyScanRecord(scanRecord *atproto.ScanRecord) (found, skipped, failed bool) { + switch scanRecord.Status { + case atproto.ScanStatusSkipped: + return false, true, false + case atproto.ScanStatusFailed: + return false, false, true + case atproto.ScanStatusOK: + return true, false, false + default: + // Legacy record (status field didn't exist when this was written). + if scanRecord.SbomBlob == nil && scanRecord.Total == 0 { + return false, false, true + } + return true, false, false + } +} + func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { digest := r.URL.Query().Get("digest") holdEndpoint := r.URL.Query().Get("holdEndpoint") @@ -122,10 +146,7 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // A failed scan has nil blobs (no SBOM generated) and zero counts. - // Successful scans always have an SBOM blob even with 0 vulnerabilities. - scanFailed := scanRecord.SbomBlob == nil && scanRecord.Total == 0 - + found, skipped, failed := classifyScanRecord(&scanRecord) h.renderBadge(w, vulnBadgeData{ Critical: scanRecord.Critical, High: scanRecord.High, @@ -133,8 +154,9 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Low: scanRecord.Low, Total: scanRecord.Total, ScannedAt: scanRecord.ScannedAt, - Found: true, - ScanFailed: scanFailed, + Found: found, + Skipped: skipped, + ScanFailed: failed, Digest: digest, HoldEndpoint: holdDID, }) @@ -189,6 +211,7 @@ func fetchScanRecord(ctx context.Context, holdEndpoint, holdDID, hexDigest strin return vulnBadgeData{Error: true} } + found, skipped, failed := classifyScanRecord(&scanRecord) return vulnBadgeData{ Critical: scanRecord.Critical, High: scanRecord.High, @@ -196,8 +219,9 @@ func fetchScanRecord(ctx context.Context, holdEndpoint, holdDID, hexDigest strin Low: scanRecord.Low, Total: scanRecord.Total, ScannedAt: scanRecord.ScannedAt, - Found: true, - ScanFailed: scanRecord.SbomBlob == nil && scanRecord.Total == 0, + Found: found, + Skipped: skipped, + ScanFailed: failed, Digest: fullDigest, HoldEndpoint: holdDID, } diff --git a/pkg/appview/handlers/vuln_details.go b/pkg/appview/handlers/vuln_details.go index c79db82..d186ea8 100644 --- a/pkg/appview/handlers/vuln_details.go +++ b/pkg/appview/handlers/vuln_details.go @@ -57,6 +57,8 @@ type vulnDetailsData struct { Matches []vulnMatch Summary vulnSummary Error string // non-empty if something went wrong + Status string // scan record's status field (ok | failed | skipped); empty for legacy records + Reason string // scan record's reason field (only meaningful when Status != ok) ScannedAt string Digest string // image digest (for download URLs) HoldEndpoint string // hold DID (for download URLs) @@ -317,11 +319,26 @@ func FetchVulnDetails(ctx context.Context, holdEndpoint, digest string) vulnDeta Total: scanRecord.Total, } + // Skipped scan records have no blobs and a non-failure status. The caller + // classifies these as "not-applicable" rather than "fetch-failed" so the + // template can show "scanning isn't applied to this artifact" instead of a + // retry hint. + if scanRecord.Status == atproto.ScanStatusSkipped { + return vulnDetailsData{ + Summary: summary, + ScannedAt: scanRecord.ScannedAt, + Status: scanRecord.Status, + Reason: scanRecord.Reason, + } + } + // Fetch the vulnerability report blob if scanRecord.VulnReportBlob == nil || scanRecord.VulnReportBlob.Ref.String() == "" { return vulnDetailsData{ Summary: summary, ScannedAt: scanRecord.ScannedAt, + Status: scanRecord.Status, + Reason: scanRecord.Reason, Error: "No detailed vulnerability report available. Only summary counts were recorded.", } } @@ -335,22 +352,22 @@ func FetchVulnDetails(ctx context.Context, holdEndpoint, digest string) vulnDeta blobReq, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil) if err != nil { - return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to build blob request"} + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Status: scanRecord.Status, Error: "Failed to build blob request"} } blobResp, err := http.DefaultClient.Do(blobReq) if err != nil { - return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to fetch vulnerability report"} + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Status: scanRecord.Status, Error: "Failed to fetch vulnerability report"} } defer blobResp.Body.Close() if blobResp.StatusCode != http.StatusOK { - return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Vulnerability report not accessible"} + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Status: scanRecord.Status, Error: "Vulnerability report not accessible"} } var report grypeReport if err := json.NewDecoder(blobResp.Body).Decode(&report); err != nil { - return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to parse vulnerability report"} + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Status: scanRecord.Status, Error: "Failed to parse vulnerability report"} } matches := make([]vulnMatch, 0, len(report.Matches)) @@ -390,6 +407,7 @@ func FetchVulnDetails(ctx context.Context, holdEndpoint, digest string) vulnDeta return vulnDetailsData{ Matches: matches, Summary: summary, + Status: scanRecord.Status, ScannedAt: scanRecord.ScannedAt, Digest: digest, HoldEndpoint: holdEndpoint, diff --git a/pkg/appview/holdclient/helm_config.go b/pkg/appview/holdclient/helm_config.go new file mode 100644 index 0000000..5a8d3f2 --- /dev/null +++ b/pkg/appview/holdclient/helm_config.go @@ -0,0 +1,148 @@ +package holdclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "atcr.io/pkg/atproto" +) + +// HelmChartMeta is the parsed Chart.yaml-equivalent metadata extracted from a +// helm chart's OCI config blob (media type +// application/vnd.cncf.helm.config.v1+json). The helm config blob is Chart.yaml +// rendered as JSON, so the field names mirror Chart.yaml. +type HelmChartMeta struct { + Name string + Version string + AppVersion string + Type string // "application" | "library" (empty in older charts; treat as "application") + Description string + KubeVersion string + Home string + Icon string + Sources []string + Keywords []string + Maintainers []HelmMaintainer + Dependencies []HelmDependency + Annotations map[string]string + Deprecated bool +} + +type HelmMaintainer struct { + Name string + Email string + URL string +} + +type HelmDependency struct { + Name string + Version string + Repository string + Alias string + Condition string +} + +// helmConfigJSON matches the on-the-wire shape of a helm OCI config blob. +// Field names track Chart.yaml's JSON form (lowerCamelCase for some fields, +// kebab-case for none — helm's CLI marshals Chart.yaml struct directly). +type helmConfigJSON struct { + Name string `json:"name"` + Version string `json:"version"` + AppVersion string `json:"appVersion"` + Type string `json:"type"` + Description string `json:"description"` + KubeVersion string `json:"kubeVersion"` + Home string `json:"home"` + Icon string `json:"icon"` + Sources []string `json:"sources"` + Keywords []string `json:"keywords"` + Maintainers []helmMaintainerJSON `json:"maintainers"` + Dependencies []helmDependencyJSON `json:"dependencies"` + Annotations map[string]string `json:"annotations"` + Deprecated bool `json:"deprecated"` +} + +type helmMaintainerJSON struct { + Name string `json:"name"` + Email string `json:"email"` + URL string `json:"url"` +} + +type helmDependencyJSON struct { + Name string `json:"name"` + Version string `json:"version"` + Repository string `json:"repository"` + Alias string `json:"alias"` + Condition string `json:"condition"` +} + +// FetchHelmChartMeta fetches a helm chart's config blob from the hold and +// parses it as Chart.yaml metadata. Uses the same getImageConfig XRPC as +// FetchImageConfig but applies a helm-specific schema to the JSON. +func FetchHelmChartMeta(ctx context.Context, holdURL, manifestDigest string) (*HelmChartMeta, error) { + reqURL := fmt.Sprintf("%s%s?digest=%s", + strings.TrimSuffix(holdURL, "/"), + atproto.HoldGetImageConfig, + url.QueryEscape(manifestDigest), + ) + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch helm chart config: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hold returned status %d for %s", resp.StatusCode, reqURL) + } + + var record struct { + ConfigJSON string `json:"configJson"` + } + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { + return nil, fmt.Errorf("parse image config response: %w", err) + } + + var raw helmConfigJSON + if err := json.Unmarshal([]byte(record.ConfigJSON), &raw); err != nil { + return nil, fmt.Errorf("parse helm chart config JSON: %w", err) + } + + meta := &HelmChartMeta{ + Name: raw.Name, + Version: raw.Version, + AppVersion: raw.AppVersion, + Type: raw.Type, + Description: raw.Description, + KubeVersion: raw.KubeVersion, + Home: raw.Home, + Icon: raw.Icon, + Sources: raw.Sources, + Keywords: raw.Keywords, + Annotations: raw.Annotations, + Deprecated: raw.Deprecated, + } + if meta.Type == "" { + meta.Type = "application" + } + for _, m := range raw.Maintainers { + meta.Maintainers = append(meta.Maintainers, HelmMaintainer(m)) + } + for _, d := range raw.Dependencies { + meta.Dependencies = append(meta.Dependencies, HelmDependency(d)) + } + return meta, nil +} diff --git a/pkg/appview/public/js/bundle.min.js b/pkg/appview/public/js/bundle.min.js index e694bbe..a28d4b3 100644 --- a/pkg/appview/public/js/bundle.min.js +++ b/pkg/appview/public/js/bundle.min.js @@ -1,6 +1,6 @@ -var Le=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var s=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return s&&(s==="*"||s.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let s=parseHTML(e);takeChildrenFor(r,s.body),r.title=s.title}else if(n==="body"){r=new DocumentFragment;let s=parseHTML(t);takeChildrenFor(r,s.body),r.title=s.title}else{let s=parseHTML('");r=s.querySelector("template").content,r.title=s.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(s=>s.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let i=0,a=0;for(let l=0;l"&&i--}a0;){let i=normalizeSelector(r.shift()),a;i.indexOf("closest ")===0?a=closest(asElement(e),normalizeSelector(i.slice(8))):i.indexOf("find ")===0?a=find(asParentNode(e),normalizeSelector(i.slice(5))):i==="next"||i==="nextElementSibling"?a=asElement(e).nextElementSibling:i.indexOf("next ")===0?a=scanForwardQuery(e,normalizeSelector(i.slice(5)),!!n):i==="previous"||i==="previousElementSibling"?a=asElement(e).previousElementSibling:i.indexOf("previous ")===0?a=scanBackwardsQuery(e,normalizeSelector(i.slice(9)),!!n):i==="document"?a=document:i==="window"?a=window:i==="body"?a=document.body:i==="root"?a=getRootNode(e,!!n):i==="host"?a=e.getRootNode().host:s.push(i),a&&o.push(a)}if(s.length>0){let i=s.join(","),a=asParentNode(getRootNode(e,!!n));o.push(...toArray(a.querySelectorAll(i)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o=0;o--){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return s}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let s=processEventArgs(e,t,n,r);s.target.addEventListener(s.event,s.listener,s.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let s=asElement(getClosestMatch(e,function(i){return i!==e&&hasAttribute(asElement(i),t)}));s&&r.push(...findAttributeTargets(s,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r0?(s=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):s=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let i=querySelectorAllExt(r,o,!1);return i.length?(forEach(i,function(a){let l,c=t.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(c),isInlineSwap(s,a)||(l=asParentNode(c));let d={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",d)&&(a=d.target,d.shouldSwap&&(handlePreservedElements(l),swapWithStyle(s,a,a,l,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",d)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","
"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let s=o.replace("'","\\'"),i=r.tagName.replace(":","\\:"),a=asParentNode(e),l=a&&a.querySelector(i+"[id='"+s+"']");if(l&&l!==a){let c=r.cloneNode();cloneAttributes(r,l),n.tasks.push(function(){cloneAttributes(r,c)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n0}function swap(e,t,n,r){r||(r={});let o=null,s=null,i=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),d=document.activeElement,u={};u={elt:d,start:d?d.selectionStart:null,end:d?d.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(f.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let g=r.selectOOB.split(",");for(let p=0;p0?getWindow().setTimeout(m,n.settleDelay):m()},a=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(a=n.transition);let l=r.contextElement||getDocument();if(a&&triggerEvent(l,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let c=new Promise(function(u,f){o=u,s=f}),d=i;i=function(){document.startViewTransition(function(){return d(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(i,n.swapDelay):i()}catch(c){throw triggerErrorEvent(l,"htmx:swapError",r.eventInfo),maybeCall(s),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let s in o)if(o.hasOwnProperty(s)){let i=o[s];isRawObject(i)?n=i.target!==void 0?i.target:n:i={value:i},triggerEvent(n,s,i)}}else{let o=r.split(",");for(let s=0;s0;){let i=t[0];if(i==="]"){if(r--,r===0){s===null&&(o=o+"true"),t.shift(),o+=")})";try{let a=maybeEval(e,function(){return Function(o)()},function(){return!0});return a.source=o,a}catch(a){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:a,source:o}),null}}}else i==="["&&r++;isPossibleRelativeReference(i,s,n)?o+="(("+n+"."+i+") ? ("+n+"."+i+") : (window."+i+"))":o=o+i,s=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let a=o.length,l=consumeUntil(o,/[,\[\s]/);if(l!=="")if(l==="every"){let c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var s=maybeGenerateConditional(e,o,"event");s&&(c.eventFilter=s),r.push(c)}else{let c={trigger:l};var s=maybeGenerateConditional(e,o,"event");for(s&&(c.eventFilter=s),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let u=o.shift();if(u==="changed")c.changed=!0;else if(u==="once")c.once=!0;else if(u==="consume")c.consume=!0;else if(u==="delay"&&o[0]===":")o.shift(),c.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(u==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var i=consumeCSSSelector(o);else{var i=consumeUntil(o,WHITESPACE_OR_COMMA);if(i==="closest"||i==="find"||i==="next"||i==="previous"){o.shift();let m=consumeCSSSelector(o);m.length>0&&(i+=" "+m)}}c.from=i}else u==="target"&&o[0]===":"?(o.shift(),c.target=consumeCSSSelector(o)):u==="throttle"&&o[0]===":"?(o.shift(),c.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):u==="queue"&&o[0]===":"?(o.shift(),c.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):u==="root"&&o[0]===":"?(o.shift(),c[u]=consumeCSSSelector(o)):u==="threshold"&&o[0]===":"?(o.shift(),c[u]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(c)}o.length===a&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let s=getRawAttribute(e,"method");r=s?s.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(s){addEventListener(e,function(i,a){let l=asElement(i);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(r,o,l,a)},t,s,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let s=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:s}),!0}return!1}function addEventListener(e,t,n,r,o){let s=getInternalData(e),i;r.from?i=querySelectorAllExt(e,r.from):i=[e],r.changed&&("lastValue"in s||(s.lastValue=new WeakMap),i.forEach(function(a){s.lastValue.has(r)||s.lastValue.set(r,new WeakMap),s.lastValue.get(r).set(a,a.value)})),forEach(i,function(a){let l=function(c){if(!bodyContains(e)){a.removeEventListener(r.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,a))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let d=getInternalData(c);if(d.triggerSpec=r,d.handledFor==null&&(d.handledFor=[]),d.handledFor.indexOf(e)<0){if(d.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(s.triggeredOnce)return;s.triggeredOnce=!0}if(r.changed){let u=c.target,f=u.value,m=s.lastValue.get(r);if(m.has(u)&&m.get(u)===f)return;m.set(u,f)}if(s.delayed&&clearTimeout(s.delayed),s.throttle)return;r.throttle>0?s.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),s.throttle=getWindow().setTimeout(function(){s.throttle=null},r.throttle)):r.delay>0?s.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,c)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,c))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:l,on:a}),a.addEventListener(r.trigger,l)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let s=getAttributeValue(e,"hx-"+o);r=!0,t.path=s,t.verb=o,n.forEach(function(i){addTriggerHandler(e,i,t,function(a,l){let c=asElement(a);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,s,c,l)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(i){for(let a=0;a0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r", "+s).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,s=function(i){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,i))})};e.addEventListener(t,s),r.onHandlers.push({event:t,listener:s})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;thtmx.config.historyCacheSize;)s.shift();for(;s.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(s));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:s}),s.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;nt.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let s=getRawAttribute(r,"name");addValueToFormData(s,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(s){e.indexOf(s)>=0?removeValueFromFormData(s.name,getValueFromInput(s),t):e.push(s),o&&validateElement(s,n)}),new FormData(r).forEach(function(s,i){s instanceof File&&s.name===""||addValueToFormData(i,s,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,s=[],i=getInternalData(e);i.lastButtonClicked&&!bodyContains(i.lastButtonClicked)&&(i.lastButtonClicked=null);let a=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(i.lastButtonClicked&&(a=a&&i.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,s,getRelatedForm(e),a),processInputValue(n,r,s,e,a),i.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=i.lastButtonClicked||e,d=getRawAttribute(c,"name");addValueToFormData(d,c.value,o)}let l=findAttributeTargets(e,"hx-include");return forEach(l,function(c){processInputValue(n,r,s,asElement(c),a),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(d){processInputValue(n,r,s,d,a)})}),overrideFormData(r,o),{errors:s,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(s){r.append(o,s)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let i=splitOnWhitespace(n);if(i.length>0)for(let a=0;a0?o.join(":"):null;r.scroll=d,r.scrollTarget=s}else if(l.indexOf("show:")===0){var o=l.slice(5).split(":");let u=o.pop();var s=o.length>0?o.join(":"):null;r.show=u,r.showTarget=s}else if(l.indexOf("focus-scroll:")===0){let c=l.slice(13);r.focusScroll=c=="true"}else a==0?r.swapStyle=l:logError("Unknown modifier in hx-swap: "+l)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let i=t.showTarget;t.showTarget==="window"&&(i="body"),o=asElement(querySelectorExt(n,i))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let s=getAttributeValue(e,t);if(s){let i=s.trim(),a=n;if(i==="unset")return null;i.indexOf("javascript:")===0?(i=i.slice(11),a=!0):i.indexOf("js:")===0&&(i=i.slice(3),a=!0),i.indexOf("{")!==0&&(i="{"+i+"}");let l;a?l=maybeEval(e,function(){return o?Function("event","return ("+i+")").call(e,o):Function("return ("+i+")").call(e)},{}):l=parseJSON(i);for(let c in l)l.hasOwnProperty(c)&&r[c]==null&&(r[c]=l[c])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),s=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!s?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:s},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(s){r.push(s),e.append(t,s)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(s){e.append(t,s)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,s){return r[o]=s,e.delete(t),r.forEach(function(i){e.append(t,i)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,s){let i=null,a=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var l=new Promise(function(E,x){i=E,a=x});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,d=o.select||null;if(!bodyContains(n))return maybeCall(i),l;let u=o.targetOverride||asElement(getTarget(n));if(u==null||u==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(a),l;let f=getInternalData(n),m=f.lastButtonClicked;if(m){let E=getRawAttribute(m,"formaction");E!=null&&(t=E);let x=getRawAttribute(m,"formmethod");if(x!=null)if(VERBS.includes(x.toLowerCase()))e=x;else return maybeCall(i),l}let h=getClosestAttributeValue(n,"hx-confirm");if(s===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(L){return issueAjaxRequest(e,t,n,r,o,!!L)},question:h})===!1)return maybeCall(i),l;let g=n,p=getClosestAttributeValue(n,"hx-sync"),y=null,w=!1;if(p){let E=p.split(":"),x=E[0].trim();if(x==="this"?g=findThisElement(n,"hx-sync"):g=asElement(querySelectorExt(n,x)),p=(E[1]||"drop").trim(),f=getInternalData(g),p==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(i),l;if(p==="abort"){if(f.xhr)return maybeCall(i),l;w=!0}else p==="replace"?triggerEvent(g,"htmx:abort"):p.indexOf("queue")===0&&(y=(p.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(g,"htmx:abort");else{if(y==null){if(r){let E=getInternalData(r);E&&E.triggerSpec&&E.triggerSpec.queue&&(y=E.triggerSpec.queue)}y==null&&(y="last")}return f.queuedRequests==null&&(f.queuedRequests=[]),y==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(i),l}let b=new XMLHttpRequest;f.xhr=b,f.abortable=w;let v=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},te=getClosestAttributeValue(n,"hx-prompt");if(te){var V=prompt(te);if(V===null||!triggerEvent(n,"htmx:prompt",{prompt:V,target:u}))return maybeCall(i),v(),l}if(h&&!s&&!confirm(h))return maybeCall(i),v(),l;let H=getHeaders(n,u,V);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let ne=getInputValues(n,e),N=ne.errors,re=ne.formData;o.values&&overrideFormData(re,formDataFromObject(o.values));let Se=formDataFromObject(getExpressionVars(n,r)),j=overrideFormData(re,Se),R=filterValues(j,n);htmx.config.getCacheBusterParam&&e==="get"&&R.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let W=getValuesForElement(n,"hx-request"),oe=getInternalData(n).boosted,P=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,C={boosted:oe,useUrlParams:P,formData:R,parameters:formDataProxy(R),unfilteredFormData:j,unfilteredParameters:formDataProxy(j),headers:H,elt:n,target:u,verb:e,errors:N,withCredentials:o.credentials||W.credentials||htmx.config.withCredentials,timeout:o.timeout||W.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",C))return maybeCall(i),v(),l;if(t=C.path,e=C.verb,H=C.headers,R=formDataFromObject(C.parameters),N=C.errors,P=C.useUrlParams,N&&N.length>0)return triggerEvent(n,"htmx:validation:halted",C),maybeCall(i),v(),l;let se=t.split("#"),Ce=se[0],X=se[1],A=t;if(P&&(A=Ce,!R.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(R),X&&(A+="#"+X))),!verifyPath(n,A,C))return triggerErrorEvent(n,"htmx:invalidPath",C),maybeCall(a),v(),l;if(b.open(e.toUpperCase(),A,!0),b.overrideMimeType("text/html"),b.withCredentials=C.withCredentials,b.timeout=C.timeout,!W.noHeaders){for(let E in H)if(H.hasOwnProperty(E)){let x=H[E];safelySetHeaderValue(b,E,x)}}let T={xhr:b,target:u,requestConfig:C,etc:o,boosted:oe,select:d,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:X}};if(b.onload=function(){try{let E=hierarchyForElt(n);if(T.pathInfo.responsePath=getPathFromResponse(b),c(n,T),T.keepIndicators!==!0&&removeRequestIndicators(F,B),triggerEvent(n,"htmx:afterRequest",T),triggerEvent(n,"htmx:afterOnLoad",T),!bodyContains(n)){let x=null;for(;E.length>0&&x==null;){let L=E.shift();bodyContains(L)&&(x=L)}x&&(triggerEvent(x,"htmx:afterRequest",T),triggerEvent(x,"htmx:afterOnLoad",T))}maybeCall(i)}catch(E){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:E},T)),E}finally{v()}},b.onerror=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:sendError",T),maybeCall(a),v()},b.onabort=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:sendAbort",T),maybeCall(a),v()},b.ontimeout=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:timeout",T),maybeCall(a),v()},!triggerEvent(n,"htmx:beforeRequest",T))return maybeCall(i),v(),l;var F=addRequestIndicatorClasses(n),B=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(E){forEach([b,b.upload],function(x){x.addEventListener(E,function(L){triggerEvent(n,"htmx:xhr:"+E,{lengthComputable:L.lengthComputable,loaded:L.loaded,total:L.total})})})}),triggerEvent(n,"htmx:beforeSend",T);let Ae=P?null:encodeParamsForBody(b,n,R);return b.send(Ae),l}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let s=t.pathInfo.finalRequestPath,i=t.pathInfo.responsePath,a=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),l=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,d=null,u=null;return a?(d="push",u=a):l?(d="replace",u=l):c&&(d="push",u=i||s),u?u==="false"?{}:(u==="true"&&(u=i||s),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:d,path:u}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,s=getInternalData(o);s&&s.xhr&&s.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),O=Le;(function(){let e;O.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(i,a){Object.hasOwn(o,a)?(Array.isArray(o[a])||(o[a]=[o[a]]),o[a].push(i)):o[a]=i});let s=e.getExpressionVars(r);return Object.keys(o).forEach(function(i){o[i]=Object.hasOwn(s,i)?s[i]:o[i]}),JSON.stringify(o)}})})();var ie="https://typeahead.waow.tech",le="https://public.api.bsky.app",Ie="/xrpc/app.bsky.actor.searchActorsTypeahead",He="/xrpc/app.bsky.actor.getProfiles";var Re="atcr_recent_handles",ce="atcr_recent_profile_cache";var $=class{constructor(t){this.input=t,this.container=t.closest(".sailor-typeahead")||t.parentElement,this.dropdown=null,this.selectedCard=null,this.actors=[],this.currentItems=[],this.mode="hidden",this.focusIndex=-1,this.debounceTimer=null,this.requestSeq=0,this.primaryUnhealthyUntil=0,this.lastPrefetchPrefix="",this.lastPrefetchAt=0,this.createDropdown(),this.bindEvents(),this.input.value.trim().length===0&&this.showRecent()}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="sailor-typeahead-dropdown",this.dropdown.setAttribute("role","listbox"),this.dropdown.style.display="none",this.input.insertAdjacentElement("afterend",this.dropdown)}bindEvents(){this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hide()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&this.selectedCard&&this.clearSelection()})}handleFocus(){this.input.value.trim().length===0&&this.showRecent()}handleInput(){let t=this.input.value.trim();if(t.length===0){this.showRecent();return}if(t.length>=2&&t.length<4){this.hide(),this.schedulePrefetch(t);return}if(t.length>=4){this.scheduleSearch(t);return}this.hide()}schedulePrefetch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runPrefetch(t),150)}scheduleSearch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runSearch(t),150)}async runPrefetch(t){let n=Date.now();if(!(t===this.lastPrefetchPrefix&&n-this.lastPrefetchAt<1e4)&&!(n=this.primaryUnhealthyUntil)try{r=await z(ie,t,1500)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}if(r===null)try{r=await z(le,t,1500)}catch{r=[]}n===this.requestSeq&&(this.actors=r||[],this.focusIndex=-1,this.renderResults())}renderResults(){if(this.mode="results",this.dropdown.innerHTML="",this.currentItems=[],this.actors.length===0){this.hide();return}this.actors.forEach((t,n)=>{this.currentItems.push(t),this.dropdown.appendChild(this.buildActorRow(t,n))}),this.dropdown.style.display="block"}buildActorRow(t,n){let r=document.createElement("div");r.className="sailor-typeahead-item",r.setAttribute("role","option"),r.setAttribute("aria-selected","false"),r.dataset.index=String(n),r.dataset.handle=t.handle;let o=document.createElement("div");if(o.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",l.loading="lazy",o.appendChild(l)}let s=document.createElement("div");s.className="sailor-typeahead-text";let i=t.displayName&&t.displayName!==t.handle;if(i){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,s.appendChild(l)}let a=document.createElement("div");return a.className=i?"sailor-typeahead-handle":"sailor-typeahead-name",a.textContent="@"+t.handle,s.appendChild(a),r.append(o,s),r.addEventListener("mousedown",l=>{l.preventDefault(),this.select(t)}),r}showRecent(){let t=Oe();if(t.length===0){this.hide();return}this.mode="recent",this.focusIndex=-1,this.renderRecent(t),this.enrichRecent(t)}renderRecent(t){let n=_();this.dropdown.innerHTML="",this.currentItems=[];let r=document.createElement("div");r.className="sailor-typeahead-header",r.textContent="Recent accounts",this.dropdown.appendChild(r),t.forEach((o,s)=>{let i=n[o]?.profile||{handle:o};this.currentItems.push(i),this.dropdown.appendChild(this.buildActorRow(i,s))}),this.dropdown.style.display="block"}async enrichRecent(t){let n=_(),r=Date.now(),o=t.filter(a=>{let l=n[a];return!l||r-l.ts>864e5});if(o.length===0)return;let s=await De(o);if(s.length===0)return;let i=_();s.forEach(a=>{i[a.handle]={ts:r,profile:{handle:a.handle,displayName:a.displayName,avatar:a.avatar}}}),ae(i),this.mode==="recent"&&this.renderRecent(t)}hide(){this.mode="hidden",this.focusIndex=-1,this.dropdown.style.display="none"}select(t){if(typeof t=="string"&&(t={handle:t}),this.input.value=t.handle,this.hide(),this.showSelectedCard(t),t.handle){let n=_();n[t.handle]={ts:Date.now(),profile:{handle:t.handle,displayName:t.displayName,avatar:t.avatar}},ae(n)}}showSelectedCard(t){this.clearSelectedCard();let n=document.createElement("div");n.className="sailor-typeahead-selected";let r=document.createElement("div");if(r.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",r.appendChild(l)}let o=document.createElement("div");o.className="sailor-typeahead-text";let s=t.displayName&&t.displayName!==t.handle;if(s){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,o.appendChild(l)}let i=document.createElement("div");i.className=s?"sailor-typeahead-handle":"sailor-typeahead-name",i.textContent="@"+t.handle,o.appendChild(i);let a=document.createElement("button");a.type="button",a.className="sailor-typeahead-clear",a.setAttribute("aria-label","Change account"),a.innerHTML="×",a.addEventListener("click",()=>this.clearSelection()),n.append(r,o,a),this.input.style.display="none",this.input.insertAdjacentElement("beforebegin",n),this.selectedCard=n}clearSelectedCard(){this.selectedCard&&(this.selectedCard.remove(),this.selectedCard=null)}clearSelection(){this.clearSelectedCard(),this.input.style.display="",this.input.value="",this.input.focus(),this.showRecent()}handleKeydown(t){if(this.mode==="hidden")return;let n=this.dropdown.querySelectorAll(".sailor-typeahead-item");n.length!==0&&(t.key==="ArrowDown"?(t.preventDefault(),this.focusIndex=(this.focusIndex+1)%n.length,this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.focusIndex=this.focusIndex<=0?n.length-1:this.focusIndex-1,this.updateFocus(n)):t.key==="Enter"?this.focusIndex>=0&&this.currentItems[this.focusIndex]&&(t.preventDefault(),this.select(this.currentItems[this.focusIndex])):t.key==="Escape"?this.hide():t.key==="Tab"&&this.focusIndex===-1&&n.length>0&&(t.preventDefault(),this.focusIndex=0,this.updateFocus(n)))}updateFocus(t){t.forEach((n,r)=>{let o=r===this.focusIndex;n.classList.toggle("focused",o),n.setAttribute("aria-selected",o?"true":"false"),o&&n.scrollIntoView({block:"nearest"})})}destroy(){this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null)}};async function z(e,t,n){let r=new URL(Ie,e);r.searchParams.set("q",t),r.searchParams.set("limit",String(8));let o=new AbortController,s=setTimeout(()=>o.abort(),n);try{let i=await fetch(r,{signal:o.signal});if(!i.ok)throw new Error("HTTP "+i.status);let a=await i.json();return Array.isArray(a.actors)?a.actors:[]}finally{clearTimeout(s)}}async function De(e){if(e.length===0)return[];let t=new URL(He,le);e.forEach(o=>t.searchParams.append("actors",o));let n=new AbortController,r=setTimeout(()=>n.abort(),3e3);try{let o=await fetch(t,{signal:n.signal});if(!o.ok)return[];let s=await o.json();return Array.isArray(s.profiles)?s.profiles:[]}catch{return[]}finally{clearTimeout(r)}}function _(){try{return JSON.parse(localStorage.getItem(ce)||"{}")}catch{return{}}}function ae(e){try{localStorage.setItem(ce,JSON.stringify(e))}catch{}}function Oe(){try{let e=localStorage.getItem(Re);return e?JSON.parse(e):[]}catch{return[]}}var I=null;function ue(){let e=document.getElementById("handle");e&&(I&&I.input===e||(I&&I.destroy(),I=new $(e)))}document.addEventListener("DOMContentLoaded",ue);document.body.addEventListener("htmx:afterSettle",ue);document.body.addEventListener("htmx:beforeSwap",()=>{I&&!document.contains(I.input)&&(I.destroy(),I=null)});function Y(e){try{return localStorage.getItem(e)}catch{return null}}function K(e,t){try{localStorage.setItem(e,t)}catch{}}function me(){return Y("theme")||"system"}function Me(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function G(){let e=me(),n=Me(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),ke(e)}function ge(e){K("theme",e),G(),qe()}function ke(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e;n.setAttribute("aria-checked",r?"true":"false");let o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function qe(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}document.addEventListener("DOMContentLoaded",()=>{document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");if(!t)return;let n=()=>e.setAttribute("aria-expanded",t.open?"true":"false");n(),t.addEventListener("toggle",n)})});window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{me()==="system"&&G()});function pe(e,t){if(!e)return;let n=e.querySelector(".nav-search-form"),r=e.querySelector('button[aria-controls="nav-search-form"]');e.classList.toggle("expanded",t),n&&(t?n.removeAttribute("inert"):n.setAttribute("inert","")),r&&r.setAttribute("aria-expanded",t?"true":"false")}function Ne(){let e=document.querySelector(".nav-search-wrapper");if(!e)return;let t=!e.classList.contains("expanded");if(pe(e,t),t){let n=document.getElementById("nav-search-input");n&&n.focus()}}function de(){let e=document.querySelector(".nav-search-wrapper");if(pe(e,!1),e){let t=e.querySelector('[aria-controls="nav-search-form"]');t&&t.focus()}}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{if(n.key==="Escape"&&e.classList.contains("expanded")&&de(),n.key==="/"&&!e.classList.contains("expanded")){let r=n.target.tagName;if(r==="INPUT"||r==="TEXTAREA"||n.target.isContentEditable)return;n.preventDefault(),e.classList.add("expanded"),t.focus()}}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&de()}))});function J(e,t){let n=()=>{if(!t||!document.contains(t))return;let r=t.innerHTML;t.innerHTML=' Copied!',setTimeout(()=>{document.contains(t)&&(t.innerHTML=r)},2e3)};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(e).then(n).catch(r=>{console.error("Clipboard API failed, falling back:",r),fe(e)?n():S("Copy failed \u2014 check browser permissions","error")});return}fe(e)?n():S("Copy failed \u2014 select the text and copy manually","error")}function fe(e){let t=document.createElement("textarea");t.value=e,t.setAttribute("readonly",""),t.setAttribute("aria-hidden","true"),t.style.position="fixed",t.style.top="0",t.style.left="0",t.style.width="1px",t.style.height="1px",t.style.opacity="0",t.style.pointerEvents="none",document.body.appendChild(t);let n=!1;try{t.focus(),t.select(),t.setSelectionRange(0,e.length),n=document.execCommand&&document.execCommand("copy")}catch{n=!1}return document.body.removeChild(t),!!n}function Pe(e){let t=s=>{let i=(s==null?"":String(s)).trim();return/[",\n\r]/.test(i)?'"'+i.replace(/"/g,'""')+'"':i},n=s=>Array.from(s).map(i=>t(i.textContent)).join(","),r=[],o=e.querySelector("thead tr");return o&&r.push(n(o.querySelectorAll("th,td"))),e.querySelectorAll("tbody tr").forEach(s=>{r.push(n(s.querySelectorAll("td,th")))}),r.join(` -`)}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-copy-csv]");if(t){let r=t.closest("[data-csv-section]"),o=r&&r.querySelector("table");o&&J(Pe(o),t);return}let n=e.target.closest("button[data-cmd]");if(n){J(n.getAttribute("data-cmd"),n);return}})});function Fe(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let s=Math.floor(t/o);if(s>=1)return s===1?`1 ${r} ago`:`${s} ${r}s ago`}return"just now"}function U(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=Fe(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{U(),G(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{ge(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",U);var M=null;function ye(){M===null&&(M=setInterval(U,6e4))}function Be(){M!==null&&(clearInterval(M),M=null)}document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?Be():(U(),ye())});ye();async function _e(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();Ue(e,t,n,o.tags)}else if(r.ok)Ee(n);else{let o=await r.text();S(`Failed to delete manifest: ${o||r.status}`,"error")}}catch(r){console.error("Error deleting manifest:",r),S(`Error deleting manifest: ${r.message}`,"error")}}function Ue(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),s=document.getElementById("manifest-delete-tags"),i=document.getElementById("confirm-manifest-delete-btn");s.innerHTML="",r.forEach(a=>{let l=document.createElement("li");l.textContent=a,s.appendChild(l)}),i.onclick=()=>Ve(e,t,n),Z(o)}function Q(){k(document.getElementById("manifest-delete-modal"))}async function Ve(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let s=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(s.ok)Q(),Ee(n),location.reload();else{let i=await s.text();S(`Failed to delete manifest: ${i||s.status}`,"error"),r.disabled=!1,r.textContent=o}}catch(s){console.error("Error deleting manifest:",s),S(`Error deleting manifest: ${s.message}`,"error"),r.disabled=!1,r.textContent=o}}async function je(e){let t=document.getElementById("confirm-untagged-delete-btn"),n=t.textContent;try{t.disabled=!0,t.textContent="Deleting...";let r=await fetch("/api/manifests/untagged",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e})}),o=await r.json();r.ok?(k(document.getElementById("untagged-delete-modal")),S(`Deleted ${o.deleted} untagged manifest(s)`,"success"),o.deleted>0&&location.reload(),t.disabled=!1,t.textContent=n):(S(`Failed to delete untagged manifests: ${o.error||"Unknown error"}`,"error"),t.disabled=!1,t.textContent=n)}catch(r){console.error("Error deleting untagged manifests:",r),S(`Error: ${r.message}`,"error"),t.disabled=!1,t.textContent=n}}function Ee(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&Q()})});var q=new WeakMap;function Z(e,t){if(e&&(q.set(e,t||document.activeElement),typeof e.showModal=="function")){e.open&&(e.open=!1);try{e.showModal()}catch{}}}function k(e,{remove:t=!1}={}){if(!e)return;let n=q.get(e);if(q.delete(e),typeof e.close=="function"&&e.open)try{e.close()}catch{}t&&e.remove(),ve(n)}function ve(e){e&&typeof e.focus=="function"&&document.contains(e)&&e.focus()}document.addEventListener("close",e=>{let t=e.target;if(!(t instanceof HTMLDialogElement))return;let n=q.get(t);q.delete(t),ve(n)},!0);document.body.addEventListener("htmx:afterSettle",()=>{document.querySelectorAll("dialog.modal-open:not([data-modal-promoted]), dialog[open]:not([data-modal-promoted])").forEach(t=>{t.dataset.modalPromoted="1",Z(t)})});document.addEventListener("change",e=>{let t=e.target.closest("select[data-diff-url]");if(!t)return;let n=t.dataset.diffUrl;n&&(window.location.href=n.replace("__VALUE__",encodeURIComponent(t.value)))});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("pull-cmd-container");if(!e)return;let t=e.dataset.registryUrl,n=e.dataset.ownerHandle,r=e.dataset.repoName,o=e.dataset.tag||"latest",s=e.dataset.isLoggedIn==="true";function i(l){let d=(l==="none"?"":l+" pull ")+t+"/"+n+"/"+r+":"+o,u=document.getElementById("pull-cmd-display");if(!u)return;let f=u.querySelector("code");f&&(f.textContent=d);let m=u.querySelector("[data-cmd]");m&&(m.dataset.cmd=d),s&&window.htmx?window.htmx.ajax("POST","/api/profile/oci-client",{values:{oci_client:l},swap:"none"}):s||K("oci-client",l)}if(!s){let l=Y("oci-client");if(l){let c=document.getElementById("oci-client-switcher");c&&(c.value=l,i(l))}}let a=document.getElementById("oci-client-switcher");a&&a.addEventListener("change",()=>i(a.value))});document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll(".platform-tab[data-platform]");e.length&&e.forEach(t=>{t.addEventListener("click",()=>{e.forEach(r=>{let o=r===t;r.classList.toggle("btn-primary",o),r.classList.toggle("btn-ghost",!o),r.setAttribute("aria-selected",o?"true":"false"),r.setAttribute("tabindex",o?"0":"-1")}),document.querySelectorAll(".platform-content").forEach(r=>{r.classList.add("hidden"),r.setAttribute("hidden","")});let n=document.getElementById(t.dataset.platform+"-content");n&&(n.classList.remove("hidden"),n.removeAttribute("hidden"),t.focus())}),t.addEventListener("keydown",n=>{if(n.key!=="ArrowLeft"&&n.key!=="ArrowRight")return;n.preventDefault();let r=Array.from(e),o=r.indexOf(t);(n.key==="ArrowRight"?r[(o+1)%r.length]:r[(o-1+r.length)%r.length]).click()})})});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form");e&&e.addEventListener("submit",()=>{let t=e.querySelector('button[type="submit"]');!t||t.disabled||(t.disabled=!0,t.innerHTML=' Navigating…')})});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t&&typeof t=="string"&&t.length>0){try{let n="atcr_recent_handles",r=Y(n),o=[];try{o=JSON.parse(r||"[]")}catch{o=[]}Array.isArray(o)||(o=[]),o=o.filter(s=>s!==t),o.unshift(t),o=o.slice(0,5),K(n,JSON.stringify(o))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function he(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=e.querySelectorAll(".carousel-item");if(r.length===0||!r[0])return;let o=null,s=5e3,i=window.matchMedia("(prefers-reduced-motion: reduce)"),a=()=>i.matches?"auto":"smooth",l=0,c=0;function d(){if(!r[0])return;let y=parseFloat(getComputedStyle(e).gap)||24;l=r[0].offsetWidth+y}d(),window.addEventListener("resize",()=>{cancelAnimationFrame(c),c=requestAnimationFrame(d)}),document.body.addEventListener("htmx:afterSettle",y=>{y.target&&y.target.contains&&y.target.contains(e)&&d()});function u(){let y=e.scrollWidth-e.clientWidth;e.scrollLeft>=y-10?e.scrollTo({left:0,behavior:a()}):e.scrollBy({left:l,behavior:a()})}function f(){e.scrollLeft<=10?e.scrollTo({left:e.scrollWidth,behavior:a()}):e.scrollBy({left:-l,behavior:a()})}function m(){o||document.visibilityState!=="hidden"&&(e.scrollWidth<=e.clientWidth+10||i.matches||(o=setInterval(u,s)))}function h(){o&&(clearInterval(o),o=null)}t&&t.addEventListener("click",()=>{h(),f(),m()}),n&&n.addEventListener("click",()=>{h(),u(),m()});let g=document.getElementById("carousel-pause"),p=!1;if(g){let y=g.querySelector(".carousel-pause-icon"),w=g.querySelector(".carousel-play-icon");g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),g.addEventListener("click",()=>{p=!p,p?(h(),g.setAttribute("aria-pressed","true"),g.setAttribute("aria-label","Resume carousel auto-advance"),y&&y.classList.add("hidden"),w&&w.classList.remove("hidden")):(g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),y&&y.classList.remove("hidden"),w&&w.classList.add("hidden"),m())})}e.addEventListener("mouseenter",h),e.addEventListener("mouseleave",()=>{p||m()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?h():p||m()}),m()}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(he,{timeout:2e3}):setTimeout(he,100)});document.body.addEventListener("htmx:responseError",e=>{let t=e.detail&&e.detail.elt;if(t&&t.closest&&t.closest("[data-suppress-htmx-toast]"))return;let n=e.detail&&e.detail.xhr,r=n&&n.getResponseHeader&&n.getResponseHeader("HX-Trigger");if(r&&r.indexOf("toast")!==-1)return;let o=n?n.status:0,s=o===401?"Session expired \u2014 please sign in again":o===403?"Not authorized":o===404?"Not found":o===429?"Too many requests \u2014 please slow down":o>=500?"Server error \u2014 please try again":"Something went wrong";S(s,"error")});document.body.addEventListener("htmx:sendError",e=>{let t=e.detail&&e.detail.elt;t&&t.closest&&t.closest("[data-suppress-htmx-toast]")||S("Network error \u2014 check your connection","error")});document.body.addEventListener("toast",e=>{let t=e&&e.detail||{},n=t.message||t.msg||"";if(!n)return;let r=t.type||"info";S(n,r)});var We=4,Xe=1500;function be(){let e=document.getElementById("toast-container");return e||(e=document.createElement("div"),e.id="toast-container",e.className="toast toast-end toast-bottom z-50",e.setAttribute("aria-live","polite"),e.setAttribute("aria-atomic","false"),document.body&&document.body.appendChild(e),e)}document.addEventListener("DOMContentLoaded",be);function S(e,t){let n=be(),r=(t||"info")+"|"+e,o=Date.now(),s=n.querySelector(`[data-toast-key="${$e(r)}"]`);if(s&&o-Number(s.dataset.toastAt)We;)n.firstElementChild.remove();we(l)}function we(e){e._dismissTimer=setTimeout(()=>{e.style.opacity="0",e._removeTimer=setTimeout(()=>e.remove(),300)},3e3)}function ze(e){clearTimeout(e._dismissTimer),clearTimeout(e._removeTimer),e.style.opacity="",e.dataset.toastAt=String(Date.now()),we(e)}function $e(e){return window.CSS&&CSS.escape?CSS.escape(e):String(e).replace(/[^a-zA-Z0-9_-]/g,t=>"\\"+t)}async function Je(e){try{let t=await fetch(`/api/webhooks/${e}/test`,{method:"POST",credentials:"include"}),n=await t.text();n.includes('class="success"')||t.ok&&!n.includes('class="error"')?S("Test webhook delivered successfully!","success"):S("Test delivery failed \u2014 check the webhook URL","error")}catch{S("Failed to reach server","error")}}(function(){let t={"switch-repo-tab":s=>window.switchRepoTab&&window.switchRepoTab(s.dataset.tab),"switch-editor-tab":s=>window.switchEditorTab&&window.switchEditorTab(s.dataset.tab),"insert-md":s=>window.insertMd&&window.insertMd(s.dataset.mdType),"toggle-editor":s=>window.toggleOverviewEditor&&window.toggleOverviewEditor(s.dataset.show==="true"),"show-modal":s=>Z(document.getElementById(s.dataset.modalId),s),"close-dialog":s=>k(s.closest("dialog")),"remove-closest-dialog":s=>k(s.closest("dialog"),{remove:!0}),"close-manifest-delete-modal":()=>window.closeManifestDeleteModal&&window.closeManifestDeleteModal(),"save-overview":()=>window.saveOverview&&window.saveOverview(),"delete-manifest":s=>window.deleteManifest&&window.deleteManifest(s.dataset.repo,s.dataset.digest,s.dataset.manifestId||""),"delete-untagged":s=>window.deleteUntaggedManifests&&window.deleteUntaggedManifests(s.dataset.repo),copy:s=>window.copyToClipboard&&window.copyToClipboard(s.dataset.copy,s),"toggle-search":()=>window.toggleSearch&&window.toggleSearch(),"switch-settings-tab":s=>window.switchSettingsTab&&window.switchSettingsTab(s.dataset.tab),"test-webhook":s=>window.testWebhook&&window.testWebhook(s.dataset.webhookId),"diff-to":(s,i)=>window.diffToTag&&window.diffToTag(i,s),"modal-backdrop-close":(s,i)=>{i.target===s&&k(s,{remove:!0})}},n={"sort-tags":s=>window.sortTags&&window.sortTags(s.value),"submit-form":s=>s.form&&s.form.requestSubmit()},r={"filter-tags":s=>window.filterTags&&window.filterTags(s.value)};function o(s,i){let a=i.target.closest("[data-action]");if(!a)return;let l=s[a.dataset.action];l&&l(a,i)}document.addEventListener("click",s=>o(t,s)),document.addEventListener("change",s=>o(n,s)),document.addEventListener("input",s=>o(r,s))})();window.setTheme=ge;window.toggleSearch=Ne;window.copyToClipboard=J;window.deleteManifest=_e;window.deleteUntaggedManifests=je;window.closeManifestDeleteModal=Q;window.showToast=S;window.testWebhook=Je;function Ye(){let e=document.getElementById("md-editor");if(!e)return;let t=e.dataset.ownerDid,n=e.dataset.repository;window.toggleOverviewEditor=function(r){document.getElementById("overview-view").classList.toggle("hidden",r),document.getElementById("overview-edit").classList.toggle("hidden",!r),r&&e.focus()},window.switchEditorTab=function(r){if(document.querySelectorAll(".editor-panel").forEach(o=>o.classList.add("hidden")),document.getElementById(r==="write"?"editor-write":"editor-preview").classList.remove("hidden"),document.querySelectorAll(".editor-tab").forEach(o=>{let s=o.dataset.tab===r;o.classList.toggle("border-primary",s),o.classList.toggle("text-primary",s),o.classList.toggle("border-transparent",!s),o.classList.toggle("text-base-content/60",!s)}),r==="preview"){let o=e.value,s=document.getElementById("preview-content");if(!o.trim()){s.innerHTML='

Nothing to preview

';return}s.innerHTML='

Rendering preview…

';let i=new FormData;i.append("markdown",o),fetch("/api/repo-page/preview",{method:"POST",body:i}).then(a=>{if(!a.ok)throw new Error("HTTP "+a.status);return a.text()}).then(a=>{s.innerHTML=a}).catch(()=>{s.innerHTML='

Preview failed. Check your connection and try again.

'})}},window.insertMd=function(r){let o=e.selectionStart,s=e.selectionEnd,i=e.value.substring(o,s),a=e.value.substring(0,o),l=e.value.substring(s),c,d,u;switch(r){case"heading":c="## "+(i||"Heading"),d=o+3,u=o+c.length;break;case"bold":c="**"+(i||"bold text")+"**",d=o+2,u=o+c.length-2;break;case"italic":c="_"+(i||"italic text")+"_",d=o+1,u=o+c.length-1;break;case"link":c="["+(i||"link text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"image":c="!["+(i||"alt text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"ul":c="- "+(i||"list item"),d=o+2,u=o+c.length;break;case"ol":c="1. "+(i||"list item"),d=o+3,u=o+c.length;break;case"code":i&&i.indexOf(` -`)!==-1?(c="```\n"+i+"\n```",d=o+4,u=o+4+i.length):(c="`"+(i||"code")+"`",d=o+1,u=o+c.length-1);break;default:return}e.value=a+c+l,e.focus(),e.selectionStart=d,e.selectionEnd=u},window.saveOverview=function(){let r=document.getElementById("save-overview-btn");r.classList.add("btn-disabled"),r.innerHTML=' Saving...';let o=new FormData;o.append("did",t),o.append("repository",n),o.append("description",e.value),fetch("/api/repo-page",{method:"POST",body:o,headers:{"HX-Request":"true"}}).then(s=>s.ok?s.text():s.text().then(i=>{throw new Error(i)})).then(s=>{document.getElementById("overview-rendered").innerHTML=s,window.toggleOverviewEditor(!1),typeof window.showToast=="function"&&window.showToast("Overview saved","success")}).catch(s=>{typeof window.showToast=="function"&&window.showToast(s.message||"Failed to save","error")}).finally(()=>{r.classList.remove("btn-disabled"),r.innerHTML="Save"})},e.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="s"&&(r.preventDefault(),window.saveOverview())})}window.sortTags=function(e){let t=document.getElementById("tags-list");if(!t)return;let n=Array.from(t.querySelectorAll(".artifact-entry"));n.sort((r,o)=>{switch(e){case"oldest":return parseInt(r.dataset.created)-parseInt(o.dataset.created);case"az":return r.dataset.tag.localeCompare(o.dataset.tag);case"za":return o.dataset.tag.localeCompare(r.dataset.tag);default:return parseInt(o.dataset.created)-parseInt(r.dataset.created)}}),n.forEach(r=>t.appendChild(r))};var D=0;window.filterTags=function(e){D&&cancelAnimationFrame(D),D=requestAnimationFrame(()=>{D=0;let t=e.toLowerCase();document.querySelectorAll("#tags-list .artifact-entry").forEach(n=>{n.style.display=!t||n.dataset.tag.toLowerCase().includes(t)?"":"none"})})};document.body.addEventListener("htmx:beforeSwap",()=>{D&&(cancelAnimationFrame(D),D=0)});function Ke(){if(!document.getElementById("tag-content"))return;let e=["overview","layers","vulns","sbom","artifacts"],t={};function n(i,a){if(t[i]==="loading"||t[i]==="loaded")return;t[i]="loading";let l=document.getElementById(i);if(!l){delete t[i];return}let c=new AbortController,d=setTimeout(()=>c.abort(),1e4);fetch(a,{signal:c.signal}).then(u=>{if(!u.ok)throw new Error("HTTP "+u.status);return u.text()}).then(u=>{t[i]="loaded",document.contains(l)&&(l.innerHTML=u,l.querySelectorAll("script").forEach(f=>{let m=document.createElement("script");m.textContent=f.textContent,f.parentNode.replaceChild(m,f)}),typeof window.htmx<"u"&&window.htmx.process(l))}).catch(u=>{if(delete t[i],!document.contains(l))return;let m=u&&u.name==="AbortError"?"This section took too long to load.":"Couldn't load this section.";l.innerHTML='

'+m+'

'}).finally(()=>clearTimeout(d))}document.body.addEventListener("click",i=>{let a=i.target.closest("[data-retry-section]");if(!a)return;let l=a.getAttribute("data-retry-section"),d={"artifacts-content":o,"layers-content":()=>r("layers"),"vulns-content":()=>r("vulns"),"sbom-content":()=>r("sbom")}[l];if(d){let u=d();u&&n(l,u)}});function r(i){let a=document.getElementById("tag-content");if(!a||!a.dataset)return null;let l=a.dataset.digest,c=a.dataset.owner,d=a.dataset.repo;return!l||!c||!d?null:"/api/digest-content/"+c+"/"+d+"?digest="+encodeURIComponent(l)+"§ion="+i}function o(){let i=document.getElementById("tag-content");if(!i||!i.dataset)return null;let a=i.dataset.owner,l=i.dataset.repo;return!a||!l?null:"/api/repo-tags/"+a+"/"+l}window.diffToTag=function(i,a){i.preventDefault();let l=a.dataset.diffTo,c=document.getElementById("tag-content"),d=document.getElementById("tag-selector");if(!c||!d||!l)return;let u=c.dataset.digest,f=d.value;!u||l===f||(window.location.href="/diff/"+c.dataset.owner+"/"+c.dataset.repo+"?from="+encodeURIComponent(u)+"&to="+encodeURIComponent(l))},window.switchRepoTab=function(i){window._activeRepoTab=i;let a=document.getElementById("tag-content");if(!a)return;a.querySelectorAll(".repo-panel").forEach(d=>d.classList.add("hidden"));let l=document.getElementById("tab-"+i);l&&l.classList.remove("hidden"),a.querySelectorAll(".repo-tab").forEach(d=>{let u=d.dataset.tab===i;d.classList.toggle("border-primary",u),d.classList.toggle("text-primary",u),d.classList.toggle("border-transparent",!u),d.classList.toggle("text-base-content/60",!u),d.setAttribute("aria-selected",u?"true":"false"),d.setAttribute("tabindex",u?"0":"-1")});let c=new URL(window.location);if(c.hash=i,history.replaceState(null,"",c.toString()),i==="artifacts"){let d=o();d&&n("artifacts-content",d)}if(i==="layers"){let d=r("layers");d&&n("layers-content",d)}if(i==="vulns"){let d=r("vulns");d&&n("vulns-content",d)}if(i==="sbom"){let d=r("sbom");d&&n("sbom-content",d)}};function s(){t={},[["artifacts-tab-btn","artifacts-content",o],["layers-tab-btn","layers-content",()=>r("layers")],["vulns-tab-btn","vulns-content",()=>r("vulns")],["sbom-tab-btn","sbom-content",()=>r("sbom")]].forEach(([c,d,u])=>{let f=document.getElementById(c);f&&f.addEventListener("mouseenter",()=>{let m=u();m&&n(d,m)},{once:!0})});let a=document.querySelector('[role="tablist"][aria-label="Repository sections"]');a&&!a.dataset.keyboardBound&&(a.dataset.keyboardBound="1",a.addEventListener("keydown",c=>{let d=Array.from(a.querySelectorAll(".repo-tab")),u=d.indexOf(document.activeElement);if(u===-1)return;let f=-1;switch(c.key){case"ArrowRight":f=(u+1)%d.length;break;case"ArrowLeft":f=(u-1+d.length)%d.length;break;case"Home":f=0;break;case"End":f=d.length-1;break;case"Enter":case" ":c.preventDefault(),window.switchRepoTab(d[u].dataset.tab);return;default:return}c.preventDefault(),d[f].focus()}));let l=window._activeRepoTab||window.location.hash.replace("#","")||"overview";e.indexOf(l)===-1&&(l="overview"),window.switchRepoTab(l)}s(),document.addEventListener("keydown",i=>{if(i.target.tagName==="INPUT"||i.target.tagName==="TEXTAREA"||i.target.tagName==="SELECT"||i.target.isContentEditable||i.ctrlKey||i.metaKey||i.altKey)return;let l={o:"overview",l:"layers",v:"vulns",s:"sbom",a:"artifacts"}[i.key.toLowerCase()];l&&e.indexOf(l)!==-1&&window.switchRepoTab(l)}),document.body.addEventListener("htmx:afterSettle",i=>{i.detail.target&&i.detail.target.id==="tag-content"&&s()})}document.addEventListener("DOMContentLoaded",()=>{Ye(),Ke()});function Ge(){let e=Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')),t=Array.from(document.querySelectorAll(".settings-tab-mobile"));if(!e.length&&!t.length)return;function n(s,i){let a=i==="vertical"?"ArrowUp":"ArrowLeft",l=i==="vertical"?"ArrowDown":"ArrowRight";s.forEach(c=>{c.addEventListener("keydown",d=>{let u=s.indexOf(d.currentTarget);if(u===-1)return;let f=null;d.key===a?f=s[(u-1+s.length)%s.length]:d.key===l?f=s[(u+1)%s.length]:d.key==="Home"?f=s[0]:d.key==="End"&&(f=s[s.length-1]),f&&(d.preventDefault(),f.focus(),f.click())})})}n(e,"vertical"),n(t,"horizontal");function r(){let s=t.find(i=>i.getAttribute("aria-selected")==="true");s&&s.scrollIntoView({inline:"center",block:"nearest"})}r();function o(s){e.forEach(i=>{let a=i.parentElement.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.parentElement.classList.toggle("menu-active",a)}),t.forEach(i=>{let a=i.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.classList.toggle("btn-secondary",a),i.classList.toggle("btn-ghost",!a)}),r()}[...e,...t].forEach(s=>{s.addEventListener("click",()=>o(s.dataset.tab||s.parentElement.dataset.tab))}),document.body.addEventListener("htmx:historyRestore",()=>{let s=location.pathname.match(/^\/settings\/(user|storage|billing|devices|webhooks|advanced)/);s&&o(s[1])})}function Qe(){document.addEventListener("click",function(n){let r=n.target.closest("#delete-account-btn");r&&t(r)});function e(n){let r=document.createElement("div");return r.textContent=n,r.innerHTML}function t(n){let r=n.dataset.clientShortName||"this account",s="DELETE "+(n.dataset.profileHandle||""),i=document.getElementById("delete-pds-records").checked,a=document.createElement("div");a.className="modal modal-open",a.innerHTML=` +var He=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var s=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return s&&(s==="*"||s.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let s=parseHTML(e);takeChildrenFor(r,s.body),r.title=s.title}else if(n==="body"){r=new DocumentFragment;let s=parseHTML(t);takeChildrenFor(r,s.body),r.title=s.title}else{let s=parseHTML('");r=s.querySelector("template").content,r.title=s.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(s=>s.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let i=0,a=0;for(let l=0;l"&&i--}a0;){let i=normalizeSelector(r.shift()),a;i.indexOf("closest ")===0?a=closest(asElement(e),normalizeSelector(i.slice(8))):i.indexOf("find ")===0?a=find(asParentNode(e),normalizeSelector(i.slice(5))):i==="next"||i==="nextElementSibling"?a=asElement(e).nextElementSibling:i.indexOf("next ")===0?a=scanForwardQuery(e,normalizeSelector(i.slice(5)),!!n):i==="previous"||i==="previousElementSibling"?a=asElement(e).previousElementSibling:i.indexOf("previous ")===0?a=scanBackwardsQuery(e,normalizeSelector(i.slice(9)),!!n):i==="document"?a=document:i==="window"?a=window:i==="body"?a=document.body:i==="root"?a=getRootNode(e,!!n):i==="host"?a=e.getRootNode().host:s.push(i),a&&o.push(a)}if(s.length>0){let i=s.join(","),a=asParentNode(getRootNode(e,!!n));o.push(...toArray(a.querySelectorAll(i)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o=0;o--){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return s}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let s=processEventArgs(e,t,n,r);s.target.addEventListener(s.event,s.listener,s.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let s=asElement(getClosestMatch(e,function(i){return i!==e&&hasAttribute(asElement(i),t)}));s&&r.push(...findAttributeTargets(s,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r0?(s=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):s=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let i=querySelectorAllExt(r,o,!1);return i.length?(forEach(i,function(a){let l,c=t.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(c),isInlineSwap(s,a)||(l=asParentNode(c));let u={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",u)&&(a=u.target,u.shouldSwap&&(handlePreservedElements(l),swapWithStyle(s,a,a,l,n),restorePreservedElements()),forEach(n.elts,function(d){triggerEvent(d,"htmx:oobAfterSwap",u)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","
"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let s=o.replace("'","\\'"),i=r.tagName.replace(":","\\:"),a=asParentNode(e),l=a&&a.querySelector(i+"[id='"+s+"']");if(l&&l!==a){let c=r.cloneNode();cloneAttributes(r,l),n.tasks.push(function(){cloneAttributes(r,c)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n0}function swap(e,t,n,r){r||(r={});let o=null,s=null,i=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),u=document.activeElement,d={};d={elt:u,start:u?u.selectionStart:null,end:u?u.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(f.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let g=r.selectOOB.split(",");for(let p=0;p0?getWindow().setTimeout(m,n.settleDelay):m()},a=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(a=n.transition);let l=r.contextElement||getDocument();if(a&&triggerEvent(l,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let c=new Promise(function(d,f){o=d,s=f}),u=i;i=function(){document.startViewTransition(function(){return u(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(i,n.swapDelay):i()}catch(c){throw triggerErrorEvent(l,"htmx:swapError",r.eventInfo),maybeCall(s),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let s in o)if(o.hasOwnProperty(s)){let i=o[s];isRawObject(i)?n=i.target!==void 0?i.target:n:i={value:i},triggerEvent(n,s,i)}}else{let o=r.split(",");for(let s=0;s0;){let i=t[0];if(i==="]"){if(r--,r===0){s===null&&(o=o+"true"),t.shift(),o+=")})";try{let a=maybeEval(e,function(){return Function(o)()},function(){return!0});return a.source=o,a}catch(a){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:a,source:o}),null}}}else i==="["&&r++;isPossibleRelativeReference(i,s,n)?o+="(("+n+"."+i+") ? ("+n+"."+i+") : (window."+i+"))":o=o+i,s=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let a=o.length,l=consumeUntil(o,/[,\[\s]/);if(l!=="")if(l==="every"){let c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var s=maybeGenerateConditional(e,o,"event");s&&(c.eventFilter=s),r.push(c)}else{let c={trigger:l};var s=maybeGenerateConditional(e,o,"event");for(s&&(c.eventFilter=s),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let d=o.shift();if(d==="changed")c.changed=!0;else if(d==="once")c.once=!0;else if(d==="consume")c.consume=!0;else if(d==="delay"&&o[0]===":")o.shift(),c.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(d==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var i=consumeCSSSelector(o);else{var i=consumeUntil(o,WHITESPACE_OR_COMMA);if(i==="closest"||i==="find"||i==="next"||i==="previous"){o.shift();let m=consumeCSSSelector(o);m.length>0&&(i+=" "+m)}}c.from=i}else d==="target"&&o[0]===":"?(o.shift(),c.target=consumeCSSSelector(o)):d==="throttle"&&o[0]===":"?(o.shift(),c.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):d==="queue"&&o[0]===":"?(o.shift(),c.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):d==="root"&&o[0]===":"?(o.shift(),c[d]=consumeCSSSelector(o)):d==="threshold"&&o[0]===":"?(o.shift(),c[d]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(c)}o.length===a&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let s=getRawAttribute(e,"method");r=s?s.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(s){addEventListener(e,function(i,a){let l=asElement(i);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(r,o,l,a)},t,s,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let s=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:s}),!0}return!1}function addEventListener(e,t,n,r,o){let s=getInternalData(e),i;r.from?i=querySelectorAllExt(e,r.from):i=[e],r.changed&&("lastValue"in s||(s.lastValue=new WeakMap),i.forEach(function(a){s.lastValue.has(r)||s.lastValue.set(r,new WeakMap),s.lastValue.get(r).set(a,a.value)})),forEach(i,function(a){let l=function(c){if(!bodyContains(e)){a.removeEventListener(r.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,a))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let u=getInternalData(c);if(u.triggerSpec=r,u.handledFor==null&&(u.handledFor=[]),u.handledFor.indexOf(e)<0){if(u.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(s.triggeredOnce)return;s.triggeredOnce=!0}if(r.changed){let d=c.target,f=d.value,m=s.lastValue.get(r);if(m.has(d)&&m.get(d)===f)return;m.set(d,f)}if(s.delayed&&clearTimeout(s.delayed),s.throttle)return;r.throttle>0?s.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),s.throttle=getWindow().setTimeout(function(){s.throttle=null},r.throttle)):r.delay>0?s.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,c)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,c))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:l,on:a}),a.addEventListener(r.trigger,l)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let s=getAttributeValue(e,"hx-"+o);r=!0,t.path=s,t.verb=o,n.forEach(function(i){addTriggerHandler(e,i,t,function(a,l){let c=asElement(a);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,s,c,l)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(i){for(let a=0;a0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r", "+s).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,s=function(i){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,i))})};e.addEventListener(t,s),r.onHandlers.push({event:t,listener:s})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;thtmx.config.historyCacheSize;)s.shift();for(;s.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(s));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:s}),s.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;nt.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let s=getRawAttribute(r,"name");addValueToFormData(s,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(s){e.indexOf(s)>=0?removeValueFromFormData(s.name,getValueFromInput(s),t):e.push(s),o&&validateElement(s,n)}),new FormData(r).forEach(function(s,i){s instanceof File&&s.name===""||addValueToFormData(i,s,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,s=[],i=getInternalData(e);i.lastButtonClicked&&!bodyContains(i.lastButtonClicked)&&(i.lastButtonClicked=null);let a=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(i.lastButtonClicked&&(a=a&&i.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,s,getRelatedForm(e),a),processInputValue(n,r,s,e,a),i.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=i.lastButtonClicked||e,u=getRawAttribute(c,"name");addValueToFormData(u,c.value,o)}let l=findAttributeTargets(e,"hx-include");return forEach(l,function(c){processInputValue(n,r,s,asElement(c),a),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(u){processInputValue(n,r,s,u,a)})}),overrideFormData(r,o),{errors:s,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(s){r.append(o,s)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let i=splitOnWhitespace(n);if(i.length>0)for(let a=0;a0?o.join(":"):null;r.scroll=u,r.scrollTarget=s}else if(l.indexOf("show:")===0){var o=l.slice(5).split(":");let d=o.pop();var s=o.length>0?o.join(":"):null;r.show=d,r.showTarget=s}else if(l.indexOf("focus-scroll:")===0){let c=l.slice(13);r.focusScroll=c=="true"}else a==0?r.swapStyle=l:logError("Unknown modifier in hx-swap: "+l)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let i=t.showTarget;t.showTarget==="window"&&(i="body"),o=asElement(querySelectorExt(n,i))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let s=getAttributeValue(e,t);if(s){let i=s.trim(),a=n;if(i==="unset")return null;i.indexOf("javascript:")===0?(i=i.slice(11),a=!0):i.indexOf("js:")===0&&(i=i.slice(3),a=!0),i.indexOf("{")!==0&&(i="{"+i+"}");let l;a?l=maybeEval(e,function(){return o?Function("event","return ("+i+")").call(e,o):Function("return ("+i+")").call(e)},{}):l=parseJSON(i);for(let c in l)l.hasOwnProperty(c)&&r[c]==null&&(r[c]=l[c])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),s=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!s?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:s},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(s){r.push(s),e.append(t,s)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(s){e.append(t,s)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,s){return r[o]=s,e.delete(t),r.forEach(function(i){e.append(t,i)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,s){let i=null,a=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var l=new Promise(function(E,x){i=E,a=x});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,u=o.select||null;if(!bodyContains(n))return maybeCall(i),l;let d=o.targetOverride||asElement(getTarget(n));if(d==null||d==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(a),l;let f=getInternalData(n),m=f.lastButtonClicked;if(m){let E=getRawAttribute(m,"formaction");E!=null&&(t=E);let x=getRawAttribute(m,"formmethod");if(x!=null)if(VERBS.includes(x.toLowerCase()))e=x;else return maybeCall(i),l}let h=getClosestAttributeValue(n,"hx-confirm");if(s===void 0&&triggerEvent(n,"htmx:confirm",{target:d,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(L){return issueAjaxRequest(e,t,n,r,o,!!L)},question:h})===!1)return maybeCall(i),l;let g=n,p=getClosestAttributeValue(n,"hx-sync"),y=null,w=!1;if(p){let E=p.split(":"),x=E[0].trim();if(x==="this"?g=findThisElement(n,"hx-sync"):g=asElement(querySelectorExt(n,x)),p=(E[1]||"drop").trim(),f=getInternalData(g),p==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(i),l;if(p==="abort"){if(f.xhr)return maybeCall(i),l;w=!0}else p==="replace"?triggerEvent(g,"htmx:abort"):p.indexOf("queue")===0&&(y=(p.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(g,"htmx:abort");else{if(y==null){if(r){let E=getInternalData(r);E&&E.triggerSpec&&E.triggerSpec.queue&&(y=E.triggerSpec.queue)}y==null&&(y="last")}return f.queuedRequests==null&&(f.queuedRequests=[]),y==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(i),l}let b=new XMLHttpRequest;f.xhr=b,f.abortable=w;let v=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},te=getClosestAttributeValue(n,"hx-prompt");if(te){var W=prompt(te);if(W===null||!triggerEvent(n,"htmx:prompt",{prompt:W,target:d}))return maybeCall(i),v(),l}if(h&&!s&&!confirm(h))return maybeCall(i),v(),l;let H=getHeaders(n,d,W);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let ne=getInputValues(n,e),q=ne.errors,re=ne.formData;o.values&&overrideFormData(re,formDataFromObject(o.values));let Ae=formDataFromObject(getExpressionVars(n,r)),X=overrideFormData(re,Ae),R=filterValues(X,n);htmx.config.getCacheBusterParam&&e==="get"&&R.set("org.htmx.cache-buster",getRawAttribute(d,"id")||"true"),(t==null||t==="")&&(t=location.href);let $=getValuesForElement(n,"hx-request"),oe=getInternalData(n).boosted,P=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,C={boosted:oe,useUrlParams:P,formData:R,parameters:formDataProxy(R),unfilteredFormData:X,unfilteredParameters:formDataProxy(X),headers:H,elt:n,target:d,verb:e,errors:q,withCredentials:o.credentials||$.credentials||htmx.config.withCredentials,timeout:o.timeout||$.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",C))return maybeCall(i),v(),l;if(t=C.path,e=C.verb,H=C.headers,R=formDataFromObject(C.parameters),q=C.errors,P=C.useUrlParams,q&&q.length>0)return triggerEvent(n,"htmx:validation:halted",C),maybeCall(i),v(),l;let se=t.split("#"),Le=se[0],z=se[1],A=t;if(P&&(A=Le,!R.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(R),z&&(A+="#"+z))),!verifyPath(n,A,C))return triggerErrorEvent(n,"htmx:invalidPath",C),maybeCall(a),v(),l;if(b.open(e.toUpperCase(),A,!0),b.overrideMimeType("text/html"),b.withCredentials=C.withCredentials,b.timeout=C.timeout,!$.noHeaders){for(let E in H)if(H.hasOwnProperty(E)){let x=H[E];safelySetHeaderValue(b,E,x)}}let S={xhr:b,target:d,requestConfig:C,etc:o,boosted:oe,select:u,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:z}};if(b.onload=function(){try{let E=hierarchyForElt(n);if(S.pathInfo.responsePath=getPathFromResponse(b),c(n,S),S.keepIndicators!==!0&&removeRequestIndicators(F,B),triggerEvent(n,"htmx:afterRequest",S),triggerEvent(n,"htmx:afterOnLoad",S),!bodyContains(n)){let x=null;for(;E.length>0&&x==null;){let L=E.shift();bodyContains(L)&&(x=L)}x&&(triggerEvent(x,"htmx:afterRequest",S),triggerEvent(x,"htmx:afterOnLoad",S))}maybeCall(i)}catch(E){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:E},S)),E}finally{v()}},b.onerror=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",S),triggerErrorEvent(n,"htmx:sendError",S),maybeCall(a),v()},b.onabort=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",S),triggerErrorEvent(n,"htmx:sendAbort",S),maybeCall(a),v()},b.ontimeout=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",S),triggerErrorEvent(n,"htmx:timeout",S),maybeCall(a),v()},!triggerEvent(n,"htmx:beforeRequest",S))return maybeCall(i),v(),l;var F=addRequestIndicatorClasses(n),B=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(E){forEach([b,b.upload],function(x){x.addEventListener(E,function(L){triggerEvent(n,"htmx:xhr:"+E,{lengthComputable:L.lengthComputable,loaded:L.loaded,total:L.total})})})}),triggerEvent(n,"htmx:beforeSend",S);let Ie=P?null:encodeParamsForBody(b,n,R);return b.send(Ie),l}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let s=t.pathInfo.finalRequestPath,i=t.pathInfo.responsePath,a=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),l=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,u=null,d=null;return a?(u="push",d=a):l?(u="replace",d=l):c&&(u="push",d=i||s),d?d==="false"?{}:(d==="true"&&(d=i||s),t.pathInfo.anchor&&d.indexOf("#")===-1&&(d=d+"#"+t.pathInfo.anchor),{type:u,path:d}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,s=getInternalData(o);s&&s.xhr&&s.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),O=He;(function(){let e;O.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(i,a){Object.hasOwn(o,a)?(Array.isArray(o[a])||(o[a]=[o[a]]),o[a].push(i)):o[a]=i});let s=e.getExpressionVars(r);return Object.keys(o).forEach(function(i){o[i]=Object.hasOwn(s,i)?s[i]:o[i]}),JSON.stringify(o)}})})();var ie="https://typeahead.waow.tech",le="https://public.api.bsky.app",Re="/xrpc/app.bsky.actor.searchActorsTypeahead",De="/xrpc/app.bsky.actor.getProfiles";var Oe="atcr_recent_handles",ce="atcr_recent_profile_cache";var Y=class{constructor(t){this.input=t,this.container=t.closest(".sailor-typeahead")||t.parentElement,this.dropdown=null,this.selectedCard=null,this.actors=[],this.currentItems=[],this.mode="hidden",this.focusIndex=-1,this.debounceTimer=null,this.requestSeq=0,this.primaryUnhealthyUntil=0,this.lastPrefetchPrefix="",this.lastPrefetchAt=0,this.createDropdown(),this.bindEvents(),this.input.value.trim().length===0&&this.showRecent()}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="sailor-typeahead-dropdown",this.dropdown.setAttribute("role","listbox"),this.dropdown.style.display="none",this.input.insertAdjacentElement("afterend",this.dropdown)}bindEvents(){this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hide()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&this.selectedCard&&this.clearSelection()})}handleFocus(){this.input.value.trim().length===0&&this.showRecent()}handleInput(){let t=this.input.value.trim();if(t.length===0){this.showRecent();return}if(t.length>=2&&t.length<4){this.hide(),this.schedulePrefetch(t);return}if(t.length>=4){this.scheduleSearch(t);return}this.hide()}schedulePrefetch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runPrefetch(t),150)}scheduleSearch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runSearch(t),150)}async runPrefetch(t){let n=Date.now();if(!(t===this.lastPrefetchPrefix&&n-this.lastPrefetchAt<1e4)&&!(n=this.primaryUnhealthyUntil)try{r=await J(ie,t,1500)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}if(r===null)try{r=await J(le,t,1500)}catch{r=[]}n===this.requestSeq&&(this.actors=r||[],this.focusIndex=-1,this.renderResults())}renderResults(){if(this.mode="results",this.dropdown.innerHTML="",this.currentItems=[],this.actors.length===0){this.hide();return}this.actors.forEach((t,n)=>{this.currentItems.push(t),this.dropdown.appendChild(this.buildActorRow(t,n))}),this.dropdown.style.display="block"}buildActorRow(t,n){let r=document.createElement("div");r.className="sailor-typeahead-item",r.setAttribute("role","option"),r.setAttribute("aria-selected","false"),r.dataset.index=String(n),r.dataset.handle=t.handle;let o=document.createElement("div");if(o.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",l.loading="lazy",o.appendChild(l)}let s=document.createElement("div");s.className="sailor-typeahead-text";let i=t.displayName&&t.displayName!==t.handle;if(i){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,s.appendChild(l)}let a=document.createElement("div");return a.className=i?"sailor-typeahead-handle":"sailor-typeahead-name",a.textContent="@"+t.handle,s.appendChild(a),r.append(o,s),r.addEventListener("mousedown",l=>{l.preventDefault(),this.select(t)}),r}showRecent(){let t=Ne();if(t.length===0){this.hide();return}this.mode="recent",this.focusIndex=-1,this.renderRecent(t),this.enrichRecent(t)}renderRecent(t){let n=_();this.dropdown.innerHTML="",this.currentItems=[];let r=document.createElement("div");r.className="sailor-typeahead-header",r.textContent="Recent accounts",this.dropdown.appendChild(r),t.forEach((o,s)=>{let i=n[o]?.profile||{handle:o};this.currentItems.push(i),this.dropdown.appendChild(this.buildActorRow(i,s))}),this.dropdown.style.display="block"}async enrichRecent(t){let n=_(),r=Date.now(),o=t.filter(a=>{let l=n[a];return!l||r-l.ts>864e5});if(o.length===0)return;let s=await Me(o);if(s.length===0)return;let i=_();s.forEach(a=>{i[a.handle]={ts:r,profile:{handle:a.handle,displayName:a.displayName,avatar:a.avatar}}}),ae(i),this.mode==="recent"&&this.renderRecent(t)}hide(){this.mode="hidden",this.focusIndex=-1,this.dropdown.style.display="none"}select(t){if(typeof t=="string"&&(t={handle:t}),this.input.value=t.handle,this.hide(),this.showSelectedCard(t),t.handle){let n=_();n[t.handle]={ts:Date.now(),profile:{handle:t.handle,displayName:t.displayName,avatar:t.avatar}},ae(n)}}showSelectedCard(t){this.clearSelectedCard();let n=document.createElement("div");n.className="sailor-typeahead-selected";let r=document.createElement("div");if(r.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",r.appendChild(l)}let o=document.createElement("div");o.className="sailor-typeahead-text";let s=t.displayName&&t.displayName!==t.handle;if(s){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,o.appendChild(l)}let i=document.createElement("div");i.className=s?"sailor-typeahead-handle":"sailor-typeahead-name",i.textContent="@"+t.handle,o.appendChild(i);let a=document.createElement("button");a.type="button",a.className="sailor-typeahead-clear",a.setAttribute("aria-label","Change account"),a.innerHTML="×",a.addEventListener("click",()=>this.clearSelection()),n.append(r,o,a),this.input.style.display="none",this.input.insertAdjacentElement("beforebegin",n),this.selectedCard=n}clearSelectedCard(){this.selectedCard&&(this.selectedCard.remove(),this.selectedCard=null)}clearSelection(){this.clearSelectedCard(),this.input.style.display="",this.input.value="",this.input.focus(),this.showRecent()}handleKeydown(t){if(this.mode==="hidden")return;let n=this.dropdown.querySelectorAll(".sailor-typeahead-item");n.length!==0&&(t.key==="ArrowDown"?(t.preventDefault(),this.focusIndex=(this.focusIndex+1)%n.length,this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.focusIndex=this.focusIndex<=0?n.length-1:this.focusIndex-1,this.updateFocus(n)):t.key==="Enter"?this.focusIndex>=0&&this.currentItems[this.focusIndex]&&(t.preventDefault(),this.select(this.currentItems[this.focusIndex])):t.key==="Escape"?this.hide():t.key==="Tab"&&this.focusIndex===-1&&n.length>0&&(t.preventDefault(),this.focusIndex=0,this.updateFocus(n)))}updateFocus(t){t.forEach((n,r)=>{let o=r===this.focusIndex;n.classList.toggle("focused",o),n.setAttribute("aria-selected",o?"true":"false"),o&&n.scrollIntoView({block:"nearest"})})}destroy(){this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null)}};async function J(e,t,n){let r=new URL(Re,e);r.searchParams.set("q",t),r.searchParams.set("limit",String(8));let o=new AbortController,s=setTimeout(()=>o.abort(),n);try{let i=await fetch(r,{signal:o.signal});if(!i.ok)throw new Error("HTTP "+i.status);let a=await i.json();return Array.isArray(a.actors)?a.actors:[]}finally{clearTimeout(s)}}async function Me(e){if(e.length===0)return[];let t=new URL(De,le);e.forEach(o=>t.searchParams.append("actors",o));let n=new AbortController,r=setTimeout(()=>n.abort(),3e3);try{let o=await fetch(t,{signal:n.signal});if(!o.ok)return[];let s=await o.json();return Array.isArray(s.profiles)?s.profiles:[]}catch{return[]}finally{clearTimeout(r)}}function _(){try{return JSON.parse(localStorage.getItem(ce)||"{}")}catch{return{}}}function ae(e){try{localStorage.setItem(ce,JSON.stringify(e))}catch{}}function Ne(){try{let e=localStorage.getItem(Oe);return e?JSON.parse(e):[]}catch{return[]}}var I=null;function ue(){let e=document.getElementById("handle");e&&(I&&I.input===e||(I&&I.destroy(),I=new Y(e)))}document.addEventListener("DOMContentLoaded",ue);document.body.addEventListener("htmx:afterSettle",ue);document.body.addEventListener("htmx:beforeSwap",()=>{I&&!document.contains(I.input)&&(I.destroy(),I=null)});function U(e){try{return localStorage.getItem(e)}catch{return null}}function V(e,t){try{localStorage.setItem(e,t)}catch{}}function me(){return U("theme")||"system"}function ke(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function G(){let e=me(),n=ke(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),qe(e)}function ge(e){V("theme",e),G(),Pe()}function qe(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e;n.setAttribute("aria-checked",r?"true":"false");let o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function Pe(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}document.addEventListener("DOMContentLoaded",()=>{document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");if(!t)return;let n=()=>e.setAttribute("aria-expanded",t.open?"true":"false");n(),t.addEventListener("toggle",n)})});window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{me()==="system"&&G()});function pe(e,t){if(!e)return;let n=e.querySelector(".nav-search-form"),r=e.querySelector('button[aria-controls="nav-search-form"]');e.classList.toggle("expanded",t),n&&(t?n.removeAttribute("inert"):n.setAttribute("inert","")),r&&r.setAttribute("aria-expanded",t?"true":"false")}function Fe(){let e=document.querySelector(".nav-search-wrapper");if(!e)return;let t=!e.classList.contains("expanded");if(pe(e,t),t){let n=document.getElementById("nav-search-input");n&&n.focus()}}function de(){let e=document.querySelector(".nav-search-wrapper");if(pe(e,!1),e){let t=e.querySelector('[aria-controls="nav-search-form"]');t&&t.focus()}}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{if(n.key==="Escape"&&e.classList.contains("expanded")&&de(),n.key==="/"&&!e.classList.contains("expanded")){let r=n.target.tagName;if(r==="INPUT"||r==="TEXTAREA"||n.target.isContentEditable)return;n.preventDefault(),e.classList.add("expanded"),t.focus()}}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&de()}))});function K(e,t){let n=()=>{if(!t||!document.contains(t))return;let r=t.innerHTML;t.innerHTML=' Copied!',setTimeout(()=>{document.contains(t)&&(t.innerHTML=r)},2e3)};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(e).then(n).catch(r=>{console.error("Clipboard API failed, falling back:",r),fe(e)?n():T("Copy failed \u2014 check browser permissions","error")});return}fe(e)?n():T("Copy failed \u2014 select the text and copy manually","error")}function fe(e){let t=document.createElement("textarea");t.value=e,t.setAttribute("readonly",""),t.setAttribute("aria-hidden","true"),t.style.position="fixed",t.style.top="0",t.style.left="0",t.style.width="1px",t.style.height="1px",t.style.opacity="0",t.style.pointerEvents="none",document.body.appendChild(t);let n=!1;try{t.focus(),t.select(),t.setSelectionRange(0,e.length),n=document.execCommand&&document.execCommand("copy")}catch{n=!1}return document.body.removeChild(t),!!n}function Be(e){let t=s=>{let i=(s==null?"":String(s)).trim();return/[",\n\r]/.test(i)?'"'+i.replace(/"/g,'""')+'"':i},n=s=>Array.from(s).map(i=>t(i.textContent)).join(","),r=[],o=e.querySelector("thead tr");return o&&r.push(n(o.querySelectorAll("th,td"))),e.querySelectorAll("tbody tr").forEach(s=>{r.push(n(s.querySelectorAll("td,th")))}),r.join(` +`)}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-copy-csv]");if(t){let r=t.closest("[data-csv-section]"),o=r&&r.querySelector("table");o&&K(Be(o),t);return}let n=e.target.closest("button[data-cmd]");if(n){K(n.getAttribute("data-cmd"),n);return}})});function _e(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let s=Math.floor(t/o);if(s>=1)return s===1?`1 ${r} ago`:`${s} ${r}s ago`}return"just now"}function j(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=_e(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{j(),G(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{ge(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",j);var M=null;function ye(){M===null&&(M=setInterval(j,6e4))}function Ue(){M!==null&&(clearInterval(M),M=null)}document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?Ue():(j(),ye())});ye();async function Ve(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();je(e,t,n,o.tags)}else if(r.ok)Ee(n);else{let o=await r.text();T(`Failed to delete manifest: ${o||r.status}`,"error")}}catch(r){console.error("Error deleting manifest:",r),T(`Error deleting manifest: ${r.message}`,"error")}}function je(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),s=document.getElementById("manifest-delete-tags"),i=document.getElementById("confirm-manifest-delete-btn");s.innerHTML="",r.forEach(a=>{let l=document.createElement("li");l.textContent=a,s.appendChild(l)}),i.onclick=()=>We(e,t,n),Z(o)}function Q(){N(document.getElementById("manifest-delete-modal"))}async function We(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let s=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(s.ok)Q(),Ee(n),location.reload();else{let i=await s.text();T(`Failed to delete manifest: ${i||s.status}`,"error"),r.disabled=!1,r.textContent=o}}catch(s){console.error("Error deleting manifest:",s),T(`Error deleting manifest: ${s.message}`,"error"),r.disabled=!1,r.textContent=o}}async function Xe(e){let t=document.getElementById("confirm-untagged-delete-btn"),n=t.textContent,r=()=>{t.disabled=!1,t.textContent=n};try{t.disabled=!0,t.textContent="Deleting...";let o=await fetch("/api/manifests/untagged",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e})}),s=await o.text(),i=null;try{i=s?JSON.parse(s):null}catch{}if(o.ok){let a=i&&i.deleted||0,l=i&&i.failed||0;if(N(document.getElementById("untagged-delete-modal")),l>0?T(`Deleted ${a} of ${a+l} untagged manifest(s); ${l} failed`,"error"):a>0?T(`Deleted ${a} untagged manifest(s)`,"success"):T("No untagged manifests to delete","info"),a>0){location.reload();return}r()}else{let a=i&&i.error||s||`HTTP ${o.status}`,l=i&&i.deleted?` (${i.deleted} succeeded before failure)`:"";T(`Failed to delete untagged manifests: ${a}${l}`,"error"),r()}}catch(o){console.error("Error deleting untagged manifests:",o),T(`Error: ${o.message}`,"error"),r()}}function Ee(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&Q()})});var k=new WeakMap;function Z(e,t){if(e&&(k.set(e,t||document.activeElement),typeof e.showModal=="function")){e.open&&(e.open=!1);try{e.showModal()}catch{}}}function N(e,{remove:t=!1}={}){if(!e)return;let n=k.get(e);if(k.delete(e),typeof e.close=="function"&&e.open)try{e.close()}catch{}t&&e.remove(),ve(n)}function ve(e){e&&typeof e.focus=="function"&&document.contains(e)&&e.focus()}document.addEventListener("close",e=>{let t=e.target;if(!(t instanceof HTMLDialogElement))return;let n=k.get(t);k.delete(t),ve(n)},!0);document.body.addEventListener("htmx:afterSettle",()=>{document.querySelectorAll("dialog.modal-open:not([data-modal-promoted]), dialog[open]:not([data-modal-promoted])").forEach(t=>{t.dataset.modalPromoted="1",Z(t)})});document.addEventListener("change",e=>{let t=e.target.closest("select[data-diff-url]");if(!t)return;let n=t.dataset.diffUrl;n&&(window.location.href=n.replace("__VALUE__",encodeURIComponent(t.value)))});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("pull-cmd-container");if(!e)return;let t=e.dataset.registryUrl,n=e.dataset.ownerHandle,r=e.dataset.repoName,o=e.dataset.tag||"latest",s=e.dataset.isLoggedIn==="true";function i(l){let u=(l==="none"?"":l+" pull ")+t+"/"+n+"/"+r+":"+o,d=document.getElementById("pull-cmd-display");if(!d)return;let f=d.querySelector("code");f&&(f.textContent=u);let m=d.querySelector("[data-cmd]");m&&(m.dataset.cmd=u),s&&window.htmx?window.htmx.ajax("POST","/api/profile/oci-client",{values:{oci_client:l},swap:"none"}):s||V("oci-client",l)}if(!s){let l=U("oci-client");if(l){let c=document.getElementById("oci-client-switcher");c&&(c.value=l,i(l))}}let a=document.getElementById("oci-client-switcher");a&&a.addEventListener("change",()=>i(a.value))});function be(e){let t=document.getElementById("helm-cmd-container");if(!t)return;let n=t.dataset.registryUrl,r=t.dataset.ownerHandle,o=t.dataset.repoName,s=t.dataset.tag||"",i="oci://"+n+"/"+r+"/"+o,a=s?" --version "+s:"",l=e==="pull"?"helm pull "+i+a:"helm install "+o+" "+i+a,c=document.getElementById("helm-cmd-display");if(!c)return;let u=c.querySelector("code");u&&(u.textContent=l);let d=c.querySelector("[data-cmd]");d&&(d.dataset.cmd=l)}function we(){let e=document.getElementById("helm-cmd-switcher");if(!e)return;let t=U("helm-cmd");(t==="install"||t==="pull")&&(e.value=t),be(e.value)}document.addEventListener("DOMContentLoaded",we);document.body.addEventListener("htmx:afterSettle",we);document.addEventListener("change",e=>{!e.target||e.target.id!=="helm-cmd-switcher"||(V("helm-cmd",e.target.value),be(e.target.value))});document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll(".platform-tab[data-platform]");e.length&&e.forEach(t=>{t.addEventListener("click",()=>{e.forEach(r=>{let o=r===t;r.classList.toggle("btn-primary",o),r.classList.toggle("btn-ghost",!o),r.setAttribute("aria-selected",o?"true":"false"),r.setAttribute("tabindex",o?"0":"-1")}),document.querySelectorAll(".platform-content").forEach(r=>{r.classList.add("hidden"),r.setAttribute("hidden","")});let n=document.getElementById(t.dataset.platform+"-content");n&&(n.classList.remove("hidden"),n.removeAttribute("hidden"),t.focus())}),t.addEventListener("keydown",n=>{if(n.key!=="ArrowLeft"&&n.key!=="ArrowRight")return;n.preventDefault();let r=Array.from(e),o=r.indexOf(t);(n.key==="ArrowRight"?r[(o+1)%r.length]:r[(o-1+r.length)%r.length]).click()})})});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form");e&&e.addEventListener("submit",()=>{let t=e.querySelector('button[type="submit"]');!t||t.disabled||(t.disabled=!0,t.innerHTML=' Navigating…')})});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t&&typeof t=="string"&&t.length>0){try{let n="atcr_recent_handles",r=U(n),o=[];try{o=JSON.parse(r||"[]")}catch{o=[]}Array.isArray(o)||(o=[]),o=o.filter(s=>s!==t),o.unshift(t),o=o.slice(0,5),V(n,JSON.stringify(o))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function he(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=e.querySelectorAll(".carousel-item");if(r.length===0||!r[0])return;let o=null,s=5e3,i=window.matchMedia("(prefers-reduced-motion: reduce)"),a=()=>i.matches?"auto":"smooth",l=0,c=0;function u(){if(!r[0])return;let y=parseFloat(getComputedStyle(e).gap)||24;l=r[0].offsetWidth+y}u(),window.addEventListener("resize",()=>{cancelAnimationFrame(c),c=requestAnimationFrame(u)}),document.body.addEventListener("htmx:afterSettle",y=>{y.target&&y.target.contains&&y.target.contains(e)&&u()});function d(){let y=e.scrollWidth-e.clientWidth;e.scrollLeft>=y-10?e.scrollTo({left:0,behavior:a()}):e.scrollBy({left:l,behavior:a()})}function f(){e.scrollLeft<=10?e.scrollTo({left:e.scrollWidth,behavior:a()}):e.scrollBy({left:-l,behavior:a()})}function m(){o||document.visibilityState!=="hidden"&&(e.scrollWidth<=e.clientWidth+10||i.matches||(o=setInterval(d,s)))}function h(){o&&(clearInterval(o),o=null)}t&&t.addEventListener("click",()=>{h(),f(),m()}),n&&n.addEventListener("click",()=>{h(),d(),m()});let g=document.getElementById("carousel-pause"),p=!1;if(g){let y=g.querySelector(".carousel-pause-icon"),w=g.querySelector(".carousel-play-icon");g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),g.addEventListener("click",()=>{p=!p,p?(h(),g.setAttribute("aria-pressed","true"),g.setAttribute("aria-label","Resume carousel auto-advance"),y&&y.classList.add("hidden"),w&&w.classList.remove("hidden")):(g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),y&&y.classList.remove("hidden"),w&&w.classList.add("hidden"),m())})}e.addEventListener("mouseenter",h),e.addEventListener("mouseleave",()=>{p||m()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?h():p||m()}),m()}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(he,{timeout:2e3}):setTimeout(he,100)});document.body.addEventListener("htmx:responseError",e=>{let t=e.detail&&e.detail.elt;if(t&&t.closest&&t.closest("[data-suppress-htmx-toast]"))return;let n=e.detail&&e.detail.xhr,r=n&&n.getResponseHeader&&n.getResponseHeader("HX-Trigger");if(r&&r.indexOf("toast")!==-1)return;let o=n?n.status:0,s=o===401?"Session expired \u2014 please sign in again":o===403?"Not authorized":o===404?"Not found":o===429?"Too many requests \u2014 please slow down":o>=500?"Server error \u2014 please try again":"Something went wrong";T(s,"error")});document.body.addEventListener("htmx:sendError",e=>{let t=e.detail&&e.detail.elt;t&&t.closest&&t.closest("[data-suppress-htmx-toast]")||T("Network error \u2014 check your connection","error")});document.body.addEventListener("toast",e=>{let t=e&&e.detail||{},n=t.message||t.msg||"";if(!n)return;let r=t.type||"info";T(n,r)});var $e=4,ze=1500;function xe(){let e=document.getElementById("toast-container");return e||(e=document.createElement("div"),e.id="toast-container",e.className="toast toast-end toast-bottom z-50",e.setAttribute("aria-live","polite"),e.setAttribute("aria-atomic","false"),document.body&&document.body.appendChild(e),e)}document.addEventListener("DOMContentLoaded",xe);function T(e,t){let n=xe(),r=(t||"info")+"|"+e,o=Date.now(),s=n.querySelector(`[data-toast-key="${Ye(r)}"]`);if(s&&o-Number(s.dataset.toastAt)$e;)n.firstElementChild.remove();Te(l)}function Te(e){e._dismissTimer=setTimeout(()=>{e.style.opacity="0",e._removeTimer=setTimeout(()=>e.remove(),300)},3e3)}function Je(e){clearTimeout(e._dismissTimer),clearTimeout(e._removeTimer),e.style.opacity="",e.dataset.toastAt=String(Date.now()),Te(e)}function Ye(e){return window.CSS&&CSS.escape?CSS.escape(e):String(e).replace(/[^a-zA-Z0-9_-]/g,t=>"\\"+t)}async function Ke(e){try{let t=await fetch(`/api/webhooks/${e}/test`,{method:"POST",credentials:"include"}),n=await t.text();n.includes('class="success"')||t.ok&&!n.includes('class="error"')?T("Test webhook delivered successfully!","success"):T("Test delivery failed \u2014 check the webhook URL","error")}catch{T("Failed to reach server","error")}}(function(){let t={"switch-repo-tab":s=>window.switchRepoTab&&window.switchRepoTab(s.dataset.tab),"switch-editor-tab":s=>window.switchEditorTab&&window.switchEditorTab(s.dataset.tab),"insert-md":s=>window.insertMd&&window.insertMd(s.dataset.mdType),"toggle-editor":s=>window.toggleOverviewEditor&&window.toggleOverviewEditor(s.dataset.show==="true"),"show-modal":s=>Z(document.getElementById(s.dataset.modalId),s),"close-dialog":s=>N(s.closest("dialog")),"remove-closest-dialog":s=>N(s.closest("dialog"),{remove:!0}),"close-manifest-delete-modal":()=>window.closeManifestDeleteModal&&window.closeManifestDeleteModal(),"save-overview":()=>window.saveOverview&&window.saveOverview(),"delete-manifest":s=>window.deleteManifest&&window.deleteManifest(s.dataset.repo,s.dataset.digest,s.dataset.manifestId||""),"delete-untagged":s=>window.deleteUntaggedManifests&&window.deleteUntaggedManifests(s.dataset.repo),copy:s=>window.copyToClipboard&&window.copyToClipboard(s.dataset.copy,s),"toggle-search":()=>window.toggleSearch&&window.toggleSearch(),"switch-settings-tab":s=>window.switchSettingsTab&&window.switchSettingsTab(s.dataset.tab),"test-webhook":s=>window.testWebhook&&window.testWebhook(s.dataset.webhookId),"diff-to":(s,i)=>window.diffToTag&&window.diffToTag(i,s),"modal-backdrop-close":(s,i)=>{i.target===s&&N(s,{remove:!0})}},n={"sort-tags":s=>window.sortTags&&window.sortTags(s.value),"submit-form":s=>s.form&&s.form.requestSubmit()},r={"filter-tags":s=>window.filterTags&&window.filterTags(s.value)};function o(s,i){let a=i.target.closest("[data-action]");if(!a)return;let l=s[a.dataset.action];l&&l(a,i)}document.addEventListener("click",s=>o(t,s)),document.addEventListener("change",s=>o(n,s)),document.addEventListener("input",s=>o(r,s))})();window.setTheme=ge;window.toggleSearch=Fe;window.copyToClipboard=K;window.deleteManifest=Ve;window.deleteUntaggedManifests=Xe;window.closeManifestDeleteModal=Q;window.showToast=T;window.testWebhook=Ke;function Ge(){let e=document.getElementById("md-editor");if(!e)return;let t=e.dataset.ownerDid,n=e.dataset.repository;window.toggleOverviewEditor=function(r){document.getElementById("overview-view").classList.toggle("hidden",r),document.getElementById("overview-edit").classList.toggle("hidden",!r),r&&e.focus()},window.switchEditorTab=function(r){if(document.querySelectorAll(".editor-panel").forEach(o=>o.classList.add("hidden")),document.getElementById(r==="write"?"editor-write":"editor-preview").classList.remove("hidden"),document.querySelectorAll(".editor-tab").forEach(o=>{let s=o.dataset.tab===r;o.classList.toggle("border-primary",s),o.classList.toggle("text-primary",s),o.classList.toggle("border-transparent",!s),o.classList.toggle("text-base-content/60",!s)}),r==="preview"){let o=e.value,s=document.getElementById("preview-content");if(!o.trim()){s.innerHTML='

Nothing to preview

';return}s.innerHTML='

Rendering preview…

';let i=new FormData;i.append("markdown",o),fetch("/api/repo-page/preview",{method:"POST",body:i}).then(a=>{if(!a.ok)throw new Error("HTTP "+a.status);return a.text()}).then(a=>{s.innerHTML=a}).catch(()=>{s.innerHTML='

Preview failed. Check your connection and try again.

'})}},window.insertMd=function(r){let o=e.selectionStart,s=e.selectionEnd,i=e.value.substring(o,s),a=e.value.substring(0,o),l=e.value.substring(s),c,u,d;switch(r){case"heading":c="## "+(i||"Heading"),u=o+3,d=o+c.length;break;case"bold":c="**"+(i||"bold text")+"**",u=o+2,d=o+c.length-2;break;case"italic":c="_"+(i||"italic text")+"_",u=o+1,d=o+c.length-1;break;case"link":c="["+(i||"link text")+"](url)",u=o+c.length-4,d=o+c.length-1;break;case"image":c="!["+(i||"alt text")+"](url)",u=o+c.length-4,d=o+c.length-1;break;case"ul":c="- "+(i||"list item"),u=o+2,d=o+c.length;break;case"ol":c="1. "+(i||"list item"),u=o+3,d=o+c.length;break;case"code":i&&i.indexOf(` +`)!==-1?(c="```\n"+i+"\n```",u=o+4,d=o+4+i.length):(c="`"+(i||"code")+"`",u=o+1,d=o+c.length-1);break;default:return}e.value=a+c+l,e.focus(),e.selectionStart=u,e.selectionEnd=d},window.saveOverview=function(){let r=document.getElementById("save-overview-btn");r.classList.add("btn-disabled"),r.innerHTML=' Saving...';let o=new FormData;o.append("did",t),o.append("repository",n),o.append("description",e.value),fetch("/api/repo-page",{method:"POST",body:o,headers:{"HX-Request":"true"}}).then(s=>s.ok?s.text():s.text().then(i=>{throw new Error(i)})).then(s=>{document.getElementById("overview-rendered").innerHTML=s,window.toggleOverviewEditor(!1),typeof window.showToast=="function"&&window.showToast("Overview saved","success")}).catch(s=>{typeof window.showToast=="function"&&window.showToast(s.message||"Failed to save","error")}).finally(()=>{r.classList.remove("btn-disabled"),r.innerHTML="Save"})},e.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="s"&&(r.preventDefault(),window.saveOverview())})}window.sortTags=function(e){let t=document.getElementById("tags-list");if(!t)return;let n=Array.from(t.querySelectorAll(".artifact-entry"));n.sort((r,o)=>{switch(e){case"oldest":return parseInt(r.dataset.created)-parseInt(o.dataset.created);case"az":return r.dataset.tag.localeCompare(o.dataset.tag);case"za":return o.dataset.tag.localeCompare(r.dataset.tag);default:return parseInt(o.dataset.created)-parseInt(r.dataset.created)}}),n.forEach(r=>t.appendChild(r))};var D=0;window.filterTags=function(e){D&&cancelAnimationFrame(D),D=requestAnimationFrame(()=>{D=0;let t=e.toLowerCase();document.querySelectorAll("#tags-list .artifact-entry").forEach(n=>{n.style.display=!t||n.dataset.tag.toLowerCase().includes(t)?"":"none"})})};document.body.addEventListener("htmx:beforeSwap",()=>{D&&(cancelAnimationFrame(D),D=0)});function Qe(){if(!document.getElementById("tag-content"))return;let e=["overview","layers","vulns","sbom","artifacts","chart"],t={};function n(i,a){if(t[i]==="loading"||t[i]==="loaded")return;t[i]="loading";let l=document.getElementById(i);if(!l){delete t[i];return}let c=new AbortController,u=setTimeout(()=>c.abort(),1e4);fetch(a,{signal:c.signal}).then(d=>{if(!d.ok)throw new Error("HTTP "+d.status);return d.text()}).then(d=>{t[i]="loaded",document.contains(l)&&(l.innerHTML=d,l.querySelectorAll("script").forEach(f=>{let m=document.createElement("script");m.textContent=f.textContent,f.parentNode.replaceChild(m,f)}),typeof window.htmx<"u"&&window.htmx.process(l))}).catch(d=>{if(delete t[i],!document.contains(l))return;let m=d&&d.name==="AbortError"?"This section took too long to load.":"Couldn't load this section.";l.innerHTML='

'+m+'

'}).finally(()=>clearTimeout(u))}document.body.addEventListener("click",i=>{let a=i.target.closest("[data-retry-section]");if(!a)return;let l=a.getAttribute("data-retry-section"),u={"artifacts-content":o,"layers-content":()=>r("layers"),"vulns-content":()=>r("vulns"),"sbom-content":()=>r("sbom")}[l];if(u){let d=u();d&&n(l,d)}});function r(i){let a=document.getElementById("tag-content");if(!a||!a.dataset)return null;let l=a.dataset.digest,c=a.dataset.owner,u=a.dataset.repo;return!l||!c||!u?null:"/api/digest-content/"+c+"/"+u+"?digest="+encodeURIComponent(l)+"§ion="+i}function o(){let i=document.getElementById("tag-content");if(!i||!i.dataset)return null;let a=i.dataset.owner,l=i.dataset.repo;return!a||!l?null:"/api/repo-tags/"+a+"/"+l}window.diffToTag=function(i,a){i.preventDefault();let l=a.dataset.diffTo,c=document.getElementById("tag-content"),u=document.getElementById("tag-selector");if(!c||!u||!l)return;let d=c.dataset.digest,f=u.value;!d||l===f||(window.location.href="/diff/"+c.dataset.owner+"/"+c.dataset.repo+"?from="+encodeURIComponent(d)+"&to="+encodeURIComponent(l))},window.switchRepoTab=function(i){window._activeRepoTab=i;let a=document.getElementById("tag-content");if(!a)return;a.querySelectorAll(".repo-panel").forEach(u=>u.classList.add("hidden"));let l=document.getElementById("tab-"+i);l&&l.classList.remove("hidden"),a.querySelectorAll(".repo-tab").forEach(u=>{let d=u.dataset.tab===i;u.classList.toggle("border-primary",d),u.classList.toggle("text-primary",d),u.classList.toggle("border-transparent",!d),u.classList.toggle("text-base-content/60",!d),u.setAttribute("aria-selected",d?"true":"false"),u.setAttribute("tabindex",d?"0":"-1")});let c=new URL(window.location);if(c.hash=i,history.replaceState(null,"",c.toString()),i==="artifacts"){let u=o();u&&n("artifacts-content",u)}if(i==="layers"){let u=r("layers");u&&n("layers-content",u)}if(i==="vulns"){let u=r("vulns");u&&n("vulns-content",u)}if(i==="sbom"){let u=r("sbom");u&&n("sbom-content",u)}if(i==="chart"){let u=r("chart");u&&n("chart-content",u)}};function s(){t={},[["artifacts-tab-btn","artifacts-content",o],["layers-tab-btn","layers-content",()=>r("layers")],["vulns-tab-btn","vulns-content",()=>r("vulns")],["sbom-tab-btn","sbom-content",()=>r("sbom")],["chart-tab-btn","chart-content",()=>r("chart")]].forEach(([c,u,d])=>{let f=document.getElementById(c);f&&f.addEventListener("mouseenter",()=>{let m=d();m&&n(u,m)},{once:!0})});let a=document.querySelector('[role="tablist"][aria-label="Repository sections"]');a&&!a.dataset.keyboardBound&&(a.dataset.keyboardBound="1",a.addEventListener("keydown",c=>{let u=Array.from(a.querySelectorAll(".repo-tab")),d=u.indexOf(document.activeElement);if(d===-1)return;let f=-1;switch(c.key){case"ArrowRight":f=(d+1)%u.length;break;case"ArrowLeft":f=(d-1+u.length)%u.length;break;case"Home":f=0;break;case"End":f=u.length-1;break;case"Enter":case" ":c.preventDefault(),window.switchRepoTab(u[d].dataset.tab);return;default:return}c.preventDefault(),u[f].focus()}));let l=window._activeRepoTab||window.location.hash.replace("#","")||"overview";e.indexOf(l)===-1&&(l="overview"),window.switchRepoTab(l)}s(),document.addEventListener("keydown",i=>{if(i.target.tagName==="INPUT"||i.target.tagName==="TEXTAREA"||i.target.tagName==="SELECT"||i.target.isContentEditable||i.ctrlKey||i.metaKey||i.altKey)return;let l={o:"overview",l:"layers",v:"vulns",s:"sbom",a:"artifacts",c:"chart"}[i.key.toLowerCase()];l&&e.indexOf(l)!==-1&&window.switchRepoTab(l)}),document.body.addEventListener("htmx:afterSettle",i=>{i.detail.target&&i.detail.target.id==="tag-content"&&s()})}document.addEventListener("DOMContentLoaded",()=>{Ge(),Qe()});function Ze(){let e=Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')),t=Array.from(document.querySelectorAll(".settings-tab-mobile"));if(!e.length&&!t.length)return;function n(s,i){let a=i==="vertical"?"ArrowUp":"ArrowLeft",l=i==="vertical"?"ArrowDown":"ArrowRight";s.forEach(c=>{c.addEventListener("keydown",u=>{let d=s.indexOf(u.currentTarget);if(d===-1)return;let f=null;u.key===a?f=s[(d-1+s.length)%s.length]:u.key===l?f=s[(d+1)%s.length]:u.key==="Home"?f=s[0]:u.key==="End"&&(f=s[s.length-1]),f&&(u.preventDefault(),f.focus(),f.click())})})}n(e,"vertical"),n(t,"horizontal");function r(){let s=t.find(i=>i.getAttribute("aria-selected")==="true");s&&s.scrollIntoView({inline:"center",block:"nearest"})}r();function o(s){e.forEach(i=>{let a=i.parentElement.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.parentElement.classList.toggle("menu-active",a)}),t.forEach(i=>{let a=i.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.classList.toggle("btn-secondary",a),i.classList.toggle("btn-ghost",!a)}),r()}[...e,...t].forEach(s=>{s.addEventListener("click",()=>o(s.dataset.tab||s.parentElement.dataset.tab))}),document.body.addEventListener("htmx:historyRestore",()=>{let s=location.pathname.match(/^\/settings\/(user|storage|billing|devices|webhooks|advanced)/);s&&o(s[1])})}function et(){document.addEventListener("click",function(n){let r=n.target.closest("#delete-account-btn");r&&t(r)});function e(n){let r=document.createElement("div");return r.textContent=n,r.innerHTML}function t(n){let r=n.dataset.clientShortName||"this account",s="DELETE "+(n.dataset.profileHandle||""),i=document.getElementById("delete-pds-records").checked,a=document.createElement("div");a.className="modal modal-open",a.innerHTML=` - `,document.body.appendChild(a);let l=document.getElementById("confirm-delete-input"),c=document.getElementById("confirm-delete"),d=document.getElementById("cancel-delete");setTimeout(()=>l.focus(),100),l.addEventListener("input",function(){c.disabled=this.value!==s}),l.addEventListener("keydown",function(m){m.key==="Enter"&&this.value===s&&f()}),d.addEventListener("click",()=>a.remove()),document.getElementById("modal-backdrop").addEventListener("click",()=>a.remove());function u(m){m.key==="Escape"&&(a.remove(),document.removeEventListener("keydown",u))}document.addEventListener("keydown",u),c.addEventListener("click",f);async function f(){let m=document.getElementById("delete-pds-records").checked;c.disabled=!0,c.innerHTML=' Deleting...',d.disabled=!0;try{let h=await fetch("/api/account",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({delete_pds_records:m,confirmation:s})}),g=await h.json();if(h.ok&&g.success)a.querySelector(".modal-box").innerHTML=` + `,document.body.appendChild(a);let l=document.getElementById("confirm-delete-input"),c=document.getElementById("confirm-delete"),u=document.getElementById("cancel-delete");setTimeout(()=>l.focus(),100),l.addEventListener("input",function(){c.disabled=this.value!==s}),l.addEventListener("keydown",function(m){m.key==="Enter"&&this.value===s&&f()}),u.addEventListener("click",()=>a.remove()),document.getElementById("modal-backdrop").addEventListener("click",()=>a.remove());function d(m){m.key==="Escape"&&(a.remove(),document.removeEventListener("keydown",d))}document.addEventListener("keydown",d),c.addEventListener("click",f);async function f(){let m=document.getElementById("delete-pds-records").checked;c.disabled=!0,c.innerHTML=' Deleting...',u.disabled=!0;try{let h=await fetch("/api/account",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({delete_pds_records:m,confirmation:s})}),g=await h.json();if(h.ok&&g.success)a.querySelector(".modal-box").innerHTML=`

Account Deleted @@ -73,4 +73,4 @@ var Le=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:n - `,a.querySelector("[data-dismiss-modal]").addEventListener("click",()=>a.remove())}}}}document.addEventListener("DOMContentLoaded",()=>{Ge(),Qe()});var ee="showEmptyLayers";function Ze(e,t,n){let r=0;for(let o=t;o('+s+' layers, click to expand)'+l+"",c.addEventListener("click",()=>{c.remove();for(let d=o;d{n.style.display=t?"":"none"})}function Te(e){let t=e||document;(t.querySelectorAll?t.querySelectorAll(".layers-table:not([data-layers-processed])"):[]).forEach(r=>{r.setAttribute("data-layers-processed","1"),tt(r),xe(r)})}function nt(e){localStorage.setItem(ee,e),document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),document.querySelectorAll(".layers-table").forEach(xe)}document.addEventListener("DOMContentLoaded",()=>{let e=localStorage.getItem(ee)==="true";document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),Te()});document.addEventListener("change",e=>{e.target.matches("[data-toggle-empty-layers]")&&nt(e.target.checked)});document.body.addEventListener("htmx:afterSettle",e=>{e.target&&e.target.querySelectorAll&&Te(e.target)});window.htmx=O;O.config.methodsThatUseUrlParams=["get"]; + `,a.querySelector("[data-dismiss-modal]").addEventListener("click",()=>a.remove())}}}}document.addEventListener("DOMContentLoaded",()=>{Ze(),et()});var ee="showEmptyLayers";function tt(e,t,n){let r=0;for(let o=t;o('+s+' layers, click to expand)'+l+"",c.addEventListener("click",()=>{c.remove();for(let u=o;u{n.style.display=t?"":"none"})}function Ce(e){let t=e||document;(t.querySelectorAll?t.querySelectorAll(".layers-table:not([data-layers-processed])"):[]).forEach(r=>{r.setAttribute("data-layers-processed","1"),rt(r),Se(r)})}function ot(e){localStorage.setItem(ee,e),document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),document.querySelectorAll(".layers-table").forEach(Se)}document.addEventListener("DOMContentLoaded",()=>{let e=localStorage.getItem(ee)==="true";document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),Ce()});document.addEventListener("change",e=>{e.target.matches("[data-toggle-empty-layers]")&&ot(e.target.checked)});document.body.addEventListener("htmx:afterSettle",e=>{e.target&&e.target.querySelectorAll&&Ce(e.target)});window.htmx=O;O.config.methodsThatUseUrlParams=["get"]; diff --git a/pkg/appview/src/js/app.js b/pkg/appview/src/js/app.js index bc2c830..b37ec3a 100644 --- a/pkg/appview/src/js/app.js +++ b/pkg/appview/src/js/app.js @@ -438,6 +438,11 @@ async function deleteUntaggedManifests(repository) { const confirmBtn = document.getElementById('confirm-untagged-delete-btn'); const originalText = confirmBtn.textContent; + const restoreButton = () => { + confirmBtn.disabled = false; + confirmBtn.textContent = originalText; + }; + try { confirmBtn.disabled = true; confirmBtn.textContent = 'Deleting...'; @@ -449,26 +454,40 @@ async function deleteUntaggedManifests(repository) { body: JSON.stringify({ repo: repository }), }); - const data = await response.json(); + const raw = await response.text(); + let data = null; + try { + data = raw ? JSON.parse(raw) : null; + } catch (_) { + // Non-JSON body (e.g., upstream proxy error page) — fall through. + } if (response.ok) { + const deleted = (data && data.deleted) || 0; + const failed = (data && data.failed) || 0; closeDialog(document.getElementById('untagged-delete-modal')); - showToast(`Deleted ${data.deleted} untagged manifest(s)`, 'success'); - if (data.deleted > 0) { - location.reload(); + if (failed > 0) { + showToast(`Deleted ${deleted} of ${deleted + failed} untagged manifest(s); ${failed} failed`, 'error'); + } else if (deleted > 0) { + showToast(`Deleted ${deleted} untagged manifest(s)`, 'success'); + } else { + showToast('No untagged manifests to delete', 'info'); } - confirmBtn.disabled = false; - confirmBtn.textContent = originalText; + if (deleted > 0) { + location.reload(); + return; + } + restoreButton(); } else { - showToast(`Failed to delete untagged manifests: ${data.error || 'Unknown error'}`, 'error'); - confirmBtn.disabled = false; - confirmBtn.textContent = originalText; + const errMsg = (data && data.error) || raw || `HTTP ${response.status}`; + const partial = data && data.deleted ? ` (${data.deleted} succeeded before failure)` : ''; + showToast(`Failed to delete untagged manifests: ${errMsg}${partial}`, 'error'); + restoreButton(); } } catch (err) { console.error('Error deleting untagged manifests:', err); showToast(`Error: ${err.message}`, 'error'); - confirmBtn.disabled = false; - confirmBtn.textContent = originalText; + restoreButton(); } } @@ -613,6 +632,51 @@ document.addEventListener('DOMContentLoaded', () => { if (sel) sel.addEventListener('change', () => updatePullCommand(sel.value)); }); +// Helm install/pull switcher. Persisted via localStorage (no profile setting +// — too minor to round-trip through the API). Uses event delegation + +// htmx:afterSettle so it survives the repo page's HTMX tag swaps; binding +// on DOMContentLoaded alone would only catch the first render. +function helmCmdSwitcher_render(mode) { + const container = document.getElementById('helm-cmd-container'); + if (!container) return; + const registryURL = container.dataset.registryUrl; + const ownerHandle = container.dataset.ownerHandle; + const repoName = container.dataset.repoName; + const tag = container.dataset.tag || ''; + const ociRef = 'oci://' + registryURL + '/' + ownerHandle + '/' + repoName; + const versionFlag = tag ? ' --version ' + tag : ''; + const cmd = mode === 'pull' + ? 'helm pull ' + ociRef + versionFlag + : 'helm install ' + repoName + ' ' + ociRef + versionFlag; + const display = document.getElementById('helm-cmd-display'); + if (!display) return; + const code = display.querySelector('code'); + if (code) code.textContent = cmd; + const btn = display.querySelector('[data-cmd]'); + if (btn) btn.dataset.cmd = cmd; +} + +function helmCmdSwitcher_apply() { + const sel = document.getElementById('helm-cmd-switcher'); + if (!sel) return; + const saved = lsGet('helm-cmd'); + if (saved === 'install' || saved === 'pull') { + sel.value = saved; + } + helmCmdSwitcher_render(sel.value); +} + +document.addEventListener('DOMContentLoaded', helmCmdSwitcher_apply); +document.body.addEventListener('htmx:afterSettle', helmCmdSwitcher_apply); + +// Delegated change handler: works on the initial render AND on any switcher +// HTMX swaps into the DOM later (repo page tag switch). +document.addEventListener('change', (e) => { + if (!e.target || e.target.id !== 'helm-cmd-switcher') return; + lsSet('helm-cmd', e.target.value); + helmCmdSwitcher_render(e.target.value); +}); + // Install page: platform tab switcher. Each .platform-tab has data-platform // pointing at a sibling panel (#-content). No-op off the install page. document.addEventListener('DOMContentLoaded', () => { diff --git a/pkg/appview/src/js/repository.js b/pkg/appview/src/js/repository.js index 6b0ce7c..6fb5b3e 100644 --- a/pkg/appview/src/js/repository.js +++ b/pkg/appview/src/js/repository.js @@ -202,7 +202,7 @@ document.body.addEventListener('htmx:beforeSwap', () => { function initTabController() { if (!document.getElementById('tag-content')) return; - const validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts']; + const validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts', 'chart']; // State per target id: 'loading' while a request is in-flight, 'loaded' // on success. On error we clear the entry so the retry button can // trigger a fresh fetch; without a separate 'loading' marker, a failing @@ -328,6 +328,7 @@ function initTabController() { if (tabId === 'layers') { const u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); } if (tabId === 'vulns') { const u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); } if (tabId === 'sbom') { const u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); } + if (tabId === 'chart') { const u = contentUrl('chart'); if (u) lazyLoad('chart-content', u); } }; function initTabs() { @@ -338,6 +339,7 @@ function initTabController() { ['layers-tab-btn', 'layers-content', () => contentUrl('layers')], ['vulns-tab-btn', 'vulns-content', () => contentUrl('vulns')], ['sbom-tab-btn', 'sbom-content', () => contentUrl('sbom')], + ['chart-tab-btn', 'chart-content', () => contentUrl('chart')], ]; prefetch.forEach(([btnId, targetId, urlFn]) => { const btn = document.getElementById(btnId); @@ -386,7 +388,7 @@ function initTabController() { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT' || e.target.isContentEditable) return; if (e.ctrlKey || e.metaKey || e.altKey) return; - const map = { o: 'overview', l: 'layers', v: 'vulns', s: 'sbom', a: 'artifacts' }; + const map = { o: 'overview', l: 'layers', v: 'vulns', s: 'sbom', a: 'artifacts', c: 'chart' }; const tab = map[e.key.toLowerCase()]; if (tab && validTabs.indexOf(tab) !== -1) window.switchRepoTab(tab); }); diff --git a/pkg/appview/templates/components/pull-command-switcher.html b/pkg/appview/templates/components/pull-command-switcher.html index 8329365..7d8569f 100644 --- a/pkg/appview/templates/components/pull-command-switcher.html +++ b/pkg/appview/templates/components/pull-command-switcher.html @@ -9,13 +9,26 @@ Anonymous users: saves to localStorage. */}} {{ if eq .ArtifactType "helm-chart" }} -
-

Pull this chart

- {{ if .Tag }} - {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Tag) }} - {{ else }} - {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName) }} - {{ end }} + {{/* Helm chart: small install/pull toggle. JS in app.js wires the + switcher and persists the choice in localStorage. Default = install. */}} + {{ $versionFlag := "" }} + {{ if .Tag }}{{ $versionFlag = print " --version " .Tag }}{{ end }} + {{ $ociRef := print "oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName }} +
+ +
+ +
+ {{ template "docker-command" (print "helm install " .RepoName " " $ociRef $versionFlag) }} +
+
{{ else }}
- + {{/* Helm chart with parsed metadata: lead with chart name + version so the page reads as a chart, not a digest. */}} + {{ $helmMeta := "" }} + {{ if and .HelmContent .HelmContent.Meta }}{{ $helmMeta = .HelmContent.Meta }}{{ end }} +
- {{ if .Manifest.Tags }} + {{ if $helmMeta }} +

+ {{ icon "helm" "size-5 text-base-content/70 shrink-0" }} + {{ $helmMeta.Name }} + {{ if $helmMeta.Version }}{{ $helmMeta.Version }}{{ end }} +

+ Helm chart + {{ else if .Manifest.Tags }}

{{ range $i, $tag := .Manifest.Tags }}{{ if lt $i 3 }}{{ if $i }}{{ end }}{{ $tag }}{{ end }}{{ end }}{{ if gt (len .Manifest.Tags) 3 }}+{{ sub (len .Manifest.Tags) 3 }} more{{ end }}

{{ else }}

{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }}

{{ end }} {{ if .Manifest.IsManifestList }} Multi-arch - {{ else if eq .Manifest.ArtifactType "helm-chart" }} + {{ else if and (eq .Manifest.ArtifactType "helm-chart") (not $helmMeta) }} {{ icon "helm" "size-3" }} Helm {{ end }} {{ if .Manifest.HasAttestations }} {{ icon "shield-check" "size-3" }} Attested {{ end }}
+ {{ if $helmMeta }} + {{/* Subtitle: appVersion + chart type */}} +
+ {{ if $helmMeta.AppVersion }}appVersion {{ $helmMeta.AppVersion }}{{ if $helmMeta.Type }} · {{ end }}{{ end }}{{ if $helmMeta.Type }}{{ $helmMeta.Type }} chart{{ end }} + {{ if $helmMeta.Deprecated }}Deprecated{{ end }} +
+ {{ if .Manifest.Tags }} +
+ {{ range .Manifest.Tags }}{{ . }}{{ end }} +
+ {{ end }} + {{ end }}
{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }} @@ -81,7 +103,7 @@ hx-swap="innerHTML">
- +
{{ if .Manifest.IsManifestList }} {{ if .Manifest.Platforms }} @@ -98,6 +120,8 @@ {{ else }}

No platform manifests found for this image index.

{{ end }} + {{ else if eq .Manifest.ArtifactType "helm-chart" }} + {{ template "helm-digest-content" (dict "Manifest" .Manifest "HelmContent" .HelmContent "RegistryURL" .RegistryURL "OwnerHandle" .Owner.Handle "RepoName" .Repository "OciClient" .OciClient "IsLoggedIn" (ne .User nil)) }} {{ else }} {{ template "digest-content" . }} {{ end }} diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 0a2df3b..fccdf99 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -120,29 +120,9 @@
{{ end }}
- {{ if .SelectedTag.Info.IsMultiArch }} -
- {{ range .SelectedTag.Info.Platforms }} - {{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }} - {{ end }} -
- {{ else if gt (len .SelectedTag.Info.Platforms) 0 }} - {{ $p := index .SelectedTag.Info.Platforms 0 }} - {{ if $p.OS }} -
- {{ $p.OS }}/{{ $p.Architecture }}{{ if $p.Variant }}/{{ $p.Variant }}{{ end }} -
- {{ end }} - {{ end }} - {{ if .SelectedTag.Info.HasAttestations }} - - {{ end }} + {{/* Platform / attestation badges live INSIDE repo-tag-section + so they re-render with each tag swap. Putting them here + would freeze them at the page's initial-load tag. */}}
{{ end }} diff --git a/pkg/appview/templates/partials/helm-digest-content.html b/pkg/appview/templates/partials/helm-digest-content.html new file mode 100644 index 0000000..8579a0f --- /dev/null +++ b/pkg/appview/templates/partials/helm-digest-content.html @@ -0,0 +1,92 @@ +{{ define "helm-digest-content" }} +{{/* + Helm-aware digest content WITH the install/pull command. Used on the + digest detail page (/d///) where there's no other + install switcher above. The repo page's chart tab uses helm-chart-info + (without the install card) since repo-tag-section already renders an + install switcher at the top. + + Expects dict: Manifest, HelmContent, RegistryURL, OwnerHandle, RepoName, + OciClient, IsLoggedIn. +*/}} +
+ {{ if .HelmContent.HoldUnreachable }} +
+ {{ icon "wifi-off" "size-4 shrink-0" }} +
+

We couldn't reach the hold

+

Chart metadata is stored on the hold. It may be offline right now.

+
+
+ {{ end }} + + {{/* Install / Pull command — reuses the existing switcher component + (helm branch renders an install/pull toggle). */}} + {{ $tag := "" }} + {{ if .Manifest.Tags }}{{ $tag = index .Manifest.Tags 0 }}{{ end }} + {{ if and (not $tag) .HelmContent.Meta }}{{ $tag = .HelmContent.Meta.Version }}{{ end }} +
+ {{ template "pull-command-switcher" (dict + "RegistryURL" .RegistryURL + "OwnerHandle" .OwnerHandle + "RepoName" .RepoName + "Tag" $tag + "ArtifactType" "helm-chart" + "OciClient" .OciClient + "IsLoggedIn" .IsLoggedIn + ) }} +
+ + {{ template "helm-chart-info" . }} +
+{{ end }} + +{{ define "helm-chart-info" }} +{{/* + Chart metadata + artifact card + scanning note. No install switcher — + callers are expected to render one elsewhere on the page if needed. + Standard tab-content padding (space-y-4 min-w-0 pt-6) matches the + layers/vulns/sbom partials so the chart tab lines up visually. + + Expects dict: HelmContent (and the rest is ignored). +*/}} +
+
+ {{/* Metadata (left, ~2/3) */}} +
+ {{ if .HelmContent.Meta }} + {{ template "helm-metadata" .HelmContent.Meta }} + {{ else if .HelmContent.MetaFetchFailed }} +
+

Couldn't read chart metadata

+

The hold is reachable but didn't return a valid Chart.yaml for this digest. The chart tarball is still pullable below.

+
+ {{ end }} +
+ + {{/* Artifact + scanning note (right, ~1/3) */}} +
+
+

Chart artifact

+ {{ if .HelmContent.Tarball }} +
+

{{ .HelmContent.Tarball.MediaType }}

+

{{ humanizeBytes .HelmContent.Tarball.Size }}

+
+ {{ truncateDigest (trimPrefix "sha256:" .HelmContent.Tarball.Digest) 16 }} + +
+
+ {{ else }} +

No chart tarball recorded for this digest.

+ {{ end }} +
+ +
+

About scanning

+

ATCR doesn't scan helm charts for vulnerabilities. Run a chart linter such as kube-linter in your CI before publishing.

+
+
+
+
+{{ end }} diff --git a/pkg/appview/templates/partials/helm-metadata.html b/pkg/appview/templates/partials/helm-metadata.html new file mode 100644 index 0000000..6401a05 --- /dev/null +++ b/pkg/appview/templates/partials/helm-metadata.html @@ -0,0 +1,115 @@ +{{ define "helm-metadata" }} +{{/* + Renders parsed Chart.yaml metadata as a description list. Expects a + HelmChartMeta value. Rows are skipped silently when fields are empty so + sparse charts don't render with a wall of "—" cells. +*/}} +
+ {{ if .Description }} +
+

Description

+

{{ .Description }}

+
+ {{ end }} + +
+ {{ if .Type }} +
Type
+
{{ .Type }}
+ {{ end }} + {{ if .AppVersion }} +
App version
+
{{ .AppVersion }}
+ {{ end }} + {{ if .KubeVersion }} +
Kube version
+
{{ .KubeVersion }}
+ {{ end }} + {{ if .Home }} +
Home
+
+ {{ if or (hasPrefix .Home "http://") (hasPrefix .Home "https://") }} + {{ .Home }} + {{ else }} + {{/* Non-http schemes (oci://, etc.) get sanitized to #ZgotmplZ + in href context. Render as plain text so the user can + still see and copy the value. */}} + {{ .Home }} + {{ end }} +
+ {{ end }} + {{ if .Sources }} +
Sources
+
+ {{ range .Sources }} +
+ {{ if or (hasPrefix . "http://") (hasPrefix . "https://") }} + {{ . }} + {{ else }} + {{ . }} + {{ end }} +
+ {{ end }} +
+ {{ end }} + {{ if .Keywords }} +
Keywords
+
+ {{ range .Keywords }}{{ . }}{{ end }} +
+ {{ end }} + {{ if .Maintainers }} +
Maintainers
+
+ {{ range .Maintainers }} +
+ {{ if and .URL (or (hasPrefix .URL "http://") (hasPrefix .URL "https://")) }} + {{ if .Name }}{{ .Name }}{{ else }}{{ .URL }}{{ end }} + {{ else if .Name }} + {{ .Name }} + {{ else if .URL }} + {{ .URL }} + {{ end }} + {{ if .Email }}<{{ .Email }}>{{ end }} +
+ {{ end }} +
+ {{ end }} +
+ + {{ if .Dependencies }} +
+

Dependencies

+
+ + + + + + + + + + {{ range .Dependencies }} + + + + + + {{ end }} + +
NameVersionRepository
{{ .Name }}{{ if .Alias }} ({{ .Alias }}){{ end }}{{ .Version }} + {{ if .Repository }} + {{ if or (hasPrefix .Repository "http://") (hasPrefix .Repository "https://") }} + {{ .Repository }} + {{ else }} + {{/* oci://, file://, etc. — render as code so it's copyable. */}} + {{ .Repository }} + {{ end }} + {{ end }} +
+
+
+ {{ end }} +
+{{ end }} diff --git a/pkg/appview/templates/partials/repo-tag-section.html b/pkg/appview/templates/partials/repo-tag-section.html index de5f66f..eeaf345 100644 --- a/pkg/appview/templates/partials/repo-tag-section.html +++ b/pkg/appview/templates/partials/repo-tag-section.html @@ -1,6 +1,34 @@ {{ define "repo-tag-section" }}
{{ if .SelectedTag }} + {{/* Tag-scoped badges (platform / attested). These live INSIDE the + HTMX-swapped section so they re-render on each tag change. + Putting them in pages/repository.html freezes them at page load. */}} +
+ {{ if .SelectedTag.Info.IsMultiArch }} + {{ range .SelectedTag.Info.Platforms }} + {{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }} + {{ end }} + Multi-arch + {{ else if gt (len .SelectedTag.Info.Platforms) 0 }} + {{ $p := index .SelectedTag.Info.Platforms 0 }} + {{ if $p.OS }} + {{ $p.OS }}/{{ $p.Architecture }}{{ if $p.Variant }}/{{ $p.Variant }}{{ end }} + {{ end }} + {{ end }} + {{ if eq .ArtifactType "helm-chart" }} + {{ icon "helm" "size-3" }} Helm + {{ end }} + {{ if .SelectedTag.Info.HasAttestations }} + + {{ end }} +
{{ template "pull-command-switcher" (dict "RegistryURL" .RegistryURL "OwnerHandle" .Owner.Handle "RepoName" .Repository.Name "Tag" .SelectedTag.Info.Tag.Tag "ArtifactType" .ArtifactType "OciClient" .OciClient "IsLoggedIn" (ne .User nil)) }} @@ -87,6 +115,19 @@ Overview {{ if .SelectedTag }} + {{ if eq .ArtifactType "helm-chart" }} + {{/* Helm charts: a single Chart tab replaces Layers/Vulns/SBOM — + those concepts don't apply. */}} + + {{ else }} +
+

Backfill is idempotent: present records are left alone.

+ {{else}} +
+ {{ icon "check-circle" "size-5" }} + Every manifest already has an image config record. Nothing to backfill. +
+ {{end}} +
+{{end}} diff --git a/pkg/hold/admin/templates/partials/gc_backfill_result.html b/pkg/hold/admin/templates/partials/gc_backfill_result.html new file mode 100644 index 0000000..c038bf7 --- /dev/null +++ b/pkg/hold/admin/templates/partials/gc_backfill_result.html @@ -0,0 +1,31 @@ +{{define "partials/gc_backfill_result.html"}} +
+
+
+
+
Created
+
{{.Result.RecordsReconciled}}
+
New image config records
+
+
+
+
+
Skipped
+
{{.Result.RecordsSkipped}}
+
Already had a record
+
+
+
+
+
Duration
+
{{formatDuration .Result.Duration}}
+
Wall-clock time
+
+
+
+ +

+ Run Scan again to verify nothing else is missing. +

+
+{{end}} diff --git a/pkg/hold/admin/templates/partials/tab_storage.html b/pkg/hold/admin/templates/partials/tab_storage.html index e2869b8..7ace056 100644 --- a/pkg/hold/admin/templates/partials/tab_storage.html +++ b/pkg/hold/admin/templates/partials/tab_storage.html @@ -42,13 +42,12 @@ Scan for Orphans
diff --git a/pkg/hold/gc/gc.go b/pkg/hold/gc/gc.go index fabf931..36cace7 100644 --- a/pkg/hold/gc/gc.go +++ b/pkg/hold/gc/gc.go @@ -63,6 +63,26 @@ type GCPreview struct { Duration time.Duration `json:"duration"` } +// BackfillConfigCandidate identifies one manifest that's missing its image +// config record on the hold. +type BackfillConfigCandidate struct { + ManifestURI string `json:"manifestUri"` + UserDID string `json:"userDid"` + Digest string `json:"digest"` +} + +// BackfillConfigsPreview is the dry-run output for the image-config backfill. +// No PDS writes or S3 fetches happen during preview — we only check which +// manifest digests already have an io.atcr.hold.image.config record. +type BackfillConfigsPreview struct { + Missing []BackfillConfigCandidate `json:"missing"` + MissingCount int `json:"missingCount"` + PresentCount int `json:"presentCount"` + ManifestsChecked int `json:"manifestsChecked"` + UsersAffected int `json:"usersAffected"` + Duration time.Duration `json:"duration"` +} + // GarbageCollector handles cleanup of orphaned blobs from storage type GarbageCollector struct { pds *pds.HoldPDS @@ -80,10 +100,12 @@ type GarbageCollector struct { running bool // Last results (for admin panel display) - lastPreview *GCPreview - lastPreviewAt time.Time - lastResult *GCResult - lastResultAt time.Time + lastPreview *GCPreview + lastPreviewAt time.Time + lastResult *GCResult + lastResultAt time.Time + lastBackfillPreview *BackfillConfigsPreview + lastBackfillPreviewAt time.Time // Progress tracking for background operations phase string // "manifests", "records", "blobs", "deleting", "complete", "error" @@ -105,6 +127,7 @@ type GCResult struct { OrphanedBlobs int64 `json:"orphaned_blobs"` ReferencedBlobs int64 `json:"referenced_blobs"` RecordsReconciled int64 `json:"records_reconciled"` + RecordsSkipped int64 `json:"records_skipped"` ManifestsChecked int64 `json:"manifests_checked"` UsersChecked int64 `json:"users_checked"` Duration time.Duration `json:"duration"` @@ -174,7 +197,7 @@ func (gc *GarbageCollector) setProgress(phase, msg, opType string) { type GCProgress struct { Phase string // "manifests", "records", "blobs", "deleting", "complete", "error" Message string - OperationType string // "preview", "run", "reconcile", "delete-records", "delete-blobs" + OperationType string // "preview", "run", "reconcile", "delete-records", "delete-blobs", "backfill-configs", "backfill-configs-preview" Running bool Error string } @@ -815,6 +838,16 @@ func (gc *GarbageCollector) reconcileMissingRecords(ctx context.Context, missing } } +// StartBackfillConfigsPreview launches a dry-run scan that classifies every +// manifest URI referenced from layer records as either already having an +// image config record or missing one. No PDS or S3 writes happen. +func (gc *GarbageCollector) StartBackfillConfigsPreview() bool { + return gc.startBackground("backfill-configs-preview", "records", "Scanning for manifests missing image config records...", func(ctx context.Context) error { + _, err := gc.doBackfillConfigsPreview(ctx) + return err + }) +} + // StartBackfillConfigs launches image config backfill in the background. // Creates io.atcr.hold.image.config records for manifests that don't have one yet // by fetching OCI config blobs from S3. @@ -825,33 +858,34 @@ func (gc *GarbageCollector) StartBackfillConfigs() bool { }) } -// doBackfillConfigs creates image config records for manifests that are missing them. -func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, error) { +// scanBackfillCandidates walks every layer record, dedupes the manifest URIs +// they reference, and bucket each one as already-present or missing an image +// config record. Returns missing candidates and the count of present. +// +// opType is the GC operationType used for progress messages so this helper +// can serve both the preview and the run. +func (gc *GarbageCollector) scanBackfillCandidates(ctx context.Context, opType string) (missing []BackfillConfigCandidate, presentCount int, err error) { recordsIndex := gc.pds.RecordsIndex() if recordsIndex == nil { - return nil, fmt.Errorf("records index not available") + return nil, 0, fmt.Errorf("records index not available") } - // Step 1: Collect unique manifest URIs from layer records manifestURIs := make(map[string]bool) cursor := "" totalScanned := 0 - for { - records, nextCursor, err := recordsIndex.ListRecords(atproto.LayerCollection, 1000, cursor, true) - if err != nil { - return nil, fmt.Errorf("list layer records: %w", err) + records, nextCursor, listErr := recordsIndex.ListRecords(atproto.LayerCollection, 1000, cursor, true) + if listErr != nil { + return nil, 0, fmt.Errorf("list layer records: %w", listErr) } - for _, rec := range records { totalScanned++ - layer, err := gc.decodeLayerRecord(ctx, rec) - if err != nil { + layer, decodeErr := gc.decodeLayerRecord(ctx, rec) + if decodeErr != nil { continue } manifestURIs[layer.Manifest] = true } - if nextCursor == "" { break } @@ -862,36 +896,99 @@ func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, e "manifests", len(manifestURIs), "layersScanned", totalScanned) - // Step 2: For each manifest, check if config record exists, create if not - start := time.Now() - result := &GCResult{} - created := int64(0) - skipped := int64(0) processed := 0 - httpClient := &http.Client{Timeout: 30 * time.Second} - for manifestURI := range manifestURIs { processed++ gc.setProgress("records", - fmt.Sprintf("Backfilling configs (%d/%d manifests)...", processed, len(manifestURIs)), - "backfill-configs") + fmt.Sprintf("Checking image configs (%d/%d manifests)...", processed, len(manifestURIs)), + opType) - aturi, err := syntax.ParseATURI(manifestURI) - if err != nil { - gc.logger.Warn("Invalid manifest URI", "uri", manifestURI, "error", err) + aturi, parseErr := syntax.ParseATURI(manifestURI) + if parseErr != nil { + gc.logger.Warn("Invalid manifest URI", "uri", manifestURI, "error", parseErr) continue } - manifestDigest := "sha256:" + aturi.RecordKey().String() - // Check if config record already exists - if _, _, err := gc.pds.GetImageConfigRecord(ctx, manifestDigest); err == nil { - skipped++ + if _, _, getErr := gc.pds.GetImageConfigRecord(ctx, manifestDigest); getErr == nil { + presentCount++ continue } + missing = append(missing, BackfillConfigCandidate{ + ManifestURI: manifestURI, + UserDID: aturi.Authority().String(), + Digest: manifestDigest, + }) + } + return missing, presentCount, nil +} - userDID := aturi.Authority().String() - manifestRkey := aturi.RecordKey().String() +// doBackfillConfigsPreview runs scanBackfillCandidates and stores the result +// for the admin UI to display. The full missing slice is kept in memory but +// rendering is capped via maxPreviewItems in the template layer. +func (gc *GarbageCollector) doBackfillConfigsPreview(ctx context.Context) (*BackfillConfigsPreview, error) { + start := time.Now() + + missing, presentCount, err := gc.scanBackfillCandidates(ctx, "backfill-configs-preview") + if err != nil { + return nil, err + } + + users := make(map[string]struct{}, len(missing)) + for _, c := range missing { + users[c.UserDID] = struct{}{} + } + + missingCount := len(missing) + display := missing + if len(display) > maxPreviewItems { + display = display[:maxPreviewItems] + } + + preview := &BackfillConfigsPreview{ + Missing: display, + MissingCount: missingCount, + PresentCount: presentCount, + ManifestsChecked: missingCount + presentCount, + UsersAffected: len(users), + Duration: time.Since(start), + } + + gc.mu.Lock() + gc.lastBackfillPreview = preview + gc.lastBackfillPreviewAt = time.Now() + gc.mu.Unlock() + + gc.logger.Info("Image config backfill preview complete", + "missing", missingCount, + "present", presentCount, + "usersAffected", preview.UsersAffected, + "duration", preview.Duration) + return preview, nil +} + +// doBackfillConfigs creates image config records for manifests that are missing them. +func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, error) { + start := time.Now() + + missing, presentCount, err := gc.scanBackfillCandidates(ctx, "backfill-configs") + if err != nil { + return nil, err + } + + result := &GCResult{RecordsSkipped: int64(presentCount)} + created := int64(0) + httpClient := &http.Client{Timeout: 30 * time.Second} + + for i, candidate := range missing { + gc.setProgress("records", + fmt.Sprintf("Backfilling configs (%d/%d missing)...", i+1, len(missing)), + "backfill-configs") + + userDID := candidate.UserDID + manifestRkey := strings.TrimPrefix(candidate.Digest, "sha256:") + manifestURI := candidate.ManifestURI + manifestDigest := candidate.Digest pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, userDID) if err != nil { @@ -899,7 +996,6 @@ func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, e continue } - // Fetch manifest via getRecord to get config digest reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", pdsEndpoint, url.QueryEscape(userDID), @@ -938,14 +1034,12 @@ func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, e continue } - // Fetch config blob from S3 configBytes, err := gc.s3.GetBytes(ctx, s3.BlobPath(manifest.Config.Digest)) if err != nil { gc.logger.Warn("Failed to fetch config blob", "digest", manifest.Config.Digest, "error", err) continue } - // Create image config record configRecord := atproto.NewImageConfigRecord(manifestURI, string(configBytes)) if _, _, err := gc.pds.CreateImageConfigRecord(ctx, configRecord, manifestDigest); err != nil { gc.logger.Warn("Failed to create image config record", "manifest", manifestURI, "error", err) @@ -963,7 +1057,9 @@ func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, e gc.lastResultAt = time.Now() gc.mu.Unlock() - gc.logger.Info("Image config backfill complete", "created", created, "skipped", skipped) + gc.logger.Info("Image config backfill complete", + "created", created, + "skipped", result.RecordsSkipped) return result, nil } @@ -1510,6 +1606,14 @@ func (gc *GarbageCollector) LastResult() (*GCResult, time.Time) { return gc.lastResult, gc.lastResultAt } +// LastBackfillPreview returns the most recent image-config backfill preview +// and when it was generated. +func (gc *GarbageCollector) LastBackfillPreview() (*BackfillConfigsPreview, time.Time) { + gc.mu.Lock() + defer gc.mu.Unlock() + return gc.lastBackfillPreview, gc.lastBackfillPreviewAt +} + // IsRunning returns whether a GC operation is currently in progress func (gc *GarbageCollector) IsRunning() bool { gc.mu.Lock() diff --git a/pkg/hold/pds/scan_broadcaster.go b/pkg/hold/pds/scan_broadcaster.go index 9c7ab19..380a08b 100644 --- a/pkg/hold/pds/scan_broadcaster.go +++ b/pkg/hold/pds/scan_broadcaster.go @@ -79,12 +79,13 @@ type ScanJobEvent struct { // ScannerMessage is a message received from scanner over WebSocket type ScannerMessage struct { - Type string `json:"type"` // "ack", "result", "error" + Type string `json:"type"` // "ack", "result", "error", "skipped" Seq int64 `json:"seq"` // Job sequence number SBOM string `json:"sbom,omitempty"` VulnReport string `json:"vulnReport,omitempty"` Summary *VulnerabilitySummary `json:"summary,omitempty"` Error string `json:"error,omitempty"` + Reason string `json:"reason,omitempty"` // Populated for "skipped" messages } // VulnerabilitySummary contains counts of vulnerabilities by severity @@ -447,6 +448,8 @@ func (sb *ScanBroadcaster) handleReader(sub *ScanSubscriber) { sb.handleResult(sub, msg) case "error": sb.handleError(sub, msg) + case "skipped": + sb.handleSkipped(sub, msg) default: slog.Warn("Unknown scanner message type", "type", msg.Type, @@ -579,12 +582,12 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage) "total", msg.Summary.Total) } -// handleError marks a job as failed and creates a scan record so the proactive -// scanner treats it as "stale" rather than "never scanned" (avoids retry loops). +// handleError marks a job as failed and creates a scan record so the stale +// loop won't immediately retry. Failed records still get retried on the +// rescan interval since failures may be transient (network, OOM, etc.). func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage) { ctx := context.Background() - // Get job details to create failure scan record var manifestDigest, repository, userDID string err := sb.db.QueryRow(` SELECT manifest_digest, repository, user_did @@ -594,13 +597,9 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage) slog.Error("Failed to get job details for failure record", "seq", msg.Seq, "error", err) } else { - // Create a scan record with zero counts and nil blobs — marks it as - // "scanned" so the proactive scheduler won't retry until rescan interval. - // Nil blobs signal failure to the appview (successful scans always have blobs). - scanRecord := atproto.NewScanRecord( + scanRecord := atproto.NewFailedScanRecord( manifestDigest, repository, userDID, - nil, nil, // no SBOM or vuln report — signals scan failure - 0, 0, 0, 0, 0, + msg.Error, "atcr-scanner-v1.0.0", ) if _, _, err := sb.pds.CreateScanRecord(ctx, scanRecord); err != nil { @@ -609,7 +608,6 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage) } } - // Mark job as failed _, err = sb.db.Exec(` UPDATE scan_jobs SET status = 'failed', completed_at = ? WHERE seq = ? @@ -620,7 +618,6 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage) "error", err) } - // Remove from in-flight tracking and wake dispatch loop sb.removeInflight(manifestDigest) sb.signalCompletion() @@ -630,6 +627,51 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage) "error", msg.Error) } +// handleSkipped marks a job complete and creates a scan record with +// status="skipped". The stale-scan loop will leave these records alone — the +// outcome won't change until the scanner gains support for the artifact type. +func (sb *ScanBroadcaster) handleSkipped(sub *ScanSubscriber, msg ScannerMessage) { + ctx := context.Background() + + var manifestDigest, repository, userDID string + err := sb.db.QueryRow(` + SELECT manifest_digest, repository, user_did + FROM scan_jobs WHERE seq = ? + `, msg.Seq).Scan(&manifestDigest, &repository, &userDID) + if err != nil { + slog.Error("Failed to get job details for skip record", + "seq", msg.Seq, "error", err) + } else { + scanRecord := atproto.NewSkippedScanRecord( + manifestDigest, repository, userDID, + msg.Reason, + "atcr-scanner-v1.0.0", + ) + if _, _, err := sb.pds.CreateScanRecord(ctx, scanRecord); err != nil { + slog.Error("Failed to store skipped scan record", + "seq", msg.Seq, "error", err) + } + } + + _, err = sb.db.Exec(` + UPDATE scan_jobs SET status = 'completed', completed_at = ? + WHERE seq = ? + `, time.Now(), msg.Seq) + if err != nil { + slog.Error("Failed to mark scan job as completed (skipped)", + "seq", msg.Seq, + "error", err) + } + + sb.removeInflight(manifestDigest) + sb.signalCompletion() + + slog.Info("Scan job skipped", + "seq", msg.Seq, + "subscriberId", sub.id, + "reason", msg.Reason) +} + // drainPendingJobs sends pending/timed-out jobs to a newly connected scanner. // Collects all pending rows first, closes cursor, then assigns and dispatches // to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY). @@ -1051,6 +1093,14 @@ func (sb *ScanBroadcaster) runStalePass() { continue } + // Permanently-skipped records (helm charts, in-toto, etc.) won't + // change outcome on retry — leave them alone. Failed records still + // get retried since failures may be transient. + if scanRecord.Status == atproto.ScanStatusSkipped { + sb.removeInflight(manifestDigest) + continue + } + scannedAt, err := time.Parse(time.RFC3339, scanRecord.ScannedAt) if err != nil { sb.removeInflight(manifestDigest) diff --git a/scanner/internal/client/hold.go b/scanner/internal/client/hold.go index 3e947b8..0a3f8e3 100644 --- a/scanner/internal/client/hold.go +++ b/scanner/internal/client/hold.go @@ -194,6 +194,13 @@ func (c *HoldClient) SendError(seq int64, errMsg string) { c.sendJSON(scanner.ErrorMessage{Type: "error", Seq: seq, Error: errMsg}) } +// SendSkipped sends a skipped message for an artifact the scanner intentionally +// won't process (e.g., helm charts). Distinct from SendError so the hold can +// distinguish a permanent skip from a retryable failure. +func (c *HoldClient) SendSkipped(seq int64, reason string) { + c.sendJSON(scanner.SkippedMessage{Type: "skipped", Seq: seq, Reason: reason}) +} + func (c *HoldClient) sendJSON(v any) { c.mu.Lock() defer c.mu.Unlock() diff --git a/scanner/internal/scan/worker.go b/scanner/internal/scan/worker.go index bedec63..b5f35d1 100644 --- a/scanner/internal/scan/worker.go +++ b/scanner/internal/scan/worker.go @@ -4,11 +4,11 @@ package scan import ( "context" + "errors" "fmt" "log/slog" "os" "runtime" - "strings" "sync" "time" @@ -18,6 +18,17 @@ import ( "atcr.io/scanner/internal/queue" ) +// SkipError is returned by processJob when the scanner intentionally bypasses +// an artifact type it can't analyze (helm charts, in-toto attestations, DSSE). +// The worker dispatches these to hold via SendSkipped so the hold can mark +// the scan record "skipped" instead of "failed". Skipped records are never +// retried by the stale-scan loop; failures are. +type SkipError struct { + Reason string +} + +func (e *SkipError) Error() string { return "skipped: " + e.Reason } + // WorkerPool manages a pool of scan workers type WorkerPool struct { cfg *config.Config @@ -94,15 +105,20 @@ func (wp *WorkerPool) worker(ctx context.Context, id int) { result, err := wp.processJob(ctx, job) if err != nil { - logLevel := slog.LevelError - if strings.HasPrefix(err.Error(), "skipped:") { - logLevel = slog.LevelInfo + var skipErr *SkipError + if errors.As(err, &skipErr) { + slog.Info("Scan job skipped", + "worker_id", id, + "repository", job.Repository, + "reason", skipErr.Reason) + wp.client.SendSkipped(job.Seq, skipErr.Reason) + } else { + slog.Error("Scan job failed", + "worker_id", id, + "repository", job.Repository, + "error", err) + wp.client.SendError(job.Seq, err.Error()) } - slog.Log(ctx, logLevel, "Scan job failed", - "worker_id", id, - "repository", job.Repository, - "error", err) - wp.client.SendError(job.Seq, err.Error()) } else { wp.client.SendResult(job.Seq, result) @@ -138,9 +154,12 @@ var unscannableConfigTypes = map[string]bool{ func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) { startTime := time.Now() - // Skip non-container OCI artifacts (Helm charts, WASM modules, etc.) + // Skip non-container OCI artifacts (Helm charts, in-toto, DSSE, etc.). + // Returning *SkipError tells the worker dispatch loop to send a "skipped" + // message rather than an "error" — the hold marks these records as + // permanently skipped and won't retry them on the rescan interval. if unscannableConfigTypes[job.Config.MediaType] { - return nil, fmt.Errorf("skipped: unscannable artifact type %s", job.Config.MediaType) + return nil, &SkipError{Reason: fmt.Sprintf("unscannable artifact type %s", job.Config.MediaType)} } // Ensure tmp dir exists diff --git a/scanner/types.go b/scanner/types.go index 056ab0e..03e4fd2 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -82,3 +82,14 @@ type ErrorMessage struct { Seq int64 `json:"seq"` Error string `json:"error"` } + +// SkippedMessage is sent from scanner to hold when an artifact is intentionally +// not scanned (e.g., helm charts, in-toto attestations). Distinct from +// ErrorMessage so the hold can mark the scan record as "skipped" rather than +// "failed" — the stale-scan loop will leave skipped records alone since the +// outcome won't change without a code change in the scanner. +type SkippedMessage struct { + Type string `json:"type"` // "skipped" + Seq int64 `json:"seq"` + Reason string `json:"reason"` +}