fix: scope and sort franchise relations
This commit is contained in:
@@ -2,83 +2,245 @@ package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func findFirstAnimeRelation(groups []JikanRelationGroup, relType string) *int {
|
||||
for _, group := range groups {
|
||||
if group.Relation == relType {
|
||||
for _, entry := range group.Entry {
|
||||
if entry.Type == "anime" {
|
||||
id := entry.MalID
|
||||
return &id
|
||||
}
|
||||
}
|
||||
var canonicalRelationOrder = []string{
|
||||
"prequel",
|
||||
"sequel",
|
||||
"parent story",
|
||||
"full story",
|
||||
"alternative version",
|
||||
"alternative setting",
|
||||
}
|
||||
|
||||
var extraRelationOrder = []string{
|
||||
"side story",
|
||||
"spin-off",
|
||||
"summary",
|
||||
"other",
|
||||
}
|
||||
|
||||
var relationPriorityOrder = append(
|
||||
append([]string{}, canonicalRelationOrder...),
|
||||
extraRelationOrder...,
|
||||
)
|
||||
|
||||
func relationKey(rel string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(rel))
|
||||
key = strings.ReplaceAll(key, "_", " ")
|
||||
key = strings.Join(strings.Fields(key), " ")
|
||||
|
||||
switch key {
|
||||
case "prequels":
|
||||
return "prequel"
|
||||
case "sequels":
|
||||
return "sequel"
|
||||
case "side stories":
|
||||
return "side story"
|
||||
case "spin off", "spinoff":
|
||||
return "spin-off"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func relationLabel(rel string) string {
|
||||
key := relationKey(rel)
|
||||
switch key {
|
||||
case "prequel":
|
||||
return "Prequels"
|
||||
case "sequel":
|
||||
return "Sequels"
|
||||
case "parent story":
|
||||
return "Parent story"
|
||||
case "full story":
|
||||
return "Full story"
|
||||
case "alternative version":
|
||||
return "Alternative version"
|
||||
case "alternative setting":
|
||||
return "Alternative setting"
|
||||
case "side story":
|
||||
return "Side story"
|
||||
case "spin-off":
|
||||
return "Spin-off"
|
||||
case "summary":
|
||||
return "Summary"
|
||||
case "other":
|
||||
return "Other"
|
||||
default:
|
||||
return strings.TrimSpace(rel)
|
||||
}
|
||||
}
|
||||
|
||||
func isCanonicalRelation(rel string) bool {
|
||||
return slices.Contains(canonicalRelationOrder, relationKey(rel))
|
||||
}
|
||||
|
||||
func isExtraRelation(rel string) bool {
|
||||
return slices.Contains(extraRelationOrder, relationKey(rel))
|
||||
}
|
||||
|
||||
func isFranchiseRelation(rel string) bool {
|
||||
return isCanonicalRelation(rel) || isExtraRelation(rel)
|
||||
}
|
||||
|
||||
func relationOrder(rel string) int {
|
||||
key := relationKey(rel)
|
||||
for i, allowed := range relationPriorityOrder {
|
||||
if key == allowed {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return len(relationPriorityOrder) + 1
|
||||
}
|
||||
|
||||
func (c *Client) fetchChain(ctx context.Context, startID int, direction string, visited map[int]bool) ([]RelationEntry, error) {
|
||||
anime, err := c.GetAnimeByID(ctx, startID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func relationAiredAt(anime Anime) (time.Time, bool) {
|
||||
from := strings.TrimSpace(anime.Aired.From)
|
||||
if from != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, from); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02", from); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
nextIDPtr := findFirstAnimeRelation(anime.Relations, direction)
|
||||
if nextIDPtr == nil {
|
||||
return nil, nil
|
||||
if anime.Year > 0 {
|
||||
return time.Date(anime.Year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
||||
}
|
||||
|
||||
nextID := *nextIDPtr
|
||||
if visited[nextID] { // prevent loops
|
||||
return nil, nil
|
||||
}
|
||||
visited[nextID] = true
|
||||
|
||||
nextAnime, err := c.GetAnimeByID(ctx, nextID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := RelationEntry{Anime: nextAnime, IsCurrent: false}
|
||||
rest, err := c.fetchChain(ctx, nextID, direction, visited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if direction == "Prequel" {
|
||||
return append(rest, entry), nil
|
||||
}
|
||||
return append([]RelationEntry{entry}, rest...), nil
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
func sortRelationEntriesChronological(entries []RelationEntry) {
|
||||
sort.SliceStable(entries, func(i int, j int) bool {
|
||||
left := entries[i]
|
||||
right := entries[j]
|
||||
|
||||
leftAiredAt, leftHasAiredAt := relationAiredAt(left.Anime)
|
||||
rightAiredAt, rightHasAiredAt := relationAiredAt(right.Anime)
|
||||
|
||||
if leftHasAiredAt != rightHasAiredAt {
|
||||
return leftHasAiredAt
|
||||
}
|
||||
|
||||
if leftHasAiredAt && !leftAiredAt.Equal(rightAiredAt) {
|
||||
return leftAiredAt.Before(rightAiredAt)
|
||||
}
|
||||
|
||||
leftRelationOrder := relationOrder(left.Relation)
|
||||
rightRelationOrder := relationOrder(right.Relation)
|
||||
if leftRelationOrder != rightRelationOrder {
|
||||
return leftRelationOrder < rightRelationOrder
|
||||
}
|
||||
|
||||
leftTitle := strings.ToLower(left.Anime.DisplayTitle())
|
||||
rightTitle := strings.ToLower(right.Anime.DisplayTitle())
|
||||
if leftTitle != rightTitle {
|
||||
return leftTitle < rightTitle
|
||||
}
|
||||
|
||||
return left.Anime.MalID < right.Anime.MalID
|
||||
})
|
||||
}
|
||||
|
||||
func relationEntries(ctx context.Context, c *Client, anime Anime) ([]RelationEntry, error) {
|
||||
entries := make([]RelationEntry, 0)
|
||||
|
||||
for _, group := range anime.Relations {
|
||||
if !isFranchiseRelation(group.Relation) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range group.Entry {
|
||||
if entry.Type != "anime" {
|
||||
continue
|
||||
}
|
||||
|
||||
relAnime, err := c.GetAnimeByID(ctx, entry.MalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries = append(entries, RelationEntry{
|
||||
Anime: relAnime,
|
||||
Relation: relationLabel(group.Relation),
|
||||
IsCurrent: false,
|
||||
IsExtra: !isCanonicalRelation(group.Relation),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func relationMap(ctx context.Context, c *Client, id int) (map[int]RelationEntry, error) {
|
||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
visited := map[int]bool{id: true}
|
||||
|
||||
prequels, err1 := c.fetchChain(ctx, id, "Prequel", visited)
|
||||
|
||||
visitedSeq := make(map[int]bool)
|
||||
maps.Copy(visitedSeq, visited)
|
||||
|
||||
sequels, err2 := c.fetchChain(ctx, id, "Sequel", visitedSeq)
|
||||
|
||||
var result []RelationEntry
|
||||
result = append(result, prequels...)
|
||||
result = append(result, RelationEntry{Anime: currentAnime, IsCurrent: true})
|
||||
result = append(result, sequels...)
|
||||
|
||||
var finalErr error
|
||||
if err1 != nil {
|
||||
finalErr = err1
|
||||
} else if err2 != nil {
|
||||
finalErr = err2
|
||||
result := map[int]RelationEntry{
|
||||
currentAnime.MalID: {
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
},
|
||||
}
|
||||
|
||||
return result, finalErr
|
||||
queue := []Anime{currentAnime}
|
||||
visited := map[int]bool{currentAnime.MalID: true}
|
||||
|
||||
for len(queue) > 0 {
|
||||
anime := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
entries, err := relationEntries(ctx, c, anime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, rel := range entries {
|
||||
existing, exists := result[rel.Anime.MalID]
|
||||
if !exists {
|
||||
result[rel.Anime.MalID] = rel
|
||||
} else if !existing.IsCurrent {
|
||||
if existing.IsExtra && !rel.IsExtra {
|
||||
// Prefer canonical timeline links over extras when both point to the same anime.
|
||||
result[rel.Anime.MalID] = rel
|
||||
} else if existing.IsExtra && rel.IsExtra && relationOrder(rel.Relation) < relationOrder(existing.Relation) {
|
||||
// Keep the most specific extra label when multiple extra relations exist.
|
||||
result[rel.Anime.MalID] = rel
|
||||
}
|
||||
}
|
||||
|
||||
if !rel.IsExtra && !visited[rel.Anime.MalID] {
|
||||
visited[rel.Anime.MalID] = true
|
||||
queue = append(queue, rel.Anime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
relationByID, err := relationMap(ctx, c, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ordered := make([]RelationEntry, 0, len(relationByID))
|
||||
for _, entry := range relationByID {
|
||||
ordered = append(ordered, entry)
|
||||
}
|
||||
|
||||
sortRelationEntriesChronological(ordered)
|
||||
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
@@ -170,7 +170,9 @@ type JikanRelationsResponse struct {
|
||||
|
||||
type RelationEntry struct {
|
||||
Anime Anime
|
||||
Relation string
|
||||
IsCurrent bool
|
||||
IsExtra bool
|
||||
}
|
||||
|
||||
func (a Anime) DisplayTitle() string {
|
||||
|
||||
@@ -253,17 +253,25 @@ func formatStatus(status string) string {
|
||||
|
||||
templ AnimeRelationsList(relations []jikan.RelationEntry) {
|
||||
if len(relations) > 1 {
|
||||
<div class="relations-grid">
|
||||
<div class="relations-controls">
|
||||
<button type="button" class="tab active" id="relations-main-tab" onclick="toggleRelationExtras(false)">Main timeline</button>
|
||||
<button type="button" class="tab" id="relations-extra-tab" onclick="toggleRelationExtras(true)">Show extras</button>
|
||||
</div>
|
||||
<div class="relations-grid" id="relations-grid">
|
||||
for _, rel := range relations {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: rel.Anime.MalID,
|
||||
Title: rel.Anime.DisplayTitle(),
|
||||
ImageURL: rel.Anime.ImageURL(),
|
||||
Class: "relation-card" + func() string { if rel.IsCurrent { return " current" }; return "" }(),
|
||||
Class: relationCardClass(rel),
|
||||
ImgClass: "relation-thumb",
|
||||
TitleClass: "relation-title",
|
||||
CurrentNode: rel.IsCurrent,
|
||||
})
|
||||
}) {
|
||||
if rel.Relation != "" && rel.Relation != "Current" {
|
||||
<div class="relation-type">{ rel.Relation }</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
@@ -271,6 +279,17 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
func relationCardClass(rel jikan.RelationEntry) string {
|
||||
base := "relation-card"
|
||||
if rel.IsCurrent {
|
||||
base += " current"
|
||||
}
|
||||
if rel.IsExtra {
|
||||
base += " relation-extra is-hidden"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
templ AnimeRecommendations(recs []jikan.Anime) {
|
||||
if len(recs) > 0 {
|
||||
<div class="relations-grid">
|
||||
|
||||
@@ -257,7 +257,7 @@ main {
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.grid-full-width {
|
||||
@@ -347,6 +347,12 @@ main {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.relation-type {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-faint);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.catalog-item:hover .catalog-title,
|
||||
.relation-card:hover .relation-title,
|
||||
.notification-card:hover .notification-title {
|
||||
@@ -782,6 +788,12 @@ main {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.relations-controls {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.anime-side-section h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-faint);
|
||||
|
||||
@@ -10,6 +10,36 @@
|
||||
|
||||
window.toggleDropdown = toggleDropdown
|
||||
|
||||
const toggleRelationExtras = (showExtras) => {
|
||||
const extras = document.querySelectorAll('.relation-extra')
|
||||
extras.forEach((item) => {
|
||||
item.classList.toggle('is-hidden', !showExtras)
|
||||
})
|
||||
|
||||
const mainTab = document.getElementById('relations-main-tab')
|
||||
const extraTab = document.getElementById('relations-extra-tab')
|
||||
if (mainTab) {
|
||||
mainTab.classList.toggle('active', !showExtras)
|
||||
}
|
||||
if (extraTab) {
|
||||
extraTab.classList.toggle('active', showExtras)
|
||||
}
|
||||
}
|
||||
|
||||
window.toggleRelationExtras = toggleRelationExtras
|
||||
window.addEventListener('load', () => toggleRelationExtras(false))
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (target.querySelector('#relations-grid') || target.id === 'relations-grid') {
|
||||
toggleRelationExtras(false)
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const dropdown = document.getElementById('watchlist-dropdown')
|
||||
if (!dropdown) {
|
||||
|
||||
Reference in New Issue
Block a user