- Select any part of a couplet or paragraph and press نشان (selection menu): the phrase is bookmarked and shown underlined with a small 🔖, also across the two misras; clicking it removes the bookmark (with a confirmation). Listed in the library as نشان زدہ عبارتیں. - Contents lists show ♥ on favourite works and 🔖 on works holding bookmarks; book/section cards and poet cards (home) show 🔖 when they hold any. API: GET /api/library/marks; phrases in state and library; phrase kind validated against the couplet's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
12 KiB
TypeScript
174 lines
12 KiB
TypeScript
// Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and phrases, saved words.
|
||
// JSON in and out with Bearer tokens, so the site and mobile apps (#44) use the same endpoints.
|
||
// GET /api/library everything, each item with its path in the site
|
||
// GET /api/library/state?poet=&poem=&word= what is saved on one page (poet, poem, couplets, phrases) or a word
|
||
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items
|
||
// POST /api/library/toggle {kind, poetId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
|
||
// POST /api/library/:id/note {note}
|
||
// POST /api/library/:id/delete
|
||
// kind: poet {poetId} | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
|
||
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word)
|
||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||
import { pool } from './db.ts';
|
||
import { sessionUser } from './auth.ts';
|
||
import { PUNCT } from './dictionary.ts';
|
||
|
||
const KINDS = ['poet', 'poem', 'couplet', 'phrase', 'word'] as const;
|
||
|
||
// a bookmarked phrase: spaces collapsed, 2 to 300 characters
|
||
export const cleanPhrase = (p: unknown) => {
|
||
const s = String(p ?? '').normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').trim();
|
||
return s.length >= 2 && s.length <= 300 ? s : null;
|
||
};
|
||
|
||
async function reader(req: FastifyRequest, reply: FastifyReply) {
|
||
const u = await sessionUser(req);
|
||
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
|
||
return u;
|
||
}
|
||
|
||
// one saved word: punctuation and spaces dropped (as in the word sidebar); Arabic script only
|
||
export const cleanWord = (w: unknown) => {
|
||
const s = String(w ?? '').normalize('NFC').replace(PUNCT, '').replace(/ؔ/g, '');
|
||
return /^[\p{Script=Arabic}\p{M}]{1,40}$/u.test(s) ? s : null;
|
||
};
|
||
|
||
// the full path of each work: poet » book » section » … (the poet's root category carries the poet's name)
|
||
export async function paths(poemIds: number[]) {
|
||
if (!poemIds.length) return new Map<number, { title: string; url: string }[]>();
|
||
const { rows } = await pool.query(
|
||
`WITH RECURSIVE up AS (
|
||
SELECT p.id AS poem_id, c.id, c.parent_id, c.title, c.url, 0 AS depth
|
||
FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1)
|
||
UNION ALL
|
||
SELECT up.poem_id, c.id, c.parent_id, c.title, c.url, up.depth + 1 FROM categories c JOIN up ON c.id = up.parent_id)
|
||
SELECT poem_id, title, url FROM up ORDER BY poem_id, depth DESC`, [poemIds]);
|
||
const out = new Map<number, { title: string; url: string }[]>();
|
||
for (const r of rows) (out.get(r.poem_id) ?? out.set(r.poem_id, []).get(r.poem_id)!).push({ title: r.title, url: r.url });
|
||
return out;
|
||
}
|
||
|
||
export function libraryRoutes(app: FastifyInstance) {
|
||
app.get('/api/library', async (req, reply) => {
|
||
const u = await reader(req, reply); if (!u) return;
|
||
const { rows } = await pool.query(
|
||
`SELECT l.id, l.kind, l.poet_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at,
|
||
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet
|
||
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
|
||
WHERE l.user_id = $1 ORDER BY l.created_at DESC, l.id DESC`, [u.id]);
|
||
const poemIds = [...new Set(rows.filter((r) => r.poem_id).map((r) => r.poem_id))];
|
||
const [crumbs, verses] = await Promise.all([
|
||
paths(poemIds),
|
||
pool.query(`SELECT poem_id, couplet, text FROM verses WHERE (poem_id, couplet) IN
|
||
(SELECT poem_id, couplet FROM library WHERE user_id = $1 AND poem_id IS NOT NULL AND couplet IS NOT NULL) ORDER BY vorder`, [u.id]),
|
||
]);
|
||
const lines = (p: number, c: number) => verses.rows.filter((v) => v.poem_id === p && v.couplet === c).map((v) => v.text);
|
||
const where = (r: any) => r.poem_id && { url: r.poem_url, title: r.poem_title, path: crumbs.get(r.poem_id) ?? [], couplet: r.couplet };
|
||
const item = (r: any) => ({ id: Number(r.id), note: r.note, saved_at: r.created_at });
|
||
return {
|
||
user: { full_name: u.full_name ?? '', bio: u.bio ?? '' },
|
||
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
|
||
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
|
||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
|
||
words: rows.filter((r) => r.kind === 'word').map((r) => ({
|
||
...item(r), word: r.word, source: r.poem_url ? { ...where(r), lines: r.couplet != null ? lines(r.poem_id, r.couplet) : [] } : null,
|
||
})),
|
||
};
|
||
});
|
||
|
||
app.get<{ Querystring: { poet?: string; poem?: string; word?: string } }>('/api/library/state', async (req, reply) => {
|
||
const u = await reader(req, reply); if (!u) return;
|
||
if (req.query.word != null) {
|
||
const w = cleanWord(req.query.word);
|
||
return { word: !!w && !!(await pool.query(`SELECT 1 FROM library WHERE user_id = $1 AND kind = 'word' AND word = $2`, [u.id, w])).rowCount };
|
||
}
|
||
const poet = Number(req.query.poet) || 0, poem = Number(req.query.poem) || 0;
|
||
const { rows } = await pool.query(
|
||
`SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $3))`,
|
||
[u.id, poet, poem]);
|
||
return {
|
||
poet: rows.some((r) => r.kind === 'poet'), poem: rows.some((r) => r.kind === 'poem'),
|
||
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet),
|
||
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
|
||
};
|
||
});
|
||
|
||
// for contents lists and cards: works that are favourites (fav) or hold bookmarks (bm), and the books,
|
||
// sections and poets containing them
|
||
app.get('/api/library/marks', async (req, reply) => {
|
||
const u = await reader(req, reply); if (!u) return;
|
||
const { rows } = await pool.query(
|
||
`SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm
|
||
FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]);
|
||
const ids = rows.map((r) => r.poem_id);
|
||
const [cats, poets] = await Promise.all([
|
||
pool.query(`WITH RECURSIVE up AS (SELECT c.id, c.parent_id FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1)
|
||
UNION SELECT c.id, c.parent_id FROM categories c JOIN up ON c.id = up.parent_id) SELECT id FROM up`, [ids]),
|
||
pool.query('SELECT DISTINCT poet_id FROM poems WHERE id = ANY($1) UNION SELECT poet_id FROM library WHERE user_id = $2 AND kind = $3', [ids, u.id, 'poet']),
|
||
]);
|
||
return {
|
||
poems: Object.fromEntries(rows.map((r) => [r.poem_id, { fav: r.fav, bm: r.bm }])),
|
||
categories: cats.rows.map((r) => r.id), poets: poets.rows.map((r) => r.poet_id),
|
||
};
|
||
});
|
||
|
||
app.post<{ Body: { kind?: string; poetId?: number; poemId?: number; couplet?: number; word?: string } }>('/api/library/toggle', async (req, reply) => {
|
||
const u = await reader(req, reply); if (!u) return;
|
||
const b = req.body ?? {}, kind = String(b.kind);
|
||
if (!(KINDS as readonly string[]).includes(kind)) return reply.code(400).send({ error: 'نامعلوم قسم' });
|
||
const poetId = Number(b.poetId) || null, poemId = Number(b.poemId) || null;
|
||
const couplet = Number.isInteger(b.couplet) ? b.couplet! : null;
|
||
let key: [string, unknown[]];
|
||
if (kind === 'word') {
|
||
const word = cleanWord(b.word);
|
||
if (!word) return reply.code(400).send({ error: 'ایک لفظ منتخب کریں' });
|
||
key = [`kind = 'word' AND word = $2`, [word]];
|
||
const hit = (await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${key[0]} RETURNING id`, [u.id, ...key[1]])).rows[0];
|
||
if (hit) return { saved: false };
|
||
// the source couplet is kept only if it exists
|
||
const src = poemId && couplet != null && (await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount ? [poemId, couplet] : [null, null];
|
||
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, word, poem_id, couplet) VALUES ($1, 'word', $2, $3, $4) RETURNING id`, [u.id, word, ...src]);
|
||
return { saved: true, id: Number(rows[0].id) };
|
||
}
|
||
if (kind === 'phrase') {
|
||
const phrase = cleanPhrase(b.phrase);
|
||
if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
|
||
// the phrase must be in that couplet or paragraph (its lines, or across its two misras)
|
||
const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t;
|
||
if (!text) return reply.code(404).send({ error: 'نہیں ملا' });
|
||
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return reply.code(400).send({ error: 'یہ عبارت اس شعر میں نہیں' });
|
||
const args = [u.id, poemId, couplet, phrase];
|
||
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount)
|
||
return { saved: false };
|
||
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args);
|
||
return { saved: true, id: Number(rows[0].id) };
|
||
}
|
||
// poet, poem or couplet: the target must exist
|
||
const exists = kind === 'poet' ? await pool.query('SELECT 1 FROM poets WHERE id = $1', [poetId])
|
||
: kind === 'poem' ? await pool.query('SELECT 1 FROM poems WHERE id = $1', [poemId])
|
||
: await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet]);
|
||
if (!exists.rowCount) return reply.code(404).send({ error: 'نہیں ملا' });
|
||
const cols = kind === 'poet' ? { poet_id: poetId, poem_id: null, couplet: null } : { poet_id: null, poem_id: poemId, couplet: kind === 'couplet' ? couplet : null };
|
||
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(poem_id, 0) = coalesce($4::int, 0) AND coalesce(couplet, -1) = coalesce($5::int, -1)`;
|
||
const args = [u.id, kind, cols.poet_id, cols.poem_id, cols.couplet];
|
||
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false };
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO library (user_id, kind, poet_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING RETURNING id', args);
|
||
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
|
||
});
|
||
|
||
app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => {
|
||
const u = await reader(req, reply); if (!u) return;
|
||
const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
|
||
const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3', [note, Number(req.params.id) || 0, u.id]);
|
||
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
|
||
});
|
||
|
||
app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => {
|
||
const u = await reader(req, reply); if (!u) return;
|
||
const r = await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [Number(req.params.id) || 0, u.id]);
|
||
return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' });
|
||
});
|
||
}
|