initial commit
This commit is contained in:
commit
7f69f86c06
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
|
@ -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"]
|
|
@ -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.
|
|
@ -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`
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,8 @@
|
||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: lingdocs/rtl-epub-maker
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:3001:3001
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
|
@ -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.
|
|
@ -0,0 +1,6 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
|
@ -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 |
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
body, p {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
text-align: center;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
|
@ -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"]
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
type Frontmatter = Record<string, string>;
|
Loading…
Reference in New Issue