core: add support for processing internal links in note content

This commit is contained in:
Abdullah Atta
2024-01-22 17:26:40 +05:00
parent cfa98578ea
commit 2a547c879b
5 changed files with 137 additions and 18 deletions

View File

@@ -306,7 +306,7 @@ export default class Vault {
data = rawContent.data; data = rawContent.data;
type = rawContent.type; type = rawContent.type;
} else if (data && type) { } else if (data && type) {
data = await this.db.content.extractAttachments({ data = await this.db.content.postProcess({
data, data,
type, type,
noteId: id noteId: id

View File

@@ -33,6 +33,7 @@ import Database from "../api";
import { getOutputType } from "./attachments"; import { getOutputType } from "./attachments";
import { SQLCollection } from "../database/sql-collection"; import { SQLCollection } from "../database/sql-collection";
import { NoteContent } from "./session-content"; import { NoteContent } from "./session-content";
import { InternalLink } from "../utils/internal-link";
export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({ export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({
noteId, noteId,
@@ -96,7 +97,7 @@ export class Content implements ICollection {
typeof content.data === "string" ? content.data : undefined; typeof content.data === "string" ? content.data : undefined;
if (unencryptedData && content.type && content.noteId) if (unencryptedData && content.type && content.noteId)
unencryptedData = await this.extractAttachments({ unencryptedData = await this.postProcess({
type: content.type, type: content.type,
data: unencryptedData, data: unencryptedData,
noteId: content.noteId noteId: content.noteId
@@ -299,19 +300,22 @@ export class Content implements ICollection {
await this.add(contentItem); await this.add(contentItem);
} }
async extractAttachments( async postProcess(contentItem: NoteContent<false> & { noteId: string }) {
contentItem: NoteContent<false> & { noteId: string }
) {
// if (contentItem.localOnly) return contentItem;
const content = getContentFromData(contentItem.type, contentItem.data); const content = getContentFromData(contentItem.type, contentItem.data);
if (!content) return contentItem.data; if (!content) return contentItem.data;
const { data, hashes } = await content.extractAttachments( const { data, hashes, internalLinks } = await content.postProcess(
this.db.attachments.save this.db.attachments.save
); );
await this.processInternalLinks(contentItem.noteId, internalLinks);
await this.processLinkedAttachments(contentItem.noteId, hashes);
return data;
}
private async processLinkedAttachments(noteId: string, hashes: string[]) {
const noteAttachments = await this.db.relations const noteAttachments = await this.db.relations
.from({ type: "note", id: contentItem.noteId }, "attachment") .from({ type: "note", id: noteId }, "attachment")
.selector.filter.select(["id", "hash"]) .selector.filter.select(["id", "hash"])
.execute(); .execute();
@@ -322,7 +326,7 @@ export class Content implements ICollection {
for (const attachment of toDelete) { for (const attachment of toDelete) {
await this.db.relations.unlink( await this.db.relations.unlink(
{ {
id: contentItem.noteId, id: noteId,
type: "note" type: "note"
}, },
{ id: attachment.id, type: "attachment" } { id: attachment.id, type: "attachment" }
@@ -340,14 +344,51 @@ export class Content implements ICollection {
for (const attachment of attachments) { for (const attachment of attachments) {
await this.db.relations.add( await this.db.relations.add(
{ {
id: contentItem.noteId, id: noteId,
type: "note" type: "note"
}, },
{ id: attachment.id, type: "attachment" } { id: attachment.id, type: "attachment" }
); );
} }
}
return data; private async processInternalLinks(
noteId: string,
internalLinks: InternalLink[]
) {
const links = await this.db.relations
.from({ type: "note", id: noteId }, "note")
.get();
const toDelete = links.filter((link) => {
return internalLinks.every((l) => l.id !== link.toId);
});
const toAdd = internalLinks.filter((link) => {
return links.every((l) => link.id !== l.toId);
});
for (const link of toDelete) {
await this.db.relations.unlink(
{
id: noteId,
type: "note"
},
{ id: link.toId, type: link.toType }
);
}
for (const link of toAdd) {
const note = await this.db.notes.exists(link.id);
if (!note) continue;
await this.db.relations.add(
{
id: noteId,
type: "note"
},
link
);
}
} }
} }

View File

@@ -36,6 +36,7 @@ import {
} from "../utils/html-parser"; } from "../utils/html-parser";
import { HTMLRewriter } from "../utils/html-rewriter"; import { HTMLRewriter } from "../utils/html-rewriter";
import { ContentBlock } from "../types"; import { ContentBlock } from "../types";
import { InternalLink, parseInternalLink } from "../utils/internal-link";
export type ResolveHashes = ( export type ResolveHashes = (
hashes: string[] hashes: string[]
@@ -46,6 +47,7 @@ const ATTRIBUTES = {
mime: "data-mime", mime: "data-mime",
filename: "data-filename", filename: "data-filename",
src: "src", src: "src",
href: "href",
blockId: "data-block-id" blockId: "data-block-id"
}; };
@@ -142,8 +144,8 @@ export class Tiptap {
}).transform(this.data); }).transform(this.data);
} }
async extractAttachments( async postProcess(
store: ( saveAttachment: (
data: string, data: string,
mime: string, mime: string,
filename?: string filename?: string
@@ -151,13 +153,17 @@ export class Tiptap {
) { ) {
if ( if (
!this.data.includes(ATTRIBUTES.src) && !this.data.includes(ATTRIBUTES.src) &&
!this.data.includes(ATTRIBUTES.hash) !this.data.includes(ATTRIBUTES.hash) &&
// check for internal links
!this.data.includes("nn://")
) )
return { return {
data: this.data, data: this.data,
hashes: [] hashes: [],
internalLinks: []
}; };
const internalLinks: InternalLink[] = [];
const sources: { const sources: {
src: string; src: string;
filename?: string; filename?: string;
@@ -168,6 +174,7 @@ export class Tiptap {
ontag: (name, attr, pos) => { ontag: (name, attr, pos) => {
const hash = attr[ATTRIBUTES.hash]; const hash = attr[ATTRIBUTES.hash];
const src = attr[ATTRIBUTES.src]; const src = attr[ATTRIBUTES.src];
const href = attr[ATTRIBUTES.href];
if (name === "img" && !hash && src) { if (name === "img" && !hash && src) {
sources.push({ sources.push({
src, src,
@@ -175,6 +182,10 @@ export class Tiptap {
mime: attr[ATTRIBUTES.mime], mime: attr[ATTRIBUTES.mime],
id: `${pos.start}${pos.end}` id: `${pos.start}${pos.end}`
}); });
} else if (name === "a" && href && href.startsWith("nn://")) {
const internalLink = parseInternalLink(href);
if (!internalLink) return;
internalLinks.push(internalLink);
} }
} }
}).parse(this.data); }).parse(this.data);
@@ -184,7 +195,7 @@ export class Tiptap {
try { try {
const { data, mimeType } = dataurl.toObject(image.src); const { data, mimeType } = dataurl.toObject(image.src);
if (!data || !mimeType) continue; if (!data || !mimeType) continue;
const hash = await store(data, mimeType, image.filename); const hash = await saveAttachment(data, mimeType, image.filename);
if (!hash) continue; if (!hash) continue;
images[image.id] = hash; images[image.id] = hash;
@@ -228,7 +239,8 @@ export class Tiptap {
return { return {
data: html, data: html,
hashes hashes,
internalLinks
}; };
} }
} }

View File

@@ -21,3 +21,8 @@ export * from "./types";
export { VirtualizedGrouping } from "./utils/virtualized-grouping"; export { VirtualizedGrouping } from "./utils/virtualized-grouping";
export { DefaultColors } from "./collections/colors"; export { DefaultColors } from "./collections/colors";
export { type BackupFile, type LegacyBackupFile } from "./database/backup"; export { type BackupFile, type LegacyBackupFile } from "./database/backup";
export {
createInternalLink,
parseInternalLink,
type InternalLink
} from "./utils/internal-link";

View File

@@ -0,0 +1,61 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const InternalLinkTypes = ["note"] as const;
type InternalLinkType = (typeof InternalLinkTypes)[number];
export type InternalLink<T extends InternalLinkType = InternalLinkType> = {
type: T;
id: string;
params?: Partial<InternalLinkParams[T]>;
};
type InternalLinkParams = {
note: { blockId: string };
};
export function createInternalLink<T extends InternalLinkType>(
type: T,
id: string,
params?: InternalLinkParams[T]
) {
let link = `nn://${type}/${id}`;
if (params) {
link +=
"?" +
Object.entries(params)
.map(([key, value]) => `${key}=${value}`)
.join("&");
}
return link;
}
export function parseInternalLink(link: string): InternalLink | undefined {
const url = new URL(link);
if (url.protocol !== "nn:") return;
const [type, id] = url.pathname.split("/").slice(2);
if (!type || !id || !isValidInternalType(type)) return;
return {
type,
id,
params: Object.fromEntries(url.searchParams.entries())
};
}
function isValidInternalType(type: string): type is InternalLinkType {
return InternalLinkTypes.includes(type as any);
}