Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8a093bf
docs: design collected Micro.blog clippings posts
jpayne-procella Oct 28, 2026
dbeeb82
feat: persist Micro.blog clipping selections and publication state
jpayne-procella Oct 28, 2026
7b4bf70
feat: publish and recover collected Micro.blog clippings posts
jpayne-procella Oct 28, 2026
bc54d44
feat: wire manual and automatic Micro.blog clipping sync
jpayne-procella Oct 28, 2026
4e6e79e
fix: preserve Micro.blog publication identity across concurrent syncs…
jpayne-procella Oct 28, 2026
2f8bc2b
feat: add Micro.blog clipping publishing controls and local testing g…
jpayne-procella Oct 28, 2026
ae515a1
fix: support explicit localhost credential entry through Docker forwa…
jpayne-procella Oct 28, 2026
8b18a63
docs: design Micro.blog destination selection and explicit post recre…
jpayne-procella Oct 28, 2026
33c3a96
docs: require book emoji in Micro.blog highlight post titles
jpayne-procella Oct 28, 2026
fc50d9a
docs: plan Micro.blog blog selection and deleted post recreation
jpayne-procella Oct 28, 2026
be29e21
feat: discover and select Micro.blog posting destinations
jpayne-procella Oct 28, 2026
d135620
feat: pin clippings posts to blogs and explicitly recreate deleted posts
jpayne-procella Oct 28, 2026
9c88c54
Add Micro.blog clippings destination and recreation controls
jpayne-procella Oct 28, 2026
36fcb4f
Allow choosing previous or current blog when recreating clippings posts
jpayne-procella Oct 28, 2026
1567a45
Use string content for Micro.blog clippings updates
jpayne-procella Oct 28, 2026
23a183f
Prevent Micro.blog publication lock leaks on account read failures
jpayne-procella Oct 28, 2026
238de63
Keep reader sync working when Micro.blog scheduling fails
jpayne-procella Oct 28, 2026
b94a1d2
Acknowledge Micro.blog rematches when post content is unchanged
jpayne-procella Oct 28, 2026
73d136e
Allow recreation when a Micro.blog post disappears during update
jpayne-procella Oct 28, 2026
977bbe6
Queue saved Micro.blog clippings after progress establishes a match
jcapayne Oct 29, 2026
126cbf2
Preserve path-based legacy Micro.blog destinations during token rotation
jcapayne Oct 29, 2026
df157d0
Initialize connector registry lazily to avoid circular import failures
jcapayne Oct 29, 2026
2de8618
Preserve selected Micro.blog destination across token rotations
jcapayne Oct 29, 2026
8a882dd
Isolate follow-up clipping scheduling from successful Micro.blog pushes
jcapayne Oct 29, 2026
4aaf763
Limit per-book Micro.blog backfill to the requested book
jcapayne Oct 29, 2026
3ebab7b
Allow corrected Micro.blog updates to retry the existing post
jcapayne Oct 29, 2026
eeece6b
Preserve clipping reading order using positional fallbacks
jcapayne Oct 29, 2026
527151d
Allow corrected initial Micro.blog posts after validation rejection
jcapayne Oct 29, 2026
8a0ee92
Schedule Micro.blog clippings after completing a document merge
jcapayne Oct 29, 2026
31c602a
Keep Micro.blog recreation available without live clippings
jcapayne Oct 29, 2026
2801b21
Preserve paused Micro.blog posts until the book is matched
jcapayne Oct 29, 2026
00ba58a
Retry Micro.blog Micropub timeouts through post recovery
jcapayne Oct 29, 2026
3d3e278
Schedule Micro.blog collections after importing clippings
jcapayne Oct 29, 2026
b19606d
Recheck Micro.blog book matches before publication writes
jcapayne Oct 29, 2026
f571e21
Surface paused Micro.blog collection conflicts in Settings
jcapayne Oct 29, 2026
33bd45e
Preserve Micro.blog conflicts raised during publication
jcapayne Oct 29, 2026
933edf0
Recheck clipping eligibility after Micro.blog source lookup
jcapayne Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ PORT=8080
# openssl rand -hex 32
TOKEN_ENC_KEY=

# Docker localhost testing only: bind the published port to 127.0.0.1.
# ALLOW_HTTP_LOCALHOST=true

# Enable only behind an HTTPS reverse proxy that sets X-Forwarded-Proto,
# with direct access to Sync blocked. See README.md for Caddy setup.
# TRUST_PROXY=true
Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ For local image builds from the checkout, use:
docker compose -f docker-compose.dev.yml up -d --build
```

For localhost testing with connector credentials, Docker can forward requests
from a bridge address instead of a loopback address. Put your persistent
`TOKEN_ENC_KEY` in `.env`, then run:

```sh
HOST_BIND=127.0.0.1 HOST_PORT=8081 ALLOW_HTTP_LOCALHOST=true \
docker compose -f docker-compose.dev.yml up -d --build
```

Open `http://localhost:8081/app/`. This opt-in allows credential entry over HTTP
only for localhost URLs; keep the host port bound to `127.0.0.1`. For `docker run`,
use `-p 127.0.0.1:8081:8080 -e ALLOW_HTTP_LOCALHOST=true` and your existing encryption
key and data volume. Public/self-hosted network deployments should use HTTPS.

To serve under a subpath, pass `-e BASE_PATH=/crosspoint-sync` to `docker run`,
or run `BASE_PATH=/crosspoint-sync docker compose up -d`. The same image works
at any prefix; no frontend rebuild is needed.
Expand Down Expand Up @@ -137,6 +151,7 @@ DATABASE_PATH=./data/crosspoint.db PORT=8080 node dist/index.js
| `REGISTRATION_DISABLED` | `false` | Set `true` to lock down a private instance |
| `AUTH_RATE_LIMIT_PER_MINUTE` | `30` | Per-IP limit on registration (0 disables) |
| `TOKEN_ENC_KEY` | _(unset)_ | Enables external-service connectors. 64 hex chars, a base64 32-byte key, or a ≥32-char passphrase. Encrypts stored connector credentials at rest; unset = connectors disabled. |
| `ALLOW_HTTP_LOCALHOST` | `false` | Local Docker testing only: allow connector credentials over HTTP at `localhost`, `127.0.0.1`, or `[::1]` when Docker forwards from a bridge peer. Bind the published port to `127.0.0.1`; leave disabled on network-facing deployments. |
| `TRUST_PROXY` | `false` (`true` on Railway) | Set `true` only when direct access is blocked and a trusted reverse proxy overwrites any client-supplied `X-Forwarded-Proto`; permits connector linking through an HTTPS-terminating proxy. Defaults to `true` when `RAILWAY_ENVIRONMENT` is present, since Railway always fronts the service with its TLS-terminating edge; set `TRUST_PROXY=false` to override. |
| `GOOGLE_BOOKS_API_KEY` | _(unset)_ | Optional. Fallback source for print page counts when Open Library has none (keyless Google Books has no quota). |
| `HARDCOVER_API_KEY` | _(unset)_ | Optional. A Hardcover API key (`read:catalog` is enough) for book details from Hardcover's catalog: moods, genres, content warnings, rating, series, release year, and page counts/covers where other sources miss. One lookup per distinct book, cached for all users, throttled under Hardcover's free-plan limits. |
Expand All @@ -161,6 +176,49 @@ Native app association files, when enabled, remain at domain-root `/.well-known/
Treat the token like a password: Micro.blog app tokens have full account access. CrossPoint Sync
encrypts the token at rest using `TOKEN_ENC_KEY`.

With Micro.blog linked, each clipping on the **Clippings** page has an
**Add clipping to Micro.blog** action. Selected clippings are collected into
one post per book, titled **📚 Clippings from {book title}**, with a link to the
matched Micro.blog book page. Later clippings update the same post. CrossPoint
Sync manages its generated content, so later updates replace edits made directly
to that content on Micro.blog.

Choose **Post clippings to** in Micro.blog settings when the token accesses
multiple blogs. A sole available blog is selected automatically. The choice
applies to new collections: existing posts and new clippings for those books stay
on the book's original blog. Revoked access pauses posting to that blog; selecting
a different one never moves existing collections automatically.

**Automatically sync clippings** is off by default. Enabling it includes existing
and future clippings. Unmatched books wait without interrupting reader sync;
after matching, eligible clippings are sent on the next reader sync or **Sync now**.
Turning automatic sync off stops automatic additions while retaining published
passages and manually selected clippings.

Deleting a post on Micro.blog pauses publication for that book while preserving
its clippings locally. After the missing post is detected on an update, the
**Micro.blog publishing paused** section on the Clippings page offers **Recreate
clippings post**, even if the book has no live local clippings. This explicitly
republishes the collected clippings to either the previous blog (the default) or
the current clippings blog selected in Settings. Future updates for this book
follow the replacement post. If the previous blog is accessible, Sync rechecks
that the old post is still missing; a restored post can be resumed on the previous
blog instead. A previously confirmed deletion can also be recreated on the current
blog after access to the previous blog is lost. Network or permission errors do
not trigger automatic recreation.

Merging books that both have Micro.blog publication history pauses their combined
collection. Settings shows the conflict as a sync error so you can review the
existing posts before publishing further clippings.

For local testing, use Node 22.13 or newer, build the frontend with
`npm run build:frontend`, and copy `.env.example` to `.env`. Set a separate
`DATABASE_PATH` (such as `./data/microblog-test.db`), `PORT=8081`, and a persistent
`TOKEN_ENC_KEY` generated with `openssl rand -hex 32`. Run `npm run dev` and open
[the local app](http://localhost:8081/app/). Create a local account, sync a book
and its clippings from a reader, link Micro.blog, and match the book before trying
the clipping action or automatic sync. These actions publish to the linked blog.

## Point your reader at it

- **KOReader:** Tools → Progress sync → Custom sync server → `http://your-host:8080`
Expand Down
98 changes: 89 additions & 9 deletions app/src/Clippings.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { Copy, FileDown, Quote, Search } from 'lucide-react'
import { api, cached, isApp } from './api.js'
import { saveFile } from './shareCard.js'
import { ClipMenu, ClipShare, DeleteClip, hold } from './Book.jsx'
import { Cover, EmptyState, ErrorNote, ShareIcon, Spinner, useLoad } from './ui.jsx'
import { ActionSheet, Cover, EmptyState, ErrorNote, ShareIcon, Spinner, useLoad } from './ui.jsx'

const date = (unix) => new Date(unix * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })

Expand All @@ -25,7 +25,7 @@ function markdown(groups) {
.join('\n\n---\n\n')
}

function Clip({ session, clip, book, onShare, onDelete, showBook }) {
function Clip({ session, clip, book, onShare, onDelete, showBook, onMicroblog, sending }) {
return (
<article className={`group relative select-none [-webkit-touch-callout:none] ${showBook
? 'border-b border-stone-200 pt-6 pb-4 md:pr-8'
Expand All @@ -42,10 +42,15 @@ function Clip({ session, clip, book, onShare, onDelete, showBook }) {
</div>
</a>
)}
<div className="mt-2 flex items-center justify-between gap-3">
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
<button onClick={() => onShare(clip)} className="-ml-2 flex h-11 items-center gap-1.5 rounded-full px-2 text-sm font-medium text-brand-600 active:bg-stone-100">
<ShareIcon className="size-4" /> Share
</button>
{onMicroblog && (
<button type="button" disabled={sending} onClick={() => onMicroblog(clip)} className="min-h-11 rounded-full px-2 text-sm font-medium text-brand-600 disabled:opacity-50">
Add clipping to Micro.blog
</button>
)}
<p className="truncate text-xs text-stone-500">{[clip.chapter, date(clip.created_at)].filter(Boolean).join(' · ')}</p>
</div>
</article>
Expand All @@ -54,12 +59,20 @@ function Clip({ session, clip, book, onShare, onDelete, showBook }) {

export default function Clippings({ session, books }) {
const [items, error, reload] = useLoad(() => api.allClippings(session, books), [session], `clippings:${session.username}`, () => cached.clippings(session))
const [connectors] = useLoad(() => api.connectors(session).then((r) => r.connectors), [session], `clipping-connectors:${session.username}`)
const microblog = connectors?.some((c) => c.id === 'microblog' && c.linked)
const [postData, postError, reloadPosts] = useLoad(() => microblog ? api.microblogClippingPosts(session) : Promise.resolve({ items: [] }), [session, microblog])
const [recreating, setRecreating] = useState(null)
const [query, setQuery] = useState('')
const [only, setOnly] = useState(null) // document
const [sharing, setSharing] = useState(null)
const [deleting, setDeleting] = useState(null)
const [note, setNote] = useState(null)
const [sending, setSending] = useState(false)
const pending = useRef(false)
const [sendError, setSendError] = useState(null)
const byDoc = useMemo(() => new Map(books.map((b) => [b.document, b])), [books])
const missingPosts = microblog ? (postData?.items || []).filter((post) => post.missing && byDoc.has(post.document)) : []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface merged-post publication errors

When two local duplicates that both have attempted or published Micro.blog collections are merged, mergeMicroblogClippings stores an error and scheduleMicroblogClippings skips that book on state.error, but this filter only renders rows marked missing; the merge also creates no failed queue row for Settings. Automatic users therefore get no warning while all future clipping updates for the merged book are suppressed. Render post.error conflicts with a recovery path or surface them through the queue.

Useful? React with 👍 / 👎.


// Only clippings of books the library shows (metadata-less ones are hidden everywhere).
const all = (items ?? []).filter((c) => byDoc.has(c.document))
Expand All @@ -74,6 +87,26 @@ export default function Clippings({ session, books }) {
const counts = all.reduce((m, c) => m.set(c.document, (m.get(c.document) ?? 0) + 1), new Map())
const daily = all.length ? ofTheDay(all) : null

async function addToMicroblog(clip) {
if (pending.current) return
pending.current = true
setSending(true)
setNote(null)
setSendError(null)
try {
const result = await api.addMicroblogClipping(session, clip.document, clip.id)
setNote(result.waiting_for_match
? 'Clipping saved for Micro.blog. Match this book in Settings; it will be included on the next sync.'
: result.queued ? 'Clipping selected for the book’s Micro.blog post. The update is queued.'
: 'Clipping is selected for the book’s Micro.blog post.')
} catch (error) {
setSendError(error.message || 'Could not add this clipping to Micro.blog.')
} finally {
pending.current = false
setSending(false)
}
}

async function exportMd(save, e) {
// A <details> menu stays open until toggled; close it so the note shows.
e.currentTarget.closest('details').open = false
Expand All @@ -90,6 +123,21 @@ export default function Clippings({ session, books }) {
return (
<div className="px-4 pt-6 pb-4 md:px-8 md:pt-6 lg:px-12">
<h1 className="mt-1 font-display text-3xl font-semibold tracking-tight text-stone-900 md:mt-0 md:flex md:h-11 md:items-center md:text-4xl">Clippings</h1>
{sendError && <p role="alert" className="mt-3 text-sm text-red-600">{sendError}</p>}
{postError && microblog && <p role="alert" className="mt-3 text-sm text-red-600">Could not load Micro.blog post status: {postError.message}</p>}
{note && <p role="status" className="mt-2 text-sm text-stone-600">{note}</p>}
{missingPosts.length > 0 && <section aria-label="Paused Micro.blog posts" className="mt-6 rounded-xl bg-surface p-4 ring-1 ring-stone-200">
<h2 className="font-medium text-stone-900">Micro.blog publishing paused</h2>
{missingPosts.map((post) => {
const book = byDoc.get(post.document)
return <div key={post.document} className="mt-3 text-sm">
<a href={`#/book/${post.document}`} className="font-medium text-stone-900">{book.title || book.filename}</a>
{book.author && <p className="text-stone-500">{book.author}</p>}
<p className="mt-1 text-stone-600">The Micro.blog post was deleted. Clippings are retained while publishing is paused.</p>
<button type="button" onClick={() => setRecreating(post)} className="min-h-11 font-medium text-brand-600">Recreate clippings post</button>
</div>
})}
</section>}

{error && !items ? (
<ErrorNote error={error} />
Expand All @@ -98,16 +146,16 @@ export default function Clippings({ session, books }) {
) : all.length === 0 ? (
<div className="mt-6 md:max-w-2xl">
<EmptyState icon={Quote} note="Clean margins, for now" title="No clippings yet">
Highlight a passage on your reader with clipping sync on, and it lands here, ready to search and share.
Clippings saved on your reader land here, ready to search and share.
</EmptyState>
</div>
) : (
<>
{daily && !q && !only && (
<section className="mt-6">
<p className="inline-block -rotate-1 font-hand text-xl text-brand-600">Highlight of the day</p>
<p className="inline-block -rotate-1 font-hand text-xl text-brand-600">Clipping of the day</p>
<div className="mt-2 md:max-w-2xl">
<Clip session={session} clip={daily} book={byDoc.get(daily.document)} onShare={setSharing} onDelete={setDeleting} showBook />
<Clip session={session} clip={daily} book={byDoc.get(daily.document)} onShare={setSharing} onDelete={setDeleting} showBook onMicroblog={microblog ? addToMicroblog : null} sending={sending} />
</div>
</section>
)}
Expand Down Expand Up @@ -140,7 +188,6 @@ export default function Clippings({ session, books }) {
</div>
</details>
</div>
{note && <p className="mt-2 text-sm text-stone-600">{note}</p>}

<div className="-mx-4 mt-4 flex gap-5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] md:mx-0 md:flex-wrap md:px-0">
<button
Expand Down Expand Up @@ -178,14 +225,18 @@ export default function Clippings({ session, books }) {
</a>
<div className="mt-3 grid grid-cols-1 gap-x-8 md:grid-cols-2">
{clips.map((c) => (
<Clip key={c.id} session={session} clip={c} book={book} onShare={setSharing} onDelete={setDeleting} />
<Clip key={c.id} session={session} clip={c} book={book} onShare={setSharing} onDelete={setDeleting} onMicroblog={microblog ? addToMicroblog : null} sending={sending} />
))}
</div>
</section>
))}
</>
)}

{recreating && <RecreateClippingsPost session={session} post={recreating} onClose={() => setRecreating(null)} onDone={() => {
reloadPosts()
setNote('Replacement clippings post queued for Micro.blog.')
}} />}
{sharing && <ClipShare session={session} book={byDoc.get(sharing.document)} clip={sharing} onClose={() => setSharing(null)} />}
{deleting && (
<DeleteClip
Expand All @@ -202,3 +253,32 @@ export default function Clippings({ session, books }) {
</div>
)
}

function RecreateClippingsPost({ session, post, onClose, onDone }) {
const [blogs, error, reload] = useLoad(() => api.microblogDestinations(session), [session])
const [target, setTarget] = useState('original')
const [busy, setBusy] = useState(false)
const currentBlog = blogs?.items?.find((b) => b.uid === blogs.selected)
const original = post.destination || post.url
return (
<ActionSheet title="Recreate clippings post?" actionLabel="Recreate post" pendingLabel="Queuing…" onClose={onClose} onConfirm={async () => {
setBusy(true)
try {
await api.recreateMicroblogPost(session, post.document, target)
onDone()
} finally { setBusy(false) }
}}>
This republishes this book’s collected clippings. Future clippings for this book will update the replacement on the chosen blog.
<label className="mt-3 flex items-start gap-2">
<input type="radio" name="recreate-blog" value="original" checked={target === 'original'} disabled={busy} onChange={() => setTarget('original')} className="mt-1" />
<span className="break-all">Previous blog: {original}</span>
</label>
{currentBlog?.uid !== post.destination && <label className="mt-3 flex items-start gap-2">
<input type="radio" name="recreate-blog" value="current" checked={target === 'current'} disabled={busy || !currentBlog} onChange={() => setTarget('current')} className="mt-1" />
<span className="break-all">Current clippings blog: {currentBlog ? `${currentBlog.name} (${currentBlog.uid})` : blogs ? 'Choose an available blog in Settings first.' : 'Loading…'}</span>
</label>}
{currentBlog?.uid === post.destination && <span className="mt-3 block">The current clippings blog is also the previous blog.</span>}
{error && <span className="mt-3 block">Could not load the current clippings blog. <button type="button" onClick={reload} className="font-medium text-brand-600">Retry</button></span>}
</ActionSheet>
)
}
Loading
Loading