diff --git a/mobile/App.js b/mobile/App.js
index cf70493..8202d2c 100644
--- a/mobile/App.js
+++ b/mobile/App.js
@@ -2,7 +2,7 @@
// browser behind it keeps its tabs of WebViews, address bar, and the shared
// suggestion engine from core/.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { ActivityIndicator, BackHandler, Keyboard, PanResponder, Platform, Pressable, Text, TextInput, View, useWindowDimensions } from "react-native";
+import { AccessibilityInfo, ActivityIndicator, BackHandler, Keyboard, PanResponder, Platform, Pressable, Text, TextInput, View, useWindowDimensions } from "react-native";
import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
@@ -17,6 +17,7 @@ import TabsSheet from "./src/components/TabsSheet";
import AddressSuggestions from "./src/components/AddressSuggestions";
import AskSheet from "./src/components/AskSheet";
import TrailHome from "./src/components/TrailHome";
+import FullTextSheet from "./src/components/FullTextSheet";
import { captureRef } from "react-native-view-shot";
import { Directory, File, Paths } from "expo-file-system";
import SettingsSheet from "./src/components/SettingsSheet";
@@ -155,6 +156,12 @@ function Browser({ services }) {
} catch {
return;
}
+ if (msg.type === "capinfo" && String(msg.id).startsWith("ft:")) {
+ // M19: the page's DOI or PMID, found on the phone; nothing is sent.
+ const ids = Capture.findIds(msg);
+ updateTab(tabId, { doi: ids.doi || "", pmid: ids.pmid || "" });
+ return;
+ }
if (msg.type === "capinfo") {
const p = pending.current.get(msg.id);
if (!p) return;
@@ -206,6 +213,7 @@ function Browser({ services }) {
// new load, so onLoadEnd does not run: start the new page's media here.
if (before && !nav.loading && before.url && nav.url !== before.url) {
if (lastSelection.current.tabId === tabId) lastSelection.current = { tabId: null, url: "", text: "" };
+ updateTab(tabId, { doi: "", pmid: "" });
services.history.record(nav.url, nav.title);
const tab = withRule({ ...before, url: nav.url });
refs.current.get(tabId)?.injectJavaScript(autoplayCall(tab.rule.autoplay));
@@ -217,6 +225,7 @@ function Browser({ services }) {
// recorded as a capture card. No screen change; a note with Undo shows.
const showToast = (t) => {
clearTimeout(toastTimer.current);
+ AccessibilityInfo.announceForAccessibility?.(t.text); // VoiceOver reads the note; TalkBack uses the live region
setToast(t);
toastTimer.current = setTimeout(() => setToast(null), 6000);
};
@@ -278,6 +287,7 @@ function Browser({ services }) {
playback.navigated(tabId); // media of the previous page will not resume
view.injectJavaScript(autoplayCall(tab.rule.autoplay));
if (tab.rule.center !== false) view.injectJavaScript(CENTER_ON_TAP);
+ view.injectJavaScript(Capture.captureInfoCall(`ft:${tabId}`)); // DOI for the Full text button
if (tabId !== activeRef.current) {
if (!soundAllRef.current) view.injectJavaScript(MUTE_ALL); // only the active tab plays sound
return;
@@ -397,8 +407,8 @@ function Browser({ services }) {
{/* Address bar, with suggestions from history and Wikipedia while typing */}
-
{editing ? (
{showMosaic && !isActive ? (
// Tap an inactive tile to make it the active tab; then it takes touches.
- switchTo(t.id)} />
+ switchTo(t.id)}
+ accessibilityRole="button"
+ accessibilityLabel={`Mosaic tile ${tile + 1}: ${t.title || t.url || "new tab"}`}
+ accessibilityHint="Makes this the active tab"
+ />
) : null}
);
@@ -494,10 +510,10 @@ function Browser({ services }) {
{/* Bottom bar */}
setSheet("suggest")} />
-
+
setMosaic(!mosaic)} />
setHome(true)} />
- setSheet("settings")} />
+ setSheet("settings")} />
{state.busy ? "Searching…" : ""}
@@ -538,8 +554,23 @@ function Browser({ services }) {
/>
setSheet(null)} services={services} />
+ {(active?.doi || active?.pmid) && !home && !sheet ? (
+
+ setSheet("fulltext")} />
+
+ ) : null}
+ setSheet(null)}
+ doi={active?.doi}
+ pmid={active?.pmid}
+ services={services}
+ onOpenUrl={(url) => addTab(url)}
+ onOpenSettings={() => setSheet("settings")}
+ />
+
{toast && !home ? (
-
+
{toast.text}
diff --git a/mobile/README.md b/mobile/README.md
index 1826ccd..de7b6dc 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -9,6 +9,8 @@ Phase 5 groundwork. A React Native (Expo SDK 58, React Native 0.88) app that reu
- **Open a `.trail.md`** (CM3): Open file → the file opens read only, on Replay: the sender's note, Start here, the steps with captions (Back, Next, tap a step); the Canvas tab dims cards not reached yet and follows the step. Save as new trail makes an editable copy, " · ", and goes to its timeline. Open on desktop hands the file to the share sheet (AirDrop it to your Mac)
- **Share** (CM4, CM5): a note, Start here, the timeline on or off, your name; file name and size; "Share file…" opens the phone's share sheet with the `.trail.md`. Sign-in and payment pages are never put in the file
- **Capture** (M18, bottom bar): one tap saves the page into the trail being recorded: address, title, DOI or PMID if the page has one, the time with the time zone, the selected text (or the last text you selected on the page) and a thumbnail. It becomes a capture card linked "captured from" the page's card, as on desktop. A note says where it went, with Undo. Sign-in and payment pages are never captured. The card's preview shows the thumbnail and Open DOI / PubMed. Shared files carry the quote and the "Captured from … · DOI …" line, not the thumbnail
+- **Full text for papers** (M19): when a page has a DOI or PMID (found on the phone, nothing sent), a Full text button shows above the bottom bar. It lists free, legal copies from Unpaywall and OpenAlex (PDFs and published versions first, with version, licence and where), plus "Through your library" (LibKey) and doi.org. Off until set up: Settings → Full text for papers takes your own email (Unpaywall) and optional OpenAlex key, both kept in the keychain, with a set-up tutorial and a "What NODE sends" disclaimer. Only the DOI with your email or key is sent, and only when you tap Full text
+- **Screen readers** (M25): every button, tab, card, Mosaic tile, keyword and result has a spoken label and role; sheet titles and timeline sessions are headings; Timeline / Canvas / Replay are tabs. Gestures have screen-reader actions: next and previous trail on the switcher, hide on a keyword, close on a tab row. The capture note is announced. Every grid has a list: the Timeline for the canvas, the tab list for Mosaic
- Pages you read on the phone go into the current trail's timeline as phone visits (timeline only, as decided in doc 25); cards are placed on desktop. Old research sessions are copied into trails on the first start
- Tabs, each a WebView kept loaded; address bar with back and forward; tab switcher
- Mosaic on every screen (Mosaic button in the bottom bar): all tabs in a grid on tablets and phones in landscape; up to 4 tiles on a phone in portrait (the active tab plus the 3 most recent others)
@@ -62,6 +64,7 @@ npm run bundle:check # bundles for Android and iOS and compiles to Hermes byte
| `metro.config.js` | Lets the bundler include `../core` |
| `App.js` | Tabs, address bar, bottom bar, wiring to the controller |
| `src/services.js` | Storage (AsyncStorage), keys (SecureStore), search registry, search flow, trails (`core/trail.js`, device "phone") with the research store on top, history, site rules |
+| `src/fulltext.js` | Full-text lookup (Unpaywall, OpenAlex, LibKey and doi.org links); tested with `npm test` |
| `src/capture.js` | Smart capture logic (DOI and PMID finding, the capture card, saving and Undo); tested with `npm test` |
| `src/trailModel.js` | The trail screens' logic with no React Native imports: timeline rows, card faces, canvas view maths, opening files, Replay, Save as new, the share file. Tested with `npm test` (in `mobile/`) |
| `src/bridge.js` | Scripts injected into pages: selection and scroll reports, page extraction, autoplay, mute |
diff --git a/mobile/package.json b/mobile/package.json
index 1e7f068..b0ff24b 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -23,7 +23,7 @@
"ios": "expo start --ios",
"web": "expo start --web",
"bundle:check": "expo export --platform android --platform ios --output-dir dist-check",
- "test": "node test/trail-flow.test.js && node test/capture.test.js"
+ "test": "node test/trail-flow.test.js && node test/capture.test.js && node test/fulltext.test.js"
},
"private": true
}
diff --git a/mobile/src/components/AddressSuggestions.js b/mobile/src/components/AddressSuggestions.js
index 25f8808..5547fee 100644
--- a/mobile/src/components/AddressSuggestions.js
+++ b/mobile/src/components/AddressSuggestions.js
@@ -64,7 +64,7 @@ export default function AddressSuggestions({ query, history, bookmarks, getText,
{/* "handled": tapping a row does not first close the keyboard and the list. */}
{rows.map((r) => (
- onPick(r.url)} style={{ paddingHorizontal: 12, paddingVertical: 9, borderTopWidth: 1, borderColor: colors.line }}>
+ onPick(r.url)} accessibilityRole="button" accessibilityLabel={`${r.history ? "From history: " : ""}${r.label}`} style={{ paddingHorizontal: 12, paddingVertical: 9, borderTopWidth: 1, borderColor: colors.line }}>
{r.history ? "🕘 " : ""}
{r.label}
diff --git a/mobile/src/components/Button.js b/mobile/src/components/Button.js
index 7703af0..e5e7ae7 100644
--- a/mobile/src/components/Button.js
+++ b/mobile/src/components/Button.js
@@ -1,11 +1,16 @@
import { Pressable, Text } from "react-native";
import { colors, ui } from "../theme";
-export default function Button({ label, onPress, onLongPress, active, style, small }) {
+// a11yLabel: what a screen reader says when the label is a symbol (‹, ⚙︎, +).
+export default function Button({ label, onPress, onLongPress, active, style, small, a11yLabel, a11yHint }) {
return (
[ui.button, small && { paddingVertical: 4, paddingHorizontal: 8 }, active && { borderColor: colors.accent }, pressed && { opacity: 0.6 }, style]}
>
{label}
diff --git a/mobile/src/components/FullTextSheet.js b/mobile/src/components/FullTextSheet.js
new file mode 100644
index 0000000..885a6ba
--- /dev/null
+++ b/mobile/src/components/FullTextSheet.js
@@ -0,0 +1,139 @@
+// M19: the Full text sheet, the set-up tutorial and the disclaimer.
+// The tutorial and disclaimer are shown in this sheet until full text is set
+// up, and always in Settings → Full text for papers.
+import { useEffect, useState } from "react";
+import { ActivityIndicator, Pressable, Text, View } from "react-native";
+import { colors, ui } from "../theme";
+import Button from "./Button";
+import Sheet from "./Sheet";
+
+const F = require("../fulltext");
+
+// Set-up steps, in order.
+export function FullTextTutorial() {
+ const steps = [
+ ["Turn on Find free copies", "In Settings → Full text for papers."],
+ ["Add your email", "Unpaywall asks every app to send a contact email with each lookup. Nothing else is needed; there is no account."],
+ ["Optional: add an OpenAlex key", "OpenAlex finds copies Unpaywall misses. Make a free account at openalex.org, then copy your key from openalex.org/settings/api and paste it here. Lookups by DOI are free."],
+ ["Tap Full text on a paper", "When a page has a DOI, a Full text button shows above the bottom bar. NODE looks the DOI up only when you tap it."],
+ ];
+ return (
+
+
+ How to set it up
+
+ {steps.map(([title, body], i) => (
+
+ {i + 1}
+
+ {title}
+ {body}
+
+
+ ))}
+
+ );
+}
+
+// What is sent, to whom, and what NODE does not promise.
+export function FullTextDisclaimer() {
+ return (
+
+
+ What NODE sends
+
+
+ Only when you tap Full text: the paper's DOI and your email go to Unpaywall (api.unpaywall.org), and the DOI and your API key go to OpenAlex (api.openalex.org). NODE sends no page address, page text or trail. Your email and key stay in this phone's keychain.
+
+
+ NODE lists copies these services report as free to read. It does not check them itself. Check the licence before you reuse or share a paper. The library link goes to LibKey, which uses your library's sign-in if your library supports it.
+
+ Unpaywall and OpenAlex are run by OurResearch, a nonprofit, under their own terms. You can turn this off in Settings at any time.
+
+ );
+}
+
+export default function FullTextSheet({ visible, onClose, doi, pmid, services, onOpenUrl, onOpenSettings }) {
+ const ft = services.settings.fulltext || {};
+ const can = F.sources(ft);
+ const ready = can.unpaywall || can.openalex;
+ const [state, setState] = useState({ loading: false, result: null });
+
+ useEffect(() => {
+ if (!visible || !doi || !ready) return;
+ let live = true;
+ setState({ loading: true, result: null });
+ F.lookUp(doi, ft, services.net.getText).then((result) => live && setState({ loading: false, result }));
+ return () => {
+ live = false;
+ };
+ }, [visible, doi, ready]);
+
+ if (!visible) return null;
+ const links = doi ? F.links(doi) : null;
+ const r = state.result;
+ const open = (url) => {
+ onClose();
+ onOpenUrl(url);
+ };
+
+ return (
+
+ {ready && doi ? (
+ state.loading ? (
+
+
+ Looking for a free copy…
+
+ ) : r ? (
+ <>
+
+ {r.copies.length ? `${r.copies.length} free ${r.copies.length === 1 ? "copy" : "copies"} found` : F.STATUS_TEXT.closed}
+ {r.status && r.copies.length ? ` · ${F.STATUS_TEXT[r.status] || r.status}` : ""}
+
+ {r.copies.map((c) => (
+ open(c.url)} accessibilityRole="link" accessibilityLabel={`Open free copy: ${F.copyLine(c)}`} style={{ borderTopWidth: 1, borderColor: colors.line, paddingVertical: 9 }}>
+
+ {c.where || c.url}
+
+ {F.copyLine(c)}
+
+ ))}
+ {r.notes.map((n) => (
+
+ {n}
+
+ ))}
+ >
+ ) : null
+ ) : null}
+
+ {links ? (
+
+ open(links.library)} />
+ open(links.doi)} />
+
+ ) : null}
+ {pmid ? (
+
+ open(`https://pubmed.ncbi.nlm.nih.gov/${pmid}/`)} />
+
+ ) : null}
+
+ {!ready ? (
+ <>
+
+ {can.reason === "bad-email" ? "The email in Settings doesn't look right." : "To find free copies of papers, set up full text once."}
+
+
+
+
+
+
+ >
+ ) : (
+ Copies come from Unpaywall and OpenAlex, which report them as free to read. Check the licence before reusing. Settings → Full text for papers says what is sent.
+ )}
+
+ );
+}
diff --git a/mobile/src/components/SettingsSheet.js b/mobile/src/components/SettingsSheet.js
index 9079bac..930a184 100644
--- a/mobile/src/components/SettingsSheet.js
+++ b/mobile/src/components/SettingsSheet.js
@@ -4,6 +4,9 @@ import { Switch, Text, TextInput, View } from "react-native";
import { colors, ui } from "../theme";
import Button from "./Button";
import Sheet from "./Sheet";
+import { FullTextDisclaimer, FullTextTutorial } from "./FullTextSheet";
+
+const F = require("../fulltext");
function Field({ label, value, onChange, secret, placeholder }) {
return (
@@ -36,11 +39,12 @@ export default function SettingsSheet({ visible, onClose, services }) {
const s = services.settings;
const [p, setP] = useState(() => JSON.parse(JSON.stringify(s.providers)));
const [ai, setAi] = useState(() => JSON.parse(JSON.stringify(s.ai)));
+ const [ft, setFt] = useState(() => ({ enabled: false, email: "", openalexKey: "", ...(s.fulltext || {}) }));
const [msg, setMsg] = useState("");
const setProv = (id, patch) => setP({ ...p, [id]: { ...p[id], ...patch } });
const save = async () => {
- await services.saveSettings({ providers: p, ai });
+ await services.saveSettings({ providers: p, ai, fulltext: ft });
setMsg("Saved.");
};
const testAI = async () => {
@@ -81,6 +85,22 @@ export default function SettingsSheet({ visible, onClose, services }) {
setAi({ ...ai, grok: { ...ai.grok, model: v } })} />
>
)}
+
+
+ Full text for papers
+
+ On a page with a DOI, Full text finds a free, legal copy of the paper and links it through your library.
+ setFt({ ...ft, enabled: v })} />
+ {ft.enabled ? (
+ <>
+ setFt({ ...ft, email: v })} placeholder="you@example.org" />
+ {ft.email && !F.EMAIL_RE.test(ft.email.trim()) ? This doesn't look like an email address. : null}
+ setFt({ ...ft, openalexKey: v })} secret placeholder="From openalex.org/settings/api" />
+ >
+ ) : null}
+
+
+
diff --git a/mobile/src/components/Sheet.js b/mobile/src/components/Sheet.js
index a6d76cf..39d5c06 100644
--- a/mobile/src/components/Sheet.js
+++ b/mobile/src/components/Sheet.js
@@ -12,7 +12,7 @@ export default function Sheet({ visible, title, kicker, onClose, children, scrol
{kicker ? {kicker} : null}
- {title}
+ {title}
diff --git a/mobile/src/components/SuggestionsSheet.js b/mobile/src/components/SuggestionsSheet.js
index 22208e0..2d62301 100644
--- a/mobile/src/components/SuggestionsSheet.js
+++ b/mobile/src/components/SuggestionsSheet.js
@@ -21,12 +21,22 @@ export default function SuggestionsSheet({ visible, onClose, state, controller,
Tap a keyword to pin it. Press and hold to hide it.
{pins.map((k) => (
- controller.togglePin(k)}>
+ controller.togglePin(k)} accessibilityRole="button" accessibilityLabel={`${k}, pinned`} accessibilityHint="Unpins this keyword">
📌 {k}
))}
{auto.map((k) => (
- controller.togglePin(k)} onLongPress={() => controller.hide(k)}>
+ controller.togglePin(k)}
+ onLongPress={() => controller.hide(k)}
+ accessibilityRole="button"
+ accessibilityLabel={k}
+ accessibilityHint="Pins this keyword. Actions: hide it"
+ accessibilityActions={[{ name: "hide", label: "Hide keyword" }]}
+ onAccessibilityAction={(e) => e.nativeEvent.actionName === "hide" && controller.hide(k)}
+ >
{k}
))}
@@ -52,7 +62,7 @@ export default function SuggestionsSheet({ visible, onClose, state, controller,
{r.source}
{r.query ? · {r.query} : null}
- controller.dismiss(r.url)} hitSlop={10}>
+ controller.dismiss(r.url)} hitSlop={10} accessibilityRole="button" accessibilityLabel={`Dismiss ${r.title}`}>
×
diff --git a/mobile/src/components/TabsSheet.js b/mobile/src/components/TabsSheet.js
index bb8688e..1c88338 100644
--- a/mobile/src/components/TabsSheet.js
+++ b/mobile/src/components/TabsSheet.js
@@ -18,6 +18,10 @@ export default function TabsSheet({ visible, onClose, tabs, activeId, onActivate
onCloseTab(t.id)}>
onActivate(t.id)}
+ accessibilityRole="button"
+ accessibilityLabel={`${t.title || "New tab"}${t.id === activeId ? ", current tab" : ""}`}
+ accessibilityActions={[{ name: "close", label: "Close tab" }]}
+ onAccessibilityAction={(e) => e.nativeEvent.actionName === "close" && onCloseTab(t.id)}
style={{ flexDirection: "row", alignItems: "center", borderTopWidth: 1, borderColor: colors.line, paddingVertical: 10 }}
>
@@ -26,7 +30,7 @@ export default function TabsSheet({ visible, onClose, tabs, activeId, onActivate
{t.pausedByLimit ? "⏸ Paused (4 tabs already playing) · " : ""}{t.sysPauses ? `Paused by the system ${t.sysPauses}× and restarted · ` : ""}{t.url}
- onCloseTab(t.id)} hitSlop={10} style={{ paddingHorizontal: 8 }}>
+ onCloseTab(t.id)} hitSlop={10} style={{ paddingHorizontal: 8 }} accessibilityRole="button" accessibilityLabel={`Close ${t.title || "tab"}`}>
×
@@ -38,7 +42,7 @@ export default function TabsSheet({ visible, onClose, tabs, activeId, onActivate
{bookmarks.search("", 100).length === 0 ? No bookmarks yet. Tap ☆ next to the address bar to add the page you are on. : null}
{bookmarks.search("", 100).map((b) => (
onRemoveBookmark(b.url)}>
- onOpenBookmark(b.url)} style={{ borderTopWidth: 1, borderColor: colors.line, paddingVertical: 10 }}>
+ onOpenBookmark(b.url)} accessibilityRole="link" style={{ borderTopWidth: 1, borderColor: colors.line, paddingVertical: 10 }}>
★ {b.title || b.url}
{b.url}
diff --git a/mobile/src/components/TrailCanvas.js b/mobile/src/components/TrailCanvas.js
index 1602abe..8a9b363 100644
--- a/mobile/src/components/TrailCanvas.js
+++ b/mobile/src/components/TrailCanvas.js
@@ -124,6 +124,8 @@ export default function TrailCanvas({ snap, onCard, shown = null, focus = null }
onCard?.(c)}
+ accessibilityRole="button"
+ accessibilityLabel={`${start ? "Start here. " : ""}${f.kicker}: ${f.title}${f.text ? `. ${f.text}` : ""}`}
style={{ position: "absolute", left: c.x, top: c.y, width: c.w, height: c.h, borderRadius: 10, borderWidth: focus === c.id || start ? 3 : 1, borderColor: focus === c.id ? colors.accent : start ? colors.ok : colors.line, backgroundColor: colors.bg3, padding: 14, opacity: dim(c.id) ? 0.2 : 1, overflow: "hidden" }}
>
@@ -152,9 +154,9 @@ export default function TrailCanvas({ snap, onCard, shown = null, focus = null }
{view && size && !empty ? setView(M.centreOn(view, wx, wy, size.w, size.h))} /> : null}
{view && size && !empty ? (
-
- setView(M.zoomAt(view, 1 / 1.4, size.w / 2, size.h / 2))} />
- setView(M.zoomAt(view, 1.4, size.w / 2, size.h / 2))} />
+
+ setView(M.zoomAt(view, 1 / 1.4, size.w / 2, size.h / 2))} />
+ setView(M.zoomAt(view, 1.4, size.w / 2, size.h / 2))} />
) : null}
@@ -172,6 +174,9 @@ function Minimap({ snap, bounds, view, size, onJump }) {
const vp = at(vx, vy);
return (
onJump(bounds.x + (e.nativeEvent.locationX - ox) / k, bounds.y + (e.nativeEvent.locationY - oy) / k)}
style={{ position: "absolute", right: 10, bottom: 10, width: MINI_W, height: MINI_H, backgroundColor: "rgba(26,26,26,0.92)", borderRadius: 8, borderWidth: 1, borderColor: colors.line, overflow: "hidden" }}
>
diff --git a/mobile/src/components/TrailHome.js b/mobile/src/components/TrailHome.js
index 48b029c..a6b541a 100644
--- a/mobile/src/components/TrailHome.js
+++ b/mobile/src/components/TrailHome.js
@@ -121,7 +121,21 @@ export default function TrailHome({ services, controller, onBrowse, onOpenUrl, o
{/* Trail switcher: initials, name, last visit, dots; Share next to it. */}
- setMenu(true)} style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }} accessibilityRole="button" accessibilityLabel="Trails">
+ setMenu(true)}
+ style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}
+ accessibilityRole="button"
+ accessibilityLabel={`Trail: ${snap?.trail.name || "Trail"}${readonly ? `, from ${snap.trail.from || "someone"}, read only` : ""}`}
+ accessibilityHint="Opens the list of trails"
+ accessibilityActions={[
+ { name: "next", label: "Next trail" },
+ { name: "previous", label: "Previous trail" },
+ ]}
+ onAccessibilityAction={(e) => {
+ const next = list[index + (e.nativeEvent.actionName === "next" ? 1 : -1)];
+ if (next) show(next.id);
+ }}
+ >
{snap?.trail.mono || "?"}
@@ -135,12 +149,12 @@ export default function TrailHome({ services, controller, onBrowse, onOpenUrl, o
- (readonly ? openOnDesktop() : setSharing(true))} />
+ (readonly ? openOnDesktop() : setSharing(true))} />
{list.length > 1 ? (
{list.slice(0, MAX_DOTS).map((t) => (
- show(t.id)} hitSlop={6}>
+ show(t.id)} hitSlop={6} accessibilityRole="button" accessibilityLabel={`Trail: ${t.name}`} accessibilityState={{ selected: t.id === id }}>
))}
@@ -170,7 +184,7 @@ export default function TrailHome({ services, controller, onBrowse, onOpenUrl, o
{tabs.map(([k, label]) => (
- setTab(k)} style={{ flex: 1, paddingVertical: 7, alignItems: "center", backgroundColor: tab === k ? colors.bg3 : "transparent" }}>
+ setTab(k)} accessibilityRole="tab" accessibilityState={{ selected: tab === k }} accessibilityLabel={label} style={{ flex: 1, paddingVertical: 7, alignItems: "center", backgroundColor: tab === k ? colors.bg3 : "transparent" }}>
{label}
))}
diff --git a/mobile/src/components/TrailTimeline.js b/mobile/src/components/TrailTimeline.js
index 8aa7231..440dace 100644
--- a/mobile/src/components/TrailTimeline.js
+++ b/mobile/src/components/TrailTimeline.js
@@ -21,11 +21,14 @@ export default function TrailTimeline({ snap, header, onItem }) {
}
renderItem={({ item }) => {
- if (item.head) return {item.head};
+ if (item.head) return {item.head};
const face = item.card ? M.cardFace(item.card) : null;
return (
onItem(item)}
+ accessibilityRole="button"
+ accessibilityLabel={[item.time, face ? `${face.kicker}: ${face.title}` : item.title, face?.text || item.sub].filter(Boolean).join(". ")}
+ accessibilityHint={item.card ? "Opens the card's preview" : "Opens the page"}
style={({ pressed }) => ({ flexDirection: "row", gap: 10, paddingVertical: 10, borderTopWidth: 1, borderColor: colors.line, opacity: pressed ? 0.6 : 1 })}
>
{item.time}
diff --git a/mobile/src/fulltext.js b/mobile/src/fulltext.js
new file mode 100644
index 0000000..53e7644
--- /dev/null
+++ b/mobile/src/fulltext.js
@@ -0,0 +1,106 @@
+// M19 full-text chip: find a free, legal copy of a paper from its DOI.
+// Unpaywall first (needs the person's email), then OpenAlex (needs their
+// free API key), and always a link through the person's library (LibKey) and
+// doi.org. Lookups happen only when the person taps Full text, and send only
+// the DOI with their email or key. No React Native imports (tested in Node).
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
+
+// What can run with these settings: { unpaywall, openalex, reason }.
+function sources(ft = {}) {
+ const unpaywall = Boolean(ft.enabled && EMAIL_RE.test(String(ft.email || "").trim()));
+ const openalex = Boolean(ft.enabled && String(ft.openalexKey || "").trim());
+ let reason = "";
+ if (!ft.enabled) reason = "off";
+ else if (!unpaywall && !openalex) reason = ft.email ? "bad-email" : "no-email";
+ return { unpaywall, openalex, reason };
+}
+
+const enc = (doi) => encodeURIComponent(doi).replace(/%2F/gi, "/");
+
+function unpaywallUrl(doi, email) {
+ return `https://api.unpaywall.org/v2/${enc(doi)}?email=${encodeURIComponent(String(email).trim())}`;
+}
+function openalexUrl(doi, key) {
+ return `https://api.openalex.org/works/doi:${enc(doi)}?select=id,doi,title,open_access,best_oa_location,locations&api_key=${encodeURIComponent(String(key).trim())}`;
+}
+
+const VERSION = { publishedVersion: "published version", acceptedVersion: "accepted manuscript", submittedVersion: "preprint" };
+const licenceText = (l) => (l ? String(l).toUpperCase().replace(/^CC-/, "CC ").replace(/-/g, " ") : "");
+
+// One copy: { url, pdf, where, version, licence, from }.
+function copyFrom(loc, from) {
+ if (!loc) return null;
+ const pdf = loc.url_for_pdf || loc.pdf_url || "";
+ const page = loc.url_for_landing_page || loc.landing_page_url || loc.url || "";
+ const url = pdf || page;
+ if (!/^https?:\/\//i.test(url)) return null;
+ const where = loc.repository_institution || loc.source?.display_name || (loc.host_type === "publisher" ? "Publisher" : loc.host_type === "repository" ? "Repository" : "") || hostOf(url);
+ return { url, pdf: Boolean(pdf), where, version: VERSION[loc.version] || "", licence: licenceText(loc.license), from };
+}
+
+function hostOf(url) {
+ const m = /^[a-z]+:\/\/([^/?#:]+)/i.exec(String(url || ""));
+ return m ? m[1].replace(/^www\./, "") : "";
+}
+
+function parseUnpaywall(text) {
+ const j = JSON.parse(text);
+ const all = [j.best_oa_location, ...(j.oa_locations || [])].map((l) => copyFrom(l, "Unpaywall")).filter(Boolean);
+ return { isOa: Boolean(j.is_oa), status: j.oa_status || "", title: j.title || "", copies: all };
+}
+
+function parseOpenAlex(text) {
+ const j = JSON.parse(text);
+ const all = [j.best_oa_location, ...(j.locations || []).filter((l) => l.is_oa)].map((l) => copyFrom(l, "OpenAlex")).filter(Boolean);
+ if (!all.length && j.open_access?.oa_url) all.push({ url: j.open_access.oa_url, pdf: /\.pdf($|\?)/i.test(j.open_access.oa_url), where: hostOf(j.open_access.oa_url), version: "", licence: "", from: "OpenAlex" });
+ return { isOa: Boolean(j.open_access?.is_oa), status: j.open_access?.oa_status || "", title: j.title || "", copies: all };
+}
+
+// Looks the DOI up. getText is core/net's (cached, rate limited, identified).
+// Returns { copies, status, title, notes, links }.
+async function lookUp(doi, ft, getText) {
+ const can = sources(ft);
+ const notes = [];
+ const found = [];
+ let status = "";
+ let title = "";
+ const run = async (name, url, parse) => {
+ try {
+ // The cache key leaves out the email and key.
+ const r = await getText(url, { cacheKey: `${name}:${doi}`, timeout: 10000 });
+ if (r.status === 404) return notes.push(`${name} doesn't know this DOI.`);
+ if (r.status === 401 || r.status === 403 || r.status === 409) return notes.push(name === "OpenAlex" ? "OpenAlex refused the API key. Check it in Settings." : `${name} refused the request. Check your email in Settings.`);
+ if (r.status === 422) return notes.push(`${name} wants a real email address. Check it in Settings.`);
+ if (r.status === 429) return notes.push(`${name} is busy (too many requests today). Try later.`);
+ if (!r.ok) return notes.push(`${name} didn't answer (${r.status}).`);
+ const out = parse(r.text);
+ found.push(...out.copies);
+ status = status || out.status;
+ title = title || out.title;
+ } catch (err) {
+ notes.push(`${name} couldn't be reached.`);
+ }
+ };
+ if (can.unpaywall) await run("Unpaywall", unpaywallUrl(doi, ft.email), parseUnpaywall);
+ if (can.openalex) await run("OpenAlex", openalexUrl(doi, ft.openalexKey), parseOpenAlex);
+ // One row per address, PDFs and published versions first.
+ const seen = new Set();
+ const rank = (c) => (c.pdf ? 0 : 1) + (c.version === "published version" ? 0 : c.version === "accepted manuscript" ? 1 : 2);
+ const copies = found.filter((c) => !seen.has(c.url) && seen.add(c.url)).sort((a, b) => rank(a) - rank(b));
+ return { copies, status, title, notes, links: links(doi) };
+}
+
+// Always shown, no lookup needed.
+function links(doi) {
+ return { doi: `https://doi.org/${enc(doi)}`, library: `https://libkey.io/${enc(doi)}` };
+}
+
+// "Free to read: published version, PDF · CC BY · Unpaywall"
+function copyLine(c) {
+ return [[c.version, c.pdf ? "PDF" : "web page"].filter(Boolean).join(", "), c.licence, c.where, c.from].filter(Boolean).join(" · ");
+}
+
+const STATUS_TEXT = { gold: "Open access from the publisher", hybrid: "Open access from the publisher (hybrid journal)", bronze: "Free to read at the publisher, no open licence", green: "Free copy in a repository", diamond: "Open access, no fees", closed: "No free copy found" };
+
+module.exports = { sources, unpaywallUrl, openalexUrl, parseUnpaywall, parseOpenAlex, lookUp, links, copyLine, STATUS_TEXT, EMAIL_RE };
diff --git a/mobile/src/services.js b/mobile/src/services.js
index 6492d34..e7e510c 100644
--- a/mobile/src/services.js
+++ b/mobile/src/services.js
@@ -8,7 +8,7 @@ const { TrailStore, migrateSessions, sessionStorageOver } = require("../../core/
const appConfig = require("../app.json");
const PREFIX = "scout:";
-const SECRET_KEYS = { brave: "scout.brave.key", grok: "scout.grok.key" };
+const SECRET_KEYS = { brave: "scout.brave.key", grok: "scout.grok.key", unpaywallEmail: "node.unpaywall.email", openalex: "node.openalex.key" };
// Load every saved file into memory first, so core stores can read
// synchronously; each write is persisted to AsyncStorage in the background.
@@ -72,6 +72,10 @@ export async function createServices() {
} catch (_) {}
settings.providers.brave.key = (await SecureStore.getItemAsync(SECRET_KEYS.brave)) || "";
settings.ai.grok.key = (await SecureStore.getItemAsync(SECRET_KEYS.grok)) || "";
+ // M19 full text: off until the person turns it on; email and key in the keychain.
+ settings.fulltext = { enabled: false, tutorialSeen: false, ...(safeParse(storage.read("settings.json")).fulltext || {}) };
+ settings.fulltext.email = (await SecureStore.getItemAsync(SECRET_KEYS.unpaywallEmail)) || "";
+ settings.fulltext.openalexKey = (await SecureStore.getItemAsync(SECRET_KEYS.openalex)) || "";
// Identify NODE to the sites it calls in the background (Wikipedia limits
// unidentified apps to 10 requests a minute). Same contact URL as desktop.
@@ -96,9 +100,14 @@ export async function createServices() {
}
await SecureStore.setItemAsync(SECRET_KEYS.brave, settings.providers.brave.key || "");
await SecureStore.setItemAsync(SECRET_KEYS.grok, settings.ai.grok.key || "");
+ if (next.fulltext) settings.fulltext = { ...settings.fulltext, ...next.fulltext };
+ await SecureStore.setItemAsync(SECRET_KEYS.unpaywallEmail, String(settings.fulltext.email || "").trim());
+ await SecureStore.setItemAsync(SECRET_KEYS.openalex, String(settings.fulltext.openalexKey || "").trim());
const disk = JSON.parse(JSON.stringify(settings));
delete disk.providers.brave.key;
delete disk.ai.grok.key;
+ delete disk.fulltext.email;
+ delete disk.fulltext.openalexKey;
storage.write("settings.json", JSON.stringify(disk));
}
@@ -118,3 +127,11 @@ export async function createServices() {
ruleFor: (url) => core.resolve(rules, url),
};
}
+
+function safeParse(text) {
+ try {
+ return JSON.parse(text || "{}");
+ } catch (_) {
+ return {};
+ }
+}
diff --git a/mobile/test/fulltext.test.js b/mobile/test/fulltext.test.js
new file mode 100644
index 0000000..ae5e34b
--- /dev/null
+++ b/mobile/test/fulltext.test.js
@@ -0,0 +1,104 @@
+// M19 full-text chip: the lookup, with a fake network. Run: npm test (in mobile/).
+const assert = require("assert");
+const F = require("../src/fulltext");
+
+let passed = 0;
+const test = async (name, fn) => {
+ await fn();
+ passed++;
+ console.log(`ok ${passed} - ${name}`);
+};
+
+const UNPAYWALL = JSON.stringify({
+ doi: "10.1038/nature12373",
+ title: "Nanometre-scale thermometry in a living cell",
+ is_oa: true,
+ oa_status: "green",
+ best_oa_location: { url: "https://europepmc.org/articles/pmc4221854", url_for_landing_page: "https://europepmc.org/articles/pmc4221854", url_for_pdf: null, license: null, version: "acceptedVersion", host_type: "repository" },
+ oa_locations: [
+ { url: "https://europepmc.org/articles/pmc4221854", url_for_landing_page: "https://europepmc.org/articles/pmc4221854", version: "acceptedVersion", host_type: "repository" },
+ { url: "https://dash.harvard.edu/x.pdf", url_for_pdf: "https://dash.harvard.edu/x.pdf", version: "acceptedVersion", license: "cc-by", host_type: "repository", repository_institution: "Harvard University" },
+ ],
+});
+const OPENALEX = JSON.stringify({
+ id: "https://openalex.org/W1",
+ title: "Nanometre-scale thermometry in a living cell",
+ open_access: { is_oa: true, oa_status: "green", oa_url: "https://europepmc.org/articles/pmc4221854" },
+ best_oa_location: { is_oa: true, landing_page_url: "https://europepmc.org/articles/pmc4221854", pdf_url: null, version: "acceptedVersion", license: null, source: { display_name: "Europe PMC" } },
+ locations: [{ is_oa: false, landing_page_url: "https://www.nature.com/articles/nature12373" }],
+});
+
+function fakeNet(answers) {
+ const calls = [];
+ const getText = async (url, opts) => {
+ calls.push({ url, opts });
+ for (const [prefix, r] of answers) if (url.startsWith(prefix)) return typeof r === "function" ? r() : r;
+ return { ok: false, status: 500, text: "" };
+ };
+ return { getText, calls };
+}
+
+(async () => {
+ await test("what can run depends on the settings", () => {
+ assert.deepStrictEqual(F.sources({}), { unpaywall: false, openalex: false, reason: "off" });
+ assert.deepStrictEqual(F.sources({ enabled: true }), { unpaywall: false, openalex: false, reason: "no-email" });
+ assert.strictEqual(F.sources({ enabled: true, email: "nope" }).reason, "bad-email");
+ assert.deepStrictEqual(F.sources({ enabled: true, email: "sven@example.org" }), { unpaywall: true, openalex: false, reason: "" });
+ assert.deepStrictEqual(F.sources({ enabled: true, email: "", openalexKey: "k" }), { unpaywall: false, openalex: true, reason: "" });
+ });
+
+ await test("only the DOI and the email or key are sent; cache keys leave them out", async () => {
+ const { getText, calls } = fakeNet([
+ ["https://api.unpaywall.org/", { ok: true, status: 200, text: UNPAYWALL }],
+ ["https://api.openalex.org/", { ok: true, status: 200, text: OPENALEX }],
+ ]);
+ await F.lookUp("10.1038/nature12373", { enabled: true, email: " sven@example.org ", openalexKey: "KEY1" }, getText);
+ assert.strictEqual(calls[0].url, "https://api.unpaywall.org/v2/10.1038/nature12373?email=sven%40example.org");
+ assert.ok(calls[1].url.startsWith("https://api.openalex.org/works/doi:10.1038/nature12373?select="));
+ assert.ok(calls[1].url.endsWith("&api_key=KEY1"));
+ assert.deepStrictEqual(calls.map((c) => c.opts.cacheKey), ["Unpaywall:10.1038/nature12373", "OpenAlex:10.1038/nature12373"]);
+ });
+
+ await test("copies merged, one per address, PDFs first; links always there", async () => {
+ const { getText } = fakeNet([
+ ["https://api.unpaywall.org/", { ok: true, status: 200, text: UNPAYWALL }],
+ ["https://api.openalex.org/", { ok: true, status: 200, text: OPENALEX }],
+ ]);
+ const r = await F.lookUp("10.1038/nature12373", { enabled: true, email: "a@b.org", openalexKey: "k" }, getText);
+ assert.strictEqual(r.copies.length, 2);
+ assert.strictEqual(r.copies[0].url, "https://dash.harvard.edu/x.pdf");
+ assert.strictEqual(F.copyLine(r.copies[0]), "accepted manuscript, PDF · CC BY · Harvard University · Unpaywall");
+ assert.strictEqual(r.status, "green");
+ assert.strictEqual(r.links.library, "https://libkey.io/10.1038/nature12373");
+ assert.strictEqual(r.links.doi, "https://doi.org/10.1038/nature12373");
+ assert.deepStrictEqual(r.notes, []);
+ });
+
+ await test("errors say what to do", async () => {
+ const { getText } = fakeNet([
+ ["https://api.unpaywall.org/", { ok: false, status: 422, text: "" }],
+ ["https://api.openalex.org/", { ok: false, status: 409, text: "" }],
+ ]);
+ const r = await F.lookUp("10.1/x", { enabled: true, email: "a@b.org", openalexKey: "bad" }, getText);
+ assert.deepStrictEqual(r.copies, []);
+ assert.match(r.notes[0], /real email/);
+ assert.match(r.notes[1], /refused the API key/);
+ const down = fakeNet([["https://api.unpaywall.org/", () => Promise.reject(new Error("offline"))]]);
+ const r2 = await F.lookUp("10.1/x", { enabled: true, email: "a@b.org" }, down.getText);
+ assert.match(r2.notes[0], /couldn't be reached/);
+ const none = fakeNet([["https://api.unpaywall.org/", { ok: false, status: 404, text: "" }]]);
+ assert.match((await F.lookUp("10.1/x", { enabled: true, email: "a@b.org" }, none.getText)).notes[0], /doesn't know/);
+ });
+
+ await test("nothing is looked up when it is off", async () => {
+ const { getText, calls } = fakeNet([]);
+ const r = await F.lookUp("10.1/x", { enabled: false, email: "a@b.org", openalexKey: "k" }, getText);
+ assert.strictEqual(calls.length, 0);
+ assert.ok(r.links.library);
+ });
+
+ console.log(`\n${passed} passed`);
+})().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});