initial commit

This commit is contained in:
lingdocs 2022-02-10 02:23:08 +04:00
commit 7f69f86c06
28 changed files with 6652 additions and 0 deletions

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

47
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,47 @@
name: CI
# TODO: use caching
on:
push:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/rtl-epub-maker:latest
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: executing remote ssh commands
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_DOMAIN_NAME }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_KEY }}
port: 22
script: |
cd repos/rtl-epub-maker &&
git pull &&
docker-compose pull &&
docker-compose up -d
docker image prune -af

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# temp file storage
/tmp
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo

36
Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# Install dependencies only when needed
FROM node:16-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image, copy all the files and run next
# or pandoc/core
FROM pandoc/minimal:alpine AS runner
RUN apk add nodejs npm
WORKDIR /app
# You only need to copy next.config.js if you are NOT using the default configuration
# COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app ./
RUN npm install
EXPOSE 3001
ENV PORT 3001
ENV NEXT_TELEMETRY_DISABLED 1
CMD ["npm", "run", "start"]

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright 2022 lingdocs.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

22
README.md Normal file
View File

@ -0,0 +1,22 @@
# RTL EPUB Maker
Easily create EPUB e-book files with proper RTL support
## Running Locally
Requires [pandoc](https://pandoc.org/installing.html)
```
npm install
npm run dev
```
### With Docker
Or, you can run the docker image. (Only available for `linux/amd64` architectures.)
```
docker compose up
```
App will be served on `http://localhost:3001`

View File

@ -0,0 +1,105 @@
import { ChangeEvent, useState, useRef } from "react";
import Select from "react-select";
const requiredFields = [
"title",
];
const possibleFields = [
"date",
"description",
"rights",
"belongs-to-collection",
"author",
"editor",
"translator",
]
type Option = {
value: string,
label: string,
};
const baseSettings = {
language: "ps-AF",
dir: "rtl",
"page-progression-direction": "rtl",
};
function BookInfoInput({ handleSubmit }: { handleSubmit: (info: { frontmatter: Frontmatter, cover: File | undefined }) => void }) {
const coverRef = useRef<any>(null);
const [fieldsChosen, setFieldsChosen] = useState<string[]>([]);
const [state, setState] = useState<Frontmatter>(Object.assign({}, ...requiredFields.map(f => ({ [f]: "" }))));
const fields = [...requiredFields, ...fieldsChosen];
const availableFields = possibleFields.filter(f => !fieldsChosen.includes(f));
const availableFieldsOptions = availableFields.map((f): Option => ({
value: f,
label: f,
}));
function handleAddField(o: Option) {
setFieldsChosen(s => [...s, o.value]);
}
function handleRemoveField(f: string) {
setFieldsChosen(s => s.filter(x => x !== f));
setState(s => {
const newS = { ...s };
delete newS[f];
return newS;
});
}
function handleFieldChange(e: ChangeEvent<HTMLInputElement>) {
const name = e.target.name;
const value = e.target.value;
setState(s => ({
...s,
[name]: value,
}));
}
function submit() {
const cover = coverRef.current.files[0] as (File | undefined);
handleSubmit({
frontmatter: {
...state,
...baseSettings,
},
cover,
});
}
return <div style={{ maxWidth: "500px" }}>
<h4>Book Metadata</h4>
<div className="my-3">
<label htmlFor="cover-file" className="form-label">cover image <span className="text-muted">(.jpg or .png less than 5mb)</span></label>
<input multiple={false} ref={coverRef} className="form-control" type="file" id="cover-file" accept="image/jpeg,image/png"/>
</div>
{fields.map((field) => (
<div key={field} className="d-flex flex-row align-items-end mb-2">
<div className="col-auto" style={{ width: "100%" }}>
<label htmlFor={field} className="form-label d-flex flex-row align-items-center">
{!requiredFields.includes(field) && <span className="me-2">
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={() => handleRemoveField(field)}>
X
</button>
</span>}
<span>{field}</span>
</label>
<input onChange={handleFieldChange} type="text" className="form-control" id={field} name={field} value={state[field]} />
</div>
</div>
))}
<div className="mt-4 mb-2">add fields:</div>
<Select
className="basic-single"
classNamePrefix="select"
isClearable={true}
value={[]}
isSearchable
// @ts-ignore
onChange={handleAddField}
// @ts-ignore
options={availableFieldsOptions}
/>
<button onClick={submit} type="button" className="btn btn-primary my-4">Submit</button>
</div>
}
export default BookInfoInput;

View File

@ -0,0 +1,28 @@
import { useDropzone } from "react-dropzone";
import { uploadDoc } from "../lib/fetchers";
function DocReceiver({ handleReceiveText }: {
handleReceiveText: (content: string) => void,
}) {
function onDrop(files: File[]) {
uploadDoc(files[0], {
start: () => null,
error: () => null,
progress: () => null,
complete: (m: string) => {
handleReceiveText(m);
}
})
}
const {getRootProps, getInputProps, isDragActive} = useDropzone({
onDrop,
multiple: false,
// accept: [".doc", ".docx", ".md", ".txt", "text/*", ""],
});
return <div {...getRootProps()} className="clickable d-flex flex-row align-items-center justify-content-center" style={{ padding: "2rem 1rem", border: "2px dashed grey", textAlign: "center", backgroundColor: isDragActive ? "#34a8eb" : "inherit" }}>
<input {...getInputProps()} />
<div className="text-muted">Add Text/Markdown File or Word Doc</div>
</div>;
}
export default DocReceiver;

8
docker-compose.yml Normal file
View File

@ -0,0 +1,8 @@
version: "3.7"
services:
app:
image: lingdocs/rtl-epub-maker
restart: always
ports:
- 127.0.0.1:3001:3001

16
lib/backend-file-utils.ts Normal file
View File

@ -0,0 +1,16 @@
import { unlink, statSync } from "fs";
export function deleteFile(...files: (string | undefined)[]) {
files.forEach((f) => {
if (!f) return;
try {
statSync(f);
} catch(e) {
console.error("file not found for deletion:", f);
return;
}
unlink(f, (err) => {
if (err) console.error(err);
});
});
}

83
lib/fetchers.ts Normal file
View File

@ -0,0 +1,83 @@
export function makeProgressPercent(e: { total: number, loaded: number}): number {
const { total, loaded } = e;
const progress = (total !== 0)
? Math.floor((loaded / total) * 100)
: 0;
return progress > 0 ? progress : 0;
}
export function bookRequest(req: {
frontmatter: Frontmatter,
content: string,
cover?: File,
}, callback: {
start: (upload: { cancel: () => void }) => void,
progress: (percentage: number) => void,
error: () => void,
}) {
const formData = new FormData();
const xhr = new XMLHttpRequest();
xhr.responseType = "blob";
formData.append("content", req.content);
formData.append("frontmatter", JSON.stringify(req.frontmatter));
if ("cover" in req && req.cover) {
formData.append("file", req.cover);
}
xhr.onload = () => {
const url = window.URL.createObjectURL(xhr.response);
const a = document.createElement('a');
a.href = url;
a.download = `${req.frontmatter.title}.epub`;
document.body.appendChild(a);
a.click();
a.remove();
};
xhr.upload.addEventListener('progress', (e) => {
const progress = makeProgressPercent(e);
callback.progress(progress);
}, false);
xhr.addEventListener('error', (e) => {
console.error(e);
callback.error();
}, false);
xhr.open("POST", "/api/book");
xhr.send(formData);
function cancel() {
xhr.abort();
}
callback.start({ cancel });
}
export function uploadDoc(file: File, callback: {
start: (upload: { cancel: () => void }) => void,
progress: (percentage: number) => void,
error: () => void,
complete: (markdown: string) => void,
}) {
const formData = new FormData();
const xhr = new XMLHttpRequest();
formData.append("file", file);
xhr.onload = () => {
try {
const { markdown } = JSON.parse(xhr.responseText);
callback.complete(markdown);
} catch (e) {
console.error(e);
callback.error();
}
};
xhr.upload.addEventListener('progress', (e) => {
const progress = makeProgressPercent(e);
callback.progress(progress);
}, false);
xhr.addEventListener('error', (e) => {
console.error(e);
callback.error();
}, false);
xhr.open("POST", "/api/file");
xhr.send(formData);
function cancel() {
xhr.abort();
}
callback.start({ cancel });
}

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

6
next.config.js Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig

5916
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "book-builder",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"multer": "^1.4.4",
"next": "12.0.10",
"next-connect": "^0.12.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-dropzone": "^12.0.1",
"react-select": "^5.2.2",
"write-yaml-file": "^4.2.0"
},
"devDependencies": {
"@types/multer": "^1.4.7",
"@types/node": "17.0.16",
"@types/react": "17.0.39",
"eslint": "8.8.0",
"eslint-config-next": "12.0.10",
"typescript": "4.5.5"
}
}

8
pages/_app.tsx Normal file
View File

@ -0,0 +1,8 @@
import "../styles/globals.css";
import type { AppProps } from "next/app";
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
export default MyApp

98
pages/api/book.ts Normal file
View File

@ -0,0 +1,98 @@
import nextConnect from "next-connect";
import { NextApiResponse } from "next";
import writeYamlFile from "write-yaml-file";
import { statSync, createReadStream, writeFileSync, readFileSync } from "fs";
import { spawn } from "child_process";
import { join } from "path";
import { deleteFile } from "../../lib/backend-file-utils";
import multer from "multer";
const upload = multer({
storage: multer.diskStorage({
destination: './tmp',
filename: (req, file, cb) => {
cb(null, file.originalname);
},
}),
});
const route = nextConnect<any, NextApiResponse>({
onError(error, req, res) {
res.status(501).json({ error: `Sorry something Happened! ${error.message}` });
},
onNoMatch(req, res) {
res.status(405).json({ error: `Method '${req.method}' Not Allowed` });
},
});
route.use(upload.any());
route.post((req, res) => {
const coverFile = (req.files[0] as Express.Multer.File);
const cover = coverFile ? join("tmp", coverFile.filename) : undefined;
const content = req.body.content as string;
const frontmatter = JSON.parse(req.body.frontmatter) as Frontmatter;
makeEpub(content, frontmatter, cover, (err, toDownload) => {
if (err || !toDownload) throw err;
const stat = statSync(toDownload);
// express res.download doesn't work with next-connect
res.writeHead(200, {
'Content-Type': 'application/epub+zip',
'Content-Length': stat.size,
'Content-Disposition': "attacment",
});
const readStream = createReadStream(toDownload);
readStream.pipe(res);
readStream.on("close", () => {
deleteFile(toDownload);
});
});
});
export default route;
export const config = {
api: {
bodyParser: false,
},
};
async function makeEpub(content: string, frontmatter: Frontmatter, cover: string | undefined, callback: (err: any, toDownload?: string) => void) {
const ts = new Date().getTime().toString();
const mdFile = join("tmp", `${ts}.md`);
const yamlFile = join("tmp", `${ts}.yaml`);
const epubFile = join("tmp", `${ts}.epub`);
if (cover) {
frontmatter["epub-cover-image"] = cover;
frontmatter["cover-image"] = cover;
}
writeFileSync(mdFile, content, "utf-8");
await writeYamlFile(yamlFile, frontmatter);
writeFileSync(yamlFile, `
---
${readFileSync(yamlFile, "utf-8")}
---
`, "utf8");
const pandoc = spawn("pandoc", [
"--table-of-contents",
"--css",
join("templates", "book.css"),
"-o",
epubFile,
yamlFile,
mdFile,
]);
pandoc.on("error", (err) => {
console.error("error converting word document");
console.error(err);
});
pandoc.stderr.on("data", data => {
console.error(data.toString());
});
pandoc.on("close", (code) => {
if (code === 0) {
callback(null, epubFile);
} else {
callback("error making epub with pandoc");
}
deleteFile(mdFile, yamlFile, cover);
});
}

86
pages/api/file.ts Normal file
View File

@ -0,0 +1,86 @@
import nextConnect from "next-connect";
import { NextApiResponse } from "next";
import multer from "multer";
import { readFile, readFileSync } from "fs";
import { spawn } from "child_process";
import { join, extname } from "path";
import { deleteFile } from "../../lib/backend-file-utils";
const upload = multer({
storage: multer.diskStorage({
destination: './tmp',
filename: (req, file, cb) => {
cb(null, file.originalname);
},
}),
});
const route = nextConnect<any, NextApiResponse>({
onError(error, req, res) {
res.status(501).json({ error: `Sorry something Happened! ${error.message}` });
},
onNoMatch(req, res) {
res.status(405).json({ error: `Method '${req.method}' Not Allowed` });
},
});
route.use(upload.any());
route.post((req, res) => {
if (!Array.isArray(req.files)) {
res.status(400).send({ ok: false, problem: "file(s) needed" });
return;
}
const file = req.files[0] as Express.Multer.File;
if (!file) {
throw new Error("file not uploaded");
}
const isWordDoc = extname(file.filename).startsWith(".doc");
(isWordDoc ? convertWordDoc : getTextDoc)(file.filename, (err, markdown) => {
if (err) throw err;
res.json({ markdown });
});
});
export default route;
export const config = {
api: {
bodyParser: false,
},
};
function getTextDoc(doc: string, callback: (err: any, markdown?: string) => void) {
readFile(join("tmp", doc), "utf8", (err, data) => {
if (err) callback(err);
callback(null, data);
deleteFile(join("tmp", doc));
});
}
function convertWordDoc(doc: string, callback: (err: any, markdown?: string) => void) {
const outputName = doc + ".md";
const pandoc = spawn("pandoc", [
"-s",
join("tmp", doc),
"-t",
"markdown",
"-o",
join("tmp", outputName),
]);
pandoc.on("error", (err) => {
console.error("error converting word document");
console.error(err);
});
pandoc.stderr.on("data", data => {
console.error(data.toString());
});
pandoc.on("close", (code) => {
if (code === 0) {
callback(null, readFileSync(join("tmp", outputName), "utf8"));
} else {
callback("error converting word file");
}
deleteFile(join("tmp", outputName), join("tmp", doc));
});
}

62
pages/index.tsx Normal file
View File

@ -0,0 +1,62 @@
import type { NextPage } from "next";
import { useRef } from "react";
import Head from "next/head";
import BookInfoInput from "../components/BookInfoInput";
import DocReceiver from "../components/DocReceiver";
import { bookRequest } from "../lib/fetchers";
// TODO: Make Title Required
// TODO: Allow Word File straight w/ images etc upload
// TDOO: Add language selection option (Pashto, Arabic, Farsi, Urdu)
const Home: NextPage = () => {
const mdRef = useRef<any>(null);
function handleReceiveText(m: string) {
mdRef.current.value = m;
}
function clearText() {
mdRef.current.value = "";
}
function handleSubmit(info: { frontmatter: Frontmatter, cover: File | undefined }) {
const content = mdRef.current.value as string;
bookRequest({
...info,
content,
}, {
// TODO: Implement progress display etc
start: () => null,
progress: () => null,
error: () => null,
});
}
return (
<div className="container" style={{ marginBottom: "50px" }}>
<Head>
<title>RTL EPUB Maker</title>
<meta name="description" content="Easily create EPUB e-book files with proper RTL support" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossOrigin="anonymous" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" href="/favicon.ico" />
</Head>
<h1 className="mt-3">RTL EPUB Maker 📚</h1>
<p className="lead mb-4">Easily create EPUB e-book files with proper RTL support</p>
<DocReceiver handleReceiveText={handleReceiveText}/>
<div className="mt-3">
<label htmlFor="mdTextarea" className="form-label">Markdown Content</label>
<textarea spellCheck="false" dir="rtl" ref={mdRef} className="form-control" id="mdTextarea" rows={15} />
</div>
<div style={{ textAlign: "right" }}>
<button type="button" className="btn btn-sm btn-light mt-2" onClick={clearText}>Clear</button>
</div>
<BookInfoInput handleSubmit={handleSubmit} />
<div className="text-center mt-4 text-muted">
<p className="lead">Made by <a className="em-link" href="https://lingdocs.com">LingDocs.com</a></p>
<p>Submissions are private. Nothing is kept on the server. See the <a className="em-link" href="https://github.com/lingdocs/rtl-epub-maker">source code here</a>.</p>
</div>
</div>
)
}
export default Home

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

14
styles/globals.css Normal file
View File

@ -0,0 +1,14 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
}
* {
box-sizing: border-box;
}

13
templates/book.css Normal file
View File

@ -0,0 +1,13 @@
body, p {
direction: rtl;
}
h1,
h2,
h3,
h4,
h5,
h6 {
text-align: center;
direction: rtl;
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

1
types.d.ts vendored Normal file
View File

@ -0,0 +1 @@
type Frontmatter = Record<string, string>;