mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 15:56:29 +01:00
Compare commits
49 Commits
feat/ui-im
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba26c478d6 | ||
|
|
055fcb7b2a | ||
|
|
f4468706ef | ||
|
|
212956201a | ||
|
|
1182592fc5 | ||
|
|
0c399a676a | ||
|
|
395f33cd5b | ||
|
|
21b20cf575 | ||
|
|
e3dd25b591 | ||
|
|
b9a4e7ab11 | ||
|
|
6ae67c410c | ||
|
|
4ef7667484 | ||
|
|
3660e2f127 | ||
|
|
357d294f2d | ||
|
|
bb636128b0 | ||
|
|
aa102ab393 | ||
|
|
ea53665e64 | ||
|
|
9cf7fa1997 | ||
|
|
fded714f18 | ||
|
|
06de06b501 | ||
|
|
9abdbbbc5b | ||
|
|
3ebfee8bd2 | ||
|
|
6d446c5b27 | ||
|
|
3a55490bbf | ||
|
|
bc4643fed2 | ||
|
|
a2110ca631 | ||
|
|
413137ac64 | ||
|
|
9bc966491d | ||
|
|
61dbc15fc6 | ||
|
|
b475037127 | ||
|
|
35622a2122 | ||
|
|
77e4c3d0ec | ||
|
|
8523050ab2 | ||
|
|
0efdf65202 | ||
|
|
acb0991d05 | ||
|
|
a9f68f5487 | ||
|
|
55bb2fdb9b | ||
|
|
e529633b8b | ||
|
|
dfd575b6eb | ||
|
|
c5196721d4 | ||
|
|
968c75b618 | ||
|
|
01beebf660 | ||
|
|
d3115e834a | ||
|
|
01a552ceb5 | ||
|
|
d8958adea5 | ||
|
|
4d5e866db6 | ||
|
|
f189deb415 | ||
|
|
9c460dbc87 | ||
|
|
2c6ba9ba2c |
@@ -9,16 +9,6 @@ async function ensureJQuery() {
|
||||
(window as any).$ = $;
|
||||
}
|
||||
|
||||
async function applyMath() {
|
||||
const anyMathBlock = document.querySelector("#content .math-tex");
|
||||
if (!anyMathBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMathInElement = (await import("./services/math.js")).renderMathInElement;
|
||||
renderMathInElement(document.getElementById("content"));
|
||||
}
|
||||
|
||||
async function formatCodeBlocks() {
|
||||
const anyCodeBlock = document.querySelector("#content pre");
|
||||
if (!anyCodeBlock) {
|
||||
@@ -31,54 +21,4 @@ async function formatCodeBlocks() {
|
||||
|
||||
async function setupTextNote() {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
|
||||
const setupMermaid = (await import("./share/mermaid.js")).default;
|
||||
setupMermaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
* @param noteId of the given note to be fetched. If false, fetches current note.
|
||||
*/
|
||||
async function fetchNote(noteId: string | null = null) {
|
||||
if (!noteId) {
|
||||
noteId = document.body.getAttribute("data-note-id");
|
||||
}
|
||||
|
||||
const resp = await fetch(`api/notes/${noteId}`);
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
const noteType = determineNoteType();
|
||||
|
||||
if (noteType === "text") {
|
||||
setupTextNote();
|
||||
}
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
|
||||
if (toggleMenuButton && layout) {
|
||||
toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu"));
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
function determineNoteType() {
|
||||
const bodyClass = document.body.className;
|
||||
const match = bodyClass.match(/type-([^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
||||
// add fetchNote as property to the window object
|
||||
Object.defineProperty(window, "fetchNote", {
|
||||
value: fetchNote
|
||||
});
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"export_status": "Export status",
|
||||
"export_in_progress": "Export in progress: {{progressCount}}",
|
||||
"export_finished_successfully": "Export finished successfully.",
|
||||
"format_pdf": "PDF - for printing or sharing purposes."
|
||||
"format_pdf": "PDF - for printing or sharing purposes.",
|
||||
"share-format": "HTML for web publishing - uses the same theme that is used shared notes, but can be published as a static website."
|
||||
},
|
||||
"help": {
|
||||
"title": "Cheatsheet",
|
||||
|
||||
@@ -79,7 +79,8 @@ export default function ExportDialog() {
|
||||
values={[
|
||||
{ value: "html", label: t("export.format_html_zip") },
|
||||
{ value: "markdown", label: t("export.format_markdown") },
|
||||
{ value: "opml", label: t("export.format_opml") }
|
||||
{ value: "opml", label: t("export.format_opml") },
|
||||
{ value: "share", label: t("export.share-format") }
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js
|
||||
import debounce from "@triliumnext/client/src/services/debounce.js";
|
||||
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
|
||||
import cls from "@triliumnext/server/src/services/cls.js";
|
||||
import type { AdvancedExportOptions } from "@triliumnext/server/src/services/export/zip.js";
|
||||
import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/server/src/services/export/zip/abstract_provider.js";
|
||||
import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js";
|
||||
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
|
||||
|
||||
@@ -75,7 +75,7 @@ async function setOptions() {
|
||||
optionsService.setOption("compressImages", "false");
|
||||
}
|
||||
|
||||
async function exportData(noteId: string, format: "html" | "markdown", outputPath: string, ignoredFiles?: Set<string>) {
|
||||
async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set<string>) {
|
||||
const zipFilePath = "output.zip";
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ async function main() {
|
||||
|
||||
// Copy assets
|
||||
build.copy("src/assets", "assets/");
|
||||
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/");
|
||||
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
||||
|
||||
// Copy node modules dependencies
|
||||
|
||||
@@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity<BBranch> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getParentNote() {
|
||||
return this.parentNote;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BBranch;
|
||||
|
||||
@@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
||||
return childBranches;
|
||||
}
|
||||
|
||||
get encodedTitle() {
|
||||
return encodeURIComponent(this.title);
|
||||
}
|
||||
|
||||
getVisibleChildBranches() {
|
||||
return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
|
||||
}
|
||||
|
||||
getVisibleChildNotes() {
|
||||
return this.getVisibleChildBranches().map((branch) => branch.getNote());
|
||||
}
|
||||
|
||||
hasVisibleChildren() {
|
||||
return this.getVisibleChildNotes().length > 0;
|
||||
}
|
||||
|
||||
get shareId() {
|
||||
return this.noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
|
||||
* @param attributeId - the id of the attribute owned by this note
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ParsedQs } from "qs";
|
||||
import type { NoteParams } from "../services/note-interface.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
||||
@@ -149,7 +150,7 @@ function register(router: Router) {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const format = req.query.format || "html";
|
||||
|
||||
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
|
||||
if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) {
|
||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@ function register(router: Router) {
|
||||
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
|
||||
const branch = note.getParentBranches()[0];
|
||||
|
||||
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
|
||||
zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res);
|
||||
});
|
||||
|
||||
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) {
|
||||
const taskContext = new TaskContext(taskId, "export", null);
|
||||
|
||||
try {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown")) {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) {
|
||||
zipExportService.exportToZip(taskContext, branch, format, res);
|
||||
} else if (type === "single") {
|
||||
if (format !== "html" && format !== "markdown") {
|
||||
|
||||
@@ -44,6 +44,7 @@ async function register(app: express.Application) {
|
||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
||||
}
|
||||
app.use(`/share/assets/`, express.static(getShareThemeAssetDir()));
|
||||
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
|
||||
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
|
||||
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
|
||||
@@ -51,6 +52,16 @@ async function register(app: express.Application) {
|
||||
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
|
||||
}
|
||||
|
||||
export function getShareThemeAssetDir() {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const srcRoot = path.join(__dirname, "..", "..");
|
||||
return path.join(srcRoot, "../../packages/share-theme/dist");
|
||||
} else {
|
||||
const resourceDir = getResourceDir();
|
||||
return path.join(resourceDir, "share-theme/assets");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
register
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { ExportFormat } from "./zip/abstract_provider.js";
|
||||
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) {
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
|
||||
const note = branch.getNote();
|
||||
|
||||
if (note.type === "image" || note.type === "file") {
|
||||
@@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
|
||||
let payload, extension, mime;
|
||||
|
||||
if (typeof content !== "string") {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
import html from "html";
|
||||
import dateUtils from "../date_utils.js";
|
||||
import path from "path";
|
||||
import mimeTypes from "mime-types";
|
||||
import mdService from "./markdown.js";
|
||||
import packageInfo from "../../../package.json" with { type: "json" };
|
||||
import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js";
|
||||
import { getContentDisposition } from "../utils.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import sanitize from "sanitize-filename";
|
||||
import fs from "fs";
|
||||
@@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js";
|
||||
import type NoteMeta from "../meta/note_meta.js";
|
||||
import type AttachmentMeta from "../meta/attachment_meta.js";
|
||||
import type AttributeMeta from "../meta/attribute_meta.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type { NoteMetaFile } from "../meta/note_meta.js";
|
||||
import HtmlExportProvider from "./zip/html.js";
|
||||
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
|
||||
import MarkdownExportProvider from "./zip/markdown.js";
|
||||
import ShareThemeExportProvider from "./zip/share_theme.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`);
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown", "share"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`);
|
||||
}
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
});
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
const provider = buildProvider();
|
||||
|
||||
const noteIdToMeta: Record<string, NoteMeta> = {};
|
||||
|
||||
function buildProvider() {
|
||||
const providerData: ZipExportProviderData = {
|
||||
getNoteTargetUrl,
|
||||
archive,
|
||||
branch,
|
||||
rewriteFn
|
||||
};
|
||||
switch (format) {
|
||||
case "html":
|
||||
return new HtmlExportProvider(providerData);
|
||||
case "markdown":
|
||||
return new MarkdownExportProvider(providerData);
|
||||
case "share":
|
||||
return new ShareThemeExportProvider(providerData);
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
|
||||
const lcFileName = fileName.toLowerCase();
|
||||
|
||||
@@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
let fileName = baseFileName.trim();
|
||||
if (!fileName) {
|
||||
fileName = "note";
|
||||
@@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
|
||||
let existingExtension = path.extname(fileName).toLowerCase();
|
||||
let newExtension;
|
||||
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
newExtension = "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
newExtension = "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
newExtension = "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
newExtension = "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
newExtension = null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
newExtension = "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
newExtension = "txt";
|
||||
} else {
|
||||
newExtension = mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
const newExtension = provider.mapExtension(type, mime, existingExtension, format);
|
||||
|
||||
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
|
||||
if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) {
|
||||
fileName += `.${newExtension}`;
|
||||
}
|
||||
|
||||
|
||||
return getUniqueFilename(existingFileNames, fileName);
|
||||
}
|
||||
|
||||
@@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
const notePath = parentMeta.notePath.concat([note.noteId]);
|
||||
|
||||
if (note.noteId in noteIdToMeta) {
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`);
|
||||
const extension = provider.mapExtension("text", "text/html", "", format);
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`);
|
||||
|
||||
const meta: NoteMeta = {
|
||||
isClone: true,
|
||||
@@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
prefix: branch.prefix,
|
||||
dataFileName: fileName,
|
||||
type: "text", // export will have text description
|
||||
format: format
|
||||
format: (format === "markdown" ? "markdown" : "html")
|
||||
};
|
||||
return meta;
|
||||
}
|
||||
@@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
if (note.type === "text") {
|
||||
meta.format = format;
|
||||
meta.format = (format === "markdown" ? "markdown" : "html");
|
||||
}
|
||||
|
||||
noteIdToMeta[note.noteId] = meta as NoteMeta;
|
||||
@@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
note.sortChildren();
|
||||
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
|
||||
|
||||
const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable();
|
||||
let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable());
|
||||
if (format !== "share") {
|
||||
shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0);
|
||||
}
|
||||
|
||||
// if it's a leaf, then we'll export it even if it's empty
|
||||
if (available && (note.getContent().length > 0 || childBranches.length === 0)) {
|
||||
if (shouldIncludeFile) {
|
||||
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
|
||||
}
|
||||
|
||||
@@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
return url;
|
||||
}
|
||||
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
|
||||
function rewriteLinks(content: string, noteMeta: NoteMeta): string {
|
||||
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => {
|
||||
const url = getNoteTargetUrl(targetNoteId, noteMeta);
|
||||
@@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (["html", "markdown"].includes(noteMeta?.format || "")) {
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer {
|
||||
const isText = ["html", "markdown"].includes(noteMeta?.format || "");
|
||||
if (isText) {
|
||||
content = content.toString();
|
||||
content = rewriteFn(content, noteMeta);
|
||||
}
|
||||
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
content = provider.prepareContent(title, content, noteMeta, note, branch);
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content;
|
||||
} else if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
|
||||
@@ -377,7 +325,7 @@ ${markdownContent}`;
|
||||
|
||||
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
||||
|
||||
content = prepareContent(noteMeta.title, content, noteMeta);
|
||||
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
|
||||
|
||||
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
|
||||
|
||||
@@ -393,7 +341,7 @@ ${markdownContent}`;
|
||||
}
|
||||
|
||||
if (noteMeta.dataFileName) {
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta);
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
|
||||
|
||||
archive.append(content, {
|
||||
name: filePathPrefix + noteMeta.dataFileName,
|
||||
@@ -429,138 +377,21 @@ ${markdownContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
function saveNavigationInner(meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += saveNavigationInner(child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${saveNavigationInner(rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
|
||||
archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName });
|
||||
}
|
||||
provider.prepareMeta(metaFile);
|
||||
|
||||
try {
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
let navigationMeta: NoteMeta | null = null;
|
||||
let indexMeta: NoteMeta | null = null;
|
||||
let cssMeta: NoteMeta | null = null;
|
||||
|
||||
if (format === "html") {
|
||||
navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(navigationMeta);
|
||||
|
||||
indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(indexMeta);
|
||||
|
||||
cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
|
||||
metaFile.files.push(cssMeta);
|
||||
}
|
||||
|
||||
for (const noteMeta of Object.values(noteIdToMeta)) {
|
||||
// filter out relations which are not inside this export
|
||||
noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => {
|
||||
@@ -584,34 +415,6 @@ ${markdownContent}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
if (format === "html") {
|
||||
if (!navigationMeta || !indexMeta || !cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
saveNavigation(rootMeta, navigationMeta);
|
||||
saveIndex(rootMeta, indexMeta);
|
||||
saveCss(rootMeta, cssMeta);
|
||||
}
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
taskContext.taskSucceeded(null);
|
||||
} catch (e: unknown) {
|
||||
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
|
||||
log.error(message);
|
||||
@@ -623,9 +426,30 @@ ${markdownContent}`;
|
||||
res.status(500).send(message);
|
||||
}
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
provider.afterDone(rootMeta);
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
const fileOutputStream = fs.createWriteStream(zipFilePath);
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
|
||||
|
||||
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Archiver } from "archiver";
|
||||
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import mimeTypes from "mime-types";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export type ExportFormat = "html" | "markdown" | "share";
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
export interface ZipExportProviderData {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
}
|
||||
|
||||
export abstract class ZipExportProvider {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
|
||||
constructor(data: ZipExportProviderData) {
|
||||
this.branch = data.branch;
|
||||
this.getNoteTargetUrl = data.getNoteTargetUrl;
|
||||
this.archive = data.archive;
|
||||
this.zipExportOptions = data.zipExportOptions;
|
||||
this.rewriteFn = data.rewriteFn;
|
||||
}
|
||||
|
||||
abstract prepareMeta(metaFile: NoteMetaFile): void;
|
||||
abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
|
||||
abstract afterDone(rootMeta: NoteMeta): void;
|
||||
|
||||
/**
|
||||
* Determines the extension of the resulting file for a specific note type.
|
||||
*
|
||||
* @param type the type of the note.
|
||||
* @param mime the mime type of the note.
|
||||
* @param existingExtension the existing extension, including the leading period character.
|
||||
* @param format the format requested for export (e.g. HTML, Markdown).
|
||||
* @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead.
|
||||
*/
|
||||
mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) {
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
return "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
return "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
return "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
return "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
return null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
return "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
return "txt";
|
||||
} else {
|
||||
return mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
176
apps/server/src/services/export/zip/html.ts
Normal file
176
apps/server/src/services/export/zip/html.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type NoteMeta from "../../meta/note_meta.js";
|
||||
import { escapeHtml, getResourceDir, isDev } from "../../utils";
|
||||
import html from "html";
|
||||
import { ZipExportProvider } from "./abstract_provider.js";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export default class HtmlExportProvider extends ZipExportProvider {
|
||||
|
||||
private navigationMeta: NoteMeta | null = null;
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
private cssMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile) {
|
||||
this.navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
metaFile.files.push(this.navigationMeta);
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
metaFile.files.push(this.indexMeta);
|
||||
|
||||
this.cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
metaFile.files.push(this.cssMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
if (content.length < 100_000) {
|
||||
content = html.prettyPrint(content, { indent_size: 2 })
|
||||
}
|
||||
content = this.rewriteFn(content as string, noteMeta);
|
||||
return content;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta) {
|
||||
if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
this.#saveNavigation(rootMeta, this.navigationMeta);
|
||||
this.#saveIndex(rootMeta, this.indexMeta);
|
||||
this.#saveCss(rootMeta, this.cssMeta);
|
||||
}
|
||||
|
||||
#saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += this.#saveNavigationInner(rootMeta, child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
#saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${this.#saveNavigationInner(rootMeta, rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
this.archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
this.archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
const cssContent = fs.readFileSync(cssFile, "utf-8");
|
||||
this.archive.append(cssContent, { name: cssMeta.dataFileName });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
27
apps/server/src/services/export/zip/markdown.ts
Normal file
27
apps/server/src/services/export/zip/markdown.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import NoteMeta from "../../meta/note_meta"
|
||||
import { ZipExportProvider } from "./abstract_provider.js"
|
||||
import mdService from "../markdown.js";
|
||||
|
||||
export default class MarkdownExportProvider extends ZipExportProvider {
|
||||
|
||||
prepareMeta() { }
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
markdownContent = this.rewriteFn(markdownContent, noteMeta);
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone() { }
|
||||
|
||||
}
|
||||
122
apps/server/src/services/export/zip/share_theme.ts
Normal file
122
apps/server/src/services/export/zip/share_theme.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { join } from "path";
|
||||
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
|
||||
import { ExportFormat, ZipExportProvider } from "./abstract_provider.js";
|
||||
import { RESOURCE_DIR } from "../../resource_dir";
|
||||
import { getResourceDir, isDev } from "../../utils";
|
||||
import fs, { readdirSync } from "fs";
|
||||
import { renderNoteForExport } from "../../../share/content_renderer";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import { getShareThemeAssetDir } from "../../../routes/assets";
|
||||
|
||||
const shareThemeAssetDir = getShareThemeAssetDir();
|
||||
|
||||
export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
|
||||
private assetsMeta: NoteMeta[] = [];
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile: NoteMetaFile): void {
|
||||
|
||||
const assets = [
|
||||
"icon-color.svg"
|
||||
];
|
||||
|
||||
for (const file of readdirSync(shareThemeAssetDir)) {
|
||||
assets.push(`assets/${file}`);
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
const assetMeta = {
|
||||
noImport: true,
|
||||
dataFileName: asset
|
||||
};
|
||||
this.assetsMeta.push(assetMeta);
|
||||
metaFile.files.push(assetMeta);
|
||||
}
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(this.indexMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
const basePath = "../".repeat(noteMeta.notePath.length - 1);
|
||||
|
||||
if (note) {
|
||||
content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1));
|
||||
if (typeof content === "string") {
|
||||
content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => {
|
||||
if (match.includes("/assets/")) return match;
|
||||
return `href="#root/${id}"`;
|
||||
});
|
||||
content = this.rewriteFn(content, noteMeta);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta): void {
|
||||
this.#saveAssets(rootMeta, this.assetsMeta);
|
||||
this.#saveIndex(rootMeta);
|
||||
}
|
||||
|
||||
mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null {
|
||||
if (mime.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "html";
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta) {
|
||||
if (!this.indexMeta?.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = this.branch.getNote();
|
||||
const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch);
|
||||
this.archive.append(fullHtml, { name: this.indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) {
|
||||
for (const assetMeta of assetsMeta) {
|
||||
if (!assetMeta.dataFileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cssContent = getShareThemeAssets(assetMeta.dataFileName);
|
||||
this.archive.append(cssContent, { name: assetMeta.dataFileName });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getShareThemeAssets(nameWithExtension: string) {
|
||||
// Rename share.css to style.css.
|
||||
if (nameWithExtension === "style.css") {
|
||||
nameWithExtension = "share.css";
|
||||
} else if (nameWithExtension === "script.js") {
|
||||
nameWithExtension = "share.js";
|
||||
}
|
||||
|
||||
let path: string | undefined;
|
||||
if (nameWithExtension === "icon-color.svg") {
|
||||
path = join(RESOURCE_DIR, "images", nameWithExtension);
|
||||
} else if (nameWithExtension.startsWith("assets")) {
|
||||
path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, ""));
|
||||
} else if (isDev) {
|
||||
path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension);
|
||||
} else {
|
||||
path = join(getResourceDir(), "public", "src", nameWithExtension);
|
||||
}
|
||||
|
||||
return fs.readFileSync(path);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NoteType } from "@triliumnext/commons";
|
||||
import type AttachmentMeta from "./attachment_meta.js";
|
||||
import type AttributeMeta from "./attribute_meta.js";
|
||||
import type { ExportFormat } from "../export/zip/abstract_provider.js";
|
||||
|
||||
export interface NoteMetaFile {
|
||||
formatVersion: number;
|
||||
@@ -19,7 +20,7 @@ export default interface NoteMeta {
|
||||
type?: NoteType;
|
||||
mime?: string;
|
||||
/** 'html' or 'markdown', applicable to text notes only */
|
||||
format?: "html" | "markdown";
|
||||
format?: ExportFormat;
|
||||
dataFileName?: string;
|
||||
dirFileName?: string;
|
||||
/** this file should not be imported (e.g., HTML navigation) */
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { parse, HTMLElement, TextNode } from "node-html-parser";
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import assetPath from "../services/asset_path.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import escapeHtml from "escape-html";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import { t } from "i18next";
|
||||
import SBranch from "./shaca/entities/sbranch.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import ejs from "ejs";
|
||||
import log from "../services/log.js";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`;
|
||||
const templateCache: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Represents the output of the content renderer.
|
||||
@@ -16,7 +28,192 @@ export interface Result {
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote) {
|
||||
interface Subroot {
|
||||
note?: SNote | BNote;
|
||||
branch?: SBranch | BBranch
|
||||
}
|
||||
|
||||
function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
|
||||
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
// share root itself is not shared
|
||||
return {};
|
||||
}
|
||||
|
||||
// every path leads to share root, but which one to choose?
|
||||
// for the sake of simplicity, URLs are not note paths
|
||||
const parentBranch = note.getParentBranches()[0];
|
||||
|
||||
if (note instanceof BNote) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
}
|
||||
}
|
||||
|
||||
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
};
|
||||
}
|
||||
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
|
||||
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) {
|
||||
const subRoot: Subroot = {
|
||||
branch: parentBranch,
|
||||
note: parentBranch.getNote()
|
||||
};
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: parentBranch.noteId,
|
||||
cssToLoad: [
|
||||
`${basePath}assets/styles.css`,
|
||||
`${basePath}assets/scripts.css`,
|
||||
],
|
||||
jsToLoad: [
|
||||
`${basePath}assets/scripts.js`
|
||||
],
|
||||
logoUrl: `${basePath}icon-color.svg`,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
export function renderNoteContent(note: SNote) {
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
|
||||
const ancestors: string[] = [];
|
||||
let notePointer = note;
|
||||
while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) {
|
||||
const pointerParent = notePointer.parents[0];
|
||||
if (!pointerParent) {
|
||||
break;
|
||||
}
|
||||
ancestors.push(pointerParent.noteId);
|
||||
notePointer = pointerParent;
|
||||
}
|
||||
|
||||
// Determine CSS to load.
|
||||
const cssToLoad: string[] = [];
|
||||
if (!note.isLabelTruthy("shareOmitDefaultCss")) {
|
||||
cssToLoad.push(`assets/styles.css`);
|
||||
cssToLoad.push(`assets/scripts.css`);
|
||||
}
|
||||
for (const cssRelation of note.getRelations("shareCss")) {
|
||||
cssToLoad.push(`api/notes/${cssRelation.value}/download`);
|
||||
}
|
||||
|
||||
// Determine JS to load.
|
||||
const jsToLoad: string[] = [
|
||||
"assets/scripts.js"
|
||||
];
|
||||
for (const jsRelation of note.getRelations("shareJs")) {
|
||||
jsToLoad.push(`api/notes/${jsRelation.value}/download`);
|
||||
}
|
||||
|
||||
const customLogoId = note.getRelation("shareLogo")?.value;
|
||||
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: "_share",
|
||||
cssToLoad,
|
||||
jsToLoad,
|
||||
logoUrl,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
subRoot: Subroot;
|
||||
rootNoteId: string;
|
||||
cssToLoad: string[];
|
||||
jsToLoad: string[];
|
||||
logoUrl: string;
|
||||
ancestors: string[];
|
||||
}
|
||||
|
||||
function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) {
|
||||
const { header, content, isEmpty } = getContent(note);
|
||||
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
|
||||
const opts = {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
assetPath: shareAdjustedAssetPath,
|
||||
assetUrlFragment,
|
||||
showLoginInShareTheme,
|
||||
t,
|
||||
isDev,
|
||||
utils,
|
||||
...renderArgs
|
||||
};
|
||||
|
||||
// Check if the user has their own template.
|
||||
if (note.hasRelation("shareTemplate")) {
|
||||
// Get the template note and content
|
||||
const templateId = note.getRelation("shareTemplate")?.value;
|
||||
const templateNote = templateId && shaca.getNote(templateId);
|
||||
|
||||
// Make sure the note type is correct
|
||||
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
|
||||
// EJS caches the result of this so we don't need to pre-cache
|
||||
const includer = (path: string) => {
|
||||
const childNote = templateNote.children.find((n) => path === n.title);
|
||||
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
|
||||
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
|
||||
|
||||
const template = childNote.getContent();
|
||||
if (typeof template !== "string") throw new Error("Invalid template content type.");
|
||||
|
||||
return { template };
|
||||
};
|
||||
|
||||
// Try to render user's template, w/ fallback to default view
|
||||
try {
|
||||
const content = templateNote.getContent();
|
||||
if (typeof content === "string") {
|
||||
return ejs.render(content, opts, { includer });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
||||
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render with the default view otherwise.
|
||||
const templatePath = getDefaultTemplatePath("page");
|
||||
return ejs.render(readTemplate(templatePath), opts, {
|
||||
includer: (path) => {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return { template: readTemplate(getDefaultTemplatePath(path)) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultTemplatePath(template: string) {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return process.env.NODE_ENV === "development"
|
||||
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
|
||||
: `../../share-theme/templates/${template}.ejs`;
|
||||
}
|
||||
|
||||
function readTemplate(path: string) {
|
||||
const cachedTemplate = templateCache.get(path);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
}
|
||||
|
||||
const templateString = readFileSync(path, "utf-8");
|
||||
templateCache.set(path, templateString);
|
||||
return templateString;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote | BNote) {
|
||||
if (note.isProtected) {
|
||||
return {
|
||||
header: "",
|
||||
@@ -65,7 +262,7 @@ function renderIndex(result: Result) {
|
||||
result.content += "</ul>";
|
||||
}
|
||||
|
||||
function renderText(result: Result, note: SNote) {
|
||||
function renderText(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") return;
|
||||
const document = parse(result.content || "");
|
||||
|
||||
@@ -174,7 +371,7 @@ export function renderCode(result: Result) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderMermaid(result: Result, note: SNote) {
|
||||
function renderMermaid(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") {
|
||||
return;
|
||||
}
|
||||
@@ -188,11 +385,11 @@ function renderMermaid(result: Result, note: SNote) {
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderImage(result: Result, note: SNote) {
|
||||
function renderImage(result: Result, note: SNote | BNote) {
|
||||
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
|
||||
}
|
||||
|
||||
function renderFile(note: SNote, result: Result) {
|
||||
function renderFile(note: SNote | BNote, result: Result) {
|
||||
if (note.mime === "application/pdf") {
|
||||
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`;
|
||||
} else {
|
||||
|
||||
@@ -4,41 +4,12 @@ import type { Request, Response, Router } from "express";
|
||||
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import shacaLoader from "./shaca/shaca_loader.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import contentRenderer from "./content_renderer.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import appPath from "../services/app_path.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import SearchContext from "../services/search/search_context.js";
|
||||
import log from "../services/log.js";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import type SBranch from "./shaca/entities/sbranch.js";
|
||||
import type SAttachment from "./shaca/entities/sattachment.js";
|
||||
import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import options from "../services/options.js";
|
||||
import { t } from "i18next";
|
||||
import ejs from "ejs";
|
||||
import { join } from "path";
|
||||
|
||||
function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } {
|
||||
if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
// share root itself is not shared
|
||||
return {};
|
||||
}
|
||||
|
||||
// every path leads to share root, but which one to choose?
|
||||
// for the sake of simplicity, URLs are not note paths
|
||||
const parentBranch = note.getParentBranches()[0];
|
||||
|
||||
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
};
|
||||
}
|
||||
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
import { renderNoteContent } from "./content_renderer.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
function addNoIndexHeader(note: SNote, res: Response) {
|
||||
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
|
||||
@@ -109,8 +80,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
|
||||
let svgString = "<svg/>";
|
||||
const attachment = image.getAttachmentByTitle(attachmentName);
|
||||
if (!attachment) {
|
||||
res.status(404);
|
||||
renderDefault(res, "404");
|
||||
|
||||
return;
|
||||
}
|
||||
const content = attachment.getContent();
|
||||
@@ -138,12 +108,19 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
function render404(res: Response) {
|
||||
res.status(404);
|
||||
const shareThemePath = `../../share-theme/templates/404.ejs`;
|
||||
res.render(shareThemePath);
|
||||
}
|
||||
|
||||
function register(router: Router) {
|
||||
|
||||
function renderNote(note: SNote, req: Request, res: Response) {
|
||||
if (!note) {
|
||||
console.log("Unable to find note ", note);
|
||||
res.status(404);
|
||||
renderDefault(res, "404");
|
||||
render404(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,63 +138,7 @@ function register(router: Router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { header, content, isEmpty } = contentRenderer.getContent(note);
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
|
||||
const opts = {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
subRoot,
|
||||
assetPath: isDev ? assetPath : `../${assetPath}`,
|
||||
assetUrlFragment,
|
||||
appPath: isDev ? appPath : `../${appPath}`,
|
||||
showLoginInShareTheme,
|
||||
t,
|
||||
isDev,
|
||||
utils
|
||||
};
|
||||
let useDefaultView = true;
|
||||
|
||||
// Check if the user has their own template
|
||||
if (note.hasRelation("shareTemplate")) {
|
||||
// Get the template note and content
|
||||
const templateId = note.getRelation("shareTemplate")?.value;
|
||||
const templateNote = templateId && shaca.getNote(templateId);
|
||||
|
||||
// Make sure the note type is correct
|
||||
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
|
||||
// EJS caches the result of this so we don't need to pre-cache
|
||||
const includer = (path: string) => {
|
||||
const childNote = templateNote.children.find((n) => path === n.title);
|
||||
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
|
||||
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
|
||||
|
||||
const template = childNote.getContent();
|
||||
if (typeof template !== "string") throw new Error("Invalid template content type.");
|
||||
|
||||
return { template };
|
||||
};
|
||||
|
||||
// Try to render user's template, w/ fallback to default view
|
||||
try {
|
||||
const content = templateNote.getContent();
|
||||
if (typeof content === "string") {
|
||||
const ejsResult = ejs.render(content, opts, { includer });
|
||||
res.send(ejsResult);
|
||||
useDefaultView = false; // Rendering went okay, don't use default view
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
||||
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useDefaultView) {
|
||||
renderDefault(res, "page", opts);
|
||||
}
|
||||
res.send(renderNoteContent(note));
|
||||
}
|
||||
|
||||
router.get("/share/", (req, res) => {
|
||||
@@ -401,14 +322,6 @@ function register(router: Router) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderDefault(res: Response<any, Record<string, any>>, template: "page" | "404", opts: any = {}) {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
const shareThemePath = process.env.NODE_ENV === "development"
|
||||
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
|
||||
: `../../share-theme/templates/${template}.ejs`;
|
||||
res.render(shareThemePath, opts);
|
||||
}
|
||||
|
||||
export default {
|
||||
register
|
||||
};
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
"Zerebos <me@zerebos.com>"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"katex": "0.16.25",
|
||||
"mermaid": "11.12.0",
|
||||
"boxicons": "2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@digitak/esrun": "3.2.26",
|
||||
"@types/swagger-ui": "5.21.1",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
// import {fileURLToPath} from "node:url";
|
||||
|
||||
@@ -51,15 +50,18 @@ async function runBuild() {
|
||||
await esbuild.build({
|
||||
entryPoints: entryPoints,
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
outdir: path.join(rootDir, "dist"),
|
||||
format: "cjs",
|
||||
format: "esm",
|
||||
target: ["chrome96"],
|
||||
loader: {
|
||||
".png": "dataurl",
|
||||
".gif": "dataurl",
|
||||
".woff": "dataurl",
|
||||
".woff2": "dataurl",
|
||||
".ttf": "dataurl",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
".ttf": "file",
|
||||
".eot": "empty",
|
||||
".svg": "empty",
|
||||
".html": "text",
|
||||
".css": "css"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,10 @@ import setupExpanders from "./modules/expanders";
|
||||
import setupMobileMenu from "./modules/mobile";
|
||||
import setupSearch from "./modules/search";
|
||||
import setupThemeSelector from "./modules/theme";
|
||||
import setupMermaid from "./modules/mermaid";
|
||||
import setupMath from "./modules/math";
|
||||
import api from "./modules/api";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
|
||||
function $try<T extends (...a: unknown[]) => unknown>(func: T, ...args: Parameters<T>) {
|
||||
try {
|
||||
@@ -13,8 +17,39 @@ function $try<T extends (...a: unknown[]) => unknown>(func: T, ...args: Paramete
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(window, api);
|
||||
$try(setupThemeSelector);
|
||||
$try(setupToC);
|
||||
$try(setupExpanders);
|
||||
$try(setupMobileMenu);
|
||||
$try(setupSearch);
|
||||
|
||||
function setupTextNote() {
|
||||
$try(setupMermaid);
|
||||
$try(setupMath);
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
const noteType = determineNoteType();
|
||||
|
||||
if (noteType === "text") {
|
||||
setupTextNote();
|
||||
}
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
|
||||
if (toggleMenuButton && layout) {
|
||||
toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu"));
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
function determineNoteType() {
|
||||
const bodyClass = document.body.className;
|
||||
const match = bodyClass.match(/type-([^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
18
packages/share-theme/src/scripts/modules/api.ts
Normal file
18
packages/share-theme/src/scripts/modules/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
* @param noteId of the given note to be fetched. If false, fetches current note.
|
||||
*/
|
||||
async function fetchNote(noteId: string | null = null) {
|
||||
if (!noteId) {
|
||||
noteId = document.body.getAttribute("data-note-id");
|
||||
}
|
||||
|
||||
const resp = await fetch(`api/notes/${noteId}`);
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export default {
|
||||
fetchNote
|
||||
};
|
||||
16
packages/share-theme/src/scripts/modules/math.ts
Normal file
16
packages/share-theme/src/scripts/modules/math.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
export default async function setupMath() {
|
||||
const anyMathBlock = document.querySelector("#content .math-tex");
|
||||
if (!anyMathBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMathInElement = (await import("katex/contrib/auto-render")).default;
|
||||
await import("katex/contrib/mhchem");
|
||||
|
||||
const contentEl = document.getElementById("content");
|
||||
if (!contentEl) return;
|
||||
renderMathInElement(contentEl);
|
||||
document.body.classList.add("math-loaded");
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import mermaid from "mermaid";
|
||||
export default async function setupMermaid() {
|
||||
const mermaidEls = document.querySelectorAll("#content pre code.language-mermaid");
|
||||
if (mermaidEls.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
export default function setupMermaid() {
|
||||
for (const codeBlock of document.querySelectorAll("#content pre code.language-mermaid")) {
|
||||
const mermaid = (await import("mermaid")).default;
|
||||
|
||||
for (const codeBlock of mermaidEls) {
|
||||
const parentPre = codeBlock.parentElement;
|
||||
if (!parentPre) {
|
||||
continue;
|
||||
@@ -46,4 +46,8 @@
|
||||
|
||||
#content img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body:not(.math-loaded) .math-tex {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -30,17 +30,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="shortcut icon" href="<% if (note.hasRelation("shareFavicon")) { %>api/notes/<%= note.getRelation("shareFavicon").value %>/download<% } else { %>../favicon.ico<% } %>">
|
||||
<script src="../<%= appPath %>/share.js" type="module"></script>
|
||||
<% if (!isDev && !note.isLabelTruthy("shareOmitDefaultCss")) { %>
|
||||
<link href="<%= assetPath %>/src/share.css" rel="stylesheet">
|
||||
<link href="<%= assetPath %>/src/boxicons.css" rel="stylesheet">
|
||||
<% for (const url of cssToLoad) { %>
|
||||
<link href="<%= url %>" rel="stylesheet">
|
||||
<% } %>
|
||||
|
||||
<% for (const cssRelation of note.getRelations("shareCss")) { %>
|
||||
<link href="api/notes/<%= cssRelation.value %>/download" rel="stylesheet">
|
||||
<% } %>
|
||||
<% for (const jsRelation of note.getRelations("shareJs")) { %>
|
||||
<script type="module" src="api/notes/<%= jsRelation.value %>/download"></script>
|
||||
<% for (const url of jsToLoad) { %>
|
||||
<script type="module" src="<%= url %>"></script>
|
||||
<% } %>
|
||||
<% if (note.hasLabel("shareDisallowRobotIndexing")) { %>
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
@@ -80,8 +74,6 @@
|
||||
<%- renderSnippets("head:end") %>
|
||||
</head>
|
||||
<%
|
||||
const customLogoId = subRoot.note.getRelation("shareLogo")?.value;
|
||||
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
|
||||
const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53;
|
||||
const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40;
|
||||
const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : "";
|
||||
@@ -131,16 +123,7 @@ content = content.replaceAll(headingRe, (...match) => {
|
||||
</div>
|
||||
<% if (hasTree) { %>
|
||||
<nav id="menu">
|
||||
<%
|
||||
const ancestors = [];
|
||||
let notePointer = note;
|
||||
while (notePointer.parents[0].noteId !== "_share") {
|
||||
const pointerParent = notePointer.parents[0];
|
||||
ancestors.push(pointerParent.noteId);
|
||||
notePointer = pointerParent;
|
||||
}
|
||||
%>
|
||||
<%- include("tree_item", {note: subRoot.note, activeNote: note, subRoot: subRoot, ancestors: ancestors}) %>
|
||||
<%- include("tree_item", {note: subRoot.note, activeNote: note, subRoot: subRoot, ancestors}) %>
|
||||
</nav>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<%
|
||||
const linkClass = `type-${note.type}` + (activeNote.noteId === note.noteId ? " active" : "");
|
||||
const isExternalLink = note.hasLabel("shareExternal");
|
||||
const linkHref = isExternalLink ? note.getLabelValue("shareExternal") : `./${note.shareId}`;
|
||||
let linkHref;
|
||||
|
||||
if (isExternalLink) {
|
||||
linkHref = note.getLabelValue("shareExternal");
|
||||
} else if (note.shareId) {
|
||||
linkHref = `./${note.shareId}`;
|
||||
} else {
|
||||
linkHref = `#${note.getBestNotePath().join("/")}`;
|
||||
}
|
||||
|
||||
const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : "";
|
||||
%>
|
||||
|
||||
|
||||
5
packages/share-theme/src/types.d.ts
vendored
Normal file
5
packages/share-theme/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module "katex/contrib/auto-render" {
|
||||
export default function renderMathInElement(elem: HTMLElement, options?: {})
|
||||
}
|
||||
|
||||
declare module "katex/contrib/mhchem" {}
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -1328,6 +1328,16 @@ importers:
|
||||
version: 1.2.0
|
||||
|
||||
packages/share-theme:
|
||||
dependencies:
|
||||
boxicons:
|
||||
specifier: 2.1.4
|
||||
version: 2.1.4
|
||||
katex:
|
||||
specifier: 0.16.25
|
||||
version: 0.16.25
|
||||
mermaid:
|
||||
specifier: 11.12.0
|
||||
version: 11.12.0
|
||||
devDependencies:
|
||||
'@digitak/esrun':
|
||||
specifier: 3.2.26
|
||||
@@ -15335,6 +15345,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-multi-root@47.1.0':
|
||||
dependencies:
|
||||
@@ -15831,6 +15843,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-restricted-editing@47.1.0':
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user