Compare commits

..

No commits in common. "main" and "feature/search-box-icons" have entirely different histories.

137 changed files with 498 additions and 8008 deletions

View File

@ -1,7 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{ "name": "api-dev", "runtimeExecutable": "node", "runtimeArgs": ["--watch", "api/src/server.ts"], "env": { "PORT": "4101", "HOST": "127.0.0.1" }, "port": 4101 },
{ "name": "web-dev", "runtimeExecutable": "npx", "runtimeArgs": ["--prefix", "web", "astro", "dev", "--root", "web", "--port", "4201"], "env": { "API_URL": "http://127.0.0.1:4101" }, "port": 4201 }
]
}

3
.gitignore vendored
View File

@ -1,5 +1,4 @@
# node_modules as a folder or a link (links were once committed by mistake)
node_modules
node_modules/
dist/
.astro/
.env

25
NOTICE
View File

@ -1,25 +0,0 @@
Divan (دیوان)
Copyright (C) 2026 Muhammad Anas Rashid and Divan contributors
This program is free software under the GNU General Public License version 3 (see LICENSE), with the
additional terms below, which the GPL allows in its section 7. They apply to the code written for Divan by its
authors. (The earlier .NET code at the tag dotnet-final is a fork of GanjoorService and stays under that
project's licence; these terms do not apply to it.)
Additional terms under section 7 of the GNU GPL version 3
1. Attribution (section 7(b)). If you convey this work or a work based on it, keep this attribution in the
Appropriate Legal Notices it displays (for a website: its about page or footer):
"Based on Divan (https://git.anasrashid.net/anas/divan) by Muhammad Anas Rashid, under the GNU GPL v3."
2. Origin (section 7(c)). A modified version must be marked as different from the original, and must not
be presented as Divan, or as made, approved or endorsed by Divan or its authors.
3. Names and logos (section 7(e)). This licence grants no rights under trademark law to the names, logos
and other brand features of Divan, listed in TRADEMARKS.md: among them the name دیوان / Divan for this
project and site, the shamsa and ترنج logos and icons, the tagline "ایک پاکستانی کی طرف سے، پاکستان کے
لیے", and the masthead and share-card designs. A modified version made available to the public must use
its own name and logo (see "Making your own site" in TRADEMARKS.md).
The texts shown on the site are in the public domain; the Divan data is CC BY-SA 4.0 (see the divan-data
repository). These terms concern only the software and Divan's brand.

View File

@ -1,5 +1,3 @@
<p align="center"><img src="web/public/logo/turanj.svg" width="140" alt="دیوان"></p>
# دیوان · Divan
An open-source site for reading and searching **classical Urdu poetry and prose**, in Urdu script (`ur-PK`). Content comes from [divan-data](https://github.com/anas-rashid/divan-data): public-domain texts from Urdu Wikisource, with short poet introductions from Urdu Wikipedia.
@ -37,14 +35,6 @@ The import upserts, so re-running it after a divan-data sync applies the changes
**Accounts and admin.** Readers sign up with an email address and password (no email is sent). The first admin is made on the server: sign up on the site, then `npm run make-admin -- you@example.com` in `api/`. Admins manage users at `/admin` (search, password reset on a reader's request, disable, roles, delete) and see every admin action at `/admin/audit`. Moderators (L2 junior, L1 senior) get scoped permissions from admins: a scope (all poets, a poet, a book with everything in it, or one work), content types (poets, books, works, dictionary) and actions (create, edit, delete, arrange); `can()` in `api/src/permissions.ts` is the one check for moderation.
**Moderation.** Moderators open **ترمیم کریں** on a work they may edit, change its Divan text (see `docs/content-model.md`) with an edit summary and submit it. An L1 moderator covering that work approves, returns (with a reason) or rejects it; an admin publishes. L1 drafts go straight to the admin and an admin's own edits publish directly. Every step is recorded (`/mod`, the activity log, each work's history at `/mod/work/<id>`). Admins are super moderators: they edit, review and publish any work without grants. Publishing numbers the version, writes it to divan-data's `divan/` folder (`DIVAN_DATA_DIR`, default `../divan-data`), **commits it there** (the moderator as author by public name, with `Reviewed-by:` and `Approved-by:` trailers; `DIVAN_GIT_PUSH=1` also pushes), and shows it on the site at once with a link to the commit (`DIVAN_DATA_COMMIT_URL`, `{sha}` replaced). Public content never carries email addresses. A draft started before a newer version was published cannot be published. The daily sync rebases on pull so these commits are kept.
**E-books.** L2/L1 moderators with the e-books permission for a poet (admins always) see **ای بک شامل کریں** in the poet's **ای بکس** section: a PDF, EPUB, TXT or DOCX file (DOCX becomes text; up to 100 MB) or an archive.org link, with its title, source and copyright. Anyone with the permission may upload any book; it is listed only after an L1 moderator and an admin confirm it (the same pipeline), and until then only moderators can open it. Readers see PDFs in the browser's viewer, EPUBs in epub.js, text as text and archive.org items in archive.org's embedded reader (`/ebook/<id>`). Approved details (not the file) are committed to divan-data (`divan/<poet>/ebooks/<id>.txt`).
**File store.** Uploaded files are kept in `DIVAN_FILES_DIR` (default `../divan-files` beside the repo) under their SHA-256, `ab/cd/<sha256>.<ext>`, so a file uploaded twice is stored once and the folder spreads evenly. On the server, point it at storage that can grow: an attached block-storage volume (e.g. Vultr Block Storage, resizable later) mounted at `/srv/divan-files`, or an S3-compatible bucket mounted with rclone. Back it up with the database. The database keeps the details, indexed by poet and by a trigram index on the title (and the text of text books), so lists and search stay fast.
**Readers' addresses and rate limits.** Sign-in and sign-up are limited per address. The site passes the reader's address to the API in `x-client-ip`, which the API believes only from the site: set the same random `DIVAN_SITE_KEY` for both (e.g. `openssl rand -hex 32`); without it, only requests from the same machine count as the site. Keep the API off the internet (only the site talks to it), and let Caddy be the only way in: it replaces any `X-Forwarded-For` a visitor sends with their real address, which is what the site reads.
## Daily content sync (server)
`deploy/sync.sh` keeps a server current: it updates a divan-data checkout, fetches new and edited works from Wikisource (incremental, about a minute), rebuilds the export and upserts it into PostgreSQL. The site shows new content immediately. Runs are locked so they never overlap.
@ -67,7 +57,6 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI
| `GET /api/search?q=&poet=1,2&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`); optionally only some poets/writers; plus `authors` (who the results come from, with counts), and on page 1 `poets` (by name) and `books` (books/chapters by title) |
| `GET /api/word?w=` | one word's meanings and pronunciation from the local Wiktionary data (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none) |
| `/api/auth/*` | accounts: sign-up, sign-in (returns a Bearer token), profile, password, delete |
| `/api/mod/*` | moderation: what a moderator may do, queue, drafts, save/submit/approve/return/reject/publish, a work's history, activity log |
| `/api/library/*` | the signed-in reader's library: toggle poets, works, couplets and words; list with full paths; notes |
| `GET /health` | database check |
@ -79,4 +68,4 @@ v2 is reaching parity with Ganjoor's features in phases: reading, accounts, comm
## License
GPL-3.0 (see `LICENSE`), with additional terms under its section 7 (see `NOTICE`): keep the attribution "Based on Divan by Muhammad Anas Rashid", mark modified versions as different, and do not use Divan's name and logos for your own public site ([TRADEMARKS.md](TRADEMARKS.md); set your own in `web/src/lib/brand.ts`). Texts are public domain; the divan-data compilation is CC BY-SA 4.0 (Urdu Wikisource and Wikipedia contributors, and Divan).
GPL-3.0 (see `LICENSE`). Texts are public domain; the divan-data compilation is CC BY-SA 4.0 (Urdu Wikisource and Wikipedia contributors).

View File

@ -1,37 +0,0 @@
# Divan's name and logos
The code is free software (GNU GPL v3, with the additional terms in [NOTICE](NOTICE)), and you are welcome to
study it, change it and run your own site with it. Divan's **name and logos are not part of that licence**:
they identify this project and its site, so that readers know what is Divan and what is not.
## What belongs to Divan
- The name **دیوان / Divan** used for this project, its site and its apps.
- The logos and icons: the shamsa (illuminated medallion) and the ترنج (lobed medallion), in every form:
`web/public/logo/`, `web/public/favicon.*`, `web/public/icon-*.png`, `web/public/apple-touch-icon.png`,
`web/public/og-image.png`, and the logo drawn in the header and home page.
- The tagline **ایک پاکستانی کی طرف سے، پاکستان کے لیے** with Pakistan's flag in the footer.
- The masthead (the logo beside **اردو کا کلاسیکی ادب** between the couplet lines) and the share-card design.
## What you may do
- Say that your work is **based on Divan**, link to it, and describe what Divan is.
- Run Divan unchanged for yourself or for testing, privately.
- Use the name when writing about the project (articles, reviews, talks).
## What you may not do
- Run a public site, app or service from a modified copy under the Divan name or with its logos.
- Use a name, domain, logo or design that could make people think your site is Divan or is endorsed by it.
- Remove the attribution required by [NOTICE](NOTICE).
## Making your own site
1. Set your name and taglines in `web/src/lib/brand.ts`.
2. Replace the logo files listed above with your own, and the logo in the header (`web/src/layouts/Base.astro`)
and home page (`web/src/pages/index.astro`).
3. Rewrite the about page (`web/src/pages/about.astro`) for your project, keeping the attribution
"Based on Divan by Muhammad Anas Rashid" from [NOTICE](NOTICE).
4. Update `web/public/site.webmanifest`.
Questions or permission requests: open an issue on the Divan repository.

216
api/package-lock.json generated
View File

@ -7,12 +7,8 @@
"name": "divan-api",
"dependencies": {
"fastify": "^5.12.5",
"mammoth": "^1.13.0",
"pg": "^8.23.1"
},
"devDependencies": {
"jszip": "^3.10.2"
},
"engines": {
"node": ">=24"
}
@ -134,15 +130,6 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@ -198,15 +185,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -236,26 +214,6 @@
"fastq": "^1.17.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@ -269,12 +227,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -284,21 +236,6 @@
"node": ">=6"
}
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.2.tgz",
"integrity": "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg==",
"license": "BSD-2-Clause"
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@ -416,18 +353,6 @@
"node": ">=20"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
@ -437,12 +362,6 @@
"node": ">= 10"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
@ -468,27 +387,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@ -526,39 +424,6 @@
],
"license": "MIT"
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/mammoth": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.13.0.tgz",
"integrity": "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@ -568,18 +433,6 @@
"node": ">=14.0.0"
}
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
@ -745,12 +598,6 @@
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
@ -773,21 +620,6 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
@ -828,12 +660,6 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
@ -899,12 +725,6 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -923,21 +743,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@ -959,27 +764,6 @@
"node": ">=20"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -9,16 +9,12 @@
"scripts": {
"start": "node src/server.ts",
"import": "node src/import.ts",
"test": "node --test --test-concurrency=1 src/*.test.ts",
"test": "node --test src/*.test.ts",
"dict-sync": "node src/dict-sync.ts",
"make-admin": "node src/admin-cli.ts"
},
"dependencies": {
"fastify": "^5.12.5",
"mammoth": "^1.13.0",
"pg": "^8.23.1"
},
"devDependencies": {
"jszip": "^3.10.2"
}
}

View File

@ -31,11 +31,6 @@ test('admin panel: only admins; reset, disable, role, delete, self-protection, a
const list = (await call('GET', `/api/admin/users?q=reader-${run}`, undefined, a.token)).json();
assert.deepEqual(list.users.map((u: any) => u.email), [`reader-${run}@divan.test`]);
const rid = r.user.id;
// role filter and counts: the search for this run's emails finds one admin and one reader
const both = (await call('GET', `/api/admin/users?q=-${run}@`, undefined, a.token)).json();
assert.equal(both.counts.all, 2); assert.equal(both.counts.admin, 1); assert.equal(both.counts.reader, 1);
assert.deepEqual((await call('GET', `/api/admin/users?q=-${run}@&role=admin`, undefined, a.token)).json().users.map((u: any) => u.email), [`admin-${run}@divan.test`]);
assert.equal(both.users[0].grants, 0);
// password reset: new temporary password works, the old one and old sessions do not
const { password } = (await call('POST', `/api/admin/users/${rid}/password`, undefined, a.token)).json();

View File

@ -1,8 +1,7 @@
// Admin panel API (admins only). No email server yet, so password resets are done here on a reader's
// request: a temporary password is generated, shown to the admin once, and the reader's sessions end.
// Every action is written to audit_log. Moderators' grants: permissions.ts.
// GET /api/admin/users?q=&role=&page= users (search by email or name; role: a role or 'disabled'), newest first,
// with counts per role and each moderator's number of grants
// GET /api/admin/users?q=&page= users (search by email), newest first
// POST /api/admin/users/:id/password -> {password} (temporary, shown once)
// POST /api/admin/users/:id/disable {disabled: boolean}
// POST /api/admin/users/:id/role {role: 'reader' | 'mod-l2' | 'mod-l1' | 'admin'}
@ -34,28 +33,18 @@ export const audit = (actor: any, action: string, target: any, detail?: object)
[actor?.id ?? null, actor?.email ?? 'server', action, target?.id ?? null, target?.email ?? null, detail ?? null]);
export function adminRoutes(app: FastifyInstance) {
app.get<{ Querystring: { q?: string; role?: string; page?: string } }>('/api/admin/users', async (req, reply) => {
app.get<{ Querystring: { q?: string; page?: string } }>('/api/admin/users', async (req, reply) => {
if (!(await requireAdmin(req, reply))) return;
const q = (req.query.q ?? '').trim().toLowerCase(), page = Math.max(1, Number(req.query.page) || 1);
const like = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
const role = req.query.role ?? '';
// the role filter: one role, or 'disabled' (any role); anything else lists everyone
const where = `(u.email LIKE $1 OR lower(coalesce(u.full_name, '')) LIKE $1)` +
(role === 'disabled' ? ' AND u.disabled_at IS NOT NULL' : ['reader', 'mod-l2', 'mod-l1', 'admin'].includes(role) ? ' AND u.role = $2' : '');
const args = ['reader', 'mod-l2', 'mod-l1', 'admin'].includes(role) ? [like, role] : [like];
const [count, rows, counts] = await Promise.all([
pool.query(`SELECT count(*)::int AS n FROM users u WHERE ${where}`, args),
const [count, rows] = await Promise.all([
pool.query('SELECT count(*)::int AS n FROM users WHERE email LIKE $1', [like]),
pool.query(
`SELECT u.id, u.email, u.full_name, u.role, u.created_at, u.disabled_at,
(SELECT max(s.created_at) FROM sessions s WHERE s.user_id = u.id) AS last_sign_in,
(SELECT count(*)::int FROM grants g WHERE g.user_id = u.id) AS grants
FROM users u WHERE ${where} ORDER BY u.created_at DESC LIMIT ${PAGE} OFFSET ${(page - 1) * PAGE}`, args),
pool.query(`SELECT u.role, count(*)::int AS n, count(u.disabled_at)::int AS disabled FROM users u
WHERE u.email LIKE $1 OR lower(coalesce(u.full_name, '')) LIKE $1 GROUP BY u.role`, [like]),
`SELECT u.id, u.email, u.role, u.created_at, u.disabled_at, max(s.created_at) AS last_sign_in
FROM users u LEFT JOIN sessions s ON s.user_id = u.id WHERE u.email LIKE $1
GROUP BY u.id ORDER BY u.created_at DESC LIMIT ${PAGE} OFFSET ${(page - 1) * PAGE}`, [like]),
]);
const by: Record<string, number> = { all: 0, disabled: 0 };
for (const r of counts.rows) { by[r.role] = r.n; by.all += r.n; by.disabled += r.disabled; }
return { total: count.rows[0].n, page, pageSize: PAGE, counts: by, users: rows.rows.map((r) => ({ ...r, id: Number(r.id) })) };
return { total: count.rows[0].n, page, pageSize: PAGE, users: rows.rows.map((r) => ({ ...r, id: Number(r.id) })) };
});
// one target user, never the acting admin themself for actions that could lock them out

View File

@ -1,7 +1,7 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes, ip } from './auth.ts';
import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes } from './auth.ts';
import { pool } from './db.ts';
after(() => pool.end());
@ -27,22 +27,6 @@ test('rate limiter: max per window, then resets', () => {
assert.equal(allow('ip', 1001), true);
});
test('client address: x-client-ip is trusted only from the site, so a made-up one cannot dodge the limits', () => {
const req = (from: string, headers: Record<string, string>) => ({ ip: from, headers }) as any;
const saved = process.env.DIVAN_SITE_KEY;
try {
delete process.env.DIVAN_SITE_KEY; // local development: only this machine may pass an address
assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '5.5.5.5');
assert.equal(ip(req('203.0.113.9', { 'x-client-ip': '5.5.5.5' })), '203.0.113.9', 'a stranger is limited by their own address');
process.env.DIVAN_SITE_KEY = 'site-secret-1234';
assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'site-secret-1234' })), '5.5.5.5', 'the site, with the key');
assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'wrong' })), '10.0.0.3');
assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '127.0.0.1', 'with a key set, even this machine needs it');
} finally {
if (saved === undefined) delete process.env.DIVAN_SITE_KEY; else process.env.DIVAN_SITE_KEY = saved;
}
});
test('HTTP flow: sign up, sign in, wrong password, change password, delete', async () => {
const app = Fastify();
authRoutes(app);

View File

@ -1,10 +1,7 @@
// Accounts: email address and password only (no email is sent; owner decision 2026-10-08).
// Passwords: scrypt with a per-user salt. Sessions: a random token held by the site in an HTTP-only
// cookie; the database stores only its SHA-256, so a database leak does not give working sessions.
// Rate limits go by the reader's address. The site passes it in x-client-ip, and only the site may: it proves itself
// with DIVAN_SITE_KEY in x-site-key (set the same key for the API and the site in production); with no key set
// (local development) only requests from this machine may. Anyone else is limited by their own address, so a
// made-up x-client-ip cannot get round the limits.
// The API is private (only the site calls it), so the site passes the reader's IP in x-client-ip.
// POST /api/auth/signup {email, password} -> {token, user}
// POST /api/auth/signin {email, password} -> {token, user}
// GET /api/auth/me Bearer token -> {user}
@ -63,9 +60,6 @@ const signinByIp = limiter(20, 15 * 60_000), signinByEmail = limiter(8, 15 * 60_
function sha(t: string) {
return createHash('sha256').update(t).digest('hex');
}
// a person's public name (shown on published content and in divan-data's git history): never their email
export const publicName = (u: { id: unknown; full_name?: string | null }) => u.full_name?.trim() || `موڈریٹر ${u.id}`;
export const publicUser = (u: any) => ({
id: Number(u.id), email: u.email, role: u.role as string, created_at: u.created_at, full_name: u.full_name ?? '', bio: u.bio ?? '',
});
@ -88,14 +82,7 @@ export async function sessionUser(req: FastifyRequest) {
return u ?? null;
}
const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
export const fromSite = (req: FastifyRequest) => {
const key = process.env.DIVAN_SITE_KEY;
if (!key) return LOOPBACK.includes(req.ip);
const given = Buffer.from(String(req.headers['x-site-key'] ?? '')), want = Buffer.from(key);
return given.length === want.length && timingSafeEqual(given, want);
};
export const ip = (req: FastifyRequest) => (fromSite(req) && (req.headers['x-client-ip'] as string)) || req.ip;
const ip = (req: FastifyRequest) => (req.headers['x-client-ip'] as string) || req.ip;
export function authRoutes(app: FastifyInstance) {
app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => {

View File

@ -1,91 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts';
import { permissionRoutes } from './permissions.ts';
import { moderationRoutes } from './moderation.ts';
import { checkDetails, parseDetails } from './details.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('details text: fields, the intro to the end, checks', () => {
const d = parseDetails('نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nپہلی سطر\n\nدوسری: سطر');
assert.equal(d['تعارف'], 'پہلی سطر\n\nدوسری: سطر', 'a colon in the intro is text');
assert.equal(checkDetails('poet', 'نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nx'), null, 'Urdu digits are years');
assert.match(checkDetails('poet', 'نام: \nتخلص: اقبال')!, /ضروری/);
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: 1900\nوفات: 1800')!, /پہلے/);
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: سن')!, /عیسوی/);
assert.match(checkDetails('book', 'عنوان:')!, /ضروری/);
});
test('poet and book editors: permissions, publishing to divan-data and the site, history and revert', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
process.env.DIVAN_DATA_DIR = data;
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify();
authRoutes(app); adminRoutes(app); permissionRoutes(app); moderationRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `det-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l2 = await person('l2', 'mod-l2');
const poet = (await pool.query(`SELECT * FROM poets WHERE url = '/p266'`)).rows[0];
const root = (await pool.query(`SELECT id, title FROM categories WHERE poet_id = $1 AND parent_id IS NULL`, [poet.id])).rows[0];
const book = (await pool.query(`SELECT id, url, title FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
try {
assert.equal((await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).statusCode, 403, 'no grant yet');
await call('POST', `/api/admin/users/${l2.id}/grants`, { scope: 'poet', target: '/p266', content: ['poets', 'books'], actions: ['edit'] }, admin.token);
const can = (await call('GET', `/api/mod/can?category=${book.id}`, undefined, l2.token)).json();
assert.deepEqual([can.poet, can.book], [true, true]);
// the poet: L2 drafts a new intro and years; the admin publishes
const cur = (await call('GET', `/api/mod/details/poet/${poet.id}`, undefined, l2.token)).json();
assert.equal(cur.fields['تخلص'], poet.nickname);
const id = (await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).json().id;
const text = `نام: ${poet.name}\nتخلص: ${poet.nickname}\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف\nدوسرا پیراگراف`;
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'نام:\nتخلص: x' }, l2.token)).statusCode, 400);
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: text, summary: 'تعارف' }, l2.token)).statusCode, 200);
await pool.query(`UPDATE revisions SET status = 'approved' WHERE id = $1`, [id]); // the L1 step is tested in moderation.test.ts
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
const now = (await pool.query('SELECT description, birth_year_ce, death_year_ce, birth_year_ah FROM poets WHERE id = $1', [poet.id])).rows[0];
assert.deepEqual([now.description, now.birth_year_ce, now.death_year_ce, now.birth_year_ah], ['نیا تعارف\nدوسرا پیراگراف', 1797, 1869, 1212]);
assert.match(await readFile(join(data, 'divan', 'p266.poet'), 'utf8'), /^نام: .+\nتخلص: .+\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف/);
assert.match(git('log', '-1', '--format=%s'), new RegExp(`^شاعر: ${poet.nickname}: تعارف \\(ورژن 1\\)`));
// the book's title, by the admin directly
const b = (await call('POST', `/api/mod/details/book/${book.id}/draft`, undefined, admin.token)).json().id;
await call('POST', `/api/mod/revisions/${b}/save`, { content: 'عنوان: غزلیات (نیا)' }, admin.token);
assert.equal((await call('POST', `/api/mod/revisions/${b}/submit`, {}, admin.token)).json().version, 1);
assert.equal((await pool.query('SELECT title FROM categories WHERE id = $1', [book.id])).rows[0].title, 'غزلیات (نیا)');
assert.equal((await readFile(join(data, 'divan', 'p266', 'ghazal.book'), 'utf8')), 'عنوان: غزلیات (نیا)\n');
// history: compare with the text before Divan's version, and bring it back as a draft
const cmp = (await call('GET', `/api/mod/compare/poet/${poet.id}?a=0&b=1`, undefined, l2.token)).json();
assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text === 'نیا تعارف'));
const back = (await call('POST', `/api/mod/revert/book/${book.id}`, { version: 0 }, l2.token)).json().id;
assert.equal((await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json().revision.content, `عنوان: ${book.title}`);
const log = (await call('GET', `/api/mod/log?who=l2-${run}`, undefined, admin.token)).json().entries;
assert.ok(log.some((e: any) => e.entity === 'poet' && e.title === poet.nickname && e.url === '/p266'), 'the log names the poet');
} finally {
await pool.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6, birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
[poet.id, poet.name, poet.nickname, poet.description, poet.birth_year_ce, poet.death_year_ce, poet.birth_year_ah, poet.death_year_ah]);
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [root.id, root.title]);
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [book.id, book.title]);
await pool.query(`DELETE FROM revisions WHERE entity IN ('poet', 'book') AND author_email LIKE $1`, [`%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true });
await app.close();
}
});

View File

@ -1,84 +0,0 @@
// Poet and book details (#32): a poet's name, pen name, years and intro, and a book or section's title, edited in
// Divan. Each is a revision (entity 'poet', entity_id = poets.id; 'book', entity_id = categories.id) through the same
// pipeline as a work. The text is one "label: value" per line; a poet's intro follows "تعارف:" to the end:
// نام: محمد اقبال
// تخلص: اقبال
// پیدائش: 1877
// وفات: 1938
// تعارف:
// ڈاکٹر سر علامہ محمد اقبال …
// Published details live in divan-data as divan/<url>.poet and divan/<url>.book, which the export
// (export_divan.py) prefers to Wikisource's and Wikipedia's, so the daily sync keeps them.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { PoolClient } from 'pg';
import { pool } from './db.ts';
export type DetailKind = 'poet' | 'book';
export const isDetail = (entity: string): entity is DetailKind => entity === 'poet' || entity === 'book';
const POET = ['نام', 'تخلص', 'پیدائش', 'وفات'];
const hijri = (ce: number | null) => (ce ? Math.round(((ce - 622) * 33) / 32) : null); // as export_divan.py
export function parseDetails(text: string) {
const lines = text.replace(/\r\n?/g, '\n').split('\n'), out: Record<string, string> = {};
const intro = lines.findIndex((l) => /^تعارف\s*:/.test(l));
for (const l of intro < 0 ? lines : lines.slice(0, intro)) {
const m = l.match(/^([^:]+):\s*(.*)$/);
if (m) out[m[1].trim()] = m[2].trim();
}
if (intro >= 0) out['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim();
return out;
}
export const detailsText = (kind: DetailKind, d: Record<string, string>) => kind === 'book' ? `عنوان: ${d['عنوان'] ?? ''}`
: `${POET.map((l) => `${l}: ${d[l] ?? ''}`).join('\n')}\nتعارف:\n${d['تعارف'] ?? ''}`;
// what the details are now (the site's values, which follow the latest published version) and that version
export async function currentDetails(kind: DetailKind, id: number) {
const row = kind === 'poet'
? (await pool.query('SELECT id, url, nickname AS title, name, nickname, description, birth_year_ce, death_year_ce FROM poets WHERE id = $1', [id])).rows[0]
: (await pool.query('SELECT id, url, title FROM categories WHERE id = $1', [id])).rows[0];
if (!row) return null;
const last = (await pool.query(
`SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [kind, id])).rows[0];
const content = detailsText(kind, kind === 'book' ? { عنوان: row.title } : {
نام: row.name, تخلص: row.nickname, پیدائش: row.birth_year_ce ?? '', وفات: row.death_year_ce ?? '', تعارف: row.description ?? '' });
return { details: { kind, id: row.id, url: row.url, title: row.title }, version: Number(last?.v ?? 0), content };
}
const year = (s: string | undefined) => (s ? Number(s.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0))) : null);
// what is wrong with the text, or null
export function checkDetails(kind: DetailKind, text: string) {
const d = parseDetails(text);
if (kind === 'book') {
if (!d['عنوان']) return 'عنوان ضروری ہے';
return d['عنوان'].length > 200 ? 'عنوان بہت لمبا ہے' : null;
}
if (!d['نام'] || !d['تخلص']) return 'نام اور تخلص ضروری ہیں';
if (d['نام'].length > 100 || d['تخلص'].length > 100) return 'نام بہت لمبا ہے';
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
for (const y of [born, died]) if (y !== null && !(Number.isInteger(y) && y >= 500 && y <= 2100)) return 'سال عیسوی میں لکھیں، جیسے ۱۸۷۷';
if (born && died && died < born) return 'وفات کا سال پیدائش سے پہلے ہے';
return (d['تعارف'] ?? '').length > 5000 ? 'تعارف بہت لمبا ہے' : null;
}
export async function applyDetails(client: PoolClient, kind: DetailKind, id: number, text: string) {
const d = parseDetails(text);
if (kind === 'book') return void (await client.query('UPDATE categories SET title = $2 WHERE id = $1', [id, d['عنوان']]));
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
await client.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6,
birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
[id, d['نام'], d['تخلص'], d['تعارف'] || null, born, died, hijri(born), hijri(died)]);
// the poet's top level carries their name (as the export makes it)
await client.query('UPDATE categories SET title = $2 WHERE poet_id = $1 AND parent_id IS NULL', [id, d['نام']]);
}
export async function writeDetails(dataDir: string, kind: DetailKind, url: string, text: string) {
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.' + kind);
await mkdir(dirname(path), { recursive: true });
const d = parseDetails(text);
for (const k of ['پیدائش', 'وفات']) if (d[k]) d[k] = String(year(d[k])); // plain digits for the export
await writeFile(path, detailsText(kind, d).trim() + '\n');
return path;
}

View File

@ -1,24 +0,0 @@
// Line diff for reviewing revisions of Divan text: each line kept ('='), removed ('-') or added ('+').
// ponytail: longest-common-subsequence table, O(lines²); fine for a poem or a chapter (hundreds of lines),
// switch to Myers' algorithm if whole books are ever diffed at once.
export type DiffLine = { op: '=' | '-' | '+'; text: string };
export function diffLines(a: string, b: string): DiffLine[] {
const x = a.split('\n'), y = b.split('\n');
const n = x.length, m = y.length;
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--) lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
const out: DiffLine[] = [];
let i = 0, j = 0;
while (i < n && j < m) {
if (x[i] === y[j]) { out.push({ op: '=', text: x[i] }); i++; j++; }
else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ op: '-', text: x[i++] });
else out.push({ op: '+', text: y[j++] });
}
while (i < n) out.push({ op: '-', text: x[i++] });
while (j < m) out.push({ op: '+', text: y[j++] });
return out;
}
export const changed = (d: DiffLine[]) => d.filter((l) => l.op !== '=').length;

View File

@ -39,8 +39,7 @@ test('prose: chapter heading, paragraphs, footnote', () => {
assert.equal(prose.meta['مصنف'], 'سید احمد خان');
assert.deepEqual(prose.blocks.map((b) => b.type), ['heading', 'para', 'para', 'para']);
assert.equal((prose.blocks[1] as any).line.notes.length, 1);
assert.deepEqual(toVerses(prose).map((v) => v.Position), ['Heading', 'Paragraph', 'Paragraph', 'Paragraph']);
assert.equal(toVerses(parse('== باب ==\n\n=== فصل ===\n\nمتن')).map((v) => v.Level ?? 0).join(), '2,3,0', 'chapter and sub-heading levels');
assert.deepEqual(toVerses(prose).map((v) => v.Position), ['Paragraph', 'Paragraph', 'Paragraph']);
});
test('stanzas and single lines', () => {
@ -62,16 +61,3 @@ test('TEI export is well-formed XML with couplets as <lg type="sher"> of two <l>
if (!lint.error) assert.equal(lint.status, 0, String(lint.stderr));
if (!lint.error) assert.equal(spawnSync('xmllint', ['--noout', '-'], { input: toTEI(prose) }).status, 0);
});
test('writing back: parse(toText(doc)) is the same doc; segments keep links, notes and variants in place', async () => {
const { toText, segmentsText } = await import('./divantext.ts');
for (const d of [ghazal, prose, parse('<poem>\n{{مقطع}} [[لغت:دیوانہ|دیوانے]] ہیں<ref>حاشیہ</ref> {{نسخہ|الف|ب|ماخذ=س}}\nدوسرا\n</poem>')]) {
const again = parse(toText(d));
assert.deepEqual(again.meta, d.meta);
assert.deepEqual(again.blocks, d.blocks);
}
const l = inline('کوئی [[لغت:دیوانہ|دیوانے]] ہیں<ref>نوٹ</ref> اور {{نسخہ|الف|ب|ماخذ=س}}');
assert.deepEqual(l.segments, [{ text: 'کوئی ' }, { lemma: 'دیوانہ', text: 'دیوانے' }, { text: ' ہیں' }, { note: 'نوٹ' }, { text: ' اور ' },
{ variant: { shown: 'الف', others: ['ب'], source: 'س' } }]);
assert.equal(segmentsText(l.segments), 'کوئی [[لغت:دیوانہ|دیوانے]] ہیں<ref>نوٹ</ref> اور {{نسخہ|الف|ب|ماخذ=س}}');
});

View File

@ -15,12 +15,9 @@
// Words: split on spaces; a zero-width non-joiner keeps a compound one word (بے‌ثبوت); punctuation ، ۔ ؟ ! is
// not part of a word; the izafat kasra stays on its word.
export type Variant = { shown: string; others: string[]; source?: string };
// a line in order: plain text, a word linked to the dictionary, a footnote, a variant reading (for editors)
export type Segment = { text: string } | { text: string; lemma: string } | { note: string } | { variant: Variant };
export type Inline = { text: string; words: { shown: string; lemma: string }[]; notes: string[]; variants: Variant[]; segments: Segment[] };
export type Inline = { text: string; words: { shown: string; lemma: string }[]; notes: string[]; variants: { shown: string; others: string[]; source?: string }[] };
export type Block =
| { type: 'heading'; text: string; level: number } // == chapter == is level 2, === sub-heading === level 3, …
| { type: 'heading'; text: string }
| { type: 'para'; line: Inline }
| { type: 'couplet' | 'line' | 'stanza'; lines: Inline[]; label?: string };
export type Doc = { meta: Record<string, string>; blocks: Block[] };
@ -47,58 +44,21 @@ function template(src: string) {
}
// one line of text: links, notes and variants out, plain text in
const TOKEN = /<ref>([\s\S]*?)<\/ref>|(\{\{نسخہ\|[^{}]*\}\})|\[\[(?:لغت:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
export function inline(src: string): Inline {
const segments: Segment[] = [];
let at = 0;
const plain = (t: string) => { if (t) segments.push({ text: t.replace(/[ \t]+/g, ' ') }); };
for (const m of src.matchAll(TOKEN)) {
plain(src.slice(at, m.index));
at = m.index! + m[0].length;
if (m[1] !== undefined) segments.push({ note: m[1].trim() });
else if (m[2]) {
const { fields } = template(m[2]);
segments.push({ variant: { shown: fields['1'] ?? '', others: Object.keys(fields).filter((k) => /^\d+$/.test(k) && k !== '1').map((k) => fields[k]),
...(fields['ماخذ'] && { source: fields['ماخذ'] }) } });
} else segments.push({ lemma: m[3].trim(), text: (m[4] ?? m[3]).trim() });
}
plain(src.slice(at));
// trim the line's outer spaces (in its first and last text)
const texts = segments.filter((x): x is { text: string } => 'text' in x && !('lemma' in x));
if (texts[0] && segments[0] === texts[0]) texts[0].text = texts[0].text.trimStart();
if (texts.at(-1) && segments.at(-1) === texts.at(-1)) texts.at(-1)!.text = texts.at(-1)!.text.trimEnd();
const shown = segments.map((x) => ('text' in x ? x.text : 'variant' in x ? x.variant.shown : '')).join('');
return {
text: shown.replace(/[ \t]+/g, ' ').trim(),
words: segments.filter((x): x is { text: string; lemma: string } => 'lemma' in x).map((x) => ({ shown: x.text, lemma: x.lemma })),
notes: segments.filter((x): x is { note: string } => 'note' in x).map((x) => x.note),
variants: segments.filter((x): x is { variant: Variant } => 'variant' in x).map((x) => x.variant),
segments: segments.filter((x) => !('text' in x && !('lemma' in x) && x.text === '')),
};
}
// ---- writing Divan text back (editors build a Doc; this is its text) ----
export const segmentsText = (segs: Segment[]) => segs.map((x) =>
'note' in x ? `<ref>${x.note}</ref>`
: 'variant' in x ? `{{نسخہ|${[x.variant.shown, ...x.variant.others].join('|')}${x.variant.source ? `|ماخذ=${x.variant.source}` : ''}}}`
: 'lemma' in x ? (x.lemma === x.text ? `[[${x.text}]]` : `[[لغت:${x.lemma}|${x.text}]]`)
: x.text).join('');
export function toText(doc: Doc) {
const out: string[] = [];
const meta = Object.entries(doc.meta).filter(([, v]) => v !== undefined);
if (meta.length) out.push(`{{دیوان\n${meta.map(([k, v]) => `| ${k} = ${v}`).join('\n')}\n}}`);
let verse: string[] = [];
const flush = () => { if (verse.length) out.push(`<poem>\n${verse.join('\n\n')}\n</poem>`); verse = []; };
for (const b of doc.blocks) {
if (b.type === 'heading') { flush(); const eq = '='.repeat(b.level); out.push(`${eq} ${b.text} ${eq}`); }
else if (b.type === 'para') { flush(); out.push(segmentsText(b.line.segments)); }
else verse.push(b.lines.map((l, i) => (i === 0 && b.label ? `{{${b.label}}} ` : '') + segmentsText(l.segments)).join('\n'));
}
flush();
return out.join('\n\n') + '\n';
const words: Inline['words'] = [], notes: string[] = [], variants: Inline['variants'] = [];
let text = src
.replace(/<ref>([\s\S]*?)<\/ref>/g, (_, n) => { notes.push(n.trim()); return ''; })
.replace(/\{\{نسخہ\|[^{}]*\}\}/g, (t) => {
const { fields } = template(t), others = Object.keys(fields).filter((k) => /^\d+$/.test(k) && k !== '1').map((k) => fields[k]);
variants.push({ shown: fields['1'] ?? '', others, source: fields['ماخذ'] });
return fields['1'] ?? '';
})
.replace(/\[\[(?:لغت:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, lemma, shown) => {
words.push({ lemma: lemma.trim(), shown: (shown ?? lemma).trim() });
return (shown ?? lemma).trim();
});
text = text.replace(/[ \t]+/g, ' ').trim();
return { text, words, notes, variants };
}
export function parse(src: string): Doc {
@ -116,8 +76,8 @@ export function parse(src: string): Doc {
if (!lines.length) continue;
if (!verse) {
for (const l of lines) {
const h = l.match(/^(=+)\s*(.+?)\s*=+$/);
blocks.push(h ? { type: 'heading', text: h[2], level: h[1].length } : { type: 'para', line: inline(l) });
const h = l.match(/^=+\s*(.+?)\s*=+$/);
blocks.push(h ? { type: 'heading', text: h[1] } : { type: 'para', line: inline(l) });
}
continue;
}
@ -138,11 +98,10 @@ export const words = (text: string) =>
// → the site's verse JSON (the ganjoor-data layout divan-data already exports)
export function toVerses(doc: Doc) {
const out: { VOrder: number; Position: string; Text: string; CoupletIndex: number; Level?: number }[] = [];
const out: { VOrder: number; Position: string; Text: string; CoupletIndex: number }[] = [];
let couplet = 0;
for (const b of doc.blocks) {
// headings inside a work (chapter, sub-heading) are their own kind of verse entry
if (b.type === 'heading') { out.push({ VOrder: 0, Position: 'Heading', Text: b.text, CoupletIndex: couplet++, Level: b.level }); continue; }
if (b.type === 'heading') continue;
if (b.type === 'para') out.push({ VOrder: 0, Position: 'Paragraph', Text: b.line.text, CoupletIndex: couplet++ });
else if (b.type === 'couplet') { b.lines.forEach((l, i) => out.push({ VOrder: 0, Position: i ? 'Left' : 'Right', Text: l.text, CoupletIndex: couplet })); couplet++; }
else for (const l of b.lines) out.push({ VOrder: 0, Position: 'Single', Text: l.text, CoupletIndex: couplet++ });
@ -164,7 +123,7 @@ export function toTEI(doc: Doc) {
const m = doc.meta, body: string[] = [];
let n = 0;
for (const b of doc.blocks) {
if (b.type === 'heading') body.push(`<head type="h${b.level}">${x(b.text)}</head>`);
if (b.type === 'heading') body.push(`<head>${x(b.text)}</head>`);
else if (b.type === 'para') body.push(`<p>${teiLine(b.line)}</p>`);
else {
const type = b.type === 'couplet' ? 'sher' : b.type === 'stanza' ? 'band' : 'misra';

View File

@ -1,131 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import Fastify from 'fastify';
import JSZip from 'jszip';
import { authRoutes } from './auth.ts';
import { moderationRoutes } from './moderation.ts';
import { libraryRoutes } from './library.ts';
import { ebookRoutes, archiveId, parseMeta, metaText } from './ebooks.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('e-book details and archive.org links', () => {
const m = parseMeta(metaText({ title: 'کلیات اقبال', source: 'archive.org', licence: 'عوامی ملکیت' })) as any;
assert.deepEqual([m.title, m.source, m.licence, m.language, m.direction], ['کلیات اقبال', 'archive.org', 'عوامی ملکیت', 'اردو', 'دائیں سے بائیں']);
assert.equal((parseMeta(metaText({ title: 'Kulliyat', language: 'انگریزی' })) as any).direction, 'بائیں سے دائیں', 'English reads left to right');
assert.match(parseMeta(metaText({ title: 'x', language: 'لاطینی' })) as string, /زبان/);
assert.match(parseMeta(metaText({ title: 'x', direction: 'اوپر سے نیچے' })) as string, /سمت/);
assert.match(parseMeta('ماخذ: کچھ') as string, /عنوان/);
assert.equal(archiveId('https://archive.org/details/BaangEDara/page/n5'), 'BaangEDara');
assert.equal(archiveId('kulliyat-e-iqbal_202001'), 'kulliyat-e-iqbal_202001');
assert.equal(archiveId('https://example.com/x'), null);
});
test('e-books: upload permission, file kinds, pipeline, published list and files, divan-data record', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-')), files = await mkdtemp(join(tmpdir(), 'divan-files-'));
Object.assign(process.env, { DIVAN_DATA_DIR: data, DIVAN_FILES_DIR: files });
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify();
authRoutes(app); moderationRoutes(app); ebookRoutes(app); libraryRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: any, token?: string, type?: string) => app.inject({
method: method as any, url, payload: body,
headers: { ...(token && { authorization: `Bearer ${token}` }), ...(type && { 'content-type': type }), 'x-client-ip': `eb-${run}` } });
const upload = (name: string, bytes: Buffer, token: string, poet = 238) =>
call('POST', `/api/mod/ebooks/upload?poet=${poet}&name=${encodeURIComponent(name)}`, bytes, token, 'application/octet-stream');
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
const pdf = Buffer.from('%PDF-1.4\n% آزمائشی کتاب ' + run + '\n%%EOF\n');
try {
assert.equal((await upload('a.pdf', pdf, l2.token)).statusCode, 403, 'needs the ebooks permission');
for (const m of [l2, l1]) await pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'poet', 238, '{ebooks}', '{create}')`, [m.id]);
assert.equal((await upload('a.pdf', pdf, l2.token, 266)).statusCode, 403, 'only for the granted poet');
// kinds: PDF, EPUB (by its mimetype entry), .docx made into text; anything else refused
const up = (await upload('کتاب.pdf', pdf, l2.token)).json();
assert.equal(up.kind, 'pdf'); assert.match(up.file, /^[0-9a-f]{64}\.pdf$/);
assert.equal((await upload('again.pdf', pdf, l2.token)).json().file, up.file, 'same file, same name (kept once)');
const epub = new JSZip(); epub.file('mimetype', 'application/epub+zip', { compression: 'STORE' }); epub.file('OEBPS/a.xhtml', '<p>ا</p>');
assert.equal((await upload('b.epub', await epub.generateAsync({ type: 'nodebuffer' }), l2.token)).json().kind, 'epub');
const docx = new JSZip();
docx.file('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
docx.file('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
docx.file('word/document.xml', '<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>دل ہی تو ہے نہ سنگ و خشت</w:t></w:r></w:p></w:body></w:document>');
const d = (await upload('c.docx', await docx.generateAsync({ type: 'nodebuffer' }), l2.token)).json();
assert.equal(d.kind, 'text'); assert.match(d.file, /\.txt$/);
assert.equal((await upload('x.exe', Buffer.from('MZ....'), l2.token)).statusCode, 415);
// the e-book and its draft; not listed and not served until approved
assert.equal((await call('POST', '/api/mod/ebooks', { poet: 238, file: up.file }, l2.token)).statusCode, 400, 'a title is required');
const made = (await call('POST', '/api/mod/ebooks', { poet: 238, file: up.file, title: `بانگ درا ${run}`, source: 'archive.org', licence: 'عوامی ملکیت',
writer: 'علامہ محمد اقبال', coauthors: ['مرزا غالب', `نیا شاعر ${run}`], tags: `کلاسیکی-${run}، قومی-${run}`, language: 'اردو', direction: 'rtl' }, l2.token)).json();
assert.ok(made.id && made.revision);
assert.equal((await call('GET', `/api/ebook/${made.id}/file`)).statusCode, 404, 'not public yet');
assert.equal((await call('GET', `/api/ebook/${made.id}/file`, undefined, l1.token)).statusCode, 200, 'reviewers can open it');
assert.equal((await call('GET', '/api/mod/queue', undefined, l2.token)).json().mine.some((r: any) => r.id === made.revision), true, 'a new upload is a change');
assert.equal((await call('POST', `/api/mod/revisions/${made.revision}/submit`, {}, l2.token)).json().status, 'submitted');
assert.equal((await call('POST', `/api/mod/revisions/${made.revision}/approve`, {}, l1.token)).json().status, 'approved');
assert.deepEqual((await call('POST', `/api/mod/revisions/${made.revision}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
assert.deepEqual((await call('GET', '/api/ebooks?poet=238')).json().map((b: any) => b.title), [`بانگ درا ${run}`]);
// co-authors: Ghalib is on the site (linked, and the book is listed on his page too); the new name is plain text
const shown = (await call('GET', `/api/ebook/${made.id}`)).json();
assert.deepEqual(shown.coauthors, [{ name: 'مرزا غالب', url: '/p266' }, { name: `نیا شاعر ${run}`, url: null }]);
assert.deepEqual([shown.writer, shown.language, shown.direction], ['علامہ محمد اقبال', 'اردو', 'rtl']);
assert.deepEqual(shown.tags.map((t: any) => t.name).sort(), [`قومی-${run}`, `کلاسیکی-${run}`]);
assert.ok((await call('GET', '/api/ebooks?poet=266')).json().some((b: any) => b.id === made.id && b.co), 'listed for the co-author');
const file = await call('GET', `/api/ebook/${made.id}/file`);
assert.equal(file.statusCode, 200); assert.equal(file.headers['content-type'], 'application/pdf'); assert.deepEqual(file.rawPayload, pdf);
const part = await app.inject({ method: 'GET', url: `/api/ebook/${made.id}/file`, headers: { range: 'bytes=0-7' } });
assert.equal(part.statusCode, 206); assert.equal(part.headers['content-range'], `bytes 0-7/${pdf.length}`); assert.equal(part.body, '%PDF-1.4');
assert.equal((await app.inject({ method: 'GET', url: `/api/ebook/${made.id}/file`, headers: { range: 'bytes=999999-' } })).statusCode, 416);
const record = await readFile(join(data, 'divan', 'p238', 'ebooks', `${made.id}.txt`), 'utf8');
assert.match(record, new RegExp(`عنوان: بانگ درا ${run}`)); assert.match(record, new RegExp(up.file));
assert.match(git('log', '-1', '--format=%s'), /^ای بک: /);
assert.ok((await pool.query(`SELECT 1 FROM ebooks WHERE id = $1 AND search_text LIKE '%بانگ درا%'`, [made.id])).rowCount, 'indexed for search');
// a cover image (the site makes it from the PDF's first page); only images, only with the permission
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
assert.equal((await call('POST', `/api/mod/ebooks/${made.id}/cover`, Buffer.from('not an image'), l2.token, 'application/octet-stream')).statusCode, 415);
assert.match((await call('POST', `/api/mod/ebooks/${made.id}/cover`, png, l2.token, 'application/octet-stream')).json().cover, /\.png$/);
const cover = await call('GET', `/api/ebook/${made.id}/cover`);
assert.equal(cover.headers['content-type'], 'image/png'); assert.deepEqual(cover.rawPayload, png);
assert.equal((await call('GET', '/api/ebooks?poet=238')).json()[0].has_cover, true);
// readers bookmark pages of a published book in their library
const reader = await person('reader', 'reader');
const mark = (page: number, ebookId = made.id) => call('POST', '/api/library/toggle', { kind: 'page', ebookId, page }, reader.token);
assert.equal((await mark(12)).json().saved, true); assert.equal((await mark(3)).json().saved, true);
assert.deepEqual((await call('GET', `/api/library/state?ebook=${made.id}`, undefined, reader.token)).json(), { pages: [3, 12] });
assert.equal((await mark(3)).json().saved, false, 'pressed again: removed');
const lib = (await call('GET', '/api/library', undefined, reader.token)).json();
assert.deepEqual(lib.pages.map((p: any) => [p.page, p.ebook.title]), [[12, `بانگ درا ${run}`]]);
assert.equal((await mark(0)).statusCode, 400);
// a text book serves its text; an archive.org item needs a real id
const t = (await call('POST', '/api/mod/ebooks', { poet: 238, file: d.file, title: 'ٹیکسٹ' }, admin.token)).json();
assert.match((await call('GET', `/api/ebook/${t.id}`, undefined, admin.token)).json().text, /دل ہی تو ہے/);
assert.equal((await call('POST', '/api/mod/ebooks', { poet: 238, archive: 'https://example.com/x', title: 'x' }, admin.token)).statusCode, 400);
const a = (await call('POST', '/api/mod/ebooks', { poet: 238, archive: 'https://archive.org/details/BaangEDara', title: 'آرکائیو' }, admin.token)).json();
assert.equal((await mark(1, a.id)).statusCode, 404, 'no bookmarks in a book under review');
assert.equal((await call('GET', `/api/ebook/${a.id}`, undefined, admin.token)).json().archive_id, 'BaangEDara');
} finally {
await pool.query(`DELETE FROM entity_tags WHERE entity = 'ebook' AND entity_id IN (SELECT id FROM ebooks WHERE created_by IN (SELECT id FROM users WHERE email LIKE $1))`, [`%-${run}@divan.test`]);
await pool.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
await pool.query(`DELETE FROM ebooks WHERE created_by IN (SELECT id FROM users WHERE email LIKE $1)`, [`%-${run}@divan.test`]);
await pool.query(`DELETE FROM revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true }); await rm(files, { recursive: true, force: true });
await app.close();
}
});

View File

@ -1,308 +0,0 @@
// E-books (owner request): a poet's books as files (PDF, EPUB, text; a .docx becomes text) or archive.org items,
// uploaded by moderators with the 'ebooks' permission and listed under ای بکس on the poet's page once approved.
// Anyone with the permission may upload any book; reviewers (L1, admin) confirm it, as with every other change.
// Files live in the file store (DIVAN_FILES_DIR) under their content hash, ab/cd/<sha256>.<ext>, so a file uploaded
// twice is kept once; the database holds the details, indexed by poet and (trigram) by title and the text of text
// books, so lists and search stay fast. An approved e-book's details (not the file) are committed to divan-data.
// The details are a revision (entity 'ebook'), one per line: عنوان، مصنف (as printed)، شریک مصنفین (names, comma
// separated; those who are poets on the site are linked and the book is listed on their pages too)، زبان، سمت
// (دائیں سے بائیں / بائیں سے دائیں: how the reader turns pages)، ٹیگ (extra tags, comma separated)، ماخذ، اجازت، تفصیل.
// POST /api/mod/ebooks/upload?poet=&name= the file as the request body (up to 100 MB): {file, kind, size}
// POST /api/mod/ebooks {poet, title, file | archive, source, licence, note}: the e-book and its draft
// GET /api/ebooks?poet= a poet's published e-books
// GET /api/ebook/:id one e-book (unpublished: moderators only); the text of a text book
// GET /api/ebook/:id/file its file
// POST /api/mod/ebooks/:id/cover a cover image as the body (JPEG, PNG or WebP, up to 5 MB); the site makes
// one from a PDF's first page or an EPUB's own cover in the browser
// GET /api/ebook/:id/cover the cover
import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { PoolClient } from 'pg';
import mammoth from 'mammoth';
import { pool } from './db.ts';
import { sessionUser } from './auth.ts';
import { can } from './permissions.ts';
import { normalise } from './urdu.ts';
const MAX = 100 * 1024 * 1024;
const store = () => process.env.DIVAN_FILES_DIR ?? new URL('../../../divan-files', import.meta.url).pathname;
const pathOf = (file: string) => join(store(), file.slice(0, 2), file.slice(2, 4), file);
const TYPES: Record<string, string> = { pdf: 'application/pdf', epub: 'application/epub+zip', txt: 'text/plain; charset=utf-8',
jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
// an image's format from its first bytes
const imageOf = (h: Buffer) => h[0] === 0xff && h[1] === 0xd8 ? 'jpg' : h.subarray(1, 4).toString() === 'PNG' ? 'png'
: h.subarray(0, 4).toString() === 'RIFF' && h.subarray(8, 12).toString() === 'WEBP' ? 'webp' : null;
const KEYS = { title: 'عنوان', writer: 'مصنف', coauthors: 'شریک مصنفین', language: 'زبان', direction: 'سمت', tags: 'ٹیگ',
source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' } as const;
type Meta = Record<keyof typeof KEYS, string>;
export const LANGUAGES = ['اردو', 'فارسی', 'عربی', 'انگریزی', 'ہندی', 'پنجابی', 'سندھی', 'پشتو', 'دیگر'];
export const DIRECTIONS: Record<string, 'rtl' | 'ltr'> = { 'دائیں سے بائیں': 'rtl', 'بائیں سے دائیں': 'ltr' };
const RTL_LANGS = ['اردو', 'فارسی', 'عربی', 'سندھی', 'پشتو'];
const list = (s: string) => [...new Set(s.split(/[،,]/).map((x) => x.trim()).filter(Boolean))].slice(0, 30);
const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
export const mayUpload = (u: any, poetId: number) => can(u, 'create', 'ebooks', { poetId });
// the details as revision text, and back
export const metaText = (m: Partial<Meta>) =>
(Object.keys(KEYS) as (keyof Meta)[]).map((k) => `${KEYS[k]}: ${String(m[k] ?? '').replace(/\s+/g, ' ').trim()}`).join('\n');
export function parseMeta(text: string): Meta | string {
const m: any = Object.fromEntries(Object.keys(KEYS).map((k) => [k, '']));
for (const line of text.split('\n')) {
const i = line.indexOf(':'); if (i < 0) continue;
const key = (Object.keys(KEYS) as (keyof Meta)[]).find((k) => KEYS[k] === line.slice(0, i).trim());
if (key) m[key] = line.slice(i + 1).trim().slice(0, key === 'note' ? 2000 : 300);
}
if (!m.title) return 'کتاب کا عنوان ضروری ہے';
m.language ||= 'اردو';
if (!LANGUAGES.includes(m.language)) return `زبان ${LANGUAGES.join('، ')} میں سے ہو`;
m.direction ||= RTL_LANGS.includes(m.language) ? 'دائیں سے بائیں' : 'بائیں سے دائیں';
if (!DIRECTIONS[m.direction]) return 'سمت «دائیں سے بائیں» یا «بائیں سے دائیں» ہو';
if (list(m.tags).some((t) => t.length > 60)) return 'ٹیگ بہت لمبا ہے';
return m;
}
// co-authors who are poets on the site (by name or pen name, Urdu-normalised)
async function poetsNamed(names: string[], db: { query: PoolClient['query'] } = pool) {
if (!names.length) return new Map<string, { id: number; url: string }>();
const { rows } = await db.query('SELECT id, url, name, nickname FROM poets');
const out = new Map<string, { id: number; url: string }>();
for (const n of names) {
const hit = rows.find((p) => normalise(p.nickname) === normalise(n) || normalise(p.name) === normalise(n));
if (hit) out.set(n, { id: hit.id, url: hit.url });
}
return out;
}
// archive.org: an item id, or a link to it
export const archiveId = (s: string) => {
const id = String(s ?? '').trim().replace(/^https?:\/\/(www\.)?archive\.org\/(details|embed)\//, '').split(/[/?#]/)[0];
return /^[A-Za-z0-9._-]{3,100}$/.test(id) ? id : null;
};
// what the bytes are: PDF, EPUB (a zip whose first entry is "mimetype"), a .docx (made into text), or UTF-8 text
async function kindOf(path: string, name: string) {
const fh = await open(path); const head = Buffer.alloc(64);
await fh.read(head, 0, 64, 0); await fh.close();
if (head.subarray(0, 5).toString() === '%PDF-') return 'pdf';
if (head.subarray(0, 4).toString('binary') === 'PK\x03\x04') {
if (head.subarray(30, 58).toString() === 'mimetypeapplication/epub+zip') return 'epub';
if (/\.docx$/i.test(name)) return 'docx';
return null;
}
if (/\.txt$/i.test(name)) {
try { new TextDecoder('utf-8', { fatal: true }).decode(await readFile(path)); return 'txt'; } catch { return null; }
}
return null;
}
// keep a file in the store under its hash; returns its name there
async function keep(tmp: string, ext: string) {
const sha = createHash('sha256').update(await readFile(tmp)).digest('hex');
const file = `${sha}.${ext}`, dest = pathOf(file);
await mkdir(dirname(dest), { recursive: true });
if (await stat(dest).catch(() => null)) await rm(tmp); else await rename(tmp, dest);
return file;
}
const textOf = async (file: string) => (await readFile(pathOf(file), 'utf8')).replace(/^/, '');
const searchText = async (title: string, kind: string, file: string | null) =>
normalise([title, kind === 'text' && file ? (await textOf(file)).slice(0, 2_000_000) : ''].join(' '));
// an e-book's published details (empty until approved, so its first draft is a change), and its version
export async function currentEbook(id: number) {
const ebook = (await pool.query(
'SELECT b.*, t.url AS poet_url, t.nickname AS poet FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.id = $1', [id])).rows[0];
if (!ebook) return null;
const last = (await pool.query(`SELECT max(version) AS v FROM revisions WHERE entity = 'ebook' AND entity_id = $1 AND status = 'published'`, [id])).rows[0];
const tags = (await pool.query(`SELECT t.name FROM entity_tags e JOIN tags t ON t.id = e.tag_id WHERE e.entity = 'ebook' AND e.entity_id = $1 ORDER BY t.name`, [id])).rows.map((r) => r.name);
const text = metaText({ ...ebook, coauthors: ebook.coauthors.join('، '), tags: tags.join('، '),
direction: Object.keys(DIRECTIONS).find((k) => DIRECTIONS[k] === ebook.direction) });
return { ebook, version: Number(last?.v ?? 0), content: ebook.published ? text : '' };
}
export const checkEbook = (text: string) => { const m = parseMeta(text); return typeof m === 'string' ? m : null; };
// publishing: the details become the e-book's and it is listed
export async function applyEbook(client: PoolClient, id: number, text: string) {
const m = parseMeta(text) as Meta;
const b = (await client.query('SELECT kind, file FROM ebooks WHERE id = $1', [id])).rows[0];
const co = list(m.coauthors), linked = await poetsNamed(co, client);
await client.query(
`UPDATE ebooks SET title = $2, source = $3, licence = $4, note = $5, search_text = $6, writer = $7, language = $8, direction = $9,
coauthors = $10, coauthor_ids = $11, published = true WHERE id = $1`,
[id, m.title, m.source || null, m.licence || null, m.note || null,
await searchText([m.title, m.writer, ...co, ...list(m.tags)].join(' '), b.kind, b.file), m.writer || null, m.language,
DIRECTIONS[m.direction], co, [...new Set([...linked.values()].map((p) => p.id))]]);
// extra tags: free tags (ٹیگ), so tag pages list the book
await client.query(`DELETE FROM entity_tags WHERE entity = 'ebook' AND entity_id = $1`, [id]);
for (const name of list(m.tags)) {
const tagId = (await client.query(`INSERT INTO tags (type, name) VALUES ('ٹیگ', $1) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name])).rows[0].id;
await client.query(`INSERT INTO entity_tags (tag_id, entity, entity_id) VALUES ($1, 'ebook', $2) ON CONFLICT DO NOTHING`, [tagId, id]);
}
await client.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
}
// divan-data/divan/<poet>/ebooks/<id>.txt: the details and where the file is (the file itself stays in the store)
export async function writeEbook(dataDir: string, ebook: any, text: string) {
const path = join(dataDir, 'divan', ebook.poet_url.replace(/^\//, ''), 'ebooks', `${ebook.id}.txt`);
const where = ebook.kind === 'archive' ? `آرکائیو: https://archive.org/details/${ebook.archive_id}`
: `فائل: ${ebook.file} (${ebook.kind}، ${ebook.size} بائٹ)`;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${text.trim()}\n${where}\n`);
return path;
}
const viewer = async (req: FastifyRequest) => { const u = await sessionUser(req); return isModerator(u) ? u : null; };
export function ebookRoutes(app: FastifyInstance) {
// uploads arrive as the raw request body
app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload));
app.post<{ Querystring: { poet?: string; name?: string } }>('/api/mod/ebooks/upload', async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
if (!(await mayUpload(u, Number(req.query.poet) || 0))) return reply.code(403).send({ error: 'اس شاعر کی ای بکس شامل کرنے کی اجازت نہیں' });
const tmp = join(store(), 'tmp', randomUUID());
await mkdir(dirname(tmp), { recursive: true });
let size = 0;
try {
await new Promise<void>((resolve, reject) => {
const out = createWriteStream(tmp);
const body = req.body as NodeJS.ReadableStream;
body.on('data', (c: Buffer) => { size += c.length; if (size > MAX) { body.removeAllListeners('data'); out.destroy(); reject(Object.assign(new Error('فائل ۱۰۰ میگا بائٹ سے بڑی ہے'), { code: 413 })); } else out.write(c); });
body.on('end', () => out.end(() => resolve()));
body.on('error', reject);
});
if (!size) throw Object.assign(new Error('فائل خالی ہے'), { code: 400 });
const kind = await kindOf(tmp, String(req.query.name ?? ''));
if (!kind) throw Object.assign(new Error('صرف PDF، EPUB، TXT یا DOCX فائلیں'), { code: 415 });
if (kind === 'docx') {
const { value } = await mammoth.extractRawText({ path: tmp });
await writeFile(tmp, value.replace(/\n{3,}/g, '\n\n').trim() + '\n');
size = (await stat(tmp)).size;
}
const ext = kind === 'docx' ? 'txt' : kind;
return { file: await keep(tmp, ext), kind: ext === 'txt' ? 'text' : ext, size };
} catch (e: any) {
await rm(tmp, { force: true });
return reply.code(e.code ?? 500).send({ error: e.message });
}
});
app.post<{ Body: any }>('/api/mod/ebooks', async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
const b = req.body ?? {}, poetId = Number(b.poet) || 0;
if (!(await mayUpload(u, poetId))) return reply.code(403).send({ error: 'اس شاعر کی ای بکس شامل کرنے کی اجازت نہیں' });
const meta = parseMeta(metaText({ ...b, coauthors: Array.isArray(b.coauthors) ? b.coauthors.join('، ') : b.coauthors,
direction: DIRECTIONS[b.direction] ? b.direction : b.direction === 'ltr' ? 'بائیں سے دائیں' : b.direction === 'rtl' ? 'دائیں سے بائیں' : '' }));
if (typeof meta === 'string') return reply.code(400).send({ error: meta });
let kind: string, file: string | null = null, archive: string | null = null, size: number | null = null;
if (b.archive) {
archive = archiveId(b.archive);
if (!archive) return reply.code(400).send({ error: 'آرکائیو کا ربط درست نہیں (archive.org/details/…)' });
kind = 'archive';
} else {
file = /^[0-9a-f]{64}\.(pdf|epub|txt)$/.test(b.file ?? '') ? b.file : null;
const st = file && (await stat(pathOf(file)).catch(() => null));
if (!file || !st) return reply.code(400).send({ error: 'پہلے فائل اپ لوڈ کریں' });
kind = file.endsWith('.txt') ? 'text' : file.slice(-4).replace('.', '');
size = st.size;
}
const content = metaText(meta);
const client = await pool.connect();
try {
await client.query('BEGIN');
const id = (await client.query(
`INSERT INTO ebooks (poet_id, title, kind, file, archive_id, size, source, licence, note, created_by, writer, language, direction, coauthors)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id`,
[poetId, meta.title, kind, file, archive, size, meta.source || null, meta.licence || null, meta.note || null, u.id,
meta.writer || null, meta.language, DIRECTIONS[meta.direction], list(meta.coauthors)])).rows[0].id;
const rev = (await client.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email, summary)
VALUES ('ebook', $1, 0, '', $2, 'draft', $3, $4, $5) RETURNING id`, [id, content, u.id, u.email, 'نئی ای بک'])).rows[0].id;
await client.query(`INSERT INTO revision_events (revision_id, actor_id, actor_email, action) VALUES ($1, $2, $3, 'created')`, [rev, u.id, u.email]);
await client.query('COMMIT');
return { id, revision: Number(rev) };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.get<{ Querystring: { poet?: string } }>('/api/ebooks', async (req) => (await pool.query(
`SELECT id, title, kind, size, source, cover IS NOT NULL AS has_cover, archive_id, language, poet_id <> $1 AS co
FROM ebooks WHERE (poet_id = $1 OR $1 = ANY(coauthor_ids)) AND published ORDER BY title`,
[Number(req.query.poet) || 0])).rows);
app.post<{ Params: { id: string } }>('/api/mod/ebooks/:id/cover', { bodyLimit: 5 * 1024 * 1024 }, async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
const b = (await pool.query('SELECT id, poet_id FROM ebooks WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
if (!b) return reply.code(404).send({ error: 'کتاب نہیں ملی' });
if (!(await mayUpload(u, b.poet_id))) return reply.code(403).send({ error: 'اس کتاب کا سرورق بدلنے کی اجازت نہیں' });
const chunks: Buffer[] = [];
let size = 0;
for await (const c of req.body as AsyncIterable<Buffer>) {
size += c.length;
if (size > 5 * 1024 * 1024) return reply.code(413).send({ error: 'تصویر ۵ میگا بائٹ سے بڑی ہے' });
chunks.push(c);
}
const img = Buffer.concat(chunks), ext = imageOf(img);
if (!ext) return reply.code(415).send({ error: 'صرف JPEG، PNG یا WebP تصویر' });
const tmp = join(store(), 'tmp', randomUUID());
await mkdir(dirname(tmp), { recursive: true });
await writeFile(tmp, img);
const cover = await keep(tmp, ext);
await pool.query('UPDATE ebooks SET cover = $2 WHERE id = $1', [b.id, cover]);
return { cover };
});
const ebook = async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const b = (await pool.query(
'SELECT b.*, t.url AS poet_url, t.nickname AS poet FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.id = $1', [Number(req.params.id) || 0])).rows[0];
if (!b || (!b.published && !(await viewer(req)))) return void reply.code(404).send({ error: 'کتاب نہیں ملی' });
return b;
};
app.get<{ Params: { id: string } }>('/api/ebook/:id', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return;
const { search_text, created_by, ...rest } = b;
// co-authors: linked to their pages when they are poets on the site
const linked = await poetsNamed(b.coauthors);
const tags = (await pool.query(`SELECT t.type, t.name FROM entity_tags e JOIN tags t ON t.id = e.tag_id WHERE e.entity = 'ebook' AND e.entity_id = $1 ORDER BY t.name`, [b.id])).rows;
return { ...rest, coauthors: b.coauthors.map((name: string) => ({ name, url: linked.get(name)?.url ?? null })), tags,
text: b.kind === 'text' ? (await textOf(b.file)).slice(0, 3_000_000) : undefined };
});
app.get<{ Params: { id: string } }>('/api/ebook/:id/cover', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return;
if (!b.cover) return reply.code(404).send({ error: 'سرورق نہیں' });
reply.header('content-type', TYPES[b.cover.split('.').pop()]).header('cache-control', b.published ? 'public, max-age=604800' : 'private, no-store');
return reply.send(createReadStream(pathOf(b.cover)));
});
app.get<{ Params: { id: string } }>('/api/ebook/:id/file', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return;
if (!b.file) return reply.code(404).send({ error: 'اس کتاب کی فائل نہیں' });
const ext = b.file.split('.').pop(), size = (await stat(pathOf(b.file))).size;
reply.header('content-type', TYPES[ext]).header('accept-ranges', 'bytes')
.header('content-disposition', `inline; filename="divan-${b.id}.${ext}"`)
.header('cache-control', b.published ? 'public, max-age=86400' : 'private, no-store');
// a byte range (the PDF reader asks for the pages it shows, not the whole file)
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ''));
if (m && (m[1] || m[2])) {
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2])), end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
if (start > end || start >= size) return reply.code(416).header('content-range', `bytes */${size}`).send();
reply.code(206).header('content-range', `bytes ${start}-${end}/${size}`).header('content-length', String(end - start + 1));
return reply.send(createReadStream(pathOf(b.file), { start, end }));
}
reply.header('content-length', String(size));
return reply.send(createReadStream(pathOf(b.file)));
});
}

View File

@ -1,33 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { feedRoutes } from './feeds.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('sitemap and feed: every public page; a published version appears with its date, summary, author and lines', async () => {
const app = Fastify(); feedRoutes(app);
const work = (await pool.query(`SELECT id, url FROM poems WHERE url LIKE '/p266/ghazal/%' ORDER BY id LIMIT 1`)).rows[0];
const when = new Date(Date.now() + 60_000).toISOString(); // newer than anything else, so it comes first
const rev = (await pool.query(
`INSERT INTO revisions (entity, entity_id, version, base_version, content, status, author_email, summary, published_at, credits)
VALUES ('work', $1, 99, 98, '', 'published', 'feed-test@divan.test', 'آزمائشی ترمیم', $2, $3) RETURNING id`,
[work.id, when, { by: 'آزمائشی موڈریٹر', publishedBy: 'ایڈمن' }])).rows[0].id;
try {
const s = (await app.inject('/api/sitemap')).json();
assert.ok(s.poets.length > 300 && s.categories.length > 500 && s.works.length > 10000, 'all the public pages');
assert.ok(s.poets.includes('/p266'));
assert.equal(new Date(s.works.find((w: any) => w.url === work.url).updated).toISOString(), when, "a work's date is its latest version");
const feed = (await app.inject('/api/feed?limit=5')).json();
assert.ok(feed.length >= 1 && feed.length <= 5);
const first = feed[0];
assert.deepEqual([first.kind, first.url, first.version, first.summary, first.by], ['work', work.url, 99, 'آزمائشی ترمیم', 'آزمائشی موڈریٹر']);
assert.ok(first.lines.length >= 1, 'the opening lines');
assert.equal((await app.inject('/api/feed?limit=999')).json().length <= 100, true, 'at most 100');
} finally {
await pool.query('DELETE FROM revisions WHERE id = $1', [rev]);
await app.close();
}
});

View File

@ -1,60 +0,0 @@
// What the site's sitemap.xml and rss.xml are made from (#83, #87).
// GET /api/sitemap every public page: poets, books/chapters, works (with the date of their latest Divan
// version), tags, e-books
// GET /api/feed?limit= the latest published changes, newest first: new versions of works (version, summary, the
// opening lines), e-books, arrangements and tags; who made each (public names)
import type { FastifyInstance } from 'fastify';
import { pool } from './db.ts';
export function feedRoutes(app: FastifyInstance) {
app.get('/api/sitemap', async (_req, reply) => {
const [poets, cats, works, tags, ebooks] = await Promise.all([
pool.query('SELECT url FROM poets ORDER BY id'),
pool.query('SELECT url FROM categories WHERE parent_id IS NOT NULL ORDER BY id'),
pool.query(`SELECT p.url, max(r.published_at) AS updated FROM poems p
LEFT JOIN revisions r ON r.entity = 'work' AND r.entity_id = p.id AND r.status = 'published'
GROUP BY p.id ORDER BY p.id`),
pool.query('SELECT DISTINCT t.type, t.name FROM tags t JOIN entity_tags e ON e.tag_id = t.id ORDER BY t.type, t.name'),
pool.query('SELECT b.id, t.url AS poet_url FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.published ORDER BY b.id'),
]);
reply.header('cache-control', 'public, max-age=3600');
return {
poets: poets.rows.map((r) => r.url),
categories: cats.rows.map((r) => r.url),
works: works.rows.map((r) => ({ url: r.url, updated: r.updated })),
tags: tags.rows,
ebooks: ebooks.rows,
};
});
app.get<{ Querystring: { limit?: string } }>('/api/feed', async (req, reply) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 100);
const { rows } = await pool.query(
`SELECT r.id, r.entity, r.entity_id, r.version, r.summary, r.published_at, r.credits,
p.title AS work_title, p.url AS work_url, pp.nickname AS work_poet,
c.title AS cat_title, c.url AS cat_url, cp.nickname AS cat_poet,
b.title AS ebook_title, bp.nickname AS ebook_poet
FROM revisions r
LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id LEFT JOIN poets pp ON pp.id = p.poet_id
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id LEFT JOIN poets cp ON cp.id = c.poet_id
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id LEFT JOIN poets bp ON bp.id = b.poet_id
WHERE r.status = 'published' AND r.published_at IS NOT NULL
ORDER BY r.published_at DESC, r.id DESC LIMIT $1`, [limit]);
// the opening lines of each new version of a work, for the feed's description
const workIds = rows.filter((r) => r.entity === 'work').map((r) => r.entity_id);
const lines = workIds.length ? (await pool.query(
`SELECT poem_id, array_agg(text ORDER BY vorder) AS lines FROM verses WHERE poem_id = ANY($1) AND vorder <= 2 GROUP BY poem_id`,
[workIds])).rows : [];
reply.header('cache-control', 'public, max-age=300');
return rows.map((r) => {
const kind = r.entity === 'work' ? 'work' : r.entity === 'ebook' ? 'ebook' : r.entity === 'order' ? 'order' : 'tags';
const title = r.work_title ?? r.cat_title ?? r.ebook_title ?? '';
const url = r.work_url ?? r.cat_url ?? (r.entity === 'ebook' ? `/ebook/${r.entity_id}` : '/');
return {
id: Number(r.id), kind, version: r.version, title, url, poet: r.work_poet ?? r.cat_poet ?? r.ebook_poet ?? null,
summary: r.summary ?? null, published: r.published_at, by: r.credits?.by ?? null,
lines: lines.find((l) => l.poem_id === r.entity_id)?.lines ?? [],
};
});
});
}

View File

@ -1,31 +0,0 @@
// Publishing to git (#34): each published version is a commit in the divan-data checkout, so the public git
// history is the record of every change. Commits run one at a time (ponytail: in-process queue; one API process).
// Moderators appear by their public name with a placeholder email, never their real address.
// DIVAN_GIT_PUSH=1 push after each commit (else the daily sync or a release pushes)
// DIVAN_GIT_EMAIL_DOMAIN domain of the placeholder emails (default users.noreply.divan)
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
const git = (dir: string, ...args: string[]) => run('git', ['-C', dir, ...args], { maxBuffer: 16 * 1024 * 1024 });
let queue: Promise<unknown> = Promise.resolve();
export type Person = { id: number | string; name: string };
// a moderator as a git identity: public name, placeholder email
export const identity = (p: Person) => `${p.name.replace(/[<>\n]/g, '')} <moderator-${p.id}@${process.env.DIVAN_GIT_EMAIL_DOMAIN ?? 'users.noreply.divan'}>`;
// commit these files (paths relative to dir) as the author; returns the commit id, or null if dir is not a git
// checkout (publishing still works, there is just no commit)
export function commit(dir: string, files: string[], author: Person, message: string): Promise<string | null> {
const job = queue.then(async () => {
try { await git(dir, 'rev-parse', '--is-inside-work-tree'); } catch { return null; }
await git(dir, 'add', '--', ...files);
await git(dir, '-c', 'user.name=Divan', '-c', `user.email=publish@${process.env.DIVAN_GIT_EMAIL_DOMAIN ?? 'users.noreply.divan'}`,
'commit', '--quiet', `--author=${identity(author)}`, '-m', message, '--', ...files);
const sha = (await git(dir, 'rev-parse', 'HEAD')).stdout.trim();
if (process.env.DIVAN_GIT_PUSH === '1') await git(dir, 'push', '--quiet').catch((e) => console.error('divan-data push failed:', e.message));
return sha;
});
queue = job.catch(() => {});
return job;
}

View File

@ -5,7 +5,6 @@ import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { pool } from './db.ts';
import { normalise } from './urdu.ts';
import { loadTags } from './tags.ts';
const SOURCE = process.argv[2] ?? new URL('../../../divan-data', import.meta.url).pathname;
const isHttp = /^https?:/.test(SOURCE);
@ -86,7 +85,5 @@ for (const entry of manifest.Poets) {
if (process.stdout.isTTY) process.stdout.write(`\r${poems} poems`); // progress only when interactive (cron logs stay clean)
}
await pool.query(await readFile(new URL('../../db/featured.sql', import.meta.url), 'utf8'));
// published tags (divan-data/divan/*.tags) back onto the pages they belong to
if (!isHttp) console.log(`\ntags on ${await loadTags(SOURCE)} pages`);
console.log(`\nimported ${manifest.Poets.length} poets, ${poems} poems`);
await pool.end();

View File

@ -26,9 +26,7 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
const other = (await call('POST', '/api/auth/signup', { email: `lib2-${run}@divan.test`, password: 'pass-word-1' })).json();
const t = me.token, toggle = (body: object, tk = t) => call('POST', '/api/library/toggle', body, tk);
// a Ghalib ghazal with at least three couplets (the test bookmarks couplet 2)
const poem = (await pool.query(`SELECT p.id FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal'
AND EXISTS (SELECT 1 FROM verses v WHERE v.poem_id = p.id AND v.couplet >= 2) ORDER BY p.id LIMIT 1`)).rows[0].id;
const poem = (await pool.query(`SELECT p.id FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' LIMIT 1`)).rows[0].id;
assert.equal((await call('GET', '/api/library')).statusCode, 401, 'signed out');
assert.deepEqual((await toggle({ kind: 'poet', poetId: 266 })).json().saved, true);
@ -55,39 +53,10 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id;
assert.ok(marks.categories.includes(book) && marks.poets.includes(266));
// state for a page
assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }], notes: [] });
assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }] });
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true });
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false });
// notes on a couplet and on a phrase are separate from bookmarks: a note never bookmarks; un-bookmarking keeps the note
const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk);
const st = () => call('GET', `/api/library/state?poem=${poem}`, undefined, t).then((r) => r.json());
assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { note: 'پہلا نوٹ' }, 'on a bookmarked couplet');
assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { note: 'نیا' });
assert.ok(!(await st()).couplets.includes(1), 'a note does not bookmark');
const ph = lines0[0].split(' ').slice(1, 3).join(' ');
assert.deepEqual((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json(), { note: 'عبارت پر' });
assert.ok(!(await st()).phrases.some((p: any) => p.phrase === ph), 'nor a phrase');
assert.equal((await note({ poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں', note: 'x' })).statusCode, 400);
assert.equal((await note({ poemId: poem, couplet: 9999, note: 'x' })).statusCode, 404);
assert.equal((await note({ poemId: poem, couplet: 1, note: 'x' }, other.token)).statusCode, 200, "another reader's note is their own");
assert.deepEqual((await st()).notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort());
// bookmarking a noted couplet keeps the note; un-bookmarking keeps it too
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, true);
assert.ok((await st()).couplets.includes(1));
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false);
assert.ok(!(await st()).couplets.includes(1) && (await st()).notes.some((n: any) => n.couplet === 1), 'the note stays');
// clearing a note that has no bookmark removes it; on a bookmark it keeps the bookmark
await note({ poemId: poem, couplet: 1, note: '' });
assert.ok(!(await st()).notes.some((n: any) => n.couplet === 1));
await note({ poemId: poem, couplet: 0, phrase: ph, note: '' });
await note({ poemId: poem, couplet: 2, note: '' });
assert.ok((await st()).couplets.includes(2) && !(await st()).notes.length, 'bookmark kept, notes gone');
// the notes list (the notes page) holds notes only; the saved lists hold bookmarks only
await note({ poemId: poem, couplet: 3, note: 'صرف نوٹ' }).catch(() => {});
const listed = (await call('GET', '/api/library', undefined, t)).json();
assert.ok(listed.notes.every((n: any) => n.note) && !listed.couplets.some((c: any) => c.poem?.couplet === 3));
await note({ poemId: poem, couplet: 3, note: '' });
// toggling again removes
assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false);

View File

@ -1,24 +1,19 @@
// 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=&category=&poem=&word= what is saved on one page, or whether a word is in the word book;
// ?ebook= the bookmarked pages of an e-book
// GET /api/library/state?poet=&category=&poem=&word= what is saved on one page, or whether a word is in the word book
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items
// POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
// POST /api/library/:id/note {note}
// POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it; never bookmarks it (a
// note and a bookmark are separate); an empty note clears it -> {note}
// notes and bookmarks share a row: bookmarked false is a note only; un-bookmarking keeps the note, clearing the note of a
// row that is not bookmarked removes the row
// POST /api/library/:id/delete (a couplet or phrase with a note keeps the note)
// POST /api/library/:id/delete
// kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book)
// 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', 'category', 'poem', 'couplet', 'phrase', 'word', 'page'] as const;
const KINDS = ['poet', 'category', 'poem', 'couplet', 'phrase', 'word'] as const;
// a bookmarked phrase: spaces collapsed, 2 to 300 characters
export const cleanPhrase = (p: unknown) => {
@ -26,25 +21,6 @@ export const cleanPhrase = (p: unknown) => {
return s.length >= 2 && s.length <= 300 ? s : null;
};
// a phrase must be in its couplet or paragraph (its lines, or across its two misras); null when it is
async function phraseIn(poemId: number, couplet: number, phrase: string) {
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 { status: 404, error: 'نہیں ملا' };
if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return { status: 400, error: 'یہ عبارت اس شعر میں نہیں' };
return null;
}
// an existing couplet or phrase row: a bookmark goes (the row stays if it holds a note), a note only becomes a bookmark;
// null when there is no row
async function flipBookmark(match: string, args: unknown[]) {
const row = (await pool.query(`SELECT id, bookmarked, note FROM library WHERE user_id = $1 AND ${match}`, args)).rows[0];
if (!row) return null;
if (!row.bookmarked) { await pool.query('UPDATE library SET bookmarked = true WHERE id = $1', [row.id]); return { saved: true, id: Number(row.id) }; }
if (row.note) await pool.query('UPDATE library SET bookmarked = false WHERE id = $1', [row.id]);
else await pool.query('DELETE FROM library WHERE id = $1', [row.id]);
return { saved: false };
}
async function reader(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req);
if (!u) reply.code(401).send({ error: 'لاگ ان کریں' });
@ -89,11 +65,9 @@ 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.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page, l.bookmarked,
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,
eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url
`SELECT l.id, l.kind, l.poet_id, l.category_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
LEFT JOIN ebooks eb ON eb.id = l.ebook_id LEFT JOIN poets ep ON ep.id = eb.poet_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, chapters] = await Promise.all([
@ -110,37 +84,29 @@ export function libraryRoutes(app: FastifyInstance) {
poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })),
categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })),
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
// every note on a couplet or phrase, bookmarked or not (the notes page)
notes: rows.filter((r) => (r.kind === 'couplet' || r.kind === 'phrase') && r.note).map((r) => ({
...item(r), phrase: r.phrase, bookmarked: r.bookmarked, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({
...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })),
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; category?: string; poem?: string; word?: string; ebook?: string } }>('/api/library/state', async (req, reply) => {
app.get<{ Querystring: { poet?: string; category?: string; poem?: string; word?: string } }>('/api/library/state', async (req, reply) => {
const u = await reader(req, reply); if (!u) return;
if (req.query.ebook != null) return { pages: (await pool.query(
`SELECT page FROM library WHERE user_id = $1 AND kind = 'page' AND ebook_id = $2 ORDER BY page`, [u.id, Number(req.query.ebook) || 0])).rows.map((r) => r.page) };
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, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0;
const { rows } = await pool.query(
`SELECT kind, couplet, phrase, note, bookmarked FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2)
`SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2)
OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`,
[u.id, poet, category, poem]);
return {
poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'),
couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => r.couplet),
phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ couplet: r.couplet, phrase: r.phrase })),
notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })),
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 })),
};
});
@ -150,7 +116,7 @@ export function libraryRoutes(app: FastifyInstance) {
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') AND bookmarked GROUP BY poem_id`, [u.id]);
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 (
@ -184,24 +150,16 @@ export function libraryRoutes(app: FastifyInstance) {
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 === 'page') {
const ebookId = Number((b as any).ebookId) || 0, page = Number((b as any).page);
if (!Number.isInteger(page) || page < 1 || page > 100000) return reply.code(400).send({ error: 'صفحہ نمبر درست نہیں' });
if (!(await pool.query('SELECT 1 FROM ebooks WHERE id = $1 AND published', [ebookId])).rowCount) return reply.code(404).send({ error: 'کتاب نہیں ملی' });
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'page' AND ebook_id = $2 AND page = $3 RETURNING id`, [u.id, ebookId, page])).rowCount)
return { saved: false };
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, ebook_id, page) VALUES ($1, 'page', $2, $3) RETURNING id`, [u.id, ebookId, page]);
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 where = await phraseIn(poemId, couplet, phrase);
if (where) return reply.code(where.status).send({ error: where.error });
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];
const flip = await flipBookmark(`kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`, args);
if (flip) return flip;
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) };
}
@ -216,51 +174,22 @@ export function libraryRoutes(app: FastifyInstance) {
const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0)
AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`;
const args = [u.id, kind, ...cols];
const flip = await flipBookmark(match, args);
if (flip) return flip;
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, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args);
return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined };
});
app.post<{ Body: { poemId?: number; couplet?: number; phrase?: string; note?: string } }>('/api/library/note', async (req, reply) => {
const u = await reader(req, reply); if (!u) return;
const b = req.body ?? {}, poemId = Number(b.poemId) || 0, couplet = Number.isInteger(b.couplet) ? b.couplet! : null;
const note = String(b.note ?? '').normalize('NFC').trim().slice(0, 1000) || null;
if (!poemId || couplet == null) return reply.code(400).send({ error: 'شعر منتخب کریں' });
let kind = 'couplet', match = `kind = 'couplet' AND poem_id = $2 AND couplet = $3`, args: unknown[] = [u.id, poemId, couplet];
if (b.phrase != null) {
const phrase = cleanPhrase(b.phrase);
if (!phrase) return reply.code(400).send({ error: 'عبارت منتخب کریں' });
const where = await phraseIn(poemId, couplet, phrase);
if (where) return reply.code(where.status).send({ error: where.error });
kind = 'phrase'; match = `kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`; args.push(phrase);
} else if (!(await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount) {
return reply.code(404).send({ error: 'نہیں ملا' });
}
const n = args.length + 1;
const row = (await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id, bookmarked`, [...args, note])).rows[0];
if (row) { if (!note && !row.bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [row.id]); return { note }; }
if (note) await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note, bookmarked)
VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n}, false)`, [...args, note]);
return { note };
});
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 RETURNING bookmarked', [note, Number(req.params.id) || 0, u.id]);
if (!r.rowCount) return reply.code(404).send({ error: 'نہیں ملا' });
if (!note && !r.rows[0].bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [Number(req.params.id)]); // a note only, now empty
return { ok: true };
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;
// removing a bookmarked couplet or phrase keeps its note (the row stays as a note only)
const id = Number(req.params.id) || 0;
const kept = await pool.query(`UPDATE library SET bookmarked = false WHERE id = $1 AND user_id = $2 AND kind IN ('couplet', 'phrase') AND note IS NOT NULL AND bookmarked`, [id, u.id]);
const r = kept.rowCount ? kept : await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [id, u.id]);
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: 'نہیں ملا' });
});
}

View File

@ -1,157 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts';
import { permissionRoutes } from './permissions.ts';
import { moderationRoutes } from './moderation.ts';
import { diffLines } from './diff.ts';
import { execFileSync } from 'node:child_process';
import { pool } from './db.ts';
after(() => pool.end());
test('line diff: kept, removed and added lines', () => {
assert.deepEqual(diffLines('ا\nب\nج', 'ا\nد\nج'), [{ op: '=', text: 'ا' }, { op: '-', text: 'ب' }, { op: '+', text: 'د' }, { op: '=', text: 'ج' }]);
});
test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permissions, conflicts, history', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
process.env.DIVAN_DATA_DIR = data;
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify();
authRoutes(app); adminRoutes(app); permissionRoutes(app); moderationRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `mod-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
await call('POST', '/api/auth/profile', { full_name: 'نیا موڈریٹر' }, l2.token); // a public name; the L1 has none
const other = await person('l1other', 'mod-l1'), reader = await person('reader', 'reader');
// a Ghalib ghazal; the work is restored at the end
const poem = (await pool.query(`SELECT p.id, p.url, p.title FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' ORDER BY p.position LIMIT 1`)).rows[0];
const before = (await pool.query('SELECT vorder, position, couplet, text FROM verses WHERE poem_id = $1 ORDER BY vorder', [poem.id])).rows;
const searchBefore = (await pool.query('SELECT search_text FROM poems WHERE id = $1', [poem.id])).rows[0].search_text;
try {
// grants: L2 and L1 on Ghalib's ghazals; the other L1 on Iqbal only
for (const [m, target] of [[l2, '/p266/ghazal'], [l1, '/p266/ghazal'], [other, '/p238']] as const)
await call('POST', `/api/admin/users/${m.id}/grants`, { scope: target === '/p238' ? 'poet' : 'category', target, content: ['works'], actions: ['edit'] }, admin.token);
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, reader.token)).statusCode, 403, 'readers cannot moderate');
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, other.token)).statusCode, 403, 'outside the grant');
assert.deepEqual((await call('GET', `/api/mod/can?poem=${poem.id}`, undefined, l2.token)).json(), { edit: true, review: false, publish: false, tags: false });
// opening the editor is not a change: an unchanged draft is not listed, and saving it unchanged drops it
const blank = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id;
assert.equal((await call('GET', '/api/mod/queue', undefined, l2.token)).json().mine.some((r: any) => r.id === blank), false, 'unchanged draft not listed');
const text = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l2.token)).json().content;
assert.deepEqual((await call('POST', `/api/mod/revisions/${blank}/save`, { content: text.replace('}}\n', '}}\n\n') }, l2.token)).json(), { discarded: true }, 'layout-only difference is no change');
assert.equal((await call('GET', `/api/mod/revisions/${blank}`, undefined, l2.token)).statusCode, 404, 'dropped');
// L2 drafts: the draft starts from the current text; reopening returns the same draft
const { id } = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json();
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id, id);
let rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
assert.equal(rev.changes, 0);
const misra = before.find((v: any) => v.couplet === 1 && v.position === 'Left').text;
const edited = rev.revision.content.replace(misra, misra + ' (ترمیم)');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'متن بغیر شعر کے {{', summary: 'x' }, l2.token)).statusCode, 200, 'prose is a paragraph');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: edited, summary: 'ایک مصرع درست کیا' }, l2.token)).statusCode, 200);
assert.equal((await call('GET', '/api/mod/queue', undefined, l2.token)).json().mine.some((r: any) => r.id === id), true, 'changed draft listed');
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
assert.deepEqual(rev.diff.filter((d: any) => d.op !== '=').map((d: any) => d.op), ['-', '+'], 'one line changed');
assert.equal(rev.may.approve, false, 'not my own draft');
// L2 submits; the out-of-scope L1 cannot see it; L1 returns it, L2 resubmits, L1 approves
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
assert.equal((await call('GET', `/api/mod/revisions/${id}`, undefined, other.token)).statusCode, 403);
assert.equal((await call('GET', '/api/mod/queue', undefined, l1.token)).json().review.some((r: any) => r.id === id), true);
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, {}, l1.token)).statusCode, 400, 'a reason is required');
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, { comment: 'وزن دیکھیں' }, l1.token)).json().status, 'returned');
assert.equal((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).statusCode, 403, 'not yet approved');
await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token);
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l2.token)).statusCode, 403, 'L2 cannot approve');
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, { comment: 'درست' }, l1.token)).json().status, 'approved');
assert.equal((await call('GET', '/api/mod/queue', undefined, admin.token)).json().publish.some((r: any) => r.id === id), true);
// admin publishes: version 1 in divan-data and on the site
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
const file = JSON.parse(await readFile(join(data, 'divan', poem.url.slice(1) + '.json'), 'utf8'));
// public names only in divan-data (it is public); a commit with the moderator as author and review trailers
assert.equal(file.Edited.by, 'نیا موڈریٹر');
assert.equal(file.Edited.reviewedBy, `موڈریٹر ${l1.id}`);
assert.equal(file.Edited.publishedBy, `موڈریٹر ${admin.id}`);
assert.ok(!JSON.stringify(file).includes('@'), 'no email addresses');
const log = git('log', '-1', '--format=%an <%ae>%n%B');
assert.match(log, new RegExp(`^نیا موڈریٹر <moderator-${l2.id}@users\\.noreply\\.divan>`));
assert.match(log, /\(ورژن 1\)/);
assert.match(log, new RegExp(`Reviewed-by: موڈریٹر ${l1.id} <moderator-${l1.id}@`));
assert.match(log, new RegExp(`Approved-by: موڈریٹر ${admin.id} <moderator-${admin.id}@`));
assert.ok(!log.includes('divan.test'), 'no real emails in git');
assert.deepEqual(git('show', '--name-only', '--format=', 'HEAD').trim().split('\n').sort(), [`divan${poem.url}.dtx`, `divan${poem.url}.json`]);
const committed = (await pool.query(`SELECT commit FROM revisions WHERE id = $1`, [id])).rows[0].commit;
assert.equal(committed, git('rev-parse', 'HEAD').trim(), 'the commit is recorded with the version');
assert.ok((await readFile(join(data, 'divan', poem.url.slice(1) + '.dtx'), 'utf8')).includes(misra + ' (ترمیم)'));
assert.equal((await pool.query('SELECT text FROM verses WHERE poem_id = $1 AND couplet = 1 AND position = $2', [poem.id, 'Left'])).rows[0].text, misra + ' (ترمیم)');
// the full event trail, in order
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, admin.token)).json();
assert.deepEqual(rev.events.map((e: any) => e.action), ['created', 'saved', 'saved', 'submitted', 'returned', 'submitted', 'approved', 'published']);
assert.equal(rev.events.find((e: any) => e.action === 'returned').comment, 'وزن دیکھیں');
// conflict: an L1 draft started from version 1, but an admin publishes version 2 first
const d2 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l1.token)).json().id;
const d3 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, admin.token)).json().id;
const cur = (await call('GET', `/api/mod/work/${poem.id}`, undefined, admin.token)).json();
assert.equal(cur.version, 1);
await call('POST', `/api/mod/revisions/${d3}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم دوم)') }, admin.token);
assert.deepEqual((await call('POST', `/api/mod/revisions/${d3}/submit`, {}, admin.token)).json(), { status: 'published', version: 2 }, "an admin's own edit publishes");
await call('POST', `/api/mod/revisions/${d2}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم سوم)') }, l1.token);
assert.equal((await call('POST', `/api/mod/revisions/${d2}/submit`, {}, l1.token)).json().status, 'approved', "an L1's own draft goes to the admin");
const conflict = await call('POST', `/api/mod/revisions/${d2}/publish`, {}, admin.token);
assert.equal(conflict.statusCode, 409, 'a newer version was published meanwhile');
assert.equal((await call('POST', `/api/mod/revisions/${d2}/reject`, { comment: 'پرانا متن' }, admin.token)).json().status, 'rejected');
const hist = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l1.token)).json();
assert.deepEqual(hist.history.filter((h: any) => h.version).map((h: any) => h.version), [2, 1]);
assert.ok((await call('GET', '/api/mod/log', undefined, l2.token)).json().entries.length >= 10);
// version history (#51): compare any two versions, bring an old one back through the pipeline, filter the log
const cmp = (await call('GET', `/api/mod/compare/work/${poem.id}`, undefined, l2.token)).json();
assert.deepEqual([cmp.a, cmp.b, cmp.changes], [1, 2, 2], 'by default the latest version against the one before');
assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text.includes('(ترمیم دوم)')));
const v0 = (await call('GET', `/api/mod/compare/work/${poem.id}?a=0&b=2`, undefined, l2.token)).json();
assert.ok(v0.diff.some((d: any) => d.op === '-' && d.text === misra), 'version 0 is the Wikisource text');
assert.equal((await call('GET', `/api/mod/compare/work/${poem.id}?a=7`, undefined, l2.token)).statusCode, 404);
assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, other.token)).statusCode, 403, 'outside the grant');
assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 2 }, l2.token)).statusCode, 400, 'already the current version');
const back = (await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, l2.token)).json().id;
const br = (await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json();
assert.deepEqual([br.revision.status, br.revision.base_version, br.revision.summary], ['draft', 2, 'ویکی ماخذ کا متن واپس لایا']);
assert.ok(br.diff.some((d: any) => d.op === '+' && d.text === misra), 'the draft brings the old line back');
assert.deepEqual(br.events.map((e: any) => e.action), ['created', 'reverted']);
assert.deepEqual((await call('POST', `/api/mod/revisions/${back}/submit`, {}, l2.token)).json().status, 'submitted', 'then reviewed like any edit');
const mine = (await call('GET', `/api/mod/log?who=l2-${run}&action=reverted`, undefined, l1.token)).json().entries;
assert.deepEqual(mine.map((e: any) => e.revision), [back], 'log filtered by person and step');
assert.equal((await call('GET', `/api/mod/log?who=l2-${run}&kind=order`, undefined, l1.token)).json().entries.length, 0);
} finally {
// restore the work and remove the test people and their revisions
await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]);
for (const v of before) await pool.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)', [poem.id, v.vorder, v.position, v.couplet, v.text]);
await pool.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1', [poem.id, poem.title, searchBefore]);
await pool.query(`DELETE FROM revisions WHERE entity = 'work' AND entity_id = $1 AND author_email LIKE $2`, [poem.id, `%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true });
await app.close();
}
});

View File

@ -1,469 +0,0 @@
// Content moderation (#31, #51): versions of works and the L2 -> L1 -> admin pipeline.
// A revision holds a work's Divan text, a summary and a status; every step taken on it is an event (who, what,
// when, comment), which is both the review thread and the moderation log.
// L2 moderator: drafts and submits -> submitted
// L1 moderator (grant covering the work): approves, returns (with a comment) or rejects; an L1's own draft
// goes straight to the admin step -> approved
// admin: publishes (an admin's own draft publishes directly), returns or rejects
// Admins are super moderators: they can edit, review and publish any work without grants.
// Publishing numbers the version, writes it to divan-data as Divan-owned content (owned.ts), commits it there (git.ts)
// and updates the site.
// If another version was published after the draft started, publishing is refused until the draft is redone.
// Arranging a book or section (order.ts, entity 'order') uses the same revisions, steps and publishing; it needs
// the separate 'arrange' permission. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags'
// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission. A poet's details
// (name, pen name, years, intro) and a book or section's title (details.ts, entities 'poet' and 'book') likewise, with
// the edit permission on poets or books (#32).
// GET /api/mod/can?poem= what the reader may do on a work
// GET /api/mod/queue my drafts, drafts to review, drafts to publish
// GET /api/mod/work/:id a work's current text and its history
// POST /api/mod/work/:id/draft start (or reopen) my draft
// GET /api/mod/revisions/:id a revision, its diff against the version it started from, its events
// GET /api/mod/order/:id a book/section's current order and its history
// POST /api/mod/order/:id/draft start (or reopen) my arrangement draft
// GET /api/mod/tags/:kind/:id a book/section's (kind category) or work's tags and their history
// GET /api/mod/details/:kind/:id a poet's details (kind poet) or a book/section's title (kind book), history
// POST /api/mod/details/:kind/:id/draft start (or reopen) my draft of them
// POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft
// POST /api/mod/revisions/:id/save {content, summary}
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
// GET /api/mod/log?page=&who=&kind=&action= who did what, newest first; filtered by person, kind of change, step
// GET /api/mod/compare/:entity/:id?a=&b= two published versions of a work, order or tags and their diff (0 = the
// Wikisource text before Divan's first version)
// POST /api/mod/revert/:entity/:id {version}: a draft that brings back that version, through the pipeline
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { pool } from './db.ts';
import { sessionUser } from './auth.ts';
import { can } from './permissions.ts';
import { fromPoem, writeOwned } from './owned.ts';
import { parse, toVerses, toText } from './divantext.ts';
import { normalise } from './urdu.ts';
import { diffLines, changed } from './diff.ts';
import { commit, identity } from './git.ts';
import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './order.ts';
import { currentTags, checkTags, applyTags, writeTags, parseTags, tagsText, type Tag, type TagTarget } from './tags.ts';
import { currentEbook, checkEbook, applyEbook, writeEbook, mayUpload } from './ebooks.ts';
import { publicName } from './auth.ts';
import { isDetail, currentDetails, checkDetails, applyDetails, writeDetails, parseDetails, detailsText, type DetailKind } from './details.ts';
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname;
const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
const mayEdit = (u: any, poemId: number) => can(u, 'edit', 'works', { poemId });
const mayArrange = async (u: any, categoryId: number) =>
(await can(u, 'arrange', 'works', { categoryId })) || (await can(u, 'arrange', 'books', { categoryId }));
const mayTag = (u: any, kind: TagTarget, id: number) => can(u, 'edit', 'tags', kind === 'work' ? { poemId: id } : { categoryId: id });
// may change what this revision changes (a work's text, a book/section's order, tags); reviewing needs L1 or admin
const mayEbook = async (u: any, ebookId: number) =>
mayUpload(u, (await pool.query('SELECT poet_id FROM ebooks WHERE id = $1', [ebookId])).rows[0]?.poet_id ?? 0);
const mayDetails = (u: any, kind: DetailKind, id: number) =>
kind === 'poet' ? can(u, 'edit', 'poets', { poetId: id }) : can(u, 'edit', 'books', { categoryId: id });
const mayChange = (u: any, r: { entity: string; entity_id: number }) => isDetail(r.entity) ? mayDetails(u, r.entity, r.entity_id) :
r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id)
: r.entity === 'ebook' ? mayEbook(u, r.entity_id) : mayEdit(u, r.entity_id);
const tagKind = (r: { entity: string }) => r.entity.slice('tags-'.length) as TagTarget;
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook', 'poet', 'book')`;
const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r));
const OPEN = ['draft', 'returned'];
// a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change)
const CHANGED = `(r.status <> 'draft' OR r.content <> r.base_content)`;
async function moderator(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req);
if (!u) return void reply.code(401).send({ error: 'لاگ ان کریں' });
if (!isModerator(u)) return void reply.code(403).send({ error: 'صرف موڈریٹرز کے لیے' });
return u;
}
const event = (revId: number, u: any, action: string, comment?: string | null) =>
pool.query('INSERT INTO revision_events (revision_id, actor_id, actor_email, action, comment) VALUES ($1, $2, $3, $4, $5)',
[revId, u?.id ?? null, u?.email ?? 'server', action, comment || null]);
// a work's current published text: its latest Divan version, or the Wikisource text as Divan text
async function current(poemId: number) {
const poem = (await pool.query(
'SELECT p.id, p.url, p.title, p.source_url, t.nickname AS poet FROM poems p JOIN poets t ON t.id = p.poet_id WHERE p.id = $1', [poemId])).rows[0];
if (!poem) return null;
const last = (await pool.query(
`SELECT version, content FROM revisions WHERE entity = 'work' AND entity_id = $1 AND status = 'published' ORDER BY version DESC LIMIT 1`, [poemId])).rows[0];
if (last) return { poem, version: last.version as number, content: last.content as string };
const verses = (await pool.query('SELECT position AS "Position", couplet AS "CoupletIndex", text AS "Text" FROM verses WHERE poem_id = $1 ORDER BY vorder', [poemId])).rows;
return { poem, version: 0, content: fromPoem({ Title: poem.title, Verses: verses, SourceUrl: poem.source_url ?? undefined }, { شاعر: poem.poet }) };
}
// what a revision changes: a work, or a book/section's order (work_title/work_url name either)
const TARGET = `LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category', 'book') AND c.id = r.entity_id
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id
LEFT JOIN poets pt ON r.entity = 'poet' AND pt.id = r.entity_id`;
// (not named URL: that would hide the global URL used for the default divan-data folder)
const TARGET_TITLE = `coalesce(p.title, c.title, b.title, pt.nickname)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id, pt.url)`;
const TARGET_COLS = `${TARGET_TITLE} AS work_title, ${TARGET_URL} AS work_url`;
async function revision(id: number) {
return (await pool.query(`SELECT r.*, ${TARGET_COLS} FROM revisions r ${TARGET} WHERE r.id = $1 AND r.entity IN ${ENTITIES}`, [id])).rows[0];
}
// what this person may do with this revision now
async function actions(u: any, r: any) {
const mine = Number(r.author_id) === Number(u.id), review = await mayReview(u, r), admin = u.role === 'admin';
return {
save: mine && OPEN.includes(r.status),
submit: mine && OPEN.includes(r.status),
approve: !mine && review && r.status === 'submitted',
return: (review || admin) && ['submitted', 'approved'].includes(r.status) && !(mine && !admin),
reject: (review || admin) && ['submitted', 'approved'].includes(r.status) && !(mine && !admin),
publish: admin && r.status === 'approved',
};
}
async function publish(r: any, u: any, comment?: string) {
const order = r.entity === 'order', tags = r.entity.startsWith('tags-'), ebook = r.entity === 'ebook';
const cur = await currentOf(r.entity, r.entity_id);
if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
if (cur.version !== r.base_version)
throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
// the section's contents may have changed since (a new work from the sync): the arrangement must be redone
const details = isDetail(r.entity);
const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content)
: ebook ? checkEbook(r.content) : details ? checkDetails(r.entity, r.content) : null;
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
const doc = order || tags || ebook || details ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
const title = 'details' in cur ? `${r.entity === 'poet' ? 'شاعر' : 'عنوان'}: ${parseDetails(r.content)[r.entity === 'poet' ? 'تخلص' : 'عنوان']}`
: 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}`
: 'ebook' in cur ? `ای بک: ${cur.ebook.poet}، ${cur.ebook.title}` : doc!.meta['عنوان'] || cur.poem.title;
const version = cur.version + 1, at = new Date().toISOString();
// who did it, by public name (divan-data is public: never email addresses)
const people = async (id: unknown) => id ? (await pool.query('SELECT id, full_name FROM users WHERE id = $1', [id])).rows[0] ?? null : null;
const [author, reviewer] = await Promise.all([people(r.author_id), people(r.reviewer_id)]);
const who = (p: any) => p && { id: Number(p.id), name: publicName(p) };
const credits = { by: who(author)?.name ?? 'موڈریٹر', reviewedBy: who(reviewer)?.name ?? null, publishedBy: publicName(u) };
// divan-data first (the published record, committed to git), then the site's database
const files = 'details' in cur ? [await writeDetails(dataDir(), r.entity, cur.details.url, r.content)]
: 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
: 'target' in cur ? [await writeTags(dataDir(), cur.target.url, r.content)]
: 'ebook' in cur ? [await writeEbook(dataDir(), cur.ebook, r.content)]
: await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`,
...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`];
const sha = await commit(dataDir(), files.map((f) => f.slice(dataDir().replace(/\/$/, '').length + 1)),
who(author) ?? { id: 0, name: 'موڈریٹر' }, `${title}: ${r.summary || 'ترمیم'} (ورژن ${version})\n\n${trailers.join('\n')}`);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`UPDATE revisions SET status = 'published', version = $2, publisher_email = $3, published_at = $4, commit = $5, credits = $6, updated_at = now()
WHERE id = $1`, [r.id, version, u.email, at, sha, credits]);
if (order) await applyOrder(client, r.entity_id, r.content);
else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
else if (ebook) await applyEbook(client, r.entity_id, r.content);
else if (details) await applyDetails(client, r.entity, r.entity_id, r.content);
else {
await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1',
[r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]);
await client.query('DELETE FROM verses WHERE poem_id = $1', [r.entity_id]);
for (const v of verses)
await client.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)',
[r.entity_id, v.VOrder, v.Position, v.CoupletIndex, v.Text]);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
// ponytail: radif/matla/maqta and the contents order are recomputed by the next daily export + import
await event(r.id, u, 'published', comment);
return version;
}
// what an entity is now (its latest published version), for any kind of revision
const currentOf = (entity: string, id: number): Promise<any> => entity === 'order' ? currentOrder(id)
: entity.startsWith('tags-') ? currentTags(tagKind({ entity }), id) : entity === 'ebook' ? currentEbook(id)
: isDetail(entity) ? currentDetails(entity, id) : current(id);
// the kinds of change with versions to compare and bring back (an e-book's details are a one-off)
const VERSIONED = ['work', 'order', 'tags-work', 'tags-category', 'poet', 'book'];
// a published version's content; version 0 is what the first Divan draft started from (the Wikisource text)
async function versionText(entity: string, id: number, v: number, cur: { version: number; content: string }) {
if (v === cur.version) return cur.content;
const { rows } = await pool.query(v
? `SELECT content FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published' AND version = $3`
: `SELECT base_content AS content FROM revisions WHERE entity = $1 AND entity_id = $2 AND base_version = 0 AND $3 = 0 ORDER BY id LIMIT 1`, [entity, id, v]);
return rows[0]?.content as string | undefined;
}
const identityOf = (p: any) => identity({ id: Number(p.id), name: publicName(p) });
export function moderationRoutes(app: FastifyInstance) {
// ?poem= on a work page; ?category= on a poet or book/section page (arrange)
app.get<{ Querystring: { poem?: string; category?: string } }>('/api/mod/can', async (req) => {
const u = await sessionUser(req), poemId = Number(req.query.poem) || 0, categoryId = Number(req.query.category) || 0;
if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false, ebooks: false };
if (categoryId) {
const poetId = (await pool.query('SELECT poet_id FROM categories WHERE id = $1', [categoryId])).rows[0]?.poet_id ?? 0;
return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId),
book: await mayDetails(u, 'book', categoryId), poet: await mayDetails(u, 'poet', poetId) };
}
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin',
tags: await mayTag(u, 'work', poemId) };
});
app.get('/api/mod/queue', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { rows } = await pool.query(
`SELECT r.id, r.entity, r.entity_id, r.status, r.summary, r.author_id, r.author_email, r.reviewer_email, r.updated_at,
${TARGET_TITLE} AS title, ${TARGET_URL} AS url
FROM revisions r ${TARGET}
WHERE r.entity IN ${ENTITIES} AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned') AND ${CHANGED}))
ORDER BY r.updated_at DESC LIMIT 300`, [u.id]);
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
const review = [];
for (const r of rows) if (r.status === 'submitted' && Number(r.author_id) !== Number(u.id) && (await mayReview(u, r))) review.push(strip(r));
return {
mine: rows.filter((r) => Number(r.author_id) === Number(u.id)).map(strip),
review,
publish: u.role === 'admin' ? rows.filter((r) => r.status === 'approved').map(strip) : [],
};
});
app.get<{ Params: { id: string } }>('/api/mod/work/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const cur = await current(Number(req.params.id) || 0);
if (!cur) return reply.code(404).send({ error: 'کلام نہیں ملا' });
const { rows } = await pool.query(
`SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
FROM revisions r WHERE entity = 'work' AND entity_id = $1 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [cur.poem.id]);
return { work: cur.poem, version: cur.version, content: cur.content, history: rows.map((r) => ({ ...r, id: Number(r.id) })),
may: { edit: await mayEdit(u, cur.poem.id) } };
});
app.post<{ Params: { id: string } }>('/api/mod/work/:id/draft', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const poemId = Number(req.params.id) || 0;
if (!(await mayEdit(u, poemId))) return reply.code(403).send({ error: 'اس کلام میں ترمیم کی اجازت نہیں' });
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = 'work' AND entity_id = $1 AND author_id = $2 AND status IN ('draft', 'returned') LIMIT 1`, [poemId, u.id])).rows[0];
if (open) return { id: Number(open.id) };
const cur = await current(poemId);
if (!cur) return reply.code(404).send({ error: 'کلام نہیں ملا' });
const { rows } = await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ('work', $1, $2, $3, $3, 'draft', $4, $5) RETURNING id`,
[poemId, cur.version, cur.content, u.id, u.email]);
await event(rows[0].id, u, 'created');
return { id: Number(rows[0].id) };
});
app.get<{ Params: { id: string } }>('/api/mod/order/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const cur = await currentOrder(Number(req.params.id) || 0);
if (!cur) return reply.code(404).send({ error: 'حصہ نہیں ملا' });
const { rows } = await pool.query(
`SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
FROM revisions r WHERE entity = 'order' AND entity_id = $1 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [cur.cat.id]);
return { section: cur.cat, version: cur.version, content: cur.content, count: cur.count,
history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { arrange: await mayArrange(u, cur.cat.id) } };
});
app.post<{ Params: { id: string } }>('/api/mod/order/:id/draft', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const catId = Number(req.params.id) || 0;
if (!(await mayArrange(u, catId))) return reply.code(403).send({ error: 'اس حصے کی ترتیب بدلنے کی اجازت نہیں' });
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = 'order' AND entity_id = $1 AND author_id = $2 AND status IN ('draft', 'returned') LIMIT 1`, [catId, u.id])).rows[0];
if (open) return { id: Number(open.id) };
const cur = await currentOrder(catId);
if (!cur) return reply.code(404).send({ error: 'حصہ نہیں ملا' });
if (cur.count < 2) return reply.code(400).send({ error: 'اس حصے میں ترتیب دینے کو کچھ نہیں' });
const { rows } = await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ('order', $1, $2, $3, $3, 'draft', $4, $5) RETURNING id`,
[catId, cur.version, cur.content, u.id, u.email]);
await event(rows[0].id, u, 'created');
return { id: Number(rows[0].id) };
});
app.get<{ Params: { kind: string; id: string } }>('/api/mod/tags/:kind/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const kind = req.params.kind as TagTarget;
if (kind !== 'work' && kind !== 'category') return reply.code(404).send({ error: 'نہیں ملا' });
const cur = await currentTags(kind, Number(req.params.id) || 0);
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const { rows } = await pool.query(
`SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
FROM revisions r WHERE entity = $1 AND entity_id = $2 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [`tags-${kind}`, cur.target.id]);
return { kind, target: cur.target, version: cur.version, content: cur.content, couplets: cur.couplets,
history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { tags: await mayTag(u, kind, cur.target.id) } };
});
app.post<{ Params: { kind: string; id: string } }>('/api/mod/tags/:kind/:id/draft', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const kind = req.params.kind as TagTarget, id = Number(req.params.id) || 0;
if (kind !== 'work' && kind !== 'category') return reply.code(404).send({ error: 'نہیں ملا' });
if (!(await mayTag(u, kind, id))) return reply.code(403).send({ error: 'یہاں ٹیگ لگانے کی اجازت نہیں' });
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [`tags-${kind}`, id, u.id])).rows[0];
if (open) return { id: Number(open.id) };
const cur = await currentTags(kind, id);
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const { rows } = await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`,
[`tags-${kind}`, id, cur.version, cur.content, u.id, u.email]);
await event(rows[0].id, u, 'created');
return { id: Number(rows[0].id) };
});
app.get<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { kind } = req.params, cur = isDetail(kind) ? await currentDetails(kind, Number(req.params.id) || 0) : null;
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const { rows } = await pool.query(
`SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
FROM revisions r WHERE entity = $1 AND entity_id = $2 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [kind, cur.details.id]);
return { kind, target: cur.details, version: cur.version, content: cur.content, fields: parseDetails(cur.content),
history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { edit: await mayDetails(u, kind as DetailKind, cur.details.id) } };
});
app.post<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id/draft', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { kind } = req.params, id = Number(req.params.id) || 0;
if (!isDetail(kind)) return reply.code(404).send({ error: 'نہیں ملا' });
if (!(await mayDetails(u, kind, id))) return reply.code(403).send({ error: 'اس میں ترمیم کی اجازت نہیں' });
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [kind, id, u.id])).rows[0];
if (open) return { id: Number(open.id) };
const cur = await currentDetails(kind, id);
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const { rows } = await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`, [kind, id, cur.version, cur.content, u.id, u.email]);
await event(rows[0].id, u, 'created');
return { id: Number(rows[0].id) };
});
app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0);
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
const may = await actions(u, r);
if (Number(r.author_id) !== Number(u.id) && !(await mayReview(u, r)) && u.role !== 'admin')
return reply.code(403).send({ error: 'یہ مسودہ دیکھنے کی اجازت نہیں' });
const diff = diffLines(r.base_content, r.content); // against the text the draft started from
const events = (await pool.query('SELECT actor_email, action, comment, at FROM revision_events WHERE revision_id = $1 ORDER BY at, id', [r.id])).rows;
const { author_id, base_content, ...rest } = r;
return { revision: { ...rest, id: Number(r.id) }, diff, changes: changed(diff), events, may };
});
app.post<{ Params: { id: string }; Body: { content?: string; summary?: string } }>('/api/mod/revisions/:id/save', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0);
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
if (!(await actions(u, r)).save) return reply.code(403).send({ error: 'یہ مسودہ اب محفوظ نہیں کیا جا سکتا' });
const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook' || isDetail(r.entity)) {
const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : r.entity === 'ebook' ? checkEbook(content)
: isDetail(r.entity) ? checkDetails(r.entity, content)
: await checkTags(tagKind(r), r.entity_id, content);
if (bad) return reply.code(400).send({ error: bad });
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
if (content.length > 500_000) return reply.code(400).send({ error: 'متن بہت لمبا ہے' });
// no change from the text it started from (compared as Divan text, so layout-only differences don't count):
// a plain draft is dropped rather than kept
const norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : r.entity.startsWith('tags-') ? tagsText(parseTags(t) as Tag[])
: r.entity === 'ebook' ? t.trim() : isDetail(r.entity) ? detailsText(r.entity, parseDetails(t)) : toText(parse(t)));
const same = norm(content) === norm(r.base_content);
if (same && r.status === 'draft') {
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
return { discarded: true };
}
if (same) return reply.code(400).send({ error: 'متن میں کوئی تبدیلی نہیں' });
const summary = String(req.body?.summary ?? '').trim().slice(0, 500) || null;
await pool.query('UPDATE revisions SET content = $2, summary = $3, updated_at = now() WHERE id = $1', [r.id, content, summary]);
await event(r.id, u, 'saved');
return { ok: true };
});
app.post<{ Params: { id: string; action: string }; Body: { comment?: string } }>('/api/mod/revisions/:id/:action', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0);
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
const action = req.params.action as keyof Awaited<ReturnType<typeof actions>>, may = await actions(u, r);
if (!['submit', 'approve', 'return', 'reject', 'publish'].includes(action)) return reply.code(404).send({ error: 'نامعلوم عمل' });
if (!may[action]) return reply.code(403).send({ error: 'یہ عمل آپ کے لیے دستیاب نہیں' });
const comment = String(req.body?.comment ?? '').trim().slice(0, 2000);
if ((action === 'return' || action === 'reject') && !comment) return reply.code(400).send({ error: 'وجہ لکھیں' });
const set = (status: string, extra = '') => pool.query(`UPDATE revisions SET status = $2, updated_at = now()${extra} WHERE id = $1`, [r.id, status]);
try {
if (action === 'submit') {
// L2 -> L1 review; an L1's own draft goes to the admin; an admin's own draft publishes
if (u.role === 'admin') { await set('approved'); await event(r.id, u, 'submitted', comment); return { status: 'published', version: await publish({ ...r, status: 'approved' }, u) }; }
await set(u.role === 'mod-l1' ? 'approved' : 'submitted');
await event(r.id, u, 'submitted', comment);
return { status: u.role === 'mod-l1' ? 'approved' : 'submitted' };
}
if (action === 'approve') {
await pool.query(`UPDATE revisions SET status = 'approved', reviewer_id = $3, reviewer_email = $2, updated_at = now() WHERE id = $1`, [r.id, u.email, u.id]);
await event(r.id, u, 'approved', comment);
return { status: 'approved' };
}
if (action === 'return' || action === 'reject') {
await set(action === 'return' ? 'returned' : 'rejected');
await event(r.id, u, action === 'return' ? 'returned' : 'rejected', comment);
return { status: action === 'return' ? 'returned' : 'rejected' };
}
return { status: 'published', version: await publish(r, u, comment) };
} catch (e: any) {
return reply.code(e.code ?? 500).send({ error: e.message });
}
});
app.get<{ Querystring: { page?: string; who?: string; kind?: string; action?: string } }>('/api/mod/log', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const page = Math.max(1, Number(req.query.page) || 1), { who, kind, action } = req.query;
// filters: a person (part of their address), a kind of change (tags covers both), a step; empty = all
const { rows } = await pool.query(
`SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.entity, r.version, ${TARGET_TITLE} AS title, ${TARGET_URL} AS url
FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET}
WHERE r.entity IN ${ENTITIES} AND ${CHANGED}
AND ($1 = '' OR e.actor_email ILIKE '%' || $1 || '%') AND ($2 = '' OR r.entity = $2 OR r.entity LIKE $2 || '-%') AND ($3 = '' OR e.action = $3)
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`, [who?.trim() ?? '', kind ?? '', action ?? '']);
return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
});
app.get<{ Params: { entity: string; id: string }; Querystring: { a?: string; b?: string } }>('/api/mod/compare/:entity/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { entity } = req.params, id = Number(req.params.id) || 0;
const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null;
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const pick = (q: string | undefined, d: number) => (q === undefined || q === '' ? d : Number(q));
const a = pick(req.query.a, Math.max(0, cur.version - 1)), b = pick(req.query.b, cur.version);
const [ta, tb] = await Promise.all([a, b].map((v) => Number.isInteger(v) && v >= 0 && v <= cur.version ? versionText(entity, id, v, cur) : undefined));
if (ta === undefined || tb === undefined) return reply.code(404).send({ error: 'یہ ورژن نہیں ملا' });
const target = cur.poem ?? cur.cat ?? cur.target ?? cur.details, diff = diffLines(ta, tb);
return { entity, id, title: target.title, url: target.url, version: cur.version, a, b, diff, changes: changed(diff),
may: { revert: await mayChange(u, { entity, entity_id: id }) } };
});
app.post<{ Params: { entity: string; id: string }; Body: { version?: number } }>('/api/mod/revert/:entity/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const { entity } = req.params, id = Number(req.params.id) || 0, v = Number(req.body?.version);
const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null;
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
if (!(await mayChange(u, { entity, entity_id: id }))) return reply.code(403).send({ error: 'اس میں تبدیلی کی اجازت نہیں' });
const content = Number.isInteger(v) && v >= 0 && v < cur.version ? await versionText(entity, id, v, cur) : undefined;
if (content === undefined) return reply.code(400).send({ error: 'یہ ورژن واپس نہیں لایا جا سکتا' });
if (content === cur.content) return reply.code(400).send({ error: 'یہ ورژن موجودہ ورژن جیسا ہی ہے' });
// the reverted text goes into my open draft (or a new one) and then through review like any edit
const summary = v ? `ورژن ${v} واپس لایا` : 'ویکی ماخذ کا متن واپس لایا';
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [entity, id, u.id])).rows[0];
const revId = open ? Number(open.id) : Number((await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`, [entity, id, cur.version, cur.content, u.id, u.email])).rows[0].id);
if (!open) await event(revId, u, 'created');
await pool.query(`UPDATE revisions SET content = $2, summary = $3, base_version = $4, base_content = $5, updated_at = now() WHERE id = $1`,
[revId, content, summary, cur.version, cur.content]);
await event(revId, u, 'reverted', summary);
return { id: revId };
});
}

View File

@ -1,77 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { permissionRoutes } from './permissions.ts';
import { moderationRoutes } from './moderation.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('arranging: its own permission; L2 reorders, L1 approves, admin publishes to divan-data and the site', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
process.env.DIVAN_DATA_DIR = data;
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify();
authRoutes(app); permissionRoutes(app); moderationRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `ord-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
// Ghalib's ghazals; their order is restored at the end
const cat = (await pool.query(`SELECT id, url FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
const before = (await pool.query('SELECT id, position FROM poems WHERE category_id = $1', [cat.id])).rows;
try {
const grant = (m: any, actions: string[]) =>
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, '{works}', $3)`, [m.id, cat.id, actions]);
await grant(l2, ['edit']);
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false, book: false, poet: false }, 'editing is not arranging');
assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
await grant(l2, ['arrange']); await grant(l1, ['arrange']);
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false, book: false, poet: false });
const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json();
const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();
const lines = cur.content.split('\n');
assert.ok(lines.length > 100 && /^sh\d+ /.test(lines[0]), 'one line per work: "<url part> <title>"');
// the list must keep every item once
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: lines.slice(1).join('\n') }, l2.token)).statusCode, 400, 'an item missing');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: [lines[0], ...lines].join('\n') }, l2.token)).statusCode, 400, 'an item twice');
// move the last ghazal to the top; titles may be anything (only the order counts)
const moved = [lines.at(-1), ...lines.slice(0, -1)].join('\n');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: moved, summary: 'آخری غزل پہلے' }, l2.token)).statusCode, 200);
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
assert.equal((await call('GET', '/api/mod/queue', undefined, l1.token)).json().review.some((r: any) => r.id === id && r.entity === 'order'), true);
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l1.token)).json().status, 'approved');
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
// the site follows the new order; divan-data holds it and its commit
const first = (await pool.query('SELECT url FROM poems WHERE category_id = $1 ORDER BY position, id LIMIT 1', [cat.id])).rows[0].url;
assert.equal(first.split('/').pop(), lines.at(-1).split(' ')[0]);
assert.equal(await readFile(join(data, 'divan', 'p266', 'ghazal.order'), 'utf8'), moved + '\n');
assert.match(git('log', '-1', '--format=%s').trim(), /^ترتیب: .+: آخری غزل پہلے \(ورژن 1\)$/);
assert.equal((await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json().version, 1);
// a new draft saved unchanged is dropped
const again = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json().id;
assert.deepEqual((await call('POST', `/api/mod/revisions/${again}/save`, { content: moved }, l2.token)).json(), { discarded: true });
} finally {
for (const p of before) await pool.query('UPDATE poems SET position = $2 WHERE id = $1', [p.id, p.position]);
await pool.query(`DELETE FROM revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true });
await app.close();
}
});

View File

@ -1,61 +0,0 @@
// Arranging (#41): the order of a book or section's contents (its sub-sections, then its works); a poet's top level
// lists their books. An arrangement is a revision (entity 'order', entity_id = categories.id) and goes through the
// same pipeline as a work's text. Its text is one line per item, "<last part of the URL> <title>", e.g.
// c641 بانگ درا
// sh6556 ہمالہ
// Only the order of the lines counts; titles are for reading. Published orders live in divan-data as
// divan/<category url>.order, which the export follows (export_divan.py), so the daily sync keeps Divan's order.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { PoolClient } from 'pg';
import { pool } from './db.ts';
const slug = (url: string) => url.slice(url.lastIndexOf('/') + 1);
// the category's items in their current order (the site's positions)
async function items(catId: number) {
const [cats, poems] = await Promise.all([
pool.query('SELECT id, url, title FROM categories WHERE parent_id = $1 ORDER BY position, id', [catId]),
pool.query('SELECT id, url, title FROM poems WHERE category_id = $1 ORDER BY position, id', [catId]),
]);
return { cats: cats.rows, poems: poems.rows };
}
const lines = (xs: { url: string; title: string }[]) => xs.map((x) => `${slug(x.url)} ${x.title}`);
// a category's current order as text, and the latest published version of its order
export async function currentOrder(catId: number) {
const cat = (await pool.query('SELECT id, url, title, parent_id FROM categories WHERE id = $1', [catId])).rows[0];
if (!cat) return null;
const { cats, poems } = await items(catId);
const last = (await pool.query(
`SELECT max(version) AS v FROM revisions WHERE entity = 'order' AND entity_id = $1 AND status = 'published'`, [catId])).rows[0];
return { cat, version: Number(last?.v ?? 0), content: [...lines(cats), ...lines(poems)].join('\n'), count: cats.length + poems.length };
}
export const orderSlugs = (text: string) => text.split('\n').map((l) => l.trim().split(/\s/)[0]).filter(Boolean);
// the text must list every item of the category exactly once (nothing added, removed or repeated)
export async function checkOrder(catId: number, text: string) {
const { cats, poems } = await items(catId);
const want = [...cats, ...poems].map((x) => slug(x.url)).sort(), got = orderSlugs(text);
if (new Set(got).size !== got.length) return 'ایک ہی چیز دو بار درج ہے';
if (got.length !== want.length || [...got].sort().some((s, i) => s !== want[i]))
return 'فہرست میں اس حصے کی تمام چیزیں ایک ایک بار ہونی چاہییں (نہ کوئی نئی، نہ کم)';
return null;
}
// positions on the site: sub-sections and works each numbered in the order of the text
export async function applyOrder(client: PoolClient, catId: number, text: string) {
const at = new Map(orderSlugs(text).map((s, i) => [s, i]));
const { cats, poems } = await items(catId);
for (const [table, xs] of [['categories', cats], ['poems', poems]] as const)
for (const x of xs) await client.query(`UPDATE ${table} SET position = $2 WHERE id = $1`, [x.id, at.get(slug(x.url))]);
}
export async function writeOrder(dataDir: string, url: string, text: string) {
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.order');
await mkdir(dirname(path), { recursive: true });
await writeFile(path, text.trim() + '\n');
return path;
}

View File

@ -1,52 +0,0 @@
// Divan-owned content (#30): works edited in Divan are kept in divan-data's divan/ folder, so the daily
// Wikisource sync never overwrites them (export_divan.py prefers them). Each work is two files:
// divan/<work url>.dtx Divan text, the readable source (see divantext.ts, docs/content-model.md)
// divan/<work url>.json generated from it: Title, Verses (the site's verse layout), Edited (who, when)
// Publishing (#34) calls writeOwned and commits both; fromPoem gives the starting text for an edit.
// node src/owned.ts <divan-data dir> <work url> <file.dtx> [editor] write one by hand
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { parse, toVerses } from './divantext.ts';
type Verse = { Position: string; Text: string; CoupletIndex: number };
// a work (ganjoor-data poem JSON) as Divan text: verse in <poem> (blank line between couplets, stanzas and
// lines), prose as paragraphs
export function fromPoem(poem: { Title: string; Verses: Verse[]; SourceUrl?: string }, meta: Record<string, string> = {}) {
const head = Object.entries({ عنوان: poem.Title, ...meta, ...(poem.SourceUrl && { ماخذ: 'ویکی ماخذ', ماخذ_لنک: poem.SourceUrl }) })
.map(([k, v]) => `| ${k} = ${v}`).join('\n');
const units = new Map<number, Verse[]>();
for (const v of poem.Verses) (units.get(v.CoupletIndex) ?? units.set(v.CoupletIndex, []).get(v.CoupletIndex)!).push(v);
const parts: string[] = [];
let verse: string[] = [];
const flush = () => { if (verse.length) parts.push(`<poem>\n${verse.join('\n\n')}\n</poem>`); verse = []; };
for (const u of units.values()) {
if (u[0].Position === 'Paragraph') { flush(); parts.push(u.map((v) => v.Text).join('\n')); }
else if (u[0].Position === 'Heading') { flush(); parts.push(`${'='.repeat((u[0] as any).Level ?? 2)} ${u[0].Text} ${'='.repeat((u[0] as any).Level ?? 2)}`); }
else verse.push(u.map((v) => v.Text).join('\n'));
}
flush();
return `{{دیوان\n${head}\n}}\n${parts.join('\n\n')}\n`;
}
// write a Divan-owned work (the .dtx and the generated .json); returns the paths written
export async function writeOwned(dataDir: string, url: string, dtx: string,
edited: { by: string; at?: string; version?: number; revision?: number; reviewedBy?: string | null; publishedBy?: string }) {
const doc = parse(dtx);
const verses = toVerses(doc);
if (!verses.length) throw new Error('the text has no verses or paragraphs');
if (!/^[\w/-]+$/.test(url) || url.includes('..')) throw new Error(`not a work url: ${url}`);
const base = join(dataDir, 'divan', url.replace(/^\/+|\/+$/g, ''));
await mkdir(dirname(base), { recursive: true });
const json = { FullUrl: '/' + url.replace(/^\/+/, ''), Title: doc.meta['عنوان'] ?? '', Verses: verses.map(({ VOrder, ...v }) => ({ VOrder, ...v, SectionIndex1: 0 })),
Edited: { ...edited, at: edited.at ?? new Date().toISOString() } };
await writeFile(base + '.dtx', dtx.endsWith('\n') ? dtx : dtx + '\n');
await writeFile(base + '.json', JSON.stringify(json, null, 1) + '\n');
return [base + '.dtx', base + '.json'];
}
if (import.meta.url === `file://${process.argv[1]}`) {
const [dir, url, file, by = 'divan'] = process.argv.slice(2);
if (!dir || !url || !file) { console.error('usage: node src/owned.ts <divan-data dir> <work url> <file.dtx> [editor]'); process.exit(1); }
console.log((await writeOwned(dir, url, await readFile(file, 'utf8'), { by })).join('\n'));
}

View File

@ -13,7 +13,7 @@ import { audit, requireAdmin } from './admin.ts';
export const MODERATORS = ['mod-l2', 'mod-l1'] as const;
export const SCOPES = ['all', 'poet', 'category', 'poem'] as const;
export const CONTENT = ['poets', 'books', 'works', 'dictionary', 'site', 'tags', 'ebooks'] as const; // site: home page sections (site.ts); tags (tags.ts); ebooks (ebooks.ts)
export const CONTENT = ['poets', 'books', 'works', 'dictionary'] as const;
export const ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const;
export type Action = (typeof ACTIONS)[number];
export type Content = (typeof CONTENT)[number];
@ -95,7 +95,7 @@ export async function grantsOf(userId: number) {
export function permissionRoutes(app: FastifyInstance) {
app.get<{ Params: { id: string } }>('/api/admin/users/:id/grants', async (req, reply) => {
if (!(await requireAdmin(req, reply))) return;
const u = (await pool.query('SELECT id, email, full_name, role, created_at, disabled_at, (SELECT max(created_at) FROM sessions WHERE user_id = users.id) AS last_sign_in FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
const u = (await pool.query('SELECT id, email, role, disabled_at FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
if (!u) return reply.code(404).send({ error: 'صارف نہیں ملا' });
return { user: { ...u, id: Number(u.id) }, grants: await grantsOf(u.id) };
});

View File

@ -7,7 +7,6 @@
// /api/auth/* accounts (see auth.ts)
// /api/admin/* admin panel (see admin.ts)
// /api/library/* a reader's saved poets, works, couplets and words (see library.ts)
// /api/mod/* content moderation: drafts, review, publishing, history (see moderation.ts)
// GET /health
import Fastify from 'fastify';
import { pool } from './db.ts';
@ -16,35 +15,10 @@ import { nameMatches } from './search.ts';
import { lookup, PUNCT } from './dictionary.ts';
import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts';
import { statsRoutes } from './stats.ts';
import { permissionRoutes } from './permissions.ts';
import { libraryRoutes } from './library.ts';
import { moderationRoutes } from './moderation.ts';
import { siteRoutes } from './site.ts';
import { tagRoutes, pageTags } from './tags.ts';
import { ebookRoutes } from './ebooks.ts';
import { feedRoutes } from './feeds.ts';
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
// Errors: readers get a short message, never internal details; the log gets the whole error (one JSON line each,
// with the method and address), which is what the server's error report reads (divan-deploy scripts/errors.sh).
app.setErrorHandler((err: any, req, reply) => {
const status = err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 500;
if (status === 500) req.log.error({ err, method: req.method, url: req.url }, 'request failed');
reply.code(status).send({ error: status === 500 ? 'سرور میں خرابی۔ تھوڑی دیر بعد کوشش کریں۔' : err.message });
});
// for the site's /health (uptime monitoring): the API answers and the database too
app.get('/api/health', async (req, reply) => {
try {
await pool.query('SELECT 1');
return { ok: true, db: 'ok' };
} catch (err) {
req.log.error({ err }, 'health: database unreachable');
return reply.code(503).send({ ok: false, db: 'down' });
}
});
const PAGE_SIZE = 20;
app.get('/health', async () => {
@ -77,7 +51,7 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
const poem = (await pool.query('SELECT * FROM poems WHERE url = $1', [url])).rows[0];
if (poem) {
const [verses, poet, crumbs, siblings, edited] = await Promise.all([
const [verses, poet, crumbs, siblings] = await Promise.all([
pool.query('SELECT vorder, position, couplet, text FROM verses WHERE poem_id = $1 ORDER BY vorder', [poem.id]),
pool.query('SELECT id, url, nickname FROM poets WHERE id = $1', [poem.poet_id]),
ancestors(poem.category_id),
@ -86,16 +60,9 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
(SELECT url FROM poems WHERE category_id = $1 AND position > $2 ORDER BY position LIMIT 1) AS next`,
[poem.category_id, poem.position],
),
// the latest Divan version (public names only) and its divan-data commit
pool.query(`SELECT version, published_at, credits, commit FROM revisions WHERE entity = 'work' AND entity_id = $1 AND status = 'published'
ORDER BY version DESC LIMIT 1`, [poem.id]),
]);
const e = edited.rows[0];
const divan = e && { version: e.version, at: e.published_at, ...e.credits,
commit_url: e.commit ? (process.env.DIVAN_DATA_COMMIT_URL ?? 'https://git.anasrashid.net/anas/divan-data/commit/{sha}').replace('{sha}', e.commit) : null };
const { search_text, ...rest } = poem;
return { type: 'poem', poem: rest, poet: poet.rows[0], breadcrumbs: crumbs, verses: verses.rows, ...siblings.rows[0], divan,
tags: await pageTags('work', poem.id) };
return { type: 'poem', poem: rest, poet: poet.rows[0], breadcrumbs: crumbs, verses: verses.rows, ...siblings.rows[0] };
}
const cat = (await pool.query('SELECT * FROM categories WHERE url = $1', [url])).rows[0];
@ -118,29 +85,19 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
]);
return {
type: cat.parent_id === null ? 'poet' : 'category',
category: cat, poet: poet.rows[0], breadcrumbs: crumbs, children: children.rows, poems: poems.rows, tags: await pageTags('category', cat.id),
category: cat, poet: poet.rows[0], breadcrumbs: crumbs, children: children.rows, poems: poems.rows,
};
}
return reply.code(404).send({ error: 'not found' });
});
app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/search', async (req) => {
// tag:عشق or ٹیگ:عشق (quotes for names with spaces) keeps works carrying the tag: on the work, one of its couplets,
// or its book/chapter; the rest of the query is text as before
const TAG = /(?:tag|ٹیگ):(?:"([^"]+)"|(\S+))/g;
const tagNames = [...new Set([...(req.query.q ?? '').matchAll(TAG)].map((m) => (m[1] ?? m[2]).trim()).filter(Boolean))].slice(0, 5);
const q = (req.query.q ?? '').replace(TAG, ' ').trim();
const patterns = likePatterns(q);
if (!patterns.length && !tagNames.length) return { total: 0, page: 1, pageSize: PAGE_SIZE, results: [], poets: [], books: [], tags: [] };
const patterns = likePatterns(req.query.q ?? '');
if (!patterns.length) return { total: 0, page: 1, pageSize: PAGE_SIZE, results: [], poets: [], books: [] };
const page = Math.max(1, Number(req.query.page) || 1);
const params: unknown[] = [...patterns];
const where = patterns.map((_, i) => `p.search_text ILIKE $${i + 1}`);
for (const name of tagNames) {
params.push(name);
where.push(`EXISTS (SELECT 1 FROM entity_tags e JOIN tags g ON g.id = e.tag_id WHERE g.name = $${params.length}
AND ((e.entity = 'work' AND e.entity_id = p.id) OR (e.entity = 'category' AND e.entity_id = p.category_id)))`);
}
const textWhere = where.join(' AND '), textParams = [...params];
const textWhere = where.join(' AND ');
const poetIds = [...new Set(String(req.query.poet ?? '').split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0))].slice(0, 50);
if (poetIds.length) {
params.push(poetIds);
@ -148,7 +105,7 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
}
const sql = `FROM poems p JOIN poets t ON t.id = p.poet_id WHERE ${where.join(' AND ')}`;
// several words: poems with them together as a phrase come first
const phrase = likePatterns(`"${q}"`)[0] ?? '%';
const phrase = likePatterns(`"${req.query.q}"`)[0];
const [count, rows] = await Promise.all([
pool.query(`SELECT count(*)::int AS n ${sql}`, params),
pool.query(
@ -159,21 +116,15 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
),
]);
// snippet: the verse holding most of the terms, best the whole phrase (with its couplet partner), else the first line
const ts = terms(q), whole = ts.join(' '), top = ts.length + (ts.length > 1 ? 1 : 0);
const ts = terms(req.query.q ?? ''), whole = ts.join(' '), top = ts.length + (ts.length > 1 ? 1 : 0);
const verses = await pool.query(
'SELECT poem_id, position, couplet, text FROM verses WHERE poem_id = ANY($1) ORDER BY poem_id, vorder',
[rows.rows.map((r) => r.id)],
);
const byPoem = Map.groupBy(verses.rows, (v) => v.poem_id);
// with tags and no words, a work's snippet is its tagged couplet
const tagged = tagNames.length && !ts.length ? (await pool.query(
`SELECT e.entity_id AS poem_id, min(e.couplet) AS couplet FROM entity_tags e JOIN tags g ON g.id = e.tag_id
WHERE e.entity = 'work' AND e.couplet > 0 AND g.name = ANY($1) AND e.entity_id = ANY($2) GROUP BY e.entity_id`,
[tagNames, rows.rows.map((r) => r.id)])).rows : [];
const results = rows.rows.map(({ id, ...r }) => {
const vs = byPoem.get(id) ?? [];
const tc = tagged.find((t) => t.poem_id === id)?.couplet;
let best = (tc && vs.find((v) => v.couplet === tc - 1)) || vs[0], score = 0; // tags count couplets from 1
let best = vs[0], score = 0;
for (const v of vs) {
const n = normalise(v.text), k = ts.filter((t) => n.includes(t)).length + (ts.length > 1 && n.includes(whole) ? 1 : 0);
if (k > score) [best, score] = [v, k];
@ -184,25 +135,14 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
return { ...r, snippet: lines, prose: best?.position === 'Paragraph' };
});
// names and titles on the first page: poets/writers whose name has every word, books/chapters likewise
const names = page === 1 && !poetIds.length && ts.length ? await nameMatches(ts) : { poets: [], books: [] };
// tags whose name has the words (first page), and the tags searched for
const tagRows = page === 1 && (ts.length || tagNames.length) ? (await pool.query(
`SELECT g.type, g.name, count(*)::int AS n FROM tags g JOIN entity_tags e ON e.tag_id = g.id GROUP BY g.id ORDER BY n DESC`)).rows
.filter((t) => tagNames.includes(t.name) || (ts.length && ts.every((w) => normalise(t.name).includes(w)))).slice(0, 20) : [];
const names = page === 1 && !poetIds.length ? await nameMatches(ts) : { poets: [], books: [] };
// which poets/writers the matching content comes from (whatever the poet filter), for narrowing down
const [authors, selected] = await Promise.all([
pool.query(`SELECT t.id, t.url, t.nickname, count(*)::int AS n FROM poems p JOIN poets t ON t.id = p.poet_id
WHERE ${textWhere} GROUP BY t.id ORDER BY n DESC, t.nickname LIMIT 40`, textParams),
WHERE ${textWhere} GROUP BY t.id ORDER BY n DESC, t.nickname LIMIT 40`, patterns),
poetIds.length ? pool.query('SELECT id, url, nickname FROM poets WHERE id = ANY($1) ORDER BY nickname', [poetIds]) : { rows: [] },
]);
// e-books whose title (or text, for text books) has the words, on the first page (trigram index)
const ebooks = page === 1 && patterns.length && !tagNames.length ? (await pool.query(
`SELECT b.id, b.title, b.kind, t.nickname AS poet, t.url AS poet_url FROM ebooks b JOIN poets t ON t.id = b.poet_id
WHERE b.published AND ${patterns.map((_, i) => `b.search_text ILIKE $${i + 1}`).join(' AND ')}
${poetIds.length ? `AND b.poet_id = ANY($${patterns.length + 1})` : ''} ORDER BY b.title LIMIT 20`,
poetIds.length ? [...patterns, poetIds] : patterns)).rows : [];
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results, ...names, tags: tagRows, searchedTags: tagNames, ebooks,
authors: authors.rows, selected: selected.rows };
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results, ...names, authors: authors.rows, selected: selected.rows };
});
// one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader
@ -216,12 +156,6 @@ authRoutes(app);
adminRoutes(app);
permissionRoutes(app);
libraryRoutes(app);
moderationRoutes(app);
siteRoutes(app);
tagRoutes(app);
ebookRoutes(app);
feedRoutes(app);
statsRoutes(app);
const port = Number(process.env.PORT ?? 4100);
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });

View File

@ -1,56 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { siteRoutes } from './site.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('home sections: default layout, the site permission, rename, sort, chosen poets, reorder', async () => {
const app = Fastify();
authRoutes(app); siteRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `site-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const saved = (await pool.query('SELECT position, title, century, sort, poets FROM home_sections ORDER BY position')).rows;
const mod = await person('mod', 'mod-l2'), other = await person('other', 'mod-l1');
try {
// the default layout: featured poets, then centuries of birth
await pool.query('DELETE FROM home_sections');
const home = (await call('GET', '/api/home')).json();
assert.equal(home[0].title, 'مقبول شعرا');
assert.ok(home.slice(1).every((s: any) => s.century !== null && s.title === null && s.sort === 'timeline'));
const all = home.slice(1).reduce((n: number, s: any) => n + s.poets.length, 0);
assert.equal(all, Number((await pool.query('SELECT count(*) FROM poets')).rows[0].count), 'every poet in a century');
// only with the site permission
assert.equal((await call('GET', '/api/mod/site', undefined, other.token)).statusCode, 403);
await pool.query(`INSERT INTO grants (user_id, scope, content, actions) VALUES ($1, 'all', '{site}', '{edit}')`, [mod.id]);
const ed = (await call('GET', '/api/mod/site', undefined, mod.token)).json();
// rename the 13th century, sort it alphabetically, put it first; a new chosen section; no nameless list
const c13 = ed.sections.find((s: any) => s.century === 13);
const iqbal = ed.poets.find((p: any) => p.name === 'محمد اقبال').id, ghalib = ed.poets.find((p: any) => p.name === 'مرزا غالب').id;
const layout = [{ ...c13, title: 'تیرھویں صدی کے شعرا', sort: 'alpha' }, { title: 'نئی فہرست', century: null, sort: 'manual', poets: [iqbal, ghalib, iqbal, 999999] },
...ed.sections.filter((s: any) => s !== c13)];
assert.equal((await call('POST', '/api/mod/site', { sections: [{ title: '', century: null, poets: [] }] }, mod.token)).statusCode, 400);
const res = (await call('POST', '/api/mod/site', { sections: layout }, mod.token)).json().sections;
assert.equal(res[0].title, 'تیرھویں صدی کے شعرا');
const names = res[0].poets.map((p: any) => p.nickname);
assert.deepEqual(names, [...names].sort(new Intl.Collator('ur').compare), 'alphabetical');
assert.deepEqual(res[1].poets.map((p: any) => p.id), [iqbal, ghalib], 'chosen order; repeats and unknown poets dropped');
assert.equal((await pool.query(`SELECT count(*) FROM audit_log WHERE action = 'site-sections' AND actor_email = $1`, [`mod-${run}@divan.test`])).rows[0].count, '1');
} finally {
await pool.query('DELETE FROM home_sections');
for (const s of saved) await pool.query('INSERT INTO home_sections (position, title, century, sort, poets) VALUES ($1, $2, $3, $4, $5)', [s.position, s.title, s.century, s.sort, s.poets]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1', [`%-${run}@divan.test`]);
await app.close();
}
});

View File

@ -1,102 +0,0 @@
// Home page sections (owner request): moderators with the 'site' permission (admins always) rename, add, remove and
// reorder the sections, choose their poets (e.g. مقبول شعرا) and how each is sorted: by hand, alphabetically or by
// timeline. A century section lists every poet born in that Hijri century, so new poets appear on their own.
// Changes apply at once and go to the audit log.
// GET /api/home the sections with their poets (public)
// GET /api/mod/site the sections as stored, and every poet (for the editor)
// POST /api/mod/site {sections: [{title, century, sort, poets}]}: the whole layout, in order
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { pool } from './db.ts';
import { sessionUser } from './auth.ts';
import { can } from './permissions.ts';
import { audit } from './admin.ts';
type Section = { id?: number; title: string | null; century: number | null; sort: string; poets: number[] };
const SORTS = ['manual', 'alpha', 'timeline'];
const centuryOf = (y: number | null) => (y ? Math.floor(y / 100) + 1 : 0); // 0: year unknown
const urdu = new Intl.Collator('ur');
async function replace(list: Section[]) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM home_sections');
for (const [i, s] of list.entries())
await client.query('INSERT INTO home_sections (position, title, century, sort, poets) VALUES ($1, $2, $3, $4, $5)',
[i, s.title, s.century, s.sort, s.poets]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// the stored sections; the first time, the layout the home page always had (featured poets, then a section per
// century of birth, each by timeline)
async function sections(): Promise<Section[]> {
const q = () => pool.query('SELECT id, title, century, sort, poets FROM home_sections ORDER BY position, id');
let { rows } = await q();
if (rows.length) return rows;
const pinned = (await pool.query('SELECT id FROM poets WHERE pin_order IS NOT NULL ORDER BY pin_order')).rows.map((r) => r.id);
const centuries = (await pool.query('SELECT DISTINCT coalesce(birth_year_ah / 100 + 1, 0) AS c FROM poets')).rows
.map((r) => r.c as number).sort((a, b) => (a || 99) - (b || 99));
// ponytail: two first requests at once could both seed; replace() runs in a transaction, the last one wins
await replace([{ title: 'مقبول شعرا', century: null, sort: 'manual', poets: pinned },
...centuries.map((c) => ({ title: null, century: c, sort: 'timeline', poets: [] }))]);
return (await q()).rows;
}
// each section with its poets in order (sections with nobody in them are left out)
export async function home() {
const poets = (await pool.query(
'SELECT id, url, nickname, birth_year_ah, death_year_ah, birth_year_ce, death_year_ce FROM poets')).rows;
const byId = new Map(poets.map((p) => [p.id, p]));
const timeline = (a: any, b: any) => (a.birth_year_ah ?? 1e9) - (b.birth_year_ah ?? 1e9) || urdu.compare(a.nickname, b.nickname);
return (await sections()).map((s) => {
const chosen = s.poets.map((id) => byId.get(id)).filter(Boolean);
let list = s.century === null ? chosen : [
...chosen.filter((p) => centuryOf(p.birth_year_ah) === s.century),
...poets.filter((p) => centuryOf(p.birth_year_ah) === s.century && !s.poets.includes(p.id)).sort(timeline)];
if (s.sort === 'alpha') list = [...list].sort((a, b) => urdu.compare(a.nickname, b.nickname));
if (s.sort === 'timeline') list = [...list].sort(timeline);
return { id: s.id, title: s.title, century: s.century, sort: s.sort, poets: list };
}).filter((s) => s.poets.length);
}
async function siteEditor(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req);
if (!u) return void reply.code(401).send({ error: 'لاگ ان کریں' });
if (!(await can(u, 'edit', 'site'))) return void reply.code(403).send({ error: 'سائٹ کے حصے بدلنے کی اجازت نہیں' });
return u;
}
export function siteRoutes(app: FastifyInstance) {
app.get('/api/home', async () => home());
app.get('/api/mod/site', async (req, reply) => {
if (!(await siteEditor(req, reply))) return;
const poets = (await pool.query('SELECT id, nickname AS name, birth_year_ah FROM poets ORDER BY nickname')).rows;
return { sections: await sections(), poets };
});
app.post<{ Body: { sections?: unknown } }>('/api/mod/site', async (req, reply) => {
const u = await siteEditor(req, reply); if (!u) return;
const raw = Array.isArray(req.body?.sections) ? req.body.sections : null;
if (!raw || !raw.length || raw.length > 100) return reply.code(400).send({ error: 'حصوں کی فہرست درست نہیں' });
const known = new Set((await pool.query('SELECT id FROM poets')).rows.map((r) => r.id));
const list: Section[] = [];
for (const s of raw as any[]) {
const century = s?.century === null || s?.century === undefined || s?.century === '' ? null : Number(s.century);
if (century !== null && !(Number.isInteger(century) && century >= 0 && century <= 20)) return reply.code(400).send({ error: 'صدی درست نہیں' });
const title = String(s?.title ?? '').trim().slice(0, 100) || null;
if (!title && century === null) return reply.code(400).send({ error: 'ہر حصے کا نام ضروری ہے' });
const poets = [...new Set((Array.isArray(s?.poets) ? s.poets : []).map(Number).filter((id: number) => known.has(id)))].slice(0, 500) as number[];
list.push({ title, century, sort: SORTS.includes(s?.sort) ? s.sort : 'manual', poets });
}
await replace(list);
await audit(u, 'site-sections', null, { sections: list.map((s) => s.title ?? `صدی ${s.century}`) });
return { sections: await home() };
});
}

View File

@ -1,42 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { statsRoutes } from './stats.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('statistics: hits only from the site, counts without addresses, admin-only dashboard', async () => {
const app = Fastify();
authRoutes(app); statsRoutes(app);
const run = Date.now(), path = `/stats-test-${run}`;
const hit = (body: object, headers: object = {}) => app.inject({ method: 'POST', url: '/api/stats/hit', payload: body, headers: headers as any });
try {
process.env.DIVAN_SITE_KEY = 'site-key';
assert.equal((await hit({ path, ip: '1.2.3.4', ua: 'x' })).statusCode, 403, 'not from the site');
const site = { 'x-site-key': 'site-key' };
for (const ip of ['1.2.3.4', '1.2.3.4', '5.6.7.8']) assert.equal((await hit({ path, ip, ua: 'Mozilla', ref: `ref-${run}.pk` }, site)).statusCode, 200);
const views = (await pool.query('SELECT views FROM stat_views WHERE path = $1', [path])).rows[0].views;
assert.equal(views, 3);
const hashes = (await pool.query('SELECT h FROM stat_visitors WHERE day = (now() AT TIME ZONE \'Asia/Karachi\')::date')).rows.map((r) => r.h);
assert.ok(!hashes.some((h) => h.includes('1.2.3.4')), 'no addresses stored');
assert.equal((await pool.query('SELECT n FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`])).rows[0].n, 3);
const s = (await app.inject({ method: 'POST', url: '/api/auth/signup', payload: { email: `stats-${run}@divan.test`, password: 'pass-word-1' }, headers: { 'x-client-ip': `stats-${run}` } })).json();
const get = () => app.inject({ method: 'GET', url: '/api/admin/stats?days=7', headers: { authorization: `Bearer ${s.token}` } });
assert.equal((await get()).statusCode, 403, 'readers cannot see statistics');
await pool.query(`UPDATE users SET role = 'admin' WHERE id = $1`, [s.user.id]);
const st = (await get()).json();
assert.equal(st.daily.length, 7);
assert.ok(st.daily.at(-1).views >= 3 && st.daily.at(-1).visitors >= 2);
assert.ok(st.pages.some((p: any) => p.path === path && p.views === 3));
assert.ok(st.accounts.roles.admin >= 1 && st.accounts.new >= 1);
} finally {
delete process.env.DIVAN_SITE_KEY;
await pool.query('DELETE FROM stat_views WHERE path = $1', [path]);
await pool.query('DELETE FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`]);
await pool.query('DELETE FROM users WHERE email = $1', [`stats-${run}@divan.test`]);
await app.close();
}
});

View File

@ -1,63 +0,0 @@
// Privacy-friendly statistics for admins (#86): page views, visitors and where they came from, plus moderation and
// account activity. No cookies and nothing personal is stored: the site reports a page view with the reader's address
// and browser, and only a one-day salted hash of them is kept (to count visitors per day); the salt is never stored.
// POST /api/stats/hit {path, ref, ip, ua} from the site only (x-site-key)
// GET /api/admin/stats?days=30 the admin dashboard's numbers
import { createHash, randomBytes } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { pool } from './db.ts';
import { fromSite } from './auth.ts';
import { requireAdmin } from './admin.ts';
const TODAY = `(now() AT TIME ZONE 'Asia/Karachi')::date`;
// ponytail: the salt is per process; a restart mid-day counts a returning visitor once more that day
let salt = { day: '', key: randomBytes(32) };
const visitor = (ip: string, ua: string) => {
const day = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Karachi' });
if (salt.day !== day) salt = { day, key: randomBytes(32) };
return createHash('sha256').update(salt.key).update(ip).update('\n').update(ua).digest('base64url').slice(0, 22);
};
export function statsRoutes(app: FastifyInstance) {
app.post<{ Body: { path?: string; ref?: string; ip?: string; ua?: string } }>('/api/stats/hit', async (req, reply) => {
if (!fromSite(req)) return reply.code(403).send({ error: 'forbidden' });
const { path = '', ref = '', ip = '', ua = '' } = req.body ?? {};
if (!path.startsWith('/') || path.length > 300) return reply.code(400).send({ error: 'path' });
await pool.query(`INSERT INTO stat_views (day, path, views) VALUES (${TODAY}, $1, 1)
ON CONFLICT (day, path) DO UPDATE SET views = stat_views.views + 1`, [path]);
await pool.query(`INSERT INTO stat_visitors (day, h) VALUES (${TODAY}, $1) ON CONFLICT DO NOTHING`, [visitor(ip, ua)]);
if (ref) await pool.query(`INSERT INTO stat_referrers (day, host, n) VALUES (${TODAY}, $1, 1)
ON CONFLICT (day, host) DO UPDATE SET n = stat_referrers.n + 1`, [ref.slice(0, 200)]);
return { ok: true };
});
app.get<{ Querystring: { days?: string } }>('/api/admin/stats', async (req, reply) => {
if (!(await requireAdmin(req, reply))) return;
const days = Math.min(365, Math.max(1, Number(req.query.days) || 30));
const since = `${TODAY} - ${days - 1}`;
const q = async (sql: string) => (await pool.query(sql)).rows;
const [daily, pages, referrers, actions, people, waiting, roles, accounts, library, content] = await Promise.all([
q(`SELECT d::date::text AS day, coalesce(v.views, 0)::int AS views, coalesce(u.visitors, 0)::int AS visitors
FROM generate_series(${since}, ${TODAY}, interval '1 day') d
LEFT JOIN (SELECT day, sum(views) AS views FROM stat_views GROUP BY day) v ON v.day = d::date
LEFT JOIN (SELECT day, count(*) AS visitors FROM stat_visitors GROUP BY day) u ON u.day = d::date ORDER BY d`),
q(`SELECT s.path, sum(s.views)::int AS views, coalesce(p.title, c.title, t.nickname) AS title
FROM stat_views s LEFT JOIN poems p ON p.url = s.path LEFT JOIN categories c ON c.url = s.path LEFT JOIN poets t ON t.url = s.path
WHERE s.day >= ${since} GROUP BY s.path, p.title, c.title, t.nickname ORDER BY views DESC LIMIT 20`),
q(`SELECT host, sum(n)::int AS n FROM stat_referrers WHERE day >= ${since} GROUP BY host ORDER BY n DESC LIMIT 10`),
q(`SELECT action, count(*)::int AS n FROM revision_events WHERE at >= ${since} GROUP BY action ORDER BY n DESC`),
q(`SELECT actor_email AS email, count(*)::int AS n, count(*) FILTER (WHERE action = 'published')::int AS published
FROM revision_events WHERE at >= ${since} AND actor_email <> 'server' GROUP BY actor_email ORDER BY n DESC LIMIT 10`),
q(`SELECT status, count(*)::int AS n FROM revisions WHERE status IN ('submitted', 'approved') GROUP BY status`),
q(`SELECT role, count(*)::int AS n FROM users GROUP BY role`),
q(`SELECT count(*) FILTER (WHERE created_at >= ${since})::int AS new,
(SELECT count(DISTINCT user_id) FROM sessions WHERE created_at >= ${since})::int AS signed_in FROM users`),
q(`SELECT kind, count(*)::int AS n FROM library GROUP BY kind`),
q(`SELECT (SELECT count(*) FROM ebooks WHERE published)::int AS ebooks, (SELECT count(*) FROM tags)::int AS tags,
(SELECT count(*) FROM revisions WHERE status = 'published')::int AS versions`),
]);
return { days, daily, pages, referrers, moderation: { actions, people, waiting: Object.fromEntries(waiting.map((r) => [r.status, r.n])) },
accounts: { ...accounts[0], roles: Object.fromEntries(roles.map((r) => [r.role, r.n])) },
library: Object.fromEntries(library.map((r) => [r.kind, r.n])), content: content[0] };
});
}

View File

@ -1,101 +0,0 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { moderationRoutes } from './moderation.ts';
import { tagRoutes, parseTags, tagsText, loadTags } from './tags.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('tag text: types, couplets, Urdu digits, repeats, order', () => {
assert.deepEqual(parseTags('موضوع: عشق\nشعر ۳ › شخصیت: مجنوں\n\nموضوع: عشق'),
[{ couplet: 0, type: 'موضوع', name: 'عشق' }, { couplet: 3, type: 'شخصیت', name: 'مجنوں' }]);
assert.match(parseTags('رنگ: سرخ') as string, /قسم/);
assert.match(parseTags('بس ایک لفظ') as string, /سمجھ/);
assert.equal(tagsText(parseTags('شعر 2 › مقام: دلی\nٹیگ: مشہور\nموضوع: تصوف') as any), 'موضوع: تصوف\nٹیگ: مشہور\nشعر 2 › مقام: دلی');
});
test('tagging: tags permission, pipeline, published to divan-data and the site, tag pages, reloaded by the import', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
process.env.DIVAN_DATA_DIR = data;
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify();
authRoutes(app); moderationRoutes(app); tagRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `tag-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
const cat = (await pool.query(`SELECT id, url FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
const work = (await pool.query(`SELECT p.id, p.url FROM poems p WHERE p.category_id = $1
AND EXISTS (SELECT 1 FROM verses v WHERE v.poem_id = p.id AND v.couplet >= 2) ORDER BY p.id LIMIT 1`, [cat.id])).rows[0];
const saved = (await pool.query('SELECT t.type, t.name, e.entity, e.entity_id, e.couplet FROM entity_tags e JOIN tags t ON t.id = e.tag_id')).rows;
try {
const grant = (m: any, content: string) =>
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, $3, '{edit}')`, [m.id, cat.id, [content]]);
await grant(l2, 'works');
assert.equal((await call('GET', `/api/mod/can?poem=${work.id}`, undefined, l2.token)).json().tags, false, 'editing text is not tagging');
assert.equal((await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, l2.token)).statusCode, 403);
await grant(l2, 'tags'); await grant(l1, 'tags');
// L2 tags the work (whole and its third couplet) and the ghazals' section
const w = (await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, l2.token)).json().id;
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: 'رنگ: سرخ' }, l2.token)).statusCode, 400, 'unknown type');
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: 'شعر 999 › موضوع: عشق' }, l2.token)).statusCode, 400, 'no such couplet');
const tagText = `موضوع: عشق-${run}\nشعر 2 › شخصیت: مجنوں-${run}`;
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: tagText, summary: 'ٹیگ' }, l2.token)).statusCode, 200);
const c = (await call('POST', `/api/mod/tags/category/${cat.id}/draft`, undefined, l2.token)).json().id;
assert.equal((await call('POST', `/api/mod/revisions/${c}/save`, { content: 'شعر 1 › موضوع: عشق' }, l2.token)).statusCode, 400, 'no couplets on a section');
assert.equal((await call('POST', `/api/mod/revisions/${c}/save`, { content: `صنف: غزل-${run}` }, l2.token)).statusCode, 200);
for (const id of [w, c]) {
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l1.token)).json().status, 'approved');
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
}
// divan-data holds the tags next to the work; the tag pages list what carries them
assert.equal(await readFile(join(data, 'divan', work.url.slice(1) + '.tags'), 'utf8'), tagsText(parseTags(tagText) as any) + '\n');
assert.match(git('log', '-1', '--format=%s'), /^ٹیگ: /);
const love = (await call('GET', `/api/tag?type=${encodeURIComponent('موضوع')}&name=${encodeURIComponent(`عشق-${run}`)}`)).json();
assert.deepEqual(love.works.map((x: any) => [x.id, x.whole]), [[work.id, true]]);
const majnun = (await call('GET', `/api/tag?type=${encodeURIComponent('شخصیت')}&name=${encodeURIComponent(`مجنوں-${run}`)}`)).json();
assert.equal(majnun.works[0].couplets[0].couplet, 2);
assert.ok(majnun.works[0].couplets[0].lines.length >= 1, 'the couplet\'s lines');
const genre = (await call('GET', `/api/tag?type=${encodeURIComponent('صنف')}&name=${encodeURIComponent(`غزل-${run}`)}`)).json();
assert.deepEqual(genre.categories.map((x: any) => x.id), [cat.id]);
assert.ok((await call('GET', '/api/tags')).json().some((t: any) => t.name === `عشق-${run}` && t.n === 1));
// the import rebuilds the site's tags from divan-data
await pool.query('DELETE FROM entity_tags');
assert.equal(await loadTags(data), 2);
assert.equal((await call('GET', `/api/tag?type=${encodeURIComponent('صنف')}&name=${encodeURIComponent(`غزل-${run}`)}`)).json().categories.length, 1);
// removing every tag deletes the file
const again = (await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, admin.token)).json().id;
await call('POST', `/api/mod/revisions/${again}/save`, { content: '' }, admin.token);
assert.deepEqual((await call('POST', `/api/mod/revisions/${again}/submit`, {}, admin.token)).json(), { status: 'published', version: 2 });
await assert.rejects(readFile(join(data, 'divan', work.url.slice(1) + '.tags'), 'utf8'));
} finally {
// the site's tags as they were
await pool.query('DELETE FROM entity_tags');
for (const t of saved) {
const id = (await pool.query('INSERT INTO tags (type, name) VALUES ($1, $2) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id', [t.type, t.name])).rows[0].id;
await pool.query('INSERT INTO entity_tags (tag_id, entity, entity_id, couplet) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [id, t.entity, t.entity_id, t.couplet]);
}
await pool.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
await pool.query(`DELETE FROM revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true });
await app.close();
}
});

View File

@ -1,156 +0,0 @@
// Tags (#52): typed labels on books/chapters (a poet's page is their root category), works and a work's couplets.
// Tagging is a revision like any other change (entity 'tags-category' or 'tags-work', entity_id = the target), so it
// goes through the pipeline with the 'tags' permission and is published to divan-data as divan/<url>.tags.
// The text is one tag per line, "<type>: <name>", or for one couplet of a work "شعر <n> › <type>: <name>" (n counts
// from 1 as readers do; verses.couplet counts from 0, and couplet 0 here means the whole work):
// موضوع: عشق
// شعر 3 › شخصیت: مجنوں
// GET /api/tags every tag with how often it is used, by type
// GET /api/tag?type=&name= what carries a tag: books/chapters, works, couplets
import { readdir, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { FastifyInstance } from 'fastify';
import type { PoolClient } from 'pg';
import { pool } from './db.ts';
export const TAG_TYPES = ['موضوع', 'صنف', 'بحر', 'شخصیت', 'مقام', 'دور', 'ٹیگ'];
export type Tag = { couplet: number; type: string; name: string };
export type TagTarget = 'category' | 'work';
const LINE = /^(?:شعر\s*(\d+)\s*›\s*)?([^:]+):\s*(.+)$/;
const ud2en = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
const key = (t: Tag) => `${t.couplet}|${t.type}|${t.name}`;
// the text as tags; an error message for a bad line
export function parseTags(text: string): Tag[] | string {
const out: Tag[] = [];
for (const raw of text.split('\n')) {
const line = raw.trim().replace(/\s+/g, ' ');
if (!line) continue;
const m = ud2en(line).match(LINE);
if (!m) return `سمجھ نہیں آیا: «${line}»`;
const [type, name] = [m[2].trim(), m[3].trim()];
if (!TAG_TYPES.includes(type)) return `ٹیگ کی قسم ${TAG_TYPES.join('، ')} میں سے ہو: «${type}»`;
if (name.length > 60) return `ٹیگ کا نام بہت لمبا ہے: «${name}»`;
const t = { couplet: Number(m[1] ?? 0), type, name };
if (!out.some((x) => key(x) === key(t))) out.push(t);
}
return out;
}
// tags in a fixed order (whole first, then by couplet; by type, then name), so equal sets give equal text
export const tagsText = (tags: Tag[]) => [...tags]
.sort((a, b) => a.couplet - b.couplet || TAG_TYPES.indexOf(a.type) - TAG_TYPES.indexOf(b.type) || a.name.localeCompare(b.name, 'ur'))
.map((t) => `${t.couplet ? `شعر ${t.couplet} › ` : ''}${t.type}: ${t.name}`).join('\n');
const tagsOf = async (entity: TagTarget, id: number, db: { query: PoolClient['query'] } = pool) => (await db.query(
`SELECT et.couplet, t.type, t.name FROM entity_tags et JOIN tags t ON t.id = et.tag_id WHERE et.entity = $1 AND et.entity_id = $2`,
[entity, id])).rows as Tag[];
// a target's published tags as text, and the latest published version of its tags
export async function currentTags(entity: TagTarget, id: number) {
const target = (await pool.query(entity === 'work'
? 'SELECT id, url, title, category_id FROM poems WHERE id = $1' : 'SELECT id, url, title, parent_id FROM categories WHERE id = $1', [id])).rows[0];
if (!target) return null;
const last = (await pool.query(
`SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [`tags-${entity}`, id])).rows[0];
const couplets = entity === 'work'
? Number((await pool.query('SELECT coalesce(max(couplet) + 1, 0) AS n FROM verses WHERE poem_id = $1', [id])).rows[0].n) : 0;
return { target, couplets, version: Number(last?.v ?? 0), content: tagsText(await tagsOf(entity, id)) };
}
// the text is valid tags, on couplets the work has
export async function checkTags(entity: TagTarget, id: number, text: string) {
const tags = parseTags(text);
if (typeof tags === 'string') return tags;
if (entity === 'category' && tags.some((t) => t.couplet)) return 'کتاب یا باب کے ٹیگ کسی شعر پر نہیں لگتے';
const cur = await currentTags(entity, id);
const bad = tags.find((t) => t.couplet > (cur?.couplets ?? 0));
return bad ? `اس کلام میں شعر ${bad.couplet} نہیں` : null;
}
// the site's tags for the target become the text's
export async function applyTags(client: PoolClient, entity: TagTarget, id: number, text: string) {
const tags = parseTags(text) as Tag[];
await client.query('DELETE FROM entity_tags WHERE entity = $1 AND entity_id = $2', [entity, id]);
for (const t of tags) {
const tagId = (await client.query(
`INSERT INTO tags (type, name) VALUES ($1, $2) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [t.type, t.name])).rows[0].id;
await client.query('INSERT INTO entity_tags (tag_id, entity, entity_id, couplet) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
[tagId, entity, id, t.couplet]);
}
await client.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
}
// divan-data/divan/<url>.tags (removed when the last tag goes)
export async function writeTags(dataDir: string, url: string, text: string) {
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.tags');
if (!text.trim()) { await rm(path, { force: true }); return path; }
await mkdir(dirname(path), { recursive: true });
await writeFile(path, text.trim() + '\n');
return path;
}
// the import: every published .tags file in divan-data back into the site's tags (divan-data is the record)
export async function loadTags(dataDir: string) {
const root = join(dataDir, 'divan');
const files = await readdir(root, { recursive: true }).catch(() => [] as string[]);
const client = await pool.connect();
let n = 0;
try {
await client.query('BEGIN');
await client.query('DELETE FROM entity_tags');
for (const f of files.filter((f) => f.endsWith('.tags'))) {
const url = '/' + f.slice(0, -'.tags'.length);
const work = (await client.query('SELECT id FROM poems WHERE url = $1', [url])).rows[0];
const cat = work ? null : (await client.query('SELECT id FROM categories WHERE url = $1', [url])).rows[0];
if (!work && !cat) { console.warn(`tags for an unknown page: ${url}`); continue; }
const text = await readFile(join(root, f), 'utf8');
if (typeof parseTags(text) === 'string') { console.warn(`bad tags in ${f}`); continue; }
await applyTags(client, work ? 'work' : 'category', (work ?? cat).id, text);
n++;
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
return n;
}
export function tagRoutes(app: FastifyInstance) {
app.get('/api/tags', async () => (await pool.query(
`SELECT t.type, t.name, count(*)::int AS n FROM tags t JOIN entity_tags e ON e.tag_id = t.id GROUP BY t.id ORDER BY n DESC, t.name`)).rows);
app.get<{ Querystring: { type?: string; name?: string } }>('/api/tag', async (req, reply) => {
const tag = (await pool.query('SELECT id, type, name FROM tags WHERE type = $1 AND name = $2', [req.query.type ?? '', req.query.name ?? ''])).rows[0];
if (!tag) return reply.code(404).send({ error: 'ٹیگ نہیں ملا' });
const [cats, works, ebooks] = await Promise.all([
pool.query(`SELECT c.id, c.url, c.title, t.nickname AS poet, t.url AS poet_url, c.parent_id IS NULL AS is_poet
FROM entity_tags e JOIN categories c ON c.id = e.entity_id JOIN poets t ON t.id = c.poet_id
WHERE e.tag_id = $1 AND e.entity = 'category' ORDER BY t.birth_year_ah NULLS LAST, c.title`, [tag.id]),
pool.query(`SELECT p.id, p.url, p.title, t.nickname AS poet, t.url AS poet_url, array_agg(e.couplet ORDER BY e.couplet) AS couplets
FROM entity_tags e JOIN poems p ON p.id = e.entity_id JOIN poets t ON t.id = p.poet_id
WHERE e.tag_id = $1 AND e.entity = 'work' GROUP BY p.id, t.id ORDER BY t.birth_year_ah NULLS LAST, p.title LIMIT 500`, [tag.id]),
pool.query(`SELECT b.id, b.title, b.kind, t.nickname AS poet, t.url AS poet_url FROM entity_tags e JOIN ebooks b ON b.id = e.entity_id
JOIN poets t ON t.id = b.poet_id WHERE e.tag_id = $1 AND e.entity = 'ebook' AND b.published ORDER BY b.title`, [tag.id]),
]);
// the tagged couplets' lines
const pairs = works.rows.flatMap((w) => w.couplets.filter((c: number) => c > 0).map((c: number) => [w.id, c]));
const lines = pairs.length ? (await pool.query(
`SELECT poem_id, couplet, array_agg(text ORDER BY vorder) AS lines FROM verses
WHERE (poem_id, couplet + 1) IN (SELECT * FROM unnest($1::int[], $2::int[])) GROUP BY poem_id, couplet`,
[pairs.map((p) => p[0]), pairs.map((p) => p[1])])).rows : [];
return {
tag, categories: cats.rows, ebooks: ebooks.rows,
works: works.rows.map((w) => ({ ...w, whole: w.couplets.includes(0),
couplets: lines.filter((l) => l.poem_id === w.id).map((l) => ({ couplet: l.couplet + 1, anchor: l.couplet, lines: l.lines })) })),
};
});
}
// tags shown on a page: the target's own, by couplet (0 = the whole page)
export async function pageTags(entity: TagTarget, id: number) {
return (await tagsOf(entity, id)).sort((a, b) => TAG_TYPES.indexOf(a.type) - TAG_TYPES.indexOf(b.type));
}

View File

@ -143,119 +143,9 @@ CREATE TABLE IF NOT EXISTS library (
ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text;
ALTER TABLE library DROP CONSTRAINT IF EXISTS library_kind_check;
ALTER TABLE library ADD COLUMN IF NOT EXISTS category_id integer;
ALTER TABLE library ADD CONSTRAINT library_kind_check CHECK (kind IN ('poet', 'category', 'poem', 'couplet', 'phrase', 'word', 'page'));
ALTER TABLE library ADD CONSTRAINT library_kind_check CHECK (kind IN ('poet', 'category', 'poem', 'couplet', 'phrase', 'word'));
DROP INDEX IF EXISTS library_items;
DROP INDEX IF EXISTS library_places;
CREATE UNIQUE INDEX IF NOT EXISTS library_bookmarks ON library (user_id, kind, coalesce(poet_id, 0), coalesce(category_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'category', 'poem', 'couplet');
CREATE UNIQUE INDEX IF NOT EXISTS library_phrases ON library (user_id, poem_id, couplet, phrase) WHERE kind = 'phrase';
CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word';
-- content moderation (api/src/moderation.ts): versions of content, and every step taken on them (#31, #51)
CREATE TABLE IF NOT EXISTS revisions (
id bigserial PRIMARY KEY,
entity text NOT NULL, -- 'work' (later: poet, intro, book, chapter, dictionary, tag)
entity_id integer NOT NULL, -- poems.id for works
version integer, -- the published version number (NULL until published)
base_version integer NOT NULL DEFAULT 0, -- the published version the draft started from (0 = Wikisource text)
base_content text NOT NULL DEFAULT '', -- the text the draft started from (for an exact diff)
content text NOT NULL, -- Divan text
summary text, -- the edit summary
status text NOT NULL CHECK (status IN ('draft', 'submitted', 'approved', 'published', 'returned', 'rejected')),
author_id bigint REFERENCES users(id) ON DELETE SET NULL,
author_email text NOT NULL, -- kept when accounts are deleted
reviewer_email text, -- the L1 moderator who approved
publisher_email text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS base_content text NOT NULL DEFAULT '';
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS reviewer_id bigint REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS commit text; -- divan-data commit of a published version
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS credits jsonb; -- public names at publishing: by, reviewedBy, publishedBy
CREATE INDEX IF NOT EXISTS revisions_entity ON revisions(entity, entity_id);
CREATE INDEX IF NOT EXISTS revisions_status ON revisions(status);
CREATE UNIQUE INDEX IF NOT EXISTS revisions_version ON revisions(entity, entity_id, version) WHERE version IS NOT NULL;
CREATE TABLE IF NOT EXISTS revision_events ( -- who did what: created, saved, submitted, approved, returned,
id bigserial PRIMARY KEY, -- rejected, published, commented
revision_id bigint NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
actor_id bigint REFERENCES users(id) ON DELETE SET NULL,
actor_email text NOT NULL,
action text NOT NULL,
comment text,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS revision_events_revision ON revision_events(revision_id);
CREATE INDEX IF NOT EXISTS revision_events_at ON revision_events(at DESC);
-- home page sections (api/src/site.ts), edited by moderators with the 'site' permission. A section either lists
-- chosen poets (poets, in order) or every poet born in a Hijri century (century; 0 = year unknown). Sorting:
-- manual (the list's order; a century section lists its chosen poets first), alpha (Urdu alphabetical) or
-- timeline (by birth year). title NULL = the default name (the century's name).
CREATE TABLE IF NOT EXISTS home_sections (
id serial PRIMARY KEY,
position integer NOT NULL,
title text,
century integer,
sort text NOT NULL DEFAULT 'manual' CHECK (sort IN ('manual', 'alpha', 'timeline')),
poets integer[] NOT NULL DEFAULT '{}'
);
-- tags (#52, api/src/tags.ts): typed labels on books/chapters (a poet's root category = the poet), works and
-- couplets (couplet > 0). The published record is divan-data/divan/<url>.tags; the import reloads it.
CREATE TABLE IF NOT EXISTS tags (
id serial PRIMARY KEY,
type text NOT NULL, -- موضوع، صنف، بحر، شخصیت، مقام، دور، ٹیگ (free)
name text NOT NULL,
UNIQUE (type, name)
);
CREATE TABLE IF NOT EXISTS entity_tags (
tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
entity text NOT NULL CHECK (entity IN ('category', 'work')),
entity_id integer NOT NULL, -- categories.id / poems.id
couplet integer NOT NULL DEFAULT 0, -- a work's couplet; 0 = the whole work or category
PRIMARY KEY (tag_id, entity, entity_id, couplet)
);
CREATE INDEX IF NOT EXISTS entity_tags_target ON entity_tags(entity, entity_id);
-- e-books (api/src/ebooks.ts): PDF, EPUB and text files kept in the file store by content hash, or archive.org
-- items; listed on the poet's page once a moderator's upload is approved (revisions entity 'ebook').
CREATE TABLE IF NOT EXISTS ebooks (
id serial PRIMARY KEY,
poet_id integer NOT NULL REFERENCES poets(id) ON DELETE CASCADE,
title text NOT NULL,
kind text NOT NULL CHECK (kind IN ('pdf', 'epub', 'text', 'archive')),
file text, -- '<sha256>.<ext>' in the file store (DIVAN_FILES_DIR)
archive_id text, -- archive.org identifier
size bigint,
source text, -- where it comes from
licence text, -- copyright / permission, as stated by the uploader
note text,
search_text text NOT NULL DEFAULT '', -- normalised title, and the text of text books
published boolean NOT NULL DEFAULT false,
created_by bigint REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK ((kind = 'archive') = (archive_id IS NOT NULL) AND (kind = 'archive') = (file IS NULL))
);
CREATE INDEX IF NOT EXISTS ebooks_poet ON ebooks(poet_id) WHERE published;
CREATE INDEX IF NOT EXISTS ebooks_search ON ebooks USING gin (search_text gin_trgm_ops);
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '<sha256>.jpg|png|webp' in the file store
-- library: bookmarked pages of e-books (kind 'page': ebook_id, page)
ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer;
ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer;
-- a couplet or phrase can hold a note without being bookmarked (bookmarked false: a note only)
ALTER TABLE library ADD COLUMN IF NOT EXISTS bookmarked boolean NOT NULL DEFAULT true; -- kinds: see library_kind_check above
-- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names;
-- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are
-- entity_tags with entity 'ebook'.
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS writer text;
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'اردو';
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS direction text NOT NULL DEFAULT 'rtl' CHECK (direction IN ('rtl', 'ltr'));
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS coauthors text[] NOT NULL DEFAULT '{}';
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS coauthor_ids integer[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS ebooks_coauthors ON ebooks USING gin (coauthor_ids);
ALTER TABLE entity_tags DROP CONSTRAINT IF EXISTS entity_tags_entity_check;
ALTER TABLE entity_tags ADD CONSTRAINT entity_tags_entity_check CHECK (entity IN ('category', 'work', 'ebook'));
-- visitor statistics (api/src/stats.ts, #86): counts only; no cookies, no addresses. A visitor is a hash of their
-- address and browser with a salt that lives one day in memory, so it cannot be traced back or followed across days.
CREATE TABLE IF NOT EXISTS stat_views (day date NOT NULL, path text NOT NULL, views integer NOT NULL DEFAULT 0, PRIMARY KEY (day, path));
CREATE TABLE IF NOT EXISTS stat_visitors (day date NOT NULL, h text NOT NULL, PRIMARY KEY (day, h));
CREATE TABLE IF NOT EXISTS stat_referrers (day date NOT NULL, host text NOT NULL, n integer NOT NULL DEFAULT 0, PRIMARY KEY (day, host));

View File

@ -26,7 +26,7 @@ if command -v flock >/dev/null && ! flock -n 9; then echo "$(date -u +%FT%TZ) sy
echo "== $(date -u +%FT%TZ) divan sync"
[ -d "$DATA_DIR/.git" ] || git clone -q https://github.com/anas-rashid/divan-data.git "$DATA_DIR"
cd "$DATA_DIR"
git pull -q --rebase # keep commits made by publishing in the Divan app (git.ts)
git pull -q --ff-only
if [ "${DIVAN_DATA_PUSH:-0}" = 1 ]; then
./update.sh # fetch + rebuild, commit and push if the data changed

View File

@ -115,61 +115,6 @@ above.
8. **Later**: Wikibase as linked-data records for poets and works, if that becomes useful; OpenITI-style
stable identifiers for works and versions.
## Content management model
Owner direction (October 2026): this is **content management**, not only text editing. Every piece of content is
identified for what it is, every change is a version with who did what, and content carries searchable tags.
### What is identified
| Entity | What it is | Its text or fields |
|---|---|---|
| شاعر / ادیب (poet, writer) | A person | Name, pen name, years, places; links to their books |
| تعارف (intro) | A poet's or a book's introduction | Divan text (paragraphs, headings) |
| کتاب (book) | A published collection | Title, year, publisher/edition, order of chapters; intro |
| باب / ذیلی باب (chapter, sub-chapter) | A section of a book, nested | Title, order of works |
| کلام (work) | A ghazal, nazm, rubai, prose chapter, … | Data points (genre, radif, metre, sources) and its text in Divan text |
| Inside a work's text | Identified by Divan text: chapter **heading** (level 2), **sub-heading** (level 3, 4, …), **شعر** (couplet) of two **مصرع** (lines), **بند** (stanza), single line, **paragraph**, **footnote**, **variant reading**, **word** (with its dictionary lemma) | Divan text |
| لغت (dictionary entry) | A word's meanings, readings, pronunciation | Fields |
| ٹیگ (tag) | A typed label | Type and name |
Every entity has a stable id. Elements inside a work (couplets, paragraphs, headings) are addressed by their
position, as the site already does with `#c3` links and bookmarks. The prototype shows the whole library can be
written this way: all 11,087 works convert to Divan text and back unchanged (the only clean-ups are repeated spaces
and raw `== … ==` markers in prose becoming real chapter headings).
### Versions: who did what (#51)
- **Revisions**: every change to any entity is a revision. It records the entity, the version number, the full
content (Divan text or fields), the author, the time, an edit summary and its status. Statuses are draft,
submitted, L1-approved, published, returned and rejected; reviewers and publishers are recorded too.
- **History** page per entity: each version with who, when, summary and status. Any two versions can be compared
with a **diff** (by line for Divan text, by field for metadata). **Revert** creates a new revision through the
pipeline.
- **Moderation log** across the site: who did what, filtered by person, entity, date or action.
- **Pipeline**: the approval pipeline (#31) moves revisions through their statuses. Publishing writes the published
version to divan-data and commits it (#34), so the git history mirrors the published revisions.
### Tags (#52)
- **Types**: tags are typed: موضوع (theme), صنف (genre), بحر (metre), شخصیت (person mentioned), مقام (place),
دور (period), and free tags.
- **Where**: they attach to any entity, down to a couplet or a phrase.
- **Finding tagged content**:
- every tag has a page listing everything tagged with it;
- tags are a search filter and facet, like authors;
- `tag:تصوف` can be typed in the search box.
- **History and permissions**: adding or removing a tag is a revision like any other change, so its history shows
who tagged what and when. The permission model gets a `tags` content type.
### Storage
- **PostgreSQL** holds the entities (`poets`, `categories`, `poems`, `verses` today), plus `revisions`, `tags` and
`entity_tags`.
- **divan-data** holds the published versions: works as `.dtx` with generated `.json` in `divan/` (#30, done in
this step). Intros, books and chapters follow the same pattern as they become editable, and tags go in the
Divan text header (`| ٹیگ = عشق، تصوف`) and a tags list.
## Prototype results
`api/src/divantext.ts` parses Divan text into blocks, converts them to the site's verse JSON, and exports TEI.

View File

@ -4,7 +4,7 @@
| صنف = غزل
| ردیف = ہوتا
| ماخذ = ویکی ماخذ
| ماخذ_لنک = https://ur.wikisource.org/wiki/%DB%8C%DB%81_%D9%86%DB%81_%D8%AA%DA%BE%DB%8C_%DB%81%D9%85%D8%A7%D8%B1%DB%8C_%D9%82%D8%B3%D9%85%D8%AA_%DA%A9%DB%81_%D9%88%D8%B5%D8%A7%D9%84_%DB%8C%D8%A7%D8%B1_%DB%81%D9%88%D8%AA%D8%A7
| ماخذ_ربط = https://ur.wikisource.org/wiki/%DB%8C%DB%81_%D9%86%DB%81_%D8%AA%DA%BE%DB%8C_%DB%81%D9%85%D8%A7%D8%B1%DB%8C_%D9%82%D8%B3%D9%85%D8%AA_%DA%A9%DB%81_%D9%88%D8%B5%D8%A7%D9%84_%DB%8C%D8%A7%D8%B1_%DB%81%D9%88%D8%AA%D8%A7
}}
<poem>
{{مطلع}} یہ نہ تھی ہماری قسمت کہ [[وصال]] یار ہوتا

View File

@ -3,7 +3,7 @@
| مصنف = سید احمد خان
| صنف = مضمون
| ماخذ = ویکی ماخذ
| ماخذ_لنک = https://ur.wikisource.org/wiki/%D8%A2%D8%B2%D8%A7%D8%AF%DB%8C_%D8%B1%D8%A7%D8%A6%DB%92
| ماخذ_ربط = https://ur.wikisource.org/wiki/%D8%A2%D8%B2%D8%A7%D8%AF%DB%8C_%D8%B1%D8%A7%D8%A6%DB%92
}}
== آزادی رائے ==

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><polygon fill="#b88828" points="32.00,1.00 36.96,7.07 43.86,3.36 46.12,10.86 53.92,10.08 53.14,17.88 60.64,20.14 56.93,27.04 63.00,32.00 56.93,36.96 60.64,43.86 53.14,46.12 53.92,53.92 46.12,53.14 43.86,60.64 36.96,56.93 32.00,63.00 27.04,56.93 20.14,60.64 17.88,53.14 10.08,53.92 10.86,46.12 3.36,43.86 7.07,36.96 1.00,32.00 7.07,27.04 3.36,20.14 10.86,17.88 10.08,10.08 17.88,10.86 20.14,3.36 27.04,7.07"/><polygon fill="#b88828" opacity=".55" points="37.62,3.72 41.49,9.09 48.02,8.03 49.54,14.46 55.97,15.98 54.91,22.51 60.28,26.38 56.80,32.00 60.28,37.62 54.91,41.49 55.97,48.02 49.54,49.54 48.02,55.97 41.49,54.91 37.62,60.28 32.00,56.80 26.38,60.28 22.51,54.91 15.98,55.97 14.46,49.54 8.03,48.02 9.09,41.49 3.72,37.62 7.20,32.00 3.72,26.38 9.09,22.51 8.03,15.98 14.46,14.46 15.98,8.03 22.51,9.09 26.38,3.72 32.00,7.20"/><circle cx="32" cy="32" r="22.94" fill="#1d3b8b"/><circle cx="32" cy="32" r="21.080000000000002" fill="none" stroke="#b88828" stroke-width="0.62"/><path transform="translate(27.6 37.5)" fill="#fbf6ea" d="M-1.41 2.9699999999999998 -1.65 2.2199999999999998Q0.21 1.26 1.7249999999999999 0.39Q3.2399999999999998 -0.48 4.41 -1.275Q5.58 -2.07 6.39 -2.76Q7.199999999999999 -3.4499999999999997 7.6049999999999995 -4.035Q8.01 -4.62 8.01 -5.069999999999999Q8.01 -5.43 7.5 -6.06Q6.989999999999999 -6.6899999999999995 5.76 -7.484999999999999Q4.53 -8.28 2.4 -9.15Q2.61 -9.9 2.985 -10.905000000000001Q3.36 -11.91 3.8099999999999996 -12.945Q4.26 -13.979999999999999 4.68 -14.82Q5.1 -15.66 5.3999999999999995 -16.05H5.819999999999999Q7.17 -15.36 8.219999999999999 -14.294999999999998Q9.27 -13.229999999999999 9.855 -11.834999999999999Q10.44 -10.44 10.44 -8.67Q10.44 -6.989999999999999 9.975 -5.444999999999999Q9.51 -3.9 8.805 -2.6399999999999997Q8.1 -1.38 7.365 -0.5399999999999999Q6.63 0.3 6.06 0.57Q5.31 0.9299999999999999 3.2849999999999997 1.62Q1.26 2.31 -1.41 2.9699999999999998Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="13" fill="#1d3b8b"/><path fill="#fbf6ea" d="M4,40 Q13.5,34.6 23,37.0 L23,43.0 Q13.5,45.4 4,40Z"/><path fill="#fbf6ea" d="M60,40 Q50.5,34.6 41,37.0 L41,43.0 Q50.5,45.4 60,40Z"/><g fill="#b88828"><path transform="translate(32 40) rotate(45)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><path transform="translate(32 40) rotate(135)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><path transform="translate(32 40) rotate(225)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><path transform="translate(32 40) rotate(315)" d="M0,0 C4.5,-3.5 2.5,-9.0 0,-10 C-2.5,-9.0 -4.5,-3.5 0,0Z"/><circle cx="32" cy="40" r="1.60" fill="#fbf6ea"/></g><path fill="#fbf6ea" d="M32,12 L37.25,17 L32,22 L26.75,17Z"/></svg>

Before

Width:  |  Height:  |  Size: 861 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="13" fill="#1d3b8b"/><g transform="translate(16 50) rotate(-45)"><path fill="#fbf6ea" d="M0,0 L22,-5.0 L22,5.0 Z M22,-5.0 h19.8 v10 h-19.8Z"/><path stroke="#fbf6ea" stroke-width="0.90" d="M3.96,0 L16.5,0"/></g><path fill="#b88828" d="M44,14 L52.4,22 L44,30 L35.6,22Z"/></svg>

Before

Width:  |  Height:  |  Size: 390 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><path d="M8,60 L8,28 C8,13.7 16.64,13.04 27.2,8.64 C30.56,7.1 32.0,6 32.0,6 C32.0,6 33.44,7.1 36.8,8.64 C47.36,13.04 56,13.7 56,28 L56,60Z" fill="#b88828"/><path d="M13,60 L13,30 C13,18.3 19.84,17.759999999999998 28.2,14.16 C30.86,12.9 32.0,12 32.0,12 C32.0,12 33.14,12.9 35.8,14.16 C44.16,17.759999999999998 51,18.3 51,30 L51,60Z" fill="#1d3b8b"/><path transform="translate(28.2 47.7)" fill="#fbf6ea" d="M-1.222 2.574 -1.43 1.924Q0.182 1.0919999999999999 1.4949999999999999 0.33799999999999997Q2.808 -0.416 3.8219999999999996 -1.105Q4.835999999999999 -1.7939999999999998 5.537999999999999 -2.392Q6.239999999999999 -2.9899999999999998 6.590999999999999 -3.497Q6.941999999999999 -4.004 6.941999999999999 -4.394Q6.941999999999999 -4.7059999999999995 6.5 -5.252Q6.058 -5.798 4.992 -6.487Q3.9259999999999997 -7.175999999999999 2.08 -7.93Q2.262 -8.58 2.5869999999999997 -9.451Q2.912 -10.322 3.3019999999999996 -11.219Q3.6919999999999997 -12.116 4.056 -12.844Q4.42 -13.572 4.68 -13.91H5.044Q6.2139999999999995 -13.312 7.123999999999999 -12.389Q8.033999999999999 -11.466 8.541 -10.257Q9.048 -9.048 9.048 -7.513999999999999Q9.048 -6.058 8.645 -4.718999999999999Q8.241999999999999 -3.38 7.630999999999999 -2.288Q7.02 -1.196 6.382999999999999 -0.46799999999999997Q5.7459999999999996 0.26 5.252 0.494Q4.601999999999999 0.8059999999999999 2.8469999999999995 1.404Q1.0919999999999999 2.002 -1.222 2.574Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

View File

@ -4,8 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>دیوان · قاری</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsPSLYr9uM2YjYp9mGIj48dGl0bGU+2K/bjNmI2KfZhjwvdGl0bGU+PGcgY2xhc3M9InJheXMtbG9uZyI+PHBvbHlnb24gZmlsbD0iI2I4ODgyOCIgb3BhY2l0eT0iLjYiIHBvaW50cz0iNDAuMDIsMi4wNiA0NC4wOSwxMS4wNiA1My45MiwxMC4wOCA1Mi45NCwxOS45MSA2MS45NCwyMy45OCA1Ni4xOCwzMi4wMCA2MS45NCw0MC4wMiA1Mi45NCw0NC4wOSA1My45Miw1My45MiA0NC4wOSw1Mi45NCA0MC4wMiw2MS45NCAzMi4wMCw1Ni4xOCAyMy45OCw2MS45NCAxOS45MSw1Mi45NCAxMC4wOCw1My45MiAxMS4wNiw0NC4wOSAyLjA2LDQwLjAyIDcuODIsMzIuMDAgMi4wNiwyMy45OCAxMS4wNiwxOS45MSAxMC4wOCwxMC4wOCAxOS45MSwxMS4wNiAyMy45OCwyLjA2IDMyLjAwLDcuODIiLz48L2c+PGcgY2xhc3M9InJheXMtc2hvcnQiPjxjaXJjbGUgY3g9IjMyLjAwIiBjeT0iMTEuNTQiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSI0Mi4yMyIgY3k9IjE0LjI4IiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iNDkuNzIiIGN5PSIyMS43NyIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjUyLjQ2IiBjeT0iMzIuMDAiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSI0OS43MiIgY3k9IjQyLjIzIiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iNDIuMjMiIGN5PSI0OS43MiIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjMyLjAwIiBjeT0iNTIuNDYiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSIyMS43NyIgY3k9IjQ5LjcyIiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iMTQuMjgiIGN5PSI0Mi4yMyIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjExLjU0IiBjeT0iMzIuMDAiIHI9IjYuMjAiIGZpbGw9IiNiODg4MjgiLz48Y2lyY2xlIGN4PSIxNC4yOCIgY3k9IjIxLjc3IiByPSI2LjIwIiBmaWxsPSIjYjg4ODI4Ii8+PGNpcmNsZSBjeD0iMjEuNzciIGN5PSIxNC4yOCIgcj0iNi4yMCIgZmlsbD0iI2I4ODgyOCIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik0zNy42LDEwLjMgTDM4LjQsMTEuMCBMMzcuNiwxMS44IEwzNi44LDExLjBaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTQ3LjMsMTUuOSBMNDguMiwxNi43IEw0Ny4zLDE3LjQgTDQ2LjUsMTYuN1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNNTMuMCwyNS42IEw1My44LDI2LjQgTDUzLjAsMjcuMiBMNTIuMSwyNi40WiIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik01My4wLDM2LjggTDUzLjgsMzcuNiBMNTMuMCwzOC40IEw1Mi4xLDM3LjZaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTQ3LjMsNDYuNiBMNDguMiw0Ny4zIEw0Ny4zLDQ4LjEgTDQ2LjUsNDcuM1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNMzcuNiw1Mi4yIEwzOC40LDUzLjAgTDM3LjYsNTMuNyBMMzYuOCw1My4wWiIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik0yNi40LDUyLjIgTDI3LjIsNTMuMCBMMjYuNCw1My43IEwyNS42LDUzLjBaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTE2LjcsNDYuNiBMMTcuNSw0Ny4zIEwxNi43LDQ4LjEgTDE1LjgsNDcuM1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNMTEuMCwzNi44IEwxMS45LDM3LjYgTDExLjAsMzguNCBMMTAuMiwzNy42WiIvPjxwYXRoIGZpbGw9IiMxZDNiOGIiIGQ9Ik0xMS4wLDI1LjYgTDExLjksMjYuNCBMMTEuMCwyNy4yIEwxMC4yLDI2LjRaIi8+PHBhdGggZmlsbD0iIzFkM2I4YiIgZD0iTTE2LjcsMTUuOSBMMTcuNSwxNi43IEwxNi43LDE3LjQgTDE1LjgsMTYuN1oiLz48cGF0aCBmaWxsPSIjMWQzYjhiIiBkPSJNMjYuNCwxMC4zIEwyNy4yLDExLjAgTDI2LjQsMTEuOCBMMjUuNiwxMS4wWiIvPjwvZz48Y2lyY2xlIGN4PSIzMiIgY3k9IjMyIiByPSIyMS4wOCIgZmlsbD0iI2I4ODgyOCIvPjxjaXJjbGUgY3g9IjMyIiBjeT0iMzIiIHI9IjE5LjIyIiBmaWxsPSIjMWQzYjhiIi8+PHBhdGggdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjUuNiA0MS42KSIgZmlsbD0iI2ZiZjZlYSIgZD0iTS0yLjA2IDQuMzUgLTIuNDIgMy4yNVEwLjMwIDEuODQgMi41MyAwLjU3UTQuNzUgLTAuNzAgNi40NiAtMS44N1E4LjE4IC0zLjAzIDkuMzcgLTQuMDRRMTAuNTYgLTUuMDYgMTEuMTUgLTUuOTFRMTEuNzQgLTYuNzcgMTEuNzQgLTcuNDNRMTEuNzQgLTcuOTYgMTEuMDAgLTguODhRMTAuMjUgLTkuODEgOC40NCAtMTAuOTdRNi42NCAtMTIuMTQgMy41MiAtMTMuNDJRMy44MiAtMTQuNTIgNC4zNyAtMTUuOTlRNC45MiAtMTcuNDYgNS41OCAtMTguOThRNi4yNCAtMjAuNTAgNi44NiAtMjEuNzNRNy40OCAtMjIuOTcgNy45MiAtMjMuNTRIOC41M1ExMC41MSAtMjIuNTMgMTIuMDUgLTIwLjk2UTEzLjU5IC0xOS40MCAxNC40NSAtMTcuMzVRMTUuMzEgLTE1LjMxIDE1LjMxIC0xMi43MVExNS4zMSAtMTAuMjUgMTQuNjMgLTcuOThRMTMuOTQgLTUuNzIgMTIuOTEgLTMuODdRMTEuODggLTIuMDIgMTAuODAgLTAuNzlROS43MiAwLjQ0IDguODggMC44M1E3Ljc4IDEuMzYgNC44MSAyLjM3UTEuODQgMy4zOCAtMi4wNiA0LjM1WiIvPjwvc3ZnPg==">
<meta name="description" content="دیوان کا سادہ قاری: اردو کا کلاسیکی ادب، براہِ راست divan-data سے۔">
<meta name="description" content="دیوان کا سادہ قاری: اردو کی کلاسیکی شاعری اور نثر، براہِ راست divan-data سے۔">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Nastaliq+Urdu:wght@400..700&family=Noto+Naskh+Arabic:wght@400..700&display=swap" rel="stylesheet">

800
web/package-lock.json generated
View File

@ -9,14 +9,7 @@
"@astrojs/node": "^11.1.7",
"@fontsource/noto-naskh-arabic": "^5.3.0",
"@fontsource/noto-nastaliq-urdu": "^5.3.0",
"@tiptap/core": "^3.31.4",
"@tiptap/extension-document": "^3.31.4",
"@tiptap/extension-text": "^3.31.4",
"@tiptap/extensions": "^3.31.4",
"@tiptap/pm": "^3.31.4",
"astro": "^7.3.8",
"epubjs": "^0.3.93",
"pdfjs-dist": "^6.4.299"
"astro": "^7.3.8"
}
},
"node_modules/@astrojs/compiler-binding": {
@ -1150,271 +1143,6 @@
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.10",
"@napi-rs/canvas-darwin-arm64": "1.0.10",
"@napi-rs/canvas-darwin-x64": "1.0.10",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz",
@ -1815,84 +1543,6 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@tiptap/core": {
"version": "3.31.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz",
"integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.31.4"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.31.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz",
"integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.31.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz",
"integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.4"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.31.4",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz",
"integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.4",
"@tiptap/pm": "3.31.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.31.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz",
"integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.11",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.42.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
@ -1927,16 +1577,6 @@
"@types/unist": "*"
}
},
"node_modules/@types/localforage": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz",
"integrity": "sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==",
"deprecated": "This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!",
"license": "MIT",
"dependencies": {
"localforage": "*"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@ -1967,16 +1607,6 @@
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC"
},
"node_modules/@xmldom/xmldom": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
"integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==",
"deprecated": "this version has critical issues, please update to the latest version",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/am-i-vibing": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz",
@ -2247,26 +1877,6 @@
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crossws": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
@ -2350,19 +1960,6 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"license": "CC0-1.0"
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2538,90 +2135,18 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/epubjs": {
"version": "0.3.93",
"resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.93.tgz",
"integrity": "sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==",
"license": "BSD-2-Clause",
"dependencies": {
"@types/localforage": "0.0.34",
"@xmldom/xmldom": "^0.7.5",
"core-js": "^3.18.3",
"event-emitter": "^0.3.5",
"jszip": "^3.7.1",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"marks-pane": "^1.0.9",
"path-webpack": "0.0.3"
}
},
"node_modules/es-module-lexer": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.3.tgz",
"integrity": "sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==",
"license": "MIT"
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -2631,31 +2156,12 @@
"node": ">= 0.6"
}
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"license": "ISC",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -2881,12 +2387,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2929,39 +2429,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@ -3223,30 +2696,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"license": "Apache-2.0",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/localforage/node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
@ -3276,12 +2725,6 @@
"source-map-js": "^1.2.1"
}
},
"node_modules/marks-pane": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/marks-pane/-/marks-pane-1.0.9.tgz",
"integrity": "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==",
"license": "MIT"
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
@ -3465,12 +2908,6 @@
"node": ">= 10"
}
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/nlcst-to-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz",
@ -3576,12 +3013,6 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz",
@ -3631,30 +3062,6 @@
"integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-webpack": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/path-webpack/-/path-webpack-0.0.3.tgz",
"integrity": "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==",
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.4.299",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.10"
}
},
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@ -3725,12 +3132,6 @@
"node": ">=18.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
@ -3741,145 +3142,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.12",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/radix3": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
@ -3899,21 +3161,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
@ -4008,18 +3255,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.13"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/satteri": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
@ -4097,12 +3332,6 @@
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==",
"license": "ISC"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -4224,15 +3453,6 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -4348,12 +3568,6 @@
"license": "0BSD",
"optional": true
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
@ -4575,12 +3789,6 @@
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/verkit": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz",
@ -4717,12 +3925,6 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",

View File

@ -12,13 +12,6 @@
"@astrojs/node": "^11.1.7",
"@fontsource/noto-naskh-arabic": "^5.3.0",
"@fontsource/noto-nastaliq-urdu": "^5.3.0",
"@tiptap/core": "^3.31.4",
"@tiptap/extension-document": "^3.31.4",
"@tiptap/extension-text": "^3.31.4",
"@tiptap/extensions": "^3.31.4",
"@tiptap/pm": "^3.31.4",
"astro": "^7.3.8",
"epubjs": "^0.3.93",
"pdfjs-dist": "^6.4.299"
"astro": "^7.3.8"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="دیوان"><title>دیوان</title><g class="rays-long"><polygon fill="#b88828" opacity=".6" points="40.02,2.06 44.09,11.06 53.92,10.08 52.94,19.91 61.94,23.98 56.18,32.00 61.94,40.02 52.94,44.09 53.92,53.92 44.09,52.94 40.02,61.94 32.00,56.18 23.98,61.94 19.91,52.94 10.08,53.92 11.06,44.09 2.06,40.02 7.82,32.00 2.06,23.98 11.06,19.91 10.08,10.08 19.91,11.06 23.98,2.06 32.00,7.82"/></g><g class="rays-short"><circle cx="32.00" cy="11.54" r="6.20" fill="#b88828"/><circle cx="42.23" cy="14.28" r="6.20" fill="#b88828"/><circle cx="49.72" cy="21.77" r="6.20" fill="#b88828"/><circle cx="52.46" cy="32.00" r="6.20" fill="#b88828"/><circle cx="49.72" cy="42.23" r="6.20" fill="#b88828"/><circle cx="42.23" cy="49.72" r="6.20" fill="#b88828"/><circle cx="32.00" cy="52.46" r="6.20" fill="#b88828"/><circle cx="21.77" cy="49.72" r="6.20" fill="#b88828"/><circle cx="14.28" cy="42.23" r="6.20" fill="#b88828"/><circle cx="11.54" cy="32.00" r="6.20" fill="#b88828"/><circle cx="14.28" cy="21.77" r="6.20" fill="#b88828"/><circle cx="21.77" cy="14.28" r="6.20" fill="#b88828"/><path fill="#1d3b8b" d="M37.6,10.3 L38.4,11.0 L37.6,11.8 L36.8,11.0Z"/><path fill="#1d3b8b" d="M47.3,15.9 L48.2,16.7 L47.3,17.4 L46.5,16.7Z"/><path fill="#1d3b8b" d="M53.0,25.6 L53.8,26.4 L53.0,27.2 L52.1,26.4Z"/><path fill="#1d3b8b" d="M53.0,36.8 L53.8,37.6 L53.0,38.4 L52.1,37.6Z"/><path fill="#1d3b8b" d="M47.3,46.6 L48.2,47.3 L47.3,48.1 L46.5,47.3Z"/><path fill="#1d3b8b" d="M37.6,52.2 L38.4,53.0 L37.6,53.7 L36.8,53.0Z"/><path fill="#1d3b8b" d="M26.4,52.2 L27.2,53.0 L26.4,53.7 L25.6,53.0Z"/><path fill="#1d3b8b" d="M16.7,46.6 L17.5,47.3 L16.7,48.1 L15.8,47.3Z"/><path fill="#1d3b8b" d="M11.0,36.8 L11.9,37.6 L11.0,38.4 L10.2,37.6Z"/><path fill="#1d3b8b" d="M11.0,25.6 L11.9,26.4 L11.0,27.2 L10.2,26.4Z"/><path fill="#1d3b8b" d="M16.7,15.9 L17.5,16.7 L16.7,17.4 L15.8,16.7Z"/><path fill="#1d3b8b" d="M26.4,10.3 L27.2,11.0 L26.4,11.8 L25.6,11.0Z"/></g><circle cx="32" cy="32" r="21.08" fill="#b88828"/><circle cx="32" cy="32" r="19.22" fill="#1d3b8b"/><path transform="translate(25.6 41.6)" fill="#fbf6ea" d="M-2.06 4.35 -2.42 3.25Q0.30 1.84 2.53 0.57Q4.75 -0.70 6.46 -1.87Q8.18 -3.03 9.37 -4.04Q10.56 -5.06 11.15 -5.91Q11.74 -6.77 11.74 -7.43Q11.74 -7.96 11.00 -8.88Q10.25 -9.81 8.44 -10.97Q6.64 -12.14 3.52 -13.42Q3.82 -14.52 4.37 -15.99Q4.92 -17.46 5.58 -18.98Q6.24 -20.50 6.86 -21.73Q7.48 -22.97 7.92 -23.54H8.53Q10.51 -22.53 12.05 -20.96Q13.59 -19.40 14.45 -17.35Q15.31 -15.31 15.31 -12.71Q15.31 -10.25 14.63 -7.98Q13.94 -5.72 12.91 -3.87Q11.88 -2.02 10.80 -0.79Q9.72 0.44 8.88 0.83Q7.78 1.36 4.81 2.37Q1.84 3.38 -2.06 4.35Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#8b2e1f"/><text x="32" y="44" font-size="34" text-anchor="middle" fill="#f8f4ea" font-family="serif">د</text></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 220 B

View File

@ -1,6 +0,0 @@
Newsreader (c) 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
Readerly (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/readerly)
Libron (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/libron)
with Reserved Font Name Libron.
Libron is a modified version of Readerly with reduced serifs.

View File

@ -1,95 +0,0 @@
Newsreader (c) 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
Readerly (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/readerly)
Libron (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/libron)
with Reserved Font Name Libron.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="دیوان"><title>دیوان</title><g class="rays-long"><polygon fill="#b88828" opacity=".6" points="40.02,2.06 44.09,11.06 53.92,10.08 52.94,19.91 61.94,23.98 56.18,32.00 61.94,40.02 52.94,44.09 53.92,53.92 44.09,52.94 40.02,61.94 32.00,56.18 23.98,61.94 19.91,52.94 10.08,53.92 11.06,44.09 2.06,40.02 7.82,32.00 2.06,23.98 11.06,19.91 10.08,10.08 19.91,11.06 23.98,2.06 32.00,7.82"/></g><g class="rays-short"><circle cx="32.00" cy="11.54" r="6.20" fill="#b88828"/><circle cx="42.23" cy="14.28" r="6.20" fill="#b88828"/><circle cx="49.72" cy="21.77" r="6.20" fill="#b88828"/><circle cx="52.46" cy="32.00" r="6.20" fill="#b88828"/><circle cx="49.72" cy="42.23" r="6.20" fill="#b88828"/><circle cx="42.23" cy="49.72" r="6.20" fill="#b88828"/><circle cx="32.00" cy="52.46" r="6.20" fill="#b88828"/><circle cx="21.77" cy="49.72" r="6.20" fill="#b88828"/><circle cx="14.28" cy="42.23" r="6.20" fill="#b88828"/><circle cx="11.54" cy="32.00" r="6.20" fill="#b88828"/><circle cx="14.28" cy="21.77" r="6.20" fill="#b88828"/><circle cx="21.77" cy="14.28" r="6.20" fill="#b88828"/><path fill="#1d3b8b" d="M37.6,10.3 L38.4,11.0 L37.6,11.8 L36.8,11.0Z"/><path fill="#1d3b8b" d="M47.3,15.9 L48.2,16.7 L47.3,17.4 L46.5,16.7Z"/><path fill="#1d3b8b" d="M53.0,25.6 L53.8,26.4 L53.0,27.2 L52.1,26.4Z"/><path fill="#1d3b8b" d="M53.0,36.8 L53.8,37.6 L53.0,38.4 L52.1,37.6Z"/><path fill="#1d3b8b" d="M47.3,46.6 L48.2,47.3 L47.3,48.1 L46.5,47.3Z"/><path fill="#1d3b8b" d="M37.6,52.2 L38.4,53.0 L37.6,53.7 L36.8,53.0Z"/><path fill="#1d3b8b" d="M26.4,52.2 L27.2,53.0 L26.4,53.7 L25.6,53.0Z"/><path fill="#1d3b8b" d="M16.7,46.6 L17.5,47.3 L16.7,48.1 L15.8,47.3Z"/><path fill="#1d3b8b" d="M11.0,36.8 L11.9,37.6 L11.0,38.4 L10.2,37.6Z"/><path fill="#1d3b8b" d="M11.0,25.6 L11.9,26.4 L11.0,27.2 L10.2,26.4Z"/><path fill="#1d3b8b" d="M16.7,15.9 L17.5,16.7 L16.7,17.4 L15.8,16.7Z"/><path fill="#1d3b8b" d="M26.4,10.3 L27.2,11.0 L26.4,11.8 L25.6,11.0Z"/></g><circle cx="32" cy="32" r="21.08" fill="#b88828"/><circle cx="32" cy="32" r="19.22" fill="#1d3b8b"/><path transform="translate(25.6 41.6)" fill="#fbf6ea" d="M-2.06 4.35 -2.42 3.25Q0.30 1.84 2.53 0.57Q4.75 -0.70 6.46 -1.87Q8.18 -3.03 9.37 -4.04Q10.56 -5.06 11.15 -5.91Q11.74 -6.77 11.74 -7.43Q11.74 -7.96 11.00 -8.88Q10.25 -9.81 8.44 -10.97Q6.64 -12.14 3.52 -13.42Q3.82 -14.52 4.37 -15.99Q4.92 -17.46 5.58 -18.98Q6.24 -20.50 6.86 -21.73Q7.48 -22.97 7.92 -23.54H8.53Q10.51 -22.53 12.05 -20.96Q13.59 -19.40 14.45 -17.35Q15.31 -15.31 15.31 -12.71Q15.31 -10.25 14.63 -7.98Q13.94 -5.72 12.91 -3.87Q11.88 -2.02 10.80 -0.79Q9.72 0.44 8.88 0.83Q7.78 1.36 4.81 2.37Q1.84 3.38 -2.06 4.35Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,16 +0,0 @@
{
"name": "دیوان · اردو کا کلاسیکی ادب",
"short_name": "دیوان",
"lang": "ur-PK",
"dir": "rtl",
"start_url": "/",
"display": "standalone",
"background_color": "#fbf6ea",
"theme_color": "#fbf6ea",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}

View File

@ -1,8 +1,7 @@
---
const { current } = Astro.props as { current: 'users' | 'audit' | 'stats' };
const { current } = Astro.props as { current: 'users' | 'audit' };
---
<nav class="admin-nav">
<a href="/admin" aria-current={current === 'users' ? 'page' : undefined}>صارفین</a>
<a href="/admin/audit" aria-current={current === 'audit' ? 'page' : undefined}>ریکارڈ</a>
<a href="/admin/stats" aria-current={current === 'stats' ? 'page' : undefined}>اعداد و شمار</a>
</nav>

View File

@ -1,9 +0,0 @@
---
// The couplet line (from the logo concepts): two half-lines tapering outward from a ✤. Decorative; coloured by the palette.
const s = 13, petal = `M0,0 C${.45 * s},${-.35 * s} ${.25 * s},${-.9 * s} 0,${-s} C${-.25 * s},${-.9 * s} ${-.45 * s},${-.35 * s} 0,0Z`;
const misra = (a: number, b: number, y = 16, h = 6) => `M${a},${y} Q${(a + b) / 2},${y - h * .9} ${b},${y - h / 2} L${b},${y + h / 2} Q${(a + b) / 2},${y + h * .9} ${a},${y}Z`;
---
<svg class="bayt" viewBox="0 0 280 32" aria-hidden="true">
<path class="misra" d={misra(124, 4)} /><path class="misra" d={misra(156, 276)} />
<g class="gul">{[45, 135, 225, 315].map((a) => <path transform={`translate(140 16) rotate(${a})`} d={petal} />)}<circle class="hole" cx="140" cy="16" r={s * .16} /></g>
</svg>

View File

@ -1,17 +0,0 @@
---
// Divan text rendered like the site (moderation preview, guide examples): headings, paragraphs, couplets with
// their labels, lines and stanzas; dictionary links underlined, footnotes numbered, variants marked
import { parse } from '../../../api/src/divantext.ts';
const { text } = Astro.props as { text: string };
const doc = parse(text);
let n = 0;
const notes: string[] = [];
const line = (l: any) => { l.notes.forEach((x: string) => notes.push(x)); return l; };
---
<article class="poem preview">
{doc.blocks.map((b: any) => b.type === 'heading' ? (b.level > 2 ? <h4 class="mod-h sub">{b.text}</h4> : <h3 class="mod-h">{b.text}</h3>)
: b.type === 'para' ? <p class="para">{line(b.line).text}{b.line.notes.map(() => <sup>{++n}</sup>)}</p>
: b.type === 'couplet' ? <div class="couplet" data-mark={b.label}>{b.lines.map((l: any) => <p>{line(l).text}{l.notes.map(() => <sup>{++n}</sup>)}</p>)}</div>
: <div class={b.type === 'stanza' ? 'stanza' : ''}>{b.lines.map((l: any) => <p class="single">{line(l).text}{l.notes.map(() => <sup>{++n}</sup>)}</p>)}</div>)}
{notes.length > 0 && <ol class="preview-notes">{notes.map((x) => <li>{x}</li>)}</ol>}
</article>

View File

@ -1,60 +0,0 @@
---
// An e-book's details, for adding a book and for reviewing it: each input has a name (the upload form posts it)
// and data-eb (the review form writes them into the revision text). Co-authors are picked from the site's
// poets (linked) or typed as new names.
import { api } from '../lib/api';
const { v = {} } = Astro.props as { v?: Record<string, string> };
const LANGUAGES = ['اردو', 'فارسی', 'عربی', 'انگریزی', 'ہندی', 'پنجابی', 'سندھی', 'پشتو', 'دیگر'];
const poets = ((await api<any[]>('/api/poets')) ?? []).map((p) => p.nickname);
const KB = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg>';
const keys = (id: string) => `<button type="button" class="in-box rom" data-urdu-rom="${id}" aria-pressed="false" title="رومن حروف سے اردو">اب</button><button type="button" class="in-box" data-urdu-kb="${id}" aria-pressed="false" title="اردو کی بورڈ" aria-label="اردو کی بورڈ">${KB}</button>`;
const direction = v.direction || (['انگریزی', 'ہندی'].includes(v.language) ? 'بائیں سے دائیں' : 'دائیں سے بائیں');
---
<label>عنوان<span class="in-box-field"><input id="eb-title" name="title" data-eb="title" value={v.title ?? ''} required maxlength="300" data-urdu /><Fragment set:html={keys('eb-title')} /></span></label>
<label>مصنف کا نام (جیسا کتاب پر لکھا ہے)<span class="in-box-field"><input id="eb-writer" name="writer" data-eb="writer" value={v.writer ?? ''} maxlength="300" data-urdu /><Fragment set:html={keys('eb-writer')} /></span></label>
<div class="eb-co">
<span>شریک مصنفین <small class="muted">(سائٹ پر موجود ہوں تو ان کے صفحے سے جڑ جاتے ہیں؛ نیا نام بھی لکھ سکتے ہیں)</small></span>
<p class="tag-chips eb-co-chips"></p>
<span class="in-box-field author-find"><input id="eb-co" placeholder="نام لکھیں، Enter سے شامل کریں" autocomplete="off" data-urdu /><Fragment set:html={keys('eb-co')} />
<span class="author-suggest" hidden></span></span>
<input type="hidden" name="coauthors" data-eb="coauthors" value={v.coauthors ?? ''} />
<script type="application/json" class="eb-poets" set:html={JSON.stringify(poets)} />
</div>
<div class="eb-row">
<label>زبان<select name="language" data-eb="language" class="tool">{LANGUAGES.map((l) => <option selected={l === (v.language || 'اردو')}>{l}</option>)}</select></label>
<fieldset class="eb-dir"><legend>پڑھنے کی سمت</legend>
{['دائیں سے بائیں', 'بائیں سے دائیں'].map((d) => <label><input type="radio" name="direction" data-eb="direction" value={d} checked={d === direction} /> {d} <span lang="en" dir="ltr">({d === 'دائیں سے بائیں' ? 'RTL' : 'LTR'})</span></label>)}
</fieldset>
</div>
<label>ٹیگ (کئی ہوں تو ، سے الگ کریں)<span class="in-box-field"><input id="eb-tags" name="tags" data-eb="tags" value={v.tags ?? ''} maxlength="500" data-urdu placeholder="مثلاً: نعت، کلاسیکی" /><Fragment set:html={keys('eb-tags')} /></span></label>
<label>ماخذ<input name="source" data-eb="source" value={v.source ?? ''} maxlength="300" data-urdu placeholder="مثلاً: ناشر، سال، کتب خانہ" /></label>
<label>حقوق / اجازت<input name="licence" data-eb="licence" value={v.licence ?? ''} maxlength="300" data-urdu placeholder="مثلاً: عوامی ملکیت، مصنف کی اجازت سے" /></label>
<label>تفصیل<textarea name="note" data-eb="note" rows="3" maxlength="2000" data-urdu>{v.note ?? ''}</textarea></label>
<script>
// co-author chips: suggestions from the site's poets; Enter adds what is typed (a new name)
for (const box of document.querySelectorAll<HTMLElement>('.eb-co')) {
const poets: string[] = JSON.parse(box.querySelector('.eb-poets')!.textContent!);
const hidden = box.querySelector<HTMLInputElement>('input[type=hidden]')!, input = box.querySelector<HTMLInputElement>('#eb-co')!;
const chips = box.querySelector<HTMLElement>('.eb-co-chips')!, list = box.querySelector<HTMLElement>('.author-suggest')!;
const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ');
let names = hidden.value.split(/[،,]/).map((s) => s.trim()).filter(Boolean);
const draw = () => {
hidden.value = names.join('، '); hidden.dispatchEvent(new Event('input', { bubbles: true }));
chips.replaceChildren(...names.map((n) => {
const c = document.createElement('button'); c.type = 'button'; c.className = 'tag-chip';
c.textContent = `${n}${poets.some((p) => norm(p) === norm(n)) ? '' : ' (نیا)'} ✕`; c.title = 'ہٹائیں';
c.onclick = () => { names = names.filter((x) => x !== n); draw(); }; return c;
}));
};
const add = (n: string) => { n = n.trim(); if (n && !names.includes(n)) names.push(n); input.value = ''; list.hidden = true; draw(); input.focus(); };
input.addEventListener('input', () => {
const t = norm(input.value.trim());
const hits = t ? poets.filter((p) => norm(p).includes(t) && !names.includes(p)).slice(0, 10) : [];
list.replaceChildren(...hits.map((p) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = p; b.onclick = () => add(p); return b; }));
list.hidden = !hits.length;
});
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); add(input.value); } });
draw();
}
</script>

View File

@ -1,36 +0,0 @@
---
// A work's, arrangement's or tags' versions (#51): compare any two published versions, see what each one changed
// (فرق), and from there bring an old one back. Version 0 is the Wikisource text before Divan's first version.
import { STATUS, when } from '../lib/mod';
import { ud } from '../lib/urdu';
interface Props { entity: string; id: number; history: any[]; version: number; empty: string }
const { entity, id, history, version, empty } = Astro.props;
const base = `/mod/compare/${entity}/${id}`;
const versions = Array.from({ length: version + 1 }, (_, v) => v);
const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ');
---
{version > 0 && (
<form method="get" action={base} class="version-tools">
<label>ورژن <select name="a">{versions.map((v) => <option value={v} selected={v === version - 1}>{label(v)}</option>)}</select></label>
<label>اور <select name="b">{versions.map((v) => <option value={v} selected={v === version}>{label(v)}</option>)}</select></label>
<button>موازنہ کریں</button>
</form>
)}
<table class="admin-table">
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th><th></th></tr></thead>
<tbody>
{history.map((h: any) => (
<tr>
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
<td>{h.summary}</td>
<td><bdi dir="ltr">{h.author_email}</bdi></td>
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
<td>{h.version && <a href={`${base}?a=${h.version - 1}&b=${h.version}`}>فرق</a>}</td>
</tr>
))}
</tbody>
</table>
{history.length === 0 && <p class="muted center">{empty}</p>}

View File

@ -1,68 +0,0 @@
---
// Icons (Lucide, ISC licence; https://lucide.dev): drawn with currentColor, so they take the colour of their button
// or link. Decorative: the button or link carries the words (aria-label/title, or its text).
const { name, size = 20, class: cls } = Astro.props as { name: keyof typeof PATHS; size?: number; class?: string };
const PATHS = {
admin: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
edit: '<path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/>',
bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
// the word book: a book; the letter ع is laid over it as text (.glyph-icon), which every browser draws the same
words: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/>',
user: '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
login: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
theme: '<circle cx="12" cy="12" r="10"/><path d="M12 18a6 6 0 0 0 0-12v12z"/>',
font: '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/>',
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r="1" fill="currentColor"/>',
arrange: '<path d="M10 12h11"/><path d="M10 18h11"/><path d="M10 6h11"/><path d="M4 10h2"/><path d="M4 6h1v4"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/>',
send: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
save: '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
exitfull: '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>',
full: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
next: '<path d="m15 18-6-6 6-6"/>', // points left: forward in a right-to-left page
prev: '<path d="m9 18 6-6-6-6"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.828 0L6 21"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
settings: '<path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
// editor toolbar
couplet: '<path d="M3 9h7"/><path d="M14 9h7"/><path d="M3 15h7"/><path d="M14 15h7"/>',
para: '<path d="M13 4v16"/><path d="M17 4v16"/><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13"/>',
h2: '<path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/>',
h3: '<path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/>',
dict: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
note: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
variant: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/>',
redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13"/>',
keyboard: '<rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M8 14h8"/>',
code: '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
top: '<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>',
bottom: '<path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/>',
// admin
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m20 20-4.9-4.9"/>',
chevron: '<path d="m15 18-6-6 6-6"/>',
// footer
rss: '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/>',
home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
scale: '<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
database: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
};
---
<svg class:list={['icon', cls]} viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" set:html={PATHS[name]} />

View File

@ -1,11 +1,9 @@
---
// tabs of the reader's own space: saved texts, favourite writers, the word book (kept separate), profile
const { current } = Astro.props as { current: 'bookmarks' | 'notes' | 'writers' | 'words' };
// tabs between the reader's bookmarks and their word book (kept separate)
const { current } = Astro.props as { current: 'bookmarks' | 'words' };
---
<nav class="admin-nav">
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> محفوظ شدہ</a>
<a href="/notes" aria-current={current === 'notes' ? 'page' : undefined}>نوٹس</a>
<a href="/writers" aria-current={current === 'writers' ? 'page' : undefined}>پسندیدہ لکھاری</a>
<a href="/library" aria-current={current === 'bookmarks' ? 'page' : undefined}><span class="bm-icon on" aria-hidden="true"></span> نشانات</a>
<a href="/words" aria-current={current === 'words' ? 'page' : undefined}>ذخیرۂ الفاظ</a>
<a href="/account">پروفائل</a>
</nav>

View File

@ -1,9 +0,0 @@
---
const { current } = Astro.props as { current?: 'queue' | 'log' | 'guide' | 'site' };
---
<nav class="admin-nav">
<a href="/mod" aria-current={current === 'queue' ? 'page' : undefined}>موڈریشن</a>
<a href="/mod?log=1" aria-current={current === 'log' ? 'page' : undefined}>سرگرمی</a>
<a href="/mod/site" aria-current={current === 'site' ? 'page' : undefined}>سرورق</a>
<a href="/mod/guide" aria-current={current === 'guide' ? 'page' : undefined}>رہنمائی</a>
</nav>

View File

@ -1,10 +1,6 @@
---
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?]
// a bookmarked phrase opens its note when pressed (the poem page's note box), where it can also be removed
const { s } = Astro.props as { s: [string, string, string?][] };
const noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined);
const noteOnly = (d?: string) => !!d && !!JSON.parse(d).noteOnly; // a phrase with a note but no bookmark
---
{s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
: k === 'bmk' ? <span class:list={['bmk', { 'has-note': noteOf(d), 'note-only': noteOnly(d) }]} data-phrase={d} role="button" tabindex="0"
title={noteOf(d) ? `نوٹ: ${noteOf(d)}` : 'محفوظ شدہ عبارت: نوٹ لکھیں یا ہٹائیں'}>{t}</span> : t))}
: k === 'bmk' ? <span class="bmk" data-save={d} data-confirm="یہ نشان ہٹائیں؟" title="نشان زدہ عبارت (ہٹانے کے لیے دبائیں)">{t}</span> : t))}

View File

@ -1,8 +0,0 @@
---
// tag chips (#52): each links to its tag page
const { tags } = Astro.props as { tags: { type: string; name: string }[] };
const href = (t: { type: string; name: string }) => `/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`;
---
{tags.length > 0 && (
<p class="tag-chips">{tags.map((t) => <a class="tag-chip" href={href(t)}><small>{t.type}</small> {t.name}</a>)}</p>
)}

View File

@ -1,14 +0,0 @@
---
// a page's tags at the end of its content (#52): «ٹیگ» and how many, at the start of the line; opened, the tags follow it
// on the same line and wrap across the content's width. A visually hidden checkbox holds open/closed (no script needed)
import { ud } from '../lib/urdu';
const { tags } = Astro.props as { tags: { type: string; name: string }[] };
const href = (t: { type: string; name: string }) => `/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`;
---
{tags.length > 0 && (
<div class="tags-box">
<input type="checkbox" id="tags-open" class="sr-only" />
<label for="tags-open" class="tags-toggle">ٹیگ <small class="muted">{ud(tags.length)}</small></label>
{tags.map((t) => <a class="tag-chip" href={href(t)}><small>{t.type}</small> {t.name}</a>)}
</div>
)}

View File

@ -1,22 +1,13 @@
---
// fonts are self-hosted (no Google Fonts request; works with Brave Shields and offline)
import '@fontsource/noto-naskh-arabic/400.css';
import '@fontsource/noto-naskh-arabic/500.css';
import '@fontsource/noto-naskh-arabic/600.css';
import '@fontsource/noto-naskh-arabic/700.css';
import '@fontsource/noto-nastaliq-urdu/400.css';
import '@fontsource/noto-nastaliq-urdu/700.css';
import '../styles/global.css';
interface Props { title?: string; description?: string; q?: string; author?: string; jsonld?: object; ogType?: string }
// author and jsonld describe a work for search engines and the browser's own reader view
const { title, description = 'اردو کا کلاسیکی ادب', q = '', author, jsonld, ogType = 'website' } = Astro.props;
// one address per page for search engines: the public origin, no query (?hl=, ?font=, ?kind= are views of the same page)
const canonical = siteUrl(Astro.url) + Astro.url.pathname;
const fullTitle = title ? `${title} · ${brand.name}` : `${brand.name} · ${brand.tagline}`;
import Icon from '../components/Icon.astro';
import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can turn its rays
import { brand } from '../lib/brand';
import { siteUrl } from '../lib/site-url';
interface Props { title?: string; description?: string; q?: string }
const { title, description = 'اردو کی کلاسیکی شاعری اور نثر', q = '' } = Astro.props;
const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو کی کلاسیکی شاعری اور نثر';
---
<!doctype html>
<html lang="ur-PK" dir="rtl">
@ -25,52 +16,23 @@ import { siteUrl } from '../lib/site-url';
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="alternate" type="application/rss+xml" title={`${brand.name}: نئی تبدیلیاں`} href="/rss.xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#fbf6ea" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#14120f" media="(prefers-color-scheme: dark)" />
<!-- link previews (WhatsApp, Facebook, X, Slack): the logo and headline -->
<meta property="og:site_name" content={brand.name} />
<meta property="og:type" content={ogType} />
<meta property="og:locale" content="ur_PK" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL('/og-image.png', Astro.url.origin).href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${brand.name}: ${brand.tagline}`} />
<meta name="twitter:card" content="summary_large_image" />
{author && <meta name="author" content={author} />}
{jsonld && <script type="application/ld+json" set:html={JSON.stringify(jsonld).replace(/</g, '\\u003c')} />}
<script is:inline>
// font, palette and light/dark applied before paint (Naskh, sepia and the system's light/dark by default)
// font + theme choice applied before paint (Naskh and system theme by default)
try {
// ?font=nastaliq|naskh in the URL sets the choice (shareable links)
const f = new URLSearchParams(location.search).get('font');
if (f === 'nastaliq' || f === 'naskh') localStorage.setItem('divan-font', f);
if (localStorage.getItem('divan-font') === 'nastaliq') document.documentElement.classList.add('nastaliq');
const w = localStorage.getItem('divan-weight'); // Naskh's weight (400-700), heavier for small screens
if (w) document.documentElement.style.setProperty('--naskh-weight', w);
const z = localStorage.getItem('divan-size'); // text size in % (100 by default), every page scales with it
if (z) document.documentElement.style.setProperty('--text-size', z + '%');
const t = localStorage.getItem('divan-theme');
if (t) document.documentElement.dataset.theme = t;
const pal = localStorage.getItem('divan-palette'); // sepia when unset
if (pal) document.documentElement.dataset.palette = pal;
if (document.cookie.includes('divan-digits=latn')) document.documentElement.dataset.digits = 'latn'; // Western numerals
if (localStorage.getItem('divan-reading')) document.documentElement.dataset.reading = ''; // reading mode, kept across pages
} catch (e) {}
</script>
</head>
<body data-user={Astro.locals.user ? '1' : undefined}>
<header class="site-header">
<div class="wrap">
<a class="wordmark" href="/" title="سرورق"><Fragment set:html={logo.replace('<svg ', '<svg class="logo" ')} /></a>
<a class="wordmark" href="/">دیوان</a>
<form class="search-form" action="/search" method="get" role="search">
<input type="search" name="q" id="q" data-urdu value={q} placeholder="شعر، لفظ یا عبارت تلاش کریں" aria-label="تلاش" autocomplete="off" />
<button type="button" id="rom-toggle" class="in-box rom" aria-label="رومن حروف سے اردو" aria-pressed="false" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
@ -81,49 +43,16 @@ import { siteUrl } from '../lib/site-url';
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>
</button>
</form>
<button type="button" class="icon-btn reading-exit" data-reading-toggle title="مطالعے کے انداز سے باہر" aria-label="مطالعے کے انداز سے باہر"><Icon name="close" /></button>
<nav class="toggles" aria-label="سائٹ">
{Astro.locals.user?.role === 'admin' && <a class="icon-btn" href="/admin" title="ایڈمن" aria-label="ایڈمن"><Icon name="admin" /></a>}
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="icon-btn" href="/mod" title="موڈریشن" aria-label="موڈریشن"><Icon name="edit" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/library" title="محفوظ شدہ" aria-label="محفوظ شدہ"><Icon name="bookmark" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/notes" title="میرے نوٹس" aria-label="میرے نوٹس"><Icon name="note" /></a>}
{Astro.locals.user && <a class="icon-btn" href="/words" title="ذخیرۂ الفاظ" aria-label="ذخیرۂ الفاظ"><span class="glyph-icon"><Icon name="words" /><span aria-hidden="true">ع</span></span></a>}
<div class="toggles">
{Astro.locals.user?.role === 'admin' && <a class="account-link" href="/admin">ایڈمن</a>}
{Astro.locals.user && <a class="account-link" href="/library">نشانات</a>}
{Astro.locals.user && <a class="account-link" href="/words">ذخیرۂ الفاظ</a>}
{Astro.locals.user
? <a class="icon-btn" href="/account" title={`اکاؤنٹ: ${Astro.locals.user.full_name || Astro.locals.user.email}`} aria-label="اکاؤنٹ"><Icon name="user" /></a>
: <a class="icon-btn" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`} title="لاگ ان" aria-label="لاگ ان"><Icon name="login" /></a>}
<details class="display-menu">
<summary class="icon-btn" title="خط اور رنگ" aria-label="خط اور رنگ"><Icon name="settings" /></summary>
<div class="display-panel">
<p>خط</p>
<div class="seg"><button type="button" data-font="naskh">نسخ</button><button type="button" data-font="nastaliq" class="f-nastaliq">نستعلیق</button></div>
<label class="weight"><span>نسخ کی موٹائی</span>
<input type="range" min="400" max="700" step="100" value="400" data-weight aria-label="نسخ کی موٹائی" /></label>
<label class="weight"><span>حروف کا سائز</span>
<input type="range" min="90" max="150" step="10" value="100" data-size aria-label="حروف کا سائز" /></label>
<p>رنگ</p>
<div class="swatches">
{[['', 'سپیا', '#fbf6ea', '#bf3f2a'], ['teal', 'فیروزی', '#e2f0ee', '#0b4f6c'], ['lapis', 'نیلگوں', '#e6ebf8', '#1d3b8b'],
['green', 'سبز', '#e5eede', '#1f5d3a'], ['rose', 'گلابی', '#f5e6ea', '#6b2c5e']].map(([v, name, a, b]) => (
<button type="button" data-palette={v} title={name} aria-label={name} style={`--a: ${a}; --b: ${b}`}><span></span>{name}</button>
))}
</div>
<p>ہندسے</p>
<div class="seg"><button type="button" data-digits="">۱۲۳ مشرقی</button><button type="button" data-digits="latn">123 مغربی</button></div>
<p>روشنی</p>
<div class="seg">
<button type="button" data-theme-pick="light"><Icon name="sun" size={16} /> روشن</button>
<button type="button" data-theme-pick="dark"><Icon name="moon" size={16} /> تاریک</button>
<button type="button" data-theme-pick=""><Icon name="theme" size={16} /> خودکار</button>
</div>
</div>
</details>
{Astro.locals.user && (
<form method="post" action="/account" class="inline-form">
<input type="hidden" name="act" value="signout" />
<button class="icon-btn danger" title="لاگ آؤٹ" aria-label="لاگ آؤٹ"><Icon name="logout" /></button>
</form>
)}
</nav>
? <a class="account-link" href="/account" title={Astro.locals.user.email}>{Astro.locals.user.full_name.split(' ')[0] || 'اکاؤنٹ'}</a>
: <a class="account-link" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`}>لاگ ان</a>}
<button type="button" id="font-toggle" aria-label="خط بدلیں">نسخ</button>
<button type="button" id="theme-toggle" aria-label="روشن یا تاریک">◐</button>
</div>
</div>
<div id="kb" class="wrap kb" hidden></div>
</header>
@ -132,7 +61,6 @@ import { siteUrl } from '../lib/site-url';
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
<button type="button" data-act="saveword" class="plus" title="ذخیرۂ الفاظ میں ڈالیں">ذخیرۂ الفاظ</button>
<button type="button" data-act="bookmark" title="یہ عبارت محفوظ کریں">محفوظ کریں</button>
<button type="button" data-act="note" title="اس عبارت پر نوٹ لکھیں">نوٹ</button>
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
@ -144,99 +72,20 @@ import { siteUrl } from '../lib/site-url';
</aside>
<footer class="site-footer">
<div class="wrap">
<div class="footer-brand">
<a class="footer-logo" href="/" title="سرورق"><img src="/logo/turanj.svg" width="64" height="64" alt={brand.name} /></a>
<div>
<p class="footer-tagline">{brand.tagline}</p>
</div>
<p class="made-in">
<svg class="flag" viewBox="0 0 30 20" width="27" height="18" role="img" aria-label="پاکستان کا پرچم">
<rect width="30" height="20" fill="#fff" /><rect x="7.5" width="22.5" height="20" fill="#01411c" />
<circle cx="19.2" cy="10" r="6" fill="#fff" /><circle cx="20.6" cy="8.7" r="5.4" fill="#01411c" />
<polygon fill="#fff" points="23.65,5.99 23.12,7.39 24.25,8.39 22.75,8.32 22.15,9.69 21.75,8.25 20.26,8.11 21.51,7.28 21.19,5.82 22.36,6.75" />
</svg>
{brand.madeBy}
</p>
</div>
<nav class="footer-links" aria-label="سائٹ کے بارے میں">
<div><h2>{brand.name}</h2><ul>
<li><a href="/about"><Icon name="info" size={18} /><span>{brand.name} کے بارے میں<small>مقصد اور طریقۂ کار</small></span></a></li>
<li><a href="/help"><Icon name="help" size={18} /><span>استعمال کا طریقہ<small>سائٹ کی سہولتیں</small></span></a></li>
<li><a href="/tags"><Icon name="tag" size={18} /><span>ٹیگ<small>موضوعات سے کلام</small></span></a></li>
<li><a href="/rss.xml"><Icon name="rss" size={18} /><span>نئی تبدیلیاں<small>RSS: نئے ورژن اور ای بکس</small></span></a></li>
<li><a href="/privacy"><Icon name="lock" size={18} /><span>رازداری<small>ہم کیا محفوظ رکھتے ہیں</small></span></a></li>
<li><a href="/terms"><Icon name="scale" size={18} /><span>استعمال کی شرائط<small>لائسنس اور ذمہ داریاں</small></span></a></li>
</ul></div>
<div><h2>ماخذ</h2><ul>
<li><a href="https://ur.wikisource.org" rel="noopener"><Icon name="dict" size={18} /><span>ویکی ماخذ<small>متن · عوامی ملکیت</small></span><Icon name="external" size={13} class="ext" /></a></li>
<li><a href="https://ur.wikipedia.org" rel="noopener"><Icon name="globe" size={18} /><span>ویکیپیڈیا<small>شعرا کے تعارف</small></span><Icon name="external" size={13} class="ext" /></a></li>
</ul></div>
<div><h2>کھلا منبع</h2><ul>
<li><a href="https://github.com/anas-rashid/divan-data" rel="noopener"><Icon name="database" size={18} /><span>ڈیٹا<small>تمام متن، آزادانہ</small></span><Icon name="external" size={13} class="ext" /></a></li>
<li><a href="https://github.com/anas-rashid/divan" rel="noopener"><Icon name="code" size={18} /><span>کوڈ<small>سائٹ کا سورس</small></span><Icon name="external" size={13} class="ext" /></a></li>
</ul></div>
</nav>
<p class="footer-bar">
<span>متن: عوامی ملکیت · کوڈ: <span lang="en" dir="ltr">GNU GPL v3</span></span>
<a href={brand.contact.url} rel="noopener">{brand.contact.text}</a>
<a href={brand.basedOn.url} rel="noopener" lang="en" dir="ltr">{brand.basedOn.text}</a>
</p>
متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
</div>
</footer>
<script is:inline>
const root = document.documentElement;
// display settings: reading font, colour palette, light/dark/automatic; each choice is remembered
const panel = document.querySelector('.display-menu');
const store = (k, v) => { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) {} };
const marks = () => {
const on = { font: root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh', palette: root.dataset.palette ?? '', theme: root.dataset.theme ?? '' };
panel.querySelectorAll('[data-font]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.font === on.font)));
weight.value = root.style.getPropertyValue('--naskh-weight') || '400';
weight.disabled = on.font === 'nastaliq'; // Nastaliq comes in regular and bold only
size.value = parseInt(root.style.getPropertyValue('--text-size')) || '100';
panel.querySelectorAll('[data-palette]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.palette === on.palette)));
panel.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === on.theme)));
panel.querySelectorAll('[data-digits]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.digits === (root.dataset.digits ?? ''))));
const fontBtn = document.getElementById('font-toggle');
const label = () => (fontBtn.textContent = root.classList.contains('nastaliq') ? 'نستعلیق' : 'نسخ');
label();
fontBtn.onclick = () => {
root.classList.toggle('nastaliq');
try { localStorage.setItem('divan-font', root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh'); } catch (e) {}
label();
};
const weight = panel.querySelector('[data-weight]');
weight.addEventListener('input', () => {
if (weight.value === '400') root.style.removeProperty('--naskh-weight'); else root.style.setProperty('--naskh-weight', weight.value);
store('divan-weight', weight.value === '400' ? '' : weight.value);
});
const size = panel.querySelector('[data-size]');
size.addEventListener('input', () => {
if (size.value === '100') root.style.removeProperty('--text-size'); else root.style.setProperty('--text-size', size.value + '%');
store('divan-size', size.value === '100' ? '' : size.value);
});
panel.addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.font) {
// switching to Nastaliq: a short note that it is for the text (the interface stays Naskh, by design)
if (b.dataset.font === 'nastaliq' && !root.classList.contains('nastaliq')) toast('نستعلیق صرف کلام اور متن پر؛ باقی سائٹ نسخ میں رہتی ہے', 'info');
root.classList.toggle('nastaliq', b.dataset.font === 'nastaliq'); store('divan-font', b.dataset.font);
}
if (b.dataset.palette != null) { if (b.dataset.palette) root.dataset.palette = b.dataset.palette; else delete root.dataset.palette; store('divan-palette', b.dataset.palette); }
if (b.dataset.digits != null) {
// numerals: a cookie, so the server sends pages with them; switching back to Eastern reloads the page
document.cookie = `divan-digits=${b.dataset.digits}; path=/; max-age=${b.dataset.digits ? 31536000 : 0}; samesite=lax`;
if (!b.dataset.digits) { delete root.dataset.digits; location.reload(); return; }
root.dataset.digits = 'latn'; westernDigits(document.body);
}
if (b.dataset.themePick != null) { if (b.dataset.themePick) root.dataset.theme = b.dataset.themePick; else delete root.dataset.theme; store('divan-theme', b.dataset.themePick); }
marks();
});
document.addEventListener('click', (e) => { if (panel.open && !panel.contains(e.target)) panel.open = false; }); // close when clicking elsewhere
// Western numerals also in what scripts add later (word sidebar, page numbers, suggestions)
function westernDigits(node) {
const walk = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
for (let t = walk.nextNode(); t; t = walk.nextNode()) if (/[۰-۹]/.test(t.data)) t.data = t.data.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0));
}
new MutationObserver((list) => {
if (root.dataset.digits !== 'latn') return;
for (const m of list) for (const n of m.addedNodes) n.nodeType === 3 ? westernDigits(n.parentNode) : n.nodeType === 1 && westernDigits(n);
}).observe(document.body, { childList: true, subtree: true });
marks();
// Urdu on-screen keyboard, Gboard/Windows Urdu layout
const BASE = [[...'طصھدٹپتبجح'], [...'مورنلہاکی'], [...'قفےسشغع']];
const SHIFT = [['ظ','ض','ذ','ڈ','ث','ّ','ۃ','ـ','چ','خ'], ['ژ','ز','ڑ','ں','ۂ','ء','آ','گ',''], ['','','ۓ','','ؤ','ئ','']];
@ -249,7 +98,6 @@ import { siteUrl } from '../lib/site-url';
',':'،', '.':'۔', '?':'؟', ';':'؛',
};
const q = document.getElementById('q'), kb = document.getElementById('kb'), kbBtn = document.getElementById('kb-toggle'), romBtn = document.getElementById('rom-toggle');
const kbHome = [kb.parentElement, kb.nextSibling]; // the keyboard's place in the header
let shift = false;
const key = (ch) => ch ? `<button type="button" data-ch="${ch}">${ch}</button>` : '';
const render = () => {
@ -257,21 +105,15 @@ import { siteUrl } from '../lib/site-url';
kb.innerHTML = `<div>${L[0].map((c) => key(c)).join('')}</div><div>${L[1].map((c) => key(c)).join('')}</div>` +
`<div><button type="button" data-act="shift" class="fn${shift ? ' on' : ''}" aria-label="شفٹ">⇧</button>${L[2].map((c) => key(c)).join('')}` +
'<button type="button" data-act="bs" class="fn" aria-label="مٹائیں">⌫</button></div>' +
`<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}` +
'<button type="button" data-act="close" class="fn" aria-label="کی بورڈ بند کریں" title="بند کریں">✕</button></div>';
`<div>${key('،')}<button type="button" data-ch=" " class="wide">فاصلہ</button>${key('۔')}${key('؟')}</div>`;
};
render();
// the Urdu keyboard and Roman typing work on the search box and on any input marked data-urdu
// (e.g. the author filter): whichever was focused last
let target = q;
document.addEventListener('focusin', (e) => { if (e.target.matches?.('[data-urdu]')) target = e.target; });
// a page may redraw its boxes (e.g. the home sections editor): find the box again by its id
const live = () => (target.isConnected ? target : (target = (target.id && document.getElementById(target.id)) || q));
const typed = () => target.dispatchEvent(new Event('input', { bubbles: true })); // so suggestions update
const insert = (text) => {
live();
// rich editors (the content editor) take typed text through the browser's own editing command
if (target.isContentEditable) { target.focus(); document.execCommand('insertText', false, text); return; }
const s = target.selectionStart ?? target.value.length, e = target.selectionEnd ?? target.value.length;
target.setRangeText(text, s, e, 'end'); target.focus(); typed();
};
@ -279,10 +121,7 @@ import { siteUrl } from '../lib/site-url';
kb.onclick = (ev) => {
const b = ev.target.closest('button'); if (!b) return;
if (b.dataset.act === 'shift') { shift = !shift; render(); return; }
if (b.dataset.act === 'close') { closeKb(); return; }
if (b.dataset.act === 'bs') {
live();
if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; }
const s = target.selectionStart, e = target.selectionEnd;
target.setRangeText('', s === e ? Math.max(0, s - 1) : s, e, 'end'); target.focus(); typed();
return;
@ -292,60 +131,15 @@ import { siteUrl } from '../lib/site-url';
};
kbBtn.onclick = () => {
kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus();
if (kb.hidden) home(); else place();
};
// back to its place in the header (not floating)
const home = () => { kb.classList.remove('kb-float'); document.body.style.paddingBottom = ''; if (kb.parentElement !== kbHome[0]) kbHome[0].insertBefore(kb, kbHome[1]); };
const closeKb = () => {
kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); home();
document.querySelectorAll('[data-urdu-kb][aria-pressed=true]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
};
// For the content editor the keyboard is docked at the bottom of the window (like a phone's) and the page follows
// the writing cursor: the line being written is kept in view just above the keyboard, never under it. The page
// gets room at the bottom while it is open, so the last lines can come up above it.
const caret = () => {
const sel = getSelection();
if (sel?.rangeCount && target.contains(sel.anchorNode)) {
const r = sel.getRangeAt(0), rect = r.getClientRects()[0] ?? r.getBoundingClientRect();
if (rect && (rect.width || rect.height)) return rect;
const el = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
if (el) return el.getBoundingClientRect(); // an empty line has no caret box of its own
}
return null;
};
let placing = 0;
function place() {
if (kb.hidden || !kb.classList.contains('kb-float')) return;
cancelAnimationFrame(placing);
placing = requestAnimationFrame(() => {
document.body.style.paddingBottom = `${kb.offsetHeight + 24}px`;
const c = caret(); if (!c) return;
const room = kb.getBoundingClientRect().top - 16, top = 90; // keep the line between the header area and the keyboard
if (c.bottom > room) scrollBy({ top: c.bottom - room + 40, behavior: 'smooth' });
else if (c.top < top) scrollBy({ top: c.top - top - 40, behavior: 'smooth' });
});
}
document.addEventListener('selectionchange', place);
addEventListener('resize', place);
romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); };
// other inputs can open the same keyboard / Roman typing with their own buttons
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
// a field's own keyboard opens right under that field (in view while typing, also in a dialog), and goes back to
// the header when closed; open under another field, it moves here
if (b.dataset.urduKb && target.isContentEditable) { kb.classList.add('kb-float'); document.body.append(kb); } // the editor: floating
else if (b.dataset.urduKb) {
const at = target.closest('.in-box-field') ?? target;
if (!kb.hidden && kb.previousElementSibling !== at) closeKb();
kb.classList.remove('kb-float'); at.after(kb);
}
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom);
(b.dataset.urduKb ? kbBtn : romBtn).click();
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
});
document.addEventListener('close', (ev) => { // a dialog closing takes nothing of the header's with it
if (ev.target.contains?.(kb)) closeKb();
}, true);
document.addEventListener('keydown', (ev) => {
if (!ev.target.matches?.('[data-urdu]')) return;
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
@ -379,7 +173,7 @@ import { siteUrl } from '../lib/site-url';
document.addEventListener('click', async (e) => {
const b = e.target.closest('[data-save]'); if (!b) return;
if (getSelection()?.toString().trim()) return; // selecting text, not pressing
if (b.dataset.confirm && !(await ask(b.dataset.confirm, { danger: true, ok: 'ہٹائیں' }))) return;
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
const item = JSON.parse(b.dataset.save), d = await save(item);
if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page
else if (d) {
@ -532,18 +326,14 @@ import { siteUrl } from '../lib/site-url';
clearTimeout(selTimer);
selTimer = setTimeout(() => {
const sel = getSelection();
// only the reading text (poems and prose, intros, e-book text, search snippets, library couplets), never the
// site's menus, buttons, tabs or forms
const at = sel && !sel.isCollapsed && sel.anchorNode?.parentElement;
const inText = at && main.contains(at) && at.closest('article, .intro, .ebook-text, .snippet, .lib-couplet')
&& !at.closest('form, input, textarea, [contenteditable], button, nav, .admin-nav');
const inText = sel && !sel.isCollapsed && main.contains(sel.anchorNode) && !sel.anchorNode.parentElement?.closest('form, input, textarea');
selText = inText ? sel.toString().replace(/\u0614/g, '').trim() : '';
if (!selText) return hideMenu();
const w = oneWord(selText);
const block = sel.anchorNode.parentElement?.closest('[data-c]');
source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.c) } : {};
menu.querySelector('[data-act=saveword]').hidden = !w;
menu.querySelector('[data-act=bookmark]').hidden = menu.querySelector('[data-act=note]').hidden = !(source.poemId && Number.isInteger(source.couplet));
menu.querySelector('[data-act=bookmark]').hidden = !(source.poemId && Number.isInteger(source.couplet));
menu.querySelector('[data-act=meaning]').hidden = !w;
menu.hidden = false;
// below the selection, centred, kept inside the viewport (absolute: moves with the page)
@ -566,7 +356,6 @@ import { siteUrl } from '../lib/site-url';
case 'meaning': showWord(oneWord(selText)); break;
case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText })
.then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break;
case 'note': hideMenu(); document.dispatchEvent(new CustomEvent('divan:note', { detail: { couplet: source.couplet, phrase: selText } })); break; // the poem page opens its note box
case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); break;
case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;
@ -587,76 +376,11 @@ import { siteUrl } from '../lib/site-url';
if (!menu.hidden) hideMenu(); else if (!dict.hidden) closeDict();
});
</script>
<!-- shared UI: the confirm dialog (instead of the browser's English confirm), toasts, busy buttons -->
<dialog class="ask" id="ask" aria-labelledby="ask-msg">
<form method="dialog">
<p id="ask-msg"></p>
<div class="ask-actions"><button class="btn primary" value="ok">ہاں</button><button class="btn" value="">نہیں</button></div>
</form>
</dialog>
<div class="toasts" aria-live="polite"></div>
<script is:inline>
// the sticky header: its height for anchors and toolbars (--header-h), and a shadow once the page is scrolled
(() => {
const root = document.documentElement, header = document.querySelector('.site-header');
new ResizeObserver(() => root.style.setProperty('--header-h', header.offsetHeight + 'px')).observe(header);
// phones: the header steps aside while scrolling down and returns on the way up (not while it is in use)
const phone = matchMedia('(max-width: 640px)');
let last = scrollY;
const mark = () => {
scrollY > 8 ? (root.dataset.scrolled = '') : delete root.dataset.scrolled;
const busy = header.contains(document.activeElement) || header.querySelector('details[open]') || !document.getElementById('kb').hidden;
if (!phone.matches || busy || scrollY < header.offsetHeight) delete root.dataset.headerHidden;
else if (scrollY > last + 6) root.dataset.headerHidden = '';
else if (scrollY < last - 6) delete root.dataset.headerHidden;
if (Math.abs(scrollY - last) > 6) last = scrollY;
};
addEventListener('scroll', mark, { passive: true }); mark();
})();
</script>
<script is:inline>
// reading mode: only the text, the breadcrumbs and the logo; remembered across pages until switched off
document.addEventListener('click', (e) => {
if (!e.target.closest('[data-reading-toggle]')) return;
const root = document.documentElement, on = !('reading' in root.dataset);
const flip = () => { on ? (root.dataset.reading = '') : delete root.dataset.reading; };
document.startViewTransition ? document.startViewTransition(flip) : flip();
try { on ? localStorage.setItem('divan-reading', '1') : localStorage.removeItem('divan-reading'); } catch (err) {}
});
</script>
<script is:inline>
(() => {
const dlg = document.getElementById('ask'), ok = dlg.querySelector('[value=ok]');
// ask(message, {danger, ok}) -> Promise<boolean>; the destructive answer is red and named for what it does
window.ask = (message, o = {}) => new Promise((done) => {
dlg.querySelector('#ask-msg').textContent = message;
ok.textContent = o.ok || 'ہاں'; ok.classList.toggle('danger', !!o.danger); ok.classList.toggle('primary', !o.danger);
dlg.returnValue = ''; dlg.showModal(); dlg.querySelector('[value=""]').focus();
dlg.addEventListener('close', () => done(dlg.returnValue === 'ok'), { once: true });
});
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); // a click outside the box is "no"
// toast(message, kind): slides in, leaves by itself
const box = document.querySelector('.toasts');
window.toast = (message, kind = 'ok') => {
const t = document.createElement('p'); t.className = 'toast ' + kind; t.textContent = message; box.append(t);
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 350); }, 4200); // removed on a timer: with reduced motion there is no animation to end
};
// a page's "done" line becomes a toast
document.querySelectorAll('.form-done').forEach((p) => { if (p.textContent.trim()) { toast(p.textContent.trim()); p.hidden = true; } });
// forms: data-confirm asks first; a submitting button shows it is busy
document.addEventListener('submit', async (e) => {
const f = e.target; if (f.method === 'dialog') return;
const btn = e.submitter;
if (f.dataset.confirm && !f.dataset.asked) {
e.preventDefault();
if (!(await ask(f.dataset.confirm, { danger: 'danger' in f.dataset, ok: f.dataset.ok }))) return;
f.dataset.asked = '1'; f.requestSubmit(btn); delete f.dataset.asked; return;
}
if (btn && !e.defaultPrevented && !f.hasAttribute('data-js')) btn.setAttribute('aria-busy', 'true');
});
addEventListener('pageshow', () => document.querySelectorAll('[aria-busy=true]').forEach((b) => b.removeAttribute('aria-busy'))); // back button
})();
document.getElementById('theme-toggle').onclick = () => {
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
root.dataset.theme = dark ? 'light' : 'dark';
try { localStorage.setItem('divan-theme', root.dataset.theme); } catch (e) {}
};
</script>
</body>
</html>

View File

@ -13,7 +13,6 @@ export async function auth(path: string, opts: { token?: string; body?: object;
...(opts.body && { 'content-type': 'application/json' }),
...(opts.token && { authorization: `Bearer ${opts.token}` }),
...(opts.ip && { 'x-client-ip': opts.ip }),
...(process.env.DIVAN_SITE_KEY && { 'x-site-key': process.env.DIVAN_SITE_KEY }), // lets the API trust x-client-ip
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
});

View File

@ -1,11 +0,0 @@
// The site's name and taglines in one place. They are Divan's brand (TRADEMARKS.md): a fork that runs its own
// public site sets its own here, replaces the logo files, and keeps the attribution required by NOTICE.
export const brand = {
name: 'دیوان',
tagline: 'اردو کا کلاسیکی ادب',
madeBy: 'ایک پاکستانی کی طرف سے، پاکستان کے لیے', // footer, beside Pakistan's flag
// where readers write: questions, corrections, copyright claims (privacy and terms pages)
contact: { text: 'ہم سے رابطہ کریں', url: 'https://git.anasrashid.net/anas/divan/issues' },
// the attribution forks keep (NOTICE, GPL v3 section 7(b))
basedOn: { text: 'Divan by Muhammad Anas Rashid', url: 'https://git.anasrashid.net/anas/divan' },
};

View File

@ -1,22 +0,0 @@
// A book's cover, made in the browser: a PDF's first page (pdf.js) or an EPUB's own cover image (epub.js).
// The server has no PDF tools; the moderator's browser makes the cover once, when the book is added.
export async function makeCover(data: ArrayBuffer, kind: 'pdf' | 'epub'): Promise<Blob | null> {
if (kind === 'pdf') {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
const page = await (await pdfjs.getDocument({ data: new Uint8Array(data) }).promise).getPage(1);
const viewport = page.getViewport({ scale: 1 }), scale = 480 / viewport.width; // 480px wide thumbnails
const v = page.getViewport({ scale }), canvas = document.createElement('canvas');
canvas.width = Math.round(v.width); canvas.height = Math.round(v.height);
await page.render({ canvasContext: canvas.getContext('2d')!, viewport: v, canvas } as any).promise;
return new Promise((done) => canvas.toBlob(done, 'image/jpeg', 0.85));
}
const { default: ePub } = await import('epubjs');
const url = await ePub(data).coverUrl();
return url ? (await fetch(url)).blob() : null;
}
export async function sendCover(id: number | string, cover: Blob) {
const res = await fetch(`/ebooks/cover/${id}`, { method: 'POST', body: cover, headers: { 'content-type': 'application/octet-stream' } });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'سرورق محفوظ نہیں ہوا');
}

View File

@ -1,163 +0,0 @@
// The Urdu content editor (#28) on Tiptap (MIT core, ProseMirror). It edits Divan text as structure:
// unit a verse unit of misras: 1 misra = مصرع, 2 = شعر (couplet), 3 or more = بند (stanza), as in Divan text;
// attribute label = مطلع | حسن مطلع | مقطع
// para a prose paragraph heading a chapter (level 2) or sub-heading (3, 4) title
// dict a word linked to the dictionary (mark, attribute lemma)
// note a footnote, variant a variant reading (inline atoms)
// Enter adds a misra to the unit (or moves into the empty next one); Enter on an empty misra starts a new unit
// (a blank line in Divan text).
// toDoc / fromEditor convert between Divan text and the editor, so the editor and the source view always agree.
import { Editor, Mark, Node, mergeAttributes } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Text from '@tiptap/extension-text';
import { Placeholder, UndoRedo } from '@tiptap/extensions';
import { parse, toText, type Doc, type Segment } from '../../../api/src/divantext.ts';
const Unit = Node.create({
name: 'unit', group: 'block', content: 'misra+', defining: true,
addAttributes: () => ({ label: { default: null, parseHTML: (e) => e.getAttribute('data-mark'), renderHTML: (a) => (a.label ? { 'data-mark': a.label } : {}) } }),
parseHTML: () => [{ tag: 'div.unit' }],
renderHTML: ({ HTMLAttributes }) => ['div', mergeAttributes(HTMLAttributes, { class: 'unit' }), 0],
addKeyboardShortcuts() {
return {
// Enter on an empty misra: end this unit and start a new one (like a blank line in Divan text)
Enter: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty || $from.parent.type.name !== 'misra') return false;
const unit = $from.node(-1), next = $from.index(-1) + 1;
// at the end of a misra followed by an empty one (a new couplet's second misra): move into it
if ($from.parentOffset === $from.parent.content.size && next < unit.childCount && !unit.child(next).content.size)
return editor.commands.setTextSelection($from.after() + 1);
if ($from.parent.content.size || unit.childCount < 2) return false;
const misraPos = $from.before(), unitEnd = $from.after(-1);
return editor.chain().command(({ tr }) => {
tr.delete(misraPos, misraPos + $from.parent.nodeSize);
return true;
}).insertContentAt(unitEnd - $from.parent.nodeSize, { type: 'unit', content: [{ type: 'misra' }] }).focus(unitEnd - $from.parent.nodeSize + 2).run();
},
};
},
});
const Misra = Node.create({
name: 'misra', content: 'inline*',
parseHTML: () => [{ tag: 'p.misra' }], renderHTML: () => ['p', { class: 'misra' }, 0],
});
const Para = Node.create({
name: 'para', group: 'block', content: 'inline*',
parseHTML: () => [{ tag: 'p.para' }], renderHTML: () => ['p', { class: 'para' }, 0],
});
const Heading = Node.create({
name: 'heading', group: 'block', content: 'text*', defining: true,
addAttributes: () => ({ level: { default: 2 } }),
parseHTML: () => [2, 3, 4].map((l) => ({ tag: `h${l + 1}`, attrs: { level: l } })),
renderHTML: ({ node }) => [`h${node.attrs.level + 1}`, { class: node.attrs.level > 2 ? 'mod-h sub' : 'mod-h' }, 0],
});
const Dict = Mark.create({
name: 'dict', inclusive: false,
addAttributes: () => ({ lemma: { default: null } }),
parseHTML: () => [{ tag: 'span.dv-dict' }],
renderHTML: ({ mark }) => ['span', { class: 'dv-dict', title: `لغت: ${mark.attrs.lemma}` }, 0],
});
const Note = Node.create({
name: 'note', group: 'inline', inline: true, atom: true,
addAttributes: () => ({ text: { default: '' } }),
parseHTML: () => [{ tag: 'sup.note' }],
renderHTML: ({ node }) => ['sup', { class: 'note', title: node.attrs.text, 'data-note': node.attrs.text }, '*'],
});
const Variant = Node.create({
name: 'variant', group: 'inline', inline: true, atom: true,
addAttributes: () => ({ shown: { default: '' }, others: { default: [] }, source: { default: null } }),
parseHTML: () => [{ tag: 'span.variant' }],
renderHTML: ({ node }) => ['span', { class: 'variant', title: `دوسرے نسخے: ${node.attrs.others.join('، ')}${node.attrs.source ? ` (${node.attrs.source})` : ''}` }, node.attrs.shown],
});
// ---- Divan text <-> editor content ----
const inlineContent = (segs: Segment[]) => segs.flatMap((s): any[] =>
'note' in s ? [{ type: 'note', attrs: { text: s.note } }]
: 'variant' in s ? [{ type: 'variant', attrs: { shown: s.variant.shown, others: s.variant.others, source: s.variant.source ?? null } }]
: !s.text ? [] : 'lemma' in s ? [{ type: 'text', text: s.text, marks: [{ type: 'dict', attrs: { lemma: s.lemma } }] }]
: [{ type: 'text', text: s.text }]);
export function toEditor(doc: Doc) {
const content = doc.blocks.map((b): any =>
b.type === 'heading' ? { type: 'heading', attrs: { level: b.level }, content: b.text ? [{ type: 'text', text: b.text }] : [] }
: b.type === 'para' ? { type: 'para', content: inlineContent(b.line.segments) }
: { type: 'unit', attrs: { label: b.label ?? null }, content: b.lines.map((l) => ({ type: 'misra', content: inlineContent(l.segments) })) });
return { type: 'doc', content: content.length ? content : [{ type: 'unit', content: [{ type: 'misra' }, { type: 'misra' }] }] };
}
const segments = (node: any): Segment[] => (node.content ?? []).map((n: any): Segment =>
n.type === 'note' ? { note: n.attrs.text }
: n.type === 'variant' ? { variant: { shown: n.attrs.shown, others: n.attrs.others, ...(n.attrs.source && { source: n.attrs.source }) } }
: n.marks?.find((m: any) => m.type === 'dict') ? { text: n.text, lemma: n.marks.find((m: any) => m.type === 'dict').attrs.lemma }
: { text: n.text });
const lineOf = (node: any) => ({ text: '', words: [], notes: [], variants: [], segments: segments(node) });
// editor content (+ the work's data points) -> Divan text; empty misras and paragraphs are dropped
export function fromEditor(json: any, meta: Record<string, string>) {
const blocks: Doc['blocks'] = [];
for (const n of json.content ?? []) {
const hasText = (x: any) => (x.content ?? []).some((c: any) => c.type !== 'text' || c.text.trim());
if (n.type === 'heading') { const t = (n.content ?? []).map((c: any) => c.text).join('').trim(); if (t) blocks.push({ type: 'heading', text: t, level: n.attrs.level }); }
else if (n.type === 'para') { if (hasText(n)) blocks.push({ type: 'para', line: lineOf(n) }); }
else {
const lines = (n.content ?? []).filter(hasText).map(lineOf);
if (lines.length) blocks.push({ type: lines.length === 2 ? 'couplet' : lines.length === 1 ? 'line' : 'stanza', lines, ...(n.attrs?.label && { label: n.attrs.label }) });
}
}
return toText({ meta, blocks });
}
export function createEditor(element: HTMLElement, text: string) {
const doc = parse(text);
const editor = new Editor({
element,
extensions: [Document, Text, Unit, Misra, Para, Heading, Dict, Note, Variant, UndoRedo,
Placeholder.configure({ placeholder: ({ node }) => (node.type.name === 'misra' ? 'مصرع' : node.type.name === 'para' ? 'پیراگراف' : 'عنوان') })],
content: toEditor(doc),
editorProps: { attributes: { class: 'dv-editor poem', dir: 'rtl', 'data-urdu': '', spellcheck: 'false' } },
});
return { editor, meta: doc.meta };
}
// toolbar actions on the block at the cursor
export const actions = {
couplet: (e: Editor) => insertAfter(e, { type: 'unit', content: [{ type: 'misra' }, { type: 'misra' }] }),
para: (e: Editor) => insertAfter(e, { type: 'para' }),
heading: (e: Editor, level: number) => insertAfter(e, { type: 'heading', attrs: { level } }),
label: (e: Editor, label: string | null) => {
const { $from } = e.state.selection;
for (let d = $from.depth; d > 0; d--) if ($from.node(d).type.name === 'unit') {
const pos = $from.before(d), cur = $from.node(d).attrs.label;
return e.chain().command(({ tr }) => { tr.setNodeMarkup(pos, undefined, { label: cur === label ? null : label }); return true; }).focus().run();
}
return false;
},
dict: (e: Editor) => {
if (e.isActive('dict')) return e.chain().focus().unsetMark('dict').run();
const { from, to } = e.state.selection;
const word = e.state.doc.textBetween(from, to).trim();
if (!word) return alert('پہلے لفظ منتخب کریں');
const lemma = prompt('لغت میں لفظ کی بنیادی شکل', word);
if (lemma) e.chain().focus().setMark('dict', { lemma: lemma.trim() }).run();
},
note: (e: Editor) => {
const text = prompt('حاشیہ');
if (text?.trim()) e.chain().focus().insertContent({ type: 'note', attrs: { text: text.trim() } }).run();
},
variant: (e: Editor) => {
const { from, to } = e.state.selection;
const shown = (e.state.doc.textBetween(from, to).trim() || prompt('متن میں دکھایا جانے والا لفظ')) ?? '';
const others = prompt('دوسرے نسخے کا متن (کئی ہوں تو ، سے الگ کریں)');
if (!shown || !others) return;
const source = prompt('دوسرے نسخے کا نام') || null;
e.chain().focus().insertContentAt({ from, to }, { type: 'variant', attrs: { shown, others: others.split('،').map((s) => s.trim()).filter(Boolean), source } }).run();
},
};
function insertAfter(e: Editor, node: any) {
const { $from } = e.state.selection;
const pos = $from.depth > 0 ? $from.after(1) : e.state.doc.content.size;
return e.chain().insertContentAt(pos, node).focus(pos + (node.type === 'unit' ? 2 : 1)).run();
}

View File

@ -1,9 +0,0 @@
// Urdu labels for moderation (api/src/moderation.ts)
export const STATUS: Record<string, string> = {
draft: 'مسودہ', submitted: 'جائزے کا منتظر', approved: 'منظور شدہ، اشاعت کا منتظر', published: 'شائع شدہ', returned: 'واپس بھیجا گیا', rejected: 'مسترد',
};
export const EVENT: Record<string, string> = {
created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا',
rejected: 'مسترد کیا', published: 'شائع کیا', reverted: 'پرانا ورژن واپس لایا',
};
export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');

View File

@ -1,72 +0,0 @@
// The PDF reader (pdf.js), right to left as an Urdu book: the next page is to the left (button, ← key, a click on
// the left half), and on wide screens pages face each other with the cover alone, then 2|3 with page 2 on the
// right. A book marked left-to-right (dir="ltr" on the reader, e.g. English) turns the other way. Pages are fetched by byte range as they are shown, so a large scan starts quickly. Signed-in readers
// bookmark pages (the right-hand page of a spread) in their library; their bookmarks jump to the page.
export async function pdfReader(root: HTMLElement) {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
const rtl = root.dir !== 'ltr';
const pagesBox = root.querySelector<HTMLElement>('.pdf-pages')!, input = root.querySelector<HTMLInputElement>('.pdf-page')!;
const doc = await pdfjs.getDocument({ url: root.dataset.src!, disableAutoFetch: true, disableStream: true, rangeChunkSize: 262144 }).promise;
root.querySelector('.pdf-total')!.textContent = String(doc.numPages).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[+d]);
input.max = String(doc.numPages);
const wide = () => pagesBox.clientWidth > 900;
// the pages shown together, starting with page p
const spread = (p: number) => (!wide() || p === 1 ? [p] : [p % 2 ? p - 1 : p, (p % 2 ? p - 1 : p) + 1].filter((n) => n >= 2 && n <= doc.numPages));
let page = 1, job = 0;
async function show(p: number) {
page = Math.min(Math.max(1, p), doc.numPages);
const nums = spread(page), mine = ++job;
input.value = String(nums[0]);
const canvases = await Promise.all(nums.map(async (n) => {
const pg = await doc.getPage(n), base = pg.getViewport({ scale: 1 });
const tall = document.fullscreenElement === root ? window.innerHeight - 70 : window.innerHeight * 0.82;
const fit = Math.min((pagesBox.clientWidth - 8 * nums.length) / nums.length / base.width, tall / base.height);
const v = pg.getViewport({ scale: fit * devicePixelRatio }), c = document.createElement('canvas');
c.width = Math.floor(v.width); c.height = Math.floor(v.height);
c.style.width = `${Math.floor(v.width / devicePixelRatio)}px`; c.style.height = `${Math.floor(v.height / devicePixelRatio)}px`;
await pg.render({ canvasContext: c.getContext('2d')!, viewport: v, canvas: c } as any).promise;
return c;
}));
if (mine === job) { pagesBox.replaceChildren(...canvases); marks(); } // the container is RTL: the first page is on the right
}
const step = (d: number) => { const nums = spread(page); show(d > 0 ? nums[nums.length - 1] + 1 : spread(Math.max(1, nums[0] - 1))[0]); };
root.querySelector<HTMLButtonElement>('.pdf-next')!.onclick = () => step(1);
root.querySelector<HTMLButtonElement>('.pdf-prev')!.onclick = () => step(-1);
input.onchange = () => show(Number(input.value) || 1);
// page bookmarks
const markBtn = root.querySelector<HTMLButtonElement>('.pdf-mark'), list = root.querySelector<HTMLElement>('.pdf-marks');
let saved: number[] = JSON.parse(root.dataset.marks ?? '[]');
const digits = (n: number) => String(n).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[+d]);
function marks() {
if (!markBtn || !list) return;
const on = saved.includes(spread(page)[0]);
markBtn.setAttribute('aria-pressed', String(on)); markBtn.textContent = on ? 'صفحہ محفوظ شدہ' : 'صفحہ محفوظ کریں';
list.replaceChildren(...saved.map((n) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tag-chip'; b.textContent = `صفحہ ${digits(n)}`; b.onclick = () => show(n); return b; }));
list.hidden = !saved.length;
}
markBtn?.addEventListener('click', async () => {
if (!document.body.dataset.user) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return; }
const p = spread(page)[0];
const r = await fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'page', ebookId: Number(root.dataset.ebook), page: p }) });
if (!r.ok) return;
saved = (await r.json()).saved ? [...saved, p].sort((a, b) => a - b) : saved.filter((n) => n !== p);
marks();
});
// the whole screen for the reader (the page is redrawn to the new size)
const full = root.querySelector<HTMLButtonElement>('.pdf-full')!;
const toggle = () => (document.fullscreenElement ? document.exitFullscreen() : root.requestFullscreen()).catch(() => {});
full.onclick = toggle;
document.addEventListener('fullscreenchange', () => { const on = document.fullscreenElement === root; full.title = on ? 'پوری اسکرین سے واپس (F)' : 'پوری اسکرین (F)'; full.setAttribute('aria-label', on ? 'پوری اسکرین سے واپس' : 'پوری اسکرین'); show(page); });
// a click on the half the book turns towards goes forward
pagesBox.onclick = (e) => { const left = e.clientX < pagesBox.getBoundingClientRect().left + pagesBox.clientWidth / 2; step(left === rtl ? 1 : -1); };
document.addEventListener('keydown', (e) => {
if ((e.target as HTMLElement).closest('input, textarea')) return;
if (e.key === 'ArrowLeft') step(rtl ? 1 : -1);
if (e.key === 'ArrowRight') step(rtl ? -1 : 1);
if (e.key === 'f' || e.key === 'F') toggle();
});
let resize = 0;
window.addEventListener('resize', () => { clearTimeout(resize); resize = window.setTimeout(() => show(page), 200); });
await show(Number(new URLSearchParams(location.search).get('page')) || 1); // ?page= from a bookmark
}

View File

@ -1,19 +1,5 @@
import { ud } from './urdu';
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
export const ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)', tags: 'ٹیگ', ebooks: 'ای بکس' };
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت' };
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };
// what each role may do, one line each (the user page's role picker)
export const ROLE_HINT: Record<string, string> = {
reader: 'پڑھ سکتے ہیں، محفوظ کر سکتے ہیں',
'mod-l2': 'دی گئی اجازتوں میں ترمیم بھیج سکتے ہیں',
'mod-l1': 'اپنے دائرے میں ترامیم منظور یا واپس کر سکتے ہیں',
admin: 'سب کچھ: صارفین، اجازتیں، اشاعت',
};
// a date as "today / yesterday / n days ago" for the last few weeks, the date after that
export const ago = (d: string | null) => {
if (!d) return 'کبھی نہیں';
const days = Math.floor((Date.now() - new Date(d).getTime()) / 864e5);
return days < 1 ? 'آج' : days < 2 ? 'کل' : days < 30 ? `${ud(days)} دن پہلے` : ud(new Date(d).toISOString().slice(0, 10));
};

View File

@ -1,4 +0,0 @@
// The site's public address for links in feeds and the sitemap: SITE_URL if set (e.g. https://divan.pk), else the
// address the request came to (behind Caddy that is the public one).
export const siteUrl = (url: URL) => (process.env.SITE_URL ?? url.origin).replace(/\/$/, '');
export const xml = (s: unknown) => String(s ?? '').replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c]!);

View File

@ -11,11 +11,8 @@ export const centuryName = (c: number | null) => (c ? `${ORDINALS[c] ?? ud(c)}
const span = (a: number | null, b: number | null, mark: string) =>
a || b ? `${a ? ud(a) : '؟'} – ${b ? ud(b) : '؟'} ${mark}` : '';
// Hijri and Gregorian (عیسوی): "۱۲۹۴ – ۱۳۵۷ ھ · ۱۸۷۷ – ۱۹۳۸ ء"
type Years = { birth_year_ah: number | null; death_year_ah: number | null; birth_year_ce?: number | null; death_year_ce?: number | null };
// Hijri and Gregorian spans, each on its own (poet cards show them as two lines)
export const yearLines = (p: Years) =>
[span(p.birth_year_ah, p.death_year_ah, 'ھ'), span(p.birth_year_ce ?? null, p.death_year_ce ?? null, 'ء')].filter(Boolean) as string[];
export const years = (p: Years) => yearLines(p).join(' · ');
export const years = (p: { birth_year_ah: number | null; death_year_ah: number | null; birth_year_ce?: number | null; death_year_ce?: number | null }) =>
[span(p.birth_year_ah, p.death_year_ah, 'ھ'), span(p.birth_year_ce ?? null, p.death_year_ce ?? null, 'ء')].filter(Boolean).join(' · ');
// search highlighting (same matching rules as the API's search)
export { highlighter } from '../../../api/src/urdu.ts';

View File

@ -1,24 +1,7 @@
// The signed-in reader (or null) for every page, from the session cookie; and the reader's numerals: pages are
// written with Eastern Arabic (Urdu) digits, and a reader who chose Western digits (cookie divan-digits=latn, set in
// the display settings) gets them in the page as sent, so nothing flashes. Errors are logged as one JSON line each.
// The signed-in reader (or null) for every page, from the session cookie
import { defineMiddleware } from 'astro:middleware';
import { auth, COOKIE } from './lib/auth';
// visitor statistics (#86): each reading page shown is reported to the API without waiting; the API keeps only counts
// and a one-day hash. Bots, link previews, private pages (accounts, moderation) and errors are not counted.
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
const PRIVATE = /^\/(api|mod|admin|account|library|notes|words|writers|signin|signup|_astro|ebooks\/file|health)(\/|$)/;
const BOT = /bot|crawl|spider|slurp|preview|fetch|curl|wget|python|java\/|go-http|headless|lighthouse|monitor/i;
function count(ctx: any, res: Response) {
const ua = ctx.request.headers.get('user-agent') ?? '', path = ctx.url.pathname;
if (ctx.request.method !== 'GET' || res.status !== 200 || !res.headers.get('content-type')?.includes('text/html') || !ua || BOT.test(ua) || PRIVATE.test(path)) return;
let ip = '', ref = '';
try { ip = ctx.clientAddress; } catch {}
try { const h = new URL(ctx.request.headers.get('referer') ?? '').hostname; if (h !== ctx.url.hostname) ref = h.replace(/^www\./, ''); } catch {}
fetch(`${API}/api/stats/hit`, { method: 'POST', headers: { 'content-type': 'application/json', ...(process.env.DIVAN_SITE_KEY && { 'x-site-key': process.env.DIVAN_SITE_KEY }) },
body: JSON.stringify({ path, ref, ip, ua }) }).catch(() => {});
}
export const onRequest = defineMiddleware(async (ctx, next) => {
const token = ctx.cookies.get(COOKIE)?.value;
ctx.locals.user = null;
@ -27,20 +10,5 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
if (r.ok) ctx.locals.user = r.data.user;
else if (r.status === 401) ctx.cookies.delete(COOKIE, { path: '/' }); // expired or signed out elsewhere
}
let res: Response;
try {
res = await next();
} catch (err: any) {
// one JSON line per error (the server's error report reads these); the reader sees the Urdu error page (500.astro)
console.error(JSON.stringify({ level: 'error', time: new Date().toISOString(), method: ctx.request.method, path: ctx.url.pathname,
msg: String(err?.message ?? err), stack: String(err?.stack ?? '').split('\n').slice(0, 8).join('\n') }));
throw err;
}
count(ctx, res);
if (ctx.cookies.get('divan-digits')?.value !== 'latn' || !res.headers.get('content-type')?.includes('text/html')) return res;
// text only: scripts and styles are left alone (the page's own scripts look for Eastern digits)
const html = (await res.text()).split(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>)/)
.map((part, i) => (i % 2 ? part : part.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0)))).join('');
const headers = new Headers(res.headers); headers.delete('content-length');
return new Response(html, { status: res.status, statusText: res.statusText, headers });
return next();
});

View File

@ -1,18 +0,0 @@
---
// صفحہ نہیں ملا: any address that is not a page, and pages that answer "not found" (a missing poet, work or book)
import Base from '../layouts/Base.astro';
import Icon from '../components/Icon.astro';
Astro.response.status = 404;
---
<Base title="صفحہ نہیں ملا">
<section class="error-page">
<p class="error-code" aria-hidden="true">۴۰۴</p>
<h1>یہ صفحہ نہیں ملا</h1>
<p class="muted">ہو سکتا ہے پتہ بدل گیا ہو یا اس میں کوئی غلطی ہو۔ اوپر کے خانے میں تلاش کیجیے، یا یہاں سے شروع کیجیے:</p>
<p class="error-links">
<a class="btn primary" href="/"><Icon name="home" size={18} /> سرورق</a>
<a class="btn" href="/search"><Icon name="search" size={18} /> تلاش</a>
<a class="btn" href="/tags"><Icon name="tag" size={18} /> ٹیگ</a>
</p>
</section>
</Base>

View File

@ -1,16 +0,0 @@
---
// کچھ غلط ہو گیا: an error in the site; nothing internal is shown (the error is in the server's log, see /health)
import Base from '../layouts/Base.astro';
import Icon from '../components/Icon.astro';
---
<Base title="کچھ غلط ہو گیا">
<section class="error-page">
<p class="error-code" aria-hidden="true">!</p>
<h1>معذرت، کچھ غلط ہو گیا</h1>
<p class="muted">یہ ہماری طرف کی خرابی ہے اور درج ہو گئی ہے۔ تھوڑی دیر بعد صفحہ دوبارہ کھولیے۔</p>
<p class="error-links">
<a class="btn primary" href="javascript:location.reload()"><Icon name="undo" size={18} /> دوبارہ کوشش کریں</a>
<a class="btn" href="/"><Icon name="home" size={18} /> سرورق</a>
</p>
</section>
</Base>

View File

@ -2,7 +2,6 @@
// Poet (/p238), category (/p238/shaeri/c641) and poem (/p238/shaeri/c641/sh6556) pages
import Base from '../layouts/Base.astro';
import Seg from '../components/Seg.astro';
import Tags from '../components/Tags.astro';
import { api } from '../lib/api';
import { asUser, COOKIE } from '../lib/auth';
import { phraseParts, phraseRe } from '../lib/phrases';
@ -53,57 +52,24 @@ const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
const categoryId = page.type === 'category' ? page.category.id : undefined;
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data
: { poet: false, category: false, poem: false, couplets: [], phrases: [], notes: [] };
// the reader's notes on this work: on a couplet (phrase null) or on a bookmarked phrase in it
const notes: { couplet: number; phrase: string | null; note: string }[] = saved.notes ?? [];
const noteOf = (c: number) => notes.find((n) => n.couplet === c && !n.phrase)?.note;
: { poet: false, category: false, poem: false, couplets: [], phrases: [] };
// on contents pages: which works are bookmarked or hold bookmarks, and which books/chapters do
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
// the marked part of each line of a block, for its bookmarked phrases
const lineMarks = (c: number, lines: string[]) => {
const out: Mark[][] = lines.map(() => []);
const noteOnly = notes.filter((n) => n.couplet === c && n.phrase && !saved.phrases.some((p: any) => p.couplet === c && p.phrase === n.phrase))
.map((n) => ({ couplet: c, phrase: n.phrase, noteOnly: true }));
for (const p of [...saved.phrases.filter((x: any) => x.couplet === c), ...noteOnly]) {
const note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note;
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note, noteOnly: p.noteOnly });
for (const p of saved.phrases.filter((x: any) => x.couplet === c)) {
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase });
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
}
return out;
};
const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'); // bookmark buttons
// moderators who may edit this work get edit and history links
const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41); with the edit permission
// on poets or books, "تفصیل" (a poet's name, years, intro) or "عنوان" (a book or section's title) (#32)
const catCan = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data : {};
const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags;
// tags (#52): the page's own, and each couplet's (tags count couplets from 1, verses from 0)
const pageTags = (page.tags ?? []).filter((t: any) => !t.couplet);
const coupletTags = (c: number) => (page.tags ?? []).filter((t: any) => t.couplet === c + 1);
// every poet has an ای بکس section beside their books and genres (/p17/ebooks)
const ebookCount = page.type === 'poet' ? ((await api<any[]>(`/api/ebooks?poet=${poetId}`)) ?? []).length : 0;
const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`;
const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c });
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
// for search engines and link previews: a work's opening lines, a poet's intro, a book's contents (#83)
const clip = (t: string) => (t.length > 160 ? t.slice(0, 159) + '…' : t);
const description = clip(page.type === 'poem' ? `${blocks[0]?.lines.join(' / ') ?? page.poem.title} (${page.poet.nickname})`
: page.type === 'poet' ? page.poet.description || `${page.poet.nickname}: کلام، کتابیں اور ای بکس`
: `${page.category.title} · ${page.poet.nickname}: ${page.poems?.length ? `${ud(page.poems.length)} تخلیقات` : `${ud(page.children?.length ?? 0)} حصے`}`);
const here = siteUrl(Astro.url) + url, poetLd = { '@type': 'Person', name: page.poet?.nickname, url: siteUrl(Astro.url) + (crumbs[0]?.url ?? '') };
const jsonld = { '@context': 'https://schema.org', inLanguage: 'ur', url: here, ...(
page.type === 'poem' ? { '@type': 'CreativeWork', name: page.poem.title, author: poetLd, isPartOf: crumbs.at(-1)?.title }
: page.type === 'poet' ? { ...poetLd, url: here, description: page.poet.description || undefined }
: { '@type': 'CollectionPage', name: page.category.title, author: poetLd }) };
import { siteUrl } from '../lib/site-url';
import Icon from '../components/Icon.astro';
import TagsBox from '../components/TagsBox.astro';
---
<Base author={page.poet?.nickname} jsonld={jsonld} title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={description} ogType={page.type === 'poem' ? 'article' : 'website'}>
<Base title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={page.type === 'poet' ? page.poet.description?.slice(0, 160) : undefined}>
{page.type !== 'poet' && (
<nav class="crumbs">
{crumbs.map((c: any, i: number) => (
@ -114,27 +80,24 @@ import TagsBox from '../components/TagsBox.astro';
{page.type === 'poet' && (
<>
<h1 class="lit" data-readable>{page.poet.name}</h1>
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.poet && <> <form method="post" action={`/mod/details/poet/${poetId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> تفصیل</button></form></>}</div>
<h1>{page.poet.name}</h1>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button></p>
<p class="muted">{years(page.poet)}</p>
{page.poet.description && <div class="intro">{page.poet.description.split(/\n\s*\n/).map((p: string) => <p>{p}</p>)}</div>}
{page.poet.description && <div class="intro">{page.poet.description}</div>}
</>
)}
{page.type === 'category' && (
<>
<h1 class="lit" data-readable>{page.category.title}</h1>
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.book && <> <form method="post" action={`/mod/details/book/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> عنوان</button></form></>}</div>
<h1>{page.category.title}</h1>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button></p>
</>
)}
{page.type !== 'poem' && (page.children.length > 0 || page.type === 'poet') && (
{page.type !== 'poem' && page.children.length > 0 && (
<div class="cards">
{page.children.map((c: any) => (
<a class={`card${marks.categories.includes(c.id) ? ' mark-bm' : ''}`} href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
))}
{page.type === 'poet' && <a class="card" href={`${page.poet.url}/ebooks`}>ای بکس{ebookCount > 0 && <small>{ud(ebookCount)}</small>}</a>}
</div>
)}
{page.type !== 'poem' && groups.map((g) => (
@ -143,76 +106,31 @@ import TagsBox from '../components/TagsBox.astro';
<ol class="toc">{g.poems.map((p: any) => <li><a class={glyph(p.id)} href={p.url}>{p.title}</a></li>)}</ol>
</>
))}
{page.type !== 'poem' && <TagsBox tags={pageTags} />}
{page.type === 'poem' && (
<>
<article class="work" data-readable>
<h1 class="lit"><Seg s={show(page.poem.title)} /></h1>
<p class="byline">{page.poet.nickname}</p>
<h1><Seg s={show(page.poem.title)} /></h1>
{re && (
<p class="hl-note muted">
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
</p>
)}
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> ترمیم کریں</button></form>
<a class="icon-btn small" href={`/mod/work/${poemId}`} title="تاریخچہ" aria-label="تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button></p>
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
<div class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
{blocks.map((b) => {
const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c);
// the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note)
const bm = <span class="c-tools">
{/* wide screens: four icons on hover; phones: one ⋯ that opens a menu (the CSS shows one or the other) */}
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />
<button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
<button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
<button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" />
<button type="button" class="more" data-for={b.c} data-bm={saved.couplets.includes(b.c) ? '' : undefined} data-nt={any ? '' : undefined}
aria-haspopup="menu" aria-label="محفوظ، نوٹ، بھیجیں" title={cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'}><i class="g-bm" /><i class="g-nt" /><i class="g-dots" /></button>
</span>;
const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />;
const lm = lineMarks(b.c, b.lines);
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}</div>
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
})}
</div>
</article>
<div id="c-menu" class="c-menu" popover role="menu" aria-label="شعر">
<button type="button" role="menuitem" data-act="bm"><i class="g-bm" /><span>محفوظ کریں</span></button>
<button type="button" role="menuitem" data-act="nt"><i class="g-nt" /><span>نوٹ</span></button>
<button type="button" role="menuitem" data-act="sh"><i class="g-sh" /><span>بھیجیں</span></button>
<button type="button" role="menuitem" data-act="dl"><i class="g-dl" /><span>تصویر ڈاؤن لوڈ</span></button>
</div>
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
<form method="dialog">
<p id="note-for" class="note-for"></p>
<div class="in-box-field note-field">
<textarea id="note-text" name="note" rows="4" maxlength="1000" dir="auto" placeholder="آپ کا نوٹ، کسی بھی زبان میں…" aria-label="نوٹ" data-urdu></textarea>
<button type="button" class="in-box rom" data-urdu-rom="note-text" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
<button type="button" class="in-box" data-urdu-kb="note-text" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
</div>
<ul class="note-others" hidden></ul>
<div class="ask-actions">
<button class="btn primary" value="save">محفوظ کریں</button><button class="btn" value="">بند کریں</button>
<button class="btn danger note-clear" value="clear">نوٹ ہٹائیں</button><button class="btn note-unmark" value="unmark">ہائی لائٹ ہٹائیں</button>
</div>
</form>
</dialog>
<script type="application/json" id="notes-data" set:html={JSON.stringify({ poemId, notes, phrases: saved.phrases, signedIn: !!token })} />
<TagsBox tags={pageTags} />
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p>
<nav class="poem-nav">
{page.prev && <a class="icon-btn" href={page.prev} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
{page.next && <a class="icon-btn" href={page.next} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
{page.next ? <a href={page.next}>اگلا ›</a> : <span />}
{page.prev ? <a href={page.prev}>‹ پچھلا</a> : <span />}
</nav>
{page.divan && (
<p class="source divan-edit">
دیوان کا ورژن {ud(page.divan.version)} · ترمیم: {page.divan.by}{page.divan.reviewedBy && <> · جائزہ: {page.divan.reviewedBy}</>} · اشاعت: {page.divan.publishedBy}
· <bdi dir="ltr">{new Date(page.divan.at).toISOString().slice(0, 10)}</bdi>
{page.divan.commit_url && <> · <a href={page.divan.commit_url}>تبدیلی دیکھیں</a></>}
</p>
)}
{page.poem.source_url && (
<p class="source">ماخذ: <a href={page.poem.source_url}>ویکی ماخذ</a> · {page.poet && <a href={page.poet.url}>{page.poet.nickname}</a>}</p>
)}
@ -220,151 +138,3 @@ import TagsBox from '../components/TagsBox.astro';
)}
{re && <script is:inline>document.querySelector('.poem mark')?.scrollIntoView({ block: 'center' });</script>}
</Base>
<script>
import nq700 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-700-normal.woff2?url';
import nq400 from '@fontsource/noto-nastaliq-urdu/files/noto-nastaliq-urdu-arabic-400-normal.woff2?url';
let cardFonts: Promise<unknown> | null = null;
// notes on couplets and phrases: the glyph beside a couplet's bookmark, a highlighted phrase, or نوٹ in the selection menu
// open one box; a note never bookmarks (bookmarks are the bookmark button's)
const data = document.getElementById('notes-data');
if (data) {
const { poemId, notes, phrases, signedIn } = JSON.parse(data.textContent!) as { poemId: number; notes: { couplet: number; phrase: string | null; note: string }[]; phrases: { couplet: number; phrase: string }[]; signedIn: boolean };
const box = document.getElementById('note-box') as HTMLDialogElement, text = box.querySelector('textarea')!;
const others = box.querySelector('.note-others') as HTMLUListElement;
let at: { couplet: number; phrase: string | null } = { couplet: 0, phrase: null };
const post = (body: object) => fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
.then((r) => (r.status === 401 ? (location.href = '/signin?next=' + encodeURIComponent(location.pathname), null) : r.ok ? r.json() : null));
const linesOf = (c: number) => [...document.querySelectorAll(`[data-c="${c}"] > p:not(.tag-chips), p[data-c="${c}"]`)].map((p) => p.textContent!.replace(/\s+/g, ' ').trim());
const lineOf = (c: number) => linesOf(c).join(' / ');
const open = (couplet: number, phrase: string | null) => {
if (!signedIn) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return; }
at = { couplet, phrase };
const mine = notes.find((n) => n.couplet === couplet && n.phrase === phrase);
box.querySelector('#note-for')!.textContent = phrase ? `«${phrase}»` : lineOf(couplet);
text.value = mine?.note ?? '';
(box.querySelector('.note-clear') as HTMLElement).hidden = !mine;
(box.querySelector('.note-unmark') as HTMLElement).hidden = !phrases.some((p) => p.couplet === couplet && p.phrase === phrase); // only a highlighted phrase
// a couplet's box also lists the notes on phrases in it; pressing one opens that note
const inIt = phrase ? [] : notes.filter((n) => n.couplet === couplet && n.phrase);
others.hidden = !inIt.length;
others.replaceChildren(...inIt.map((n) => { const li = document.createElement('li'); const b = document.createElement('button'); b.type = 'button';
b.textContent = `«${n.phrase}»: ${n.note}`; b.onclick = () => { box.close(); open(couplet, n.phrase); }; li.append(b); return li; }));
box.showModal(); text.focus();
};
const mark = (couplet: number) => { // the glyph follows the notes on this couplet
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
const nt = document.querySelector(`.nt[data-for="${couplet}"]`), more = document.querySelector(`.more[data-for="${couplet}"]`) as HTMLElement | null;
nt?.setAttribute('aria-pressed', String(any)); nt?.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں');
if (more) { any ? (more.dataset.nt = '') : delete more.dataset.nt; more.title = cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'; } // the bookmark is separate
};
box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at;
if (!act) return;
if (act === 'unmark') { if (await post({ kind: 'phrase', poemId, couplet, phrase })) location.reload(); return; }
const note = act === 'clear' ? '' : text.value.trim();
const d = await post({ note, poemId, couplet, ...(phrase ? { phrase } : {}) });
if (!d) return (window as any).toast('نوٹ محفوظ نہیں ہو سکا', 'error');
if (phrase) { location.reload(); return; } // a phrase's highlight and note glyph are drawn by the page
const i = notes.findIndex((n) => n.couplet === couplet && !n.phrase);
if (i >= 0) notes.splice(i, 1);
if (d.note) notes.push({ couplet, phrase: null, note: d.note });
mark(couplet); (window as any).toast(d.note ? 'نوٹ محفوظ ہو گیا' : 'نوٹ ہٹا دیا');
});
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
const nt = t.closest('.nt') as HTMLElement | null; if (nt) return open(Number(nt.dataset.for), null);
const ph = t.closest('.bmk[data-phrase]') as HTMLElement | null;
if (ph && !getSelection()?.toString().trim()) { const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); }
});
document.addEventListener('keydown', (e) => { const ph = (e.target as HTMLElement).closest?.('.bmk[data-phrase]') as HTMLElement | null;
if (ph && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); } });
// share a couplet as an image: a square card (frame, logo, the lines, the poet), drawn here and handed to the share sheet
const share = async (c: number, download = false) => {
const [poet, title] = (document.querySelector('[data-cite]') as HTMLElement).dataset.cite!.split('، ');
const prose = !!document.querySelector(`p.para[data-c="${c}"]`);
const lines = linesOf(c), S = 1080, cv = document.createElement('canvas'); cv.width = cv.height = S;
const g = cv.getContext('2d')!; // the card is always on paper, whatever the page's palette
// the card's own Nastaliq faces, loaded here: the page may not have used bold Nastaliq yet, and a fallback font is
// wider (lines would wrap) and not Nastaliq
const NQ = '"Divan Card"';
if (!cardFonts) cardFonts = Promise.all([[nq700, '700'], [nq400, '400']].map(([url, weight]) => new FontFace('Divan Card', `url(${url})`, { weight }).load()
.then((f) => { document.fonts.add(f); })));
await cardFonts;
g.fillStyle = '#fbf6ea'; g.fillRect(0, 0, S, S);
g.strokeStyle = '#b88828'; g.lineWidth = 18; g.strokeRect(9, 9, S - 18, S - 18); g.lineWidth = 3; g.strokeRect(40, 40, S - 80, S - 80);
const logo = new Image(); logo.src = '/logo/turanj.svg'; await logo.decode().catch(() => {});
if (logo.complete) g.drawImage(logo, S / 2 - 70, 80, 140, 140);
g.direction = 'rtl'; g.textAlign = 'center'; g.textBaseline = 'middle';
// the lines: one per misra; prose wraps; the size shrinks until everything fits the card
let size = 72, rows: string[] = [];
const wrap = (t: string, w: number) => { const out: string[] = []; let row = '';
for (const word of t.split(' ')) { const next = row ? row + ' ' + word : word; if (g.measureText(next).width > w && row) { out.push(row); row = word; } else row = next; }
return row ? [...out, row] : out; };
for (; size >= (prose ? 30 : 22); size -= 2) { // verse shrinks until each misra fits on one line
g.font = `700 ${size}px ${NQ}`;
rows = lines.flatMap((l) => wrap(l, S - 200));
// verse keeps each misra on one line (prose wraps); Nastaliq is tall: lines 2.6 sizes apart leave room for the ✤ between misras
if ((prose || rows.length === lines.length) && rows.length * size * 2.6 <= 560) break;
}
const lh = size * 2.6, top = 250 + (560 - rows.length * lh) / 2;
g.fillStyle = '#17130f'; rows.forEach((r, i) => g.fillText(r, S / 2, top + lh * (i + .5)));
// the couplet line (as on the home page) between the verse and the poet: two tapering half-lines and a ✤
const y = S - 260, half = (from: number, to: number) => { g.beginPath(); g.moveTo(from, y); g.quadraticCurveTo((from + to) / 2, y - 6, to, y - 4); g.lineTo(to, y + 4); g.quadraticCurveTo((from + to) / 2, y + 6, from, y); g.fill(); };
g.fillStyle = '#1f4e79'; half(S / 2 - 30, S / 2 - 230); half(S / 2 + 30, S / 2 + 230);
g.fillStyle = '#bf3f2a'; g.font = '700 34px "Noto Naskh Arabic"'; g.fillText('✤', S / 2, y + 7);
g.fillStyle = '#bf3f2a'; g.font = `700 40px ${NQ}`; g.fillText(poet, S / 2, S - 190);
g.fillStyle = '#635543'; g.font = `400 30px ${NQ}`; g.fillText(`${title} · دیوان`, S / 2, S - 115);
const blob: Blob = await new Promise((done) => cv.toBlob((b) => done(b!), 'image/png'));
const file = new File([blob], `divan-${poemId}-${c + 1}.png`, { type: 'image/png' });
const url = `${location.origin}${location.pathname}#c${c}`;
if (download) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); (window as any).toast('تصویر ڈاؤن لوڈ ہو گئی'); return; }
// the share menu: with the picture where the browser can share files, otherwise the couplet and its link; never a download
const text = `${lines.join('\n')}\n— ${poet}`;
if (navigator.canShare?.({ files: [file] })) await navigator.share({ files: [file], text: url }).catch(() => {});
else if (navigator.share) await navigator.share({ title: document.title, text, url }).catch(() => {});
else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } }
};
document.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('.sh, .dl') as HTMLElement | null; if (b) share(Number(b.dataset.for), b.classList.contains('dl')); });
// phones: one ⋯ per couplet opens the shared menu beside it: bookmark, note, share, download
const menu = document.getElementById('c-menu') as HTMLElement & { showPopover(): void; hidePopover(): void };
let forC = 0, from: HTMLElement | null = null;
document.addEventListener('click', (e) => {
const more = (e.target as HTMLElement).closest('.more') as HTMLElement | null; if (!more) return;
forC = Number(more.dataset.for); from = more;
const on = 'bm' in more.dataset;
menu.querySelector('[data-act=bm] span')!.textContent = on ? 'محفوظ شدہ سے ہٹائیں' : 'محفوظ کریں';
menu.querySelector('[data-act=bm]')!.toggleAttribute('data-on', on);
menu.showPopover();
const r = more.getBoundingClientRect(), m = menu.getBoundingClientRect(); // below the button, kept on screen
menu.style.left = `${Math.max(8, Math.min(innerWidth - m.width - 8, r.left))}px`;
menu.style.top = `${r.bottom + 6 + m.height > innerHeight ? r.top - m.height - 6 : r.bottom + 6}px`;
(menu.querySelector('[role=menuitem]') as HTMLElement).focus();
});
menu.addEventListener('click', async (e) => {
const act = (e.target as HTMLElement).closest('[data-act]')?.getAttribute('data-act'); if (!act) return;
menu.hidePopover();
if (act === 'nt') return open(forC, null);
if (act === 'sh' || act === 'dl') return share(forC, act === 'dl');
const d = await post({ kind: 'couplet', poemId, couplet: forC }); if (!d || !from) return;
d.saved ? (from.dataset.bm = '') : delete from.dataset.bm;
from.parentElement?.querySelector('.bm')?.setAttribute('aria-pressed', String(d.saved)); // the wide-screen icon too
(window as any).toast(d.saved ? 'شعر محفوظ ہو گیا' : 'محفوظ شدہ سے ہٹا دیا');
});
menu.addEventListener('keydown', (e) => { // arrows move through the menu
const items = [...menu.querySelectorAll<HTMLElement>('[role=menuitem]')], i = items.indexOf(document.activeElement as HTMLElement);
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
});
// touch screens: tapping a couplet shows its tools (and hides the last one's); links, buttons and selections are left alone
if (matchMedia('(hover: none)').matches) document.querySelector('.poem')?.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
if (t.closest('a, button, .bmk') || getSelection()?.toString().trim()) return;
const block = t.closest('.couplet, .para, .single'); if (!block) return;
const was = block.classList.contains('is-active');
document.querySelectorAll('.poem .is-active').forEach((x) => x.classList.remove('is-active'));
if (!was) block.classList.add('is-active');
});
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
}
</script>

View File

@ -1,43 +0,0 @@
---
// A poet's e-books (/p17/ebooks), every poet has one: tabs by kind, covers in a grid
import Base from '../../layouts/Base.astro';
import { api } from '../../lib/api';
import { asUser, COOKIE } from '../../lib/auth';
import { ud } from '../../lib/urdu';
const page = /^p\d+$/.test(Astro.params.poet ?? '') ? await api(`/api/page?url=${encodeURIComponent('/' + Astro.params.poet)}`) : null;
if (!page || page.type !== 'poet') return new Response(null, { status: 404, statusText: 'Not found' });
const books = (await api<any[]>(`/api/ebooks?poet=${page.poet.id}`)) ?? [];
// tabs: the three kinds always, archive.org only when there are such books
const TABS = [['pdf', 'پی ڈی ایف', 'PDF'], ['epub', 'ای پب', 'ePUB'], ['text', 'یونیکوڈ متن', 'Unicode'], ['archive', 'آرکائیو', 'archive.org']] as const;
const tabs = TABS.filter(([k]) => k !== 'archive' || books.some((b) => b.kind === 'archive'));
const asked = Astro.url.searchParams.get('kind');
const kind = tabs.some(([k]) => k === asked) ? asked : (tabs.find(([k]) => books.some((b) => b.kind === k))?.[0] ?? 'pdf');
const shown = books.filter((b) => b.kind === kind);
const cover = (b: any) => (b.has_cover ? `/ebooks/cover/${b.id}` : b.kind === 'archive' ? `https://archive.org/services/img/${b.archive_id}` : null);
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const mayAdd = token && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user!.role)
&& (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data.ebooks;
import Icon from '../../components/Icon.astro';
---
<Base title={`ای بکس · ${page.poet.nickname}`} description={`${page.poet.nickname} کی ای بکس: پی ڈی ایف، ای پب اور یونیکوڈ متن`}>
<nav class="crumbs"><a href={page.poet.url}>{page.poet.nickname}</a></nav>
<h1>ای بکس</h1>
{mayAdd && <p class="center"><a class="btn primary" href={`/mod/ebook/new?poet=${page.poet.id}`}><Icon name="plus" size={18} /> ای بک شامل کریں</a></p>}
<nav class="admin-nav ebook-tabs" aria-label="کتابوں کی قسم">
{tabs.map(([k, ur, en]) => {
const n = books.filter((b) => b.kind === k).length;
return <a href={`?kind=${k}`} aria-current={k === kind ? 'page' : undefined}>{ur} <span lang="en" dir="ltr">({en})</span>{n > 0 && <small> {ud(n)}</small>}</a>;
})}
</nav>
{shown.length === 0 ? <p class="muted center">اس قسم کی کوئی کتاب ابھی نہیں۔</p> : (
<div class="ebook-grid">
{shown.map((b) => (
<a class="ebook-card" href={`/ebook/${b.id}`}>
{cover(b) ? <img src={cover(b)} alt="" loading="lazy" /> : <span class="ebook-plain"><span>{b.title}</span><small>{page.poet.nickname}</small></span>}
<span class="ebook-title">{b.title}{b.co && <small class="muted"> (شریک مصنف)</small>}{b.language !== 'اردو' && <small class="muted"> · {b.language}</small>}</span>
</a>
))}
</div>
)}
</Base>

View File

@ -1,76 +0,0 @@
---
// About Divan (public): inspiration (Ganjoor), where the content comes from, open source, moderation and plans
import Base from '../layouts/Base.astro';
import { api } from '../lib/api';
import { ud } from '../lib/urdu';
const poets = ((await api<unknown[]>('/api/poets')) ?? []).length;
---
<Base title="دیوان کے بارے میں" description="دیوان: اردو کی کلاسیکی شاعری اور نثر کا مفت اور آزاد ذخیرہ، گنجور سے متاثر">
<article class="guide about">
<h1>دیوان کے بارے میں</h1>
<p>
دیوان اردو کی کلاسیکی شاعری اور نثر کا ایک مفت اور آزاد (اوپن سورس) ذخیرہ ہے، جو اردو پڑھنے والوں کی خدمت کے لیے
بنایا جا رہا ہے۔ یہاں ہر کوئی بغیر کسی فیس یا پابندی کے پڑھ، تلاش اور شیئر کر سکتا ہے۔
</p>
<h2>گنجور سے تحریک</h2>
<p>
دیوان فارسی شاعری کی مشہور ویب سائٹ <a href="https://ganjoor.net" lang="en" dir="ltr">ganjoor.net</a> (گنجور) سے
متاثر ہو کر بنایا گیا ہے۔ اس کا انداز اور اس کی بہت سی سہولتیں گنجور کی طرز پر ہیں: صدیوں کے لحاظ سے شعرا کی فہرست،
کتابوں اور ابواب کی ترتیب، تلاش، متن منتخب کرنے پر معنی، کاپی اور شیئر کا مینو، اور پسندیدہ کلام محفوظ کرنا۔
گنجور کی طرح دیوان بھی اوپن سورس ہے۔ دیوان گنجور کا حصہ نہیں بلکہ اردو کے لیے ایک الگ اور آزاد منصوبہ ہے۔
ہم گنجور بنانے والوں کے شکر گزار ہیں جنھوں نے اپنا کام سب کے لیے کھلا رکھا۔
</p>
<h2>مواد کہاں سے آیا</h2>
<p>
ابتدائی مواد <a href="https://ur.wikisource.org">اردو ویکی ماخذ</a> سے لیا گیا ہے: {ud(poets)} شعرا و ادبا کی
گیارہ ہزار سے زیادہ تخلیقات، جو عوامی ملکیت میں ہیں۔ شعرا کے تعارف <a href="https://ur.wikipedia.org">اردو ویکیپیڈیا</a>
سے ہیں، اور الفاظ کے معنی، تلفظ اور اشتقاق <a href="https://en.wiktionary.org">ویکی لغت</a> (اردو، فارسی اور عربی) سے۔
ہر کلام کے صفحے پر اس کے ماخذ کا ربط موجود ہے، اور ویکی ماخذ کی نئی تبدیلیاں روزانہ یہاں آتی ہیں۔
</p>
<h2>آزاد اور اوپن سورس</h2>
<p>
دیوان کا <a href="https://github.com/anas-rashid/divan">کوڈ</a> اور <a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a>
دونوں کھلے ہیں۔ متن عوامی ملکیت میں ہے، اس لیے اسے پڑھنا، نقل کرنا اور آگے بانٹنا سب کے لیے آزاد ہے۔ ویکی لغت کا مواد
اپنے لائسنس <bdi dir="ltr">CC BY-SA</bdi> کے تحت ہے۔ مقصد یہ ہے کہ اردو کا کلاسیکی ادب کسی ایک ادارے کی ملکیت نہ رہے بلکہ سب کا مشترکہ
سرمایہ بنے۔
</p>
<h2>ماڈریٹر اور دیوان کا اپنا نسخہ</h2>
<p>
دیوان صرف ویکی ماخذ کی نقل نہیں رہے گا۔ ماڈریٹر کلاسیکی اردو مواد کی تصحیح، اضافہ اور ترتیب کرتے ہیں: کسی مصرعے کی
غلطی درست کرنا، مطلع اور مقطع کی نشان دہی، الفاظ کو لغت سے جوڑنا، حاشیے اور نسخوں کے فرق لکھنا، اور کسی دیوان کی غزلوں یا
کسی کتاب کی نظموں کو مطبوعہ ترتیب میں لانا۔ ہر تبدیلی جائزے کے مراحل سے گزرتی ہے، اور منظور ہونے کے بعد دیوان کا اپنا نسخہ
ویکی ماخذ کے متن پر ترجیح پاتا ہے۔
</p>
<p>
ہر شائع شدہ تبدیلی ڈیٹا کے کھلے ذخیرے میں درج ہوتی ہے، کس نے کی، کس نے منظور کی اور کب، تاکہ ہر لفظ کی تاریخ سب کے
سامنے رہے۔ کلام کے صفحے کے آخر میں دیوان کا ورژن اور اس کی تبدیلی کا ربط نظر آتا ہے۔
</p>
<h2>نئے لکھنے والوں کے لیے</h2>
<p>
آگے چل کر دیوان نئے شاعروں، ادیبوں اور مصنفین کا گھر بھی ہوگا، جہاں وہ اپنی تخلیقات اسی احترام اور اسی انداز سے پیش کر سکیں
جیسے کلاسیکی ادب۔ یوں کلاسیکی روایت اور آج کا اردو ادب ایک ہی جگہ ملیں گے۔
</p>
<h2>آگے کیا ہے</h2>
<ul>
<li>ماڈریٹروں کے لیے شعرا، تعارف، کتابوں اور لغت کی ترمیم، اور دستاویزات (docx، odt، txt) سے متن لانا</li>
<li>موضوعات اور ٹیگ، اور ان سے تلاش</li>
<li>ویکی لغت کی طرز پر دیوان کا اپنا لغت کا حصہ</li>
<li>قارئین کے تبصرے، اور کلام کی آواز میں قرأت</li>
<li>موبائل ایپس کے لیے کھلا API</li>
</ul>
<h2>شامل ہوں</h2>
<p>
<a href="/signup">اکاؤنٹ بنائیں</a> تو پسندیدہ شعرا، کلام، اشعار اور عبارتیں محفوظ کر سکتے ہیں اور اپنا ذخیرۂ الفاظ بنا
سکتے ہیں۔ ماڈریٹر ایڈمن مقرر کرتے ہیں؛ اگر آپ کلاسیکی اردو کی خدمت میں ہاتھ بٹانا چاہتے ہیں تو اکاؤنٹ بنا کر ہم سے رابطہ کریں۔
ماڈریٹروں کے لیے <a href="/mod/guide">لکھنے کا طریقہ</a> یہاں ہے۔
</p>
</article>
</Base>

Some files were not shown because too many files have changed in this diff Show More