feat(v3): add v3 stack — backend rewrite, renamed env vars, docs
- New Go backend binary (backend + runner) replacing old scraper/ - Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose - Rename scraperFetch → backendFetch across all 19 UI server files - Remove SCRAPER_PROXY env var and proxy transport from browser.Config - Add Meilisearch, Valkey, Caddy to docker-compose - Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
This commit is contained in:
423
v3/scripts/e2e-test.mjs
Normal file
423
v3/scripts/e2e-test.mjs
Normal file
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* e2e-test.mjs — End-to-end tests for the LibNovel v3 stack.
|
||||
*
|
||||
* Hits live services via https://localhost (self-signed cert, TLS verify skipped).
|
||||
* Requires: Node 18+ (built-in fetch with TLS options via --experimental-fetch or
|
||||
* native in Node 21+). Run with: node --experimental-vm-modules scripts/e2e-test.mjs
|
||||
* or simply: node scripts/e2e-test.mjs
|
||||
*
|
||||
* Services tested:
|
||||
* - Caddy / UI https://localhost
|
||||
* - Go backend via UI proxy routes
|
||||
* - PocketBase via UI server-side (indirect)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/e2e-test.mjs
|
||||
* node scripts/e2e-test.mjs --verbose
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:https';
|
||||
import { request as httpRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
const BASE = 'https://localhost';
|
||||
const VERBOSE = process.argv.includes('--verbose');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
function log(...args) {
|
||||
if (VERBOSE) console.log(...args);
|
||||
}
|
||||
|
||||
function pass(name) {
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, reason) {
|
||||
failed++;
|
||||
const msg = ` ✗ ${name}: ${reason}`;
|
||||
console.log(msg);
|
||||
failures.push({ name, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch() that ignores TLS certificate errors (self-signed cert on localhost).
|
||||
*/
|
||||
async function get(path, { headers = {}, followRedirects = false } = {}) {
|
||||
const url = path.startsWith('http') ? path : `${BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
redirect: followRedirects ? 'follow' : 'manual',
|
||||
headers,
|
||||
// Node 18/19 uses undici which respects NODE_TLS_REJECT_UNAUTHORIZED
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
async function post(path, body, { headers = {}, cookie = '' } = {}) {
|
||||
const url = path.startsWith('http') ? path : `${BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
async function del(path, { cookie = '' } = {}) {
|
||||
const url = path.startsWith('http') ? path : `${BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
redirect: 'manual',
|
||||
headers: cookie ? { Cookie: cookie } : {},
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Extract Set-Cookie header value(s) as a single cookie string. */
|
||||
function extractCookies(res) {
|
||||
const raw = res.headers.getSetCookie?.() ?? [];
|
||||
return raw.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function assert(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
pass(name);
|
||||
} catch (e) {
|
||||
fail(name, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function expect(val, label) {
|
||||
return {
|
||||
toBe(expected) {
|
||||
if (val !== expected) throw new Error(`${label}: expected ${expected}, got ${val}`);
|
||||
},
|
||||
toBeOneOf(...options) {
|
||||
if (!options.includes(val)) throw new Error(`${label}: expected one of [${options.join(', ')}], got ${val}`);
|
||||
},
|
||||
toBeOk() {
|
||||
if (!val) throw new Error(`${label} was falsy`);
|
||||
},
|
||||
toContainKey(key) {
|
||||
if (!(key in val)) throw new Error(`${label}: missing key "${key}"`);
|
||||
},
|
||||
toBeArray() {
|
||||
if (!Array.isArray(val)) throw new Error(`${label}: expected array, got ${typeof val}`);
|
||||
},
|
||||
toBeAbove(n) {
|
||||
if (!(val > n)) throw new Error(`${label}: expected > ${n}, got ${val}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
// Pick a known book slug from the database (first available)
|
||||
let TEST_SLUG = null;
|
||||
|
||||
console.log('\nLibNovel v3 — End-to-End Tests');
|
||||
console.log('================================\n');
|
||||
|
||||
// ── 1. Health checks ──────────────────────────────────────────────────────────
|
||||
console.log('1. Health checks');
|
||||
|
||||
await assert('UI health endpoint returns 200', async () => {
|
||||
const res = await get('/health');
|
||||
expect(res.status, 'status').toBe(200);
|
||||
});
|
||||
|
||||
await assert('Home page returns 200', async () => {
|
||||
const res = await get('/', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html.includes('<html') || html.includes('<!DOCTYPE'), 'has html').toBe(true);
|
||||
});
|
||||
|
||||
await assert('Browse page returns 200', async () => {
|
||||
const res = await get('/browse', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
});
|
||||
|
||||
// ── 2. Home API ───────────────────────────────────────────────────────────────
|
||||
console.log('\n2. Home API');
|
||||
|
||||
let homeData = null;
|
||||
|
||||
await assert('GET /api/home returns continue_reading and recently_updated', async () => {
|
||||
const res = await get('/api/home', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
homeData = await res.json();
|
||||
expect(homeData, 'data').toContainKey('continue_reading');
|
||||
expect(homeData, 'data').toContainKey('recently_updated');
|
||||
expect(homeData.continue_reading, 'continue_reading').toBeArray();
|
||||
expect(homeData.recently_updated, 'recently_updated').toBeArray();
|
||||
});
|
||||
|
||||
await assert('GET /api/home stats has totalBooks and totalChapters', async () => {
|
||||
if (!homeData) {
|
||||
const res = await get('/api/home', { followRedirects: true });
|
||||
homeData = await res.json();
|
||||
}
|
||||
expect(homeData, 'data').toContainKey('stats');
|
||||
expect(typeof homeData.stats.totalBooks, 'totalBooks type').toBe('number');
|
||||
expect(typeof homeData.stats.totalChapters, 'totalChapters type').toBe('number');
|
||||
});
|
||||
|
||||
// ── 3. Browse / ranking / search ──────────────────────────────────────────────
|
||||
console.log('\n3. Browse / ranking / search');
|
||||
|
||||
await assert('GET /api/browse-page returns novels array', async () => {
|
||||
const res = await get('/api/browse-page?page=1', { followRedirects: true });
|
||||
expect(res.status, 'status').toBeOneOf(200, 503);
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
// Accepts { novels: [...] } or { error: ... } (when MinIO cache is empty)
|
||||
expect(typeof data, 'response type').toBe('object');
|
||||
}
|
||||
});
|
||||
|
||||
await assert('GET /api/ranking returns array or 502 (no data yet)', async () => {
|
||||
const res = await get('/api/ranking', { followRedirects: true });
|
||||
// 200 = ranking data exists; 502 = no ranking data scraped yet — both are expected
|
||||
expect(res.status, 'status').toBeOneOf(200, 502);
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
expect(data, 'data').toBeArray();
|
||||
}
|
||||
});
|
||||
|
||||
await assert('GET /api/search?q=shadow returns results object', async () => {
|
||||
const res = await get('/api/search?q=shadow', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(typeof data, 'response type').toBe('object');
|
||||
});
|
||||
|
||||
// ── 4. Books ──────────────────────────────────────────────────────────────────
|
||||
console.log('\n4. Books');
|
||||
|
||||
// Find a real slug from /api/home
|
||||
await assert('GET /api/home books are accessible', async () => {
|
||||
if (!homeData) {
|
||||
const res = await get('/api/home', { followRedirects: true });
|
||||
homeData = await res.json();
|
||||
}
|
||||
const books = homeData?.recently_updated ?? [];
|
||||
if (books.length > 0) {
|
||||
TEST_SLUG = books[0].slug;
|
||||
log(` Using test slug: ${TEST_SLUG}`);
|
||||
}
|
||||
// Pass regardless — we just want to find a slug
|
||||
});
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`GET /api/book/${TEST_SLUG} returns book metadata`, async () => {
|
||||
const res = await get(`/api/book/${TEST_SLUG}`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { book: { slug, ... }, chapters: [...] }
|
||||
expect(data, 'data').toContainKey('book');
|
||||
expect(data.book, 'book').toContainKey('slug');
|
||||
expect(data.book.slug, 'slug').toBe(TEST_SLUG);
|
||||
});
|
||||
|
||||
await assert(`Book detail page /${TEST_SLUG} returns 200`, async () => {
|
||||
const res = await get(`/${TEST_SLUG}`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠ No books in database — skipping book-specific tests');
|
||||
}
|
||||
|
||||
// ── 5. Voices ─────────────────────────────────────────────────────────────────
|
||||
console.log('\n5. Voices');
|
||||
|
||||
await assert('GET /api/voices returns voices array', async () => {
|
||||
const res = await get('/api/voices', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { voices: [...] }
|
||||
expect(data, 'data').toContainKey('voices');
|
||||
expect(data.voices, 'voices').toBeArray();
|
||||
expect(data.voices.length, 'voice count').toBeAbove(0);
|
||||
});
|
||||
|
||||
// ── 6. Auth flow ──────────────────────────────────────────────────────────────
|
||||
console.log('\n6. Auth flow');
|
||||
|
||||
const TEST_USER = `e2e_test_${Date.now()}`;
|
||||
const TEST_PASS = 'E2eTestPassword1!';
|
||||
let authCookie = '';
|
||||
|
||||
await assert('POST /api/auth/register creates new user', async () => {
|
||||
const res = await post('/api/auth/register', { username: TEST_USER, password: TEST_PASS });
|
||||
expect(res.status, 'status').toBeOneOf(200, 201);
|
||||
const data = await res.json();
|
||||
// Returns { token: "...", user: { id, username, role } }
|
||||
expect(data, 'response').toContainKey('user');
|
||||
expect(data.user, 'user').toContainKey('username');
|
||||
expect(data.user.username, 'username').toBe(TEST_USER);
|
||||
// Build cookie from token
|
||||
if (data.token) {
|
||||
authCookie = `libnovel_auth=${data.token}`;
|
||||
} else {
|
||||
authCookie = extractCookies(res);
|
||||
}
|
||||
log(` Auth cookie: ${authCookie.slice(0, 40)}...`);
|
||||
});
|
||||
|
||||
await assert('GET /api/auth/me returns current user when logged in', async () => {
|
||||
const res = await get('/api/auth/me', { followRedirects: true, headers: { Cookie: authCookie } });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data, 'data').toContainKey('username');
|
||||
expect(data.username, 'username').toBe(TEST_USER);
|
||||
});
|
||||
|
||||
await assert('POST /api/auth/logout clears session', async () => {
|
||||
const res = await post('/api/auth/logout', {}, { cookie: authCookie });
|
||||
expect(res.status, 'status').toBeOneOf(200, 204);
|
||||
});
|
||||
|
||||
await assert('POST /api/auth/login works after register', async () => {
|
||||
const res = await post('/api/auth/login', { username: TEST_USER, password: TEST_PASS });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { token: "...", user: { id, username, role } }
|
||||
expect(data, 'response').toContainKey('user');
|
||||
expect(data.user.username, 'username').toBe(TEST_USER);
|
||||
if (data.token) {
|
||||
authCookie = `libnovel_auth=${data.token}`;
|
||||
} else {
|
||||
authCookie = extractCookies(res);
|
||||
}
|
||||
});
|
||||
|
||||
await assert('GET /api/auth/me unauthenticated returns 401', async () => {
|
||||
const res = await get('/api/auth/me', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(401);
|
||||
});
|
||||
|
||||
// ── 7. Progress ───────────────────────────────────────────────────────────────
|
||||
console.log('\n7. Progress');
|
||||
|
||||
// /api/progress (root) is POST-only. Per-slug is GET/POST/DELETE via /api/progress/[slug].
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`POST /api/progress/${TEST_SLUG} sets progress`, async () => {
|
||||
const res = await post(`/api/progress/${TEST_SLUG}`, { chapter: 1 });
|
||||
expect(res.status, 'status').toBeOneOf(200, 201);
|
||||
const data = await res.json();
|
||||
expect(data, 'data').toContainKey('ok');
|
||||
});
|
||||
|
||||
await assert(`DELETE /api/progress/${TEST_SLUG} removes progress`, async () => {
|
||||
const res = await del(`/api/progress/${TEST_SLUG}`);
|
||||
expect(res.status, 'status').toBeOneOf(200, 204);
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠ No books — skipping progress tests');
|
||||
}
|
||||
|
||||
// ── 8. Library ────────────────────────────────────────────────────────────────
|
||||
console.log('\n8. Library');
|
||||
|
||||
await assert('GET /api/library returns object', async () => {
|
||||
const res = await get('/api/library', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(typeof data, 'data type').toBe('object');
|
||||
});
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`POST /api/library/${TEST_SLUG} saves book`, async () => {
|
||||
const res = await post(`/api/library/${TEST_SLUG}`, {});
|
||||
expect(res.status, 'status').toBeOneOf(200, 201);
|
||||
});
|
||||
|
||||
await assert(`DELETE /api/library/${TEST_SLUG} removes book`, async () => {
|
||||
const res = await del(`/api/library/${TEST_SLUG}`);
|
||||
expect(res.status, 'status').toBeOneOf(200, 204);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Settings ───────────────────────────────────────────────────────────────
|
||||
console.log('\n9. Settings');
|
||||
|
||||
await assert('GET /api/settings returns settings object', async () => {
|
||||
const res = await get('/api/settings', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(typeof data, 'data type').toBe('object');
|
||||
});
|
||||
|
||||
// ── 10. Sessions ──────────────────────────────────────────────────────────────
|
||||
console.log('\n10. Sessions');
|
||||
|
||||
await assert('GET /api/sessions (authenticated) returns sessions array', async () => {
|
||||
const res = await get('/api/sessions', { followRedirects: true, headers: { Cookie: authCookie } });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { sessions: [...] }
|
||||
expect(data, 'data').toContainKey('sessions');
|
||||
expect(data.sessions, 'sessions').toBeArray();
|
||||
});
|
||||
|
||||
// ── 11. Comments ──────────────────────────────────────────────────────────────
|
||||
console.log('\n11. Comments');
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`GET /api/comments/${TEST_SLUG} returns comments`, async () => {
|
||||
const res = await get(`/api/comments/${TEST_SLUG}`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { comments: [...], myVotes: {}, avatarUrls: {} }
|
||||
expect(data, 'data').toContainKey('comments');
|
||||
expect(data.comments, 'comments').toBeArray();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12. Chapter endpoints ─────────────────────────────────────────────────────
|
||||
console.log('\n12. Chapter endpoints');
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`GET /api/chapter/${TEST_SLUG}/1 returns 200 or 404`, async () => {
|
||||
const res = await get(`/api/chapter/${TEST_SLUG}/1`, { followRedirects: true });
|
||||
// 200 if chapter exists in MinIO, 404 if not
|
||||
expect(res.status, 'status').toBeOneOf(200, 404);
|
||||
});
|
||||
|
||||
await assert(`GET /api/chapter-text-preview/${TEST_SLUG}/1 returns 200 or error`, async () => {
|
||||
const res = await get(`/api/chapter-text-preview/${TEST_SLUG}/1`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBeOneOf(200, 404, 500, 503);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
console.log('\n─────────────────────────────────────');
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('\nFailures:');
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name}`);
|
||||
console.log(` ${f.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('─────────────────────────────────────\n');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
250
v3/scripts/pb-init-v3.sh
Executable file
250
v3/scripts/pb-init-v3.sh
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/bin/sh
|
||||
# pb-init-v3.sh — idempotent PocketBase bootstrap for the v3 stack.
|
||||
#
|
||||
# Safe to re-run: existing collections and fields are silently skipped.
|
||||
#
|
||||
# Env vars (defaults match docker-compose.yml):
|
||||
# POCKETBASE_URL http://pocketbase:8090
|
||||
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
||||
# POCKETBASE_ADMIN_PASSWORD changeme123
|
||||
|
||||
set -e
|
||||
|
||||
PB="${POCKETBASE_URL:-http://pocketbase:8090}"
|
||||
EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PASS="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
|
||||
log() { printf '[pb-init] %s\n' "$*"; }
|
||||
|
||||
# ── 0. Ensure dependencies ────────────────────────────────────────────────────
|
||||
command -v curl > /dev/null 2>&1 || apk add --no-cache curl > /dev/null 2>&1
|
||||
command -v python3 > /dev/null 2>&1 || apk add --no-cache python3 > /dev/null 2>&1
|
||||
|
||||
# ── 1. Wait for PocketBase ────────────────────────────────────────────────────
|
||||
log "waiting for PocketBase..."
|
||||
until curl -sf "$PB/api/health" > /dev/null 2>&1; do sleep 2; done
|
||||
log "PocketBase ready"
|
||||
|
||||
# ── 2. Bootstrap superuser (first-run only) ───────────────────────────────────
|
||||
LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB/_/" 2>/dev/null || true)
|
||||
if echo "$LOCATION" | grep -q "pbinstal/"; then
|
||||
TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n')
|
||||
curl -sf -X POST "$PB/api/collections/_superusers/records" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"passwordConfirm\":\"$PASS\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
log "superuser created"
|
||||
fi
|
||||
|
||||
# ── 3. Authenticate ───────────────────────────────────────────────────────────
|
||||
AUTH=$(curl -sf -X POST "$PB/api/collections/_superusers/auth-with-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"identity\":\"$EMAIL\",\"password\":\"$PASS\"}")
|
||||
TOK=$(echo "$AUTH" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
[ -z "$TOK" ] || [ "$TOK" = "$AUTH" ] && { log "ERROR: auth failed"; exit 1; }
|
||||
log "authenticated"
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# create NAME BODY — POST collection; 400/422 = already exists, treated as ok.
|
||||
create() {
|
||||
NAME="$1"; BODY="$2"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "$PB/api/collections" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOK" \
|
||||
-d "$BODY")
|
||||
case "$STATUS" in
|
||||
200|201) log "created: $NAME" ;;
|
||||
400|422) log "exists (skip): $NAME" ;;
|
||||
*) log "WARNING: $NAME returned $STATUS" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# add_field COLLECTION FIELD_NAME FIELD_TYPE
|
||||
# Fetches current schema, appends field if absent, PATCHes collection.
|
||||
# Requires python3 for safe JSON manipulation.
|
||||
add_field() {
|
||||
COLL="$1"; FIELD="$2"; TYPE="$3"
|
||||
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
|
||||
# Check existence and extract collection id + fields via python3
|
||||
PARSED=$(echo "$SCHEMA" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
fields = d.get('fields', [])
|
||||
exists = any(f.get('name') == '$FIELD' for f in fields)
|
||||
print('exists=' + str(exists))
|
||||
print('id=' + d.get('id', ''))
|
||||
if not exists:
|
||||
fields.append({'name': '$FIELD', 'type': '$TYPE'})
|
||||
print('fields=' + json.dumps(fields))
|
||||
" 2>/dev/null)
|
||||
if echo "$PARSED" | grep -q "^exists=True"; then
|
||||
log "field exists (skip): $COLL.$FIELD"; return
|
||||
fi
|
||||
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
||||
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
|
||||
NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//')
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PATCH "$PB/api/collections/$COLL_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOK" \
|
||||
-d "{\"fields\":${NEW_FIELDS}}")
|
||||
case "$STATUS" in
|
||||
200|201) log "added field: $COLL.$FIELD ($TYPE)" ;;
|
||||
*) log "WARNING: add_field $COLL.$FIELD returned $STATUS" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── 4. Collections ────────────────────────────────────────────────────────────
|
||||
|
||||
create "books" '{
|
||||
"name":"books","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"title", "type":"text", "required":true},
|
||||
{"name":"author", "type":"text"},
|
||||
{"name":"cover", "type":"text"},
|
||||
{"name":"status", "type":"text"},
|
||||
{"name":"genres", "type":"json"},
|
||||
{"name":"summary", "type":"text"},
|
||||
{"name":"total_chapters","type":"number"},
|
||||
{"name":"source_url", "type":"text"},
|
||||
{"name":"ranking", "type":"number"},
|
||||
{"name":"meta_updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "chapters_idx" '{
|
||||
"name":"chapters_idx","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number","type":"number", "required":true},
|
||||
{"name":"title", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "ranking" '{
|
||||
"name":"ranking","type":"base","fields":[
|
||||
{"name":"rank", "type":"number","required":true},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"title", "type":"text"},
|
||||
{"name":"author", "type":"text"},
|
||||
{"name":"cover", "type":"text"},
|
||||
{"name":"status", "type":"text"},
|
||||
{"name":"genres", "type":"json"},
|
||||
{"name":"source_url","type":"text"}
|
||||
]}'
|
||||
|
||||
create "progress" '{
|
||||
"name":"progress","type":"base","fields":[
|
||||
{"name":"session_id","type":"text", "required":true},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"chapter", "type":"number"},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"audio_time","type":"number"},
|
||||
{"name":"updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "scraping_tasks" '{
|
||||
"name":"scraping_tasks","type":"base","fields":[
|
||||
{"name":"kind", "type":"text"},
|
||||
{"name":"target_url", "type":"text"},
|
||||
{"name":"from_chapter", "type":"number"},
|
||||
{"name":"to_chapter", "type":"number"},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"status", "type":"text","required":true},
|
||||
{"name":"books_found", "type":"number"},
|
||||
{"name":"chapters_scraped", "type":"number"},
|
||||
{"name":"chapters_skipped", "type":"number"},
|
||||
{"name":"errors", "type":"number"},
|
||||
{"name":"error_message", "type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "audio_jobs" '{
|
||||
"name":"audio_jobs","type":"base","fields":[
|
||||
{"name":"cache_key", "type":"text", "required":true},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"chapter", "type":"number","required":true},
|
||||
{"name":"voice", "type":"text"},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"status", "type":"text", "required":true},
|
||||
{"name":"error_message","type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "app_users" '{
|
||||
"name":"app_users","type":"base","fields":[
|
||||
{"name":"username", "type":"text","required":true},
|
||||
{"name":"password_hash","type":"text"},
|
||||
{"name":"role", "type":"text"},
|
||||
{"name":"avatar_url", "type":"text"},
|
||||
{"name":"created", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_sessions" '{
|
||||
"name":"user_sessions","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_agent","type":"text"},
|
||||
{"name":"ip", "type":"text"},
|
||||
{"name":"created_at","type":"text"},
|
||||
{"name":"last_seen", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_library" '{
|
||||
"name":"user_library","type":"base","fields":[
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"slug", "type":"text","required":true},
|
||||
{"name":"saved_at", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_settings" '{
|
||||
"name":"user_settings","type":"base","fields":[
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"auto_next","type":"bool"},
|
||||
{"name":"voice", "type":"text"},
|
||||
{"name":"speed", "type":"number"},
|
||||
{"name":"updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_subscriptions" '{
|
||||
"name":"user_subscriptions","type":"base","fields":[
|
||||
{"name":"follower_id","type":"text","required":true},
|
||||
{"name":"followee_id","type":"text","required":true},
|
||||
{"name":"created", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "book_comments" '{
|
||||
"name":"book_comments","type":"base","fields":[
|
||||
{"name":"slug", "type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"username", "type":"text"},
|
||||
{"name":"body", "type":"text"},
|
||||
{"name":"upvotes", "type":"number"},
|
||||
{"name":"downvotes","type":"number"},
|
||||
{"name":"parent_id","type":"text"},
|
||||
{"name":"created", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "comment_votes" '{
|
||||
"name":"comment_votes","type":"base","fields":[
|
||||
{"name":"comment_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"session_id","type":"text"},
|
||||
{"name":"vote", "type":"text"}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
add_field "progress" "user_id" "text"
|
||||
add_field "progress" "audio_time" "number"
|
||||
add_field "progress" "updated" "text"
|
||||
add_field "books" "meta_updated" "text"
|
||||
|
||||
log "done"
|
||||
Reference in New Issue
Block a user