Skip to content

Commit ea55c0c

Browse files
authored
Merge pull request #60 from jphastings/refined-by
Adding support for refinedBy and physical book page numbers
2 parents 65b9743 + cc7d6b1 commit ea55c0c

15 files changed

Lines changed: 370 additions & 293 deletions

File tree

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ WORKDIR /app/web
44
COPY web/package.json web/bun.lock ./
55
RUN bun install
66
COPY web/ ./
7+
# Lexicons are the source of truth for schema docs rendered by the web build.
8+
COPY lexicons/ /app/lexicons/
79
RUN bun run build
810

911
FROM golang:1.25-alpine AS backend-builder

backend/internal/api/hydration.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ type Author struct {
3636
}
3737

3838
type APISelector struct {
39-
Type string `json:"type"`
40-
Exact string `json:"exact,omitempty"`
41-
Prefix string `json:"prefix,omitempty"`
42-
Suffix string `json:"suffix,omitempty"`
43-
Start *int `json:"start,omitempty"`
44-
End *int `json:"end,omitempty"`
45-
Value string `json:"value,omitempty"`
46-
ConformsTo string `json:"conformsTo,omitempty"`
39+
Type string `json:"type"`
40+
Exact string `json:"exact,omitempty"`
41+
Prefix string `json:"prefix,omitempty"`
42+
Suffix string `json:"suffix,omitempty"`
43+
Start *int `json:"start,omitempty"`
44+
End *int `json:"end,omitempty"`
45+
Value string `json:"value,omitempty"`
46+
ConformsTo string `json:"conformsTo,omitempty"`
47+
RefinedBy *APISelector `json:"refinedBy,omitempty"`
4748
}
4849

4950
type APIBody struct {

backend/internal/service/hydration.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ import (
1111
)
1212

1313
type APISelector struct {
14-
Type string `json:"type"`
15-
Exact string `json:"exact,omitempty"`
16-
Prefix string `json:"prefix,omitempty"`
17-
Suffix string `json:"suffix,omitempty"`
18-
Start *int `json:"start,omitempty"`
19-
End *int `json:"end,omitempty"`
20-
Value string `json:"value,omitempty"`
21-
ConformsTo string `json:"conformsTo,omitempty"`
14+
Type string `json:"type"`
15+
Exact string `json:"exact,omitempty"`
16+
Prefix string `json:"prefix,omitempty"`
17+
Suffix string `json:"suffix,omitempty"`
18+
Start *int `json:"start,omitempty"`
19+
End *int `json:"end,omitempty"`
20+
Value string `json:"value,omitempty"`
21+
ConformsTo string `json:"conformsTo,omitempty"`
22+
RefinedBy *APISelector `json:"refinedBy,omitempty"`
2223
}
2324

2425
type APIBody struct {

lexicons/at/margin/note.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"lexicon": 1,
33
"id": "at.margin.note",
4-
"revision": 3,
4+
"revision": 4,
55
"description": "W3C Web Annotation Data Model compliant unified note record for ATProto",
66
"defs": {
77
"main": {
@@ -171,8 +171,7 @@
171171
"TextPositionSelector",
172172
"CssSelector",
173173
"XPathSelector",
174-
"FragmentSelector",
175-
"RangeSelector"
174+
"FragmentSelector"
176175
]
177176
},
178177
"exact": {
@@ -212,6 +211,11 @@
212211
"type": "string",
213212
"format": "uri",
214213
"description": "FragmentSelector: URI of the specification the fragment conforms to"
214+
},
215+
"refinedBy": {
216+
"type": "ref",
217+
"ref": "#selector",
218+
"description": "W3C refinement: a further selector applied within the region this selector identifies (e.g. a FragmentSelector locating a page or CFI range inside a TextQuoteSelector match)."
215219
}
216220
}
217221
},

web/astro.config.mjs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,42 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
1111

1212
const API_PORT = process.env.API_PORT || 8081;
1313

14+
function lexiconsPlugin() {
15+
const virtualId = "virtual:lexicons";
16+
const resolvedId = "\0" + virtualId;
17+
return {
18+
name: "lexicons",
19+
/** @param {string} id */
20+
resolveId(id) {
21+
if (id === virtualId) return resolvedId;
22+
},
23+
/** @param {string} id */
24+
load(id) {
25+
if (id !== resolvedId) return;
26+
// Single source of truth: every lexicon in ../lexicons, keyed by its NSID
27+
// id, read raw at build time so docs never drift from the schema.
28+
const lexiconsDir = join(__dirname, "../lexicons");
29+
/** @type {Record<string, string>} */
30+
const lexicons = {};
31+
/** @param {string} dir */
32+
const walk = (dir) => {
33+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
34+
const full = join(dir, entry.name);
35+
if (entry.isDirectory()) {
36+
walk(full);
37+
} else if (entry.name.endsWith(".json")) {
38+
const text = readFileSync(full, "utf-8").trimEnd();
39+
const id = JSON.parse(text).id;
40+
if (id) lexicons[id] = text;
41+
}
42+
}
43+
};
44+
walk(lexiconsDir);
45+
return `export const lexicons = ${JSON.stringify(lexicons)};`;
46+
},
47+
};
48+
}
49+
1450
function i18nResourcesPlugin() {
1551
const virtualId = "virtual:i18n-resources";
1652
const resolvedId = "\0" + virtualId;
@@ -89,7 +125,7 @@ export default defineConfig({
89125
defaultStrategy: "viewport",
90126
},
91127
vite: {
92-
plugins: [i18nResourcesPlugin(), i18nLanguagesPlugin()],
128+
plugins: [lexiconsPlugin(), i18nResourcesPlugin(), i18nLanguagesPlugin()],
93129
resolve: {
94130
alias: {
95131
"@": resolve(__dirname, "src"),

web/src/api/client.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
NotificationItem,
88
Selector,
99
Target,
10+
TextQuoteSelector,
1011
UserProfile,
1112
} from "../types";
1213

@@ -341,7 +342,7 @@ interface CreateAnnotationParams {
341342
url: string;
342343
text?: string;
343344
title?: string;
344-
selector?: { exact: string; prefix?: string; suffix?: string };
345+
selector?: TextQuoteSelector;
345346
tags?: string[];
346347
labels?: string[];
347348
}
@@ -370,7 +371,7 @@ export async function createAnnotation({
370371

371372
interface CreateHighlightParams {
372373
url: string;
373-
selector: { exact: string; prefix?: string; suffix?: string };
374+
selector: TextQuoteSelector;
374375
color?: string;
375376
tags?: string[];
376377
title?: string;
@@ -517,7 +518,7 @@ export async function convertHighlightToAnnotation(
517518
highlightUri: string,
518519
url: string,
519520
text: string,
520-
selector?: { exact: string; prefix?: string; suffix?: string },
521+
selector?: TextQuoteSelector,
521522
title?: string,
522523
): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> {
523524
try {

web/src/components/common/Card.tsx

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ import type {
4646
AnnotationItem,
4747
ContentLabel,
4848
LabelVisibility,
49+
Selector,
4950
} from "../../types";
51+
import { asTextQuote } from "../../types";
5052

5153
import { Avatar } from "../ui";
5254
import CollectionIcon from "./CollectionIcon";
@@ -105,6 +107,45 @@ function getContentWarning(
105107
return null;
106108
}
107109

110+
const PHYSICAL_BOOKS_SPEC = "https://margin.at/docs/specs/physical-books";
111+
112+
// Decode one application/x-www-form-urlencoded component. "+" maps to a space;
113+
// the replace runs before decodeURIComponent so an escaped plus (%2B) survives.
114+
const formDecode = (component: string): string =>
115+
decodeURIComponent(component.replace(/\+/g, " "));
116+
117+
// Printed page label from a selector's physical-books FragmentSelector, e.g.
118+
// "255-256", or "A-1 - A-2" when a label itself contains an (encoded) hyphen.
119+
function bookPageLabel(selector?: Selector | null): string | null {
120+
for (
121+
let sel: Selector | null | undefined = selector;
122+
sel;
123+
sel = sel.refinedBy
124+
) {
125+
if (
126+
sel.type !== "FragmentSelector" ||
127+
sel.conformsTo !== PHYSICAL_BOOKS_SPEC ||
128+
!sel.value
129+
) {
130+
continue;
131+
}
132+
// Keep the value raw (%2D still encoded) so the range hyphen stays unambiguous.
133+
const raw = sel.value
134+
.split("&")
135+
.map((pair) => pair.split("="))
136+
.find(([key]) => key === "page")?.[1];
137+
if (!raw) return null;
138+
try {
139+
const pages = raw.split("-").map(formDecode);
140+
const separator = /%2d/i.test(raw) ? " - " : "-";
141+
return pages.join(separator);
142+
} catch {
143+
return null;
144+
}
145+
}
146+
return null;
147+
}
148+
108149
interface CardProps {
109150
item: AnnotationItem;
110151
onDelete?: (uri: string) => void;
@@ -252,7 +293,7 @@ export default function Card({
252293
item.uri,
253294
pageUrl,
254295
convertText.trim(),
255-
item.target?.selector,
296+
asTextQuote(item.target?.selector),
256297
item.target?.title,
257298
);
258299
setConverting(false);
@@ -344,9 +385,10 @@ export default function Card({
344385
const bookUrl = isbn ? `https://openlibrary.org/isbn/${isbn}` : null;
345386
const bookTitle =
346387
item.target?.title || item.title || (isbn ? `ISBN ${isbn}` : null);
388+
const bookPage = bookPageLabel(item.target?.selector);
347389

348390
const quoteLinkUrl = (() => {
349-
const sel = item.target?.selector;
391+
const sel = asTextQuote(item.target?.selector);
350392
if (!sel?.exact) return null;
351393
if (bookUrl) return bookUrl;
352394
if (!pageUrl) return null;
@@ -565,20 +607,29 @@ export default function Card({
565607
</div>
566608

567609
{pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && (
568-
<a
569-
href={bookUrl || pageUrl}
570-
target="_blank"
571-
rel="noopener noreferrer"
572-
onClick={(e) => handleExternalClick(e, bookUrl || pageUrl)}
573-
className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5 max-w-full"
574-
>
575-
{isbn ? (
576-
<BookOpen size={10} className="flex-shrink-0" />
577-
) : (
578-
<ExternalLink size={10} className="flex-shrink-0" />
610+
<div className="flex items-center gap-1.5 mt-0.5 max-w-full">
611+
<a
612+
href={bookUrl || pageUrl}
613+
target="_blank"
614+
rel="noopener noreferrer"
615+
onClick={(e) => handleExternalClick(e, bookUrl || pageUrl)}
616+
className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline min-w-0"
617+
>
618+
{isbn ? (
619+
<BookOpen size={10} className="flex-shrink-0" />
620+
) : (
621+
<ExternalLink size={10} className="flex-shrink-0" />
622+
)}
623+
<span className="truncate">
624+
{isbn ? bookTitle : displayUrl}
625+
</span>
626+
</a>
627+
{isbn && bookPage && (
628+
<span className="flex-shrink-0 text-xs text-surface-500 dark:text-surface-400">
629+
(p.{bookPage})
630+
</span>
579631
)}
580-
<span className="truncate">{isbn ? bookTitle : displayUrl}</span>
581-
</a>
632+
</div>
582633
)}
583634
</div>
584635
</div>
@@ -689,7 +740,7 @@ export default function Card({
689740
)}
690741

691742
{!(contentWarning && !contentRevealed) &&
692-
item.target?.selector?.exact && (
743+
asTextQuote(item.target?.selector)?.exact && (
693744
<blockquote
694745
className={clsx(
695746
"pl-4 py-2 border-l-[3px] mb-3 text-[15px] italic text-surface-600 dark:text-surface-300 rounded-r-lg hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors",
@@ -726,7 +777,7 @@ export default function Card({
726777
}}
727778
className="block break-words"
728779
>
729-
"{item.target?.selector?.exact}"
780+
"{asTextQuote(item.target?.selector)?.exact}"
730781
</a>
731782
</blockquote>
732783
)}

web/src/components/feed/Composer.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getTrendingTags,
99
} from "../../api/client";
1010
import type { Selector, ContentLabelValue } from "../../types";
11+
import { asTextQuote } from "../../types";
1112
import { X, ShieldAlert, Highlighter, PenLine } from "lucide-react";
1213
import TagInput from "../ui/TagInput";
1314
import { analytics } from "../../lib/analytics";
@@ -114,17 +115,14 @@ export default function Composer({
114115
};
115116
}
116117

118+
const quoteSelector = asTextQuote(finalSelector);
117119
const tagList = tags.filter(Boolean);
118120

119121
if (!text.trim()) {
120-
if (!finalSelector) throw new Error("No text selected");
122+
if (!quoteSelector) throw new Error("No text selected");
121123
await createHighlight({
122124
url,
123-
selector: finalSelector as {
124-
exact: string;
125-
prefix?: string;
126-
suffix?: string;
127-
},
125+
selector: quoteSelector,
128126
color: "yellow",
129127
tags: tagList,
130128
labels: selfLabels.length > 0 ? selfLabels : undefined,
@@ -139,13 +137,13 @@ export default function Composer({
139137
await createAnnotation({
140138
url,
141139
text: text.trim(),
142-
selector: finalSelector || undefined,
140+
selector: quoteSelector,
143141
tags: tagList,
144142
labels: selfLabels.length > 0 ? selfLabels : undefined,
145143
});
146144
analytics.capture("annotation_created", {
147145
url,
148-
has_quote: !!finalSelector,
146+
has_quote: !!quoteSelector,
149147
tag_count: tagList.length,
150148
has_labels: selfLabels.length > 0,
151149
});

web/src/components/modals/EditItemModal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getTrendingTags,
1111
} from "../../api/client";
1212
import type { AnnotationItem, ContentLabelValue } from "../../types";
13+
import { asTextQuote } from "../../types";
1314
import TagInput from "../ui/TagInput";
1415

1516
const SELF_LABEL_VALUES: ContentLabelValue[] = [
@@ -236,9 +237,9 @@ function EditItemModalContent({
236237
/>
237238
))}
238239
</div>
239-
{item.target?.selector?.exact && (
240+
{asTextQuote(item.target?.selector)?.exact && (
240241
<blockquote className="mt-3 pl-3 py-2 border-l-2 border-surface-300 dark:border-surface-600 text-sm italic text-surface-500 dark:text-surface-400">
241-
{item.target.selector.exact}
242+
{asTextQuote(item.target?.selector)?.exact}
242243
</blockquote>
243244
)}
244245
</div>

web/src/env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
/// <reference types="astro/client" />
22

3+
declare module "virtual:lexicons" {
4+
/** Raw lexicon JSON text, keyed by NSID id (e.g. "at.margin.note"). */
5+
export const lexicons: Record<string, string>;
6+
}
7+
38
declare module "virtual:i18n-resources" {
49
export const resources: Record<
510
string,

0 commit comments

Comments
 (0)