trilium/src/routes/api/note_map.js
2023-04-23 15:36:34 +08:00

397 lines
11 KiB
JavaScript

"use strict";
const becca = require("../../becca/becca");
const { JSDOM } = require("jsdom");
const NotFoundError = require("../../errors/not_found_error");
function buildDescendantCountMap(noteIdsToCount) {
if (!Array.isArray(noteIdsToCount)) {
throw new Error('noteIdsToCount: type error');
}
const noteIdToCountMap = Object.create(null);
function getCount(noteId) {
if (!(noteId in noteIdToCountMap)) {
const note = becca.getNote(noteId);
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;
for (const child of note.children) {
noteIdToCountMap[noteId] += getCount(child.noteId);
}
}
return noteIdToCountMap[noteId];
}
noteIdsToCount.forEach((nodeId) => {
getCount(nodeId);
});
return noteIdToCountMap;
}
/**
* @param {BNote} note
* @param {int} depth
* @returns {string[]} noteIds
*/
function getNeighbors(note, depth) {
if (depth === 0) {
return [];
}
const retNoteIds = [];
function isIgnoredRelation(relation) {
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();
if (!targetNote || targetNote.hasLabel('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();
if (!sourceNote || sourceNote.hasLabel('excludeFromNoteMap')) {
continue;
}
retNoteIds.push(sourceNote.noteId);
for (const noteId of getNeighbors(sourceNote, depth - 1)) {
retNoteIds.push(noteId);
}
}
return retNoteIds;
}
function getLinkMap(req) {
const mapRootNote = becca.getNote(req.params.noteId);
// if the map root itself has exclude attribute (journal typically) then there wouldn't be anything to display, so
// we'll just ignore it
const ignoreExcludeFromNoteMap = mapRootNote.hasLabel('excludeFromNoteMap');
let unfilteredNotes;
if (mapRootNote.type === 'search') {
// 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;
}
const noteIds = new Set(
unfilteredNotes
.filter(note => ignoreExcludeFromNoteMap || !note.hasLabel('excludeFromNoteMap'))
.map(note => note.noteId)
);
if (mapRootNote.type === 'search') {
noteIds.delete(mapRootNote.noteId);
}
for (const noteId of getNeighbors(mapRootNote, 3)) {
noteIds.add(noteId);
}
const noteIdsArray = Array.from(noteIds)
const notes = noteIdsArray.map(noteId => {
const note = becca.getNote(noteId);
return [
note.noteId,
note.getTitleOrProtected(),
note.type,
note.getLabelValue('color')
];
});
const links = Object.values(becca.attributes).filter(rel => {
if (rel.type !== 'relation' || rel.name === 'relationMapLink' || rel.name === 'template' || rel.name === 'inherit') {
return false;
}
else if (!noteIds.has(rel.noteId) || !noteIds.has(rel.value)) {
return false;
}
else if (rel.name === 'imageLink') {
const parentNote = becca.getNote(rel.noteId);
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
}));
return {
notes: notes,
noteIdToDescendantCountMap: buildDescendantCountMap(noteIdsArray),
links: links
};
}
function getTreeMap(req) {
const mapRootNote = becca.getNote(req.params.noteId);
// if the map root itself has ignore (journal typically) then there wouldn't be anything to display, so
// we'll just ignore it
const ignoreExcludeFromNoteMap = mapRootNote.hasLabel('excludeFromNoteMap');
const subtree = mapRootNote.getSubtree({
includeArchived: false,
resolveSearch: true,
includeHidden: mapRootNote.isInHiddenSubtree()
});
const notes = subtree.notes
.filter(note => ignoreExcludeFromNoteMap || !note.hasLabel('excludeFromNoteMap'))
.filter(note => {
if (note.type !== 'image' || note.getChildNotes().length > 0) {
return true;
}
const imageLinkRelation = note.getTargetRelations().find(rel => rel.name === 'imageLink');
if (!imageLinkRelation) {
return true;
}
return !note.getParentNotes().find(parentNote => parentNote.noteId === imageLinkRelation.noteId);
})
.map(note => [
note.noteId,
note.getTitleOrProtected(),
note.type,
note.getLabelValue('color')
]);
const noteIds = new Set();
notes.forEach(([noteId]) => noteIds.add(noteId));
const links = [];
for (const {parentNoteId, childNoteId} of subtree.relationships) {
if (!noteIds.has(parentNoteId) || !noteIds.has(childNoteId)) {
continue;
}
links.push({
sourceNoteId: parentNoteId,
targetNoteId: childNoteId
});
}
const noteIdToDescendantCountMap = buildDescendantCountMap(Array.from(noteIds));
updateDescendantCountMapForSearch(noteIdToDescendantCountMap, subtree.relationships);
return {
notes: notes,
noteIdToDescendantCountMap: noteIdToDescendantCountMap,
links: links
};
}
function updateDescendantCountMapForSearch(noteIdToDescendantCountMap, relationships) {
for (const {parentNoteId, childNoteId} of relationships) {
const parentNote = becca.notes[parentNoteId];
if (!parentNote || parentNote.type !== 'search') {
continue;
}
noteIdToDescendantCountMap[parentNote.noteId] = noteIdToDescendantCountMap[parentNoteId] || 0;
noteIdToDescendantCountMap[parentNote.noteId] += noteIdToDescendantCountMap[childNoteId] || 1;
}
}
function removeImages(document) {
const images = document.getElementsByTagName('img');
while (images.length > 0) {
images[0].parentNode.removeChild(images[0]);
}
}
const EXCERPT_CHAR_LIMIT = 200;
function findExcerpts(sourceNote, referencedNoteId) {
const html = sourceNote.getContent();
const document = new JSDOM(html).window.document;
const excerpts = [];
removeImages(document);
for (const linkEl of document.querySelectorAll("a")) {
const href = linkEl.getAttribute("href");
if (!href || !href.endsWith(referencedNoteId)) {
continue;
}
linkEl.classList.add("backlink-link");
let centerEl = linkEl;
while (centerEl.tagName !== 'BODY' && centerEl.parentElement?.textContent?.length <= EXCERPT_CHAR_LIMIT) {
centerEl = centerEl.parentElement;
}
/** @var {HTMLElement[]} */
const excerptEls = [centerEl];
let excerptLength = centerEl.textContent.length;
let left = centerEl;
let right = centerEl;
while (excerptLength < EXCERPT_CHAR_LIMIT) {
let added = false;
const prev = left.previousElementSibling;
if (prev) {
const prevText = prev.textContent;
if (prevText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const prefix = prevText.substr(prevText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
const textNode = document.createTextNode(`${prefix}`);
excerptEls.unshift(textNode);
break;
}
left = prev;
excerptEls.unshift(left);
excerptLength += prevText.length;
added = true;
}
const next = right.nextElementSibling;
if (next) {
const nextText = next.textContent;
if (nextText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const suffix = nextText.substr(nextText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
const textNode = document.createTextNode(`${suffix}`);
excerptEls.push(textNode);
break;
}
right = next;
excerptEls.push(right);
excerptLength += nextText.length;
added = true;
}
if (!added) {
break;
}
}
const excerptWrapper = document.createElement('div');
excerptWrapper.classList.add("ck-content");
excerptWrapper.classList.add("backlink-excerpt");
for (const childEl of excerptEls) {
excerptWrapper.appendChild(childEl);
}
excerpts.push(excerptWrapper.outerHTML);
}
return excerpts;
}
function getFilteredBacklinks(note) {
return note.getTargetRelations()
// search notes have "ancestor" relations which are not interesting
.filter(relation => !!relation.getNote() && relation.getNote().type !== 'search');
}
function getBacklinkCount(req) {
const {noteId} = req.params;
const note = becca.getNote(noteId);
if (!note) {
throw new NotFoundError(`Note '${noteId}' not found`);
}
else {
return {
count: getFilteredBacklinks(note).length
};
}
}
function getBacklinks(req) {
const {noteId} = req.params;
const note = becca.getNote(noteId);
if (!note) {
throw new NotFoundError(`Note '${noteId}' was not found`);
}
let backlinksWithExcerptCount = 0;
return getFilteredBacklinks(note).map(backlink => {
const sourceNote = backlink.note;
if (sourceNote.type !== 'text' || backlinksWithExcerptCount > 50) {
return {
noteId: sourceNote.noteId,
relationName: backlink.name
};
}
backlinksWithExcerptCount++;
const excerpts = findExcerpts(sourceNote, noteId);
return {
noteId: sourceNote.noteId,
excerpts
};
});
}
module.exports = {
getLinkMap,
getTreeMap,
getBacklinkCount,
getBacklinks
};