diff --git a/wiki/api/wiki_space.py b/wiki/api/wiki_space.py index 4887ce70..c5c80454 100644 --- a/wiki/api/wiki_space.py +++ b/wiki/api/wiki_space.py @@ -1,7 +1,117 @@ +import hashlib +import re + import frappe from frappe import _ from frappe.utils.nestedset import get_descendants_of +# Matches the `src` attribute of `` tags in rendered markdown — used to +# enumerate embedded assets so the service worker can precache them. +_IMG_SRC_RE = re.compile(r']*?\bsrc=["\']([^"\']+)["\']', re.IGNORECASE) + + +@frappe.whitelist( + allow_guest=True, methods=["GET"] +) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method +def get_space_manifest(space_route: str) -> dict: + """Return the precache manifest for a Wiki Space, used by the PWA service worker. + + Only published, public, internal leaf pages are listed. External links and + groups are omitted from `pages` but kept in the tree metadata so the sidebar + renders correctly offline. + """ + space = frappe.db.get_value( + "Wiki Space", + {"route": space_route, "is_published": 1}, + ["name", "space_name", "route", "root_group", "favicon", "light_mode_logo"], + as_dict=True, + ) + if not space or not space.root_group: + frappe.throw(_("Space not found"), frappe.DoesNotExistError) + + descendants = get_descendants_of("Wiki Document", space.root_group, ignore_permissions=True) + if not descendants: + return _empty_manifest(space) + + docs = frappe.db.get_all( + "Wiki Document", + fields=["name", "title", "route", "content", "modified", "is_group", "is_external_link"], + filters={ + "name": ("in", descendants), + "is_published": 1, + "is_private": 0, + "is_group": 0, + "is_external_link": 0, + }, + order_by="lft asc", + ) + + pages = [] + hasher = hashlib.sha256() + for doc in docs: + page_hash = hashlib.sha256( + (doc.content or "").encode("utf-8") + str(doc.modified).encode("utf-8") + ).hexdigest()[:12] + + image_urls = sorted( + {src for src in _IMG_SRC_RE.findall(doc.content or "") if src.startswith("/files/")} + ) + + pages.append( + { + "route": doc.route, + "title": doc.title, + "hash": page_hash, + "images": image_urls, + } + ) + hasher.update(page_hash.encode("utf-8")) + + manifest = _empty_manifest(space) + manifest.update( + { + "version": hasher.hexdigest()[:16], + "pages": pages, + "shared_assets": _shared_assets(), + } + ) + return manifest + + +def _empty_manifest(space: dict) -> dict: + return { + "space": { + "name": space.name, + "space_name": space.space_name, + "route": space.route, + "favicon": space.favicon, + "logo": space.light_mode_logo, + }, + "version": "", + "pages": [], + "shared_assets": _shared_assets(), + } + + +def _shared_assets() -> list[str]: + """Shared CSS/JS/font assets every wiki page needs, regardless of space. + + These are precached when the service worker installs, before any space is + downloaded. + """ + from wiki.utils import get_tailwindcss_hash + + return [ + f"/assets/wiki/css/tailwind.css?v={get_tailwindcss_hash()}", + "/assets/wiki/css/hljs-github-light.css", + "/assets/wiki/js/vendor/alpinejs/plugins/persist.js", + "/assets/wiki/js/vendor/alpinejs/plugins/collapse.js", + "/assets/wiki/js/vendor/alpinejs/core.js", + "/assets/wiki/js/code-blocks.js", + "/assets/wiki/js/image-viewer.js", + "/assets/wiki/favicon.png", + ] + @frappe.whitelist() def get_wiki_tree(space_id: str) -> dict: diff --git a/wiki/templates/wiki/includes/install_prompt.html b/wiki/templates/wiki/includes/install_prompt.html new file mode 100644 index 00000000..e97827f6 --- /dev/null +++ b/wiki/templates/wiki/includes/install_prompt.html @@ -0,0 +1,168 @@ +{% from "templates/wiki/macros/buttons.html" import render_icon %} + +{# Install + offline nudge. Mobile-focused, one-shot per space. + - Android-class browsers: capture beforeinstallprompt → show dialog → on accept, install + bulk-download the current space. + - iOS: no install API → show popover with manual Share → Add to Home Screen instructions, plus a "Save for offline" button. + - Already-installed or non-mobile: silent. Desktop users discover offline via /wiki-offline. +#} +{% if wiki_space and wiki_space.is_published %} +
+ + {# Android install dialog #} +
+
+
+
+

+

+
+ +
+
+ + +
+
+
+ + {# iOS instructional popover #} +
+
+
+

+

+ Tap + + + in Safari then "Add to Home Screen" to install. You can also save this space for offline reading right now. +

+
+ +
+ +
+
+ + +{% endif %} diff --git a/wiki/templates/wiki/layout.html b/wiki/templates/wiki/layout.html index 2de3d5b8..ea661b37 100644 --- a/wiki/templates/wiki/layout.html +++ b/wiki/templates/wiki/layout.html @@ -9,6 +9,12 @@ {% endblock %} + + + + + + @@ -40,6 +46,9 @@ // CSRF token for API requests window.CSRF_TOKEN = '{{ csrf_token }}'; + + // Space route the current page belongs to (used by the PWA store). + window.WIKI_SPACE_ROUTE = {{ (wiki_space.route if wiki_space else "") | tojson }}; @@ -86,9 +95,186 @@ class="min-w-[45rem] max-w-[60vw] max-h-[80vh] object-contain cursor-zoom-out max-[768px]:min-w-[100vw]" /> + {% if not hide_chrome %} + {% include "templates/wiki/includes/install_prompt.html" %} + {% endif %} + + {# PWA update-available banner — shown when cached version of current space is stale #} + {% if wiki_space and wiki_space.is_published %} +
+
+ New content available for offline reading. +
+ + +
+
+
+ {% endif %} + + {# PWA download progress + completion toast — shown across all wiki pages #} +
+
+ + +
+
+ {{ include_script("syntax_highlighting.bundle.js") }} + + {% block scripts %} {% endblock %} \ No newline at end of file diff --git a/wiki/www/manifest.json b/wiki/www/manifest.json new file mode 100644 index 00000000..0ada1910 --- /dev/null +++ b/wiki/www/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Frappe Wiki", + "short_name": "Wiki", + "description": "Documentation reader. Browse spaces online, take them offline.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "theme_color": "#171717", + "background_color": "#ffffff", + "icons": [ + { + "src": "/assets/wiki/favicon.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/wiki/favicon.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/wiki/www/offline.html b/wiki/www/offline.html new file mode 100644 index 00000000..9efb5e82 --- /dev/null +++ b/wiki/www/offline.html @@ -0,0 +1,32 @@ + + + + + + Offline — Frappe Wiki + + + + +
+
📡
+

You're offline

+

+ This page hasn't been downloaded for offline reading. Open a space you've + made available offline, or reconnect to the internet. +

+
+ + +
+
+ + diff --git a/wiki/www/sw.js b/wiki/www/sw.js new file mode 100644 index 00000000..031a4866 --- /dev/null +++ b/wiki/www/sw.js @@ -0,0 +1,199 @@ +/** + * Frappe Wiki service worker. + * + * Strategy (v1): + * - App shell (CSS/JS/fonts) precached on install. + * - Wiki pages cached on demand when the user taps "Make available offline" + * for a space. Each space gets its own cache: `wiki-space--v`. + * - Fetch handler: cache-first for any URL we've stored, network-first for + * everything else, navigation fallback to /offline when both fail. + * - POST requests (e.g. the SPA's get_page_data) are never intercepted. + * When offline, the page's existing try/catch falls through to + * `window.location.href`, which then hits this SW's HTML cache. + */ + +const SHELL_CACHE = 'wiki-shell-v1'; +const SPACE_CACHE_PREFIX = 'wiki-space-'; + +const SHELL_ASSETS = ['/offline']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => cache.addAll(SHELL_ASSETS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k === 'wiki-shell-v0') + .map((k) => caches.delete(k)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + + // Only handle GETs. POSTs (get_page_data, form submits) pass through so the + // page's own error handling kicks in when offline. + if (request.method !== 'GET') return; + + const url = new URL(request.url); + + // Same-origin only — don't interfere with third-party assets. + if (url.origin !== self.location.origin) return; + + // Never cache API endpoints — too dynamic. + if (url.pathname.startsWith('/api/')) return; + + event.respondWith(handleFetch(request)); +}); + +async function handleFetch(request) { + const cacheMatch = await caches.match(request, { ignoreSearch: false }); + if (cacheMatch) return cacheMatch; + + try { + return await fetch(request); + } catch (err) { + // Network failed and we have no cached copy. + if (request.mode === 'navigate') { + const offline = await caches.match('/offline'); + if (offline) return offline; + } + throw err; + } +} + +self.addEventListener('message', (event) => { + const data = event.data || {}; + if (data.type === 'PRECACHE_SPACE') { + event.waitUntil(precacheSpace(data.manifest, event.source)); + } else if (data.type === 'REMOVE_SPACE') { + event.waitUntil(removeSpace(data.spaceRoute)); + } else if (data.type === 'LIST_SPACES') { + event.waitUntil( + listSpaces().then((spaces) => { + event.source?.postMessage({ type: 'SPACES_LIST', spaces }); + }), + ); + } +}); + +/** + * Download every page + image + shared asset for a space into a dedicated + * cache. Reports progress back to the calling client. + */ +async function precacheSpace(manifest, client) { + const spaceRoute = manifest.space.route; + const cacheName = `${SPACE_CACHE_PREFIX}${spaceRoute}-v1`; + + // Drop any older copy of this space so leftover URLs don't linger. + const existing = await caches.keys(); + await Promise.all( + existing + .filter( + (k) => + k.startsWith(`${SPACE_CACHE_PREFIX}${spaceRoute}-`) && + k !== cacheName, + ) + .map((k) => caches.delete(k)), + ); + + const cache = await caches.open(cacheName); + + // Build the URL list: shared assets + page HTMLs + embedded images. + const urls = new Set(); + for (const u of manifest.shared_assets || []) urls.add(u); + for (const page of manifest.pages) { + urls.add(`/${page.route}`); + for (const img of page.images || []) urls.add(img); + } + + const allUrls = Array.from(urls); + let done = 0; + const failed = []; + + // Fetch one at a time so we can report progress and don't hammer the server. + for (const url of allUrls) { + try { + const res = await fetch(url, { + credentials: 'same-origin', + redirect: 'follow', + }); + if (res.ok || res.type === 'opaqueredirect') { + await cache.put(url, res.clone()); + } else { + failed.push({ url, status: res.status }); + } + } catch (err) { + failed.push({ url, error: String(err) }); + } + done++; + if (client) { + client.postMessage({ + type: 'PRECACHE_PROGRESS', + spaceRoute, + done, + total: allUrls.length, + }); + } + } + + // Persist the manifest itself so we can detect updates later. + await cache.put( + `__manifest__/${spaceRoute}`, + new Response(JSON.stringify(manifest), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + + if (client) { + client.postMessage({ + type: 'PRECACHE_DONE', + spaceRoute, + version: manifest.version, + failed, + }); + } +} + +async function removeSpace(spaceRoute) { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => k.startsWith(`${SPACE_CACHE_PREFIX}${spaceRoute}-`)) + .map((k) => caches.delete(k)), + ); +} + +/** + * Returns the cached manifest for each space currently stored offline. + * The page UI uses this to render "downloaded / update available" state. + */ +async function listSpaces() { + const keys = await caches.keys(); + const spaceCaches = keys.filter((k) => k.startsWith(SPACE_CACHE_PREFIX)); + const out = []; + for (const name of spaceCaches) { + const cache = await caches.open(name); + // Find the manifest inside this cache. + const requests = await cache.keys(); + const manifestReq = requests.find((r) => r.url.includes('/__manifest__/')); + if (!manifestReq) continue; + const res = await cache.match(manifestReq); + if (!res) continue; + out.push(await res.json()); + } + return out; +} diff --git a/wiki/www/wiki-offline.html b/wiki/www/wiki-offline.html new file mode 100644 index 00000000..dd60b75d --- /dev/null +++ b/wiki/www/wiki-offline.html @@ -0,0 +1,168 @@ + + + + + + Offline Spaces — Frappe Wiki + + + + + + + + + +
+
+

Offline spaces

+

Manage the wiki spaces you've saved for offline reading.

+
+ + {# Not supported #} +
+ Your browser doesn't support offline reading. Try a recent version of Chrome, Edge, Firefox, or Safari. +
+ + {# Loading #} +
Loading…
+ + {# Empty state #} +
+

No spaces saved for offline reading yet.

+

Visit a wiki space and accept the install prompt to save it.

+
+ + {# List #} +
    + +
+ + +
+ + + +