Notes/src/routes/api/note_map.ts

380 lines
12 KiB
TypeScript
Raw Normal View History

2019-06-03 22:55:59 +02:00
"use strict";
import becca from "../../becca/becca.js";
2024-04-06 21:45:58 +03:00
import { JSDOM } from "jsdom";
import BNote from "../../becca/entities/bnote.js";
import BAttribute from "../../becca/entities/battribute.js";
2025-01-09 18:07:02 +02:00
import { Request } from "express";
2024-04-06 21:45:58 +03:00
function buildDescendantCountMap(noteIdsToCount: string[]) {
if (!Array.isArray(noteIdsToCount)) {
2025-01-09 18:07:02 +02:00
throw new Error("noteIdsToCount: type error");
}
2019-06-03 22:55:59 +02:00
const noteIdToCountMap = Object.create(null);
2021-09-17 22:34:23 +02:00
2024-04-06 21:45:58 +03:00
function getCount(noteId: string) {
2021-09-17 22:34:23 +02:00
if (!(noteId in noteIdToCountMap)) {
const note = becca.getNote(noteId);
2024-04-06 21:45:58 +03:00
if (!note) {
return;
}
2021-09-17 22:34:23 +02:00
2025-01-09 18:07:02 +02:00
const hiddenImageNoteIds = note.getRelations("imageLink").map((rel) => rel.value);
const childNoteIds = note.children.map((child) => child.noteId);
const nonHiddenNoteIds = childNoteIds.filter((childNoteId) => !hiddenImageNoteIds.includes(childNoteId));
noteIdToCountMap[noteId] = nonHiddenNoteIds.length;
2021-09-17 22:34:23 +02:00
for (const child of note.children) {
noteIdToCountMap[noteId] += getCount(child.noteId);
}
}
return noteIdToCountMap[noteId];
}
2023-04-24 13:43:19 +08:00
noteIdsToCount.forEach((noteId) => {
getCount(noteId);
});
2021-09-17 22:34:23 +02:00
return noteIdToCountMap;
}
2024-04-06 21:45:58 +03:00
function getNeighbors(note: BNote, depth: number): string[] {
if (depth === 0) {
return [];
}
const retNoteIds = [];
2024-04-06 21:45:58 +03:00
function isIgnoredRelation(relation: BAttribute) {
2025-01-09 18:07:02 +02:00
return ["relationMapLink", "template", "inherit", "image", "ancestor"].includes(relation.name);
}
// forward links
for (const relation of note.getRelations()) {
if (isIgnoredRelation(relation)) {
continue;
}
const targetNote = relation.getTargetNote();
2025-01-09 18:07:02 +02:00
if (!targetNote || targetNote.isLabelTruthy("excludeFromNoteMap")) {
continue;
}
retNoteIds.push(targetNote.noteId);
for (const noteId of getNeighbors(targetNote, depth - 1)) {
retNoteIds.push(noteId);
}
}
// backward links
for (const relation of note.getTargetRelations()) {
if (isIgnoredRelation(relation)) {
continue;
}
const sourceNote = relation.getNote();
2025-01-09 18:07:02 +02:00
if (!sourceNote || sourceNote.isLabelTruthy("excludeFromNoteMap")) {
continue;
}
retNoteIds.push(sourceNote.noteId);
for (const noteId of getNeighbors(sourceNote, depth - 1)) {
retNoteIds.push(noteId);
}
}
return retNoteIds;
}
2024-04-06 21:45:58 +03:00
function getLinkMap(req: Request) {
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
2023-06-30 11:18:34 +02:00
// if the map root itself has "excludeFromNoteMap" attribute (journal typically) then there wouldn't be anything
// to display, so we'll just ignore it
2025-01-09 18:07:02 +02:00
const ignoreExcludeFromNoteMap = mapRootNote.isLabelTruthy("excludeFromNoteMap");
let unfilteredNotes;
2025-01-09 18:07:02 +02:00
if (mapRootNote.type === "search") {
2023-06-30 11:18:34 +02:00
// for search notes, we want to consider the direct search results only without the descendants
unfilteredNotes = mapRootNote.getSearchResultNotes();
} else {
unfilteredNotes = mapRootNote.getSubtree({
includeArchived: false,
resolveSearch: true,
includeHidden: mapRootNote.isInHiddenSubtree()
}).notes;
}
2021-09-20 23:04:41 +02:00
2025-01-09 18:07:02 +02:00
const noteIds = new Set(unfilteredNotes.filter((note) => ignoreExcludeFromNoteMap || !note.isLabelTruthy("excludeFromNoteMap")).map((note) => note.noteId));
2021-09-20 20:02:23 +02:00
2025-01-09 18:07:02 +02:00
if (mapRootNote.type === "search") {
2022-11-07 23:19:38 +01:00
noteIds.delete(mapRootNote.noteId);
}
for (const noteId of getNeighbors(mapRootNote, 3)) {
noteIds.add(noteId);
}
2025-01-09 18:07:02 +02:00
const noteIdsArray = Array.from(noteIds);
2025-01-09 18:07:02 +02:00
const notes = noteIdsArray.map((noteId) => {
2024-04-06 21:45:58 +03:00
const note = becca.getNoteOrThrow(noteId);
2025-01-09 18:07:02 +02:00
return [note.noteId, note.getTitleOrProtected(), note.type, note.getLabelValue("color")];
});
2021-09-20 20:02:23 +02:00
2025-01-09 18:07:02 +02:00
const links = Object.values(becca.attributes)
.filter((rel) => {
if (rel.type !== "relation" || rel.name === "relationMapLink" || rel.name === "template" || rel.name === "inherit") {
2024-04-06 21:45:58 +03:00
return false;
2025-01-09 18:07:02 +02:00
} else if (!noteIds.has(rel.noteId) || !noteIds.has(rel.value)) {
return false;
} else if (rel.name === "imageLink") {
const parentNote = becca.getNote(rel.noteId);
if (!parentNote) {
return false;
}
2021-09-17 22:34:23 +02:00
2025-01-09 18:07:02 +02:00
return !parentNote.getChildNotes().find((childNote) => childNote.noteId === rel.value);
} else {
return true;
}
})
.map((rel) => ({
id: `${rel.noteId}-${rel.name}-${rel.value}`,
sourceNoteId: rel.noteId,
targetNoteId: rel.value,
name: rel.name
}));
2021-09-17 22:34:23 +02:00
2021-09-20 20:02:23 +02:00
return {
notes: notes,
noteIdToDescendantCountMap: buildDescendantCountMap(noteIdsArray),
2021-09-20 20:02:23 +02:00
links: links
};
}
2021-09-17 22:34:23 +02:00
2024-04-06 21:45:58 +03:00
function getTreeMap(req: Request) {
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
2023-06-30 11:18:34 +02:00
// if the map root itself has "excludeFromNoteMap" (journal typically) then there wouldn't be anything to display,
// so we'll just ignore it
2025-01-09 18:07:02 +02:00
const ignoreExcludeFromNoteMap = mapRootNote.isLabelTruthy("excludeFromNoteMap");
const subtree = mapRootNote.getSubtree({
includeArchived: false,
resolveSearch: true,
includeHidden: mapRootNote.isInHiddenSubtree()
});
2021-09-17 22:34:23 +02:00
const notes = subtree.notes
2025-01-09 18:07:02 +02:00
.filter((note) => ignoreExcludeFromNoteMap || !note.isLabelTruthy("excludeFromNoteMap"))
.filter((note) => {
if (note.type !== "image" || note.getChildNotes().length > 0) {
2021-09-21 22:45:06 +02:00
return true;
}
2025-01-09 18:07:02 +02:00
const imageLinkRelation = note.getTargetRelations().find((rel) => rel.name === "imageLink");
2021-09-21 22:45:06 +02:00
if (!imageLinkRelation) {
return true;
}
2025-01-09 18:07:02 +02:00
return !note.getParentNotes().find((parentNote) => parentNote.noteId === imageLinkRelation.noteId);
2021-09-21 22:45:06 +02:00
})
2025-01-09 18:07:02 +02:00
.map((note) => [note.noteId, note.getTitleOrProtected(), note.type, note.getLabelValue("color")]);
2021-09-17 22:34:23 +02:00
2024-04-06 21:45:58 +03:00
const noteIds = new Set<string>();
notes.forEach(([noteId]) => noteId && noteIds.add(noteId));
2021-09-17 22:34:23 +02:00
2021-09-20 20:02:23 +02:00
const links = [];
2025-01-09 18:07:02 +02:00
for (const { parentNoteId, childNoteId } of subtree.relationships) {
if (!noteIds.has(parentNoteId) || !noteIds.has(childNoteId)) {
2021-09-17 22:34:23 +02:00
continue;
}
links.push({
sourceNoteId: parentNoteId,
targetNoteId: childNoteId
2021-09-17 22:34:23 +02:00
});
}
const noteIdToDescendantCountMap = buildDescendantCountMap(Array.from(noteIds));
updateDescendantCountMapForSearch(noteIdToDescendantCountMap, subtree.relationships);
2021-09-17 22:34:23 +02:00
return {
notes: notes,
noteIdToDescendantCountMap: noteIdToDescendantCountMap,
2021-09-17 22:34:23 +02:00
links: links
};
}
2025-01-09 18:07:02 +02:00
function updateDescendantCountMapForSearch(noteIdToDescendantCountMap: Record<string, number>, relationships: { parentNoteId: string; childNoteId: string }[]) {
for (const { parentNoteId, childNoteId } of relationships) {
const parentNote = becca.notes[parentNoteId];
2025-01-09 18:07:02 +02:00
if (!parentNote || parentNote.type !== "search") {
continue;
}
noteIdToDescendantCountMap[parentNote.noteId] = noteIdToDescendantCountMap[parentNoteId] || 0;
noteIdToDescendantCountMap[parentNote.noteId] += noteIdToDescendantCountMap[childNoteId] || 1;
}
}
2024-04-06 21:45:58 +03:00
function removeImages(document: Document) {
2025-01-09 18:07:02 +02:00
const images = document.getElementsByTagName("img");
2024-04-06 21:45:58 +03:00
while (images && images.length > 0) {
images[0]?.parentNode?.removeChild(images[0]);
2021-12-01 23:12:54 +01:00
}
}
2021-12-02 22:00:42 +01:00
const EXCERPT_CHAR_LIMIT = 200;
2025-01-09 18:07:02 +02:00
type ElementOrText = Element | Text;
2021-12-01 23:12:54 +01:00
2024-04-06 21:45:58 +03:00
function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
2021-12-02 22:00:42 +01:00
const html = sourceNote.getContent();
const document = new JSDOM(html).window.document;
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
const excerpts = [];
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
removeImages(document);
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
for (const linkEl of document.querySelectorAll("a")) {
const href = linkEl.getAttribute("href");
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
if (!href || !href.endsWith(referencedNoteId)) {
continue;
}
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
linkEl.classList.add("backlink-link");
2021-12-01 23:12:54 +01:00
2024-04-06 21:45:58 +03:00
let centerEl: HTMLElement = linkEl;
2021-12-01 23:12:54 +01:00
2025-01-09 18:07:02 +02:00
while (centerEl.tagName !== "BODY" && centerEl.parentElement && (centerEl.parentElement?.textContent?.length || 0) <= EXCERPT_CHAR_LIMIT) {
2021-12-02 22:00:42 +01:00
centerEl = centerEl.parentElement;
}
2021-12-01 23:12:54 +01:00
2024-04-06 21:45:58 +03:00
const excerptEls: ElementOrText[] = [centerEl];
let excerptLength = centerEl.textContent?.length || 0;
let left: ElementOrText = centerEl;
let right: ElementOrText = centerEl;
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
while (excerptLength < EXCERPT_CHAR_LIMIT) {
let added = false;
2021-12-01 23:12:54 +01:00
2024-04-06 21:45:58 +03:00
const prev: Element | null = left.previousElementSibling;
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
if (prev) {
2024-04-06 21:45:58 +03:00
const prevText = prev.textContent || "";
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
if (prevText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const prefix = prevText.substr(prevText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
2021-12-01 23:12:54 +01:00
const textNode = document.createTextNode(`${prefix}`);
2021-12-02 22:00:42 +01:00
excerptEls.unshift(textNode);
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
break;
}
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
left = prev;
excerptEls.unshift(left);
excerptLength += prevText.length;
added = true;
}
2021-12-01 23:12:54 +01:00
2024-04-06 21:45:58 +03:00
const next: Element | null = right.nextElementSibling;
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
if (next) {
const nextText = next.textContent;
2021-12-01 23:12:54 +01:00
2024-04-06 21:45:58 +03:00
if (nextText && nextText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
2021-12-02 22:00:42 +01:00
const suffix = nextText.substr(nextText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
2021-12-01 23:12:54 +01:00
const textNode = document.createTextNode(`${suffix}`);
2021-12-02 22:00:42 +01:00
excerptEls.push(textNode);
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
break;
2021-12-01 23:12:54 +01:00
}
2021-12-02 22:00:42 +01:00
right = next;
excerptEls.push(right);
2024-04-06 21:45:58 +03:00
excerptLength += nextText?.length || 0;
2021-12-02 22:00:42 +01:00
added = true;
}
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
if (!added) {
break;
}
}
2021-12-01 23:12:54 +01:00
2025-01-09 18:07:02 +02:00
const excerptWrapper = document.createElement("div");
2021-12-02 22:00:42 +01:00
excerptWrapper.classList.add("ck-content");
excerptWrapper.classList.add("backlink-excerpt");
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
for (const childEl of excerptEls) {
excerptWrapper.appendChild(childEl);
}
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
excerpts.push(excerptWrapper.outerHTML);
}
return excerpts;
}
2021-12-01 23:12:54 +01:00
2024-04-06 21:45:58 +03:00
function getFilteredBacklinks(note: BNote) {
2025-01-09 18:07:02 +02:00
return (
note
.getTargetRelations()
// search notes have "ancestor" relations which are not interesting
.filter((relation) => !!relation.getNote() && relation.getNote().type !== "search")
);
}
2024-04-06 21:45:58 +03:00
function getBacklinkCount(req: Request) {
2025-01-09 18:07:02 +02:00
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
return {
count: getFilteredBacklinks(note).length
};
}
2024-04-06 21:45:58 +03:00
function getBacklinks(req: Request) {
2025-01-09 18:07:02 +02:00
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
2021-12-01 23:12:54 +01:00
2021-12-02 22:00:42 +01:00
let backlinksWithExcerptCount = 0;
2021-12-01 23:12:54 +01:00
2025-01-09 18:07:02 +02:00
return getFilteredBacklinks(note).map((backlink) => {
2021-12-02 22:00:42 +01:00
const sourceNote = backlink.note;
2021-12-01 23:12:54 +01:00
2025-01-09 18:07:02 +02:00
if (sourceNote.type !== "text" || backlinksWithExcerptCount > 50) {
2021-12-02 22:00:42 +01:00
return {
noteId: sourceNote.noteId,
relationName: backlink.name
};
2021-12-01 23:12:54 +01:00
}
2021-12-02 22:00:42 +01:00
backlinksWithExcerptCount++;
const excerpts = findExcerpts(sourceNote, noteId);
2021-12-01 23:12:54 +01:00
return {
noteId: sourceNote.noteId,
excerpts
};
});
}
export default {
2021-09-17 22:34:23 +02:00
getLinkMap,
2021-12-01 23:12:54 +01:00
getTreeMap,
getBacklinkCount,
2021-12-01 23:12:54 +01:00
getBacklinks
2020-06-20 12:31:38 +02:00
};