zotero-better-notes/src/utils/note.ts

538 lines
15 KiB
TypeScript

import TreeModel = require("tree-model");
import katex = require("katex");
import { getEditorInstance, getPositionAtLine, insert } from "./editor";
import { formatPath, getItemDataURL } from "./str";
import { showHint } from "./hint";
import { config } from "../../package.json";
export {
renderNoteHTML,
parseHTMLLines,
getLinesInNote,
addLineToNote,
getNoteTree,
getNoteTreeFlattened,
getNoteTreeNodeById,
copyEmbeddedImagesFromNote,
copyEmbeddedImagesInHTML,
importImageToNote,
};
function parseHTMLLines(html: string): string[] {
// Remove container with one of the attrs named data-schema-version if exists
if (html.includes("data-schema-version")) {
html = html.replace(/<div[^>]*data-schema-version[^>]*>/, "");
html = html.replace(/<\/div>/, "");
}
const noteLines = html.split("\n").filter((e) => e);
// A cache for temporarily stored lines
let previousLineCache = [];
let nextLineCache = [];
const forceInline = ["table", "blockquote", "pre", "ol", "ul"];
const selfInline: string[] = [];
const forceInlineStack = [];
let forceInlineFlag = false;
let selfInlineFlag = false;
const parsedLines = [];
for (const line of noteLines) {
// restore self inline flag
selfInlineFlag = false;
// For force inline tags, set flag to append lines to current line
for (const tag of forceInline) {
const startReg = `<${tag}`;
const isStart = line.includes(startReg);
const endReg = `</${tag}>`;
const isEnd = line.includes(endReg);
if (isStart && !isEnd) {
forceInlineStack.push(tag);
ztoolkit.log("push", tag, line, forceInlineStack);
forceInlineFlag = true;
break;
}
if (isEnd && !isStart) {
forceInlineStack.pop();
ztoolkit.log("pop", tag, line, forceInlineStack);
// Exit force inline mode if the stack is empty
if (forceInlineStack.length === 0) {
forceInlineFlag = false;
}
break;
}
}
if (forceInlineFlag) {
nextLineCache.push(line);
} else {
// For self inline tags, cache start as previous line and end as next line
for (const tag of selfInline) {
const isStart = line.includes(`<${tag}`);
const isEnd = line.includes(`</${tag}>`);
if (isStart && !isEnd) {
selfInlineFlag = true;
nextLineCache.push(line);
break;
}
if (!isStart && isEnd) {
selfInlineFlag = true;
previousLineCache.push(line);
break;
}
}
if (!selfInlineFlag) {
// Append cache to previous line
if (previousLineCache.length) {
parsedLines[parsedLines.length - 1] += `\n${previousLineCache.join(
"\n",
)}`;
previousLineCache = [];
}
let nextLine = "";
// Append cache to next line
if (nextLineCache.length) {
nextLine = nextLineCache.join("\n");
nextLineCache = [];
}
if (nextLine) {
nextLine += "\n";
}
nextLine += `${line}`;
parsedLines.push(nextLine);
}
}
}
return parsedLines;
}
function getLinesInNote(note: Zotero.Item): string[];
async function getLinesInNote(
note: Zotero.Item,
options: {
convertToHTML?: true;
},
): Promise<string[]>;
function getLinesInNote(
note: Zotero.Item,
options?: {
convertToHTML?: boolean;
},
): string[] | Promise<string[]> {
if (!note) {
return [];
}
const noteText: string = note.getNote();
if (options?.convertToHTML) {
return new Promise((resolve) => {
addon.api.convert.note2html(note).then((html) => {
resolve(parseHTMLLines(html));
});
});
}
return parseHTMLLines(noteText);
}
async function setLinesToNote(note: Zotero.Item, lines: string[]) {
if (!note) {
return [];
}
const noteText: string = note.getNote();
const containerIndex = noteText.search(/data-schema-version="[0-9]*/g);
if (containerIndex === -1) {
note.setNote(
`<div data-schema-version="${config.dataSchemaVersion}">${lines.join(
"\n",
)}</div>`,
);
} else {
const noteHead = noteText.substring(0, containerIndex);
note.setNote(
`${noteHead}data-schema-version="${
config.dataSchemaVersion
}">${lines.join("\n")}</div>`,
);
}
await note.saveTx();
}
async function addLineToNote(
note: Zotero.Item,
html: string,
lineIndex: number = -1,
forceMetadata: boolean = false,
) {
if (!note || !html) {
return;
}
const noteLines = getLinesInNote(note);
if (lineIndex < 0 || lineIndex >= noteLines.length) {
lineIndex = noteLines.length;
}
ztoolkit.log(`insert to ${lineIndex}, it used to be ${noteLines[lineIndex]}`);
ztoolkit.log(html);
const editor = getEditorInstance(note.id);
if (editor && !forceMetadata) {
// The note is opened. Add line via note editor
const pos = getPositionAtLine(editor, lineIndex, "start");
ztoolkit.log("Add note line via note editor", pos);
insert(editor, html, pos);
// The selection is automatically moved to the next line
} else {
// The note editor does not exits yet. Fall back to modify the metadata
ztoolkit.log("Add note line via note metadata");
noteLines.splice(lineIndex, 0, html);
await setLinesToNote(note, noteLines);
}
}
async function renderNoteHTML(
html: string,
refNotes: Zotero.Item[],
): Promise<string>;
async function renderNoteHTML(noteItem: Zotero.Item): Promise<string>;
async function renderNoteHTML(
htmlOrNote: string | Zotero.Item,
refNotes?: Zotero.Item[],
): Promise<string> {
let html: string;
if (typeof htmlOrNote === "string") {
html = htmlOrNote;
refNotes = (refNotes || []).filter((item) => item.isNote());
} else {
const noteItem = htmlOrNote as Zotero.Item;
if (!noteItem.isNote()) {
throw new Error("Item is not a note");
}
html = noteItem.getNote();
refNotes = [noteItem];
}
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const imageAttachments = refNotes.reduce((acc, note) => {
acc.push(...Zotero.Items.get(note.getAttachments()));
return acc;
}, [] as Zotero.Item[]);
for (const attachment of imageAttachments) {
if (await attachment.fileExists()) {
const imageNodes = Array.from(
doc.querySelectorAll(`img[data-attachment-key="${attachment.key}"]`),
);
if (imageNodes.length) {
try {
const b64 = await getItemDataURL(attachment);
imageNodes.forEach((node) => {
node.setAttribute("src", b64);
const width = Number(node.getAttribute("width"));
const height = Number(node.getAttribute("height"));
// 650/470 is the default width of images in Word
const maxWidth = Zotero.isMac ? 470 : 650;
if (width > maxWidth) {
node.setAttribute("width", maxWidth.toString());
if (height) {
node.setAttribute(
"height",
Math.round((height * maxWidth) / width).toString(),
);
}
}
});
} catch (e) {
ztoolkit.log(e);
}
}
}
}
const bgNodes = doc.querySelectorAll(
"span[style]",
) as NodeListOf<HTMLElement>;
for (const node of Array.from(bgNodes)) {
// Browser converts #RRGGBBAA hex color to rgba function, and we convert it to rgb function,
// because word processors don't understand colors with alpha channel
if (
node.style.backgroundColor &&
node.style.backgroundColor.startsWith("rgba")
) {
node.style.backgroundColor =
node.style.backgroundColor
.replace("rgba", "rgb")
.split(",")
.slice(0, 3)
.join(",") + ")";
}
}
const mathDelimiterRegex = /^\$+|\$+$/g;
doc.querySelectorAll(".math").forEach((node) => {
const displayMode = node.innerHTML.startsWith("$$");
node.innerHTML = katex.renderToString(
node.textContent!.replace(mathDelimiterRegex, ""),
{
throwOnError: false,
// output: "mathml",
displayMode,
},
);
});
return doc.body.innerHTML;
}
function getNoteTree(
note: Zotero.Item,
parseLink: boolean = true,
): TreeModel.Node<NoteNodeData> {
const noteLines = getLinesInNote(note);
const parser = new DOMParser();
const tree = new TreeModel();
const root = tree.parse({
id: -1,
level: 0,
lineIndex: -1,
endIndex: -1,
});
let id = 0;
let lastNode = root;
const headingRegex = new RegExp("^<h([1-6])(.*?)</h[1-6]>");
const linkRegex = new RegExp('href="(zotero://note/[^"]*)"');
for (const i in noteLines) {
let currentLevel = 7;
const lineElement = noteLines[i];
const matchHeadingResult = lineElement.match(headingRegex);
const matchLinkResult = parseLink ? lineElement.match(linkRegex) : null;
const isHeading = Boolean(matchHeadingResult);
// Links in blockquote are ignored
const isLink =
Boolean(matchLinkResult) && !noteLines[i].startsWith("<blockquote");
if (isHeading || isLink) {
let name = "";
let link = "";
if (isHeading) {
currentLevel = parseInt(matchHeadingResult![1] || "7");
} else {
link = matchLinkResult![1];
}
name = parser.parseFromString(lineElement, "text/html").body.innerText;
// Find parent node
let parentNode = lastNode;
while (parentNode.model.level >= currentLevel) {
parentNode = parentNode.parent;
}
const currentNode = tree.parse({
id: id++,
level: currentLevel,
name: name,
lineIndex: parseInt(i),
endIndex: noteLines.length - 1,
link: link,
});
parentNode.addChild(currentNode);
const currentIndex = parentNode.children.indexOf(currentNode);
if (currentIndex > 0) {
const previousNode = parentNode.children[
currentIndex - 1
] as TreeModel.Node<NoteNodeData>;
// Traverse the previous node tree and set the end index
previousNode.walk((node) => {
if (node.model.endIndex > parseInt(i) - 1) {
node.model.endIndex = parseInt(i) - 1;
}
return true;
});
previousNode.model.endIndex = parseInt(i) - 1;
}
lastNode = currentNode;
}
}
return root;
}
function getNoteTreeFlattened(
note: Zotero.Item,
options: {
keepRoot?: boolean;
keepLink?: boolean;
customFilter?: (node: TreeModel.Node<NoteNodeData>) => boolean;
} = { keepRoot: false, keepLink: false },
): TreeModel.Node<NoteNodeData>[] {
if (!note) {
return [];
}
return getNoteTree(note).all(
(node) =>
(options.keepRoot || node.model.lineIndex >= 0) &&
(options.keepLink || node.model.level <= 6) &&
(options.customFilter ? options.customFilter(node) : true),
);
}
function getNoteTreeNodeById(
note: Zotero.Item,
id: number,
root: TreeModel.Node<NoteNodeData> | undefined = undefined,
) {
root = root || getNoteTree(note);
return root.first(function (node) {
return node.model.id === id;
});
}
function getNoteTreeNodesByLevel(
note: Zotero.Item,
level: number,
root: TreeModel.Node<NoteNodeData> | undefined = undefined,
) {
root = root || getNoteTree(note);
return root.all(function (node) {
return node.model.level === level;
});
}
async function copyEmbeddedImagesFromNote(
targetNote: Zotero.Item,
sourceNotes: Zotero.Item[],
) {
await Zotero.DB.executeTransaction(async () => {
for (const fromNote of sourceNotes) {
await Zotero.Notes.copyEmbeddedImages(fromNote, targetNote);
}
});
}
async function copyEmbeddedImagesInHTML(
html: string,
targetNote?: Zotero.Item,
refNotes: Zotero.Item[] = [],
) {
ztoolkit.log("parseEmbeddedImagesInHTML", html, targetNote, refNotes);
if (!targetNote) {
return html;
}
const attachments = refNotes.reduce((acc, note) => {
acc.push(...Zotero.Items.get(note.getAttachments()));
return acc;
}, [] as Zotero.Item[]);
if (!attachments.length) {
return html;
}
ztoolkit.log(attachments);
const doc = new DOMParser().parseFromString(html, "text/html");
// Copy note image attachments and replace keys in the new note
for (const attachment of attachments) {
if (await attachment.fileExists()) {
const nodes = Array.from(
doc.querySelectorAll(`img[data-attachment-key="${attachment.key}"]`),
);
if (nodes.length) {
let copiedAttachment: Zotero.Item;
await Zotero.DB.executeTransaction(async () => {
Zotero.DB.requireTransaction();
copiedAttachment = await Zotero.Attachments.copyEmbeddedImage({
attachment,
note: targetNote,
});
});
nodes.forEach((node) =>
node.setAttribute("data-attachment-key", copiedAttachment.key),
);
}
}
}
ztoolkit.log("embed", doc.body.innerHTML);
return doc.body.innerHTML;
}
function dataURLtoBlob(dataurl: string) {
const parts = dataurl.split(",");
const matches = parts[0]?.match(/:(.*?);/);
if (!matches || !matches[1]) {
return;
}
const mime = matches[1];
if (parts[0].indexOf("base64") !== -1) {
const bstr = ztoolkit.getGlobal("atob")(parts[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new (ztoolkit.getGlobal("Blob"))([u8arr], {
type: mime,
});
}
return null;
}
async function importImageToNote(
note: Zotero.Item,
src: string,
type: "b64" | "url" | "file" = "b64",
): Promise<string | void> {
if (!note || !note.isNote()) {
return "";
}
let blob: Blob;
if (src.startsWith("data:")) {
const dataBlob = dataURLtoBlob(src);
if (!dataBlob) {
return;
}
blob = dataBlob;
} else if (type === "url") {
let res;
try {
res = await Zotero.HTTP.request("GET", src, { responseType: "blob" });
} catch (e) {
return;
}
blob = res.response;
} else if (type === "file") {
src = formatPath(src);
const noteAttachmentKeys = Zotero.Items.get(note.getAttachments()).map(
(_i) => _i.key,
);
const filename = src.split("/").pop()?.split(".").shift();
// The exported image is KEY.png by default.
// If it is already an attachment, just keep it.
if (noteAttachmentKeys.includes(filename || "")) {
return filename;
}
const imageData = await Zotero.File.getBinaryContentsAsync(src);
const array = new Uint8Array(imageData.length);
for (let i = 0; i < imageData.length; i++) {
array[i] = imageData.charCodeAt(i);
}
blob = new Blob([array], { type: "image/png" });
} else {
return;
}
if (!blob) {
showHint("Failed to import image.");
return;
}
const attachment = await Zotero.Attachments.importEmbeddedImage({
blob,
parentItemID: note.id,
saveOptions: {},
});
return attachment.key;
}