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()} + +
+ + + + + ${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 })} + + +
${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 @@
$_("This Edition") + $if book.scorecard: + +
diff --git a/openlibrary/templates/design.html b/openlibrary/templates/design.html index d94e9df2627..cb79bd6be5e 100644 --- a/openlibrary/templates/design.html +++ b/openlibrary/templates/design.html @@ -24,6 +24,7 @@

Web Component Library

  • Options Popover
  • Dialog
  • Read More
  • +
  • Scorecard
  • Chip Group
      @@ -237,6 +238,36 @@

      Content shorter than limit (no button)

      $:render_template("design/api_tables", components.get("ol-read-more")) +
      +

      Scorecard

      +

      The ol-scorecard component displays a metadata quality scorecard as a tabbed set of sections, each listing the checks that make up its score.

      + +
      +

      Collapsed (default)

      +

      Data is passed as a JSON results attribute (or property). Labels are individually translatable. Collapsed by default, showing a small ring proportional to the overall percentage — click it to expand.

      +
      <ol-scorecard
      +  results='{"name":"Edition Scorecard","score":115,"maxScore":225,"sections":[...]}'>
      +</ol-scorecard>
      +
      + +
      +
      + +
      +

      Expanded

      +

      Add the expanded attribute to render the full tabbed UI directly. Clicking the header collapses it back.

      +
      <ol-scorecard
      +  expanded
      +  results='{"name":"Edition Scorecard","score":115,"maxScore":225,"sections":[...]}'>
      +</ol-scorecard>
      +
      + +
      +
      + + $:render_template("design/api_tables", components.get("ol-scorecard")) +
      +

      Chip Group

      The ol-chip-group component is a flex-wrap container that provides consistent spacing for groups of chips.

      diff --git a/openlibrary/tests/solr/updater/test_edition.py b/openlibrary/tests/solr/updater/test_edition.py index f150d02ebeb..87d70825b7f 100644 --- a/openlibrary/tests/solr/updater/test_edition.py +++ b/openlibrary/tests/solr/updater/test_edition.py @@ -50,6 +50,6 @@ def test_identifiers(self): } ) - assert EditionSolrBuilder(edition).identifiers == { + assert EditionSolrBuilder(edition, solr_work={}, db_work=None, db_authors=[])._identifiers == { "id_some_weird_key": ["id-1", "id-2"], } diff --git a/openlibrary/tests/solr/updater/test_work.py b/openlibrary/tests/solr/updater/test_work.py index d99c258c910..f683a381c07 100644 --- a/openlibrary/tests/solr/updater/test_work.py +++ b/openlibrary/tests/solr/updater/test_work.py @@ -169,16 +169,16 @@ def test_identifiers(self): make_edition(work, identifiers={"librarything": ["lt-1"]}), make_edition(work, identifiers={"librarything": ["lt-2"]}), ], - ).build_identifiers() + )._identifiers assert sorted(d.get("id_librarything", [])) == ["lt-1", "lt-2"] def test_ia_boxid(self): w = make_work() - d = make_work_solr_builder(w, [make_edition(w)]).build_legacy_ia_fields() + d = make_work_solr_builder(w, [make_edition(w)])._legacy_ia_fields assert "ia_box_id" not in d w = make_work() - d = make_work_solr_builder(w, [make_edition(w, ia_box_id="foo")]).build_legacy_ia_fields() + d = make_work_solr_builder(w, [make_edition(w, ia_box_id="foo")])._legacy_ia_fields assert d["ia_box_id"] == ["foo"] def test_with_one_lending_edition(self): @@ -294,7 +294,7 @@ def test_with_multiple_editions(self): def test_subjects(self): w = make_work(subjects=["a", "b c"]) - d = make_work_solr_builder(w).build_subjects() + d = make_work_solr_builder(w)._subjects assert d["subject"] == ["a", "b c"] assert d["subject_facet"] == ["a", "b c"] @@ -310,7 +310,7 @@ def test_subjects(self): subject_people=["a", "b c"], subject_times=["a", "b c"], ) - d = make_work_solr_builder(w).build_subjects() + d = make_work_solr_builder(w)._subjects for k in ["subject", "person", "place", "time"]: assert d[k] == ["a", "b c"] diff --git a/openlibrary/tests/test_scorecards.py b/openlibrary/tests/test_scorecards.py new file mode 100644 index 00000000000..b49a9242d8d --- /dev/null +++ b/openlibrary/tests/test_scorecards.py @@ -0,0 +1,38 @@ +import os +from dataclasses import dataclass + +from openlibrary.scorecards import ScorecardCheck, ScorecardSection, generate_scorecard + + +@dataclass +class SectionA(ScorecardSection): + passing_check = ScorecardCheck(name="passing_check", score=10, description="Passing", details="Passing details") + failing_check = ScorecardCheck(name="failing_check", score=5, description="Failing", details="Failing details") + + +root = os.path.dirname(__file__) + + +def test_edition_scorecard_up_to_date(): + yml_path = os.path.join(root, "..", "edition_scorecard.yml") + py_path = os.path.join(root, "..", "edition_scorecard.py") + with open(py_path) as f: + assert generate_scorecard(yml_path).strip() == f.read().strip(), """ + This auto-generated file is out-of-date. Run: + ./openlibrary/scorecards.py generate openlibrary/edition_scorecard.yml + """ + + +def test_scorecard_section_to_dict(): + section = SectionA(name="Section A") + section.set_check(SectionA.passing_check, True) + + assert section.to_dict() == { + "name": "Section A", + "score": 10, + "maxScore": 15, + "checks": [ + {"description": "Failing", "details": "Failing details", "score": 5, "passing": False}, + {"description": "Passing", "details": "Passing details", "score": 10, "passing": True}, + ], + }