Book
${esc(b.title)}
${readControl}${coverManager}Highlights
diff --git a/.gitea/workflows/build-scan-push.yml b/.gitea/workflows/build-scan-push.yml new file mode 100644 index 0000000..f0a6b35 --- /dev/null +++ b/.gitea/workflows/build-scan-push.yml @@ -0,0 +1,166 @@ +name: Build, Scan & Publish Image + +# Publishing a Gitea release (for example with tag 2026.09.25) builds the image, +# scans it with Grype (report only), and pushes it to this repository's registry. +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Image tag to build and push when run manually + required: false + default: latest + +concurrency: + group: image-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-scan-push: + runs-on: running-man + env: + REGISTRY: git.sysmd.uk + IMAGE: git.sysmd.uk/mdaleo404/kovi + COSIGN_VERSION: v3.0.5 + GRYPE_VERSION: v0.110.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: false + fetch-depth: 0 + + - name: Resolve release tag + env: + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + TAG="${EVENT_TAG:-}" + if [ -z "$TAG" ]; then TAG="${INPUT_TAG:-}"; fi + if [ -z "$TAG" ]; then TAG="${GITHUB_REF_NAME:-}"; fi + if [ -z "$TAG" ]; then TAG="$(git describe --tags --exact-match 2>/dev/null || echo "")"; fi + if [ -z "$TAG" ]; then echo "Could not determine a tag for this run." >&2; exit 1; fi + SAFE_TAG="${TAG//\//-}" + case "$SAFE_TAG" in + [A-Za-z0-9_]*) ;; + *) SAFE_TAG="v$SAFE_TAG" ;; + esac + { + echo "TAG=$SAFE_TAG" + echo "IMAGE_REF=${IMAGE}:${SAFE_TAG}" + } >> "$GITHUB_ENV" + if [ "${{ github.event.release.prerelease }}" != "true" ]; then + echo "PUSH_LATEST=1" >> "$GITHUB_ENV" + fi + echo "Resolved image reference: ${IMAGE}:${SAFE_TAG}" + + - name: Login to registry + run: | + echo "$REGISTRY_TOKEN" | docker login "${REGISTRY}" -u "$REGISTRY_USER" --password-stdin + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build image + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + TAGS=(-t "${IMAGE_REF}") + if [ "${PUSH_LATEST:-}" = "1" ]; then TAGS+=(-t "${IMAGE}:latest"); fi + docker buildx build \ + --load \ + --no-cache \ + --secret id=gitea_token,env=GITEA_TOKEN \ + "${TAGS[@]}" \ + . + + - name: Install Cosign + run: | + set -euo pipefail + curl -fLO "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" + chmod +x cosign-linux-amd64 + mv cosign-linux-amd64 /usr/local/bin/cosign + + - name: Install Grype + run: | + set -euo pipefail + + VERSION_NO_V="${GRYPE_VERSION#v}" + FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz" + BASE="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}" + + curl -fLO "${BASE}/${FILE}" + curl -fLO "${BASE}/grype_${VERSION_NO_V}_checksums.txt" + curl -fLO "${BASE}/grype_${VERSION_NO_V}_checksums.txt.sig" + curl -fLO "${BASE}/grype_${VERSION_NO_V}_checksums.txt.pem" + + cosign verify-blob \ + --signature "grype_${VERSION_NO_V}_checksums.txt.sig" \ + --certificate "grype_${VERSION_NO_V}_checksums.txt.pem" \ + --certificate-identity-regexp "https://github.com/anchore/grype" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "grype_${VERSION_NO_V}_checksums.txt" + + CHECK=$(grep " ${FILE}$" "grype_${VERSION_NO_V}_checksums.txt") + [ -n "$CHECK" ] || { echo "Missing checksum"; exit 1; } + + echo "$CHECK" | sha256sum -c - + + tar -xzf "${FILE}" + mv grype /usr/local/bin/ + + - name: Scan image with Grype (report only) + id: grype + continue-on-error: true + run: | + grype "${IMAGE_REF}" -o json > grype.json + + echo "Fixable HIGH/CRITICAL vulnerabilities:" + jq -r ' + .matches[] + | select( + ( + .vulnerability.severity == "High" or + .vulnerability.severity == "Critical" + ) + and + ( + (.vulnerability.fix.versions | length) > 0 + ) + ) + | "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])" + ' grype.json + + # Report fixable HIGH/CRITICAL without failing the build. + COUNT=$(jq ' + [ + .matches[] + | select( + ( + .vulnerability.severity == "High" or + .vulnerability.severity == "Critical" + ) + and + ( + (.vulnerability.fix.versions | length) > 0 + ) + ) + ] + | length + ' grype.json) + echo "Fixable HIGH/CRITICAL count: ${COUNT}" + if [ "$COUNT" != "0" ]; then + echo "::warning::Grype found ${COUNT} fixable HIGH/CRITICAL vulnerabilities." + fi + + - name: Push image + run: | + docker push "${IMAGE_REF}" + if [ "${PUSH_LATEST:-}" = "1" ]; then docker push "${IMAGE}:latest"; fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55447ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4 + +# kovi does not vendor or copy Calibre code. The container installs Debian's +# unmodified Calibre package and calls its fetch-ebook-metadata CLI as a separate +# process for cover resolution. kovi uses both fetch-ebook-metadata and +# calibre-debug's headless cover-source path so Calibre's cover-only Google +# Images source can run when identification fails. +RUN apt-get update \ + && apt-get install -y --no-install-recommends calibre ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json server.mjs ./ +COPY lib ./lib +COPY public ./public +COPY plugins ./plugins +RUN mkdir -p /app/data/uploads /app/data/covers && chown -R node:node /app +USER node +ENV HOST=0.0.0.0 PORT=3000 DATA_PATH=/app/data TZ=UTC CALIBRE_COVER_RESOLVER=auto +VOLUME ["/app/data"] +EXPOSE 3000 +CMD ["node","server.mjs"] diff --git a/README.md b/README.md index 58b5c66..b7056be 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,242 @@ +[](https://git.sysmd.uk/mdaleo404/kovi/src/branch/main/LICENSE) +[](https://git.sysmd.uk/mdaleo404/kovi/releases) +[](https://nodejs.org/) + # kovi +
+${esc(b.authors||'Unknown author')}
`} + +function bookReadingState(book){if(bookIsRead(book))return 'completed';if(book.read_override!=null&&Number(book.read_override)===0)return 'unread';return progress(book)===0?'unread':'progress'} +function renderLibrary(){ + const collator=new Intl.Collator(undefined,{sensitivity:'base',numeric:true}),title=(a,b)=>collator.compare(a.title||'',b.title||''); + const comparators={recent:(a,b)=>Number(b.recent_read||b.last_open||0)-Number(a.recent_read||a.last_open||0)||title(a,b),title,author:(a,b)=>collator.compare(a.authors||'Unknown author',b.authors||'Unknown author')||title(a,b),time:(a,b)=>Number(b.total_read_time||0)-Number(a.total_read_time||0)||title(a,b),progress:(a,b)=>progress(b)-progress(a)||title(a,b)}; + const query=state.libraryQuery.trim().toLowerCase(); + const books=state.books.filter(book=>(state.libraryFilter==='all'||bookReadingState(book)===state.libraryFilter)&&(!query||`${book.title} ${book.authors||''}`.toLowerCase().includes(query))).sort(comparators[state.librarySort]); + $('#bookSort').value=state.librarySort;$('#bookFilter').value=state.libraryFilter; + $('#librarySummary').textContent=books.length===state.books.length?`${books.length} book${books.length===1?'':'s'}`:`Showing ${books.length} of ${state.books.length} books`; + $('#bookGrid').innerHTML=books.map(bookCard).join('')||`${state.books.length?'No books match these filters.':'No books yet.'}
`; +} + +function rangeLabel(){return RANGE_LABELS[state.rangeKey]||'Custom dates'} +function rangeDescription(range){return `${formatDate(range.from)} – ${formatDate(range.to)}`} +function buildRangeSeries(days,range){ + const by=new Map((days||[]).map(x=>[x.day,Number(x.seconds||0)])); + const start=parseLocalDateKey(range?.from),end=parseLocalDateKey(range?.to);if(!start||!end)return []; + const out=[];for(const d=new Date(start);d<=end;d.setDate(d.getDate()+1)){const day=localDateKey(d);out.push({day,seconds:by.get(day)||0})}return out; +} + +function render(){ + const d=state.dashboard,b=state.books;const empty=!b.length; + $('#emptyState').classList.toggle('hidden',!empty);$('#dashboardContent').classList.toggle('hidden',empty); + if(!empty&&d){ + $('#metrics').innerHTML=[ + ['Books',d.books,'in your reading history'], + ['Books read',Number(d.range?.read_books||0).toLocaleString(),`started and completed · ${rangeLabel()}`], + ['Reading time',fmtSeconds(d.all_time_reading_seconds??d.total_read_time??d.session_seconds),'all-time KOReader total'], + ['Pages',Number(d.total_read_pages||0).toLocaleString(),'pages turned'], + ['Highlights',Number(d.highlights||0).toLocaleString(),'ideas kept'], + ].map(x=>`${esc(date)}
There are no dated KOReader sessions for this day.
`;return} + const books=(day.books||[]).map(book=>``).join(''); + detail.innerHTML=`${esc(date)}
${esc(text)}`:''}${note?`
${esc(note)}
`:''}${location?`${esc(location)}
`:''}The statistics database stores only the count. Sync the kovi plugin to import the actual highlight text from KOReader’s book sidecars.
No highlights have been synced for this book.
${esc(statusDetail)}
Cover manager
Book
Highlights
No KOReader devices paired yet.
No plugin syncs recorded yet.
'; + $('#statusImports').innerHTML=(d.recentImports||[]).slice(0,3).map(x=>`No manual imports recorded yet.
'; +} +async function loadStatus({feedback=false}={}){ + const button=$('#refreshStatus');const original=button?.textContent||'Refresh'; + if(button){button.disabled=true;button.textContent='Refreshing…'} + try{ + state.status=await api(`/api/status?fresh=${Date.now()}`,{cache:'no-store'});renderStatus(); + if(feedback)toast(`Status refreshed at ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}.`); + }catch(e){toast(e.message)} + finally{if(button){button.disabled=false;button.textContent=original}} +} +$('#refreshStatus')?.addEventListener('click',()=>loadStatus({feedback:true})); + +$('#retryCovers').addEventListener('click',async()=>{try{const d=await api('/api/covers/retry',{method:'POST'});toast(d.queued?`${d.queued} cover${d.queued===1?'':'s'} queued.`:'No missing covers to retry.');state.books.forEach(b=>{if(b.cover_status==='none'||b.cover_status==='error')b.cover_status='pending'});coverPoll=setTimeout(()=>refresh().catch(()=>{}),3000)}catch(e){toast(e.message)}}); +function setTheme(dark){document.documentElement.classList.toggle('dark',dark);localStorage.setItem('kovi-theme',dark?'dark':'light');const meta=$('#themeColor');if(meta)meta.setAttribute('content',dark?'#0c1320':'#f6f3ec');const button=$('#themeButton');button.textContent=dark?'☀':'◐';button.setAttribute('aria-label',dark?'Switch to light theme':'Switch to dark theme');button.setAttribute('aria-pressed',String(dark))} +const savedTheme=localStorage.getItem('kovi-theme');setTheme(savedTheme==='dark'||(!savedTheme&&matchMedia('(prefers-color-scheme: dark)').matches)); +$('#themeButton').addEventListener('click',()=>setTheme(!document.documentElement.classList.contains('dark'))); + +api('/api/session').then(s=>{state.csrf=s.csrf;return refresh()}).then(routeFromLocation).catch(e=>toast(e.message)); diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..4b82d09 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..ada80d0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,125 @@ + + + + + + + +Your reading room
Last year
Last year
Reading calendar
KOReader sync
Each device receives its own revocable token. Pairing codes expire after ten minutes.
Plugin setup
plugins/kovi.koplugin into your KOReader plugins folder.System & data
Sync health, storage, recent imports, and portable copies of your reading data.
KOReader
Database
Own your data
A backup contains a consistent SQLite snapshot plus your selected and historical cover files. Exports are convenient portable views of the same local data.