Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions codex/librarian/onlinetag/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,21 @@ def serialize_candidate(c) -> dict[str, Any]:
# The series' ordinal volume. Metron's issue rows carry one;
# Comic Vine's search results have no equivalent.
"volume": getattr(summary, "volume", None),
# What tells apart candidates whose covers and titles read
# identically: a reprint volume, a foreign or a collected
# edition. Comic Vine fills most of these from rows it already
# fetched. Metron always has the start year, but gets the
# format, publisher and issue count only when titles collide.
# Absent before comicbox 5.4, and None wherever a source has
# nothing.
"series_start_year": getattr(summary, "series_start_year", None),
"series_publisher": getattr(summary, "series_publisher", None),
"series_issue_count": getattr(summary, "series_issue_count", None),
# Publication format, e.g. "Trade Paperback". Metron only.
"series_type": getattr(summary, "series_type", None),
# The issue's own title and one-line deck. Comic Vine only.
"issue_title": getattr(summary, "issue_title", None),
"issue_summary": getattr(summary, "issue_summary", None),
},
"score": float(c.score),
# What the blended score is made of. Two candidates can read
Expand Down
116 changes: 106 additions & 10 deletions frontend/src/components/online-tag/candidate-row.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
a link to the source's own page, and what the blended score is made
of (#854).

Covers are often near-identical too: a reprint volume, a foreign or a
collected edition. So two subdued lines under the title carry what the
source knows about the series (publisher, format, start year, issue
count) and about the issue (its title and one-line deck). Comic Vine
supplies most of it from rows comicbox already fetched. Metron always
has the start year, but its format, publisher and issue count cost
comicbox a request, so they arrive only when titles would otherwise
collide. Any of it may be absent, and a prompt cached before comicbox
carried it shows neither line.

The thumbnail loads straight from the source CDN. An <img> is exempt
from CORS, and codex's own img-src allows the two upload paths (see
_ONLINE_TAG_COVER_SECURE_CSP), so there is nothing to proxy. A blocked
Expand Down Expand Up @@ -44,9 +54,6 @@
<span v-if="candidate.summary.year" class="candidateYear">
({{ candidate.summary.year }})
</span>
<span v-if="candidate.summary.publisher" class="candidatePublisher">
&mdash; {{ candidate.summary.publisher }}
</span>
<!-- eslint-disable-next-line sonarjs/no-vue-bypass-sanitization -->
<a
v-if="candidate.url"
Expand All @@ -57,6 +64,13 @@
>{{ sourceLabel(candidate.source) }}
<v-icon size="x-small">{{ mdiOpenInNew }}</v-icon></a
>
<div v-if="seriesDetail" class="candidateSeriesDetail">
{{ seriesDetail }}
</div>
<!-- A deck can run long: clamped, the rest in the tooltip. -->
<div v-if="issueDetail" class="candidateIssueDetail" :title="issueDetail">
{{ issueDetail }}
</div>
<!-- Matching scores reprint series names too, so a comic filed
under a localized title matches a series name that looks
nothing like its filename. These are the reason. -->
Expand All @@ -67,10 +81,19 @@
{{ scoreDetail }}
</div>
</div>
<v-chip size="x-small" class="ml-2">{{ scorePercent }}%</v-chip>
<v-btn variant="tonal" size="small" color="primary" @click="$emit('pick')">
Pick
</v-btn>
<!-- Never shrinks, so long detail text wraps instead of squeezing
these. On phones it drops to its own line; see the styles. -->
<div class="candidateActions">
<v-chip size="x-small" class="ml-2">{{ scorePercent }}%</v-chip>
<v-btn
variant="tonal"
size="small"
color="primary"
@click="$emit('pick')"
>
Pick
</v-btn>
</div>
</div>
</template>

Expand Down Expand Up @@ -107,6 +130,31 @@ export default {
scorePercent() {
return Math.round(this.candidate.score * PERCENT);
},
seriesDetail() {
const {
seriesPublisher,
publisher,
seriesType,
seriesStartYear,
seriesIssueCount,
} = this.candidate.summary;
return [
seriesPublisher || publisher,
seriesType,
seriesStartYear ? `Series began ${seriesStartYear}` : "",
seriesIssueCount
? `${seriesIssueCount} ${seriesIssueCount === 1 ? "issue" : "issues"}`
: "",
]
.filter(Boolean)
.join(" · ");
},
issueDetail() {
const { issueTitle, issueSummary } = this.candidate.summary;
return [issueTitle ? `“${issueTitle}”` : "", issueSummary]
.filter(Boolean)
.join(" — ");
},
scoreDetail() {
// Prompts cached before these fields existed carry neither, and
// the headline percentage alone is still meaningful.
Expand Down Expand Up @@ -138,6 +186,9 @@ export default {
</script>

<style scoped lang="scss">
@use "vuetify/styles/settings/variables" as vuetify;
@use "sass:map";

.candidateRow {
display: flex;
align-items: center;
Expand All @@ -163,19 +214,64 @@ export default {

.candidateVolume,
.candidateYear,
.candidatePublisher,
.candidateSeriesDetail,
.candidateIssueDetail,
.candidateAka,
.candidateScoreDetail {
color: rgb(var(--v-theme-text-secondary));
}

.candidateAka,
.candidateScoreDetail {
.candidateSeriesDetail,
.candidateAka {
font-size: 0.8125rem;
}

// A step smaller: the deck and the score caveat are the least needed.
.candidateIssueDetail,
.candidateScoreDetail {
font-size: 0.75rem;
}

// A deck can run long. Two lines, then an ellipsis: one line left a
// phone-width row about a dozen characters, and touch has no tooltip.
// Same multi-line clamp as browser-table-cell.vue.
.candidateIssueDetail {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: break-word;
}

.candidateSourceLink {
margin-left: 8px;
white-space: nowrap;
}

.candidateActions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}

// A phone has no room for cover, text, score and Pick side by side; the
// text was left about 100px. The score and Pick wrap to their own line,
// right-aligned, so the text gets the width beside the cover. The text's
// zero basis keeps it on the cover's line however long it runs.
@media #{map.get(vuetify.$display-breakpoints, 'xs')} {
.candidateRow {
flex-wrap: wrap;
}

.candidateInfo {
flex-basis: 0;
}

.candidateActions {
flex-basis: 100%;
justify-content: flex-end;
}
}
</style>
88 changes: 88 additions & 0 deletions frontend/tests/unit/candidate-row.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ describe("CandidateRow details", () => {
expect(mountRow().find(".candidateVolume").exists()).toBe(false);
});

/*
* On phones the score and Pick wrap below the text as one unit, so the
* text gets the row's width. happy-dom applies no media queries; this
* pins the grouping the phone styles rely on.
*/
test("groups the score and Pick apart from the text", () => {
const wrapper = mountRow();
const actions = wrapper.find(".candidateRow > .candidateActions");

expect(actions.exists()).toBe(true);
expect(actions.find(".v-chip").text()).toBe("92%");
expect(actions.find("button").text()).toBe("Pick");
expect(wrapper.find(".candidateInfo .candidateActions").exists()).toBe(
false,
);
});

test("emits pick", async () => {
const wrapper = mountRow();

Expand All @@ -222,3 +239,74 @@ describe("CandidateRow details", () => {
expect(wrapper.emitted("pick")).toHaveLength(1);
});
});

/*
* Near-identical covers with identical titles are common: a reprint
* volume, a foreign or a collected edition. Two subdued lines under the
* title carry what tells them apart.
*/
describe("CandidateRow series and issue detail", () => {
const DETAIL = {
seriesPublisher: "DC Comics",
seriesType: "Ongoing Series",
seriesStartYear: 2016,
seriesIssueCount: 85,
issueTitle: "I Am Gotham, Part One",
issueSummary: "A new hero arrives.",
};

test("joins what the source knows about the series", () => {
const line = mountRow({ summary: DETAIL }).find(".candidateSeriesDetail");

expect(line.text()).toBe(
"DC Comics · Ongoing Series · Series began 2016 · 85 issues",
);
});

test("a one-issue series says issue, not issues", () => {
const line = mountRow({ summary: { seriesIssueCount: 1 } }).find(
".candidateSeriesDetail",
);

expect(line.text()).toBe("Dark Horse · 1 issue");
});

test("the series publisher wins over the scored one", () => {
const line = mountRow({ summary: { seriesPublisher: "Panini" } }).find(
".candidateSeriesDetail",
);

expect(line.text()).toBe("Panini");
});

test("the publisher has left the title line", () => {
const wrapper = mountRow();

expect(wrapper.find(".candidatePublisher").exists()).toBe(false);
expect(wrapper.find(".candidateSeriesDetail").text()).toBe("Dark Horse");
});

test("quotes the issue title before its deck, the whole as a tooltip", () => {
const line = mountRow({ summary: DETAIL }).find(".candidateIssueDetail");
const text = "“I Am Gotham, Part One” — A new hero arrives.";

expect(line.text()).toBe(text);
expect(line.attributes("title")).toBe(text);
});

test.each([
[{ issueTitle: "Title" }, "“Title”"],
[{ issueSummary: "Deck" }, "Deck"],
])("shows either half of the issue line alone", (summary, expected) => {
const line = mountRow({ summary }).find(".candidateIssueDetail");

expect(line.text()).toBe(expected);
});

test("a prompt cached before comicbox carried these shows neither line", () => {
const wrapper = mountRow({ summary: { publisher: null } });

expect(wrapper.find(".candidateSeriesDetail").exists()).toBe(false);
expect(wrapper.find(".candidateIssueDetail").exists()).toBe(false);
});
});
30 changes: 30 additions & 0 deletions tests/test_onlinetag_serialize_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
the serializer against real comicbox objects.
"""

from types import MappingProxyType, SimpleNamespace
from typing import Final

from comicbox.formats.base.online.profile import Candidate, CandidateSummary
Expand All @@ -25,6 +26,16 @@
_METADATA_SCORE: Final = 1.0
_COVER_SCORE: Final = 0.6
_VOLUME: Final = 2
_DETAIL: Final = MappingProxyType(
{
"series_start_year": 2016,
"series_publisher": "DC Comics",
"series_issue_count": 85,
"series_type": "Ongoing Series",
"issue_title": "I Am Gotham, Part One",
"issue_summary": "A new hero arrives.",
}
)


def _summary(
Expand Down Expand Up @@ -169,6 +180,25 @@ class _Bare:
assert data["cover_score"] is None
assert data["cover_hash_attempted"] is False
assert data["volume_id"] is None
for key in _DETAIL:
assert data["summary"][key] is None, key

def test_what_tells_identical_titles_apart_is_carried(self) -> None:
"""
Near-identical covers and titles differ in these.

The summary is a stand-in because the pinned comicbox predates
the fields; the serializer reads them by name either way.
"""
summary = SimpleNamespace(series="Batman", issue="1", year=2016, **_DETAIL)
candidate = SimpleNamespace(
source="comicvine", issue_id=1, score=0.5, summary=summary
)

data = serialize_candidate(candidate)

for key, value in _DETAIL.items():
assert data["summary"][key] == value, key

def test_display_fields_do_not_bump_the_prompt_version(self) -> None:
"""
Expand Down