diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 1c1389810a6..d2c5ae290e3 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -20,6 +20,7 @@ import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx"; import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx"; +import { LinkEmbedOpts } from "../widgets/dialogs/link_embed.jsx"; import type { InfoProps } from "../widgets/dialogs/info.jsx"; import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; @@ -224,6 +225,7 @@ export type CommandMappings = { showProtectedSessionPasswordDialog: CommandData; showUploadAttachmentsDialog: CommandData & { noteId: string }; showIncludeNoteDialog: CommandData & IncludeNoteOpts; + showLinkEmbedDialog: CommandData & LinkEmbedOpts; showAddLinkDialog: CommandData & AddLinkOpts; showPasteMarkdownDialog: CommandData & MarkdownImportOpts; closeProtectedSessionPasswordDialog: CommandData; diff --git a/apps/client/src/layouts/layout_commons.tsx b/apps/client/src/layouts/layout_commons.tsx index 50550ea4b54..6055be70c31 100644 --- a/apps/client/src/layouts/layout_commons.tsx +++ b/apps/client/src/layouts/layout_commons.tsx @@ -7,6 +7,7 @@ import RecentChangesDialog from "../widgets/dialogs/recent_changes.js"; import PromptDialog from "../widgets/dialogs/prompt.js"; import AddLinkDialog from "../widgets/dialogs/add_link.js"; import IncludeNoteDialog from "../widgets/dialogs/include_note.js"; +import LinkEmbedDialog from "../widgets/dialogs/link_embed.js"; import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js"; import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js"; import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js"; @@ -35,6 +36,7 @@ export function applyModals(rootContainer: RootContainer) { .child() .child() .child() + .child() .child() .child() .child() diff --git a/apps/client/src/services/link_embed.spec.ts b/apps/client/src/services/link_embed.spec.ts new file mode 100644 index 00000000000..a03273f77b0 --- /dev/null +++ b/apps/client/src/services/link_embed.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { extractYouTubeVideoId, detectEmbedType, safeHostname } from "./link_embed.js"; + +describe("extractYouTubeVideoId", () => { + it("extracts from standard watch URL", () => { + expect(extractYouTubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ"); + }); + + it("extracts from youtu.be short URL", () => { + expect(extractYouTubeVideoId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ"); + }); + + it("extracts from youtu.be with query params", () => { + expect(extractYouTubeVideoId("https://youtu.be/fV16ck4Bgc0?si=bxl0pGUK2VIfUHkw")).toBe("fV16ck4Bgc0"); + }); + + it("extracts from embed URL", () => { + expect(extractYouTubeVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ"); + }); + + it("extracts from shorts URL", () => { + expect(extractYouTubeVideoId("https://www.youtube.com/shorts/abcde_-FGHI")).toBe("abcde_-FGHI"); + }); + + it("extracts from watch URL with extra params", () => { + expect(extractYouTubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf")).toBe("dQw4w9WgXcQ"); + }); + + it("returns null for non-YouTube URLs", () => { + expect(extractYouTubeVideoId("https://example.com")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(extractYouTubeVideoId("")).toBeNull(); + }); + + it("returns null for YouTube URL without video ID", () => { + expect(extractYouTubeVideoId("https://www.youtube.com/")).toBeNull(); + }); +}); + +describe("detectEmbedType", () => { + it("detects YouTube URLs", () => { + expect(detectEmbedType("https://youtu.be/fV16ck4Bgc0")).toBe("youtube"); + expect(detectEmbedType("https://www.youtube.com/watch?v=abc12345678")).toBe("youtube"); + }); + + it("returns opengraph for non-YouTube URLs", () => { + expect(detectEmbedType("https://example.com")).toBe("opengraph"); + expect(detectEmbedType("https://github.com/TriliumNext/Notes")).toBe("opengraph"); + }); +}); + +describe("safeHostname", () => { + it("extracts hostname from valid URL", () => { + expect(safeHostname("https://www.example.com/page")).toBe("www.example.com"); + }); + + it("returns raw string for invalid URL", () => { + expect(safeHostname("not-a-url")).toBe("not-a-url"); + }); + + it("handles URLs with ports", () => { + expect(safeHostname("http://localhost:8080/api")).toBe("localhost"); + }); +}); diff --git a/apps/client/src/services/link_embed.ts b/apps/client/src/services/link_embed.ts new file mode 100644 index 00000000000..e58f2910237 --- /dev/null +++ b/apps/client/src/services/link_embed.ts @@ -0,0 +1,193 @@ +import "../widgets/type_widgets/text/LinkEmbed.css"; + +import type { LinkEmbedMetadata } from "@triliumnext/commons"; +import server from "./server.js"; + +/** Paste mode chosen by user from the floating popup. */ +export type LinkPasteMode = "mention" | "url" | "embed"; + +const metadataCache = new Map(); +const pendingRequests = new Map>(); + +const YOUTUBE_REGEX = /(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/; + +export function extractYouTubeVideoId(url: string): string | null { + const match = url.match(YOUTUBE_REGEX); + return match ? match[1] : null; +} + +export function detectEmbedType(url: string): "youtube" | "opengraph" { + return extractYouTubeVideoId(url) ? "youtube" : "opengraph"; +} + +export function safeHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export async function fetchMetadata(url: string): Promise { + const cached = metadataCache.get(url); + if (cached) return cached; + + const pending = pendingRequests.get(url); + if (pending) return pending; + + const request = server.get(`link-embed/metadata?url=${encodeURIComponent(url)}`) + .then((metadata) => { + metadataCache.set(url, metadata); + pendingRequests.delete(url); + return metadata; + }) + .catch(() => { + pendingRequests.delete(url); + const fallback: LinkEmbedMetadata = { + url, + title: safeHostname(url), + embedType: detectEmbedType(url) + }; + metadataCache.set(url, fallback); + return fallback; + }); + + pendingRequests.set(url, request); + return request; +} + +// --------------------------------------------------------------------------- +// Loading spinner (shared by all renderers) +// --------------------------------------------------------------------------- + +function createLoadingEl(): JQuery { + return $('