GUTENBERG
Books
Statement Begins
I hand it a cover and four lines about what the book was, and it builds the rest of the dust jacket itself: the spine, the topics, the description set where a blurb would sit.
It fetches nothing. The cover is the one I gave it, and when that link goes dead the card hides the empty frame rather than show you a cracked one.
What is left is the book as it would sit on a shelf, made from the handful of facts I bothered to write down.
Custom Views Code

<div class="bk-card" data-name="{{file.name}}">
<div class="bk-top">
<div class="bk-cover-wrap"><img class="bk-cover" src="{{cover}}" alt="" /></div>
<div class="bk-head">
<h1 class="bk-title" data-raw="{{file.basename}}">{{file.basename}}</h1>
<div class="bk-rule"></div>
<p class="bk-author" data-wiki="{{author}}"></p>
<div class="bk-meta">
<p class="bk-facts">{% if year %}{{year}}{% endif %}</p>
<p class="bk-genre" data-wiki="{{genre}}"></p>
<p class="bk-topics" data-wiki="{{topics}}"></p>
<div class="media-score" data-kind="book"></div>
</div>
</div>
</div>
{% if description %}<div class="bk-rule-full"></div><p class="bk-desc">{{description}}</p>{% endif %}
<button class="bk-more" type="button">SHOW MORE</button>
<div class="bk-notes"></div>
</div>
.obsidian-custom-view-editable .obsidian-custom-view-render,
.obsidian-custom-view-render { padding: 0 !important; --line-width: 100%; --max-width: none; }
.bk-card {
--bk-serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
--bk-pad: clamp(28px, 5vw, 76px);
container-type: inline-size;
container-name: bk;
width: 100%;
box-sizing: border-box;
padding: var(--bk-pad);
background: var(--background-primary);
color: var(--text-normal);
font-family: var(--bk-serif);
}
/* ── top: cover + masthead ── */
.bk-top { display: flex; gap: clamp(24px, 4vw, 52px); align-items: flex-start; }
.bk-cover-wrap { flex: 0 0 auto; }
.bk-cover {
width: clamp(140px, 18vw, 200px);
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 3px;
box-shadow: 0 14px 38px rgba(0,0,0,.55), 0 2px 6px rgba(0,0,0,.4);
display: block;
max-width: none;
}
.bk-head { flex: 1 1 0; min-width: 0; padding-top: 4px; }
.bk-title {
margin: 0;
font-family: var(--bk-serif);
font-size: clamp(30px, 4vw, 52px);
font-weight: 600;
line-height: 1.08;
letter-spacing: -0.01em;
color: var(--text-normal);
word-break: break-word;
}
.bk-rule { width: 56px; height: 2px; margin: 16px 0 14px; background: var(--color-accent, var(--text-faint)); opacity: .85; }
.bk-author {
margin: 0 0 18px;
font-family: var(--bk-serif);
font-style: italic;
font-size: clamp(16px, 1.5vw, 20px);
color: var(--text-muted);
}
.bk-meta { display: flex; flex-direction: column; gap: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif; }
.bk-facts { margin: 0; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); }
.bk-rating { color: var(--color-accent, var(--text-normal)); letter-spacing: 2px; }
.bk-genre { margin: 0; font-size: 14px; color: var(--text-muted); }
.bk-topics { margin: 2px 0 0; display: flex; flex-wrap: wrap; gap: 7px; }
.bk-tag {
font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-faint);
border: 1px solid var(--background-modifier-border); border-radius: 999px; padding: 3px 10px;
}
/* ── description ── */
.bk-rule-full { width: 100%; height: 1px; margin: clamp(28px,4vw,44px) 0 clamp(22px,3vw,30px); background: var(--background-modifier-border); }
.bk-desc {
margin: 0; max-width: 66ch;
font-family: var(--bk-serif);
font-size: clamp(16px, 1.25vw, 19px);
line-height: 1.72;
color: var(--text-normal);
}
.bk-desc::first-letter { float: left; font-size: 3.5em; line-height: .82; padding: 6px 10px 0 0; font-weight: 600; color: var(--text-normal); }
/* ── notes ── */
.bk-notes-head {
margin: clamp(34px,5vw,56px) 0 14px;
font-family: -apple-system, sans-serif; font-size: 12px; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: var(--text-faint);
display: flex; align-items: center; gap: 14px;
}
.bk-notes-head::after { content: ""; flex: 1; height: 1px; background: var(--background-modifier-border); }
.bk-notes { font-size: 16px; line-height: 1.7; color: var(--text-muted); }
.bk-notes > :first-child { margin-top: 0; }
.bk-notes .markdown-rendered-content.markdown-preview-view.markdown-rendered { margin: 0; padding: 0; }
/* ── narrow / mobile (container + media fallback) ── */
@container bk (max-width: 620px) {
.bk-top { flex-direction: column; align-items: center; text-align: center; }
.bk-cover { width: clamp(150px, 46vw, 200px); }
.bk-rule { margin-left: auto; margin-right: auto; }
.bk-meta { align-items: center; }
.bk-topics { justify-content: center; }
.bk-desc { max-width: none; }
}
@media (max-width: 620px) {
.bk-top { flex-direction: column; align-items: center; text-align: center; }
.bk-cover { width: clamp(150px, 46vw, 200px); }
.bk-rule { margin-left: auto; margin-right: auto; }
.bk-meta { align-items: center; }
.bk-topics { justify-content: center; }
.bk-desc { max-width: none; }
}
/* -- SHOW MORE button + collapsible notes (added 2026-05-30) -- */
.bk-more {
display: inline-block; align-self: flex-start;
font: 600 11px/1 -apple-system, BlinkMacSystemFont, sans-serif; letter-spacing: .12em;
color: var(--text-normal); background: var(--background-modifier-hover);
border: 1px solid var(--background-modifier-border); padding: 9px 17px;
border-radius: 999px; cursor: pointer; transition: background .15s ease;
}
.bk-more:hover { background: var(--background-modifier-active-hover); }
.bk-notes { display: none; margin-top: 16px; }
.bk-card.notes-open .bk-notes { display: block; }
.bk-notes > :first-child { margin-top: 0; }
const root = this;
const clean = s => (s || "").replace(/\[\[|\]\]/g, "").trim();
const listOf = el => clean(el.getAttribute("data-wiki") || "").split(",").map(x => x.trim()).filter(Boolean);
// masthead title: strip the "Books - " filename prefix
const title = root.querySelector(".bk-title");
if (title) title.textContent = (title.getAttribute("data-raw") || "").replace(/^Books?\s*-\s*/, "");
// author
const au = root.querySelector(".bk-author");
if (au) { const v = listOf(au); if (v.length) au.textContent = "by " + v.join(", "); else au.remove(); }
// genre
const gn = root.querySelector(".bk-genre");
if (gn) { const v = listOf(gn); if (v.length) gn.textContent = v.join(" · "); else gn.remove(); }
// topics → tags
const tps = root.querySelector(".bk-topics");
if (tps) {
const v = listOf(tps);
if (v.length) { tps.innerHTML = ""; v.forEach(t => { const s = document.createElement("span"); s.className = "bk-tag"; s.textContent = t; tps.appendChild(s); }); }
else tps.remove();
}
// drop the empty facts line if nothing landed in it
const facts = root.querySelector(".bk-facts");
if (facts && !facts.textContent.trim()) facts.remove();
// cover error → hide the broken image frame
const cov = root.querySelector(".bk-cover");
if (cov) cov.addEventListener("error", () => { cov.style.display = "none"; });
/* -- SHOW MORE: render note body (frontmatter + score callout stripped), toggle like Albums -- */
(async () => {
try {
const _card = root.querySelector(".bk-card") || root;
const moreBtn = _card.querySelector(".bk-more");
const notesEl = _card.querySelector(".bk-notes");
if (!notesEl) return;
const wantName = _card.dataset.name || "";
let file = app.workspace.getActiveFile();
if ((!file && wantName) || (file && wantName && file.name !== wantName)) {
const byName = app.vault.getFiles().find(f => f.name === wantName);
if (byName) file = byName;
}
const fail = () => { if (moreBtn) moreBtn.remove(); notesEl.remove(); };
if (!file) return fail();
let body = await app.vault.read(file);
body = body.replace(/^---[\s\S]*?\n---\n?/, "");
body = body.replace(/^\s*((?:>.*\n?)+)\n?/, m => /dataviewjs|\[!abstract\]/.test(m) ? "" : m);
body = body.trim();
const meaningful = body.replace(/^#{1,6}\s.*$/gm, "").replace(/^#\S+\s*$/gm, "").replace(/^suggested by.*$/gim, "").replace(/\s+/g, "");
if (!meaningful) return fail();
const obs = require("obsidian");
notesEl.classList.add("markdown-rendered");
notesEl.innerHTML = "";
const comp = new obs.Component();
if (obs.MarkdownRenderer && obs.MarkdownRenderer.render) await obs.MarkdownRenderer.render(app, body, notesEl, file.path, comp);
else if (obs.MarkdownRenderer) await obs.MarkdownRenderer.renderMarkdown(body, notesEl, file.path, comp);
if (moreBtn) moreBtn.addEventListener("click", () => {
const open = _card.classList.toggle("notes-open");
moreBtn.textContent = open ? "SHOW LESS" : "SHOW MORE";
});
} catch (e) { console.error("[Custom Views notes]", e); }
})();
/* -- computed 0-100 score + verdict (mirrors the medium's scoring rubric) -- */
(() => {
try {
const _scard = root.querySelector(".bk-card"); if (!_scard) return;
const el = _scard.querySelector(".media-score");
let _sf = app.workspace.getActiveFile();
const wn = _scard.dataset.name || "";
if (wn && (!_sf || _sf.name !== wn)) { const b = app.vault.getFiles().find(x => x.name === wn); if (b) _sf = b; }
const fm = _sf ? ((app.metadataCache.getFileCache(_sf) || {}).frontmatter || {}) : {};
const n = v => (typeof v === "number" && !isNaN(v)) ? v : 0;
const C = ["prose","narrative","characters","themes","pacing","worldbuilding","originality","ending","emotional_impact","personal_score"];
const sum = C.reduce((a, k) => a + n(fm[k]), 0);
const count = C.filter(k => n(fm[k])).length;
const bonus = (fm.favorite?3:0)+(fm.reread?2:0)+(fm.stuck_with_me?2:0);
const total = count >= 6 ? Math.min(100, Math.round(sum / count * 10) + bonus) : null;
const B = [[100,'A masterpiece - flawless in every respect.'],[95,'An all-time great with only unoverlookable minor flaws.'],[90,'Genre-defining, held back only slightly. An all-timer.'],[85,'A standout of its year - memorable, with very few flaws.'],[80,'Excellent and distinctive. Well worth your time.'],[75,'Above average, with several elevating elements.'],[70,'Solid, lifted by some standout element.'],[65,'Above average, but with noticeable flaws.'],[60,'Decent - some genuinely enjoyable parts.'],[55,'Passable, but disappoints on most fronts.'],[50,'The midpoint - average, forgettable.'],[45,'Subpar; falls short on most fronts.'],[40,'Poor - significant flaws, little to enjoy.'],[35,'Below average; flawed and unoriginal.'],[30,'Flat and typical; forgettable.'],[25,'Disappointing; problems outweigh the positives.'],[20,'Largely unenjoyable; few redeeming qualities.'],[15,'Severely flawed; not worth experiencing.'],[10,'Among the worst of its kind.'],[5,'Abysmal - a complete failure.'],[0,'Not worth mentioning.']];
const verdict = total === null ? "" : (B.find(b => total >= b[0]) || B[B.length - 1])[1];
if (el) {
if (total === null) el.innerHTML = `<span class="media-score-num" data-empty="1">Not yet scored</span><span class="media-verdict">${count}/6 core fields filled</span>`;
else el.innerHTML = `<span class="media-score-num">${total}</span><span class="media-verdict">${verdict}</span>`;
}
} catch (e) { console.error("[Custom Views score]", e); }
})();
Templater Template
<%*
/**
* Wikidata + Wikipedia → Book Frontmatter (Obsidian Templater)
* No API key required. Uses the Wikidata ACTION api (wbsearchentities /
* wbgetentities — separate from the rate-limited SPARQL service) for
* author / year / genre, and the Wikipedia REST summary for the description
* and cover image. Fields Wikipedia can't reach (pages, ISBN) are omitted.
* Rendered by the Custom Views "Books" view.
*/
const WD = "https://www.wikidata.org/w/api.php";
const WPSUM = "https://en.wikipedia.org/api/rest_v1/page/summary/";
const today = tp.date.now("YYYY-MM-DD");
const fileTitle = tp.file.title;
async function wd(params) {
const qs = new URLSearchParams(Object.assign({ format: "json" }, params)).toString();
return await tp.web.request(`${WD}?${qs}`);
}
// 1) Ask for title -----------------------------------------------------
let query = await tp.system.prompt("Enter book title (adding the author helps):");
if (!query || !query.trim()) query = fileTitle.replace(/^Books?\s*-\s*/, "");
query = query.trim();
// 2) Search Wikidata ---------------------------------------------------
let s;
try { s = await wd({ action: "wbsearchentities", search: query, language: "en", uselang: "en", type: "item", limit: 12 }); }
catch (e) { tR += `Wikidata search error: ${e}`; return; }
const cands = (s && s.search) || [];
if (!cands.length) { tR += `No results for "${query}".`; return; }
const display = cands.map(c => `${c.label}${c.description ? " — " + c.description : ""}`);
// 3) Pick (the description tells you which is the book) ----------------
const chosen = await tp.system.suggester(display, cands, true, "Pick the book");
if (!chosen) { tR += "Selection cancelled."; return; }
const qid = chosen.id;
// 4) Entity claims + Wikipedia sitelink --------------------------------
let ent = {};
try {
const e = await wd({ action: "wbgetentities", ids: qid, props: "claims|sitelinks", languages: "en", sitefilter: "enwiki" });
ent = (e.entities && e.entities[qid]) || {};
} catch (e) { /* non-fatal */ }
const cl = ent.claims || {};
const idsOf = p => (cl[p] || []).map(c => c.mainsnak.datavalue && c.mainsnak.datavalue.value.id).filter(Boolean);
const authorsQ = idsOf("P50");
const genresQ = idsOf("P136").slice(0, 4);
const pubTime = (cl.P577 || []).map(c => c.mainsnak.datavalue && c.mainsnak.datavalue.value.time).filter(Boolean)[0] || "";
const year = (pubTime.match(/(\d{4})/) || [])[1] || "";
const wikiTitle = ((ent.sitelinks || {}).enwiki || {}).title || "";
// 5) Resolve author / genre Q-ids to names -----------------------------
let authors = [], genres = [];
const allQ = authorsQ.concat(genresQ);
if (allQ.length) {
try {
const lab = await wd({ action: "wbgetentities", ids: allQ.join("|"), props: "labels", languages: "en" });
const nm = q => (((lab.entities[q] || {}).labels || {}).en || {}).value || q;
authors = authorsQ.map(nm);
genres = genresQ.map(nm);
} catch (e) { /* leave empty */ }
}
// 6) Wikipedia summary: description + cover ----------------------------
let description = "", cover = "";
if (wikiTitle) {
try {
const sum = await tp.web.request(WPSUM + encodeURIComponent(wikiTitle.replace(/ /g, "_")));
if (sum && sum.extract) description = sum.extract;
if (sum && sum.originalimage && sum.originalimage.source) cover = sum.originalimage.source;
} catch (e) { /* leave empty */ }
}
description = description.replace(/\r?\n+/g, " ").replace(/\s+/g, " ").replace(/"/g, '\\"').trim().slice(0, 700);
// Helper: link a person/subject if a note exists (first few always linked)
async function noteExists(name) {
for (const folder of ["References", ""]) {
const path = folder ? `${folder}/${name}.md` : `${name}.md`;
try { if (await tp.file.exists(path)) return true; } catch (e) {}
}
return false;
}
async function linkOrName(names, alwaysLinkFirst = 3) {
const out = [];
for (let i = 0; i < names.length; i++) {
out.push((i < alwaysLinkFirst || await noteExists(names[i])) ? `[[${names[i]}]]` : names[i]);
}
return out;
}
const authorLinks = await linkOrName(authors);
const genreLinks = await linkOrName(genres.map(g => g.replace(/\b\w/g, c => c.toUpperCase())));
// 8) Rename to "Books - Title" -----------------------------------------
const bookTitle = (chosen.label || fileTitle).trim();
const colonFixed = bookTitle.replace(/:/g, " - ");
let sanitized = colonFixed.replace(/[\/#%&{}<>*?$!'"@+`|=]/g, "").trim();
if (!sanitized) sanitized = fileTitle;
const requiresAlias = sanitized !== bookTitle;
const newName = sanitized;
if (newName !== fileTitle) await tp.file.rename(newName);
// 9) Build YAML (no pages / ISBN — not reachable via Wikipedia) --------
const L = [];
L.push("---");
L.push("connections:");
L.push("categories:");
L.push(' - "[[Books]]"');
if (authorLinks.length) { L.push("author:"); authorLinks.forEach(a => L.push(` - "${a}"`)); } else L.push("author: []");
L.push(cover ? `cover: "${cover}"` : "cover: []");
if (genreLinks.length) { L.push("genre:"); genreLinks.forEach(g => L.push(` - "${g}"`)); } else L.push("genre: []");
L.push("topics: []");
L.push(`description: "${description}"`);
L.push(`year: ${year}`);
L.push(`created: ${today}`);
L.push("last:");
L.push("via:");
if (wikiTitle) L.push(`wiki: "https://en.wikipedia.org/wiki/${wikiTitle.replace(/ /g, "_").replace(/"/g, '%22')}"`);
// Scores — each 0–10 ----------------------------------------------------
L.push("prose: ");
L.push("narrative: ");
L.push("characters: ");
L.push("themes: ");
L.push("pacing: ");
L.push("worldbuilding: ");
L.push("originality: ");
L.push("ending: ");
L.push("emotional_impact: ");
L.push("personal_score: ");
// Flags ------------------------------------------------------------------
L.push("favorite: false");
L.push("reread: false");
L.push("stuck_with_me: false");
L.push("status:");
L.push("---");
const body = [
"",
"> [!abstract] Score",
"> ```dataviewjs",
"> const p = dv.current();",
'> const n = v => (typeof v === "number" && !isNaN(v)) ? v : 0;',
'> const C = ["prose","narrative","characters","themes","pacing","worldbuilding","originality","ending","emotional_impact","personal_score"];',
"> const sum = C.reduce((a,k)=>a+n(p[k]),0);",
"> const count = C.filter(k=>n(p[k])).length;",
"> const bonus = (p.favorite?3:0) + (p.reread?2:0) + (p.stuck_with_me?2:0);",
"> const total = count >= 6 ? Math.min(100, Math.round(sum/count*9 + bonus/7*10)) : null;",
"> const B = [[100,'A masterpiece — flawless in every respect.'],[95,'An all-time great with only unoverlookable minor flaws.'],[90,'Genre-defining, held back only slightly. An all-timer.'],[85,'A standout of its year — memorable, with very few flaws.'],[80,'Excellent and distinctive. Well worth your time.'],[75,'Above average, with several elevating elements.'],[70,'Solid, lifted by some standout element.'],[65,'Above average, but with noticeable flaws.'],[60,'Decent — some genuinely enjoyable parts.'],[55,'Passable, but disappoints on most fronts.'],[50,'The midpoint — average, forgettable.'],[45,'Subpar; falls short on most fronts.'],[40,'Poor — significant flaws, little to enjoy.'],[35,'Below average; flawed and unoriginal.'],[30,'Flat and typical; forgettable.'],[25,'Disappointing; problems outweigh the positives.'],[20,'Largely unenjoyable; few redeeming qualities.'],[15,'Severely flawed; not worth experiencing.'],[10,'Among the worst of its kind.'],[5,'Abysmal — a complete failure.'],[0,'Not worth mentioning.']];",
"> const verdict = total === null ? '' : (B.find(b => total >= b[0]) || B[B.length-1])[1];",
"> dv.paragraph(total === null ? `_Not yet scored — ${count}/6 core fields._` : `**${total}** — ${verdict}`);",
"> ```",
"",
"## Premise",
"",
"## First Read",
"",
"## Standout Passages",
"",
"## Prose & Craft",
"",
"## Themes",
"",
"## Verdict",
""
];
tR += L.join("\n") + "\n" + body.join("\n");
-%>
Field Assembly
Building this jacket in your vault takes two plugins.
- Custom Views: the renderer. Make a view, build its rule in the menu to match
categoriescontainsBooks(the screenshot above), then paste the template, styling, and JS blocks into their fields. - Templater: the note-maker. Paste the Templater block into a template file and bind it to a hotkey or a folder template.
The template fills the note from a public database when you create it, no key needed. After that the card runs on what is in the note.

Statement Ends