Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Laravel 13 · PHP 8.3 · SQLite · Breeze (Blade) · Tailwind · Alpine.
- **Reader**: serif or sans, adjustable size, light, sepia or dark;
remembered per browser. Your scroll position is saved as you read and
restored when you come back, on any device.
- **Offline reading**: the installed app keeps the newest unread articles
(30 by default), with their stored images, on each device, and refreshes
them whenever it is opened online.
- **Pocket import**: upload Pocket's export CSV, or use
`php artisan docket:import-pocket`. Dates, tags and unread/archive status
are kept; extraction is queued at a gentle rate. Re-running it is safe.
Expand Down Expand Up @@ -93,6 +96,24 @@ for a new item and `200` when the link was already saved, with the item
requests a minute. Tokens are Laravel Sanctum personal access tokens; only
a hash is stored, and revoking one on the Settings page stops it at once.

## Reading offline

Install Docket on the device (**Add to Home screen** on Android or iOS, or
the install button in a desktop browser) and open it while online. Every
time a logged-in page opens with a connection, the service worker updates
its copies of the newest unread articles (**Settings → Articles kept for
offline reading**, 30 by default; 0 keeps none) and their stored images.

Offline, those articles open as normal from the list on the offline page or
from any link to them. Your place in an article is remembered and sent when
the connection comes back. Archiving, favourites, tags and search need a
connection. Images that were never downloaded to the server (past the
per-article limits, or SVG) stay remote and don't show offline.

An article leaves the device at the next sync once it is archived or
deleted, or drops out of the newest unread. Logging out deletes every
offline copy in that browser.

## Coming from Pocket

Pocket's export is a ZIP with one or more CSV files (`part_000000.csv`,
Expand Down
5 changes: 4 additions & 1 deletion app/Http/Controllers/Auth/AuthenticatedSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public function destroy(Request $request): RedirectResponse

$request->session()->regenerateToken();

return redirect('/');
// Drop the offline copies of articles (and the service worker
// holding them) from this browser. The logout form also clears
// them before submitting, for browsers that ignore this header.
return redirect('/')->header('Clear-Site-Data', '"cache", "storage"');
}
}
71 changes: 71 additions & 0 deletions app/Http/Controllers/OfflineController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace App\Http\Controllers;

use App\Models\Item;
use App\Services\ImageLocaliser;
use Illuminate\Http\JsonResponse;

/**
* The list the service worker (public/sw.js) keeps on the device for
* offline reading: the newest unread articles that have a readable copy,
* each with the reader page's address, a version that changes whenever the
* page would, and the stored images the page uses.
*
* The worker fetches each reader page and image itself, so this only says
* what to keep. Anything not in the list is dropped from the device on the
* next sync, which is how archiving or deleting an article (or turning the
* setting down to 0) removes its offline copy.
*/
class OfflineController extends Controller
{
public function __invoke(): JsonResponse
{
$limit = (int) settings('offline.articles');

$items = $limit < 1 ? collect() : Item::query()
->withoutBodies()
->addSelect('items.content_html')
->unread()
->whereNotNull('items.content_html')
->where('items.content_html', '!=', '')
->sorted('newest')
->limit($limit)
->get();

return response()->json(
[
'items' => $items->map(fn (Item $item): array => [
'url' => route('items.show', $item, false),
'version' => (string) $item->updated_at?->getTimestamp(),
'title' => $item->displayTitle(),
'site' => $item->site_name ?: $item->host(),
'minutes' => (int) $item->reading_time,
'images' => $this->images($item),
])->values()->all(),
],
200,
['Cache-Control' => 'no-store']
);
}

/**
* The stored images the article's HTML points at. Remote images are
* left out: they come from other sites and can't be kept reliably.
*
* @return list<string>
*/
protected function images(Item $item): array
{
// The route's own prefix, so this follows any change to the URL.
$prefix = substr(route('items.image', ['item' => $item, 'filename' => 'x'], false), 0, -1);

preg_match_all(
'#src="('.preg_quote($prefix, '#').ImageLocaliser::FILENAME_PATTERN.')"#',
(string) $item->content_html,
$matches
);

return array_values(array_unique($matches[1]));
}
}
6 changes: 6 additions & 0 deletions app/Services/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ public static function fields(): array
'type' => 'int',
'rules' => ['required', 'integer', 'min:0', 'max:500'],
],
'offline.articles' => [
'label' => 'Articles kept for offline reading',
'help' => 'The installed app keeps this many of the newest unread articles on each device, refreshed whenever it is opened online. Set to 0 to keep none.',
'type' => 'int',
'rules' => ['required', 'integer', 'min:0', 'max:200'],
],
'import.spacing_seconds' => [
'label' => 'Import spacing (seconds)',
'help' => 'Gap between extraction jobs queued by a Pocket import, so a large import fetches gently.',
Expand Down
9 changes: 9 additions & 0 deletions config/docket.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@
'time_budget' => 120,
],

/*
| Offline reading: the installed app keeps this many of the newest unread
| articles, with their stored images, on the device, and refreshes them
| whenever it is opened online. 0 turns it off and clears the copies.
*/
'offline' => [
'articles' => 30,
],

/*
| Reading time is word count divided by this, rounded up.
*/
Expand Down
18 changes: 14 additions & 4 deletions public/offline.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Docket — offline</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; margin: 0; background: #f8fafc; color: #334155; }
main { text-align: center; padding: 2rem; max-width: 28rem; }
h1 { font-size: 1.25rem; }
body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #f8fafc; color: #334155; }
main { padding: 2rem 1.25rem; max-width: 40rem; margin: 0 auto; }
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; color: #0f172a; }
p { line-height: 1.5; }
ul { list-style: none; padding: 0; margin: 1.5rem 0 0; }
li { border-top: 1px solid #e2e8f0; }
li a { display: block; padding: 0.875rem 0; color: #0f172a; text-decoration: none; }
li a:hover strong { text-decoration: underline; }
li span { display: block; margin-top: 0.25rem; font-size: 0.875rem; color: #64748b; }
.muted { font-size: 0.875rem; color: #64748b; }
</style>
</head>
<body>
<main>
<h1>You&rsquo;re offline</h1>
<p>Docket needs a connection. Anything you were sharing hasn&rsquo;t been saved yet &mdash; share it again when you&rsquo;re back online.</p>
<p id="summary">Docket needs a connection for everything except the articles saved on this device. Anything you were sharing hasn&rsquo;t been saved yet &mdash; share it again when you&rsquo;re back online.</p>
<ul id="articles"></ul>
<p id="synced" class="muted"></p>
</main>
<script src="/offline.js"></script>
</body>
</html>
46 changes: 46 additions & 0 deletions public/offline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Lists the articles the service worker (sw.js) has saved on this device,
// on the page shown when Docket is opened without a connection. A plain
// file rather than an inline script, so it runs under any
// Content-Security-Policy.
(function () {
var list = document.getElementById('articles');
var summary = document.getElementById('summary');
var synced = document.getElementById('synced');

if (!('caches' in window)) {
return;
}

caches.open('docket-articles')
.then(function (cache) { return cache.match('/__docket/offline-index'); })
.then(function (response) { return response ? response.json() : { items: [] }; })
.then(function (index) {
if (!index.items.length) {
return;
}

summary.textContent = 'These unread articles are saved on this device. Everything else in Docket needs a connection.';

index.items.forEach(function (item) {
var link = document.createElement('a');
var title = document.createElement('strong');
var meta = document.createElement('span');

link.href = item.url;
title.textContent = item.title;
meta.textContent = item.site + (item.minutes > 0 ? ' · ' + item.minutes + ' min read' : '');

link.appendChild(title);
link.appendChild(meta);

var row = document.createElement('li');
row.appendChild(link);
list.appendChild(row);
});

if (index.syncedAt) {
synced.textContent = 'Last updated ' + new Date(index.syncedAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) + '.';
}
})
.catch(function () {});
})();
Loading
Loading