chore(nx): move all monorepo-style in subfolder for processing

This commit is contained in:
Elian Doran
2025-04-22 10:06:06 +03:00
parent 2e200eab39
commit 62dbcc0a2e
1469 changed files with 16 additions and 16 deletions

View File

@@ -1,34 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderCode, type Result } from "./content_renderer.js";
describe("content_renderer", () => {
describe("renderCode", () => {
it("identifies empty content", () => {
const emptyResult: Result = {
header: "",
content: " "
};
renderCode(emptyResult);
expect(emptyResult.isEmpty).toBeTruthy();
});
it("identifies unsupported content type", () => {
const emptyResult: Result = {
header: "",
content: Buffer.from("Hello world")
};
renderCode(emptyResult);
expect(emptyResult.isEmpty).toBeTruthy();
});
it("wraps code in <pre>", () => {
const result: Result = {
header: "",
content: "\tHello\nworld"
};
renderCode(result);
expect(result.isEmpty).toBeFalsy();
expect(result.content).toBe("<pre>\tHello\nworld</pre>");
});
});
});

View File

@@ -1,189 +0,0 @@
import { JSDOM } from "jsdom";
import shaca from "./shaca/shaca.js";
import assetPath from "../services/asset_path.js";
import shareRoot from "./share_root.js";
import escapeHtml from "escape-html";
import type SNote from "./shaca/entities/snote.js";
import { t } from "i18next";
/**
* Represents the output of the content renderer.
*/
export interface Result {
header: string;
content: string | Buffer | undefined;
/** Set to `true` if the provided content should be rendered as empty. */
isEmpty?: boolean;
}
function getContent(note: SNote) {
if (note.isProtected) {
return {
header: "",
content: "<p>Protected note cannot be displayed</p>",
isEmpty: false
};
}
const result: Result = {
content: note.getContent(),
header: "",
isEmpty: false
};
if (note.type === "text") {
renderText(result, note);
} else if (note.type === "code") {
renderCode(result);
} else if (note.type === "mermaid") {
renderMermaid(result, note);
} else if (["image", "canvas", "mindMap"].includes(note.type)) {
renderImage(result, note);
} else if (note.type === "file") {
renderFile(note, result);
} else if (note.type === "book") {
result.isEmpty = true;
} else {
result.content = `<p>${t("content_renderer.note-cannot-be-displayed")}</p>`;
}
return result;
}
function renderIndex(result: Result) {
result.content += '<ul id="index">';
const rootNote = shaca.getNote(shareRoot.SHARE_ROOT_NOTE_ID);
for (const childNote of rootNote.getChildNotes()) {
const isExternalLink = childNote.hasLabel("shareExternalLink");
const href = isExternalLink ? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const target = isExternalLink ? `target="_blank" rel="noopener noreferrer"` : "";
result.content += `<li><a class="${childNote.type}" href="${href}" ${target}>${childNote.escapedTitle}</a></li>`;
}
result.content += "</ul>";
}
function renderText(result: Result, note: SNote) {
const document = new JSDOM(result.content || "").window.document;
result.isEmpty = document.body.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0;
if (!result.isEmpty) {
for (const linkEl of document.querySelectorAll("a")) {
const href = linkEl.getAttribute("href");
// Preserve footnotes.
if (href?.startsWith("#fn")) {
continue;
}
if (href?.startsWith("#")) {
handleAttachmentLink(linkEl, href);
}
}
result.content = document.body.innerHTML;
if (result.content.includes(`<span class="math-tex">`)) {
result.header += `
<script src="../${assetPath}/node_modules/katex/dist/katex.min.js"></script>
<link rel="stylesheet" href="../${assetPath}/node_modules/katex/dist/katex.min.css">
<script src="../${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script>
<script src="../${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
renderMathInElement(document.getElementById('content'));
});
</script>`;
}
if (note.hasLabel("shareIndex")) {
renderIndex(result);
}
}
}
function handleAttachmentLink(linkEl: HTMLAnchorElement, href: string) {
const linkRegExp = /attachmentId=([a-zA-Z0-9_]+)/g;
let attachmentMatch;
if ((attachmentMatch = linkRegExp.exec(href))) {
const attachmentId = attachmentMatch[1];
const attachment = shaca.getAttachment(attachmentId);
if (attachment) {
linkEl.setAttribute("href", `api/attachments/${attachmentId}/download`);
linkEl.classList.add(`attachment-link`);
linkEl.classList.add(`role-${attachment.role}`);
linkEl.innerText = attachment.title;
} else {
linkEl.removeAttribute("href");
}
} else {
const [notePath] = href.split("?");
const notePathSegments = notePath.split("/");
const noteId = notePathSegments[notePathSegments.length - 1];
const linkedNote = shaca.getNote(noteId);
if (linkedNote) {
const isExternalLink = linkedNote.hasLabel("shareExternalLink");
const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `./${linkedNote.shareId}`;
if (href) {
linkEl.setAttribute("href", href);
}
if (isExternalLink) {
linkEl.setAttribute("target", "_blank");
linkEl.setAttribute("rel", "noopener noreferrer");
}
linkEl.classList.add(`type-${linkedNote.type}`);
} else {
linkEl.removeAttribute("href");
}
}
}
/**
* Renders a code note.
*/
export function renderCode(result: Result) {
if (typeof result.content !== "string" || !result.content?.trim()) {
result.isEmpty = true;
} else {
const document = new JSDOM().window.document;
const preEl = document.createElement("pre");
preEl.appendChild(document.createTextNode(result.content));
result.content = preEl.outerHTML;
}
}
function renderMermaid(result: Result, note: SNote) {
if (typeof result.content !== "string") {
return;
}
result.content = `
<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">
<hr>
<details>
<summary>Chart source</summary>
<pre>${escapeHtml(result.content)}</pre>
</details>`;
}
function renderImage(result: Result, note: SNote) {
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
}
function renderFile(note: SNote, result: Result) {
if (note.mime === "application/pdf") {
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`;
} else {
result.content = `<button type="button" onclick="location.href='api/notes/${note.noteId}/download'">Download file</button>`;
}
}
export default {
getContent
};

View File

@@ -1,37 +0,0 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import supertest from "supertest";
import { initializeTranslations } from "../services/i18n.js";
import type { Application, Request, Response, NextFunction } from "express";
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
let app: Application;
describe("Share API test", () => {
let cannotSetHeadersCount = 0;
beforeAll(async () => {
initializeTranslations();
app = (await import("../app.js")).default;
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
const [ errMessage ] = safeExtractMessageAndStackFromError(err);
if (errMessage.includes("Cannot set headers after they are sent to the client")) {
cannotSetHeadersCount++;
}
next();
});
});
beforeEach(() => {
cannotSetHeadersCount = 0;
});
it("requests password for password-protected share", async () => {
await supertest(app)
.get("/share/YjlPRj2E9fOV")
.expect(401)
.expect("WWW-Authenticate", 'Basic realm="User Visible Realm", charset="UTF-8"');
expect(cannotSetHeadersCount).toBe(0);
});
});

View File

@@ -1,389 +0,0 @@
import safeCompare from "safe-compare";
import ejs from "ejs";
import type { Request, Response, Router } from "express";
import shaca from "./shaca/shaca.js";
import shacaLoader from "./shaca/shaca_loader.js";
import shareRoot from "./share_root.js";
import contentRenderer from "./content_renderer.js";
import assetPath from "../services/asset_path.js";
import appPath from "../services/app_path.js";
import searchService from "../services/search/services/search.js";
import SearchContext from "../services/search/search_context.js";
import log from "../services/log.js";
import type SNote from "./shaca/entities/snote.js";
import type SBranch from "./shaca/entities/sbranch.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import utils, { safeExtractMessageAndStackFromError } from "../services/utils.js";
import options from "../services/options.js";
function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } {
if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
// share root itself is not shared
return {};
}
// every path leads to share root, but which one to choose?
// for the sake of simplicity, URLs are not note paths
const parentBranch = note.getParentBranches()[0];
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
return {
note,
branch: parentBranch
};
}
return getSharedSubTreeRoot(parentBranch.getParentNote());
}
function addNoIndexHeader(note: SNote, res: Response) {
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
res.setHeader("X-Robots-Tag", "noindex");
}
}
function requestCredentials(res: Response) {
res.setHeader("WWW-Authenticate", 'Basic realm="User Visible Realm", charset="UTF-8"').sendStatus(401);
}
function checkAttachmentAccess(attachmentId: string, req: Request, res: Response) {
const attachment = shaca.getAttachment(attachmentId);
if (!attachment) {
res.status(404).json({ message: `Attachment '${attachmentId}' not found.` });
return false;
}
const note = checkNoteAccess(attachment.ownerId, req, res);
// truthy note means the user has access, and we can return the attachment
return note ? attachment : false;
}
function checkNoteAccess(noteId: string, req: Request, res: Response) {
const note = shaca.getNote(noteId);
if (!note) {
res.status(404).json({ message: `Note '${noteId}' not found.` });
return false;
}
if (noteId === "_share" && !shaca.shareIndexEnabled) {
res.status(403).json({ message: `Accessing share index is forbidden.` });
return false;
}
const credentials = note.getCredentials();
if (credentials.length === 0) {
return note;
}
const header = req.header("Authorization");
if (!header?.startsWith("Basic ")) {
return false;
}
const base64Str = header.substring("Basic ".length);
const buffer = Buffer.from(base64Str, "base64");
const authString = buffer.toString("utf-8");
for (const credentialLabel of credentials) {
if (safeCompare(authString, credentialLabel.value)) {
return note; // success;
}
}
return false;
}
function renderImageAttachment(image: SNote, res: Response, attachmentName: string) {
let svgString = "<svg/>";
const attachment = image.getAttachmentByTitle(attachmentName);
if (!attachment) {
res.status(404).render("share/404");
return;
}
const content = attachment.getContent();
if (typeof content === "string") {
svgString = content;
} else {
// backwards compatibility, before attachments, the SVG was stored in the main note content as a separate key
const possibleSvgContent = image.getJsonContentSafely();
const contentSvg = (typeof possibleSvgContent === "object"
&& possibleSvgContent !== null
&& "svg" in possibleSvgContent
&& typeof possibleSvgContent.svg === "string")
? possibleSvgContent.svg
: null;
if (contentSvg) {
svgString = contentSvg;
}
}
const svg = svgString;
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
}
function register(router: Router) {
function renderNote(note: SNote, req: Request, res: Response) {
if (!note) {
console.log("Unable to find note ", note);
res.status(404).render("share/404");
return;
}
if (!checkNoteAccess(note.noteId, req, res)) {
requestCredentials(res);
return;
}
addNoIndexHeader(note, res);
if (note.isLabelTruthy("shareRaw") || typeof req.query.raw !== "undefined") {
res.setHeader("Content-Type", note.mime).send(note.getContent());
return;
}
const { header, content, isEmpty } = contentRenderer.getContent(note);
const subRoot = getSharedSubTreeRoot(note);
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
const opts = { note, header, content, isEmpty, subRoot, assetPath, appPath, showLoginInShareTheme };
let useDefaultView = true;
// Check if the user has their own template
if (note.hasRelation("shareTemplate")) {
// Get the template note and content
const templateId = note.getRelation("shareTemplate")?.value;
const templateNote = templateId && shaca.getNote(templateId);
// Make sure the note type is correct
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
// EJS caches the result of this so we don't need to pre-cache
const includer = (path: string) => {
const childNote = templateNote.children.find((n) => path === n.title);
if (!childNote) throw new Error("Unable to find child note.");
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
const template = childNote.getContent();
if (typeof template !== "string") throw new Error("Invalid template content type.");
return { template };
};
// Try to render user's template, w/ fallback to default view
try {
const content = templateNote.getContent();
if (typeof content === "string") {
const ejsResult = ejs.render(content, opts, { includer });
res.send(ejsResult);
useDefaultView = false; // Rendering went okay, don't use default view
}
} catch (e: unknown) {
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
}
}
}
if (useDefaultView) {
res.render("share/page", opts);
}
}
router.get("/share/", (req, res) => {
if (req.path.substr(-1) !== "/") {
res.redirect("../share/");
return;
}
shacaLoader.ensureLoad();
if (!shaca.shareRootNote) {
res.status(404).json({ message: "Share root note not found" });
return;
}
renderNote(shaca.shareRootNote, req, res);
});
router.get("/share/:shareId", (req, res) => {
shacaLoader.ensureLoad();
const { shareId } = req.params;
const note = shaca.aliasToNote[shareId] || shaca.notes[shareId];
renderNote(note, req, res);
});
router.get("/share/api/notes/:noteId", (req, res) => {
shacaLoader.ensureLoad();
let note: SNote | boolean;
if (!(note = checkNoteAccess(req.params.noteId, req, res))) {
return;
}
addNoIndexHeader(note, res);
res.json(note.getPojo());
});
router.get("/share/api/notes/:noteId/download", (req, res) => {
shacaLoader.ensureLoad();
let note: SNote | boolean;
if (!(note = checkNoteAccess(req.params.noteId, req, res))) {
return;
}
addNoIndexHeader(note, res);
const filename = utils.formatDownloadTitle(note.title, note.type, note.mime);
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", note.mime);
res.send(note.getContent());
});
// :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename
router.get("/share/api/images/:noteId/:filename", (req, res) => {
shacaLoader.ensureLoad();
let image: SNote | boolean;
if (!(image = checkNoteAccess(req.params.noteId, req, res))) {
return;
}
if (image.type === "image") {
// normal image
res.set("Content-Type", image.mime);
addNoIndexHeader(image, res);
res.send(image.getContent());
} else if (image.type === "canvas") {
renderImageAttachment(image, res, "canvas-export.svg");
} else if (image.type === "mermaid") {
renderImageAttachment(image, res, "mermaid-export.svg");
} else if (image.type === "mindMap") {
renderImageAttachment(image, res, "mindmap-export.svg");
} else {
res.status(400).json({ message: "Requested note is not a shareable image" });
}
});
// :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename
router.get("/share/api/attachments/:attachmentId/image/:filename", (req, res) => {
shacaLoader.ensureLoad();
let attachment: SAttachment | boolean;
if (!(attachment = checkAttachmentAccess(req.params.attachmentId, req, res))) {
return;
}
if (attachment.role === "image") {
res.set("Content-Type", attachment.mime);
addNoIndexHeader(attachment.note, res);
res.send(attachment.getContent());
} else {
res.status(400).json({ message: "Requested attachment is not a shareable image" });
}
});
router.get("/share/api/attachments/:attachmentId/download", (req, res) => {
shacaLoader.ensureLoad();
let attachment: SAttachment | boolean;
if (!(attachment = checkAttachmentAccess(req.params.attachmentId, req, res))) {
return;
}
addNoIndexHeader(attachment.note, res);
const filename = utils.formatDownloadTitle(attachment.title, null, attachment.mime);
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", attachment.mime);
res.send(attachment.getContent());
});
// used for PDF viewing
router.get("/share/api/notes/:noteId/view", (req, res) => {
shacaLoader.ensureLoad();
let note: SNote | boolean;
if (!(note = checkNoteAccess(req.params.noteId, req, res))) {
return;
}
addNoIndexHeader(note, res);
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", note.mime);
res.send(note.getContent());
});
// Used for searching, require noteId so we know the subTreeRoot
router.get("/share/api/notes", (req, res) => {
shacaLoader.ensureLoad();
const ancestorNoteId = req.query.ancestorNoteId ?? "_share";
if (typeof ancestorNoteId !== "string") {
res.status(400).json({ message: "'ancestorNoteId' parameter is mandatory." });
return;
}
// This will automatically return if no ancestorNoteId is provided and there is no shareIndex
if (!checkNoteAccess(ancestorNoteId, req, res)) {
return;
}
const { search } = req.query;
if (typeof search !== "string" || !search?.trim()) {
res.status(400).json({ message: "'search' parameter is mandatory." });
return;
}
const searchContext = new SearchContext({ ancestorNoteId: ancestorNoteId });
const searchResults = searchService.findResultsWithQuery(search, searchContext);
const filteredResults = searchResults.map((sr) => {
const fullNote = shaca.notes[sr.noteId];
const startIndex = sr.notePathArray.indexOf(ancestorNoteId);
const localPathArray = sr.notePathArray.slice(startIndex + 1).filter((id) => shaca.notes[id]);
const pathTitle = localPathArray.map((id) => shaca.notes[id].title).join(" / ");
return { id: fullNote.shareId, title: fullNote.title, score: sr.score, path: pathTitle };
});
res.json({ results: filteredResults });
});
}
export default {
register
};

View File

@@ -1,10 +0,0 @@
import shaca from "../shaca.js";
import type Shaca from "../shaca-interface.js";
class AbstractShacaEntity {
get shaca(): Shaca {
return shaca;
}
}
export default AbstractShacaEntity;

View File

@@ -1,4 +0,0 @@
export type SNoteRow = [string, string, string, string, string, string, boolean];
export type SBranchRow = [string, string, string, string, string, boolean];
export type SAttributeRow = [string, string, string, string, string, boolean, number];
export type SAttachmentRow = [string, string, string, string, string, string, string];

View File

@@ -1,76 +0,0 @@
"use strict";
import sql from "../../sql.js";
import utils from "../../../services/utils.js";
import AbstractShacaEntity from "./abstract_shaca_entity.js";
import type SNote from "./snote.js";
import type { Blob } from "../../../services/blob-interface.js";
import type { SAttachmentRow } from "./rows.js";
class SAttachment extends AbstractShacaEntity {
private attachmentId: string;
ownerId: string;
title: string;
role: string;
mime: string;
private blobId: string;
/** used for caching of images */
private utcDateModified: string;
constructor([attachmentId, ownerId, role, mime, title, blobId, utcDateModified]: SAttachmentRow) {
super();
this.attachmentId = attachmentId;
this.ownerId = ownerId;
this.title = title;
this.role = role;
this.mime = mime;
this.blobId = blobId;
this.utcDateModified = utcDateModified;
this.shaca.attachments[this.attachmentId] = this;
this.shaca.notes[this.ownerId].attachments.push(this);
}
get note(): SNote {
return this.shaca.notes[this.ownerId];
}
getContent(silentNotFoundError = false) {
const row = sql.getRow<Pick<Blob, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
if (!row) {
if (silentNotFoundError) {
return undefined;
} else {
throw new Error(`Cannot find blob for attachment '${this.attachmentId}', blob '${this.blobId}'`);
}
}
const content = row.content;
if (this.hasStringContent()) {
return content === null ? "" : content.toString("utf-8");
} else {
return content;
}
}
/** @returns true if the attachment has string content (not binary) */
hasStringContent() {
return utils.isStringNote(undefined, this.mime);
}
getPojo() {
return {
attachmentId: this.attachmentId,
role: this.role,
mime: this.mime,
title: this.title,
blobId: this.blobId,
utcDateModified: this.utcDateModified
};
}
}
export default SAttachment;

View File

@@ -1,111 +0,0 @@
"use strict";
import AbstractShacaEntity from "./abstract_shaca_entity.js";
import type { SAttributeRow } from "./rows.js";
import type SNote from "./snote.js";
class SAttribute extends AbstractShacaEntity {
attributeId: string;
private noteId: string;
type: string;
name: string;
private position: number;
value: string;
isInheritable: boolean;
constructor([attributeId, noteId, type, name, value, isInheritable, position]: SAttributeRow) {
super();
this.attributeId = attributeId;
this.noteId = noteId;
this.type = type;
this.name = name;
this.position = position;
this.value = value;
this.isInheritable = !!isInheritable;
this.shaca.attributes[this.attributeId] = this;
this.shaca.notes[this.noteId].ownedAttributes.push(this);
const targetNote = this.targetNote;
if (targetNote) {
targetNote.targetRelations.push(this);
}
if (this.type === "relation" && this.name === "imageLink") {
const linkedChildNote = this.note.getChildNotes().find((childNote) => childNote.noteId === this.value);
if (linkedChildNote) {
const branch = this.shaca.getBranchFromChildAndParent(linkedChildNote.noteId, this.noteId);
branch.isHidden = true;
}
}
if (this.type === "label" && this.name === "shareAlias" && this.value.trim()) {
this.shaca.aliasToNote[this.value.trim()] = this.note;
}
if (this.type === "label" && this.name === "shareRoot") {
this.shaca.shareRootNote = this.note;
}
if (this.type === "label" && this.name === "shareIndex") {
this.shaca.shareIndexEnabled = true;
}
}
get isAffectingSubtree() {
return this.isInheritable || (this.type === "relation" && ["template", "inherit"].includes(this.name));
}
get targetNoteId() {
// alias
return this.type === "relation" ? this.value : undefined;
}
isAutoLink() {
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
}
get note(): SNote {
return this.shaca.notes[this.noteId];
}
get targetNote(): SNote | null | undefined {
if (this.type === "relation") {
return this.shaca.notes[this.value];
}
}
getNote(): SNote | null {
return this.shaca.getNote(this.noteId);
}
getTargetNote(): SNote | null {
if (this.type !== "relation") {
throw new Error(`Attribute '${this.attributeId}' is not relation`);
}
if (!this.value) {
return null;
}
return this.shaca.getNote(this.value);
}
getPojo() {
return {
attributeId: this.attributeId,
noteId: this.noteId,
type: this.type,
name: this.name,
position: this.position,
value: this.value,
isInheritable: this.isInheritable
};
}
}
export default SAttribute;

View File

@@ -1,61 +0,0 @@
"use strict";
import AbstractShacaEntity from "./abstract_shaca_entity.js";
import type { SBranchRow } from "./rows.js";
import type SNote from "./snote.js";
class SBranch extends AbstractShacaEntity {
private branchId: string;
private noteId: string;
parentNoteId: string;
private prefix: string;
private isExpanded: boolean;
isHidden: boolean;
constructor([branchId, noteId, parentNoteId, prefix, isExpanded]: SBranchRow) {
super();
this.branchId = branchId;
this.noteId = noteId;
this.parentNoteId = parentNoteId;
this.prefix = prefix;
this.isExpanded = !!isExpanded;
this.isHidden = false;
const childNote = this.childNote;
const parentNote = this.parentNote;
if (!childNote.parents.includes(parentNote)) {
childNote.parents.push(parentNote);
}
if (!childNote.parentBranches.includes(this)) {
childNote.parentBranches.push(this);
}
if (!parentNote.children.includes(childNote)) {
parentNote.children.push(childNote);
}
this.shaca.branches[this.branchId] = this;
this.shaca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
}
get childNote(): SNote {
return this.shaca.notes[this.noteId];
}
getNote() {
return this.childNote;
}
get parentNote(): SNote {
return this.shaca.notes[this.parentNoteId];
}
getParentNote() {
return this.parentNote;
}
}
export default SBranch;

View File

@@ -1,535 +0,0 @@
"use strict";
import sql from "../../sql.js";
import utils from "../../../services/utils.js";
import AbstractShacaEntity from "./abstract_shaca_entity.js";
import escape from "escape-html";
import type { Blob } from "../../../services/blob-interface.js";
import type SAttachment from "./sattachment.js";
import type SAttribute from "./sattribute.js";
import type SBranch from "./sbranch.js";
import type { SNoteRow } from "./rows.js";
const LABEL = "label";
const RELATION = "relation";
const CREDENTIALS = "shareCredentials";
const isCredentials = (attr: SAttribute) => attr.type === "label" && attr.name === CREDENTIALS;
class SNote extends AbstractShacaEntity {
noteId: string;
title: string;
type: string;
mime: string;
private blobId: string;
utcDateModified: string;
isProtected: boolean;
parentBranches: SBranch[];
parents: SNote[];
children: SNote[];
ownedAttributes: SAttribute[];
private __attributeCache: SAttribute[] | null;
private __inheritableAttributeCache: SAttribute[] | null;
targetRelations: SAttribute[];
attachments: SAttachment[];
constructor([noteId, title, type, mime, blobId, utcDateModified, isProtected]: SNoteRow) {
super();
this.noteId = noteId;
this.title = isProtected ? "[protected]" : title;
this.type = type;
this.mime = mime;
this.blobId = blobId;
this.utcDateModified = utcDateModified; // used for caching of images
this.isProtected = isProtected;
this.parentBranches = [];
this.parents = [];
this.children = [];
this.ownedAttributes = [];
this.__attributeCache = null;
this.__inheritableAttributeCache = null;
this.targetRelations = [];
this.attachments = [];
this.shaca.notes[this.noteId] = this;
}
getParentBranches() {
return this.parentBranches;
}
getBranches() {
return this.parentBranches;
}
getChildBranches(): SBranch[] {
return this.children.map((childNote) => this.shaca.getBranchFromChildAndParent(childNote.noteId, this.noteId));
}
getVisibleChildBranches() {
return this.getChildBranches().filter((branch) => !branch.isHidden && !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
}
getParentNotes() {
return this.parents;
}
getChildNotes() {
return this.children;
}
getVisibleChildNotes() {
return this.getVisibleChildBranches().map((branch) => branch.getNote());
}
hasChildren() {
return this.children && this.children.length > 0;
}
hasVisibleChildren() {
return this.getVisibleChildNotes().length > 0;
}
getContent(silentNotFoundError = false) {
const row = sql.getRow<Pick<Blob, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
if (!row) {
if (silentNotFoundError) {
return undefined;
} else {
throw new Error(`Cannot find note content for note '${this.noteId}', blob '${this.blobId}'`);
}
}
const content = row.content;
if (this.hasStringContent()) {
return content === null ? "" : content.toString("utf-8");
} else {
return content;
}
}
/** @returns true if the note has string content (not binary) */
hasStringContent() {
return utils.isStringNote(this.type, this.mime);
}
/**
* @param type - (optional) attribute type to filter
* @param name - (optional) attribute name to filter
* @returns all note's attributes, including inherited ones
*/
getAttributes(type?: string, name?: string) {
let attributeCache = this.__attributeCache;
if (!attributeCache) {
attributeCache = this.__getAttributes([]);
}
if (type && name) {
return attributeCache.filter((attr) => attr.type === type && attr.name === name && !isCredentials(attr));
} else if (type) {
return attributeCache.filter((attr) => attr.type === type && !isCredentials(attr));
} else if (name) {
return attributeCache.filter((attr) => attr.name === name && !isCredentials(attr));
} else {
return attributeCache.filter((attr) => !isCredentials(attr));
}
}
getCredentials() {
return this.__getAttributes([]).filter(isCredentials);
}
__getAttributes(path: string[]) {
if (path.includes(this.noteId)) {
return [];
}
if (!this.__attributeCache) {
const parentAttributes = this.ownedAttributes.slice();
const newPath = [...path, this.noteId];
if (this.noteId !== "root") {
for (const parentNote of this.parents) {
parentAttributes.push(...parentNote.__getInheritableAttributes(newPath));
}
}
const templateAttributes: SAttribute[] = [];
for (const ownedAttr of parentAttributes) {
// parentAttributes so we process also inherited templates
if (ownedAttr.type === "relation" && ["template", "inherit"].includes(ownedAttr.name)) {
const templateNote = this.shaca.notes[ownedAttr.value];
if (templateNote) {
templateAttributes.push(...templateNote.__getAttributes(newPath));
}
}
}
this.__attributeCache = [];
const addedAttributeIds = new Set();
for (const attr of parentAttributes.concat(templateAttributes)) {
if (!addedAttributeIds.has(attr.attributeId)) {
addedAttributeIds.add(attr.attributeId);
this.__attributeCache.push(attr);
}
}
this.__inheritableAttributeCache = [];
for (const attr of this.__attributeCache) {
if (attr.isInheritable) {
this.__inheritableAttributeCache.push(attr);
}
}
}
return this.__attributeCache;
}
__getInheritableAttributes(path: string[]) {
if (path.includes(this.noteId)) {
return [];
}
if (!this.__inheritableAttributeCache) {
this.__getAttributes(path); // will refresh also this.__inheritableAttributeCache
}
return this.__inheritableAttributeCache || [];
}
/**
* @throws Error in case of invalid JSON
*/
getJsonContent(): unknown | null {
const content = this.getContent();
if (typeof content !== "string" || !content || !content.trim()) {
return null;
}
return JSON.parse(content);
}
/** @returns valid object or null if the content cannot be parsed as JSON */
getJsonContentSafely() {
try {
return this.getJsonContent();
} catch (e) {
return null;
}
}
hasAttribute(type: string, name: string) {
return !!this.getAttributes().find((attr) => attr.type === type && attr.name === name);
}
getRelationTarget(name: string) {
const relation = this.getAttributes().find((attr) => attr.type === "relation" && attr.name === name);
return relation ? relation.targetNote : null;
}
/**
* @param name - label name
* @returns true if label exists (including inherited)
*/
hasLabel(name: string) {
return this.hasAttribute(LABEL, name);
}
/**
* @param name - label name
* @returns true if label exists (including inherited) and does not have "false" value.
*/
isLabelTruthy(name: string) {
const label = this.getLabel(name);
if (!label) {
return false;
}
return !!label && label.value !== "false";
}
/**
* @param name - label name
* @returns true if label exists (excluding inherited)
*/
hasOwnedLabel(name: string) {
return this.hasOwnedAttribute(LABEL, name);
}
/**
* @param name - relation name
* @returns true if relation exists (including inherited)
*/
hasRelation(name: string) {
return this.hasAttribute(RELATION, name);
}
/**
* @param name - relation name
* @returns true if relation exists (excluding inherited)
*/
hasOwnedRelation(name: string) {
return this.hasOwnedAttribute(RELATION, name);
}
/**
* @param name - label name
* @returns label if it exists, null otherwise
*/
getLabel(name: string) {
return this.getAttribute(LABEL, name);
}
/**
* @param name - label name
* @returns label if it exists, null otherwise
*/
getOwnedLabel(name: string) {
return this.getOwnedAttribute(LABEL, name);
}
/**
* @param name - relation name
* @returns relation if it exists, null otherwise
*/
getRelation(name: string) {
return this.getAttribute(RELATION, name);
}
/**
* @param name - relation name
* @returns relation if it exists, null otherwise
*/
getOwnedRelation(name: string) {
return this.getOwnedAttribute(RELATION, name);
}
/**
* @param name - label name
* @returns label value if label exists, null otherwise
*/
getLabelValue(name: string) {
return this.getAttributeValue(LABEL, name);
}
/**
* @param name - label name
* @returns label value if label exists, null otherwise
*/
getOwnedLabelValue(name: string) {
return this.getOwnedAttributeValue(LABEL, name);
}
/**
* @param name - relation name
* @returns relation value if relation exists, null otherwise
*/
getRelationValue(name: string) {
return this.getAttributeValue(RELATION, name);
}
/**
* @param name - relation name
* @returns relation value if relation exists, null otherwise
*/
getOwnedRelationValue(name: string) {
return this.getOwnedAttributeValue(RELATION, name);
}
/**
* @param type - attribute type (label, relation, etc.)
* @param name - attribute name
* @returns true if note has an attribute with given type and name (excluding inherited)
*/
hasOwnedAttribute(type: string, name: string) {
return !!this.getOwnedAttribute(type, name);
}
/**
* @param type - attribute type (label, relation, etc.)
* @param name - attribute name
* @returns attribute of the given type and name. If there are more such attributes, first is returned.
* Returns null if there's no such attribute belonging to this note.
*/
getAttribute(type: string, name: string) {
const attributes = this.getAttributes();
return attributes.find((attr) => attr.type === type && attr.name === name);
}
/**
* @param type - attribute type (label, relation, etc.)
* @param name - attribute name
* @returns attribute value of the given type and name or null if no such attribute exists.
*/
getAttributeValue(type: string, name: string) {
const attr = this.getAttribute(type, name);
return attr ? attr.value : null;
}
/**
* @param type - attribute type (label, relation, etc.)
* @param name - attribute name
* @returns attribute value of the given type and name or null if no such attribute exists.
*/
getOwnedAttributeValue(type: string, name: string) {
const attr = this.getOwnedAttribute(type, name);
return attr ? (attr.value as string) : null; // FIXME
}
/**
* @param name - label name to filter
* @returns all note's labels (attributes with type label), including inherited ones
*/
getLabels(name: string) {
return this.getAttributes(LABEL, name);
}
/**
* @param name - label name to filter
* @returns all note's label values, including inherited ones
*/
getLabelValues(name: string) {
return this.getLabels(name).map((l) => l.value) as string[]; // FIXME
}
/**
* @param name - label name to filter
* @returns all note's labels (attributes with type label), excluding inherited ones
*/
getOwnedLabels(name: string) {
return this.getOwnedAttributes(LABEL, name);
}
/**
* @param name - label name to filter
* @returns all note's label values, excluding inherited ones
*/
getOwnedLabelValues(name: string) {
return this.getOwnedAttributes(LABEL, name).map((l) => l.value);
}
/**
* @param name - relation name to filter
* @returns all note's relations (attributes with type relation), including inherited ones
*/
getRelations(name: string) {
return this.getAttributes(RELATION, name);
}
/**
* @param name - relation name to filter
* @returns all note's relations (attributes with type relation), excluding inherited ones
*/
getOwnedRelations(name: string) {
return this.getOwnedAttributes(RELATION, name);
}
/**
* @param type - (optional) attribute type to filter
* @param name - (optional) attribute name to filter
* @returns note's "owned" attributes - excluding inherited ones
*/
getOwnedAttributes(type: string, name: string) {
// it's a common mistake to include # or ~ into attribute name
if (name && ["#", "~"].includes(name[0])) {
name = name.substr(1);
}
if (type && name) {
return this.ownedAttributes.filter((attr) => attr.type === type && attr.name === name);
} else if (type) {
return this.ownedAttributes.filter((attr) => attr.type === type);
} else if (name) {
return this.ownedAttributes.filter((attr) => attr.name === name);
} else {
return this.ownedAttributes.slice();
}
}
/**
* @returns attribute belonging to this specific note (excludes inherited attributes)
*
* This method can be significantly faster than the getAttribute()
*/
getOwnedAttribute(type: string, name: string) {
const attrs = this.getOwnedAttributes(type, name);
return attrs.length > 0 ? attrs[0] : null;
}
get isArchived() {
return this.hasAttribute("label", "archived");
}
isInherited() {
return !!this.targetRelations.find((rel) => rel.name === "template" || rel.name === "inherit");
}
getTargetRelations() {
return this.targetRelations;
}
getAttachments() {
return this.attachments;
}
getAttachmentByTitle(title: string) {
return this.attachments.find((attachment) => attachment.title === title);
}
get shareId() {
if (this.hasOwnedLabel("shareRoot")) {
return "";
}
const sharedAlias = this.getOwnedLabelValue("shareAlias");
return sharedAlias || this.noteId;
}
get escapedTitle() {
return escape(this.title);
}
get encodedTitle() {
return encodeURIComponent(this.title);
}
getPojo() {
return {
noteId: this.noteId,
title: this.title,
type: this.type,
mime: this.mime,
utcDateModified: this.utcDateModified,
attributes: this.getAttributes()
// relations could link across shared subtrees which might leak them
// individual relations might be whitelisted based on needs #3434
.filter((attr) => attr.type === "label")
.map((attr) => attr.getPojo()),
attachments: this.getAttachments().map((attachment) => attachment.getPojo()),
parentNoteIds: this.parents.map((parentNote) => parentNote.noteId),
childNoteIds: this.children.map((child) => child.noteId)
};
}
}
export default SNote;

View File

@@ -1,90 +0,0 @@
import type SAttachment from "./entities/sattachment.js";
import type SAttribute from "./entities/sattribute.js";
import type SBranch from "./entities/sbranch.js";
import type SNote from "./entities/snote.js";
export default class Shaca {
notes!: Record<string, SNote>;
branches!: Record<string, SBranch>;
childParentToBranch!: Record<string, SBranch>;
attributes!: Record<string, SAttribute>;
attachments!: Record<string, SAttachment>;
aliasToNote!: Record<string, SNote>;
shareRootNote!: SNote | null;
/** true if the index of all shared subtrees is enabled */
shareIndexEnabled!: boolean;
loaded!: boolean;
constructor() {
this.reset();
}
reset() {
this.notes = {};
this.branches = {};
this.childParentToBranch = {};
this.attributes = {};
this.attachments = {};
this.aliasToNote = {};
this.shareRootNote = null;
this.shareIndexEnabled = false;
this.loaded = false;
}
getNote(noteId: string) {
return this.notes[noteId];
}
hasNote(noteId: string) {
return noteId in this.notes;
}
getNotes(noteIds: string[], ignoreMissing = false) {
const filteredNotes = [];
for (const noteId of noteIds) {
const note = this.notes[noteId];
if (!note) {
if (ignoreMissing) {
continue;
}
throw new Error(`Note '${noteId}' was not found in shaca.`);
}
filteredNotes.push(note);
}
return filteredNotes;
}
getBranch(branchId: string) {
return this.branches[branchId];
}
getBranchFromChildAndParent(childNoteId: string, parentNoteId: string) {
return this.childParentToBranch[`${childNoteId}-${parentNoteId}`];
}
getAttribute(attributeId: string) {
return this.attributes[attributeId];
}
getAttachment(attachmentId: string) {
return this.attachments[attachmentId];
}
getEntity(entityName: string, entityId: string) {
if (!entityName || !entityId) {
return null;
}
const camelCaseEntityName = entityName.toLowerCase().replace(/(_[a-z])/g, (group) => group.toUpperCase().replace("_", ""));
return (this as any)[camelCaseEntityName][entityId];
}
}

View File

@@ -1,7 +0,0 @@
"use strict";
import Shaca from "./shaca-interface.js";
const shaca = new Shaca();
export default shaca;

View File

@@ -1,104 +0,0 @@
"use strict";
import sql from "../sql.js";
import shaca from "./shaca.js";
import log from "../../services/log.js";
import SNote from "./entities/snote.js";
import SBranch from "./entities/sbranch.js";
import SAttribute from "./entities/sattribute.js";
import SAttachment from "./entities/sattachment.js";
import shareRoot from "../share_root.js";
import eventService from "../../services/events.js";
import type { SAttachmentRow, SAttributeRow, SBranchRow, SNoteRow } from "./entities/rows.js";
function load() {
const start = Date.now();
shaca.reset();
// using a raw query and passing arrays to avoid allocating new objects
const noteIds = sql.getColumn(
`
WITH RECURSIVE
tree(noteId) AS (
SELECT ?
UNION
SELECT branches.noteId FROM branches
JOIN tree ON branches.parentNoteId = tree.noteId
WHERE branches.isDeleted = 0
)
SELECT noteId FROM tree`,
[shareRoot.SHARE_ROOT_NOTE_ID]
);
if (noteIds.length === 0) {
shaca.loaded = true;
return;
}
const noteIdStr = noteIds.map((noteId) => `'${noteId}'`).join(",");
const rawNoteRows = sql.getRawRows<SNoteRow>(`
SELECT noteId, title, type, mime, blobId, utcDateModified, isProtected
FROM notes
WHERE isDeleted = 0
AND noteId IN (${noteIdStr})`);
for (const row of rawNoteRows) {
new SNote(row);
}
const rawBranchRows = sql.getRawRows<SBranchRow>(`
SELECT branchId, noteId, parentNoteId, prefix, isExpanded, utcDateModified
FROM branches
WHERE isDeleted = 0
AND parentNoteId IN (${noteIdStr})
ORDER BY notePosition`);
for (const row of rawBranchRows) {
new SBranch(row);
}
const rawAttributeRows = sql.getRawRows<SAttributeRow>(`
SELECT attributeId, noteId, type, name, value, isInheritable, position, utcDateModified
FROM attributes
WHERE isDeleted = 0
AND noteId IN (${noteIdStr})`);
for (const row of rawAttributeRows) {
new SAttribute(row);
}
const rawAttachmentRows = sql.getRawRows<SAttachmentRow>(`
SELECT attachmentId, ownerId, role, mime, title, blobId, utcDateModified
FROM attachments
WHERE isDeleted = 0
AND ownerId IN (${noteIdStr})`);
for (const row of rawAttachmentRows) {
new SAttachment(row);
}
shaca.loaded = true;
log.info(`Shaca loaded ${rawNoteRows.length} notes, ${rawBranchRows.length} branches, ${rawAttachmentRows.length} attributes took ${Date.now() - start}ms`);
}
function ensureLoad() {
if (!shaca.loaded) {
load();
}
}
eventService.subscribe(
[eventService.ENTITY_CREATED, eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED, eventService.ENTITY_CHANGE_SYNCED, eventService.ENTITY_DELETE_SYNCED],
() => {
shaca.reset();
}
);
export default {
load,
ensureLoad
};

View File

@@ -1,3 +0,0 @@
export default {
SHARE_ROOT_NOTE_ID: "_share"
};

View File

@@ -1,39 +0,0 @@
"use strict";
import Database from "better-sqlite3";
import dataDir from "../services/data_dir.js";
import sql_init from "../services/sql_init.js";
let dbConnection!: Database.Database;
sql_init.dbReady.then(() => {
dbConnection = new Database(dataDir.DOCUMENT_PATH, { readonly: true });
[`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`].forEach((eventType) => {
process.on(eventType, () => {
if (dbConnection) {
// closing connection is especially important to fold -wal file into the main DB file
// (see https://sqlite.org/tempfiles.html for details)
dbConnection.close();
}
});
});
});
function getRawRows<T>(query: string, params = []): T[] {
return dbConnection.prepare(query).raw().all(params) as T[];
}
function getRow<T>(query: string, params: string[] = []): T {
return dbConnection.prepare(query).get(params) as T;
}
function getColumn<T>(query: string, params: string[] = []): T[] {
return dbConnection.prepare(query).pluck().all(params) as T[];
}
export default {
getRawRows,
getRow,
getColumn
};