mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-10-06 10:37:16 +00:00
Keep published geocaches available without the live bridge
This commit is contained in:
@@ -46,6 +46,7 @@ jobs:
|
||||
npm run build:geocaching
|
||||
node --test ../tests/site/geocaching-protocol.test.mjs
|
||||
node --test ../tests/site/geocaching-regions.test.mjs
|
||||
node --test ../tests/site/geocaching-snapshot.test.mjs
|
||||
- name: Overlay the Geocaching page and navigation
|
||||
run: |
|
||||
cp -a site/geocaching "$RUNNER_TEMP/publish/"
|
||||
|
||||
@@ -102,6 +102,7 @@ jobs:
|
||||
npm run build:components
|
||||
node build-geocaching.mjs
|
||||
node --test ../tests/site/geocaching-protocol.test.mjs
|
||||
node --test ../tests/site/geocaching-snapshot.test.mjs
|
||||
rm -rf node_modules
|
||||
|
||||
- name: Upload GitHub Pages artifact
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
!objects/
|
||||
!objects/*.bin
|
||||
@@ -0,0 +1,24 @@
|
||||
# Published public directory
|
||||
|
||||
The map loads this directory even when its live Reticulum bridge is offline.
|
||||
`index.json` includes the export time and current public summaries. Immutable
|
||||
signed objects are fetched on demand and verified against their summaries before
|
||||
displaying details or producing GPX. Archived heads are retained so archive
|
||||
filters and later exports preserve the publisher's state.
|
||||
|
||||
This is a dated copy, not a live global inventory. An unavailable bridge must not
|
||||
be presented as an empty live result. The worker can use live directories when
|
||||
available and falls back to the published copy when a live query fails.
|
||||
|
||||
To refresh from an operator's directory database:
|
||||
|
||||
```sh
|
||||
python tools/geocaching/export_public_directory.py --database /path/to/directory.sqlite --output site/geocaching/data/public
|
||||
```
|
||||
|
||||
Run `node --test tests/site/geocaching-snapshot.test.mjs` and publish the resulting
|
||||
files through the normal Pages deployment. The exporter opens SQLite read-only,
|
||||
verifies each signed object, excludes conflicting heads, and exports only public
|
||||
records. Never copy a service state directory, identity file or SQLite database
|
||||
into the website. Automatic collection and deployment still require an available
|
||||
directory source and an authorized scheduled deployment.
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"updatedAt":"2026-09-28T09:25:41.666848+00:00","summaries":["m8QgPjDvQphX74IMV5HHSMCxfnI5sftPiWd2GooXGm1BvoICxCB8R9Y20Voo4TFwaXef3IGHgER4nHqyrnLe17eagCz3LwIAALFBcmNoaXZlZCBsb2NhdGlvbgICAM0BBg==","m8QgkeI2uB8xiX5BTqpHoVUqvLkZp4OyZ5a5Ki04f7mGtMwBxCDNtFrWgLOpafU+MDYGnyH5SyBvyUFPg2VECHqL3ZrGkwDOBfXhANLSs1IAsFNhbiBKb3NlIEdhbGxlb24CAgDNAW0=","m8Qg31G0+16F7BrdNsYbLAahfe4UyiQcm7hsCuoKO3QeE5gCxCAJSCAIyc/6t1oylnbiFnoATIeWWlKSnwBnpyap3L70EwLOBfXhANLSs1IAsUFyY2hpdmVkIGxvY2F0aW9uAgIAzQEO"]}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -17,7 +17,7 @@ const labels = {
|
||||
show:tr('Show caches','显示藏宝点'),active:tr('Active','开放'),disabled:tr('Disabled','停用'),archived:tr('Archived','归档'),
|
||||
emptyCount:tr('Loaded 0 caches','已加载 0 个藏宝点'),start:tr('Preparing your map…','正在准备你的寻宝地图…'),
|
||||
more:tr('Load more','加载更多'),downloadSelected:tr('Download selected GPX','下载所选 GPX'),downloadPartial:tr('Download successful items only','仅下载成功的项目'),
|
||||
scope:tr('Results cover the directories reached in this session, not a global total.','结果来自本次连接到的目录,数量仅指已加载内容,不代表全网总数。'),
|
||||
scope:tr('Results cover published copies or live directories, not a global total.','结果来自已同步副本或实时目录,数量不代表全网总数。'),
|
||||
search:tr('Search this area','搜索此区域'),download:tr('Download GPX','下载 GPX'),
|
||||
region:tr('Where to explore','探索范围'),regionKind:tr('Search by','按范围查找'),
|
||||
mapRegion:tr('Current map area','当前地图区域'),countries:tr('Country / region','国家/地区'),seas:tr('Ocean / sea','海洋/海域'),
|
||||
@@ -28,6 +28,7 @@ for (const node of document.querySelectorAll('[data-label]')) node.textContent =
|
||||
const $ = id => document.getElementById(id);
|
||||
const worker = new Worker(new URL('./reticulum-worker.js', import.meta.url), {type:'module'});
|
||||
let nextId = 0, ready = false, rows = [], detailId = null, detailGeneration = 0, partial = null;
|
||||
let snapshotAt = null;
|
||||
const pending = new Map(), selected = new Set();
|
||||
const map = L.map('map', {worldCopyJump:true, minZoom:0}).fitWorld();
|
||||
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom:19, updateWhenIdle:true,
|
||||
@@ -153,7 +154,7 @@ function renderMarkers() {
|
||||
for (const cluster of cells.values()) {
|
||||
const {row,lat,lon} = cluster[0], multi = cluster.length > 1;
|
||||
const content = document.createElement('span'), image = document.createElement('img');
|
||||
image.src = cacheIconUrl; image.alt = ''; image.width = image.height = 32;
|
||||
image.src = new URL(cacheIconUrl, import.meta.url).href; image.alt = ''; image.width = image.height = 32;
|
||||
content.append(image);
|
||||
if (multi) {
|
||||
const count = document.createElement('span');
|
||||
@@ -176,13 +177,14 @@ async function openDetail(row) {
|
||||
try {
|
||||
const item = await rpc('get',{cacheId:row.id});
|
||||
if (generation !== detailGeneration) return;
|
||||
$('detail-state').textContent = item.isCurrent ? tr('AUTHOR SIGNATURE VERIFIED','作者签名已验证') : tr('VERIFIED HISTORICAL VERSION','已验证的历史版本');
|
||||
$('detail-state').textContent = item.snapshotAt ? tr('VERIFIED PUBLISHED COPY','已验证的公开副本') : item.isCurrent ? tr('AUTHOR SIGNATURE VERIFIED','作者签名已验证') : tr('VERIFIED HISTORICAL VERSION','已验证的历史版本');
|
||||
const description = document.createElement('p'); description.textContent = item.record[9];
|
||||
const hint = document.createElement('details'), summary = document.createElement('summary'), text = document.createElement('p');
|
||||
summary.textContent = tr('Show hint','展开提示'); text.textContent = item.record[10] || tr('No hint provided.','未提供提示。'); hint.append(summary,text);
|
||||
const metadata = document.createElement('p'); metadata.className = 'fingerprint';
|
||||
metadata.textContent = `${tr('Author','作者')}: ${item.authorHash}\n${tr('Version','版本')}: ${item.record[3]}\n` +
|
||||
`${tr('Verified at','验证时间')}: ${new Date(item.checkedAt).toLocaleString()}\n${item.sourceName}`;
|
||||
`${tr('Verified at','验证时间')}: ${new Date(item.checkedAt).toLocaleString()}\n${item.sourceName}` +
|
||||
(item.snapshotAt ? `\n${tr('Directory synced','目录同步时间')}: ${new Date(item.snapshotAt).toLocaleString()}` : '');
|
||||
$('detail-content').append(description,hint,metadata); $('download-one').disabled = false;
|
||||
} catch (error) { if (generation === detailGeneration) $('detail-state').textContent = error.message; }
|
||||
}
|
||||
@@ -216,7 +218,17 @@ worker.onmessage = ({data}) => {
|
||||
return;
|
||||
}
|
||||
const event=data.event;
|
||||
if (event.type==='status') {
|
||||
if (event.type==='snapshot') {
|
||||
snapshotAt = event.updatedAt; ready=true;
|
||||
$('search').disabled=false; $('region-apply').disabled=false;
|
||||
$('connection-state').textContent=tr('Published directory','已同步目录');
|
||||
$('directory-status').textContent=tr('Directory synced: ','目录同步时间:')+new Date(snapshotAt).toLocaleString();
|
||||
initialLocation.finally(()=>{if(ready && querySerial===0)search();});
|
||||
} else if (event.type==='status') {
|
||||
if (snapshotAt) {
|
||||
$('connection-state').textContent=tr('Published directory','已同步目录');
|
||||
return;
|
||||
}
|
||||
$('connection-state').textContent = event.state==='disconnected'?tr('Disconnected','连接已断开'):tr('Discovering directories…','正在发现目录…');
|
||||
if (event.state==='disconnected') { ready=false; $('search').disabled=true; $('region-apply').disabled=true; $('connection-state').classList.remove('ready'); updateSelection(); }
|
||||
} else if (event.type==='directory') {
|
||||
@@ -229,11 +241,14 @@ worker.onmessage = ({data}) => {
|
||||
rows=event.rows; $('more').hidden=!event.more; renderRows();
|
||||
notice(event.limited?tr('500-item display limit reached. Narrow the area to explore more.','已达到 500 项显示上限,请缩小区域继续探索。'):
|
||||
!rows.length && event.more?tr('No matches in these pages yet. Load more to continue searching this region.','当前批次暂无匹配点,可加载更多继续检索此区域。'):
|
||||
event.snapshotAt ? tr('Showing the published directory. Last synced: ','正在显示已同步目录,最后同步:')+new Date(event.snapshotAt).toLocaleString() :
|
||||
tr('Loaded from live directory responses. Open a cache to verify its details.','已加载目录实时响应。打开藏宝点以验证完整详情。'));
|
||||
} else if (event.type==='source-error') notice(`${event.name}: ${event.message}`);
|
||||
};
|
||||
worker.onerror = () => { notice(tr('The map service stopped. Refresh the page to try again.','地图服务已停止,请刷新页面重试。')); ready=false; $('search').disabled=true; $('region-apply').disabled=true; };
|
||||
async function startService() {
|
||||
try { await rpc('snapshot', {url:new URL('../data/public/index.json', import.meta.url).href}); }
|
||||
catch { /* Live discovery can still work when no published copy is available. */ }
|
||||
try {
|
||||
const response = await fetch(new URL('../network.json', import.meta.url), {cache:'no-store'});
|
||||
if (!response.ok) throw Error('Missing deployment configuration');
|
||||
@@ -242,6 +257,11 @@ async function startService() {
|
||||
if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname))) throw Error('Invalid deployment endpoint');
|
||||
await rpc('connect',{url:url.href,discoverySeeds:config.discoverySeeds});
|
||||
} catch {
|
||||
if (snapshotAt) {
|
||||
$('connection-state').textContent=tr('Published directory','已同步目录');
|
||||
notice(tr('Live updates unavailable. Published caches, details and GPX remain accessible.','实时更新暂不可用,仍可查看已同步藏宝点、详情并下载 GPX。'));
|
||||
return;
|
||||
}
|
||||
$('connection-state').textContent = tr('Service unavailable','服务暂不可用');
|
||||
$('directory-status').textContent = tr('Please try again later.','请稍后再试。');
|
||||
notice(tr('The map service is temporarily unavailable. No setup is needed on your side.','寻宝地图服务暂时不可用,你无需进行任何网络设置。'));
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
import {DirectoryClient} from './protocol-client.js';
|
||||
import {makeGpx} from './gpx-download.js';
|
||||
import {SnapshotClient} from './snapshot-client.js';
|
||||
|
||||
let client = null;
|
||||
let client = null, snapshot = null, liveReady = false, selected = null;
|
||||
const notify = event => self.postMessage({event});
|
||||
self.onmessage = async ({data}) => {
|
||||
const {id, command, args = {}} = data;
|
||||
try {
|
||||
let result;
|
||||
if (command === 'connect') {
|
||||
if (command === 'snapshot') {
|
||||
const candidate = new SnapshotClient(notify);
|
||||
result = await candidate.load(args.url);
|
||||
snapshot = candidate;
|
||||
notify({type:'snapshot', updatedAt:result});
|
||||
} else if (command === 'connect') {
|
||||
await client?.close();
|
||||
client = new DirectoryClient(event => self.postMessage({event}));
|
||||
liveReady = false;
|
||||
client = new DirectoryClient(event => {
|
||||
if (event.type === 'directory') liveReady = true;
|
||||
if (event.type === 'status' && event.state === 'disconnected') liveReady = false;
|
||||
notify(event);
|
||||
});
|
||||
await client.connect(args.url, null, args.discoverySeeds);
|
||||
} else if (command === 'disconnect') {
|
||||
await client?.close(); client = null;
|
||||
await client?.close(); client = null; liveReady = false;
|
||||
} else {
|
||||
if (!client) throw Error('Connect to Reticulum first');
|
||||
if (command === 'query') await client.query(args.bounds, args.stateMask, args.region, args.queryToken);
|
||||
else if (command === 'more') await client.more();
|
||||
else if (command === 'get') result = await client.get(args.cacheId);
|
||||
if (!client && !snapshot) throw Error('Directory unavailable');
|
||||
if (command === 'query') {
|
||||
selected = liveReady ? client : snapshot;
|
||||
if (!selected) throw Error('Still discovering public directories');
|
||||
try { await selected.query(args.bounds, args.stateMask, args.region, args.queryToken); }
|
||||
catch (error) {
|
||||
if (!snapshot || selected === snapshot) throw error;
|
||||
selected = snapshot;
|
||||
await selected.query(args.bounds, args.stateMask, args.region, args.queryToken);
|
||||
}
|
||||
}
|
||||
else if (command === 'more') { if (selected === client) await client.more(); }
|
||||
else if (command === 'get') result = await selected.get(args.cacheId);
|
||||
else if (command === 'download') {
|
||||
if (!Array.isArray(args.cacheIds) || !args.cacheIds.length || args.cacheIds.length > 20) throw Error('Select 1–20 caches');
|
||||
const records = [], failures = [];
|
||||
const records = [], failures = [], provider = selected;
|
||||
for (const cacheId of args.cacheIds) {
|
||||
try { records.push((await client.get(cacheId)).signed); }
|
||||
try { records.push((await provider.get(cacheId)).signed); }
|
||||
catch (error) { failures.push({cacheId, message: error.message}); }
|
||||
}
|
||||
result = {gpx: records.length ? await makeGpx(records) : null, succeeded: records.length, failures};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {decode, validateSummary, verifySigned, viewportBoxes} from './protocol.js';
|
||||
import {countryContains} from './country-boundaries.js';
|
||||
|
||||
const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Public summaries load independently of WSS. Full objects remain immutable,
|
||||
// are fetched on demand, and use the same author verification as live replies.
|
||||
export class SnapshotClient {
|
||||
constructor(notify, fetcher = (...args) => fetch(...args)) { this.notify = notify; this.fetcher = fetcher; this.rows = new Map(); }
|
||||
|
||||
async load(url) {
|
||||
this.base = new URL('.', url);
|
||||
const response = await this.fetcher(url, {cache:'no-cache', signal:AbortSignal.timeout(15000)});
|
||||
if (!response.ok) throw Error('Published directory unavailable');
|
||||
const text = await response.text();
|
||||
if (text.length > 16 * 1024 * 1024) throw Error('Published directory too large');
|
||||
const data = JSON.parse(text);
|
||||
if (data.version !== 1 || !Number.isFinite(Date.parse(data.updatedAt)) || !Array.isArray(data.summaries) || data.summaries.length > 20000) throw Error('Invalid published directory');
|
||||
const rows = new Map();
|
||||
for (const encoded of data.summaries) {
|
||||
if (typeof encoded !== 'string' || encoded.length > 1024) throw Error('Invalid published summary');
|
||||
const summary = validateSummary(decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0))));
|
||||
const id = hex(summary[0]);
|
||||
if (rows.has(id)) throw Error('Duplicate published cache');
|
||||
rows.set(id, {id, summary, conflict:false, sourceName:'Published directory'});
|
||||
}
|
||||
this.rows = rows; this.updatedAt = data.updatedAt;
|
||||
return this.updatedAt;
|
||||
}
|
||||
|
||||
query(bounds, stateMask, region, queryToken) {
|
||||
const boxes = viewportBoxes(bounds), found = [];
|
||||
for (const row of this.rows.values()) {
|
||||
const s = row.summary;
|
||||
if (!(stateMask & (1 << s[3])) || !boxes.some(([south,west,north,east]) => s[4] >= south && s[4] <= north && s[5] >= west && s[5] <= east)) continue;
|
||||
if (region && !countryContains(region, s[4]/1e7, s[5]/1e7)) continue;
|
||||
found.push(row);
|
||||
if (found.length > 500) break;
|
||||
}
|
||||
this.notify({type:'results', queryToken, rows:found.slice(0,500), more:false, limited:found.length > 500, snapshotAt:this.updatedAt});
|
||||
}
|
||||
|
||||
async get(id) {
|
||||
const row = this.rows.get(id);
|
||||
if (!row) throw Error('Cache not present in published directory');
|
||||
const response = await this.fetcher(new URL(`objects/${hex(row.summary[2])}.bin`, this.base), {signal:AbortSignal.timeout(15000)});
|
||||
if (!response.ok) throw Error('Published details unavailable');
|
||||
const raw = new Uint8Array(await response.arrayBuffer());
|
||||
const verified = await verifySigned(decode(raw, 8192), row.summary);
|
||||
return {...verified, isCurrent:false, snapshotAt:this.updatedAt, checkedAt:Date.now(), sourceName:'Published directory'};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {test} from 'node:test';
|
||||
import {SnapshotClient} from '../../site/geocaching/src/snapshot-client.js';
|
||||
import {decode, encode, verifySigned} from '../../site/geocaching/src/protocol.js';
|
||||
import {DirectoryClient} from '../../site/geocaching/src/protocol-client.js';
|
||||
|
||||
const bytes = await readFile(new URL('../../modules/core_geocaching/tests/fixtures/signed-cache-v1.bin', import.meta.url));
|
||||
const verified = await verifySigned(decode(bytes));
|
||||
const manifest = {version:1, updatedAt:'2026-09-28T00:00:00Z', summaries:[Buffer.from(encode(verified.summary)).toString('base64')]};
|
||||
const fetcher = async url => new Response(String(url).endsWith('index.json') ? JSON.stringify(manifest) : bytes);
|
||||
|
||||
test('published directory filters locally and verifies full signed details', async () => {
|
||||
let event;
|
||||
const client = new SnapshotClient(value => event=value, fetcher);
|
||||
await client.load('https://example.org/geocaching/data/public/index.json');
|
||||
client.query([-90,-180,90,180],1,null,7);
|
||||
assert.equal(event.rows.length,1);
|
||||
assert.equal(event.queryToken,7);
|
||||
assert.equal(event.snapshotAt,manifest.updatedAt);
|
||||
const detail = await client.get(verified.cacheId);
|
||||
assert.equal(detail.record[9],verified.record[9]);
|
||||
assert.equal(detail.isCurrent,false);
|
||||
client.query([-90,-180,90,180],4,null,8);
|
||||
assert.equal(event.rows.length,0);
|
||||
client.query([-90,-180,90,180],1,{type:'Feature',geometry:{type:'Polygon',coordinates:[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}},9);
|
||||
assert.equal(event.rows.length,0);
|
||||
const damaged=Uint8Array.from(bytes); damaged[damaged.length-1]^=1;
|
||||
client.fetcher=async()=>new Response(damaged);
|
||||
await assert.rejects(client.get(verified.cacheId),/signature/);
|
||||
});
|
||||
|
||||
test('worker keeps query, details and GPX available when WSS cannot connect', async () => {
|
||||
const events=[], originalFetch=globalThis.fetch, originalConnect=DirectoryClient.prototype.connect;
|
||||
globalThis.self={postMessage:value=>events.push(value)};
|
||||
globalThis.fetch=fetcher;
|
||||
DirectoryClient.prototype.connect=async()=>{throw Error('Bridge offline');};
|
||||
try {
|
||||
await import('../../site/geocaching/src/reticulum-worker.js');
|
||||
await self.onmessage({data:{id:1,command:'snapshot',args:{url:'https://example.org/data/public/index.json'}}});
|
||||
await self.onmessage({data:{id:2,command:'connect',args:{url:'wss://offline.example.org'}}});
|
||||
assert.equal(events.find(value=>value.id===2).error,'Bridge offline');
|
||||
await self.onmessage({data:{id:3,command:'query',args:{bounds:[-90,-180,90,180],stateMask:1,queryToken:1}}});
|
||||
assert.equal(events.find(value=>value.event?.type==='results').event.rows.length,1);
|
||||
await self.onmessage({data:{id:4,command:'get',args:{cacheId:verified.cacheId}}});
|
||||
assert.equal(events.find(value=>value.id===4).result.record[9],verified.record[9]);
|
||||
await self.onmessage({data:{id:5,command:'download',args:{cacheIds:[verified.cacheId]}}});
|
||||
const download=events.find(value=>value.id===5).result;
|
||||
assert.equal(download.succeeded,1); assert.equal(download.failures.length,0);
|
||||
assert.match(download.gpx,/<gpx/);
|
||||
} finally {
|
||||
globalThis.fetch=originalFetch; DirectoryClient.prototype.connect=originalConnect;
|
||||
delete globalThis.self;
|
||||
}
|
||||
});
|
||||
|
||||
test('published San Jose record remains readable and verifies against its summary', async () => {
|
||||
const base=new URL('../../site/geocaching/data/public/',import.meta.url);
|
||||
const fetchFiles=async url=>new Response(await readFile(new URL(url)));
|
||||
let event;
|
||||
const client=new SnapshotClient(value=>event=value,fetchFiles);
|
||||
await client.load(new URL('index.json',base));
|
||||
client.query([-90,-180,90,180],1,null,1);
|
||||
const row=event.rows.find(row=>row.summary[6]==='San Jose Galleon');
|
||||
assert.ok(row);
|
||||
const detail=await client.get(row.id);
|
||||
assert.ok(detail.record[9].length>20);
|
||||
assert.equal(detail.cacheId,row.id);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Export current public signed records for GitHub Pages, without private state."""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from directory_store import packed, verify_cache
|
||||
|
||||
|
||||
def export(database, output):
|
||||
output = Path(output)
|
||||
objects = output / "objects"
|
||||
objects.mkdir(parents=True, exist_ok=True)
|
||||
summaries = []
|
||||
with sqlite3.connect(Path(database).resolve().as_uri() + "?mode=ro", uri=True) as db:
|
||||
db.execute("BEGIN")
|
||||
rows = db.execute("""SELECT o.cache, o.hash, o.raw, o.signature FROM heads h
|
||||
JOIN objects o ON o.hash=h.hash JOIN public_refs p ON p.hash=o.hash
|
||||
WHERE h.conflict=0 ORDER BY o.cache""")
|
||||
for cache_id, revision_hash, raw, signature in rows:
|
||||
verified = verify_cache([raw, signature])
|
||||
if verified.cache_id != cache_id or verified.revision_hash != revision_hash:
|
||||
raise ValueError("Stored object identity mismatch")
|
||||
(objects / f"{revision_hash.hex()}.bin").write_bytes(packed([raw, signature]))
|
||||
summaries.append(base64.b64encode(packed(verified.summary)).decode("ascii"))
|
||||
if len(summaries) > 20000:
|
||||
raise ValueError("Published directory requires partitioning above 20000 caches")
|
||||
data = {"version": 1, "updatedAt": datetime.now(timezone.utc).isoformat(), "summaries": summaries}
|
||||
temporary = output / "index.json.tmp"
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=True, separators=(",", ":")) + "\n", encoding="utf-8")
|
||||
temporary.replace(output / "index.json")
|
||||
return len(summaries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
print(f"Exported {export(args.database, args.output)} public caches")
|
||||
Reference in New Issue
Block a user