E-books: upload with its own permission, review, and viewers for PDF, EPUB, text and archive.org #67

Merged
anas merged 1 commits from feature/ebooks into main 2026-10-09 12:54:28 +00:00
20 changed files with 1051 additions and 25 deletions

View File

@ -37,6 +37,10 @@ The import upserts, so re-running it after a divan-data sync applies the changes
**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. **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.
## Daily content sync (server) ## 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. `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.

216
api/package-lock.json generated
View File

@ -7,8 +7,12 @@
"name": "divan-api", "name": "divan-api",
"dependencies": { "dependencies": {
"fastify": "^5.12.5", "fastify": "^5.12.5",
"mammoth": "^1.13.0",
"pg": "^8.23.1" "pg": "^8.23.1"
}, },
"devDependencies": {
"jszip": "^3.10.2"
},
"engines": { "engines": {
"node": ">=24" "node": ">=24"
} }
@ -130,6 +134,15 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT" "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": { "node_modules/abstract-logging": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@ -185,6 +198,15 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -214,6 +236,26 @@
"fastq": "^1.17.1" "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": { "node_modules/cookie": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@ -227,6 +269,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -236,6 +284,21 @@
"node": ">=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": { "node_modules/fast-decode-uri-component": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@ -353,6 +416,18 @@
"node": ">=20" "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": { "node_modules/ipaddr.js": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
@ -362,6 +437,12 @@
"node": ">= 10" "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": { "node_modules/json-schema-ref-resolver": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
@ -387,6 +468,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "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": { "node_modules/light-my-request": {
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@ -424,6 +526,39 @@
], ],
"license": "MIT" "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": { "node_modules/on-exit-leak-free": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@ -433,6 +568,18 @@
"node": ">=14.0.0" "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": { "node_modules/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
@ -598,6 +745,12 @@
"node": ">=0.10.0" "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": { "node_modules/process-warning": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
@ -620,6 +773,21 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT" "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": { "node_modules/real-require": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
@ -660,6 +828,12 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT" "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": { "node_modules/safe-regex2": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
@ -725,6 +899,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "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": { "node_modules/sonic-boom": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -743,6 +923,21 @@
"node": ">= 10.x" "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": { "node_modules/thread-stream": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@ -764,6 +959,27 @@
"node": ">=20" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -15,6 +15,10 @@
}, },
"dependencies": { "dependencies": {
"fastify": "^5.12.5", "fastify": "^5.12.5",
"mammoth": "^1.13.0",
"pg": "^8.23.1" "pg": "^8.23.1"
},
"devDependencies": {
"jszip": "^3.10.2"
} }
} }

96
api/src/ebooks.test.ts Normal file
View File

@ -0,0 +1,96 @@
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 { ebookRoutes, archiveId, parseMeta, metaText } from './ebooks.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('e-book details and archive.org links', () => {
assert.deepEqual(parseMeta(metaText({ title: 'کلیات اقبال', source: 'ریختہ نہیں', licence: 'عوامی ملکیت' })),
{ title: 'کلیات اقبال', source: 'ریختہ نہیں', licence: 'عوامی ملکیت', note: '' });
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);
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: 'عوامی ملکیت' }, 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}`]);
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 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 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 call('GET', `/api/ebook/${a.id}`, undefined, admin.token)).json().archive_id, 'BaangEDara');
} finally {
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();
}
});

214
api/src/ebooks.ts Normal file
View File

@ -0,0 +1,214 @@
// 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: "عنوان: …", "ماخذ: …", "اجازت: …", "تفصیل: …".
// 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
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' };
const KEYS = { title: 'عنوان', source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' } as const;
type Meta = { title: string; source: string; licence: string; note: string };
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 = { title: '', source: '', licence: '', note: '' };
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);
}
return m.title ? m : 'کتاب کا عنوان ضروری ہے';
}
// 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];
return { ebook, version: Number(last?.v ?? 0), content: ebook.published ? metaText(ebook) : '' };
}
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];
await client.query('UPDATE ebooks SET title = $2, source = $3, licence = $4, note = $5, search_text = $6, published = true WHERE id = $1',
[id, m.title, m.source || null, m.licence || null, m.note || null, await searchText(m.title, b.kind, b.file)]);
}
// 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));
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[poetId, meta.title, kind, file, archive, size, meta.source || null, meta.licence || null, meta.note || null, u.id])).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 FROM ebooks WHERE poet_id = $1 AND published ORDER BY title`, [Number(req.query.poet) || 0])).rows);
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;
return { ...rest, text: b.kind === 'text' ? (await textOf(b.file)).slice(0, 3_000_000) : undefined };
});
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();
reply.header('content-type', TYPES[ext]).header('content-length', String((await stat(pathOf(b.file))).size))
.header('content-disposition', `inline; filename="divan-${b.id}.${ext}"`)
.header('cache-control', b.published ? 'public, max-age=86400' : 'private, no-store');
return reply.send(createReadStream(pathOf(b.file)));
});
}

View File

@ -11,7 +11,7 @@
// If another version was published after the draft started, publishing is refused until the draft is redone. // 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 // 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' // the separate 'arrange' permission. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags'
// permission. // permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission.
// GET /api/mod/can?poem= what the reader may do on a work // 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/queue my drafts, drafts to review, drafts to publish
// GET /api/mod/work/:id a work's current text and its history // GET /api/mod/work/:id a work's current text and its history
@ -35,6 +35,7 @@ import { diffLines, changed } from './diff.ts';
import { commit, identity } from './git.ts'; import { commit, identity } from './git.ts';
import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './order.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 { 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 { publicName } from './auth.ts';
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname; const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname;
@ -44,10 +45,13 @@ const mayArrange = async (u: any, categoryId: number) =>
(await can(u, 'arrange', 'works', { categoryId })) || (await can(u, 'arrange', 'books', { categoryId })); (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 }); 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 // 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 mayChange = (u: any, r: { entity: string; entity_id: number }) => const mayChange = (u: any, r: { entity: string; entity_id: number }) =>
r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id) : mayEdit(u, 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 tagKind = (r: { entity: string }) => r.entity.slice('tags-'.length) as TagTarget;
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work')`; const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook')`;
const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r)); 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']; 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) // a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change)
@ -78,8 +82,9 @@ async function current(poemId: number) {
// what a revision changes: a work, or a book/section's order (work_title/work_url name either) // 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 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') AND c.id = r.entity_id`; LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id
const TARGET_COLS = `coalesce(p.title, c.title) AS work_title, coalesce(p.url, c.url) AS work_url`; LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id`;
const TARGET_COLS = `coalesce(p.title, c.title, b.title) AS work_title, coalesce(p.url, c.url, '/ebook/' || b.id) AS work_url`;
async function revision(id: number) { 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]; return (await pool.query(`SELECT r.*, ${TARGET_COLS} FROM revisions r ${TARGET} WHERE r.id = $1 AND r.entity IN ${ENTITIES}`, [id])).rows[0];
@ -99,16 +104,19 @@ async function actions(u: any, r: any) {
} }
async function publish(r: any, u: any, comment?: string) { async function publish(r: any, u: any, comment?: string) {
const order = r.entity === 'order', tags = r.entity.startsWith('tags-'); const order = r.entity === 'order', tags = r.entity.startsWith('tags-'), ebook = r.entity === 'ebook';
const cur = order ? await currentOrder(r.entity_id) : tags ? await currentTags(tagKind(r), r.entity_id) : await current(r.entity_id); const cur = order ? await currentOrder(r.entity_id) : tags ? await currentTags(tagKind(r), r.entity_id)
: ebook ? await currentEbook(r.entity_id) : await current(r.entity_id);
if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 }); if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
if (cur.version !== r.base_version) if (cur.version !== r.base_version)
throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 }); 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 // the section's contents may have changed since (a new work from the sync): the arrangement must be redone
const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content) : null; const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content)
: ebook ? checkEbook(r.content) : null;
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 }); if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
const doc = order || tags ? null : parse(r.content), verses = doc ? toVerses(doc) : []; const doc = order || tags || ebook ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}` : doc!.meta['عنوان'] || cur.poem.title; const title = '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(); const version = cur.version + 1, at = new Date().toISOString();
// who did it, by public name (divan-data is public: never email addresses) // 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 people = async (id: unknown) => id ? (await pool.query('SELECT id, full_name FROM users WHERE id = $1', [id])).rows[0] ?? null : null;
@ -118,6 +126,7 @@ async function publish(r: any, u: any, comment?: string) {
// divan-data first (the published record, committed to git), then the site's database // divan-data first (the published record, committed to git), then the site's database
const files = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)] const files = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
: 'target' in cur ? [await writeTags(dataDir(), cur.target.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) }); : await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`, const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`,
...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`]; ...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`];
@ -130,6 +139,7 @@ async function publish(r: any, u: any, comment?: string) {
WHERE id = $1`, [r.id, version, u.email, at, sha, credits]); WHERE id = $1`, [r.id, version, u.email, at, sha, credits]);
if (order) await applyOrder(client, r.entity_id, r.content); 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 (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
else if (ebook) await applyEbook(client, r.entity_id, r.content);
else { else {
await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1', 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(' '))]); [r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]);
@ -156,8 +166,11 @@ export function moderationRoutes(app: FastifyInstance) {
// ?poem= on a work page; ?category= on a poet or book/section page (arrange) // ?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) => { 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; 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 }; if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false, ebooks: false };
if (categoryId) return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId) }; 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) };
}
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin', 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) }; tags: await mayTag(u, 'work', poemId) };
}); });
@ -287,14 +300,16 @@ export function moderationRoutes(app: FastifyInstance) {
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' }); if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
if (!(await actions(u, r)).save) return reply.code(403).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'); const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
if (r.entity === 'order' || r.entity.startsWith('tags-')) { if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook') {
const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : await checkTags(tagKind(r), r.entity_id, content); const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : r.entity === 'ebook' ? checkEbook(content)
: await checkTags(tagKind(r), r.entity_id, content);
if (bad) return reply.code(400).send({ error: bad }); if (bad) return reply.code(400).send({ error: bad });
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' }); } else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
if (content.length > 500_000) 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): // 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 // 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[]) : toText(parse(t))); 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() : toText(parse(t)));
const same = norm(content) === norm(r.base_content); const same = norm(content) === norm(r.base_content);
if (same && r.status === 'draft') { if (same && r.status === 'draft') {
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]); await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);

View File

@ -36,10 +36,10 @@ test('arranging: its own permission; L2 reorders, L1 approves, admin publishes t
const grant = (m: any, actions: string[]) => 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]); 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']); await grant(l2, ['edit']);
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false }, 'editing is not arranging'); assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false }, 'editing is not arranging');
assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403); assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
await grant(l2, ['arrange']); await grant(l1, ['arrange']); 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 }); assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false });
const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json(); 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 cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();

View File

@ -13,7 +13,7 @@ import { audit, requireAdmin } from './admin.ts';
export const MODERATORS = ['mod-l2', 'mod-l1'] as const; export const MODERATORS = ['mod-l2', 'mod-l1'] as const;
export const SCOPES = ['all', 'poet', 'category', 'poem'] as const; export const SCOPES = ['all', 'poet', 'category', 'poem'] as const;
export const CONTENT = ['poets', 'books', 'works', 'dictionary', 'site', 'tags'] as const; // site: the home page sections (site.ts); tags (tags.ts) 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 ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const; export const ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const;
export type Action = (typeof ACTIONS)[number]; export type Action = (typeof ACTIONS)[number];
export type Content = (typeof CONTENT)[number]; export type Content = (typeof CONTENT)[number];

View File

@ -21,6 +21,7 @@ import { libraryRoutes } from './library.ts';
import { moderationRoutes } from './moderation.ts'; import { moderationRoutes } from './moderation.ts';
import { siteRoutes } from './site.ts'; import { siteRoutes } from './site.ts';
import { tagRoutes, pageTags } from './tags.ts'; import { tagRoutes, pageTags } from './tags.ts';
import { ebookRoutes } from './ebooks.ts';
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -173,7 +174,13 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
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`, textParams),
poetIds.length ? pool.query('SELECT id, url, nickname FROM poets WHERE id = ANY($1) ORDER BY nickname', [poetIds]) : { rows: [] }, poetIds.length ? pool.query('SELECT id, url, nickname FROM poets WHERE id = ANY($1) ORDER BY nickname', [poetIds]) : { rows: [] },
]); ]);
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results, ...names, tags: tagRows, searchedTags: tagNames, // 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 }; authors: authors.rows, selected: selected.rows };
}); });
@ -191,6 +198,7 @@ libraryRoutes(app);
moderationRoutes(app); moderationRoutes(app);
siteRoutes(app); siteRoutes(app);
tagRoutes(app); tagRoutes(app);
ebookRoutes(app);
const port = Number(process.env.PORT ?? 4100); const port = Number(process.env.PORT ?? 4100);
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' }); await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });

View File

@ -215,3 +215,24 @@ CREATE TABLE IF NOT EXISTS entity_tags (
PRIMARY KEY (tag_id, entity, entity_id, couplet) PRIMARY KEY (tag_id, entity, entity_id, couplet)
); );
CREATE INDEX IF NOT EXISTS entity_tags_target ON entity_tags(entity, entity_id); 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);

282
web/package-lock.json generated
View File

@ -14,7 +14,8 @@
"@tiptap/extension-text": "^3.31.4", "@tiptap/extension-text": "^3.31.4",
"@tiptap/extensions": "^3.31.4", "@tiptap/extensions": "^3.31.4",
"@tiptap/pm": "^3.31.4", "@tiptap/pm": "^3.31.4",
"astro": "^7.3.8" "astro": "^7.3.8",
"epubjs": "^0.3.93"
} }
}, },
"node_modules/@astrojs/compiler-binding": { "node_modules/@astrojs/compiler-binding": {
@ -1660,6 +1661,16 @@
"@types/unist": "*" "@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": { "node_modules/@types/mdast": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@ -1690,6 +1701,16 @@
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC" "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": { "node_modules/am-i-vibing": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz", "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz",
@ -1960,6 +1981,26 @@
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
"license": "MIT" "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": { "node_modules/crossws": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
@ -2043,6 +2084,19 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"license": "CC0-1.0" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2218,18 +2272,90 @@
"url": "https://github.com/fb55/entities?sponsor=1" "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": { "node_modules/es-module-lexer": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.3.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.3.tgz",
"integrity": "sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==", "integrity": "sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==",
"license": "MIT" "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": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "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": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -2239,12 +2365,31 @@
"node": ">= 0.6" "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": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "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": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -2470,6 +2615,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2512,12 +2663,39 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/jsonc-parser": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT" "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": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@ -2779,6 +2957,30 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/lru-cache": {
"version": "11.5.3", "version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
@ -2808,6 +3010,12 @@
"source-map-js": "^1.2.1" "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": { "node_modules/mdast-util-to-hast": {
"version": "13.2.1", "version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
@ -2991,6 +3199,12 @@
"node": ">= 10" "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": { "node_modules/nlcst-to-string": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz",
@ -3151,6 +3365,18 @@
"integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==",
"license": "MIT" "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/piccolore": { "node_modules/piccolore": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@ -3221,6 +3447,12 @@
"node": ">=18.0.0" "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": { "node_modules/property-information": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
@ -3389,6 +3621,21 @@
"url": "https://opencollective.com/express" "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": { "node_modules/readdirp": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
@ -3489,6 +3736,12 @@
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT" "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": { "node_modules/satteri": {
"version": "0.10.5", "version": "0.10.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
@ -3566,6 +3819,12 @@
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==",
"license": "ISC" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -3687,6 +3946,15 @@
"node": ">= 0.8" "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": { "node_modules/stringify-entities": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -3802,6 +4070,12 @@
"license": "0BSD", "license": "0BSD",
"optional": true "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": { "node_modules/ufo": {
"version": "1.6.4", "version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
@ -4023,6 +4297,12 @@
} }
} }
}, },
"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": { "node_modules/verkit": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz",

View File

@ -17,6 +17,7 @@
"@tiptap/extension-text": "^3.31.4", "@tiptap/extension-text": "^3.31.4",
"@tiptap/extensions": "^3.31.4", "@tiptap/extensions": "^3.31.4",
"@tiptap/pm": "^3.31.4", "@tiptap/pm": "^3.31.4",
"astro": "^7.3.8" "astro": "^7.3.8",
"epubjs": "^0.3.93"
} }
} }

View File

@ -1,5 +1,5 @@
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts) // 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 ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' }; export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)', tags: 'ٹیگ' }; export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)', tags: 'ٹیگ', ebooks: 'ای بکس' };
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' }; export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };

View File

@ -77,6 +77,9 @@ const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : ca
// tags (#52): the page's own, and each couplet's (tags count couplets from 1, verses from 0) // 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 pageTags = (page.tags ?? []).filter((t: any) => !t.couplet);
const coupletTags = (c: number) => (page.tags ?? []).filter((t: any) => t.couplet === c + 1); const coupletTags = (c: number) => (page.tags ?? []).filter((t: any) => t.couplet === c + 1);
// a poet's e-books (published), and the upload link for moderators with the e-books permission for this poet
const ebooks = page.type === 'poet' ? (await api<any[]>(`/api/ebooks?poet=${poetId}`)) ?? [] : [];
const EBOOK_KIND: Record<string, string> = { pdf: 'PDF', epub: 'EPUB', text: 'متن', archive: 'آرکائیو' };
const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`; const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`;
const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c }); 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; const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
@ -123,6 +126,16 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
</> </>
))} ))}
{page.type === 'poet' && (ebooks.length > 0 || catCan.ebooks) && (
<section class="search-group" id="ebooks">
<h2>ای بکس {ebooks.length > 0 && <small class="muted">{ud(ebooks.length)}</small>}</h2>
<ul class="book-results">
{ebooks.map((b) => <li><a href={`/ebook/${b.id}`}>{b.title}</a> <small class="muted">{EBOOK_KIND[b.kind]}</small></li>)}
</ul>
{catCan.ebooks && <p class="center"><a class="mod-edit-btn" href={`/mod/ebook/new?poet=${poetId}`}>ای بک شامل کریں</a></p>}
</section>
)}
{page.type === 'poem' && ( {page.type === 'poem' && (
<> <>
<h1><Seg s={show(page.poem.title)} /></h1> <h1><Seg s={show(page.poem.title)} /></h1>

View File

@ -0,0 +1,48 @@
---
// One e-book: PDF in the browser's own viewer, EPUB with epub.js, text as text, archive.org in its embedded reader
import Base from '../../layouts/Base.astro';
import { asUser, COOKIE } from '../../lib/auth';
import { api } from '../../lib/api';
import { ud } from '../../lib/urdu';
const id = Number(Astro.params.id) || 0, token = Astro.cookies.get(COOKIE)?.value;
const b = token ? (await asUser(token, `/api/ebook/${id}`)).data : await api(`/api/ebook/${id}`);
if (!b?.id) return new Response(null, { status: 404, statusText: 'Not found' });
const file = `/ebooks/file/${b.id}`;
const KIND: Record<string, string> = { pdf: 'PDF', epub: 'EPUB', text: 'متن', archive: 'آرکائیو' };
const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size / 1024)))} KB` : `${ud((b.size / 1048576).toFixed(1))} MB`;
---
<Base title={`${b.title} · ${b.poet}`}>
<nav class="crumbs"><a href={b.poet_url}>{b.poet}</a> » <a href={`${b.poet_url}#ebooks`}>ای بکس</a></nav>
<h1>{b.title}</h1>
{!b.published && <p class="form-error center">یہ کتاب ابھی جائزے میں ہے؛ صرف موڈریٹر دیکھ سکتے ہیں۔</p>}
<p class="center muted">
{KIND[b.kind]}{mb && <> · {mb}</>}{b.source && <> · ماخذ: {b.source}</>}{b.licence && <> · {b.licence}</>}
{b.file && <> · <a href={file} download>ڈاؤن لوڈ</a></>}
{b.kind === 'archive' && <> · <a href={`https://archive.org/details/${b.archive_id}`}>archive.org پر</a></>}
</p>
{b.note && <p class="intro">{b.note}</p>}
{b.kind === 'pdf' && <iframe class="ebook-frame" src={file} title={b.title}></iframe>}
{b.kind === 'archive' && <iframe class="ebook-frame" src={`https://archive.org/embed/${b.archive_id}`} title={b.title} allowfullscreen></iframe>}
{b.kind === 'text' && <article class="ebook-text" dir="auto">{b.text}</article>}
{b.kind === 'epub' && (
<>
<div id="epub-view" class="ebook-frame" data-src={file}></div>
<nav class="poem-nav"><button type="button" class="mod-edit-btn" id="epub-prev">‹ پچھلا صفحہ</button><button type="button" class="mod-edit-btn" id="epub-next">اگلا صفحہ ›</button></nav>
</>
)}
<script>
// EPUB: epub.js pages through the book (right-to-left books turn the other way)
const view = document.getElementById('epub-view');
if (view) {
const { default: ePub } = await import('epubjs');
const book = ePub(view.dataset.src!, { openAs: 'epub' });
const rendition = book.renderTo(view, { width: '100%', height: '100%', spread: 'none' });
rendition.display();
document.getElementById('epub-prev')!.onclick = () => rendition.prev();
document.getElementById('epub-next')!.onclick = () => rendition.next();
document.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') rendition.next(); if (e.key === 'ArrowRight') rendition.prev(); });
}
</script>
</Base>

View File

@ -0,0 +1,14 @@
// An e-book's file, passed through from the private API (with the reader's session, so moderators can open
// files still under review)
import type { APIRoute } from 'astro';
import { COOKIE } from '../../../lib/auth';
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
export const GET: APIRoute = async ({ params, cookies }) => {
const token = cookies.get(COOKIE)?.value;
const res = await fetch(`${API}/api/ebook/${Number(params.id) || 0}/file`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
const headers = new Headers();
for (const k of ['content-type', 'content-length', 'content-disposition', 'cache-control']) { const v = res.headers.get(k); if (v) headers.set(k, v); }
return new Response(res.ok ? res.body : null, { status: res.status, headers });
};

View File

@ -0,0 +1,53 @@
---
// Add an e-book to a poet (moderators with the e-books permission for that poet): a file (PDF, EPUB, TXT, DOCX up to
// 100 MB) or an archive.org link, with its title, source and copyright; it goes for review like any other change
import Base from '../../../layouts/Base.astro';
import ModNav from '../../../components/ModNav.astro';
import { asUser, COOKIE } from '../../../lib/auth';
import { api } from '../../../lib/api';
const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
const token = Astro.cookies.get(COOKIE)!.value, API = process.env.API_URL ?? 'http://127.0.0.1:4100';
const poetId = Number(Astro.url.searchParams.get('poet')) || 0;
const page = poetId ? await api(`/api/page?url=${encodeURIComponent(`/p${poetId}`)}`) : null;
const may = page && ['mod-l2', 'mod-l1', 'admin'].includes(me.role) && (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data.ebooks;
let error = '';
if (may && Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const fields = { poet: poetId, title: f.get('title'), source: f.get('source'), licence: f.get('licence'), note: f.get('note') };
const upload = f.get('file') as File | null, archive = String(f.get('archive') ?? '').trim();
let body: object | null = null;
if (archive) body = { ...fields, archive };
else if (upload && upload.size) {
const res = await fetch(`${API}/api/mod/ebooks/upload?poet=${poetId}&name=${encodeURIComponent(upload.name)}`, {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' }, body: upload.stream(), duplex: 'half',
} as RequestInit);
const up = await res.json().catch(() => ({}));
if (res.ok) body = { ...fields, file: up.file }; else error = up.error ?? 'فائل اپ لوڈ نہیں ہوئی';
} else error = 'فائل چنیں یا archive.org کا ربط دیں';
if (body) {
const r = await asUser(token, '/api/mod/ebooks', body);
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.revision}`);
error = r.data.error ?? 'کچھ غلط ہو گیا';
}
}
---
<Base title="ای بک شامل کریں">
<h1>ای بک شامل کریں</h1>
<ModNav />
{!may ? <p class="form-error center" role="alert">اس شاعر کی ای بکس شامل کرنے کی اجازت نہیں۔</p> : (
<form method="post" enctype="multipart/form-data" class="account-form ebook-form">
<p class="center">شاعر: <a href={page.poet.url}>{page.poet.nickname}</a></p>
{error && <p class="form-error" role="alert">{error}</p>}
<label>عنوان<input name="title" required maxlength="300" data-urdu /></label>
<label>فائل (PDF، EPUB، TXT یا DOCX؛ زیادہ سے زیادہ ۱۰۰ میگا بائٹ)<input type="file" name="file" accept=".pdf,.epub,.txt,.docx,application/pdf,application/epub+zip,text/plain" /></label>
<label>یا archive.org کا ربط<input name="archive" dir="ltr" placeholder="https://archive.org/details/…" /></label>
<label>ماخذ<input name="source" maxlength="300" data-urdu placeholder="مثلاً: ناشر، سال، کتب خانہ" /></label>
<label>حقوق / اجازت<input name="licence" maxlength="300" data-urdu placeholder="مثلاً: عوامی ملکیت، مصنف کی اجازت سے" /></label>
<label>تفصیل<textarea name="note" rows="3" maxlength="2000" data-urdu></textarea></label>
<p class="muted note">کتاب جائزے کے بعد شاعر کے صفحے پر «ای بکس» میں نظر آئے گی۔ جائزہ لینے والے حقوق کی تصدیق کرتے ہیں۔</p>
<button>اپ لوڈ کریں</button>
</form>
)}
</Base>

View File

@ -36,21 +36,38 @@ const tagRows = tagging ? rev.content.split('\n').filter(Boolean).map((l: string
}) : []; }) : [];
const couplets = tagging && tagKind === 'work' ? (await asUser(token, `/api/mod/tags/work/${rev.entity_id}`)).data.couplets ?? 0 : 0; const couplets = tagging && tagKind === 'work' ? (await asUser(token, `/api/mod/tags/work/${rev.entity_id}`)).data.couplets ?? 0 : 0;
const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : []; const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : [];
// an e-book's details: title, source, copyright, note
const ebook = rev.entity === 'ebook';
const EB: [string, string][] = [['title', 'عنوان'], ['source', 'ماخذ'], ['licence', 'اجازت'], ['note', 'تفصیل']];
const ebMeta = ebook ? Object.fromEntries(EB.map(([k, label]) => [k, (rev.content.split('\n').find((l: string) => l.startsWith(label + ':')) ?? '').slice(label.length + 1).trim()])) : {};
const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]); const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
--- ---
<Base title={`مسودہ: ${rev.work_title}`}> <Base title={`مسودہ: ${rev.work_title}`}>
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{rev.work_title}</h1> <h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{ebook && 'ای بک: '}{rev.work_title}</h1>
<ModNav /> <ModNav />
<p class="center"> <p class="center">
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span> <span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
· <bdi dir="ltr">{rev.author_email}</bdi> · <bdi dir="ltr">{rev.author_email}</bdi>
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'} · {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
· <a href={rev.work_url}>{order ? 'حصہ' : tagging ? 'صفحہ' : 'کلام'}</a> · <a href={tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a> · <a href={rev.work_url}>{order ? 'حصہ' : tagging ? 'صفحہ' : ebook ? 'کتاب دیکھیں' : 'کلام'}</a>{!ebook && <> · <a href={tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a></>}
</p> </p>
{error && <p class="form-error" role="alert">{error}</p>} {error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>} {done && <p class="form-done" role="status">{done}</p>}
{may.save && tagging ? ( {may.save && ebook ? (
<form method="post" class="mod-edit account-form">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
<label>عنوان<input data-eb="title" value={ebMeta.title} maxlength="300" required data-urdu /></label>
<label>ماخذ<input data-eb="source" value={ebMeta.source} maxlength="300" data-urdu /></label>
<label>حقوق / اجازت<input data-eb="licence" value={ebMeta.licence} maxlength="300" data-urdu /></label>
<label>تفصیل<textarea data-eb="note" rows="3" maxlength="2000" data-urdu>{ebMeta.note}</textarea></label>
<textarea name="content" id="eb-content" hidden>{rev.content}</textarea>
<div class="filter-actions">
<button name="act" value="save">محفوظ کریں</button>
<button name="act" value="submit">{me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
</div>
</form>
) : may.save && tagging ? (
<form method="post" class="mod-edit"> <form method="post" class="mod-edit">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: موضوعات اور شخصیات کے ٹیگ" /></label> <label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: موضوعات اور شخصیات کے ٹیگ" /></label>
<p class="muted note">ہر ٹیگ کی قسم چنیں اور نام لکھیں۔{couplets > 0 && ' ٹیگ پورے کلام پر یا کسی ایک شعر پر لگ سکتا ہے۔'} پہلے سے موجود نام تجویز ہوتے ہیں۔</p> <p class="muted note">ہر ٹیگ کی قسم چنیں اور نام لکھیں۔{couplets > 0 && ' ٹیگ پورے کلام پر یا کسی ایک شعر پر لگ سکتا ہے۔'} پہلے سے موجود نام تجویز ہوتے ہیں۔</p>
@ -162,6 +179,7 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
<section class="lib-section"> <section class="lib-section">
<h2>پیش نظارہ</h2> <h2>پیش نظارہ</h2>
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol> {order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol>
: ebook ? <dl class="eb-meta">{EB.map(([k, label]) => ebMeta[k] && <><dt>{label}</dt><dd>{ebMeta[k]}</dd></>)}</dl>
: tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>) : tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>)
: <DivanPreview text={rev.content} />} : <DivanPreview text={rev.content} />}
</section> </section>
@ -182,6 +200,13 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
</ol> </ol>
</section> </section>
<script> <script>
// e-book details -> the hidden text, one "label: value" per line
const ebText = document.getElementById('eb-content') as HTMLTextAreaElement | null;
if (ebText) {
const LABEL: Record<string, string> = { title: 'عنوان', source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' };
const sync = () => { ebText.value = [...document.querySelectorAll<HTMLInputElement>('[data-eb]')].map((i) => `${LABEL[i.dataset.eb!]}: ${i.value.replace(/\s+/g, ' ').trim()}`).join('\n'); };
ebText.form!.addEventListener('input', sync); ebText.form!.addEventListener('submit', sync);
}
// tagging: rows -> the hidden text ("شعر 3 › موضوع: عشق"), on every change and before saving // tagging: rows -> the hidden text ("شعر 3 › موضوع: عشق"), on every change and before saving
const rowsBox = document.getElementById('tag-rows'); const rowsBox = document.getElementById('tag-rows');
const tagText = document.getElementById('tag-content') as HTMLTextAreaElement | null; const tagText = document.getElementById('tag-content') as HTMLTextAreaElement | null;

View File

@ -61,6 +61,14 @@ const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.
</section> </section>
)} )}
{res.ebooks?.length > 0 && (
<section class="search-group">
<h2>ای بکس <small class="muted">{ud(res.ebooks.length)}</small></h2>
<ul class="book-results">
{res.ebooks.map((b: any) => <li><a href={`/ebook/${b.id}`}><Seg s={hl(b.title)} /></a> · <a class="muted" href={b.poet_url}>{b.poet}</a></li>)}
</ul>
</section>
)}
{res.tags?.length > 0 && ( {res.tags?.length > 0 && (
<section class="search-group"> <section class="search-group">
<h2>ٹیگ <small class="muted">{ud(res.tags.length)}</small></h2> <h2>ٹیگ <small class="muted">{ud(res.tags.length)}</small></h2>

View File

@ -419,3 +419,9 @@ h1 + .muted { text-align: center; margin-top: 0; }
.tag-row .tag-name-box { flex: 1 1 12em; } .tag-row .tag-name-box { flex: 1 1 12em; }
.tag-row .tag-name { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); } .tag-row .tag-name { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); }
.tag-preview { display: flex; align-items: center; justify-content: center; gap: 6px; } .tag-preview { display: flex; align-items: center; justify-content: center; gap: 6px; }
/* e-books */
.ebook-frame { display: block; width: 100%; height: 82vh; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
.ebook-text { white-space: pre-wrap; line-height: 2.2; max-width: 900px; margin: 0 auto; padding: 16px; background: var(--inner); border: 1px solid var(--border); border-radius: 10px; }
.eb-meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; max-width: 700px; margin: 0 auto; }
.eb-meta dt { color: var(--muted); }
.eb-meta dd { margin: 0; }