mirror of
https://github.com/kosmosisdire/obsidian-webpage-export.git
synced 2026-07-22 07:10:24 +00:00
heavy refactor and bug fixes
This commit is contained in:
parent
616f2eb316
commit
cf2279f74a
14 changed files with 1508 additions and 1465 deletions
|
|
@ -7,4 +7,4 @@ end_of_line = lf
|
|||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
tab_width = 4
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -15,7 +15,7 @@ esbuild.build({
|
|||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ['main.ts'],
|
||||
entryPoints: ['./scripts/main.ts'],
|
||||
bundle: true,
|
||||
external: [
|
||||
'obsidian',
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@
|
|||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"file-saver": "^2.0.5",
|
||||
"html2pdf.js": "^0.10.1",
|
||||
"jquery": "^3.5.14",
|
||||
"jszip": "^3.10.1"
|
||||
}
|
||||
|
|
|
|||
677
scripts/html-gen.ts
Normal file
677
scripts/html-gen.ts
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
import { writeFile } from "fs/promises";
|
||||
import { MarkdownView, Notice, TextFileView } from "obsidian";
|
||||
import { ExportSettings } from "./settings";
|
||||
import { Utils } from "./utils";
|
||||
import jQuery from 'jquery';
|
||||
const $ = jQuery;
|
||||
|
||||
export class HTMLGenerator
|
||||
{
|
||||
// When this is enabled the plugin will download the extra .css and .js files from github.
|
||||
autoDownloadExtras = true;
|
||||
|
||||
private vaultPluginsPath: string = Utils.getVaultPath() + "/.obsidian/plugins";
|
||||
private thisPluginPath: string = this.vaultPluginsPath + "/obsidian-webpage-export";
|
||||
private assetsPath: string = this.thisPluginPath + "/assets";
|
||||
|
||||
// this is a list of images that is populated during generation and then downloaded upon export
|
||||
// I am sure there is a better way to handle this data flow but I am not sure what to do.
|
||||
private imagesToDownload: { original_path: string, destination_path_rel: string }[] = [];
|
||||
|
||||
// this is a string containing the filtered app.css file. It is populated on load.
|
||||
// The math styles are attempted to load on every export until they are succesfully loaded.
|
||||
// This is because they only load when a file containing latex is opened.
|
||||
appStyles: string = "";
|
||||
|
||||
// short html snippet for a dark mode toggle
|
||||
darkModeToggle =
|
||||
`\n\n
|
||||
<div>
|
||||
<label class="theme-toggle-inline" for="theme_toggle">
|
||||
<input class="toggle__input" type="checkbox" id="theme_toggle">
|
||||
<div class="toggle__fill"></div>
|
||||
</label>
|
||||
</div>
|
||||
\n\n`
|
||||
|
||||
// the raw github urls for the extra files
|
||||
private webpagejsURL: string = "https://raw.githubusercontent.com/KosmosisDire/obsidian-webpage-export/master/webpage.js";
|
||||
private pluginStylesURL: string = "https://raw.githubusercontent.com/KosmosisDire/obsidian-webpage-export/master/plugin-styles.css";
|
||||
private obsidianStylesURL: string = "https://raw.githubusercontent.com/KosmosisDire/obsidian-webpage-export/master/obsidian-styles.css";
|
||||
private async downloadExtras()
|
||||
{
|
||||
//Download webpage.js
|
||||
let webpagejs = await fetch(this.webpagejsURL);
|
||||
let webpagejsText = await webpagejs.text();
|
||||
await writeFile(this.assetsPath + "/webpage.js", webpagejsText).catch((err) => { console.log(err); });
|
||||
|
||||
//Download plugin-styles.css
|
||||
let pluginStyles = await fetch(this.pluginStylesURL);
|
||||
let pluginStylesText = await pluginStyles.text();
|
||||
await writeFile(this.assetsPath + "/plugin-styles.css", pluginStylesText).catch((err) => { console.log(err); });
|
||||
|
||||
//Download obsidian-styles.css
|
||||
let obsidianStyles = await fetch(this.obsidianStylesURL);
|
||||
let obsidianStylesText = await obsidianStyles.text();
|
||||
await writeFile(this.assetsPath + "/obsidian-styles.css", obsidianStylesText).catch((err) => { console.log(err); });
|
||||
}
|
||||
|
||||
private async loadAppStyles()
|
||||
{
|
||||
var appSheet = document.styleSheets[1];
|
||||
let stylesheets = document.styleSheets;
|
||||
for (let i = 0; i < stylesheets.length; i++)
|
||||
{
|
||||
if (stylesheets[i].href && stylesheets[i].href?.includes("app.css"))
|
||||
{
|
||||
appSheet = stylesheets[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.appStyles += await Utils.getText(this.assetsPath + "/obsidian-styles.css");
|
||||
|
||||
for (var i = 0; i < appSheet.cssRules.length; i++)
|
||||
{
|
||||
var rule = appSheet.cssRules[i];
|
||||
if (rule)
|
||||
{
|
||||
if (rule.cssText.startsWith("@font-face")) continue;
|
||||
if (rule.cssText.startsWith(".CodeMirror")) continue;
|
||||
if (rule.cssText.startsWith(".cm-")) continue;
|
||||
|
||||
this.appStyles += rule.cssText + "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async initialize()
|
||||
{
|
||||
await this.downloadExtras();
|
||||
await this.loadAppStyles();
|
||||
}
|
||||
|
||||
private async getThirdPartyPluginCSS() : Promise<string>
|
||||
{
|
||||
// load 3rd party plugin css
|
||||
let pluginCSS = "";
|
||||
|
||||
let thirdPartyPluginStyleNames = ExportSettings.settings.includePluginCSS.split("\n");
|
||||
for (let i = 0; i < thirdPartyPluginStyleNames.length; i++)
|
||||
{
|
||||
if (!thirdPartyPluginStyleNames[i] || (thirdPartyPluginStyleNames[i] && !(/\S/.test(thirdPartyPluginStyleNames[i])))) continue;
|
||||
|
||||
let path = this.vaultPluginsPath + "/" + thirdPartyPluginStyleNames[i].replace("\n", "") + "/styles.css";
|
||||
let style = await Utils.getText(path);
|
||||
if (style)
|
||||
{
|
||||
pluginCSS += style;
|
||||
}
|
||||
}
|
||||
|
||||
return pluginCSS;
|
||||
}
|
||||
|
||||
async getSeperateFilesToDownload() : Promise<{filename: string, data: string, type: string, relativePath?: string, unicode?: boolean}[]>
|
||||
{
|
||||
var toDownload: {filename: string, data: string, type: string, relativePath?: string, unicode?: boolean}[] = [];
|
||||
|
||||
if (!ExportSettings.settings.inlineCSS)
|
||||
{
|
||||
let appcss = this.appStyles;
|
||||
let plugincss = await Utils.getText(this.assetsPath + "/plugin-styles.css");
|
||||
let themecss = await Utils.getThemeContent(Utils.getCurrentTheme());
|
||||
|
||||
let snippetsList = await Utils.getStyleSnippetsContent();
|
||||
let snippetsNames = await Utils.getEnabledSnippets();
|
||||
var snippets = "";
|
||||
|
||||
for (var i = 0; i < snippetsList.length; i++)
|
||||
{
|
||||
snippets += `/* --- ${snippetsNames[i]}.css --- */ \n ${snippetsList[i]} \n\n\n`;
|
||||
}
|
||||
|
||||
let thirdPartyPluginCSS = await this.getThirdPartyPluginCSS();
|
||||
plugincss += "\n" + thirdPartyPluginCSS + "\n";
|
||||
|
||||
let appcssDownload = { filename: "obsidian-styles.css", data: appcss, type: "text/css" };
|
||||
let plugincssDownload = { filename: "plugin-styles.css", data: plugincss, type: "text/css" };
|
||||
let themecssDownload = { filename: "theme.css", data: themecss, type: "text/css" };
|
||||
let snippetsDownload = { filename: "snippets.css", data: snippets, type: "text/css" };
|
||||
|
||||
toDownload.push(appcssDownload);
|
||||
toDownload.push(plugincssDownload);
|
||||
toDownload.push(themecssDownload);
|
||||
toDownload.push(snippetsDownload);
|
||||
}
|
||||
|
||||
if (!ExportSettings.settings.inlineJS)
|
||||
{
|
||||
var webpagejs = await Utils.getText(this.assetsPath + "/webpage.js");
|
||||
var webpagejsDownload = { filename: "webpage.js", data: webpagejs, type: "text/javascript" };
|
||||
toDownload.push(webpagejsDownload);
|
||||
}
|
||||
|
||||
// let imagesDownload : {path: string, data: string}[] = [];
|
||||
if (!ExportSettings.settings.inlineImages)
|
||||
{
|
||||
for (var i = 0; i < this.imagesToDownload.length; i++)
|
||||
{
|
||||
var image = this.imagesToDownload[i];
|
||||
var data = await Utils.getTextBase64(image.original_path);
|
||||
var imageDownload =
|
||||
{
|
||||
filename: Utils.getFileNameFromFilePath(image.destination_path_rel),
|
||||
data: data,
|
||||
type: "image/png",
|
||||
relativePath: Utils.getDirectoryFromFilePath(image.destination_path_rel),
|
||||
unicode: false
|
||||
};
|
||||
toDownload.push(imageDownload);
|
||||
}
|
||||
}
|
||||
|
||||
return toDownload;
|
||||
}
|
||||
|
||||
//#region General HTML
|
||||
|
||||
public async GetCurrentFileHTML(): Promise<string | null>
|
||||
{
|
||||
await Utils.delay(200);
|
||||
|
||||
let view = await Utils.getActiveView();
|
||||
if (!view) return null;
|
||||
|
||||
Utils.setLineWidth(ExportSettings.settings.customLineWidth);
|
||||
|
||||
if (view instanceof MarkdownView)
|
||||
await Utils.viewEnableFullRender(view);
|
||||
|
||||
var head = await this.generateHead(view);
|
||||
var html = this.generateBodyHTML();
|
||||
html = this.fixLinks(html);
|
||||
html = this.repairOnClick(html); //replace data-onlick with onclick
|
||||
|
||||
if (ExportSettings.settings.inlineImages)
|
||||
html = await this.inlineImages(html);
|
||||
else
|
||||
html = await this.outlineImages(html, view);
|
||||
|
||||
// inject darkmode toggle
|
||||
if (ExportSettings.settings.addDarkModeToggle)
|
||||
{
|
||||
html = await this.injectToggle(html);
|
||||
}
|
||||
|
||||
if (ExportSettings.settings.includeOutline)
|
||||
{
|
||||
var headers = this.getHeaderList(html);
|
||||
if (headers)
|
||||
{
|
||||
var outline = this.generateOutline(headers);
|
||||
// put side bars on either side of content and put them in a flex container
|
||||
let el = document.createElement("html");
|
||||
el.innerHTML = html;
|
||||
let body = el.querySelector("body");
|
||||
if (body)
|
||||
{
|
||||
html = `<div class="flex-container"><div id="sidebar" class="sidebar-left"></div>${body.innerHTML}<div id="sidebar" class="sidebar-right">${outline}</div></div>`;
|
||||
body.innerHTML = html;
|
||||
html = body?.outerHTML;
|
||||
}
|
||||
else
|
||||
{
|
||||
console.error("Could not find body element in html");
|
||||
}
|
||||
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
html = head + html;
|
||||
|
||||
// enclose in <html> tags
|
||||
html = "<!DOCTYPE html>\n<html>\n" + html + "\n</html>";
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private generateBodyHTML(): string
|
||||
{
|
||||
var bodyClasses = document.body.getAttribute("class") ?? "";
|
||||
var bodyStyle = document.body.getAttribute("style") ?? "";
|
||||
/*@ts-ignore*/
|
||||
bodyClasses = bodyClasses.replaceAll("\"", "'");
|
||||
/*@ts-ignore*/
|
||||
bodyStyle = bodyStyle.replaceAll("\"", "'");
|
||||
|
||||
var htmlEl = (document.querySelector(".workspace-leaf.mod-active .markdown-reading-view") as HTMLElement)
|
||||
if (!htmlEl) htmlEl = (document.querySelector(".workspace-leaf.mod-active .view-content") as HTMLElement);
|
||||
|
||||
let width = "1000px";
|
||||
if (ExportSettings.settings.customLineWidth > 0)
|
||||
width = ExportSettings.settings.customLineWidth + "px";
|
||||
else
|
||||
{
|
||||
let sizer = (htmlEl.querySelector(".markdown-preview-sizer.markdown-preview-section") as HTMLElement);
|
||||
if (sizer)
|
||||
{
|
||||
width = sizer.style.width;
|
||||
}
|
||||
}
|
||||
|
||||
htmlEl.style.flexBasis = width;
|
||||
htmlEl.style.width = "unset";
|
||||
var html = htmlEl.outerHTML;
|
||||
|
||||
html = "\n<body class=\"" + bodyClasses + "\" style=\"" + bodyStyle + "\">\n" + html + "\n</body>\n";
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private getMathStyles(): string
|
||||
{
|
||||
let mathStyles = document.styleSheets[document.styleSheets.length - 1];
|
||||
var mathStylesString = "";
|
||||
|
||||
var success = true;
|
||||
for (var i = 0; i < mathStyles.cssRules.length; i++)
|
||||
{
|
||||
var rule = mathStyles.cssRules[i];
|
||||
|
||||
if (rule)
|
||||
{
|
||||
if (i == 0 && !rule.cssText.startsWith("mjx"))
|
||||
{
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (rule.cssText.startsWith("@font-face")) continue;
|
||||
|
||||
mathStylesString += rule.cssText + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return success ? mathStylesString : "";
|
||||
}
|
||||
|
||||
private async generateHead(view: TextFileView): Promise<string>
|
||||
{
|
||||
let meta =
|
||||
`
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>${view.file.basename}</title>
|
||||
<link rel="icon" sizes="96x96" href="https://publish-01.obsidian.md/access/f786db9fac45774fa4f0d8112e232d67/favicon-96x96.png">
|
||||
`
|
||||
|
||||
let mathStyles = this.getMathStyles();
|
||||
let cssSettings = document.getElementById("css-settings-manager")?.innerHTML ?? "";
|
||||
let scripts = "\n\n<script src='https://code.jquery.com/jquery-3.6.0.js'></script>"
|
||||
+ ((ExportSettings.settings.inlineJS ? ("<script>\n" + await Utils.getText(this.assetsPath + "/webpage.js"))
|
||||
: "<script src='webpage.js'></script>\n") + "\n</script>\n");
|
||||
|
||||
if (ExportSettings.settings.inlineCSS)
|
||||
{
|
||||
let pluginStyles = await Utils.getText(this.assetsPath + "/plugin-styles.css");
|
||||
let snippets = await Utils.getStyleSnippetsContent();
|
||||
let snippetNames = Utils.getEnabledSnippets();
|
||||
let theme = await Utils.getThemeContent(Utils.getCurrentTheme());
|
||||
|
||||
let thirdPartyPluginStyles = await this.getThirdPartyPluginCSS();
|
||||
pluginStyles += thirdPartyPluginStyles;
|
||||
|
||||
var header =
|
||||
`
|
||||
<head>
|
||||
|
||||
${meta}
|
||||
|
||||
<!-- Obsidian App Styles / Other Built-in Styles -->
|
||||
<style> ${this.appStyles} </style>
|
||||
<style> ${mathStyles} </style>
|
||||
<style> ${cssSettings} </style>
|
||||
|
||||
<!-- Plugin Styles -->
|
||||
<style> ${pluginStyles} </style>
|
||||
|
||||
<!-- Theme Styles ( ${Utils.getCurrentTheme()} ) -->
|
||||
<style> ${theme} </style>
|
||||
|
||||
<!-- Snippets: ${snippetNames.join(", ")} -->
|
||||
<style> ${snippets.join("</style><style>")} </style>
|
||||
|
||||
${scripts}
|
||||
|
||||
</head>
|
||||
`;
|
||||
}
|
||||
else
|
||||
{
|
||||
header =
|
||||
`
|
||||
<head>
|
||||
|
||||
${meta}
|
||||
|
||||
<link rel="stylesheet" href="obsidian-styles.css">
|
||||
<link rel="stylesheet" href="plugin-styles.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
<link rel="stylesheet" href="snippets.css">
|
||||
|
||||
<style> ${cssSettings} </style>
|
||||
<style> ${mathStyles} </style>
|
||||
|
||||
${scripts}
|
||||
|
||||
</head>
|
||||
`;
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Links and Images
|
||||
|
||||
private fixLinks(html: string): string
|
||||
{
|
||||
let el = document.createElement('html');
|
||||
el.innerHTML = html;
|
||||
|
||||
let query = jQuery(el);
|
||||
query.find("a.internal-link").each(function ()
|
||||
{
|
||||
$(this).attr("target", "_self");
|
||||
|
||||
let finalHref = "";
|
||||
let href = $(this).attr("href")?.split("#");
|
||||
|
||||
if (!href) return;
|
||||
|
||||
// if the file doesn't start with #, then it links to a file, or a header in another file.
|
||||
if (!(href[0] == ""))
|
||||
{
|
||||
if (href.length == 1)
|
||||
{
|
||||
finalHref = href[0] + ".html";
|
||||
}
|
||||
|
||||
if (href.length == 2)
|
||||
{
|
||||
var filePath = "";
|
||||
if (!href[0].contains("/") && !href[0].contains("\\"))
|
||||
{
|
||||
filePath = Utils.getDirectoryFromFilePath(Utils.getFirstFileByName(href[0])?.path ?? "") + "/";
|
||||
}
|
||||
|
||||
finalHref = filePath + href[0] + ".html#" + href[1].replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
|
||||
}
|
||||
|
||||
if (href.length > 2)
|
||||
{
|
||||
let first = href.shift() ?? "";
|
||||
|
||||
var filePath = "";
|
||||
if (!first.contains("/") && !first.contains("\\"))
|
||||
{
|
||||
filePath = Utils.getDirectoryFromFilePath(Utils.getFirstFileByName(first)?.path ?? "") + "/";
|
||||
}
|
||||
|
||||
finalHref = filePath + first + ".html#" + href.join("#").replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
|
||||
}
|
||||
}
|
||||
else // if the file starts with #, then it links to an internal header.
|
||||
{
|
||||
href.shift();
|
||||
if (href.length == 1)
|
||||
{
|
||||
finalHref = "#" + href[0].replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
|
||||
}
|
||||
|
||||
if (href.length > 1)
|
||||
{
|
||||
finalHref = href.join("#").replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_");
|
||||
}
|
||||
}
|
||||
|
||||
$(this).attr("href", finalHref);
|
||||
});
|
||||
|
||||
query.find("h1, h2, h3, h4, h5, h6").each(function ()
|
||||
{
|
||||
// use the headers inner text as the id
|
||||
$(this).attr("id", $(this).text().replaceAll(" ", "_").replaceAll("#", "").replaceAll("__", "_"));
|
||||
});
|
||||
|
||||
let result = el.innerHTML;
|
||||
el.remove();
|
||||
return result;
|
||||
}
|
||||
|
||||
private async inlineImages(html: string): Promise<string>
|
||||
{
|
||||
let el = document.createElement('html');
|
||||
el.innerHTML = html;
|
||||
|
||||
let query = jQuery(el);
|
||||
let images = query.find("img").toArray();
|
||||
|
||||
for (let i = 0; i < images.length; i++)
|
||||
{
|
||||
let img = images[i];
|
||||
if ($(img).attr("src")?.startsWith("app://local/"))
|
||||
{
|
||||
let path = $(img).attr("src")?.replace("app://local/", "").replaceAll("%20", " ").split("?")[0];
|
||||
|
||||
if (path)
|
||||
{
|
||||
var base64 = "";
|
||||
try
|
||||
{
|
||||
base64 = await Utils.getTextBase64(path);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.error(e);
|
||||
console.warn("Failed to inline image: " + path);
|
||||
new Notice("Failed to inline image: " + path, 5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
$(img).attr("src", "data:image/png;base64," + base64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = el.innerHTML;
|
||||
el.remove();
|
||||
return result;
|
||||
}
|
||||
|
||||
private async outlineImages(html: string, view: TextFileView): Promise<string>
|
||||
{
|
||||
let el = document.createElement('html');
|
||||
el.innerHTML = html;
|
||||
|
||||
let query = jQuery(el);
|
||||
|
||||
this.imagesToDownload = [];
|
||||
|
||||
let img2Download: { original_path: string, destination_path_rel: string }[] = [];
|
||||
let vaultPath = Utils.getVaultPath();
|
||||
|
||||
query.find("img").each(function ()
|
||||
{
|
||||
if (!$(this).attr("src")?.startsWith("app://local/")) return;
|
||||
|
||||
let originalPath = $(this).attr("src")?.replaceAll("\\", "/").replaceAll("%20", " ").replace("app://local/", "");
|
||||
|
||||
// console.log("originalPath: " + originalPath);
|
||||
|
||||
if (!originalPath)
|
||||
{
|
||||
new Notice("Failed to outline image: " + originalPath + ". Couldn't find image src", 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalPath.startsWith("data:image/png;base64,") || originalPath.startsWith("data:image/jpeg;base64,")) return;
|
||||
|
||||
let relPath = originalPath.split(Utils.getDirectoryFromFilePath(vaultPath + "/" + view.file.path.replaceAll("\\", "/")) + "/")[1];
|
||||
|
||||
// console.log(originalPath, "vs.", Utils.getDirectoryFromFilePath(vaultPath + "/" + view.file.path.replaceAll("\\", "/")) + "/");
|
||||
|
||||
if (!relPath)
|
||||
relPath = ("images/" + Utils.getFileNameFromFilePath($(this).attr("src")?.replaceAll("\\", "/").replaceAll("%20", " ") ?? "")) ?? "img.png";
|
||||
|
||||
// console.log("relPath: " + relPath);
|
||||
|
||||
$(this).attr("src", relPath);
|
||||
|
||||
img2Download.push({ original_path: originalPath, destination_path_rel: relPath });
|
||||
|
||||
});
|
||||
|
||||
this.imagesToDownload = img2Download;
|
||||
|
||||
let result = el.innerHTML;
|
||||
el.remove();
|
||||
return result;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Special Features
|
||||
|
||||
private async injectToggle(html: string): Promise<string>
|
||||
{
|
||||
if (!html.contains(this.darkModeToggle.split("\n")[4]))
|
||||
{
|
||||
//insert fixed toggle in corner
|
||||
console.log("Injecting toggle");
|
||||
html = this.darkModeToggle.replace("theme-toggle-inline", "theme-toggle") + html;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private repairOnClick(html: string): string
|
||||
{
|
||||
html = html.replaceAll("data-onclick", "onclick");
|
||||
return html;
|
||||
}
|
||||
|
||||
private getHeaderList(html: string): { size: number, title: string, href: string }[] | null
|
||||
{
|
||||
var headers = [];
|
||||
|
||||
var el = document.createElement('html');
|
||||
el.innerHTML = html;
|
||||
|
||||
var headerElements = el.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
||||
|
||||
for (var i = 0; i < headerElements.length; i++)
|
||||
{
|
||||
var header = headerElements[i];
|
||||
var size = parseInt(header.tagName[1]);
|
||||
var title = (header as HTMLElement).innerText;
|
||||
var href = (header as HTMLHeadingElement).id;
|
||||
headers.push({ size, title, href });
|
||||
}
|
||||
|
||||
el.remove();
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private generateOutline(headers: { size: number, title: string, href: string }[]): string
|
||||
{
|
||||
var outline =
|
||||
`
|
||||
<div class="outline-container" data-size="0">
|
||||
|
||||
<div class="outline-header">
|
||||
<svg viewBox="0 0 100 100" class="bullet-list" width="18" height="18"><path fill="var(--h6-color)" stroke="var(--h6-color)" d="M16.4,16.4c-3.5,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4s6.4-2.9,6.4-6.4S19.9,16.4,16.4,16.4z M16.4,19.6 c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.2-3.2,3.2s-3.2-1.4-3.2-3.2C13.2,21,14.6,19.6,16.4,19.6z M29.2,21.2v3.2H90v-3.2H29.2z M16.4,43.6c-3.5,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4s6.4-2.9,6.4-6.4S19.9,43.6,16.4,43.6z M16.4,46.8c1.8,0,3.2,1.4,3.2,3.2 s-1.4,3.2-3.2,3.2s-3.2-1.4-3.2-3.2S14.6,46.8,16.4,46.8z M29.2,48.4v3.2H90v-3.2H29.2z M16.4,70.8c-3.5,0-6.4,2.9-6.4,6.4 c0,3.5,2.9,6.4,6.4,6.4s6.4-2.9,6.4-6.4C22.8,73.7,19.9,70.8,16.4,70.8z M16.4,74c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.2-3.2,3.2 s-3.2-1.4-3.2-3.2C13.2,75.4,14.6,74,16.4,74z M29.2,75.6v3.2H90v-3.2H29.2z"></path></svg>
|
||||
<h6 style="margin: 1em"> Table of Contents </h6>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
var builderRoot = document.createElement('html');
|
||||
builderRoot.innerHTML = outline;
|
||||
|
||||
var outlineEl = builderRoot.querySelector(".outline-container");
|
||||
|
||||
if (!outlineEl) return "";
|
||||
|
||||
var listItemTemplate =
|
||||
`
|
||||
|
||||
<div class="outline-item" data-size="{size}">
|
||||
|
||||
<div class="outline-item-contents">
|
||||
<div class="tree-item-icon collapse-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon right-triangle"><path d="M3 8L12 17L21 8"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<a class="outline-item-title" href="#{href}">{title}</a>
|
||||
</div>
|
||||
|
||||
<div class="outline-item-children">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
var listStack = [outlineEl];
|
||||
|
||||
// function to get the data-size of the previous list item as a number
|
||||
function getLastStackSize(): number
|
||||
{
|
||||
return parseInt(listStack[listStack.length - 1].getAttribute("data-size") ?? "0");
|
||||
}
|
||||
|
||||
|
||||
for (var i = 0; i < headers.length; i++)
|
||||
{
|
||||
var header = headers[i];
|
||||
var listItem = listItemTemplate.replace("{size}", header.size.toString()).replace("{href}", header.href).replace("{title}", header.title);
|
||||
|
||||
while (getLastStackSize() >= header.size && listStack.length > 1)
|
||||
{
|
||||
listStack.pop();
|
||||
}
|
||||
|
||||
var builditemRoot = document.createElement('div');
|
||||
builditemRoot.innerHTML = listItem;
|
||||
var newOutlineItem = builditemRoot.querySelector(".outline-item");
|
||||
|
||||
if (!newOutlineItem) continue;
|
||||
|
||||
var childContainer = listStack.last()?.querySelector(".outline-item-children");
|
||||
if (getLastStackSize() == 0) childContainer = listStack.last();
|
||||
|
||||
if (!childContainer) continue;
|
||||
|
||||
childContainer.appendChild(newOutlineItem);
|
||||
listStack.push(newOutlineItem);
|
||||
|
||||
builditemRoot.remove();
|
||||
}
|
||||
|
||||
var result = builderRoot.innerHTML;
|
||||
builderRoot.remove();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
114
scripts/leaf-handler.ts
Normal file
114
scripts/leaf-handler.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { MarkdownView, OpenViewState, PaneType, SplitDirection, TFile, WorkspaceLeaf } from "obsidian";
|
||||
|
||||
|
||||
export class LeafHandler
|
||||
{
|
||||
// https://github.com/darlal/obsidian-switcher-plus/blob/27d337039883008bcbf40ca13ea2f9287469dde4/src/Handlers/handler.ts#L388
|
||||
// Some functions here are from this source and have been modified to fit the needs of this plugin.
|
||||
|
||||
isMainPanelLeaf(leaf: WorkspaceLeaf): boolean
|
||||
{
|
||||
const { workspace } = app;
|
||||
const root = leaf?.getRoot();
|
||||
/*@ts-ignore*/
|
||||
return root === workspace.rootSplit || root === workspace.floatingSplit;
|
||||
}
|
||||
|
||||
getOpenLeaves(excludeMainPanelViewTypes?: string[], includeSidePanelViewTypes?: string[]): WorkspaceLeaf[]
|
||||
{
|
||||
const leaves: WorkspaceLeaf[] = [];
|
||||
|
||||
const saveLeaf = (l: WorkspaceLeaf) =>
|
||||
{
|
||||
const viewType = l.view?.getViewType();
|
||||
|
||||
if (this.isMainPanelLeaf(l))
|
||||
{
|
||||
if (!excludeMainPanelViewTypes?.includes(viewType))
|
||||
{
|
||||
leaves.push(l);
|
||||
}
|
||||
} else if (includeSidePanelViewTypes?.includes(viewType))
|
||||
{
|
||||
leaves.push(l);
|
||||
}
|
||||
};
|
||||
|
||||
app.workspace.iterateAllLeaves(saveLeaf);
|
||||
return leaves;
|
||||
}
|
||||
|
||||
openFileInNewLeaf(
|
||||
file: TFile,
|
||||
navType: PaneType | boolean,
|
||||
openState?: OpenViewState,
|
||||
errorContext?: string,
|
||||
splitDirection: SplitDirection = 'vertical',
|
||||
): WorkspaceLeaf
|
||||
{
|
||||
const { workspace } = app;
|
||||
errorContext = errorContext ?? '';
|
||||
const message = `HTML Export: error opening file. ${errorContext}`;
|
||||
|
||||
const getLeaf = () =>
|
||||
{
|
||||
return navType === 'split'
|
||||
? workspace.getLeaf(navType, splitDirection)
|
||||
: workspace.getLeaf(navType);
|
||||
};
|
||||
|
||||
var leaf = getLeaf();
|
||||
|
||||
try
|
||||
{
|
||||
leaf.openFile(file, openState).catch((reason) =>
|
||||
{
|
||||
console.log(message, reason);
|
||||
});
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.log(message, error);
|
||||
}
|
||||
|
||||
return leaf;
|
||||
}
|
||||
|
||||
getLeafByFile(file: TFile): WorkspaceLeaf | null
|
||||
{
|
||||
const leaves = this.getOpenLeaves();
|
||||
for (let leaf of leaves)
|
||||
{
|
||||
if (leaf.view instanceof MarkdownView)
|
||||
{
|
||||
if (leaf.view.file.path === file.path)
|
||||
{
|
||||
return leaf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
switchToLeafWithFile(file: TFile, openNewIfNotOpen: boolean): {leaf: WorkspaceLeaf | null, alreadyOpen: boolean}
|
||||
{
|
||||
const { workspace } = app;
|
||||
let leaf = this.getLeafByFile(file);
|
||||
|
||||
let alreadyOpen = false;
|
||||
if (leaf)
|
||||
{
|
||||
workspace.setActiveLeaf(leaf);
|
||||
alreadyOpen = true;
|
||||
}
|
||||
else if (openNewIfNotOpen)
|
||||
{
|
||||
leaf = this.openFileInNewLeaf(file, true, { active: true }, "Failed to open file to new tab after it was found to not be open yet.");
|
||||
}
|
||||
|
||||
return {leaf, alreadyOpen};
|
||||
}
|
||||
}
|
||||
|
||||
154
scripts/main.ts
Normal file
154
scripts/main.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// imports from obsidian API
|
||||
import { Plugin, TAbstractFile, TFile} from 'obsidian';
|
||||
|
||||
// modules that are part of the plugin
|
||||
import { ExportSettings } from './settings';
|
||||
import { Utils } from './utils';
|
||||
import { LeafHandler } from './leaf-handler';
|
||||
import { HTMLGenerator } from './html-gen';
|
||||
|
||||
export default class HTMLExportPlugin extends Plugin
|
||||
{
|
||||
leafHandler: LeafHandler = new LeafHandler();
|
||||
htmlGenerator: HTMLGenerator = new HTMLGenerator();
|
||||
|
||||
addTogglePostprocessor()
|
||||
{
|
||||
this.registerMarkdownCodeBlockProcessor("theme-toggle", (source, el, ctx) =>
|
||||
{
|
||||
let parent = el.createEl('div');
|
||||
parent.innerHTML = this.htmlGenerator.darkModeToggle;
|
||||
});
|
||||
|
||||
//also replace `theme-toggle` and ```theme-toggle``` for better inline toggles, or in places you couldn't use a normal code block
|
||||
this.registerMarkdownPostProcessor((element, context) =>
|
||||
{
|
||||
let codeBlocks = element.querySelectorAll('code, span.cm-inline-code');
|
||||
codeBlocks.forEach((codeBlock) =>
|
||||
{
|
||||
// console.log(codeBlock);
|
||||
if (codeBlock instanceof HTMLElement && codeBlock.innerText == "theme-toggle")
|
||||
{
|
||||
codeBlock.outerHTML = this.htmlGenerator.darkModeToggle;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onload()
|
||||
{
|
||||
console.log('loading obsidian-webpage-export plugin');
|
||||
|
||||
// init settings
|
||||
new ExportSettings(this);
|
||||
ExportSettings.loadSettings();
|
||||
|
||||
// Register the Export As HTML button in the file menu
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-menu", (menu, file) =>
|
||||
{
|
||||
menu.addItem((item) =>
|
||||
{
|
||||
item
|
||||
.setTitle("Export to HTML")
|
||||
.setIcon("document")
|
||||
.onClick(async () =>
|
||||
{
|
||||
if (Utils.getFileNameFromFilePath(file.path).contains("."))
|
||||
this.exportFile(file);
|
||||
|
||||
else
|
||||
this.exportFolder(file.path);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// add export vault icon to ribbon
|
||||
this.addRibbonIcon("folder-up", "Export Vault to HTML", async () =>
|
||||
{
|
||||
this.exportFolder("");
|
||||
});
|
||||
|
||||
// add toggle postprocessor
|
||||
this.addTogglePostprocessor();
|
||||
|
||||
// init html generator
|
||||
this.htmlGenerator.initialize();
|
||||
}
|
||||
|
||||
onunload()
|
||||
{
|
||||
console.log('unloading obsidian-webpage-export plugin');
|
||||
}
|
||||
|
||||
async exportFile(file: TAbstractFile, fullPath: string = "", showSettings: boolean = true)
|
||||
{
|
||||
// Open the settings modal and wait until it's closed
|
||||
let onlyCopy = false;
|
||||
if (showSettings)
|
||||
{
|
||||
let result = await new ExportSettings(this).open();
|
||||
if (result.canceled) return;
|
||||
onlyCopy = result.onlyCopy;
|
||||
}
|
||||
|
||||
let fileTab = this.leafHandler.openFileInNewLeaf(file as TFile, true);
|
||||
|
||||
var html = await this.htmlGenerator.GetCurrentFileHTML();
|
||||
if (!html) return;
|
||||
|
||||
let toDownload = await this.htmlGenerator.getSeperateFilesToDownload();
|
||||
|
||||
// Close the file tab after HTML is generated
|
||||
fileTab.detach();
|
||||
|
||||
|
||||
// if onlyCopy is true, then we don't want to download the file, we just want to copy it to the clipboard
|
||||
if (onlyCopy)
|
||||
{
|
||||
navigator.clipboard.writeText(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download files
|
||||
var htmlDownload = { filename: file.name.replace(".md", ".html"), data: html, type: "text/html" };
|
||||
toDownload.push(htmlDownload);
|
||||
|
||||
var htmlPath: string | null = fullPath;
|
||||
if (htmlPath == "")
|
||||
htmlPath = await Utils.showSaveDialog(Utils.idealDefaultPath(), file.name.replace(".md", ".html"), false);
|
||||
|
||||
if (!htmlPath) return;
|
||||
|
||||
let filename = Utils.getFileNameFromFilePath(htmlPath);
|
||||
let folderPath = Utils.getDirectoryFromFilePath(htmlPath);
|
||||
|
||||
toDownload[toDownload.length - 1].filename = filename;
|
||||
|
||||
await Utils.downloadFiles(toDownload, folderPath);
|
||||
}
|
||||
|
||||
async exportFolder(folderPath: string)
|
||||
{
|
||||
// folder path is the path relative to the vault that we are exporting
|
||||
|
||||
// Open the settings modal and wait until it's closed
|
||||
var exportCanceled = !await new ExportSettings(this).open();
|
||||
if (exportCanceled) return;
|
||||
|
||||
let htmlPath = await Utils.showSelectFolderDialog(Utils.idealDefaultPath());
|
||||
|
||||
var files = this.app.vault.getFiles();
|
||||
|
||||
for (var i = 0; i < files.length; i++)
|
||||
{
|
||||
var file = files[i];
|
||||
if (file.path.startsWith(folderPath) && file.extension == "md")
|
||||
{
|
||||
var fullPath = htmlPath + "/" + file.path.replace(".md", ".html");
|
||||
await this.exportFile(file, fullPath, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
221
scripts/settings.ts
Normal file
221
scripts/settings.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { App, Modal, Plugin, Setting } from 'obsidian';
|
||||
import { Utils } from './utils';
|
||||
|
||||
export interface ExportSettingsData
|
||||
{
|
||||
inlineCSS: boolean;
|
||||
inlineJS: boolean;
|
||||
inlineImages: boolean;
|
||||
|
||||
uzeZip: boolean;
|
||||
addDarkModeToggle: boolean;
|
||||
includeOutline: boolean;
|
||||
customLineWidth: number;
|
||||
lastExportPath: string;
|
||||
|
||||
customJS: string;
|
||||
customCSS: string;
|
||||
|
||||
includePluginCSS: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ExportSettingsData =
|
||||
{
|
||||
inlineCSS: true,
|
||||
inlineJS: true,
|
||||
inlineImages: true,
|
||||
|
||||
uzeZip: false,
|
||||
addDarkModeToggle: true,
|
||||
includeOutline: true,
|
||||
customLineWidth: 0,
|
||||
lastExportPath: '',
|
||||
|
||||
customJS: '',
|
||||
customCSS: '',
|
||||
|
||||
includePluginCSS: ''
|
||||
}
|
||||
|
||||
export class ExportSettings extends Modal
|
||||
{
|
||||
static settings: ExportSettingsData = DEFAULT_SETTINGS;
|
||||
static plugin: Plugin;
|
||||
static isClosed: boolean = true;
|
||||
static success: boolean = false;
|
||||
static onlyCopy: boolean = false;
|
||||
|
||||
constructor(plugin: Plugin)
|
||||
{
|
||||
super(plugin.app);
|
||||
ExportSettings.plugin = plugin;
|
||||
}
|
||||
|
||||
static async loadSettings()
|
||||
{
|
||||
ExportSettings.settings = Object.assign({}, DEFAULT_SETTINGS, await ExportSettings.plugin.loadData());
|
||||
}
|
||||
|
||||
static async saveSettings()
|
||||
{
|
||||
await ExportSettings.plugin.saveData(ExportSettings.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Opens the modal and async blocks until the modal is closed.
|
||||
* @returns True if the EXPORT button was pressed, false is the export was canceled.
|
||||
* @override
|
||||
*/
|
||||
async open(): Promise<{canceled: boolean, onlyCopy: boolean}>
|
||||
{
|
||||
super.open();
|
||||
ExportSettings.isClosed = false;
|
||||
|
||||
const { contentEl } = this;
|
||||
|
||||
contentEl.empty();
|
||||
|
||||
let header = contentEl.createEl('h2', { text: 'HTML Webpage Settings' });
|
||||
header.style.display = 'inline-block';
|
||||
|
||||
let supporLink = contentEl.createEl('a', { href: 'https://www.buymeacoffee.com/nathangeorge' });
|
||||
let supportButton = supporLink.createEl('img');
|
||||
supportButton.setAttribute('src', 'https://cdn.buymeacoffee.com/buttons/v2/default-violet.png');
|
||||
supportButton.setAttribute('style', ' height: 30px; margin-right: 0; margin-left: 17px; transform: translateY(25%);');
|
||||
supportButton.setAttribute('alt', 'Buy Me A Coffee');
|
||||
|
||||
contentEl.createEl('h3', { text: 'Inlining Options:' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Inline CSS')
|
||||
.setDesc('Inline the CSS into the HTML file.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.inlineCSS)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.inlineCSS = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Inline JS')
|
||||
.setDesc('Inline the JS into the HTML file.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.inlineJS)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.inlineJS = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Inline Images')
|
||||
.setDesc('Inline the images into the HTML file.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.inlineImages)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.inlineImages = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
|
||||
contentEl.createEl('h3', { text: 'Special Features:' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Add Dark Mode Toggle')
|
||||
.setDesc('Adds a fixed theme toggle to the top of any page that doesn\'t already have a toggle embedded with `theme-toggle`.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.addDarkModeToggle)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.addDarkModeToggle = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Include Document Outline')
|
||||
.setDesc('Will include an interactive document outline tree on the right side of the document.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.includeOutline)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.includeOutline = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}
|
||||
));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Custom Line Width')
|
||||
.setDesc('Will set the line width of the document to the specified value. If set to 0, will use whatever the current line width is.')
|
||||
.addText((text) => text
|
||||
.setValue(ExportSettings.settings.customLineWidth.toString())
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.customLineWidth = parseInt(value);
|
||||
await ExportSettings.saveSettings();
|
||||
}
|
||||
));
|
||||
|
||||
contentEl.createEl('h3', { text: 'Export Options:' });
|
||||
|
||||
// new Setting(contentEl)
|
||||
// .setName('Export to ZIP')
|
||||
// .setDesc('Will export a .zip file rather than putting all the files loose in the chosen folder.')
|
||||
// .addToggle((toggle) => toggle
|
||||
// .setValue(ExportSettings.settings.uzeZip)
|
||||
// .onChange(async (value) =>
|
||||
// {
|
||||
// ExportSettings.settings.uzeZip = value;
|
||||
// await ExportSettings.saveSettings();
|
||||
// }));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Include Plugin CSS')
|
||||
.setDesc('Will include the CSS from the plugins listed below. Please write out the plugin\'s ID / folder name exactly each on a new line.')
|
||||
.addTextArea((text) => text
|
||||
.setValue(ExportSettings.settings.includePluginCSS)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.includePluginCSS = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}
|
||||
));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Start Export')
|
||||
.addButton((button) => button
|
||||
.setButtonText('Export')
|
||||
.onClick(async () =>
|
||||
{
|
||||
this.close();
|
||||
ExportSettings.success = true;
|
||||
}
|
||||
));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Export and Copy HTML')
|
||||
.addButton((button) => button
|
||||
.setButtonText('Copy HTML')
|
||||
.onClick(async () =>
|
||||
{
|
||||
this.close();
|
||||
ExportSettings.success = true;
|
||||
ExportSettings.onlyCopy = true;
|
||||
}
|
||||
));
|
||||
|
||||
await Utils.waitUntil(() => ExportSettings.isClosed, 60 * 60 * 1000, 200);
|
||||
|
||||
return { canceled: !ExportSettings.success, onlyCopy: ExportSettings.onlyCopy };
|
||||
}
|
||||
|
||||
onClose()
|
||||
{
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
ExportSettings.isClosed = true;
|
||||
ExportSettings.success = false;
|
||||
ExportSettings.onlyCopy = false;
|
||||
}
|
||||
}
|
||||
340
scripts/utils.ts
Normal file
340
scripts/utils.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { open, readFile, writeFile, existsSync, mkdirSync } from 'fs';
|
||||
import { FileSystemAdapter, MarkdownView, TextFileView, TFile } from 'obsidian';
|
||||
import { ExportSettings } from './settings';
|
||||
var JSZip = require("jszip");
|
||||
|
||||
/* @ts-ignore */
|
||||
const dialog: Electron.Dialog = require('electron').remote.dialog;
|
||||
|
||||
export class Utils
|
||||
{
|
||||
static async delay (ms: number)
|
||||
{
|
||||
return new Promise( resolve => setTimeout(resolve, ms) );
|
||||
}
|
||||
|
||||
|
||||
static async getText(path: string): Promise<string>
|
||||
{
|
||||
return new Promise((resolve, reject) => {
|
||||
open(path, 'r', (err, fd) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
readFile(fd, { encoding: 'utf8' }, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async getTextBase64(path: string): Promise<string>
|
||||
{
|
||||
return new Promise((resolve, reject) => {
|
||||
open(path, 'r', (err, fd) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
readFile(fd, { encoding: 'base64' }, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static changeViewMode(view: MarkdownView, modeName: "preview" | "source")
|
||||
{
|
||||
/*@ts-ignore*/
|
||||
const mode = view.modes[modeName];
|
||||
/*@ts-ignore*/
|
||||
mode && view.setMode(mode);
|
||||
};
|
||||
|
||||
static createUnicodeArray(content: string) : Uint8Array
|
||||
{
|
||||
var charCode, byteArray = [];
|
||||
|
||||
// BE BOM
|
||||
byteArray.push(254, 255);
|
||||
|
||||
// LE BOM
|
||||
// byteArray.push(255, 254);
|
||||
|
||||
for (var i = 0; i < content.length; ++i) {
|
||||
|
||||
charCode = content.charCodeAt(i);
|
||||
|
||||
// BE Bytes
|
||||
byteArray.push((charCode & 0xFF00) >>> 8);
|
||||
byteArray.push(charCode & 0xFF);
|
||||
|
||||
// LE Bytes
|
||||
// byteArray.push(charCode & 0xff);
|
||||
// byteArray.push(charCode / 256 >>> 0);
|
||||
}
|
||||
|
||||
return new Uint8Array(byteArray);
|
||||
}
|
||||
|
||||
static async showSaveDialog(defaultPath: string, defaultFileName: string, showAllFilesOption: boolean = true): Promise<string | null>
|
||||
{
|
||||
let type = (defaultFileName.split(".").pop() ?? "txt");
|
||||
|
||||
var filters = [{
|
||||
name: type.toUpperCase() + " Files",
|
||||
extensions: [type]
|
||||
}];
|
||||
|
||||
if (showAllFilesOption)
|
||||
{
|
||||
filters.push({
|
||||
name: "All Files",
|
||||
extensions: ["*"]
|
||||
});
|
||||
}
|
||||
|
||||
let picker = await dialog.showSaveDialog({
|
||||
defaultPath: (defaultPath + "/" + defaultFileName).replaceAll("\\", "/").replaceAll("//", "/"),
|
||||
filters: filters,
|
||||
properties: ["showOverwriteConfirmation"]
|
||||
})
|
||||
|
||||
if (picker.canceled) return null;
|
||||
|
||||
let path = picker.filePath ?? "";
|
||||
|
||||
if (path != "")
|
||||
{
|
||||
ExportSettings.settings.lastExportPath = path;
|
||||
ExportSettings.saveSettings();
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
static async showSelectFolderDialog(defaultPath: string): Promise<string | null>
|
||||
{
|
||||
let picker = await dialog.showOpenDialog({
|
||||
defaultPath: defaultPath,
|
||||
properties: ["openDirectory"]
|
||||
});
|
||||
|
||||
if (picker.canceled) return null;
|
||||
|
||||
let path = picker.filePaths[0] ?? "";
|
||||
|
||||
if (path != "")
|
||||
{
|
||||
ExportSettings.settings.lastExportPath = path;
|
||||
ExportSettings.saveSettings();
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
static idealDefaultPath() : string
|
||||
{
|
||||
return ExportSettings.settings.lastExportPath == "" ? (Utils.getVaultPath() ?? "") : ExportSettings.settings.lastExportPath;
|
||||
}
|
||||
|
||||
static async downloadFile(data: string, filename: string, path: string = "")
|
||||
{
|
||||
if (path == "")
|
||||
{
|
||||
path = await Utils.showSaveDialog(Utils.idealDefaultPath(), filename) ?? "";
|
||||
|
||||
if (path == "") return;
|
||||
}
|
||||
|
||||
var array = Utils.createUnicodeArray(data);
|
||||
|
||||
writeFile(path, array, (err) => {
|
||||
if (err) throw err;
|
||||
console.log('The file has been saved!');
|
||||
});
|
||||
}
|
||||
|
||||
static async downloadFilesAsZip(files: {filename: string, data: string, type: string, relativePath?: string}[], zipFileName: string)
|
||||
{
|
||||
var blobs = files.map(file => new Blob([file.data], {type: file.type}));
|
||||
var zip = new JSZip();
|
||||
for (var i = 0; i < files.length; i++)
|
||||
{
|
||||
let path = ((files[i].relativePath ?? "") + "/" + files[i].filename).replaceAll("//", "/");
|
||||
zip.file(path, blobs[i]);
|
||||
}
|
||||
|
||||
var zipBlob = await zip.generateAsync({type: "uint8array"});
|
||||
|
||||
var path = await Utils.showSaveDialog(Utils.idealDefaultPath(), zipFileName, false) ?? "";
|
||||
|
||||
if (path == "") return;
|
||||
|
||||
writeFile(path, zipBlob, (err) => {
|
||||
if (err) throw err;
|
||||
console.log('The file has been saved!');
|
||||
});
|
||||
}
|
||||
|
||||
static async downloadFiles(files: {filename: string, data: string, type?: string, relativePath?: string, unicode?: boolean}[], folderPath: string)
|
||||
{
|
||||
for (var i = 0; i < files.length; i++)
|
||||
{
|
||||
var array = (files[i].unicode ?? true) ? Utils.createUnicodeArray(files[i].data) : Buffer.from(files[i].data, 'base64');
|
||||
|
||||
let path = (folderPath + "/" + (files[i].relativePath ?? "") + "/" + files[i].filename).replaceAll("\\", "/").replaceAll("//", "/").replaceAll("//", "/");
|
||||
|
||||
let dir = Utils.getDirectoryFromFilePath(path);
|
||||
if (!existsSync(dir))
|
||||
{
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFile(path, array, (err) => {
|
||||
if (err) throw err;
|
||||
console.log('The file has been saved!');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static getDirectoryFromFilePath(path: string): string
|
||||
{
|
||||
var forwardIndex = path.lastIndexOf("/");
|
||||
var backwardIndex = path.lastIndexOf("\\");
|
||||
|
||||
var index = forwardIndex > backwardIndex ? forwardIndex : backwardIndex;
|
||||
|
||||
if (index == -1) return "";
|
||||
|
||||
return path.substring(0, index);
|
||||
}
|
||||
|
||||
static getFileNameFromFilePath(path: string): string
|
||||
{
|
||||
var forwardIndex = path.lastIndexOf("/");
|
||||
var backwardIndex = path.lastIndexOf("\\");
|
||||
|
||||
var index = forwardIndex > backwardIndex ? forwardIndex : backwardIndex;
|
||||
|
||||
if (index == -1) return path;
|
||||
|
||||
return path.substring(index + 1);
|
||||
}
|
||||
|
||||
static getVaultPath(): string | null
|
||||
{
|
||||
let adapter = app.vault.adapter;
|
||||
if (adapter instanceof FileSystemAdapter) {
|
||||
return adapter.getBasePath().replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//async function that awaits until a condition is met
|
||||
static async waitUntil(condition: () => boolean, timeout: number = 1000, interval: number = 100): Promise<void>
|
||||
{
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = 0;
|
||||
let intervalId = setInterval(() => {
|
||||
if (condition()) {
|
||||
clearInterval(intervalId);
|
||||
resolve();
|
||||
} else {
|
||||
timer += interval;
|
||||
if (timer >= timeout) {
|
||||
clearInterval(intervalId);
|
||||
reject();
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
static async getThemeContent(themeName: string): Promise<string>
|
||||
{
|
||||
let themePath = this.getVaultPath() + "/.obsidian/themes/" + themeName + "/theme.css";
|
||||
if (!existsSync(themePath)) return "";
|
||||
let themeContent = await Utils.getText(themePath);
|
||||
return themeContent;
|
||||
}
|
||||
|
||||
static getCurrentTheme(): string
|
||||
{
|
||||
/*@ts-ignore*/ // config does exist
|
||||
return app.vault.config?.cssTheme ?? "Default";
|
||||
}
|
||||
|
||||
static getEnabledSnippets(): string[]
|
||||
{
|
||||
/*@ts-ignore*/
|
||||
return app.vault.config?.enabledCssSnippets ?? [];
|
||||
}
|
||||
|
||||
static async getStyleSnippetsContent(): Promise<string[]>
|
||||
{
|
||||
let snippetContents : string[] = [];
|
||||
let enabledSnippets = this.getEnabledSnippets();
|
||||
|
||||
for (var i = 0; i < enabledSnippets.length; i++)
|
||||
{
|
||||
snippetContents.push(await Utils.getText(Utils.getVaultPath() + "/.obsidian/snippets/" + enabledSnippets[i] + ".css"));
|
||||
}
|
||||
|
||||
return snippetContents;
|
||||
}
|
||||
|
||||
static async viewEnableFullRender(view: MarkdownView)
|
||||
{
|
||||
Utils.changeViewMode(view, "preview");
|
||||
await this.delay(200);
|
||||
/*@ts-ignore*/
|
||||
view.previewMode.renderer.showAll = true;
|
||||
/*@ts-ignore*/
|
||||
await view.previewMode.renderer.unfoldAllHeadings();
|
||||
await Utils.delay(300);
|
||||
/*@ts-ignore*/
|
||||
await view.previewMode.renderer.rerender();
|
||||
}
|
||||
|
||||
static async getActiveView(): Promise<TextFileView | null>
|
||||
{
|
||||
let view = app.workspace.getActiveViewOfType(TextFileView);
|
||||
if (!view)
|
||||
{
|
||||
console.log("Failed to find active view");
|
||||
return null;
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
static getFirstFileByName(name: string): TFile | undefined
|
||||
{
|
||||
return app.vault.getFiles().find(file =>
|
||||
{
|
||||
if(!name) return false;
|
||||
return file.basename == name;
|
||||
});
|
||||
}
|
||||
|
||||
static setLineWidth(width: number) : void
|
||||
{
|
||||
if (width != 0)
|
||||
{
|
||||
let sizers = document.getElementsByClassName("markdown-preview-sizer markdown-preview-section");
|
||||
if (sizers.length > 0)
|
||||
sizers[0].setAttribute("style", "max-width: " + width + "px");
|
||||
}
|
||||
}
|
||||
}
|
||||
218
settings.ts
218
settings.ts
|
|
@ -1,218 +0,0 @@
|
|||
import { App, Modal, Plugin, Setting} from 'obsidian';
|
||||
import { Utils } from 'main';
|
||||
|
||||
export interface ExportSettingsData
|
||||
{
|
||||
inlineCSS: boolean;
|
||||
inlineJS: boolean;
|
||||
inlineImages: boolean;
|
||||
|
||||
uzeZip: boolean;
|
||||
addDarkModeToggle: boolean;
|
||||
includeOutline: boolean;
|
||||
customLineWidth: number;
|
||||
lastExportPath: string;
|
||||
|
||||
customJS: string;
|
||||
customCSS: string;
|
||||
|
||||
includePluginCSS: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ExportSettingsData =
|
||||
{
|
||||
inlineCSS: true,
|
||||
inlineJS: true,
|
||||
inlineImages: true,
|
||||
|
||||
uzeZip: false,
|
||||
addDarkModeToggle: true,
|
||||
includeOutline: true,
|
||||
customLineWidth: 0,
|
||||
lastExportPath: '',
|
||||
|
||||
customJS: '',
|
||||
customCSS: '',
|
||||
|
||||
includePluginCSS: ''
|
||||
}
|
||||
|
||||
export class ExportSettings extends Modal
|
||||
{
|
||||
static settings: ExportSettingsData = DEFAULT_SETTINGS;
|
||||
static plugin: Plugin;
|
||||
static isClosed: boolean = true;
|
||||
static success: boolean = false;
|
||||
|
||||
constructor(plugin: Plugin)
|
||||
{
|
||||
super(plugin.app);
|
||||
ExportSettings.plugin = plugin;
|
||||
}
|
||||
|
||||
static async loadSettings()
|
||||
{
|
||||
ExportSettings.settings = Object.assign({}, DEFAULT_SETTINGS, await ExportSettings.plugin.loadData());
|
||||
}
|
||||
|
||||
static async saveSettings()
|
||||
{
|
||||
await ExportSettings.plugin.saveData(ExportSettings.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Opens the modal and async blocks until the modal is closed.
|
||||
* @returns True if the EXPORT button was pressed, false is the export was canceled.
|
||||
* @override
|
||||
*/
|
||||
async open(): Promise<boolean>
|
||||
{
|
||||
super.open();
|
||||
ExportSettings.isClosed = false;
|
||||
|
||||
const {contentEl} = this;
|
||||
|
||||
contentEl.empty();
|
||||
|
||||
let header = contentEl.createEl('h2', {text: 'HTML Webpage Settings'});
|
||||
header.style.display = 'inline-block';
|
||||
|
||||
let supporLink = contentEl.createEl('a', {href: 'https://www.buymeacoffee.com/nathangeorge'});
|
||||
let supportButton = supporLink.createEl('img');
|
||||
supportButton.setAttribute('src', 'https://cdn.buymeacoffee.com/buttons/v2/default-violet.png');
|
||||
supportButton.setAttribute('style', ' height: 30px; margin-right: 0; margin-left: 17px; transform: translateY(25%);');
|
||||
supportButton.setAttribute('alt', 'Buy Me A Coffee');
|
||||
|
||||
contentEl.createEl('h3', {text: 'Inlining Options:'});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Inline CSS')
|
||||
.setDesc('Inline the CSS into the HTML file.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.inlineCSS)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.inlineCSS = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Inline JS')
|
||||
.setDesc('Inline the JS into the HTML file.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.inlineJS)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.inlineJS = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Inline Images')
|
||||
.setDesc('Inline the images into the HTML file.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.inlineImages)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.inlineImages = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
|
||||
contentEl.createEl('h3', {text: 'Special Features:'});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Add Dark Mode Toggle')
|
||||
.setDesc('Adds a fixed theme toggle to the top of any page that doesn\'t already have a toggle embedded with `theme-toggle`.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.addDarkModeToggle)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.addDarkModeToggle = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Include Document Outline')
|
||||
.setDesc('Will include an interactive document outline tree on the right side of the document.')
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(ExportSettings.settings.includeOutline)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.includeOutline = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}
|
||||
));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Custom Line Width')
|
||||
.setDesc('Will set the line width of the document to the specified value. If set to 0, will use whatever the current line width is.')
|
||||
.addText((text) => text
|
||||
.setValue(ExportSettings.settings.customLineWidth.toString())
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.customLineWidth = parseInt(value);
|
||||
await ExportSettings.saveSettings();
|
||||
}
|
||||
));
|
||||
|
||||
contentEl.createEl('h3', {text: 'Export Options:'});
|
||||
|
||||
// new Setting(contentEl)
|
||||
// .setName('Export to ZIP')
|
||||
// .setDesc('Will export a .zip file rather than putting all the files loose in the chosen folder.')
|
||||
// .addToggle((toggle) => toggle
|
||||
// .setValue(ExportSettings.settings.uzeZip)
|
||||
// .onChange(async (value) =>
|
||||
// {
|
||||
// ExportSettings.settings.uzeZip = value;
|
||||
// await ExportSettings.saveSettings();
|
||||
// }));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Include Plugin CSS')
|
||||
.setDesc('Will include the CSS from the plugins listed below. Please write out the plugin\'s ID / folder name exactly each on a new line.')
|
||||
.addTextArea((text) => text
|
||||
.setValue(ExportSettings.settings.includePluginCSS)
|
||||
.onChange(async (value) =>
|
||||
{
|
||||
ExportSettings.settings.includePluginCSS = value;
|
||||
await ExportSettings.saveSettings();
|
||||
}
|
||||
));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Start Export')
|
||||
.addButton((button) => button
|
||||
.setButtonText('Export')
|
||||
.onClick(async () =>
|
||||
{
|
||||
this.close();
|
||||
ExportSettings.success = true;
|
||||
}
|
||||
));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Export and Copy HTML')
|
||||
.addButton((button) => button
|
||||
.setButtonText('Copy HTML')
|
||||
.onClick(async () =>
|
||||
{
|
||||
this.close();
|
||||
ExportSettings.success = true;
|
||||
}
|
||||
));
|
||||
|
||||
await Utils.waitUntil(() => ExportSettings.isClosed, 60 * 60 * 1000, 200);
|
||||
|
||||
return ExportSettings.success;
|
||||
}
|
||||
|
||||
onClose()
|
||||
{
|
||||
const {contentEl} = this;
|
||||
contentEl.empty();
|
||||
ExportSettings.isClosed = true;
|
||||
ExportSettings.success = false;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue