Add Single file archive method.

This commit is contained in:
András Rutkai 2024-03-15 19:41:41 +01:00
parent 5990d4ce2d
commit 5fe6a5b19a
32 changed files with 211 additions and 31 deletions

View File

@ -45,6 +45,10 @@ PROXY_BYPASS=
PDF_MARGIN_TOP= PDF_MARGIN_TOP=
PDF_MARGIN_BOTTOM= PDF_MARGIN_BOTTOM=
# Singlefile archive settings
SINGLEFILE_ARCHIVE_COMMAND= # single-file "{{URL}}" --dump-content
SINGLEFILE_ARCHIVE_HTTP_API= # http://singlefile:3000/
# #
# SSO Providers # SSO Providers
# #

View File

@ -57,7 +57,7 @@ We've forked the old version from the current repository into [this repo](https:
## Features ## Features
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage. - 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional) - 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, sub-collection, name, description and multiple tags. - 📂 Organize links by collection, sub-collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection. - 👥 Collaborate on gathering links in a collection.

View File

@ -37,6 +37,7 @@ export default function CollectionCard({ collection, className }: Props) {
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsSinglefile: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean, archiveAsPDF: undefined as unknown as boolean,
}); });
@ -52,6 +53,7 @@ export default function CollectionCard({ collection, className }: Props) {
username: account.username as string, username: account.username as string,
image: account.image as string, image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean, archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsSinglefile: account.archiveAsSinglefile as boolean,
archiveAsPDF: account.archiveAsPDF as boolean, archiveAsPDF: account.archiveAsPDF as boolean,
}); });
} }

View File

@ -43,6 +43,8 @@ export default function LinkGroupedIconURL({
<i className={`bi-file-earmark-pdf`}></i> <i className={`bi-file-earmark-pdf`}></i>
) : link.type === "image" ? ( ) : link.type === "image" ? (
<i className={`bi-file-earmark-image`}></i> <i className={`bi-file-earmark-image`}></i>
) : link.type === "singlefile" ? (
<i className={`bi-filetype-html`}></i>
) : undefined} ) : undefined}
<p className="truncate bg-white text-black mr-1"> <p className="truncate bg-white text-black mr-1">
<p className="text-sm">{shortendURL}</p> <p className="text-sm">{shortendURL}</p>

View File

@ -42,6 +42,8 @@ export default function LinkIcon({
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i> <i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
) : link.type === "image" ? ( ) : link.type === "image" ? (
<i className={`bi-file-earmark-image ${iconClasses}`}></i> <i className={`bi-file-earmark-image ${iconClasses}`}></i>
) : link.type === "singlefile" ? (
<i className={`bi-filetype-html ${iconClasses}`}></i>
) : undefined} ) : undefined}
</> </>
); );

View File

@ -65,6 +65,7 @@ export default function EditCollectionSharingModal({
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsSinglefile: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean, archiveAsPDF: undefined as unknown as boolean,
}); });

View File

@ -29,6 +29,7 @@ export default function NewLinkModal({ onClose }: Props) {
image: "", image: "",
pdf: "", pdf: "",
readable: "", readable: "",
singlefile: "",
textContent: "", textContent: "",
collection: { collection: {
name: "", name: "",

View File

@ -12,6 +12,7 @@ import { useSession } from "next-auth/react";
import { import {
pdfAvailable, pdfAvailable,
readabilityAvailable, readabilityAvailable,
singlefileAvailable,
screenshotAvailable, screenshotAvailable,
} from "@/lib/shared/getArchiveValidity"; } from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow"; import PreservedFormatRow from "@/components/PreserverdFormatRow";
@ -42,6 +43,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsSinglefile: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean, archiveAsPDF: undefined as unknown as boolean,
}); });
@ -59,6 +61,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
username: account.username as string, username: account.username as string,
image: account.image as string, image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean, archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsSinglefile: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean, archiveAsPDF: account.archiveAsPDF as boolean,
}); });
} }
@ -73,6 +76,9 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
(collectionOwner.archiveAsScreenshot === true (collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending" ? link.pdf && link.pdf !== "pending"
: true) && : true) &&
(collectionOwner.archiveAsSinglefile === true
? link.singlefile && link.singlefile !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true (collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending" ? link.pdf && link.pdf !== "pending"
: true) && : true) &&
@ -109,7 +115,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
clearInterval(interval); clearInterval(interval);
} }
}; };
}, [link?.image, link?.pdf, link?.readable]); }, [link?.image, link?.pdf, link?.readable, link?.singlefile]);
const updateArchive = async () => { const updateArchive = async () => {
const load = toast.loading("Sending request..."); const load = toast.loading("Sending request...");
@ -140,7 +146,8 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
{isReady() && {isReady() &&
(screenshotAvailable(link) || (screenshotAvailable(link) ||
pdfAvailable(link) || pdfAvailable(link) ||
readabilityAvailable(link)) ? ( readabilityAvailable(link) ||
singlefileAvailable(link)) ? (
<p className="mb-3"> <p className="mb-3">
The following formats are available for this link: The following formats are available for this link:
</p> </p>
@ -183,6 +190,16 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
activeLink={link} activeLink={link}
/> />
) : undefined} ) : undefined}
{singlefileAvailable(link) ? (
<PreservedFormatRow
name={"Singlefile"}
icon={"bi-filetype-html"}
format={ArchivedFormat.singlefile}
activeLink={link}
downloadable={true}
/>
) : undefined}
</> </>
) : ( ) : (
<div <div

View File

@ -31,6 +31,7 @@ export default function UploadFileModal({ onClose }: Props) {
image: "", image: "",
pdf: "", pdf: "",
readable: "", readable: "",
singlefile: "",
textContent: "", textContent: "",
collection: { collection: {
name: "", name: "",
@ -101,7 +102,7 @@ export default function UploadFileModal({ onClose }: Props) {
const submit = async () => { const submit = async () => {
if (!submitLoader && file) { if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null; let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null; let linkType: "url" | "image" | "singlefile" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") { if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg; fileType = ArchivedFormat.jpeg;
@ -109,6 +110,9 @@ export default function UploadFileModal({ onClose }: Props) {
} else if (file.type === "image/png") { } else if (file.type === "image/png") {
fileType = ArchivedFormat.png; fileType = ArchivedFormat.png;
linkType = "image"; linkType = "image";
} else if (file.type === "text/html") {
fileType = ArchivedFormat.singlefile;
linkType = "singlefile";
} else if (file.type === "application/pdf") { } else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf; fileType = ArchivedFormat.pdf;
linkType = "pdf"; linkType = "pdf";
@ -165,13 +169,13 @@ export default function UploadFileModal({ onClose }: Props) {
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between"> <label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input <input
type="file" type="file"
accept=".pdf,.png,.jpg,.jpeg" accept=".pdf,.png,.jpg,.jpeg,.html"
className="cursor-pointer custom-file-input" className="cursor-pointer custom-file-input"
onChange={(e) => e.target.files && setFile(e.target.files[0])} onChange={(e) => e.target.files && setFile(e.target.files[0])}
/> />
</label> </label>
<p className="text-xs font-semibold mt-2"> <p className="text-xs font-semibold mt-2">
PDF, PNG, JPG (Up to {process.env.NEXT_PUBLIC_MAX_FILE_SIZE || 30} PDF, PNG, JPG, HTML (Up to {process.env.NEXT_PUBLIC_MAX_FILE_SIZE || 30}
MB) MB)
</p> </p>
</div> </div>

View File

@ -1,10 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { import { ArchivedFormat, LinkIncludingShortenedCollectionAndTags } from "@/types/global";
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
@ -61,7 +57,7 @@ export default function PreservedFormatRow({
clearInterval(interval); clearInterval(interval);
} }
}; };
}, [link?.image, link?.pdf, link?.readable]); }, [link?.image, link?.pdf, link?.readable, link?.singlefile]);
const handleDownload = () => { const handleDownload = () => {
const path = `/api/v1/archives/${link?.id}?format=${format}`; const path = `/api/v1/archives/${link?.id}?format=${format}`;
@ -69,10 +65,10 @@ export default function PreservedFormatRow({
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
// Create a temporary link and click it to trigger the download // Create a temporary link and click it to trigger the download
const link = document.createElement("a"); const anchorElement = document.createElement("a");
link.href = path; anchorElement.href = path;
link.download = format === ArchivedFormat.pdf ? "PDF" : "Screenshot"; anchorElement.download = format === ArchivedFormat.singlefile ? (link.name ?? 'index') : format === ArchivedFormat.pdf ? "PDF" : "Screenshot";
link.click(); anchorElement.click();
} else { } else {
console.error("Failed to download file"); console.error("Failed to download file");
} }

View File

@ -65,9 +65,11 @@ export default function ReadableView({ link }: Props) {
(link?.image === "pending" || (link?.image === "pending" ||
link?.pdf === "pending" || link?.pdf === "pending" ||
link?.readable === "pending" || link?.readable === "pending" ||
link?.singlefile === "pending" ||
!link?.image || !link?.image ||
!link?.pdf || !link?.pdf ||
!link?.readable) !link?.readable ||
!link?.singlefile)
) { ) {
interval = setInterval(() => getLink(link.id as number), 5000); interval = setInterval(() => getLink(link.id as number), 5000);
} else { } else {
@ -81,7 +83,7 @@ export default function ReadableView({ link }: Props) {
clearInterval(interval); clearInterval(interval);
} }
}; };
}, [link?.image, link?.pdf, link?.readable]); }, [link?.image, link?.pdf, link?.readable, link?.singlefile]);
const rgbToHex = (r: number, g: number, b: number): string => const rgbToHex = (r: number, g: number, b: number): string =>
"#" + "#" +

View File

@ -19,3 +19,6 @@ services:
- ./data:/data/data - ./data:/data/data
depends_on: depends_on:
- postgres - postgres
singlefile:
image: rutkai/single-file-web:latest
container_name: singlefile

View File

@ -9,6 +9,9 @@ import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize"; import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile"; import removeFile from "./storage/removeFile";
import Jimp from "jimp"; import Jimp from "jimp";
import { execSync } from "child_process";
import axios from "axios";
import { Agent } from "http";
import createFolder from "./storage/createFolder"; import createFolder from "./storage/createFolder";
type LinksAndCollectionAndOwner = Link & { type LinksAndCollectionAndOwner = Link & {
@ -93,6 +96,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
readable: !link.readable?.startsWith("archive") readable: !link.readable?.startsWith("archive")
? "pending" ? "pending"
: undefined, : undefined,
singlefile: !link.singlefile?.startsWith("archive")
? "pending"
: undefined,
preview: !link.readable?.startsWith("archive") preview: !link.readable?.startsWith("archive")
? "pending" ? "pending"
: undefined, : undefined,
@ -113,19 +119,46 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
const content = await page.content(); const content = await page.content();
// TODO single file // Singlefile
// const session = await page.context().newCDPSession(page); if (user.archiveAsSinglefile && !link.singlefile?.startsWith("archive")) {
// const doc = await session.send("Page.captureSnapshot", { let command = process.env.SINGLEFILE_ARCHIVE_COMMAND;
// format: "mhtml", let httpApi = process.env.SINGLEFILE_ARCHIVE_HTTP_API;
// }); if (command) {
// const saveDocLocally = (doc: any) => { if (command.includes("{{URL}}")) {
// console.log(doc); try {
// return createFile({ let html = execSync(command.replace("{{URL}}", link.url), {
// data: doc, timeout: 60000,
// filePath: `archives/${targetLink.collectionId}/${link.id}.mhtml`, maxBuffer: 1024 * 1024 * 100,
// }); });
// }; await createFile({
// saveDocLocally(doc.data); data: html,
filePath: `archives/${targetLink.collectionId}/${link.id}.html`,
});
} catch (err) {
console.error("Error running SINGLEFILE_ARCHIVE_COMMAND:", err);
}
} else {
console.error("Invalid SINGLEFILE_ARCHIVE_COMMAND. Missing {{URL}}");
}
} else if (httpApi) {
try {
let html = await axios.post(httpApi, { url: link.url }, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
httpAgent: new Agent({ keepAlive: false }),
});
await createFile({
data: html.data,
filePath: `archives/${targetLink.collectionId}/${link.id}.html`,
});
} catch (err) {
console.error("Error fetching Singlefile using SINGLEFILE_ARCHIVE_HTTP_API:", err);
}
} else {
console.error("No SINGLEFILE_ARCHIVE_COMMAND or SINGLEFILE_ARCHIVE_HTTP_API defined.");
}
}
// Readability // Readability
const window = new JSDOM("").window; const window = new JSDOM("").window;
@ -284,6 +317,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
image: user.archiveAsScreenshot image: user.archiveAsScreenshot
? `archives/${linkExists.collectionId}/${link.id}.png` ? `archives/${linkExists.collectionId}/${link.id}.png`
: undefined, : undefined,
singlefile: user.archiveAsSinglefile
? `archives/${linkExists.collectionId}/${link.id}.html`
: undefined,
pdf: user.archiveAsPDF pdf: user.archiveAsPDF
? `archives/${linkExists.collectionId}/${link.id}.pdf` ? `archives/${linkExists.collectionId}/${link.id}.pdf`
: undefined, : undefined,
@ -314,6 +350,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
image: !finalLink.image?.startsWith("archives") image: !finalLink.image?.startsWith("archives")
? "unavailable" ? "unavailable"
: undefined, : undefined,
singlefile: !finalLink.singlefile?.startsWith("archives")
? "unavailable"
: undefined,
pdf: !finalLink.pdf?.startsWith("archives") pdf: !finalLink.pdf?.startsWith("archives")
? "unavailable" ? "unavailable"
: undefined, : undefined,
@ -324,6 +363,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}); });
else { else {
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` }); removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.html` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` }); removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({ removeFile({
filePath: `archives/${link.collectionId}/${link.id}_readability.json`, filePath: `archives/${link.collectionId}/${link.id}_readability.json`,

View File

@ -52,6 +52,9 @@ export default async function deleteLinksById(
removeFile({ removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
}); });
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.html`,
});
} }
return { response: deletedLinks, status: 200 }; return { response: deletedLinks, status: 200 };

View File

@ -30,6 +30,9 @@ export default async function deleteLink(userId: number, linkId: number) {
removeFile({ removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
}); });
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.html`,
});
return { response: deleteLink, status: 200 }; return { response: deleteLink, status: 200 };
} }

View File

@ -160,6 +160,11 @@ export default async function updateLinkById(
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
`archives/${data.collection.id}/${linkId}_readability.json` `archives/${data.collection.id}/${linkId}_readability.json`
); );
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.html`,
`archives/${data.collection.id}/${linkId}.html`
);
} }
return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };

View File

@ -75,6 +75,7 @@ export default async function getPublicUser(
username: lessSensitiveInfo.username, username: lessSensitiveInfo.username,
image: lessSensitiveInfo.image, image: lessSensitiveInfo.image,
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot, archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
archiveAsSinglefile: lessSensitiveInfo.archiveAsSinglefile,
archiveAsPDF: lessSensitiveInfo.archiveAsPDF, archiveAsPDF: lessSensitiveInfo.archiveAsPDF,
}; };

View File

@ -187,6 +187,7 @@ export default async function updateUserById(
(value, index, self) => self.indexOf(value) === index (value, index, self) => self.indexOf(value) === index
), ),
archiveAsScreenshot: data.archiveAsScreenshot, archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsSinglefile: data.archiveAsSinglefile,
archiveAsPDF: data.archiveAsPDF, archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine, archiveAsWaybackMachine: data.archiveAsWaybackMachine,
linksRouteTo: data.linksRouteTo, linksRouteTo: data.linksRouteTo,

View File

@ -10,6 +10,7 @@ import util from "util";
type ReturnContentTypes = type ReturnContentTypes =
| "text/plain" | "text/plain"
| "text/html"
| "image/jpeg" | "image/jpeg"
| "image/png" | "image/png"
| "application/pdf" | "application/pdf"
@ -61,6 +62,8 @@ export default async function readFile(filePath: string) {
contentType = "image/png"; contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) { } else if (filePath.endsWith("_readability.json")) {
contentType = "application/json"; contentType = "application/json";
} else if (filePath.endsWith(".html")) {
contentType = "text/html";
} else { } else {
// if (filePath.endsWith(".jpg")) // if (filePath.endsWith(".jpg"))
contentType = "image/jpeg"; contentType = "image/jpeg";
@ -88,6 +91,8 @@ export default async function readFile(filePath: string) {
contentType = "image/png"; contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) { } else if (filePath.endsWith("_readability.json")) {
contentType = "application/json"; contentType = "application/json";
} else if (filePath.endsWith(".html")) {
contentType = "text/html";
} else { } else {
// if (filePath.endsWith(".jpg")) // if (filePath.endsWith(".jpg"))
contentType = "image/jpeg"; contentType = "image/jpeg";

View File

@ -7,6 +7,7 @@ import { LinksRouteTo } from "@prisma/client";
import { import {
pdfAvailable, pdfAvailable,
readabilityAvailable, readabilityAvailable,
singlefileAvailable,
screenshotAvailable, screenshotAvailable,
} from "../shared/getArchiveValidity"; } from "../shared/getArchiveValidity";
@ -27,6 +28,10 @@ export const generateLinkHref = (
if (!readabilityAvailable(link)) return link.url || ""; if (!readabilityAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
case LinksRouteTo.SINGLEFILE:
if (!singlefileAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.singlefile}`;
case LinksRouteTo.SCREENSHOT: case LinksRouteTo.SCREENSHOT:
if (!screenshotAvailable(link)) return link.url || ""; if (!screenshotAvailable(link)) return link.url || "";

View File

@ -28,6 +28,17 @@ export function readabilityAvailable(
); );
} }
export function singlefileAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return (
link &&
link.singlefile &&
link.singlefile !== "pending" &&
link.singlefile !== "unavailable"
);
}
export function previewAvailable(link: any) { export function previewAvailable(link: any) {
return ( return (
link && link &&

View File

@ -27,6 +27,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
else if (format === ArchivedFormat.jpeg) suffix = ".jpeg"; else if (format === ArchivedFormat.jpeg) suffix = ".jpeg";
else if (format === ArchivedFormat.pdf) suffix = ".pdf"; else if (format === ArchivedFormat.pdf) suffix = ".pdf";
else if (format === ArchivedFormat.readability) suffix = "_readability.json"; else if (format === ArchivedFormat.readability) suffix = "_readability.json";
else if (format === ArchivedFormat.singlefile) suffix = ".html";
//@ts-ignore //@ts-ignore
if (!linkId || !suffix) if (!linkId || !suffix)

View File

@ -76,6 +76,7 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
image: null, image: null,
pdf: null, pdf: null,
readable: null, readable: null,
singlefile: null,
preview: null, preview: null,
}, },
}); });
@ -89,6 +90,9 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
await removeFile({ await removeFile({
filePath: `archives/${link.collection.id}/${link.id}_readability.json`, filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
}); });
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.html`,
});
await removeFile({ await removeFile({
filePath: `archives/preview/${link.collection.id}/${link.id}.png`, filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
}); });

View File

@ -61,6 +61,7 @@ export default function Index() {
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsSinglefile: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean, archiveAsPDF: undefined as unknown as boolean,
}); });
@ -78,6 +79,7 @@ export default function Index() {
username: account.username as string, username: account.username as string,
image: account.image as string, image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean, archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsSinglefile: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean, archiveAsPDF: account.archiveAsPDF as boolean,
}); });
} }

View File

@ -36,6 +36,12 @@ export default function Index() {
{link && Number(router.query.format) === ArchivedFormat.readability && ( {link && Number(router.query.format) === ArchivedFormat.readability && (
<ReadableView link={link} /> <ReadableView link={link} />
)} )}
{link && Number(router.query.format) === ArchivedFormat.singlefile && (
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.singlefile}`}
className="w-full h-screen border-none"
></iframe>
)}
{link && Number(router.query.format) === ArchivedFormat.pdf && ( {link && Number(router.query.format) === ArchivedFormat.pdf && (
<iframe <iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`} src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`}

View File

@ -53,6 +53,7 @@ export default function PublicCollections() {
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsSinglefile: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean, archiveAsPDF: undefined as unknown as boolean,
}); });

View File

@ -20,6 +20,8 @@ export default function Appearance() {
useState<boolean>(false); useState<boolean>(false);
const [archiveAsScreenshot, setArchiveAsScreenshot] = const [archiveAsScreenshot, setArchiveAsScreenshot] =
useState<boolean>(false); useState<boolean>(false);
const [archiveAsSinglefile, setArchiveAsSinglefile] =
useState<boolean>(false);
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false); const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] = const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
useState<boolean>(false); useState<boolean>(false);
@ -31,6 +33,7 @@ export default function Appearance() {
setUser({ setUser({
...account, ...account,
archiveAsScreenshot, archiveAsScreenshot,
archiveAsSinglefile,
archiveAsPDF, archiveAsPDF,
archiveAsWaybackMachine, archiveAsWaybackMachine,
linksRouteTo, linksRouteTo,
@ -39,6 +42,7 @@ export default function Appearance() {
}, [ }, [
account, account,
archiveAsScreenshot, archiveAsScreenshot,
archiveAsSinglefile,
archiveAsPDF, archiveAsPDF,
archiveAsWaybackMachine, archiveAsWaybackMachine,
linksRouteTo, linksRouteTo,
@ -52,6 +56,7 @@ export default function Appearance() {
useEffect(() => { useEffect(() => {
if (!objectIsEmpty(account)) { if (!objectIsEmpty(account)) {
setArchiveAsScreenshot(account.archiveAsScreenshot); setArchiveAsScreenshot(account.archiveAsScreenshot);
setArchiveAsSinglefile(account.archiveAsSinglefile);
setArchiveAsPDF(account.archiveAsPDF); setArchiveAsPDF(account.archiveAsPDF);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine); setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
setLinksRouteTo(account.linksRouteTo); setLinksRouteTo(account.linksRouteTo);
@ -129,6 +134,12 @@ export default function Appearance() {
onClick={() => setArchiveAsScreenshot(!archiveAsScreenshot)} onClick={() => setArchiveAsScreenshot(!archiveAsScreenshot)}
/> />
<Checkbox
label="Singlefile"
state={archiveAsSinglefile}
onClick={() => setArchiveAsSinglefile(!archiveAsSinglefile)}
/>
<Checkbox <Checkbox
label="PDF" label="PDF"
state={archiveAsPDF} state={archiveAsPDF}
@ -207,6 +218,22 @@ export default function Appearance() {
<span className="label-text">Open Readable, if available</span> <span className="label-text">Open Readable, if available</span>
</label> </label>
<label
className="label cursor-pointer flex gap-2 justify-start w-fit"
tabIndex={0}
role="button"
>
<input
type="radio"
name="link-preference-radio"
className="radio checked:bg-primary"
value="Singlefile"
checked={linksRouteTo === LinksRouteTo.SINGLEFILE}
onChange={() => setLinksRouteTo(LinksRouteTo.SINGLEFILE)}
/>
<span className="label-text">Open Singlefile, if available</span>
</label>
<label <label
className="label cursor-pointer flex gap-2 justify-start w-fit" className="label cursor-pointer flex gap-2 justify-start w-fit"
tabIndex={0} tabIndex={0}

View File

@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "LinksRouteTo" ADD VALUE 'SINGLEFILE';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "archiveAsSinglefile" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "singlefile" text;

View File

@ -45,6 +45,7 @@ model User {
linksRouteTo LinksRouteTo @default(ORIGINAL) linksRouteTo LinksRouteTo @default(ORIGINAL)
preventDuplicateLinks Boolean @default(false) preventDuplicateLinks Boolean @default(false)
archiveAsScreenshot Boolean @default(true) archiveAsScreenshot Boolean @default(true)
archiveAsSinglefile Boolean @default(true)
archiveAsPDF Boolean @default(true) archiveAsPDF Boolean @default(true)
archiveAsWaybackMachine Boolean @default(false) archiveAsWaybackMachine Boolean @default(false)
isPrivate Boolean @default(false) isPrivate Boolean @default(false)
@ -56,6 +57,7 @@ enum LinksRouteTo {
ORIGINAL ORIGINAL
PDF PDF
READABLE READABLE
SINGLEFILE
SCREENSHOT SCREENSHOT
} }
@ -127,6 +129,7 @@ model Link {
image String? image String?
pdf String? pdf String?
readable String? readable String?
singlefile String?
lastPreserved DateTime? lastPreserved DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt

View File

@ -38,6 +38,13 @@ async function processBatch() {
{ {
readable: "pending", readable: "pending",
}, },
///////////////////////
{
singlefile: null,
},
{
singlefile: "pending",
},
], ],
}, },
take: archiveTakeCount, take: archiveTakeCount,
@ -75,6 +82,13 @@ async function processBatch() {
{ {
readable: "pending", readable: "pending",
}, },
///////////////////////
{
singlefile: null,
},
{
singlefile: "pending",
},
], ],
}, },
take: archiveTakeCount, take: archiveTakeCount,

View File

@ -46,6 +46,10 @@ declare global {
PDF_MARGIN_TOP?: string; PDF_MARGIN_TOP?: string;
PDF_MARGIN_BOTTOM?: string; PDF_MARGIN_BOTTOM?: string;
// PDF archive settings
SINGLEFILE_ARCHIVE_COMMAND?: string;
SINGLEFILE_ARCHIVE_HTTP_API?: string;
// //
// SSO Providers // SSO Providers
// //

View File

@ -128,12 +128,14 @@ export enum ArchivedFormat {
jpeg, jpeg,
pdf, pdf,
readability, readability,
singlefile,
} }
export enum LinkType { export enum LinkType {
url, url,
pdf, pdf,
image, image,
singlefile,
} }
export enum TokenExpiry { export enum TokenExpiry {