diff --git a/conf/solr/conf/managed-schema.xml b/conf/solr/conf/managed-schema.xml
index 8356ccdf930..94e12804c59 100644
--- a/conf/solr/conf/managed-schema.xml
+++ b/conf/solr/conf/managed-schema.xml
@@ -166,6 +166,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/openlibrary/components/lit/OlScorecard.js b/openlibrary/components/lit/OlScorecard.js
new file mode 100644
index 00000000000..63cf82d4fc5
--- /dev/null
+++ b/openlibrary/components/lit/OlScorecard.js
@@ -0,0 +1,611 @@
+import { LitElement, html, css, nothing } from 'lit';
+
+let _idCounter = 0;
+
+/**
+ * OlScoreGauge - Circular gauge for OlScorecard: arc length and color track
+ * a 0-100 percentage. Used for the collapsed badge, the Total indicator, and
+ * each section tab. Internal helper, not part of the public component index.
+ */
+class OlScoreGauge extends LitElement {
+ static properties = {
+ percentage: { type: Number },
+ size: { type: String, reflect: true },
+ showPercent: { type: Boolean, attribute: 'show-percent' },
+ };
+
+ static styles = css`
+ :host {
+ display: inline-block;
+ }
+
+ .gauge {
+ position: relative;
+ border-radius: var(--border-radius-circle, 50%);
+ background-color: color-mix(in srgb, currentColor 18%, transparent);
+ }
+
+ .gauge svg {
+ display: block;
+ transform: rotate(-90deg);
+ }
+
+ .track {
+ fill: none;
+ stroke: currentColor;
+ stroke-opacity: 0.1;
+ }
+
+ .arc {
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ transition: stroke-dashoffset 0.3s;
+ }
+
+ .value {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--font-size-headline-small, 24px);
+ font-weight: normal;
+ }
+
+ :host([size="small"]) .value {
+ font-size: var(--font-size-label-medium, 12px);
+ font-weight: bold;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.percentage = 0;
+ this.size = 'default';
+ this.showPercent = false;
+ }
+
+ /**
+ * Threshold color for the current percentage, as a CSS custom-property
+ * reference with a literal fallback (never a hardcoded default), so a
+ * consumer can still override e.g. --scorecard-excellent from outside.
+ * @returns {String}
+ */
+ _colorVar() {
+ if (this.percentage >= 80) return 'var(--scorecard-excellent, #4caf50)';
+ if (this.percentage >= 60) return 'var(--scorecard-good, #8bc34a)';
+ if (this.percentage >= 40) return 'var(--scorecard-moderate, #ffc107)';
+ if (this.percentage >= 20) return 'var(--scorecard-needs-work, #ff9800)';
+ return 'var(--scorecard-poor, #f44336)';
+ }
+
+ /** @returns {import('lit').TemplateResult} */
+ render() {
+ const { percentage, showPercent, size } = this;
+ const diameter = size === 'small' ? 28 : 64;
+ const strokeWidth = size === 'small' ? 4 : 6;
+ const radius = (diameter - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const dashoffset = circumference * (1 - percentage / 100);
+ const center = diameter / 2;
+ const color = this._colorVar();
+
+ return html`
+
+
+ ${percentage}${showPercent ? '%' : ''}
+
+ `;
+ }
+}
+
+if (!customElements.get('ol-score-gauge')) {
+ customElements.define('ol-score-gauge', OlScoreGauge);
+}
+
+/**
+ * OlScorecard - A tabbed scorecard: each section is a gauge tab listing its
+ * failing checks (always shown) and passing checks (collapsed by default).
+ * A "Total" gauge and separator lead the tab row. Each check row expands to
+ * show its description via a trailing chevron.
+ *
+ * Collapsed by default to a small badge (gauge, name, chevron); click it to
+ * expand, click the header to collapse again.
+ *
+ * @element ol-scorecard
+ *
+ * @prop {Object} results - The scorecard data:
+ * `{ name, score, maxScore, sections: [{ name, score, maxScore, checks: [{ description, details, score, passing }] }] }`.
+ * Settable as a JSON attribute (`results='{"score":10,...}'`) or property.
+ * @prop {Boolean} expanded - Whether the full tabbed UI is shown instead of the
+ * collapsed badge. Default `false`. Presence of the attribute expands it.
+ * @prop {String} labelTotal - Label for the non-interactive "Total" gauge (default: "Total")
+ * @prop {String} labelFailingChecks - Failing checks section heading template, use `{count}` (default: "Failing Checks ({count})")
+ * @prop {String} labelPassingChecks - Passing checks section heading template, use `{count}` (default: "Passing Checks ({count})")
+ * @prop {String} labelPoints - Check point-value template, use `{score}` (default: "{score} points")
+ * @prop {String} labelExpand - Accessible label for the collapsed badge button, use `{name}`, `{percentage}` (default: "{name}: {percentage}%. Click to expand.")
+ * @prop {String} labelCollapse - Accessible label for the header collapse button (default: "Collapse")
+ * @prop {Boolean} outdated - Whether the solr record is stale relative to the database. Default `false`. Presence of the attribute shows a warning banner at the top of the expanded card; no banner is shown otherwise.
+ * @prop {String} labelOutdated - Warning banner text shown when outdated (default: "This record has been edited and is not yet reflected in Solr. It should update in a minute or so.")
+ *
+ * @example
+ *
+ */
+export class OlScorecard extends LitElement {
+ static properties = {
+ results: { type: Object },
+ expanded: { type: Boolean, reflect: true },
+ labelTotal: { type: String, attribute: 'label-total' },
+ labelFailingChecks: { type: String, attribute: 'label-failing-checks' },
+ labelPassingChecks: { type: String, attribute: 'label-passing-checks' },
+ labelPoints: { type: String, attribute: 'label-points' },
+ labelExpand: { type: String, attribute: 'label-expand' },
+ labelCollapse: { type: String, attribute: 'label-collapse' },
+ outdated: { type: Boolean },
+ labelOutdated: { type: String, attribute: 'label-outdated' },
+ _activeSection: { type: Number, state: true },
+ };
+
+ /** Chevron icon; rotated by callers to indicate open/closed state. */
+ static _chevronIcon = html``;
+
+ /** Clock icon; shown in the outdated-data warning banner. */
+ static _clockIcon = html``;
+
+ static styles = css`
+ :host {
+ display: inline-block;
+ border: var(--border-card, 1px solid #ddd);
+ border-radius: var(--border-radius-pill, 9999px);
+ padding: 0;
+ }
+
+ :host([expanded]) {
+ display: block;
+ max-width: 480px;
+ border-radius: var(--border-radius-card, 9px);
+ padding: var(--spacing-inset-sm, 8px) var(--spacing-inset-xs, 4px);
+ margin: var(--spacing-inset-md, 16px) 0;
+ }
+
+ .collapsed-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-inline-md, 8px);
+ background: none;
+ border: none;
+ font: inherit;
+ cursor: pointer;
+ padding: var(--spacing-inset-xs, 4px);
+ /* Room for chevron */
+ padding-right: 12px;
+ }
+
+ .collapsed-toggle:focus-visible {
+ outline: var(--focus-width, 2px) solid var(--color-focus-ring, #1a73e8);
+ outline-offset: 2px;
+ }
+
+ .collapsed-label {
+ font-size: var(--font-size-body-medium, 14px);
+ }
+
+ .collapsed-chevron {
+ width: 1em;
+ height: 1em;
+ flex-shrink: 0;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-inline-md, 8px);
+ width: 100%;
+ padding: var(--spacing-inset-md, 16px);
+ font-size: var(--font-size-title-medium, 16px);
+ background: none;
+ border: none;
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+ }
+
+ .header:focus-visible {
+ outline: var(--focus-width, 2px) solid var(--color-focus-ring, #1a73e8);
+ outline-offset: 2px;
+ }
+
+ .header-chevron {
+ width: 1.1em;
+ height: 1.1em;
+ flex-shrink: 0;
+ transform: rotate(180deg);
+ }
+
+ .tabs {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-inline-lg, 12px);
+ padding: var(--spacing-inset-lg, 24px);
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+
+ .tab-separator {
+ align-self: stretch;
+ width: var(--border-width-divider, 1px);
+ background: var(--color-border-subtle, #ddd);
+ }
+
+ .tab {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ background: none;
+ border: none;
+ border-radius: var(--border-radius-button, 6px);
+ font: inherit;
+ padding: var(--spacing-inset-sm, 8px);
+ }
+
+ .tab--total {
+ cursor: default;
+ }
+
+ button.tab {
+ cursor: pointer;
+ }
+
+ ol-score-gauge {
+ background: var(--white, white);
+ border-radius: 100px;
+ box-shadow: 0 0 4px 2px var(--white, white);
+ }
+
+ button.tab:hover ol-score-gauge {
+ transform: scale(1.05);
+ }
+
+ button.tab[aria-selected="true"] {
+ background: var(--lightest-grey, #ededed);
+ }
+
+ button.tab[aria-selected="true"] .tab-label {
+ font-weight: bold;
+ }
+
+ button.tab:focus-visible {
+ outline: var(--focus-width, 2px) solid var(--color-focus-ring, #1a73e8);
+ outline-offset: 2px;
+ }
+
+ .tab-label {
+ margin-top: var(--spacing-stack-sm, 8px);
+ font-size: var(--font-size-body-medium, 14px);
+ text-align: center;
+ max-width: 120px;
+ }
+
+ .tab-points {
+ font-size: var(--font-size-label-small, 11px);
+ color: color-mix(in srgb, currentColor 65%, transparent);
+ }
+
+ .section {
+ padding: var(--spacing-inset-lg, 24px) var(--spacing-inset-xs, 4px);
+ }
+
+ .checks-group {
+ margin-bottom: var(--spacing-stack-sm, 8px);
+ }
+
+ .checks-heading {
+ margin: 0;
+ padding: var(--spacing-inset-sm, 8px) var(--spacing-inset-md, 16px) var(--spacing-inset-xs, 4px);
+ color: var(--accessible-grey, #767676);
+ font-size: var(--font-size-label-medium, 12px);
+ font-weight: normal;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ list-style: none;
+ }
+
+ .checks-heading::-webkit-details-marker {
+ display: none;
+ }
+
+ details > .checks-heading {
+ cursor: pointer;
+ }
+
+ .check {
+ font-size: var(--font-size-body-medium, 14px);
+ border-bottom: var(--border-divider, 1px solid #ddd);
+ }
+
+ .check:last-child {
+ border-bottom: none;
+ }
+
+ .check[open] {
+ border: var(--border-card, 1px solid #ddd);
+ border-radius: var(--border-radius-md, 6px);
+ margin: var(--spacing-stack-xs, 4px) 0;
+ padding: 0 var(--spacing-inset-sm, 8px);
+ }
+
+ .check[open] + .check {
+ border-top: var(--border-divider, 1px solid #ddd);
+ }
+
+ .check summary {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-inline-md, 8px);
+ padding: var(--spacing-inset-sm, 8px) 0;
+ cursor: pointer;
+ font-weight: normal;
+ list-style: none;
+ }
+
+ .check summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .check-points {
+ white-space: nowrap;
+ }
+
+ .check-chevron {
+ width: 1em;
+ height: 1em;
+ flex-shrink: 0;
+ }
+
+ .check[open] .check-chevron {
+ transform: rotate(180deg);
+ }
+
+ .check-details {
+ padding: var(--spacing-inset-sm, 8px) 0;
+ font-size: var(--font-size-label-medium, 12px);
+ color: color-mix(in srgb, currentColor 65%, transparent);
+ }
+
+ .outdated-banner {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-inline-md, 8px);
+ margin: 0 var(--spacing-inset-md, 16px) var(--spacing-inset-md, 16px);
+ padding: var(--spacing-inset-sm, 8px);
+ border-radius: var(--border-radius-md, 6px);
+ font-size: var(--font-size-label-medium, 12px);
+ background-color: var(--scorecard-warning-bg, hsl(32deg 100% 90%));
+ color: var(--scorecard-warning-text, hsl(32deg 100% 35%));
+ }
+
+ .outdated-banner-icon {
+ width: 1.2em;
+ height: 1.2em;
+ flex-shrink: 0;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.results = null;
+ this.expanded = false;
+ this.labelTotal = 'Total';
+ this.labelFailingChecks = 'Failing Checks ({count})';
+ this.labelPassingChecks = 'Passing Checks ({count})';
+ this.labelPoints = '{score} points';
+ this.labelExpand = '{name}: {percentage}%. Click to expand.';
+ this.labelCollapse = 'Collapse';
+ this.outdated = false;
+ this.labelOutdated = 'This record has been edited and is not yet reflected in Solr. It should update in a minute or so.';
+ this._activeSection = 0;
+ this._panelIdPrefix = `ol-scorecard-${++_idCounter}`;
+ }
+
+ /**
+ * @param {String} template
+ * @param {Object} values
+ * @returns {String}
+ */
+ _interpolateLabel(template, values) {
+ return template.replace(/\{(\w+)\}/g, (_, key) => values[key] ?? '');
+ }
+
+ /**
+ * @param {Number} score
+ * @param {Number} maxScore
+ * @returns {Number}
+ */
+ _percentage(score, maxScore) {
+ return maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
+ }
+
+ /**
+ * @param {KeyboardEvent} e
+ * @param {Number} index
+ * @returns {void}
+ */
+ _onTabKeydown(e, index) {
+ const sections = this.results?.sections ?? [];
+ let next = null;
+ if (e.key === 'ArrowRight') next = (index + 1) % sections.length;
+ else if (e.key === 'ArrowLeft') next = (index - 1 + sections.length) % sections.length;
+ else if (e.key === 'Home') next = 0;
+ else if (e.key === 'End') next = sections.length - 1;
+ else return;
+
+ e.preventDefault();
+ this._activeSection = next;
+ this.shadowRoot.querySelectorAll('button.tab')[next]?.focus();
+ }
+
+ /** @returns {import('lit').TemplateResult|typeof nothing} */
+ render() {
+ if (!this.results) return nothing;
+
+ return this.expanded ? this._renderExpanded() : this._renderCollapsed();
+ }
+
+ /** @returns {import('lit').TemplateResult} */
+ _renderCollapsed() {
+ const { name, score, maxScore } = this.results;
+ const percentage = this._percentage(score, maxScore);
+ const ariaLabel = this._interpolateLabel(this.labelExpand, { name, percentage });
+
+ return html`
+
+ `;
+ }
+
+ /** @returns {import('lit').TemplateResult} */
+ _renderExpanded() {
+ const { name, score, maxScore, sections } = this.results;
+ const percentage = this._percentage(score, maxScore);
+
+ return html`
+
+
+ ${this._renderOutdatedBanner()}
+
+
+
+
+
${this.labelTotal}
+
${score}/${maxScore}
+
+
+
+
+ ${sections.map((section, i) => {
+ const sectionPct = this._percentage(section.score, section.maxScore);
+ const isActive = i === this._activeSection;
+ const panelId = `${this._panelIdPrefix}-panel-${i}`;
+ const tabId = `${this._panelIdPrefix}-tab-${i}`;
+ return html`
+
+ `;
+ })}
+
+
+ ${sections.map((section, i) => this._renderSectionPanel(section, i))}
+ `;
+ }
+
+ /** @returns {import('lit').TemplateResult|typeof nothing} */
+ _renderOutdatedBanner() {
+ if (!this.outdated) return nothing;
+
+ return html`
+
+ ${OlScorecard._clockIcon}
+ ${this.labelOutdated}
+
+ `;
+ }
+
+ /**
+ * @param {Object} section
+ * @param {Number} index
+ * @returns {import('lit').TemplateResult}
+ */
+ _renderSectionPanel(section, index) {
+ const isActive = index === this._activeSection;
+ const panelId = `${this._panelIdPrefix}-panel-${index}`;
+ const tabId = `${this._panelIdPrefix}-tab-${index}`;
+
+ const failingChecks = section.checks.filter(c => !c.passing).sort((a, b) => b.score - a.score);
+ const passingChecks = section.checks.filter(c => c.passing).sort((a, b) => b.score - a.score);
+
+ return html`
+
+
+
${this._interpolateLabel(this.labelFailingChecks, { count: failingChecks.length })}
+ ${failingChecks.map(check => this._renderCheck(check))}
+
+
+
+ ${this._interpolateLabel(this.labelPassingChecks, { count: passingChecks.length })}
+ ${passingChecks.map(check => this._renderCheck(check))}
+
+
+ `;
+ }
+
+ /**
+ * @param {Object} check
+ * @returns {import('lit').TemplateResult}
+ */
+ _renderCheck(check) {
+ const emoji = check.passing ? '✅' : '❌';
+ return html`
+
+
+ ${emoji} ${check.description}
+ ${this._interpolateLabel(this.labelPoints, { score: check.score })}
+ ${OlScorecard._chevronIcon}
+
+ ${check.details}
+
+ `;
+ }
+}
+
+customElements.define('ol-scorecard', OlScorecard);
diff --git a/openlibrary/components/lit/index.js b/openlibrary/components/lit/index.js
index 2fb6f7003db..7e17977ff26 100644
--- a/openlibrary/components/lit/index.js
+++ b/openlibrary/components/lit/index.js
@@ -23,3 +23,4 @@ export { OlToast } from './OlToast.js';
export { OlToastRegion, showToast } from './OlToastRegion.js';
export { OpenLibraryOTP } from './OpenLibraryOTP.js';
export { OlCarousel } from './OlCarousel.js';
+export { OlScorecard } from './OlScorecard.js';
diff --git a/openlibrary/core/models.py b/openlibrary/core/models.py
index 523e590c27b..9f268035fa2 100644
--- a/openlibrary/core/models.py
+++ b/openlibrary/core/models.py
@@ -106,6 +106,7 @@ class Thing(client.Thing):
"""Base class for all OL models."""
key: ThingKey
+ last_modified: datetime
@functools.cached_property
def history_preview(self):
diff --git a/openlibrary/edition_scorecard.py b/openlibrary/edition_scorecard.py
new file mode 100644
index 00000000000..4311eefc971
--- /dev/null
+++ b/openlibrary/edition_scorecard.py
@@ -0,0 +1,393 @@
+# THIS FILE IS AUTO-GENERATED. DO NOT EDIT DIRECTLY.
+# Source: edition_scorecard.yml
+# Regenerate: ./openlibrary/scorecards.py generate edition_scorecard.yml
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+
+from openlibrary.i18n import gettext as _
+from openlibrary.scorecards import Scorecard, ScorecardCheck, ScorecardEvaluator, ScorecardSection
+
+
+@dataclass
+class EditionScorecardAccessSection(ScorecardSection):
+ read_access = ScorecardCheck(
+ name="read_access",
+ score=200,
+ description=_("Can be accessed in full online (read / borrowed)"),
+ details=_("Borrowable and readable books can be accessed by readers, researchers, and all patrons alike."),
+ )
+ search_inside_access = ScorecardCheck(
+ name="search_inside_access",
+ score=50,
+ description=_("Can view snippets via search inside"),
+ details=_("Books that allow search inside enable patrons to quickly find relevant content within the book."),
+ )
+ programmatic_access = ScorecardCheck(
+ name="programmatic_access",
+ score=25,
+ description=_("Open access (can analyze in full)"),
+ details=_("Fully public books can be read online or downloaded without any restrictions, providing excellent access for patrons."),
+ )
+ purchase_options = ScorecardCheck(
+ name="purchase_options",
+ score=25,
+ description=_("Has purchase options"),
+ details=_("An ISBN, Amazon ASIN identifier, or BWB identifier enables patrons to purchase the book from retailers."),
+ )
+ library_options = ScorecardCheck(
+ name="library_options",
+ score=25,
+ description=_("Has library options (ISBN or OCLC)"),
+ details=_("ISBN or OCLC numbers enable patrons to find the book through library catalogs."),
+ )
+ fan_fiction = ScorecardCheck(
+ name="fan_fiction",
+ score=20,
+ description=_("Has fan fiction link (Archive of Our Own)"),
+ details=_("A link to Archive of Our Own helps readers discover related fan-created works."),
+ )
+ wikipedia = ScorecardCheck(
+ name="wikipedia",
+ score=20,
+ description=_("Has Wikipedia link"),
+ details=_("A Wikipedia link provides additional context and encyclopedic information about the work."),
+ )
+ first_sentence = ScorecardCheck(
+ name="first_sentence",
+ score=2,
+ description=_("Has first sentence"),
+ details=_("The first sentence gives patrons an immediate taste of the writing style."),
+ )
+
+
+@dataclass
+class EditionScorecardDiscoverySection(ScorecardSection):
+ title = ScorecardCheck(
+ name="title",
+ score=50,
+ description=_("Has title field"),
+ details=_("A title is essential for discovery and helps patrons identify the book in search results and catalogs."),
+ )
+ author_name = ScorecardCheck(
+ name="author_name",
+ score=40,
+ description=_("Has author information"),
+ details=_("Author information is crucial for discovery and helps patrons find other works by the same author."),
+ )
+ genre_tags = ScorecardCheck(
+ name="genre_tags",
+ score=25,
+ description=_("Has genre/category tags"),
+ details=_("Genre tags help patrons search by genre or category to discover relevant books."),
+ )
+ series = ScorecardCheck(
+ name="series",
+ score=20,
+ description=_("Has series information"),
+ details=_("Series information helps patrons search by series and discover related books."),
+ )
+ table_of_contents = ScorecardCheck(
+ name="table_of_contents",
+ score=20,
+ description=_("Has table of contents"),
+ details=_("Table of contents entries are indexed and searchable, improving discoverability."),
+ )
+ classifications = ScorecardCheck(
+ name="classifications",
+ score=15,
+ description=_("Has DDC or LCC classification"),
+ details=_("Dewey Decimal or Library of Congress classifications help with organization and shelf browsing."),
+ )
+ language = ScorecardCheck(
+ name="language",
+ score=15,
+ description=_("Has language information"),
+ details=_("Language information helps patrons filter and find books in their preferred language."),
+ )
+ isbn = ScorecardCheck(
+ name="isbn",
+ score=15,
+ description=_("Has ISBN"),
+ details=_("ISBNs allow searching by barcode scanner and help with identification."),
+ )
+ lexile = ScorecardCheck(
+ name="lexile",
+ score=5,
+ description=_("Has Lexile measure"),
+ details=_("Lexile measures help educators and parents find books at the appropriate reading level."),
+ )
+ star_ratings = ScorecardCheck(
+ name="star_ratings",
+ score=5,
+ description=_("Has star ratings (ranking signal)"),
+ details=_("Star ratings contribute to search ranking so better-rated books surface higher."),
+ )
+ on_readinglogs = ScorecardCheck(
+ name="on_readinglogs",
+ score=3,
+ description=_("Appears on reading logs (ranking signal)"),
+ details=_("Presence on reading logs is a popularity signal that helps with search ranking."),
+ )
+ on_lists = ScorecardCheck(
+ name="on_lists",
+ score=2,
+ description=_("Appears on lists (ranking signal)"),
+ details=_("Presence on user lists is a popularity signal that helps with search ranking."),
+ )
+ contributor_names = ScorecardCheck(
+ name="contributor_names",
+ score=1,
+ description=_("Has contributor names"),
+ details=_("Contributor names (editors, illustrators, translators) enable searching by contributor."),
+ )
+
+
+@dataclass
+class EditionScorecardEvaluationSection(ScorecardSection):
+ basic_description = ScorecardCheck(
+ name="basic_description",
+ score=40,
+ description=_("Has a description"),
+ details=_("A description helps patrons understand what the book is about before accessing it."),
+ )
+ cover = ScorecardCheck(
+ name="cover",
+ score=35,
+ description=_("Has cover image"),
+ details=_("A cover image helps patrons visually identify the book and makes it more appealing in search results."),
+ )
+ table_of_contents = ScorecardCheck(
+ name="table_of_contents",
+ score=30,
+ description=_("Has table of contents"),
+ details=_("A table of contents helps patrons evaluate the structure and scope of the book."),
+ )
+ genre_tags = ScorecardCheck(
+ name="genre_tags",
+ score=25,
+ description=_("Has genre/category tags"),
+ details=_("Genre tags help patrons quickly assess whether a book matches their interests."),
+ )
+ star_ratings = ScorecardCheck(
+ name="star_ratings",
+ score=20,
+ description=_("Has star ratings (popularity signal)"),
+ details=_("Star ratings from other readers help patrons gauge the quality and reception of the book."),
+ )
+ rich_description = ScorecardCheck(
+ name="rich_description",
+ score=10,
+ description=_("Has a rich description (50+ characters)"),
+ details=_("A detailed description (50+ characters) provides more context to help patrons evaluate relevance."),
+ )
+ readinglog_counts = ScorecardCheck(
+ name="readinglog_counts",
+ score=10,
+ description=_("Has reading log activity (popularity signal)"),
+ details=_("Reading log activity from other patrons signals the book is being actively read."),
+ )
+ list_count = ScorecardCheck(
+ name="list_count",
+ score=10,
+ description=_("Appears on lists (popularity signal)"),
+ details=_("Presence on curated lists helps patrons gauge relevance and community interest."),
+ )
+ page_count = ScorecardCheck(
+ name="page_count",
+ score=10,
+ description=_("Has page count"),
+ details=_("Page count helps patrons estimate the length and time commitment of the book."),
+ )
+ series = ScorecardCheck(
+ name="series",
+ score=10,
+ description=_("Has series information"),
+ details=_("Series information helps patrons understand the book's place in a broader narrative."),
+ )
+ author_photo = ScorecardCheck(
+ name="author_photo",
+ score=5,
+ description=_("Has author photo"),
+ details=_("An author photo helps patrons visually recognize the author."),
+ )
+ first_publish_year = ScorecardCheck(
+ name="first_publish_year",
+ score=5,
+ description=_("Has first publication year"),
+ details=_("The first publication year helps patrons understand when the work originally appeared."),
+ )
+ publish_year = ScorecardCheck(
+ name="publish_year",
+ score=5,
+ description=_("Has publication year"),
+ details=_("Publication year helps patrons understand the context and currency of the content."),
+ )
+ author_bio = ScorecardCheck(
+ name="author_bio",
+ score=3,
+ description=_("Has author biography"),
+ details=_("An author biography helps patrons understand the author's background and expertise."),
+ )
+ publisher = ScorecardCheck(
+ name="publisher",
+ score=2,
+ description=_("Has publisher"),
+ details=_("Publisher information helps patrons evaluate the credibility and source of the book."),
+ )
+ author_links = ScorecardCheck(
+ name="author_links",
+ score=2,
+ description=_("Has author links"),
+ details=_("Links to author websites or profiles provide additional context about the author."),
+ )
+
+
+@dataclass
+class EditionScorecard(Scorecard):
+ name: str = _("Edition Scorecard")
+ access = EditionScorecardAccessSection(name=_("Access"))
+ discovery = EditionScorecardDiscoverySection(
+ name=_("Discovery"),
+ details=_("How well can this edition be accurately surfaced in searches and recommendations?"),
+ )
+ evaluation = EditionScorecardEvaluationSection(
+ name=_("Evaluation"),
+ details=_("How well can a patron determine if this edition is relevant to their needs before accessing it?"),
+ )
+
+
+class EditionScorecardEvaluator(ScorecardEvaluator[EditionScorecard], ABC):
+ scorecard_cls = EditionScorecard
+
+ @property
+ @abstractmethod
+ def read_access(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def search_inside_access(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def programmatic_access(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def purchase_options(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def library_options(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def fan_fiction(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def wikipedia(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def first_sentence(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def title(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def author_name(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def genre_tags(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def series(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def table_of_contents(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def classifications(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def language(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def isbn(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def lexile(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def star_ratings(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def on_readinglogs(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def on_lists(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def contributor_names(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def basic_description(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def cover(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def rich_description(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def readinglog_counts(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def list_count(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def page_count(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def author_photo(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def first_publish_year(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def publish_year(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def author_bio(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def publisher(self) -> bool: ...
+
+ @property
+ @abstractmethod
+ def author_links(self) -> bool: ...
diff --git a/openlibrary/edition_scorecard.yml b/openlibrary/edition_scorecard.yml
new file mode 100644
index 00000000000..e7fef99ffe1
--- /dev/null
+++ b/openlibrary/edition_scorecard.yml
@@ -0,0 +1,163 @@
+name: Edition Scorecard
+sections:
+ access:
+ name: Access
+ checks:
+ read_access:
+ score: 200
+ description: Can be accessed in full online (read / borrowed)
+ details: Borrowable and readable books can be accessed by readers, researchers, and all patrons alike.
+ search_inside_access:
+ score: 50
+ description: Can view snippets via search inside
+ details: Books that allow search inside enable patrons to quickly find relevant content within the book.
+ programmatic_access:
+ score: 25
+ description: Open access (can analyze in full)
+ details: Fully public books can be read online or downloaded without any restrictions, providing excellent access for patrons.
+ purchase_options:
+ score: 25
+ description: Has purchase options
+ details: An ISBN, Amazon ASIN identifier, or BWB identifier enables patrons to purchase the book from retailers.
+ library_options:
+ score: 25
+ description: Has library options (ISBN or OCLC)
+ details: ISBN or OCLC numbers enable patrons to find the book through library catalogs.
+ fan_fiction:
+ score: 20
+ description: Has fan fiction link (Archive of Our Own)
+ details: A link to Archive of Our Own helps readers discover related fan-created works.
+ wikipedia:
+ score: 20
+ description: Has Wikipedia link
+ details: A Wikipedia link provides additional context and encyclopedic information about the work.
+ first_sentence:
+ score: 2
+ description: Has first sentence
+ details: The first sentence gives patrons an immediate taste of the writing style.
+
+ discovery:
+ name: Discovery
+ details: How well can this edition be accurately surfaced in searches and recommendations?
+ checks:
+ title:
+ score: 50
+ description: Has title field
+ details: A title is essential for discovery and helps patrons identify the book in search results and catalogs.
+ author_name:
+ score: 40
+ description: Has author information
+ details: Author information is crucial for discovery and helps patrons find other works by the same author.
+ genre_tags:
+ score: 25
+ description: Has genre/category tags
+ details: Genre tags help patrons search by genre or category to discover relevant books.
+ series:
+ score: 20
+ description: Has series information
+ details: Series information helps patrons search by series and discover related books.
+ table_of_contents:
+ score: 20
+ description: Has table of contents
+ details: Table of contents entries are indexed and searchable, improving discoverability.
+ classifications:
+ score: 15
+ description: Has DDC or LCC classification
+ details: Dewey Decimal or Library of Congress classifications help with organization and shelf browsing.
+ language:
+ score: 15
+ description: Has language information
+ details: Language information helps patrons filter and find books in their preferred language.
+ isbn:
+ score: 15
+ description: Has ISBN
+ details: ISBNs allow searching by barcode scanner and help with identification.
+ lexile:
+ score: 5
+ description: Has Lexile measure
+ details: Lexile measures help educators and parents find books at the appropriate reading level.
+ star_ratings:
+ score: 5
+ description: Has star ratings (ranking signal)
+ details: Star ratings contribute to search ranking so better-rated books surface higher.
+ on_readinglogs:
+ score: 3
+ description: Appears on reading logs (ranking signal)
+ details: Presence on reading logs is a popularity signal that helps with search ranking.
+ on_lists:
+ score: 2
+ description: Appears on lists (ranking signal)
+ details: Presence on user lists is a popularity signal that helps with search ranking.
+ contributor_names:
+ score: 1
+ description: Has contributor names
+ details: Contributor names (editors, illustrators, translators) enable searching by contributor.
+
+ evaluation:
+ name: Evaluation
+ details: How well can a patron determine if this edition is relevant to their needs before accessing it?
+ checks:
+ basic_description:
+ score: 40
+ description: Has a description
+ details: A description helps patrons understand what the book is about before accessing it.
+ cover:
+ score: 35
+ description: Has cover image
+ details: A cover image helps patrons visually identify the book and makes it more appealing in search results.
+ table_of_contents:
+ score: 30
+ description: Has table of contents
+ details: A table of contents helps patrons evaluate the structure and scope of the book.
+ genre_tags:
+ score: 25
+ description: Has genre/category tags
+ details: Genre tags help patrons quickly assess whether a book matches their interests.
+ star_ratings:
+ score: 20
+ description: Has star ratings (popularity signal)
+ details: Star ratings from other readers help patrons gauge the quality and reception of the book.
+ rich_description:
+ score: 10
+ description: Has a rich description (50+ characters)
+ details: A detailed description (50+ characters) provides more context to help patrons evaluate relevance.
+ readinglog_counts:
+ score: 10
+ description: Has reading log activity (popularity signal)
+ details: Reading log activity from other patrons signals the book is being actively read.
+ list_count:
+ score: 10
+ description: Appears on lists (popularity signal)
+ details: Presence on curated lists helps patrons gauge relevance and community interest.
+ page_count:
+ score: 10
+ description: Has page count
+ details: Page count helps patrons estimate the length and time commitment of the book.
+ series:
+ score: 10
+ description: Has series information
+ details: "Series information helps patrons understand the book's place in a broader narrative."
+ author_photo:
+ score: 5
+ description: Has author photo
+ details: An author photo helps patrons visually recognize the author.
+ first_publish_year:
+ score: 5
+ description: Has first publication year
+ details: The first publication year helps patrons understand when the work originally appeared.
+ publish_year:
+ score: 5
+ description: Has publication year
+ details: Publication year helps patrons understand the context and currency of the content.
+ author_bio:
+ score: 3
+ description: Has author biography
+ details: "An author biography helps patrons understand the author's background and expertise."
+ publisher:
+ score: 2
+ description: Has publisher
+ details: Publisher information helps patrons evaluate the credibility and source of the book.
+ author_links:
+ score: 2
+ description: Has author links
+ details: Links to author websites or profiles provide additional context about the author.
diff --git a/openlibrary/i18n/__init__.py b/openlibrary/i18n/__init__.py
index 19122124bed..68b4b2f78ad 100644
--- a/openlibrary/i18n/__init__.py
+++ b/openlibrary/i18n/__init__.py
@@ -319,7 +319,7 @@ def generate_po(args):
@functools.cache
-def load_translations(lang):
+def load_translations(lang: str):
mo_path = os.path.join(root, lang, "messages.mo")
if os.path.exists(mo_path):
@@ -327,18 +327,30 @@ def load_translations(lang):
@functools.cache
-def load_locale(lang):
+def load_locale(lang: str):
try:
return babel.Locale(lang)
except babel.UnknownLocaleError:
- pass
+ return None
class GetText:
def __call__(self, string, *args, **kwargs):
"""Translate a given string to the language of the current locale."""
# Get the website locale from the global ctx.lang variable, set in i18n_loadhook
- translations = load_translations(req_context.get().lang)
+ try:
+ lang = req_context.get().lang
+ except LookupError:
+ lang = None
+
+ if not lang:
+ print(
+ "Warning: No language set in request context. Returning untranslated string.",
+ file=web.debug,
+ )
+ lang = "en"
+
+ translations = load_translations(lang)
value = (translations and translations.ugettext(string)) or string
if args:
diff --git a/openlibrary/i18n/messages.pot b/openlibrary/i18n/messages.pot
index 1934bc9626a..0ba0e91d237 100644
--- a/openlibrary/i18n/messages.pot
+++ b/openlibrary/i18n/messages.pot
@@ -3972,6 +3972,27 @@ msgstr ""
msgid "This Edition"
msgstr ""
+#: books/edit/edition.html
+#, python-brace-format
+msgid "Failing Checks ({count})"
+msgstr ""
+
+#: books/edit/edition.html
+#, python-brace-format
+msgid "Passing Checks ({count})"
+msgstr ""
+
+#: books/edit/edition.html
+#, python-brace-format
+msgid "{score} points"
+msgstr ""
+
+#: books/edit/edition.html
+msgid ""
+"This record was recently edited and this scorecard is outdated. It should"
+" update in a minute or so."
+msgstr ""
+
#: books/edit/edition.html
msgid "Enter the title of this specific edition."
msgstr ""
@@ -8365,6 +8386,388 @@ msgstr ""
msgid "Unknown"
msgstr ""
+#: openlibrary/edition_scorecard.py
+msgid "Can be accessed in full online (read / borrowed)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Borrowable and readable books can be accessed by readers, researchers, "
+"and all patrons alike."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Can view snippets via search inside"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Books that allow search inside enable patrons to quickly find relevant "
+"content within the book."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Open access (can analyze in full)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Fully public books can be read online or downloaded without any "
+"restrictions, providing excellent access for patrons."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has purchase options"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"An ISBN, Amazon ASIN identifier, or BWB identifier enables patrons to "
+"purchase the book from retailers."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has library options (ISBN or OCLC)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"ISBN or OCLC numbers enable patrons to find the book through library "
+"catalogs."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has fan fiction link (Archive of Our Own)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"A link to Archive of Our Own helps readers discover related fan-created "
+"works."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has Wikipedia link"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"A Wikipedia link provides additional context and encyclopedic information"
+" about the work."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has first sentence"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "The first sentence gives patrons an immediate taste of the writing style."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has title field"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"A title is essential for discovery and helps patrons identify the book in"
+" search results and catalogs."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has author information"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Author information is crucial for discovery and helps patrons find other "
+"works by the same author."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has genre/category tags"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Genre tags help patrons search by genre or category to discover relevant "
+"books."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has series information"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Series information helps patrons search by series and discover related "
+"books."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has table of contents"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Table of contents entries are indexed and searchable, improving "
+"discoverability."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has DDC or LCC classification"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Dewey Decimal or Library of Congress classifications help with "
+"organization and shelf browsing."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has language information"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Language information helps patrons filter and find books in their "
+"preferred language."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has ISBN"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "ISBNs allow searching by barcode scanner and help with identification."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has Lexile measure"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Lexile measures help educators and parents find books at the appropriate "
+"reading level."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has star ratings (ranking signal)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Star ratings contribute to search ranking so better-rated books surface "
+"higher."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Appears on reading logs (ranking signal)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Presence on reading logs is a popularity signal that helps with search "
+"ranking."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Appears on lists (ranking signal)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Presence on user lists is a popularity signal that helps with search "
+"ranking."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has contributor names"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Contributor names (editors, illustrators, translators) enable searching "
+"by contributor."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has a description"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"A description helps patrons understand what the book is about before "
+"accessing it."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has cover image"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"A cover image helps patrons visually identify the book and makes it more "
+"appealing in search results."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"A table of contents helps patrons evaluate the structure and scope of the"
+" book."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Genre tags help patrons quickly assess whether a book matches their "
+"interests."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has star ratings (popularity signal)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Star ratings from other readers help patrons gauge the quality and "
+"reception of the book."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has a rich description (50+ characters)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"A detailed description (50+ characters) provides more context to help "
+"patrons evaluate relevance."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has reading log activity (popularity signal)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Reading log activity from other patrons signals the book is being "
+"actively read."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Appears on lists (popularity signal)"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Presence on curated lists helps patrons gauge relevance and community "
+"interest."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has page count"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Page count helps patrons estimate the length and time commitment of the "
+"book."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Series information helps patrons understand the book's place in a broader"
+" narrative."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has author photo"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "An author photo helps patrons visually recognize the author."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has first publication year"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"The first publication year helps patrons understand when the work "
+"originally appeared."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+#: openlibrary/plugins/openlibrary/librarian_dashboard.py
+msgid "Has publication year"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Publication year helps patrons understand the context and currency of the"
+" content."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has author biography"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"An author biography helps patrons understand the author's background and "
+"expertise."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+#: openlibrary/plugins/openlibrary/librarian_dashboard.py
+msgid "Has publisher"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Publisher information helps patrons evaluate the credibility and source "
+"of the book."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Has author links"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"Links to author websites or profiles provide additional context about the"
+" author."
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Edition Scorecard"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Access"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Discovery"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"How well can this edition be accurately surfaced in searches and "
+"recommendations?"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid "Evaluation"
+msgstr ""
+
+#: openlibrary/edition_scorecard.py
+msgid ""
+"How well can a patron determine if this edition is relevant to their "
+"needs before accessing it?"
+msgstr ""
+
#: openlibrary/plugins/openlibrary/api.py
msgid "Search Results"
msgstr ""
@@ -8501,10 +8904,6 @@ msgstr ""
msgid "Has work (orphaned)"
msgstr ""
-#: openlibrary/plugins/openlibrary/librarian_dashboard.py
-msgid "Has publication year"
-msgstr ""
-
#: openlibrary/plugins/openlibrary/librarian_dashboard.py
msgid "Has cover"
msgstr ""
@@ -8513,10 +8912,6 @@ msgstr ""
msgid "Has language"
msgstr ""
-#: openlibrary/plugins/openlibrary/librarian_dashboard.py
-msgid "Has publisher"
-msgstr ""
-
#: openlibrary/plugins/openlibrary/librarian_dashboard.py
msgid "At least 2 editions"
msgstr ""
diff --git a/openlibrary/plugins/upstream/models.py b/openlibrary/plugins/upstream/models.py
index d44aed91aef..bee21c66755 100644
--- a/openlibrary/plugins/upstream/models.py
+++ b/openlibrary/plugins/upstream/models.py
@@ -2,6 +2,7 @@
import re
import sys
from collections import defaultdict
+from datetime import datetime
from functools import cached_property
from typing import cast
@@ -10,7 +11,6 @@
from infogami import config # noqa: F401 side effects may be needed
from infogami.infobase import client
-from infogami.utils import stats
from infogami.utils.view import safeint # noqa: F401 side effects may be needed
from openlibrary.core import ia, lending, models
from openlibrary.core.models import Image
@@ -18,7 +18,9 @@
from openlibrary.plugins.upstream.table_of_contents import TableOfContents
from openlibrary.plugins.upstream.utils import MultiDict, get_identifier_config
from openlibrary.plugins.worksearch.code import works_by_author
+from openlibrary.plugins.worksearch.schemes.works import WorkSearchScheme
from openlibrary.plugins.worksearch.search import get_solr
+from openlibrary.solr.solr_types import SolrDocument
from openlibrary.utils import dateutil # noqa: F401 side effects may be needed
from openlibrary.utils.isbn import (
isbn_10_to_isbn_13,
@@ -42,6 +44,70 @@ def follow_redirect(doc):
class Edition(models.Edition):
+ @cached_property
+ def solr_work_data(self) -> SolrDocument | None:
+ import openlibrary.book_providers as bp
+ from openlibrary.solr.updater.edition import EditionScorecardForSolr
+
+ work = cast(Work, self.works[0]) if self.works else None
+ if work:
+ return work._solr_data
+ else:
+ edition_olid = self.key.split("/")[-1]
+ return cast(
+ SolrDocument | None,
+ get_solr().get(
+ f"/works/{edition_olid}",
+ fields=[
+ *list(WorkSearchScheme.default_fetched_fields),
+ *bp.get_solr_keys(),
+ *list(EditionScorecardForSolr.REQUIRED_SOLR_WORK_FIELDS),
+ ],
+ request_label="GET_WORK_SOLR_DATA",
+ ),
+ )
+
+ @cached_property
+ def solr_last_modified(self) -> datetime | None:
+ work = cast(Work, self.works[0]) if self.works else None
+ if not work:
+ return None
+
+ return work.solr_last_modified
+
+ def is_solr_data_outdated(self) -> bool:
+ """
+ Returns True if the solr data is outdated compared to the database data.
+ """
+ if not self.solr_last_modified:
+ # Not in solr yet?
+ return True
+
+ work = cast(Work, self.works[0]) if self.works else None
+
+ # Stored as a plain int in solr, so need to drop ms
+ return self.solr_last_modified < self.last_modified.replace(microsecond=0) or (work.is_solr_data_outdated() if work else False)
+
+ @cached_property
+ def scorecard(self):
+ from openlibrary.solr.updater.edition import EditionSolrBuilder
+ from openlibrary.solr.updater.work import full_ia_metadata_to_lite_metadata
+
+ solr_work = self.solr_work_data
+
+ if not solr_work:
+ return None
+
+ edition_solr_builder = EditionSolrBuilder(
+ edition=self.dict(),
+ solr_work=solr_work,
+ db_work=self.works[0].dict() if self.works else None,
+ db_authors=[a.dict() for a in self.get_authors()] if self.get_authors() else [],
+ # TODO: Switch to using solr instead of IA for metadata
+ ia_metadata=full_ia_metadata_to_lite_metadata(self.get_ia_meta_fields()),
+ )
+ return edition_solr_builder.get_scorecard()
+
def get_title(self):
if self["title_prefix"]:
return self["title_prefix"] + " " + self["title"]
@@ -492,27 +558,47 @@ def get_covers_from_solr(self) -> list[Image]:
@cached_property
def _solr_data(self):
- from openlibrary.book_providers import get_solr_keys
+ import openlibrary.book_providers as bp
+ from openlibrary.solr.updater.edition import EditionScorecardForSolr
fields = [
"key",
"cover_edition_key",
- "cover_id",
"edition_key",
"first_publish_year",
"has_fulltext",
"lending_edition_s",
"public_scan_b",
- ] + get_solr_keys()
+ "last_modified_i",
+ *bp.get_solr_keys(),
+ *list(WorkSearchScheme.default_fetched_fields),
+ *list(EditionScorecardForSolr.REQUIRED_SOLR_WORK_FIELDS),
+ ]
solr = get_solr()
- stats.begin("solr", get=self.key, fields=fields)
- try:
- return solr.get(self.key, fields=fields, request_label="GET_WORK_SOLR_DATA")
- except Exception:
- logging.getLogger("openlibrary").exception("Failed to get solr data")
+ return cast(SolrDocument | None, solr.get(self.key, fields=fields, request_label="GET_WORK_SOLR_DATA"))
+
+ @cached_property
+ def solr_last_modified(self) -> datetime | None:
+ if not self._solr_data:
return None
- finally:
- stats.end()
+ last_modified_i = self._solr_data["last_modified_i"]
+ if last_modified_i is None:
+ raise ValueError("Work missing last_modified_i solr field")
+ return datetime.fromtimestamp(last_modified_i)
+
+ def is_solr_data_outdated(self) -> bool:
+ """
+ Returns True if the solr data is outdated compared to the database data.
+
+ Note this only checks the current work; it might still be outdated because
+ one of the editions has been edited.
+ """
+ if not self.solr_last_modified:
+ # Not in solr yet?
+ return True
+
+ # Stored as a plain int in solr, so need to drop ms
+ return self.solr_last_modified < self.last_modified.replace(microsecond=0)
def get_cover(self, use_solr=True):
covers = self.get_covers(use_solr=use_solr)
diff --git a/openlibrary/plugins/worksearch/schemes/editions.py b/openlibrary/plugins/worksearch/schemes/editions.py
index 2aadd94bab7..a2bbb259e66 100644
--- a/openlibrary/plugins/worksearch/schemes/editions.py
+++ b/openlibrary/plugins/worksearch/schemes/editions.py
@@ -26,6 +26,10 @@ class EditionSearchScheme(SearchScheme):
"ia",
"ia_collection",
"isbn",
+ "access_score",
+ "discovery_score",
+ "evaluation_score",
+ "usefulness_score",
"publisher",
"has_fulltext",
"title_suggest",
@@ -56,6 +60,19 @@ class EditionSearchScheme(SearchScheme):
"key": "key asc",
"key asc": "key asc",
"key desc": "key desc",
+ # Quality scores
+ "access_score": "access_score desc",
+ "access_score asc": "access_score asc",
+ "access_score desc": "access_score desc",
+ "discovery_score": "discovery_score desc",
+ "discovery_score asc": "discovery_score asc",
+ "discovery_score desc": "discovery_score desc",
+ "evaluation_score": "evaluation_score desc",
+ "evaluation_score asc": "evaluation_score asc",
+ "evaluation_score desc": "evaluation_score desc",
+ "usefulness_score": "usefulness_score desc",
+ "usefulness_score asc": "usefulness_score asc",
+ "usefulness_score desc": "usefulness_score desc",
# Random
"random": "random_1 asc",
"random asc": "random_1 asc",
diff --git a/openlibrary/scorecards.py b/openlibrary/scorecards.py
new file mode 100644
index 00000000000..6ff00265bf3
--- /dev/null
+++ b/openlibrary/scorecards.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python
+import typing
+from dataclasses import dataclass, field
+from functools import cached_property
+from pathlib import Path
+from typing import ClassVar, cast
+
+import yaml
+
+
+@dataclass(frozen=True)
+class ScorecardCheck:
+ """Represents a metadata quality check for edition scoring."""
+
+ name: str
+ score: int
+ description: str
+ details: str
+
+ def to_dict(self, passing: bool) -> dict:
+ return {
+ "description": self.description,
+ "details": self.details,
+ "score": self.score,
+ "passing": passing,
+ }
+
+
+@dataclass
+class ScorecardSection:
+ name: str
+ details: str = ""
+ _passing_checks: set[ScorecardCheck] = field(default_factory=set)
+ _set_checks: set[ScorecardCheck] = field(default_factory=set)
+
+ @property
+ def passing_checks(self) -> set[ScorecardCheck]:
+ return self._passing_checks
+
+ @property
+ def score(self) -> int:
+ """Calculates the total score based on the passing checks."""
+ return sum(check.score for check in self.passing_checks)
+
+ @property
+ def score_normalized(self) -> int:
+ """Calculates the total score based on the passing checks, normalized to be out of 100."""
+ return 100 * self.score // self.max_score
+
+ @cached_property
+ def max_score(self) -> int:
+ """Calculates the maximum possible score based on the defined checks."""
+ return sum(check.score for check in self.get_checks())
+
+ def set_check(self, check: ScorecardCheck, passed: bool):
+ """Adds or removes a check from the passing checks based on whether it passed."""
+ self._set_checks.add(check)
+ if passed:
+ self._passing_checks.add(check)
+ else:
+ self._passing_checks.discard(check)
+
+ def get_checks(self) -> list[ScorecardCheck]:
+ """Returns a list of ScorecardCheck instances representing the metadata quality checks."""
+ checks = []
+ for attr_name in dir(self):
+ if attr_name in ("score", "score_normalized", "max_score", "passing_checks"):
+ continue
+
+ attr_value = getattr(self, attr_name)
+ if isinstance(attr_value, ScorecardCheck):
+ checks.append(attr_value)
+ return checks
+
+ def to_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "score": self.score,
+ "maxScore": self.max_score,
+ "checks": [check.to_dict(passing=check in self.passing_checks) for check in self.get_checks()],
+ }
+
+
+@dataclass
+class Scorecard(ScorecardSection):
+ @property
+ @typing.override
+ def passing_checks(self) -> set[ScorecardCheck]:
+ """Aggregates passing checks from all sections."""
+ return {check for section in self.get_sections() for check in section.passing_checks}
+
+ @typing.override
+ def get_checks(self) -> list[ScorecardCheck]:
+ # Handle nested sections by recursively gathering checks from any ScorecardSection attributes
+ checks = []
+ for attr_name in dir(self):
+ if attr_name in ("score", "score_normalized", "max_score", "passing_checks"):
+ continue
+
+ attr_value = getattr(self, attr_name)
+ if isinstance(attr_value, ScorecardCheck):
+ checks.append(attr_value)
+ elif isinstance(attr_value, ScorecardSection):
+ checks.extend(attr_value.get_checks())
+ return checks
+
+ def get_sections(self) -> list[ScorecardSection]:
+ """Returns a list of ScorecardSection instances representing the different sections of the scorecard."""
+ sections = []
+ for attr_name in dir(self):
+ if attr_name in ("score", "score_normalized", "max_score", "passing_checks"):
+ continue
+
+ attr_value = getattr(self, attr_name)
+ if isinstance(attr_value, ScorecardSection):
+ sections.append(attr_value)
+ return sections
+
+ @typing.override
+ def to_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "score": self.score,
+ "maxScore": self.max_score,
+ "sections": [section.to_dict() for section in self.get_sections()],
+ }
+
+
+class ScorecardEvaluator[S: Scorecard]:
+ scorecard_cls: ClassVar[type]
+
+ def evaluate(self) -> S:
+ scorecard = cast(S, self.scorecard_cls())
+ for section in scorecard.get_sections():
+ for check in section.get_checks():
+ section.set_check(check, getattr(self, check.name))
+ return scorecard
+
+
+def generate_scorecard(yml_file: str) -> str:
+ def to_pascal_case(s: str) -> str:
+ return "".join(word.title() for word in s.split("_"))
+
+ def py_str(s: str) -> str:
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+ yml_path = Path(yml_file)
+ pascal_name = to_pascal_case(yml_path.stem)
+
+ with open(yml_path) as f:
+ data = yaml.safe_load(f)
+
+ sections = data["sections"]
+ scorecard_name = data["name"]
+
+ lines = [
+ "# THIS FILE IS AUTO-GENERATED. DO NOT EDIT DIRECTLY.",
+ f"# Source: {yml_path.name}",
+ f"# Regenerate: ./openlibrary/scorecards.py generate {yml_path.name}",
+ "",
+ "from abc import ABC, abstractmethod",
+ "from dataclasses import dataclass",
+ "",
+ "from openlibrary.i18n import gettext as _",
+ "from openlibrary.scorecards import Scorecard, ScorecardCheck, ScorecardEvaluator, ScorecardSection",
+ "",
+ "",
+ ]
+
+ section_class_names = {}
+ all_check_names: list[str] = []
+ seen_check_names: set[str] = set()
+
+ for section_key, section_data in sections.items():
+ class_name = f"{pascal_name}{to_pascal_case(section_key)}Section"
+ section_class_names[section_key] = class_name
+
+ lines += ["@dataclass", f"class {class_name}(ScorecardSection):"]
+
+ for check_name, check_data in section_data["checks"].items():
+ if check_name not in seen_check_names:
+ all_check_names.append(check_name)
+ seen_check_names.add(check_name)
+ lines += [
+ f" {check_name} = ScorecardCheck(",
+ f" name={py_str(check_name)},",
+ f" score={check_data['score']},",
+ f" description=_({py_str(check_data['description'])}),",
+ f" details=_({py_str(check_data['details'])}),",
+ " )",
+ ]
+
+ lines += ["", ""]
+
+ lines += ["@dataclass", f"class {pascal_name}(Scorecard):", f" name: str = _({py_str(scorecard_name)})"]
+
+ for section_key, section_data in sections.items():
+ class_name = section_class_names[section_key]
+ section_name = section_data["name"]
+ section_details = section_data.get("details", "")
+ if section_details:
+ lines += [
+ f" {section_key} = {class_name}(",
+ f" name=_({py_str(section_name)}),",
+ f" details=_({py_str(section_details)}),",
+ " )",
+ ]
+ else:
+ lines.append(f" {section_key} = {class_name}(name=_({py_str(section_name)}))")
+
+ lines += [
+ "",
+ "",
+ f"class {pascal_name}Evaluator(ScorecardEvaluator[{pascal_name}], ABC):",
+ f" scorecard_cls = {pascal_name}",
+ "",
+ ]
+
+ for check_name in all_check_names:
+ lines += [" @property", " @abstractmethod", f" def {check_name}(self) -> bool: ...", ""]
+
+ return "\n".join(lines)
+
+
+def codegen_scorecards(yml_file: str):
+ yml_path = Path(yml_file)
+ out_path = yml_path.with_suffix(".py")
+ out_path.write_text(generate_scorecard(yml_file))
+ print(f"Generated {out_path}")
+
+
+if __name__ == "__main__":
+ import sys
+
+ if len(sys.argv) == 3 and sys.argv[1] == "generate":
+ codegen_scorecards(sys.argv[2])
+ else:
+ print(f"Usage: {sys.argv[0]} generate .yml", file=sys.stderr)
+ sys.exit(1)
diff --git a/openlibrary/solr/solr_types.py b/openlibrary/solr/solr_types.py
index 5ad637ea80e..2316697ab86 100644
--- a/openlibrary/solr/solr_types.py
+++ b/openlibrary/solr/solr_types.py
@@ -41,6 +41,14 @@ class SolrDocument(TypedDict):
isbn: Optional[list[str]]
ebook_access: Optional[Literal['no_ebook', 'unclassified', 'printdisabled', 'borrowable', 'public']]
ebook_provider: Optional[list[str]]
+ usefulness_score: Optional[int]
+ usefulness_score_normalized: Optional[int]
+ access_score: Optional[int]
+ access_score_normalized: Optional[int]
+ discovery_score: Optional[int]
+ discovery_score_normalized: Optional[int]
+ evaluation_score: Optional[int]
+ evaluation_score_normalized: Optional[int]
lexile: Optional[list[int]]
lcc: Optional[list[str]]
lcc_sort: Optional[str]
@@ -138,5 +146,6 @@ class SolrDocument(TypedDict):
printdisabled_s: Optional[str]
lending_edition_s: Optional[str]
ebook_count_i: Optional[int]
+ last_modified_i: Optional[int]
# fmt: on
diff --git a/openlibrary/solr/types_generator.py b/openlibrary/solr/types_generator.py
index c0720cd3424..3e3c747da9d 100755
--- a/openlibrary/solr/types_generator.py
+++ b/openlibrary/solr/types_generator.py
@@ -9,6 +9,7 @@
"printdisabled_s": "Optional[str]",
"lending_edition_s": "Optional[str]",
"ebook_count_i": "Optional[int]",
+ "last_modified_i": "Optional[int]",
}
diff --git a/openlibrary/solr/updater/abstract.py b/openlibrary/solr/updater/abstract.py
index d1031c0d0d8..767b460cddd 100644
--- a/openlibrary/solr/updater/abstract.py
+++ b/openlibrary/solr/updater/abstract.py
@@ -31,7 +31,10 @@ async def update_key(self, thing: dict) -> tuple[SolrUpdateRequest, list[str]]:
class AbstractSolrBuilder:
- def build(self) -> SolrDocument:
+ def build(self, exclude: list[str] | None = None) -> SolrDocument:
+ if exclude is None:
+ exclude = []
+
# Iterate over all non-_ properties of this instance and add them to the
# document.
# Allow @property and @cached_property though!
@@ -39,6 +42,8 @@ def build(self) -> SolrDocument:
for field in dir(self):
if field.startswith("_"):
continue
+ if field in exclude:
+ continue
val = getattr(self, field)
if callable(val):
diff --git a/openlibrary/solr/updater/author.py b/openlibrary/solr/updater/author.py
index b7cf5b72f02..76808ef0b43 100644
--- a/openlibrary/solr/updater/author.py
+++ b/openlibrary/solr/updater/author.py
@@ -117,8 +117,8 @@ def top_subjects(self) -> list[str]:
all_subjects.sort(reverse=True)
return [top_facets for num, top_facets in all_subjects[:10]]
- def build(self) -> SolrDocument:
- doc = cast(dict, super().build())
+ def build(self, exclude: list[str] | None = None) -> SolrDocument:
+ doc = cast(dict, super().build(exclude=exclude))
doc |= self.build_ratings()
doc |= self.build_reading_log()
return cast(SolrDocument, doc)
diff --git a/openlibrary/solr/updater/edition.py b/openlibrary/solr/updater/edition.py
index 82f3c07447a..44f4f6b669a 100644
--- a/openlibrary/solr/updater/edition.py
+++ b/openlibrary/solr/updater/edition.py
@@ -6,6 +6,7 @@
import requests
import openlibrary.book_providers as bp
+from openlibrary.edition_scorecard import EditionScorecard, EditionScorecardEvaluator
from openlibrary.solr.solr_types import SolrDocument
from openlibrary.solr.updater.abstract import AbstractSolrBuilder, AbstractSolrUpdater
from openlibrary.solr.utils import SolrUpdateRequest, get_solr_base_url
@@ -114,12 +115,16 @@ class EditionSolrBuilder(AbstractSolrBuilder):
def __init__(
self,
edition: dict,
- solr_work: WorkSolrBuilder | None = None,
+ solr_work: WorkSolrBuilder | SolrDocument,
+ db_work: dict | None,
+ db_authors: list[dict],
ia_metadata: bp.IALiteMetadata | None = None,
data_provider: DataProvider | None = None,
):
self._edition = edition
self._solr_work = solr_work
+ self._db_work = db_work
+ self._authors = db_authors
self._ia_metadata = ia_metadata
self._data_provider = data_provider
self._providers = list(bp.get_book_providers(edition))
@@ -316,7 +321,7 @@ def ia_box_id(self) -> list[str]:
return uniq(boxids, key=lambda x: x.lower())
@property
- def identifiers(self) -> dict:
+ def _identifiers(self) -> dict:
identifiers = {}
for key, id_list in self._edition.get("identifiers", {}).items():
solr_key = key.replace(".", "_").replace(",", "_").replace("(", "").replace(")", "").replace(":", "_").replace("/", "").replace("#", "").lower()
@@ -351,7 +356,64 @@ def has_fulltext(self) -> bool:
def public_scan_b(self) -> bool:
return self.ebook_access == bp.EbookAccess.PUBLIC
- def build(self) -> SolrDocument:
+ def _get_description_text(self) -> str:
+ """Extract description text, handling both plain strings and text_block dicts."""
+ desc = self._edition.get("description")
+ if isinstance(desc, dict):
+ return desc.get("value", "")
+ return desc or ""
+
+ def get_scorecard(self) -> EditionScorecard:
+ if isinstance(self._solr_work, dict):
+ solr_work = self._solr_work
+ else:
+ # Don't include editions or we'll get an infinite loop!
+ solr_work = self._solr_work.build(exclude=["editions"])
+
+ return EditionScorecardForSolr(
+ solr_edition=self,
+ solr_work=solr_work,
+ db_work=self._db_work,
+ authors=self._authors or [],
+ ).evaluate()
+
+ @cached_property
+ def _usefulness_scorecard(self) -> EditionScorecard:
+ return self.get_scorecard()
+
+ @property
+ def usefulness_score(self) -> int:
+ return self._usefulness_scorecard.score
+
+ @property
+ def usefulness_score_normalized(self) -> int:
+ return self._usefulness_scorecard.score_normalized
+
+ @property
+ def access_score(self) -> int:
+ return self._usefulness_scorecard.access.score
+
+ @property
+ def access_score_normalized(self) -> int:
+ return self._usefulness_scorecard.access.score_normalized
+
+ @property
+ def discovery_score(self) -> int:
+ return self._usefulness_scorecard.discovery.score
+
+ @property
+ def discovery_score_normalized(self) -> int:
+ return self._usefulness_scorecard.discovery.score_normalized
+
+ @property
+ def evaluation_score(self) -> int:
+ return self._usefulness_scorecard.evaluation.score
+
+ @property
+ def evaluation_score_normalized(self) -> int:
+ return self._usefulness_scorecard.evaluation.score_normalized
+
+ def build(self, exclude: list[str] | None = None) -> SolrDocument:
"""
Build the solr document for the given edition to store as a nested
document
@@ -359,60 +421,245 @@ def build(self) -> SolrDocument:
Completely override parent class method to handle some peculiar
fields
"""
- solr_doc: SolrDocument = cast(
- SolrDocument,
- {
- "key": self.key,
- "work_key": self.work_key,
- "type": "edition",
- # Display data
- "title": self.title,
- "subtitle": self.subtitle,
- "alternative_title": list(self.alternative_title),
- "chapter": self.chapter,
- "cover_i": self.cover_i,
- "cover_width": self.cover_width,
- "cover_height": self.cover_height,
- "language": self.language,
- # Duplicate the author data from the work
- **(
- {
- "author_name": self._solr_work.author_name,
- "author_key": self._solr_work.author_key,
- "author_alternative_name": list(self._solr_work.author_alternative_name),
- "author_facet": self._solr_work.author_facet,
- }
- if self._solr_work
- else {}
- ),
- "edition_name": ([self.edition_name] if self.edition_name else None),
- # Misc useful data
- "publisher": self.publisher,
- "format": [self.format] if self.format else None,
- "publish_date": [self.publish_date] if self.publish_date else None,
- "publish_year": [self.publish_year] if self.publish_year else None,
- # Identifiers
- "isbn": self.isbn,
- "lccn": self.lccn,
- "oclc": self.oclc,
- **self.identifiers,
- # IA
- "ia": [self.ia] if self.ia else None,
- "ia_collection": self.ia_collection,
- "ia_box_id": self.ia_box_id,
- # Ebook access
- "ebook_access": self.ebook_access.to_solr_str(),
- "ebook_provider": self.ebook_provider,
- "has_fulltext": self.has_fulltext,
- "public_scan_b": self.public_scan_b,
- },
- )
+ from openlibrary.solr.updater.work import WorkSolrBuilder # noqa: PLC0415
+
+ exclude = exclude or []
+
+ # Avoid calling self._solr_work.build(), since that makes db requests for eg ratings
+ if self._solr_work and isinstance(self._solr_work, WorkSolrBuilder):
+ author_fields = {
+ "author_name": self._solr_work.author_name,
+ "author_key": self._solr_work.author_key,
+ "author_alternative_name": list(self._solr_work.author_alternative_name),
+ "author_facet": self._solr_work.author_facet,
+ }
+ elif self._solr_work and isinstance(self._solr_work, dict):
+ author_fields = {
+ "author_name": self._solr_work.get("author_name") or [],
+ "author_key": self._solr_work.get("author_key") or [],
+ "author_alternative_name": self._solr_work.get("author_alternative_name") or [],
+ "author_facet": self._solr_work.get("author_facet") or [],
+ }
+ else:
+ author_fields = {}
+
+ solr_doc = {
+ "key": self.key,
+ "work_key": self.work_key,
+ "type": "edition",
+ # Display data
+ "title": self.title,
+ "subtitle": self.subtitle,
+ "alternative_title": list(self.alternative_title),
+ "chapter": self.chapter,
+ "cover_i": self.cover_i,
+ "cover_width": self.cover_width,
+ "cover_height": self.cover_height,
+ "language": self.language,
+ # Duplicate the author data from the work
+ **author_fields,
+ "edition_name": ([self.edition_name] if self.edition_name else None),
+ # Misc useful data
+ "publisher": self.publisher,
+ "format": [self.format] if self.format else None,
+ "publish_date": [self.publish_date] if self.publish_date else None,
+ "publish_year": [self.publish_year] if self.publish_year else None,
+ # Usefulness scores
+ "usefulness_score": self.usefulness_score,
+ "usefulness_score_normalized": self.usefulness_score_normalized,
+ "access_score": self.access_score,
+ "access_score_normalized": self.access_score_normalized,
+ "discovery_score": self.discovery_score,
+ "discovery_score_normalized": self.discovery_score_normalized,
+ "evaluation_score": self.evaluation_score,
+ "evaluation_score_normalized": self.evaluation_score_normalized,
+ # Identifiers
+ "isbn": self.isbn,
+ "lccn": self.lccn,
+ "oclc": self.oclc,
+ **self._identifiers,
+ # IA
+ "ia": [self.ia] if self.ia else None,
+ "ia_collection": self.ia_collection,
+ "ia_box_id": self.ia_box_id,
+ # Ebook access
+ "ebook_access": self.ebook_access.to_solr_str(),
+ "ebook_provider": self.ebook_provider,
+ "has_fulltext": self.has_fulltext,
+ "public_scan_b": self.public_scan_b,
+ }
return cast(
SolrDocument,
- {
- key: solr_doc[key] # type: ignore
- for key in solr_doc
- if solr_doc[key] not in (None, [], "") # type: ignore
- },
+ {key: solr_doc[key] for key in solr_doc if solr_doc[key] not in (None, [], "") and key not in exclude},
)
+
+
+class EditionScorecardForSolr(EditionScorecardEvaluator):
+ """Evaluates an EditionScorecard using an EditionSolrBuilder as the data source."""
+
+ REQUIRED_SOLR_WORK_FIELDS = (
+ "ddc_sort",
+ "first_publish_year",
+ "lcc_sort",
+ "lexile",
+ "ratings_count",
+ "readinglog_count",
+ )
+
+ def __init__(
+ self,
+ solr_edition: EditionSolrBuilder,
+ solr_work: SolrDocument,
+ db_work: dict | None,
+ authors: list[dict],
+ ):
+ self.solr_edition = solr_edition
+ self.solr_work = solr_work
+ self.db_work = db_work or {}
+ self.authors = authors
+
+ # --- Access ---
+
+ @property
+ def read_access(self) -> bool:
+ return self.solr_edition.ebook_access >= bp.EbookAccess.BORROWABLE
+
+ @property
+ def search_inside_access(self) -> bool:
+ return self.solr_edition.ebook_access >= bp.EbookAccess.PRINTDISABLED
+
+ @property
+ def programmatic_access(self) -> bool:
+ return self.solr_edition.ebook_access == bp.EbookAccess.PUBLIC
+
+ @property
+ def purchase_options(self) -> bool:
+ identifiers = self.solr_edition._edition.get("identifiers", {})
+ return bool(self.solr_edition.isbn or identifiers.get("amazon") or identifiers.get("better_world_books"))
+
+ @property
+ def library_options(self) -> bool:
+ return bool(self.solr_edition.isbn or self.solr_edition.oclc)
+
+ @property
+ def fan_fiction(self) -> bool:
+ return any("archiveofourown.org" in (link.get("url", "") if isinstance(link, dict) else "") for link in self.db_work.get("links", []))
+
+ @property
+ def wikipedia(self) -> bool:
+ return bool(self.db_work.get("identifiers", {}).get("wikidata")) or any(
+ "wikipedia.org" in (link.get("url", "") if isinstance(link, dict) else "") for link in self.db_work.get("links", [])
+ )
+
+ @property
+ def first_sentence(self) -> bool:
+ return bool(self.solr_edition._edition.get("first_sentence"))
+
+ # --- Discovery ---
+
+ @property
+ def title(self) -> bool:
+ return bool(self.solr_edition.title)
+
+ @property
+ def author_name(self) -> bool:
+ return bool(self.db_work.get("authors"))
+
+ @property
+ def genre_tags(self) -> bool:
+ # Not a thing yet
+ # return bool(self.solr_work.get("genres"))
+ return False
+
+ @property
+ def series(self) -> bool:
+ return bool(self.db_work.get("series"))
+
+ @property
+ def table_of_contents(self) -> bool:
+ return bool(self.solr_edition.chapter)
+
+ @property
+ def classifications(self) -> bool:
+ # Doesn't matter which edition has the classification
+ return bool(self.solr_work.get("ddc_sort") or self.solr_work.get("lcc_sort"))
+
+ @property
+ def language(self) -> bool:
+ return bool(self.solr_edition.language)
+
+ @property
+ def isbn(self) -> bool:
+ return bool(self.solr_edition.isbn)
+
+ @property
+ def lexile(self) -> bool:
+ # Doesn't matter which edition has the lexile score
+ return bool(self.solr_work.get("lexile"))
+
+ @property
+ def star_ratings(self) -> bool:
+ return (self.solr_work.get("ratings_count") or 0) > 0
+
+ @property
+ def on_readinglogs(self) -> bool:
+ return (self.solr_work.get("readinglog_count") or 0) > 0
+
+ @property
+ def on_lists(self) -> bool:
+ return False # not yet available in Solr builder
+
+ @property
+ def contributor_names(self) -> bool:
+ return bool(self.solr_edition._edition.get("contributions"))
+
+ # --- Evaluation ---
+
+ @property
+ def basic_description(self) -> bool:
+ return bool(self.solr_edition._get_description_text())
+
+ @property
+ def cover(self) -> bool:
+ return bool(self.solr_edition.cover_i)
+
+ @property
+ def rich_description(self) -> bool:
+ return len(self.solr_edition._get_description_text()) > 50
+
+ @property
+ def readinglog_counts(self) -> bool:
+ return (self.solr_work.get("readinglog_count") or 0) > 0
+
+ @property
+ def list_count(self) -> bool:
+ return False # not yet available in Solr builder
+
+ @property
+ def page_count(self) -> bool:
+ return bool(self.solr_edition.number_of_pages)
+
+ @property
+ def author_photo(self) -> bool:
+ return any(p for a in self.authors for p in (a.get("photos") or []) if p != -1)
+
+ @property
+ def first_publish_year(self) -> bool:
+ return bool(self.solr_work.get("first_publish_year"))
+
+ @property
+ def publish_year(self) -> bool:
+ return bool(self.solr_edition.publish_year)
+
+ @property
+ def author_bio(self) -> bool:
+ return any(a.get("bio") or a.get("description") for a in self.authors)
+
+ @property
+ def publisher(self) -> bool:
+ return bool(self.solr_edition.publisher) and not all(is_sine_nomine(p) for p in self.solr_edition.publisher)
+
+ @property
+ def author_links(self) -> bool:
+ return any(a.get("remote_ids") for a in self.authors)
diff --git a/openlibrary/solr/updater/list.py b/openlibrary/solr/updater/list.py
index 4408605695c..75c9bc64b4f 100644
--- a/openlibrary/solr/updater/list.py
+++ b/openlibrary/solr/updater/list.py
@@ -84,8 +84,8 @@ def __init__(self, list: dict, solr_reply: dict | None = None):
self._list = list
self._solr_reply = solr_reply
- def build(self) -> SolrDocument:
- doc = cast(dict, super().build())
+ def build(self, exclude: list[str] | None = None) -> SolrDocument:
+ doc = cast(dict, super().build(exclude=exclude))
doc |= self.build_subjects()
return cast(SolrDocument, doc)
diff --git a/openlibrary/solr/updater/work.py b/openlibrary/solr/updater/work.py
index e33178f5d54..c32ac6fd251 100644
--- a/openlibrary/solr/updater/work.py
+++ b/openlibrary/solr/updater/work.py
@@ -165,13 +165,18 @@ def get_ia_collection_and_box_id(ia: str, data_provider: DataProvider) -> bp.IAL
if len(ia) == 1:
return None
- def get_list(d, key):
+ metadata = data_provider.get_metadata(ia)
+ if metadata is None:
+ # It's none when the IA id is not found/invalid.
+ # TODO: It would be better if get_metadata raised an error.
+ return None
+ return full_ia_metadata_to_lite_metadata(metadata)
+
+
+def full_ia_metadata_to_lite_metadata(metadata: dict) -> bp.IALiteMetadata:
+ def get_list(d: dict | None, key: str) -> list:
"""
Return d[key] as some form of list, regardless of if it is or isn't.
-
- :param dict or None d:
- :param str key:
- :rtype: list
"""
if not d:
return []
@@ -183,11 +188,6 @@ def get_list(d, key):
else:
return value
- metadata = data_provider.get_metadata(ia)
- if metadata is None:
- # It's none when the IA id is not found/invalid.
- # TODO: It would be better if get_metadata raised an error.
- return None
return {
"boxid": set(get_list(metadata, "boxid")),
"collection": set(get_list(metadata, "collection")),
@@ -287,29 +287,40 @@ def __init__(
self._ia_metadata = ia_metadata
self._data_provider = data_provider
self._trending_data = trending_data
+ self._as_solr_edition = EditionSolrBuilder(
+ edition=self._work,
+ solr_work=self,
+ db_work=self._work,
+ db_authors=authors,
+ data_provider=self._data_provider,
+ )
self._solr_editions = [
EditionSolrBuilder(
edition=e,
solr_work=self,
+ db_work=self._work,
+ db_authors=authors,
ia_metadata=self._ia_metadata.get(e.get("ocaid", "").strip()),
data_provider=self._data_provider,
)
for e in self._editions
]
- self._as_solr_edition = EditionSolrBuilder(
- edition=self._work,
- data_provider=self._data_provider,
- )
- def build(self) -> SolrDocument:
- doc = cast(dict, super().build())
- doc |= self.build_identifiers()
- doc |= self.build_subjects()
- doc |= self.build_legacy_ia_fields()
- doc |= self.build_ratings() or {}
- doc |= self.build_reading_log() or {}
- doc |= self._trending_data
- return cast(SolrDocument, doc)
+ def build(self, exclude: list[str] | None = None) -> SolrDocument:
+ exclude = exclude or []
+
+ return cast(
+ SolrDocument,
+ {
+ **super().build(exclude=exclude),
+ **self._identifiers,
+ **self._subjects,
+ **self._legacy_ia_fields,
+ **(self._ratings or {}),
+ **(self._reading_log or {}),
+ **self._trending_data,
+ },
+ )
@property
def key(self):
@@ -567,10 +578,12 @@ def printdisabled_s(self) -> str | None:
# ^^^ These should be deprecated and removed ^^^
- def build_ratings(self) -> WorkRatingsSummary | None:
+ @cached_property
+ def _ratings(self) -> WorkRatingsSummary | None:
return self._data_provider.get_work_ratings(self._work["key"])
- def build_reading_log(self) -> WorkReadingLogSolrSummary | None:
+ @cached_property
+ def _reading_log(self) -> WorkReadingLogSolrSummary | None:
return self._data_provider.get_work_reading_log(self._work["key"])
@cached_property
@@ -623,7 +636,8 @@ def format(self) -> set[str]:
def language(self) -> set[str]:
return {lang for ed in self._solr_editions for lang in ed.language}
- def build_legacy_ia_fields(self) -> dict:
+ @property
+ def _legacy_ia_fields(self) -> dict:
ia_box_id = set()
for e in self._editions:
@@ -661,14 +675,16 @@ def author_alternative_name(self) -> set[str]:
def author_facet(self) -> list[str]:
return [f"{key} {name}" for key, name in zip(self.author_key, self.author_name)]
- def build_identifiers(self) -> dict[str, list[str]]:
+ @property
+ def _identifiers(self) -> dict[str, list[str]]:
identifiers: dict[str, list[str]] = defaultdict(list)
for ed in self._solr_editions:
- for k, v in ed.identifiers.items():
+ for k, v in ed._identifiers.items():
identifiers[k] += v
return dict(identifiers)
- def build_subjects(self) -> dict:
+ @property
+ def _subjects(self) -> dict:
doc: dict = {}
field_map = {
"subjects": "subject",
diff --git a/openlibrary/templates/books/edit/edition.html b/openlibrary/templates/books/edit/edition.html
index 8623cd48fb4..752a9197a53 100644
--- a/openlibrary/templates/books/edit/edition.html
+++ b/openlibrary/templates/books/edit/edition.html
@@ -61,6 +61,16 @@