logancyang_obsidian-copilot/src/utils.ts
Logan Yang 60dc554c1f
feat(model-management): add DeepInfra Copilot Plus models, default off (#2645)
* feat(model-management): add DeepInfra Copilot Plus models, default off

Expand the curated Copilot Plus lineup (`COPILOT_PLUS_MODELS`) from just
`copilot-plus-flash` to the full public set served by
models.brevilabs.com/v1/models: kimi-k2.6, glm-5.2, kimi-k2.7-code,
deepseek-v4-pro, mimo-v2.5, minimax-m2.7. These surface in the chat +
opencode pickers (the "Copilot Plus" section under OpenCode) for the user
to toggle on.

Only copilot-plus-flash is enabled by default. Adds an optional
`autoEnrollModelIds` to `registerPlusProvider`/`#reconcileModels` so Plus
can curate a default-on subset: newly-added models outside the set are
created (and shown in the pickers) but left unenrolled. `undefined`
preserves the prior enroll-everything behavior, so BYOK is unaffected.
This also keeps existing users' curation intact - the 6 new models arrive
off on the next sign-in sync rather than auto-enabling. Adds the new wire
ids to the `ChatModels` enum and threads canonical one-line descriptions
through from the models service.

Also fixes capability resolution for bridged Plus models so image input
routes correctly: CopilotPlusChainRunner.isMultimodalModel() ->
hasCapability() -> ChatModelManager.findModelByName() only searched legacy
`settings.activeModels`, so bridged ConfiguredModel-only models (e.g.
kimi-k2.7-code) were treated as non-multimodal and silently dropped
attached images. findModelByName now falls back to the active bridged
model when its name matches; that CustomModel carries capabilities derived
from its modalities (configuredModelToCustomModel maps image input ->
VISION). Legacy lookups are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* feat(copilot-plus): reasoning-effort picker for Plus models + usage-cap credits prompt

Builds on the Plus model lineup to surface reasoning effort and handle cap hits.

- Mark the reasoning-capable Plus models (all but Azure kimi-k2.6) with
  reasoning: true so the chat + agent pickers offer an effort selector instead
  of "na". Matches the models service's supports_reasoning. These models don't
  reason unless an effort is picked, so flash stays fast by default.
- Forward the picked effort in the Simple Chat COPILOT_PLUS provider branch
  (the relay reads reasoning_effort), gated on the REASONING capability.
- Inject reasoning: true into the OpenCode provider model config so opencode
  surfaces a thought-level option for these models (it has no catalog entry for
  Copilot Plus / self-hosted OpenAI-compatible providers otherwise).
- Usage-cap (plan limit) errors now render a friendly, actionable message with
  a link to the website usage dashboard to purchase credits, instead of the raw
  relay error. New formatUsageCapError util (deep-searches the thrown error for
  the cap signal, cycle-safe) wired into the central getApiErrorMessage, so both
  Simple Chat and Agent Mode show it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): re-sync capability fields so reasoning effort surfaces

registerPlusProvider's reconcile only refreshed displayName/description on
existing ConfiguredModels, never the capability fields. So an existing Plus
user kept a stale snapshot with reasoning unset — the chat bridge derived no
REASONING capability and the OpenCode config injection (gated on info.reasoning)
never fired, so the effort picker stayed empty ("na") even after the models were
flagged reasoning: true.

Reconcile now also re-syncs modalities, reasoning, and toolCall in place when
they drift (Plus models are server-curated, so there are no user overrides to
clobber). Verified end to end: persisted info.reasoning flips to true on
re-sign-in, so the reasoning models advertise effort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): keep flash fast by default; prefer bridged model capabilities

Addresses two Codex review comments on the reasoning-effort wiring:

- Gate enableReasoning on an explicit effort for the Copilot Plus provider.
  ChatOpenRouter.invocationParams falls back to `reasoning: { max_tokens: 1024 }`
  whenever reasoning is enabled without an effort, so flagging copilot-plus-flash
  REASONING-capable made the default-on model start spending reasoning budget.
  Now enableReasoning requires a user-picked effort, so flash stays fast until
  the user chooses one.

- findModelByName prefers the active bridged model on an exact name match before
  the legacy activeModels lookup. copilot-plus-flash exists in both (the built-in
  legacy entry advertises only VISION); the early legacy return masked the bridged
  REASONING/VISION capabilities, so CopilotPlusChainRunner.hasCapability treated
  flash as non-reasoning and hid reasoning content. The bridged model is the one
  actually running, so it wins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): show usage-cap credits message on the streaming error path

getApiErrorMessage (with formatUsageCapError) was only reached from the planning
catch. The common cap path — the relay returns token_limit_error mid-invocation
after planning succeeds — goes through BaseChainRunner.handleError, which rendered
the raw 429 payload. Route usage-cap errors through formatUsageCapError there too,
so the purchase-credits message + dashboard link shows on the main invocation path
across all chain runners.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): render usage-cap link as plain text for ErrorBlock

The streaming error path renders the message via ErrorBlock as plain text
(whitespace-pre-wrap), so the Markdown link syntax showed literally as
`[purchase credits ...](url)`. Switch formatUsageCapError to a plain-text
message with a bare URL, which is readable on the streaming path and still
auto-links in any Markdown-rendered context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* Refine GLM-5.2 description in Copilot Plus model list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* Badge Copilot Plus group with privacy + license hint, taller model list

Add a "privacy" badge and a "Copilot license required" hover hint (key
icon) to the Copilot Plus group header in the model enable lists, and
raise the list's max height so more models are visible without scrolling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:52:44 -07:00

1468 lines
48 KiB
TypeScript

// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by esbuild
// so the same Buffer code path works on desktop (Electron) and mobile (WebView).
// eslint-disable-next-line import/no-nodejs-modules
import { Buffer } from "buffer";
import { ChainType } from "@/chainType";
import {
ALLOWED_NOTE_CONTEXT_EXTENSIONS,
ChatModelProviders,
EmbeddingModelProviders,
ModelCapability,
NOMIC_EMBED_TEXT,
Provider,
ProviderInfo,
ProviderMetadata,
SettingKeyProviders,
TEXT_READABLE_EXTENSIONS,
} from "@/constants";
import { logInfo, logWarn } from "@/logger";
import { CopilotSettings } from "@/settings/model";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { Document } from "@langchain/core/documents";
import { MemoryVariables } from "@langchain/core/memory";
import { DateTime } from "luxon";
import { App, MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
import { getApiKeyForProvider } from "@/utils/modelUtils";
import { formatUsageCapError } from "@/utils/usageCapError";
export { err2String } from "@/errorFormat";
/**
* Unified type for fetch implementation.
* Used for dependency injection of fetch (e.g., safeFetch for CORS bypass).
*/
export type FetchImplementation = (url: string, init?: RequestInit) => Promise<Response>;
/**
* Extract domain from URL, removing 'www.' prefix.
* Returns the original URL string if parsing fails.
* @param url - The URL to extract domain from
* @returns Domain without 'www.' prefix, or original string on parse failure
*/
export function getDomainFromUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// Add custom error type at the top of the file
interface APIError extends Error {
json?: unknown;
}
// Error message constants
const ERROR_MESSAGES = {
INVALID_LICENSE_KEY_USER:
"Invalid Copilot Plus license key. Please check your license key in settings.",
UNKNOWN_ERROR: "An unknown error occurred",
REQUEST_FAILED: (status: number) => `Request failed, status ${status}`,
} as const;
// Error handling utilities
interface ErrorDetail {
status?: number;
message?: string;
reason?: string;
}
function extractErrorDetail(error: unknown): ErrorDetail {
const err = error as Record<string, unknown> | null | undefined;
const errorDetail: ErrorDetail = (err?.detail as ErrorDetail) || {};
return {
status: errorDetail.status,
message: errorDetail.message || (err?.message as string | undefined),
reason: errorDetail.reason,
};
}
function isLicenseKeyError(error: unknown): boolean {
const errorDetail = extractErrorDetail(error);
const err = error as Record<string, unknown> | null | undefined;
const message = err?.message as string | undefined;
return Boolean(
errorDetail.reason === "Invalid license key" ||
message === "Invalid license key" ||
message?.includes("status 403") ||
errorDetail.status === 403
);
}
export function getApiErrorMessage(error: unknown): string {
if (isLicenseKeyError(error)) {
return ERROR_MESSAGES.INVALID_LICENSE_KEY_USER;
}
// Usage-cap (plan limit) errors get a friendly, actionable message with a link to
// the usage dashboard to purchase credits, instead of the raw relay error text.
const capMessage = formatUsageCapError(error);
if (capMessage) {
return capMessage;
}
const errorDetail = extractErrorDetail(error);
return (
errorDetail.message ||
(errorDetail.reason ? `Error: ${errorDetail.reason}` : ERROR_MESSAGES.UNKNOWN_ERROR)
);
}
export const isFolderMatch = (fileFullpath: string, inputPath: string): boolean => {
const fileSegments = fileFullpath.split("/").map((segment) => segment.toLowerCase());
return fileSegments.includes(inputPath.toLowerCase());
};
/** TODO: Rewrite with app.vault.getAbstractFileByPath() */
export const getNotesFromPath = (vault: Vault, path: string): TFile[] => {
const files = vault.getMarkdownFiles();
// Special handling for the root path '/'
if (path === "/") {
return files;
}
// Normalize the input path
const normalizedPath = path.toLowerCase().replace(/^\/|\/$/g, "");
return files.filter((file) => {
// Normalize the file path
const normalizedFilePath = file.path.toLowerCase();
const filePathParts = normalizedFilePath.split("/");
const pathParts = normalizedPath.split("/");
// Check if the file path contains all parts of the input path in order
let filePathIndex = 0;
for (const pathPart of pathParts) {
while (filePathIndex < filePathParts.length) {
if (filePathParts[filePathIndex] === pathPart) {
break;
}
filePathIndex++;
}
if (filePathIndex >= filePathParts.length) {
return false;
}
}
return true;
});
};
/**
* @param tag - The tag to strip the hash symbol from.
* @returns The tag without the hash symbol.
*/
export function stripHash(tag: string): string {
return tag.replace(/^#/, "").trim();
}
/**
* Options for {@link stripFrontmatter}.
*/
interface StripFrontmatterOptions {
/**
* When true (default), trims leading whitespace after the frontmatter block.
* When false, preserves leading whitespace from the body, but still removes
* the single newline that immediately follows the closing frontmatter marker.
*/
trimStart?: boolean;
}
/**
* Strip YAML frontmatter from markdown content.
* @param content - The markdown content to strip frontmatter from.
* @param options - Options controlling how leading whitespace is handled.
* @returns The content without the frontmatter block.
*/
export function stripFrontmatter(content: string, options: StripFrontmatterOptions = {}): string {
const { trimStart = true } = options;
if (content.startsWith("---")) {
const end = content.indexOf("---", 3);
if (end !== -1) {
const body = content.slice(end + 3);
if (trimStart) {
return body.trimStart();
}
// Preserve body whitespace, but remove the frontmatter/body separator newline.
if (body.startsWith("\r\n")) {
return body.slice(2);
}
if (body.startsWith("\n") || body.startsWith("\r")) {
return body.slice(1);
}
return body;
}
}
return content;
}
/**
* @param app - The Obsidian app instance.
* @param file - The note file to get tags from.
* @param frontmatterOnly - Whether to only get tags from frontmatter.
* @returns An array of lowercase tags without the hash symbol.
*/
export function getTagsFromNote(app: App, file: TFile, frontmatterOnly = true): string[] {
const metadata = app.metadataCache.getFileCache(file);
const frontmatterTags = metadata?.frontmatter?.tags;
const allTags = new Set<string>();
if (!frontmatterOnly) {
const inlineTags = metadata?.tags?.map((tag) => tag.tag);
if (inlineTags) {
inlineTags.forEach((tag) => allTags.add(stripHash(tag)));
}
}
// Add frontmatter tags
if (frontmatterTags) {
if (Array.isArray(frontmatterTags)) {
frontmatterTags.forEach((tag) => {
if (typeof tag === "string") {
allTags.add(stripHash(tag));
}
});
} else if (typeof frontmatterTags === "string") {
allTags.add(stripHash(frontmatterTags));
}
}
return Array.from(allTags);
}
/**
* Get notes from tags.
* @param app - The Obsidian app instance.
* @param tags - The tags to get notes from. Tags should be with the hash symbol.
* @param noteFiles - The notes to get notes from.
* @returns An array of note files.
*/
export function getNotesFromTags(app: App, tags: string[], noteFiles?: TFile[]): TFile[] {
if (tags.length === 0) {
return [];
}
tags = tags.map((tag) => stripHash(tag));
const files = noteFiles && noteFiles.length > 0 ? noteFiles : getNotesFromPath(app.vault, "/");
const filesWithTag = [];
for (const file of files) {
const noteTags = getTagsFromNote(app, file);
if (tags.some((tag) => noteTags.includes(tag))) {
filesWithTag.push(file);
}
}
return filesWithTag;
}
export interface FormattedDateTime {
fileName: string;
display: string;
epoch: number;
}
export const formatDateTime = (
now: Date,
timezone: "local" | "utc" = "local"
): FormattedDateTime => {
const dt = timezone === "utc" ? DateTime.fromJSDate(now).toUTC() : DateTime.fromJSDate(now);
return {
fileName: dt.toFormat("yyyyMMdd_HHmmss"),
display: dt.toFormat("yyyy/MM/dd HH:mm:ss"),
epoch: dt.toMillis(),
};
};
/**
* Ensure a folder path exists by creating any missing parent directories.
* Works across desktop and mobile. Safe to call repeatedly.
*
* Examples:
* - ensureFolderExists("copilot/copilot-conversations")
* - ensureFolderExists("some/deep/nested/path")
*
* Throws if any segment conflicts with an existing file.
*/
export async function ensureFolderExists(vault: Vault, folderPath: string): Promise<void> {
const path = normalizePath(folderPath).replace(/^\/+/, "").replace(/\/+$/, "");
if (!path) return; // nothing to ensure
const parts = path.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
const existing = vault.getAbstractFileByPath(current);
if (existing) {
if (existing instanceof TFile) {
throw new Error(`Path conflict: "${current}" exists as a file, expected folder.`);
}
// If it's a folder, continue to check/create the next segment
continue;
}
// Create this level; parents are guaranteed to exist from previous iterations
await vault.adapter.mkdir(current);
}
}
export function stringToFormattedDateTime(timestamp: string): FormattedDateTime {
const date = DateTime.fromFormat(timestamp, "yyyy/MM/dd HH:mm:ss");
if (!date.isValid) {
// If the string is not in the expected format, return current date/time
return formatDateTime(new Date());
}
return {
fileName: date.toFormat("yyyyMMdd_HHmmss"),
display: date.toFormat("yyyy/MM/dd HH:mm:ss"),
epoch: date.toMillis(),
};
}
/**
* Check if a file has a text-readable extension (md, canvas, base).
*/
export function isTextReadableFile(file: TFile | null): boolean {
if (!file) return false;
return TEXT_READABLE_EXTENSIONS.includes(file.extension);
}
export async function getFileContent(file: TFile, vault: Vault): Promise<string | null> {
if (!isTextReadableFile(file)) return null;
return await vault.read(file);
}
export function getFileName(file: TFile): string {
return file.basename;
}
/**
* Check if a file is allowed for note context (text-readable files plus PDF).
* This does NOT include images - images are handled separately in the UI.
* @param file The file to check
* @returns true if the file is allowed for note context, false otherwise
*/
export function isAllowedFileForNoteContext(file: TFile | null): boolean {
if (!file) return false;
return ALLOWED_NOTE_CONTEXT_EXTENSIONS.includes(file.extension);
}
/**
* Checks if a chain type is a Plus mode chain (Copilot Plus or Project Chain).
* Plus mode chains have access to premium features like PDF processing and URL processing.
* @param chainType The chain type to check
* @returns true if this is a Plus mode chain, false otherwise
*/
export function isPlusChain(chainType: ChainType): boolean {
return chainType === ChainType.COPILOT_PLUS_CHAIN || chainType === ChainType.PROJECT_CHAIN;
}
/**
* Checks if a file extension is allowed for context based on the chain type.
* All chains support text-readable files (md, canvas, base).
* Plus chains additionally support PDF, EPUB, PPT, DOCX, etc.
* @param file The file to check
* @param chainType The current chain type
* @returns true if the file is allowed for this chain type, false otherwise
*/
export function isAllowedFileForChainContext(file: TFile | null, chainType: ChainType): boolean {
if (!file) return false;
if (isTextReadableFile(file)) {
return true;
}
// Plus chains support all other file types (PDF, EPUB, PPT, DOCX, etc.)
return isPlusChain(chainType);
}
export function areEmbeddingModelsSame(
model1: string | undefined,
model2: string | undefined
): boolean {
if (!model1 || !model2) return false;
// TODO: Hacks to handle different embedding model names for the same model. Need better handling.
if (model1.includes(NOMIC_EMBED_TEXT) && model2.includes(NOMIC_EMBED_TEXT)) {
return true;
}
if (
(model1 === "small" && model2 === "cohereai") ||
(model1 === "cohereai" && model2 === "small")
) {
return true;
}
return model1 === model2;
}
export interface ChatHistoryEntry {
role: "user" | "assistant";
content: string;
}
/**
* Extract text-only chat history from memory variables.
* This function pairs messages by index (i, i+1) and returns only string content.
*
* Note: For multimodal chains (CopilotPlus, AutonomousAgent), use
* chatHistoryUtils.processRawChatHistory instead to preserve image content.
*
* @param memoryVariables Memory variables from LangChain memory
* @returns Array of text-only chat history entries
*/
// TODO: Deprecated, use chatHistoryUtils.processRawChatHistory instead
export function extractChatHistory(memoryVariables: MemoryVariables): ChatHistoryEntry[] {
const chatHistory: ChatHistoryEntry[] = [];
const history = memoryVariables.history as Array<{ content?: string }>;
for (let i = 0; i < history.length; i += 2) {
const userMessage = history[i]?.content || "";
const aiMessage = history[i + 1]?.content || "";
chatHistory.push(
{ role: "user", content: userMessage },
{ role: "assistant", content: aiMessage }
);
}
return chatHistory;
}
/**
* Core logic for extracting note files from wikilink patterns.
* Resolves note titles/paths to TFile objects, handling both unique titles and full paths.
*
* @param noteTitles - Array of note title/path strings extracted from wikilinks
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects
*/
function resolveNoteFilesFromTitles(noteTitles: string[], vault: Vault): TFile[] {
const uniqueFiles = new Map<string, TFile>();
noteTitles.forEach((noteTitle) => {
// First try to get file by full path
const file = vault.getAbstractFileByPath(noteTitle);
if (file instanceof TFile) {
// Found by path, use it directly
uniqueFiles.set(file.path, file);
} else {
// Try to find by title
const files = vault.getMarkdownFiles();
const matchingFiles = files.filter((f) => f.basename === noteTitle);
if (matchingFiles.length > 0) {
if (isNoteTitleUnique(noteTitle, vault)) {
// Only one file with this title, use it
uniqueFiles.set(matchingFiles[0].path, matchingFiles[0]);
} else {
// Multiple files with same title - this shouldn't happen
// as we should be using full paths for duplicate titles
console.warn(
`Found multiple files with title "${noteTitle}". Expected a full path for duplicate titles.`
);
}
}
}
});
return Array.from(uniqueFiles.values());
}
/**
* Extract note files from text containing wikilinks: [[note title]]
* Used by search/retrieval systems to find explicitly mentioned notes.
*
* @param query - Text containing [[...]] patterns
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects matching the [[...]] patterns
*/
export function extractNoteFiles(query: string, vault: Vault): TFile[] {
// Use a regular expression to extract note titles and paths wrapped in [[]]
const regex = /\[\[(.*?)\]\]/g;
const matches = query.match(regex);
if (!matches) {
return [];
}
// Extract inner content from [[...]]
const noteTitles = matches.map((match) => match.slice(2, -2));
return resolveNoteFilesFromTitles(noteTitles, vault);
}
/**
* Extract note files from text containing wikilinks wrapped in curly braces: {[[note title]]}
* This is specifically for custom prompt templating where only {[[...]]} syntax should trigger
* note content inclusion.
*
* @param query - Text containing {[[...]]} patterns
* @param vault - Obsidian vault instance
* @returns Array of unique TFile objects matching the {[[...]]} patterns
*/
export function extractTemplateNoteFiles(query: string, vault: Vault): TFile[] {
// Use a regular expression to extract note titles and paths wrapped in {[[]]}
const regex = /\{\[\[(.*?)\]\]\}/g;
const matches = query.match(regex);
if (!matches) {
return [];
}
// Extract inner content from {[[...]]}
const noteTitles = matches.map((match) => match.slice(3, -3));
return resolveNoteFilesFromTitles(noteTitles, vault);
}
// Helper function to check if a note title is unique in the vault
function isNoteTitleUnique(title: string, vault: Vault): boolean {
const files = vault.getMarkdownFiles();
return files.filter((f) => f.basename === title).length === 1;
}
/**
* Process the variable name to generate a note path if it's enclosed in double brackets,
* otherwise return the variable name as is.
*
* @param {string} variableName - The name of the variable to process
* @return {string} The processed note path or the variable name itself
*/
export function processVariableNameForNotePath(variableName: string): string {
variableName = variableName.trim();
// Check if the variable name is enclosed in double brackets indicating it's a note
if (variableName.startsWith("[[") && variableName.endsWith("]]")) {
// It's a note, so we remove the brackets and append '.md'
return `${variableName.slice(2, -2).trim()}.md`;
}
// It's a path, so we just return it as is
return variableName;
}
export function extractUniqueTitlesFromDocs(docs: Document[]): string[] {
const titlesSet = new Set<string>();
docs.forEach((doc) => {
if (doc.metadata?.title) {
titlesSet.add(doc.metadata.title as string);
}
});
return Array.from(titlesSet);
}
const YOUTUBE_URL_REGEX =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([^\s&]+)/;
/**
* Validates a YouTube URL and returns detailed validation result
*/
export function validateYoutubeUrl(url: string): {
isValid: boolean;
error?: string;
videoId?: string;
} {
if (!url || typeof url !== "string") {
return { isValid: false, error: "URL is required" };
}
const trimmedUrl = url.trim();
if (!trimmedUrl) {
return { isValid: false, error: "URL cannot be empty" };
}
// Extract video ID
const videoId = extractYoutubeVideoId(trimmedUrl);
if (!videoId) {
return { isValid: false, error: "Invalid YouTube URL format" };
}
// Check if video ID is valid (11 characters, alphanumeric with dashes and underscores)
if (!/^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
return { isValid: false, error: "Invalid YouTube video ID" };
}
return { isValid: true, videoId };
}
/**
* Extract YouTube video ID from various URL formats
*/
export function extractYoutubeVideoId(url: string): string | null {
try {
// Handle different YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
} catch {
return null;
}
}
/**
* Create a standard YouTube URL from video ID
*/
export function formatYoutubeUrl(videoId: string): string {
return `https://www.youtube.com/watch?v=${videoId}`;
}
/**
* Check if a string is a valid YouTube URL (legacy function for backward compatibility)
*/
export function isYoutubeUrl(url: string): boolean {
return validateYoutubeUrl(url).isValid;
}
/**
* Check if a URL is a Twitter/X URL (e.g. tweet or post link)
*/
export function isTwitterUrl(url: string): boolean {
if (!url || typeof url !== "string") return false;
try {
const urlObj = new URL(url.trim());
return (
(urlObj.hostname === "x.com" ||
urlObj.hostname === "www.x.com" ||
urlObj.hostname === "twitter.com" ||
urlObj.hostname === "www.twitter.com") &&
urlObj.pathname.includes("/status/")
);
} catch {
return false;
}
}
/**
* Extract all YouTube URLs from text (legacy function for backward compatibility)
*/
export function extractAllYoutubeUrls(text: string): string[] {
const matches = text.matchAll(new RegExp(YOUTUBE_URL_REGEX, "g"));
return Array.from(matches, (match) => match[0]);
}
/**
* Proxy function to use in place of fetch() to bypass CORS restrictions.
* Uses Obsidian's requestUrl which bypasses browser CORS restrictions.
*
* @param url - The URL to fetch
* @param options - Fetch options (subset of RequestInit)
* @param options.throwOnHttpError - If true (default), throws on HTTP >= 400. Set to false for fetch-like behavior.
*
* @remarks
* **AbortSignal Limitation**: The `signal` option is accepted for API compatibility
* but is NOT honored by the underlying `requestUrl` implementation. Requests made
* through this function cannot be cancelled via AbortSignal. If cancellation is
* required, use native `fetch` instead (which may encounter CORS issues on mobile).
*
* **Streaming Limitation**: This function does not support true streaming responses.
* The entire response body is buffered before being returned.
*
* @see https://forum.obsidian.md/t/support-streaming-the-request-and-requesturl-response-body/87381
*/
export async function safeFetch(
url: string,
options: RequestInit & { throwOnHttpError?: boolean } = {}
): Promise<Response> {
const { throwOnHttpError = true } = options;
// Initialize headers if not provided
const normalizedHeaders = new Headers(options.headers);
const headers = Object.fromEntries(normalizedHeaders.entries());
// Remove content-length if it exists
delete (headers as Record<string, string>)["content-length"];
logInfo("safeFetch request");
const method = options.method?.toUpperCase() || "POST";
const methodsWithBody = ["POST", "PUT", "PATCH"];
const response = await requestUrl({
url,
contentType: "application/json",
headers: headers,
method: method,
...(methodsWithBody.includes(method) &&
typeof options.body === "string" && { body: options.body }),
throw: false, // Don't throw so we can get the response body
});
// Check if response is error status (only throw if throwOnHttpError is true)
if (throwOnHttpError && response.status >= 400) {
type ErrorJson = {
detail?: { reason?: string; message?: string } | string;
reason?: string;
message?: string;
};
let errorJson: ErrorJson | null = null;
try {
errorJson = (
typeof response.json === "string" ? JSON.parse(response.json) : response.json
) as ErrorJson;
} catch {
try {
errorJson = (
typeof response.text === "string" ? JSON.parse(response.text) : response.text
) as ErrorJson;
} catch {
errorJson = null;
}
}
// Create error with proper structure
const error = new Error(ERROR_MESSAGES.REQUEST_FAILED(response.status)) as APIError;
error.json = errorJson;
// Handle nested error structure
const detail = errorJson && typeof errorJson.detail === "object" ? errorJson.detail : undefined;
if (detail?.reason === "Invalid license key" || errorJson?.reason === "Invalid license key") {
error.message = "Invalid license key";
} else if (detail?.message || errorJson?.message) {
const message = detail?.message || errorJson?.message;
const reason = detail?.reason || errorJson?.reason;
error.message = reason ? `${message}: ${reason}` : (message ?? "");
} else if (errorJson?.detail) {
error.message = JSON.stringify(errorJson.detail);
} else if (errorJson) {
// for external error, add more msg
error.message += ". " + JSON.stringify(errorJson);
}
throw error;
}
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.status.toString(),
headers: new Headers(response.headers),
url: url,
type: "basic" as ResponseType,
redirected: false,
bytes: () => Promise.resolve(new Uint8Array(0)),
body: createReadableStreamFromString(response.text),
bodyUsed: true,
json: (): Promise<unknown> => Promise.resolve(response.json as unknown),
text: async () => response.text,
arrayBuffer: async () => {
if (response.arrayBuffer) {
return response.arrayBuffer;
}
const base64 = response.text.replace(/^data:.*;base64,/, "");
// Reason: Buffer (from the `buffer` polyfill imported above) is the
// cross-platform path — bare global Buffer is undefined in mobile WebView.
const buf = Buffer.from(base64, "base64");
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
},
blob: () => {
throw new Error("not implemented");
},
formData: () => {
throw new Error("not implemented");
},
clone: () => {
throw new Error("not implemented");
},
};
}
/**
* Wrapper around safeFetch that doesn't throw on HTTP errors (fetch-like behavior).
* Use this when you need to check response.status for retry logic (e.g., 401 token refresh).
*
* @remarks
* Inherits all limitations from safeFetch:
* - AbortSignal is NOT honored (requests cannot be cancelled)
* - No true streaming support (response is fully buffered)
*
* @see safeFetch for full documentation
*/
export function safeFetchNoThrow(url: string, options: RequestInit = {}): Promise<Response> {
return safeFetch(url, { ...options, throwOnHttpError: false });
}
function createReadableStreamFromString(input: string) {
return new ReadableStream({
start(controller) {
// Convert the input string to a Uint8Array
const encoder = new TextEncoder();
const uint8Array = encoder.encode(input);
// Push the data to the stream
controller.enqueue(uint8Array);
// Close the stream
controller.close();
},
});
}
// err2String is now exported from '@/errorFormat' to avoid circular dependencies and duplication.
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const result = { ...obj };
keys.forEach((key) => {
delete result[key];
});
return result;
}
export function findCustomModel(modelKey: string, activeModels: CustomModel[]): CustomModel {
const [modelName, provider] = modelKey.split("|");
const model = activeModels.find((m) => m.name === modelName && m.provider === provider);
if (!model) {
throw new Error(`No model configuration found for: ${modelKey}`);
}
return model;
}
// Capabilities can be undefined when a model's vision support is simply unknown;
// callers that hard-block on missing vision must treat undefined as "unknown", not "no".
export function modelSupportsVision(model: CustomModel): boolean {
return !!model.capabilities?.includes(ModelCapability.VISION);
}
export function getProviderInfo(provider: string): ProviderMetadata {
const info = ProviderInfo[provider as Provider];
return {
...info,
label: info.label || provider,
};
}
export function getProviderLabel(provider: string, model?: CustomModel): string {
const baseLabel = ProviderInfo[provider as Provider]?.label || provider;
return baseLabel + (model?.believerExclusive && baseLabel === "Copilot Plus" ? "(Believer)" : "");
}
/**
* Cleans a message by removing Think blocks, Action blocks (writeFile), tool call markers,
* and agent reasoning blocks for copying to clipboard or inserting at cursor.
* This is more comprehensive than removeThinkTags which is used for RAG.
*/
export function cleanMessageForCopy(message: string): string {
let cleanedMessage = message;
// First use the existing removeThinkTags function
cleanedMessage = removeThinkTags(cleanedMessage);
// Remove writeFile blocks wrapped in XML codeblocks (also handles legacy writeToFile tag)
cleanedMessage = cleanedMessage.replace(
/```xml\s*[\s\S]*?<write(?:File|ToFile)>[\s\S]*?<\/write(?:File|ToFile)>[\s\S]*?```/g,
""
);
// Remove standalone writeFile/writeToFile blocks
cleanedMessage = cleanedMessage.replace(
/<write(?:File|ToFile)>[\s\S]*?<\/write(?:File|ToFile)>/g,
""
);
// Remove tool call markers
// Format: <!--TOOL_CALL_START:id:toolName:displayName:emoji:confirmationMessage:isExecuting-->content<!--TOOL_CALL_END:id:result-->
cleanedMessage = cleanedMessage.replace(
/<!--TOOL_CALL_START:[^:]+:[^:]+:[^:]+:[^:]+:[^:]*:[^:]+-->[\s\S]*?<!--TOOL_CALL_END:[^:]+:[\s\S]*?-->/g,
""
);
// Remove agent reasoning blocks
// Format: <!--AGENT_REASONING:status:elapsed:["step1","step2"]-->
// Use greedy .* so we match to the real closing --> even if the JSON payload contains -->
cleanedMessage = cleanedMessage.replace(/<!--AGENT_REASONING:\w+:\d+:.*-->/g, "");
// Clean up any resulting multiple consecutive newlines (more than 2)
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, "\n\n");
// Trim leading and trailing whitespace
cleanedMessage = cleanedMessage.trim();
return cleanedMessage;
}
/**
* Inserts text at the cursor of the most recent markdown editor, replacing the
* current selection when there is one. Resolves the target leaf via the passed
* `app` (no global `app`) and threads that same `app` into `insertIntoEditor`
* so selection detection and insertion always target the same editor — even in
* a popout window where the global `app` would resolve a different leaf.
*/
export async function insertAtCursor(app: App, text: string) {
let leaf = app.workspace.getMostRecentLeaf();
if (!leaf || !(leaf.view instanceof MarkdownView)) {
leaf = app.workspace.getLeaf(false);
if (!leaf || !(leaf.view instanceof MarkdownView)) return;
}
const hasSelection = leaf.view.editor.getSelection().length > 0;
await insertIntoEditor(app, text, hasSelection);
}
/**
* Inserts a message into the active markdown editor, optionally replacing the current selection.
* Uses a single CM6 transaction to avoid undo stack splitting.
* Ensures the inserted/replaced range is selected after the operation.
*
* Resolves the target leaf from the passed `app` (not the global) so the write
* lands in the caller's window — critical for popout-window chats.
*/
export async function insertIntoEditor(app: App, message: string, replace: boolean = false) {
let leaf = app.workspace.getMostRecentLeaf();
if (!leaf) {
new Notice("No active leaf found.");
return;
}
if (!(leaf.view instanceof MarkdownView)) {
leaf = app.workspace.getLeaf(false);
await leaf.setViewState({ type: "markdown", state: leaf.view.getState() });
}
if (!(leaf.view instanceof MarkdownView)) {
new Notice("Failed to open a markdown view.");
return;
}
const editor = leaf.view.editor;
const cursorFrom = editor.getCursor("from");
const cursorTo = editor.getCursor("to");
// Clean the message before inserting (removes think tags, writeFile blocks, tool calls)
const cleanedMessage = cleanMessageForCopy(message);
const cleanedLines = cleanedMessage.split("\n");
/** Computes the end editor position after inserting text at start position */
const getEndPosition = (
start: { line: number; ch: number },
textLines: string[]
): { line: number; ch: number } => {
const lineDelta = textLines.length - 1;
if (lineDelta === 0) {
return { line: start.line, ch: start.ch + (textLines[0]?.length ?? 0) };
}
return { line: start.line + lineDelta, ch: textLines[textLines.length - 1]?.length ?? 0 };
};
/** Inserts via Obsidian Editor API (fallback when CM6 unavailable or dispatch fails) */
const insertWithEditorAPI = (): void => {
const changeFrom = replace ? cursorFrom : cursorTo;
editor.replaceRange(cleanedMessage, changeFrom, cursorTo);
editor.setSelection(changeFrom, getEndPosition(changeFrom, cleanedLines));
};
/** Focuses the editor and shows success notice */
const finalizeInsertion = (): void => {
editor.focus();
new Notice("Message inserted into the active note.");
};
// Check if CM6 EditorView is available and valid
const view = editor.cm;
const isCM6View = view?.state?.doc && typeof view.dispatch === "function";
if (!isCM6View) {
insertWithEditorAPI();
finalizeInsertion();
return;
}
// Use CM6 selection offsets directly (simpler than manual line/ch conversion)
const { from, to } = view.state.selection.main;
const changeFrom = replace ? from : to;
// Use CM6's toText to handle CRLF normalization correctly
// (CM6 treats \r\n as single newline, so string.length would be wrong)
const insertText = view.state.toText(cleanedMessage);
const endOffset = changeFrom + insertText.length;
try {
// Single transaction: text change + selection change
view.dispatch({
changes: { from: changeFrom, to, insert: insertText },
selection: { anchor: changeFrom, head: endOffset },
});
} catch (e) {
// Fallback to Obsidian API if CM6 dispatch fails
logWarn("CM6 dispatch failed, falling back to Obsidian API", e);
insertWithEditorAPI();
}
finalizeInsertion();
}
export { debounce } from "@/utils/debounce";
/**
* Compare two semantic version strings.
* @returns true if latest version is newer than current version
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParts = latest.split(".").map(Number);
const currentParts = current.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false;
}
/**
* Check for latest version from GitHub releases.
* @returns latest version string or error message
*/
export async function checkLatestVersion(): Promise<{
version: string | null;
error: string | null;
}> {
try {
const response = await requestUrl({
url: "https://api.github.com/repos/logancyang/obsidian-copilot/releases/latest",
method: "GET",
});
const version = (response.json as { tag_name: string }).tag_name.replace("v", "");
return { version, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to check for updates";
return { version: null, error: errorMessage };
}
}
// Note: LangChain 0.6.6+ handles O-series and GPT-5 models automatically
// These functions are kept for backward compatibility and specific checks
export function isOSeriesModel(model: BaseChatModel | string): boolean {
if (typeof model === "string") {
return model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4");
}
// For BaseChatModel instances
const m = model as unknown as Record<string, unknown>;
const modelName: string = (m.modelName as string) || (m.model as string) || "";
return modelName.startsWith("o1") || modelName.startsWith("o3") || modelName.startsWith("o4");
}
function isGPT5Model(model: BaseChatModel | string): boolean {
if (typeof model === "string") {
return model.startsWith("gpt-5");
}
// For BaseChatModel instances
const m = model as unknown as Record<string, unknown>;
const modelName: string = (m.modelName as string) || (m.model as string) || "";
return modelName.startsWith("gpt-5");
}
/**
* Checks whether a model belongs to the Codex family.
* Codex model identifiers consistently include the "codex" token.
* @param model - Model instance or model name string.
* @returns True when the model name indicates a Codex model.
*/
function isCodexModel(model: BaseChatModel | string): boolean {
const m = model as unknown as Record<string, unknown>;
const modelName: string =
typeof model === "string" ? model : (m.modelName as string) || (m.model as string) || "";
return modelName.toLowerCase().includes("codex");
}
/**
* Determines whether a GitHub Copilot model should use the Responses API.
* Copilot Codex models reject `/chat/completions` and must be sent to `/responses`.
* @param model - Minimal model configuration used for routing.
* @returns True when the model should be routed to `/responses`.
*/
export function shouldUseGitHubCopilotResponsesApi(
model: Pick<CustomModel, "provider" | "name" | "useResponsesApi">
): boolean {
if ((model.provider as ChatModelProviders) !== ChatModelProviders.GITHUB_COPILOT) {
return false;
}
if (model.useResponsesApi === true) {
return true;
}
return isCodexModel(model.name);
}
/**
* Utility for determining model characteristics
* Note: Most of this is handled by LangChain 0.6.6+ internally
*/
export interface ModelInfo {
isOSeries: boolean;
isGPT5: boolean;
isThinkingEnabled: boolean;
usesAdaptiveThinking: boolean;
}
export function getModelInfo(model: BaseChatModel | string): ModelInfo {
const m = model as unknown as Record<string, unknown>;
const modelName: string =
typeof model === "string" ? model : (m.modelName as string) || (m.model as string) || "";
const isOSeries = isOSeriesModel(modelName);
const isGPT5 = isGPT5Model(modelName);
const isThinkingEnabled =
modelName.startsWith("claude-3-7-sonnet") ||
modelName.startsWith("claude-sonnet-4") ||
modelName.startsWith("claude-opus-4");
// claude-opus-4-7 and later reject the legacy { type: "enabled", budget_tokens } shape
// with a 400 and require { type: "adaptive" }. Detect by minor version on the opus-4 line.
// Constrain the minor to 1-2 digits followed by a delimiter or end-of-string so dated
// snapshot IDs (e.g. "claude-opus-4-20250514") aren't misread as Opus 4.20250514.
const opusMinorMatch = modelName.match(/^claude-opus-4-(\d{1,2})(?:[-.]|$)/);
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
return {
isOSeries,
isGPT5,
isThinkingEnabled,
usesAdaptiveThinking,
};
}
export function getMessageRole(
model: BaseChatModel | string,
defaultRole: "system" | "human" = "system"
): "system" | "human" {
return isOSeriesModel(model) ? "human" : defaultRole;
}
export function getNeedSetKeyProvider(): Provider[] {
// List of providers to exclude
const excludeProviders: Provider[] = [
ChatModelProviders.OPENAI_FORMAT,
ChatModelProviders.OLLAMA,
ChatModelProviders.LM_STUDIO,
ChatModelProviders.AZURE_OPENAI,
ChatModelProviders.GITHUB_COPILOT,
EmbeddingModelProviders.COPILOT_PLUS,
EmbeddingModelProviders.COPILOT_PLUS_JINA,
];
return (Object.keys(ProviderInfo) as Provider[]).filter((key) => !excludeProviders.includes(key));
}
export function checkModelApiKey(
model: CustomModel,
settings: Readonly<CopilotSettings>
): {
hasApiKey: boolean;
errorNotice?: string;
} {
const provider = model.provider as ChatModelProviders;
if (provider === ChatModelProviders.AMAZON_BEDROCK) {
const apiKey = model.apiKey || settings.amazonBedrockApiKey;
if (!apiKey) {
return {
hasApiKey: false,
errorNotice:
"Amazon Bedrock API key is missing. Please add a key in Settings > Copilot > BYOK or update the model configuration.",
};
}
// Region defaults to us-east-1 if not specified, so API key is the only required check
return { hasApiKey: true };
}
// GitHub Copilot uses OAuth, not API key
if (provider === ChatModelProviders.GITHUB_COPILOT) {
const hasAuth = Boolean(
model.apiKey || settings.githubCopilotToken || settings.githubCopilotAccessToken
);
if (!hasAuth) {
return {
hasApiKey: false,
errorNotice:
"GitHub Copilot is not authenticated. Please connect it in Settings > Copilot > BYOK.",
};
}
return { hasApiKey: true };
}
const needSetKeyPath = !!getNeedSetKeyProvider().find((p) => p === provider);
const hasNoApiKey = !getApiKeyForProvider(model.provider as SettingKeyProviders, model);
// For Providers that require setting a key in the dialog, an inspection is necessary.
if (needSetKeyPath && hasNoApiKey) {
const notice =
`Please configure API Key for ${model.name} in settings first.` +
"\nPath: Settings > Copilot > BYOK";
return {
hasApiKey: false,
errorNotice: notice,
};
}
return {
hasApiKey: true,
};
}
/**
* Extracts text content from a message chunk that could be either a string
* or an array of content objects (Claude 3.7 format)
*/
export function extractTextFromChunk(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return (content as Array<{ type?: string; text?: string }>)
.filter((item) => item.type === "text")
.map((item) => item.text ?? "")
.join("");
}
// For any other type, return empty string
return "";
}
/**
* Removes any <think> tags and their content from the text.
* This is used to clean model outputs before using them for RAG.
* Handles both string content and array-based content (Claude 3.7 format)
* @param text - The text or content array to remove think tags from
* @returns The text with think tags removed
*/
export function removeThinkTags(text: unknown): string {
// First convert any content format to plain text
const plainText = extractTextFromChunk(text);
// Remove complete think tags and their content
let cleanedText = plainText.replace(/<think>[\s\S]*?<\/think>/g, "");
// Remove any remaining unclosed think tags (for streaming scenarios)
cleanedText = cleanedText.replace(/<think>[\s\S]*$/g, "");
return cleanedText.trim();
}
/**
* Removes any <errorChunk> tags and their content from the text.
* This is used to clean model outputs before using them for RAG.
* Handles both string content and array-based content (Claude 3.7 format)
* @param text - The text or content array to remove error tags from
* @returns The text with error tags removed
*/
export function removeErrorTags(text: unknown): string {
// First convert any content format to plain text
const plainText = extractTextFromChunk(text);
// Then remove error tags
return plainText.replace(/<errorChunk>[\s\S]*?<\/errorChunk>/g, "").trim();
}
export function randomUUID() {
return crypto.randomUUID();
}
/**
* Executes a function with token counting warnings suppressed
* This can be used anywhere in the codebase where token counting warnings should be suppressed
* @param fn The function to execute without token counting warnings
* @returns The result of the function
*/
export async function withSuppressedTokenWarnings<T>(fn: () => Promise<T>): Promise<T> {
// Store original console.warn
const originalWarn = console.warn;
try {
// Replace with filtered version
console.warn = function (...args: unknown[]) {
// Ignore token counting warnings
const first = args[0];
if (
typeof first === "string" &&
(first.includes("Failed to calculate number of tokens") || first.includes("Unknown model"))
) {
return;
}
// Pass through other warnings
originalWarn.apply(console, args);
};
// Execute the provided function
return await fn();
} finally {
// Always restore original console.warn, even if an error occurs
console.warn = originalWarn;
}
}
/**
* Execute an operation with a timeout using AbortController for proper cancellation
* @param operation - Function that accepts an AbortSignal and returns a Promise
* @param timeoutMs - Timeout in milliseconds
* @param operationName - Name of the operation for error messages
* @returns Promise that resolves with the operation result or rejects with TimeoutError
*/
export async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
operationName: string = "Operation"
): Promise<T> {
const { TimeoutError } = await import("@/error");
const controller = new AbortController();
const timeoutId = window.setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
controller.signal.addEventListener("abort", () => {
reject(new TimeoutError(operationName, timeoutMs));
});
}),
]);
} finally {
window.clearTimeout(timeoutId);
}
}
/**
* Check if the current Obsidian editor setting is in source mode
*/
export function isSourceModeOn(app: App): boolean {
const view = app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return true;
const state = view.getState() as { source?: boolean };
return state.source === true;
}
/**
* Calculate the UTF-8 byte length of a string.
* This is important for filesystem operations where filename limits are in bytes, not characters.
* @param str - The string to measure
* @returns The byte length when encoded as UTF-8
*/
export function getUtf8ByteLength(str: string): number {
// Use TextEncoder which always uses UTF-8 encoding
return new TextEncoder().encode(str).length;
}
/**
* Truncate a string to fit within a byte limit, ensuring UTF-8 character boundaries are respected.
* This prevents cutting multibyte UTF-8 sequences in the middle.
* @param str - The string to truncate
* @param byteLimit - Maximum number of bytes (not characters)
* @returns The truncated string that fits within the byte limit
*/
export function truncateToByteLimit(str: string, byteLimit: number): string {
if (byteLimit <= 0) {
return "";
}
// Fast path: if string already fits, return as-is
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
if (bytes.length <= byteLimit) {
return str;
}
// Binary search to find the longest prefix that fits
let low = 0;
let high = str.length;
let result = "";
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = str.substring(0, mid);
const candidateBytes = encoder.encode(candidate);
if (candidateBytes.length <= byteLimit) {
// This candidate fits, try a longer one
result = candidate;
low = mid + 1;
} else {
// This candidate is too long, try a shorter one
high = mid - 1;
}
}
return result;
}
/** Maximum filename (basename) length in bytes for most filesystems (ext4, NTFS, APFS). */
const MAX_FILENAME_BYTES = 255;
/**
* Sanitize a vault-relative file path by truncating the basename if it exceeds
* filesystem filename length limits. Preserves the file extension.
* @param filePath - Vault-relative file path
* @returns The sanitized path with basename truncated if necessary
*/
export function sanitizeFilePath(filePath: string): string {
const parts = filePath.split("/");
const basename = parts[parts.length - 1];
if (getUtf8ByteLength(basename) <= MAX_FILENAME_BYTES) {
return filePath;
}
const extIndex = basename.lastIndexOf(".");
const ext = extIndex >= 0 ? basename.substring(extIndex) : "";
const name = extIndex >= 0 ? basename.substring(0, extIndex) : basename;
const extBytes = getUtf8ByteLength(ext);
const availableBytes = MAX_FILENAME_BYTES - extBytes;
if (availableBytes <= 0) {
parts[parts.length - 1] = truncateToByteLimit(basename, MAX_FILENAME_BYTES);
} else {
parts[parts.length - 1] = truncateToByteLimit(name, availableBytes) + ext;
}
return parts.join("/");
}
/**
* Opens a file in the workspace, reusing an existing tab if the file is already open.
* @param app - The Obsidian app instance
* @param file - The TFile to open
* @param focusIfOpen - If true, focuses the existing leaf if the file is already open (default: true)
*/
export async function openFileInWorkspace(
app: App,
file: TFile,
focusIfOpen: boolean = true
): Promise<void> {
// Check if the file is already open in any leaf
let existingLeaf = null;
app.workspace.iterateAllLeaves((leaf) => {
if (
leaf.view.getViewType() === "markdown" ||
leaf.view.getViewType() === "pdf" ||
leaf.view.getViewType() === "canvas"
) {
const viewFile = (leaf.view as unknown as Record<string, unknown>).file as
| { path: string }
| undefined;
if (viewFile && viewFile.path === file.path) {
existingLeaf = leaf;
}
}
});
if (existingLeaf && focusIfOpen) {
// File is already open, focus the existing leaf
app.workspace.setActiveLeaf(existingLeaf, { focus: true });
} else if (!existingLeaf) {
// File is not open, open it in a new tab
const leaf = app.workspace.getLeaf("tab");
await leaf.openFile(file);
}
}