├── .scignore ├── .gitignore ├── assets └── smart-chatgpt-getting_started.gif ├── manifest.json ├── src ├── components │ ├── smart-chat │ │ ├── codeblock.css │ │ └── codeblock.js │ └── external-chat-thread │ │ ├── webview.css │ │ ├── chatgpt_webview.js │ │ └── webview.js ├── utils │ ├── chat_platform.js │ └── chat_platform.test.js ├── collections │ └── external_chat_threads.js └── views │ └── sc_chatgpt.obsidian.js ├── package.json ├── chatgpt_thread_link.test.js ├── styles.css ├── chatgpt_thread_link.js ├── LICENSE ├── esbuild.js ├── smart_chat_codeblock.js ├── release.js ├── main.js ├── README.md ├── smart_grok_codeblock.js ├── smart_claude_codeblock.js ├── smart_deepseek_codeblock.js ├── smart_perplexity_codeblock.js ├── smart_gemini_codeblock.js ├── smart_aistudio_codeblock.js └── smart_chatgpt_codeblock.js /.scignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | release.js 3 | esbuild.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | node_modules/** 4 | dist 5 | dist/** -------------------------------------------------------------------------------- /assets/smart-chatgpt-getting_started.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianpetro/smart-chatgpt-obsidian/HEAD/assets/smart-chatgpt-getting_started.gif -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "smart-chatgpt", 3 | "name": "Smart ChatGPT", 4 | "version": "1.0.17", 5 | "minAppVersion": "1.8.0", 6 | "description": "Integrate OpenAI's ChatGPT seamlessly in notes. Automatically saves links, allows marking threads as done and integrates with Dataview.", 7 | "author": "🌴 Brian", 8 | "authorUrl": "https://smartconnections.app", 9 | "isDesktopOnly": true 10 | } -------------------------------------------------------------------------------- /src/components/smart-chat/codeblock.css: -------------------------------------------------------------------------------- 1 | .sc-smart-chat-codeblock { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 12px; 5 | padding: 8px 4px; 6 | } 7 | 8 | .sc-smart-chat-header { 9 | display: flex; 10 | flex-wrap: wrap; 11 | gap: 8px; 12 | align-items: center; 13 | } 14 | 15 | .sc-smart-chat-select { 16 | min-width: 220px; 17 | flex: 1; 18 | } 19 | 20 | .sc-smart-chat-toolbar { 21 | display: flex; 22 | gap: 6px; 23 | } 24 | 25 | .sc-smart-chat-body { 26 | min-height: 360px; 27 | } -------------------------------------------------------------------------------- /src/utils/chat_platform.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Determine chat platform from a thread URL. 3 | * @param {string} url - Chat thread URL. 4 | * @returns {string} - Platform key: chatgpt, claude, grok, or unknown. 5 | */ 6 | export function chat_platform_from_url(url) { 7 | if (typeof url !== 'string') return 'unknown'; 8 | const patterns = [ 9 | { key: 'chatgpt', re: /chatgpt\.com|chat\.openai\.com/ }, 10 | { key: 'claude', re: /claude\.ai/ }, 11 | { key: 'grok', re: /grok\.com/ }, 12 | ]; 13 | const match = patterns.find(p => p.re.test(url)); 14 | return match ? match.key : 'unknown'; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/chat_platform.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { chat_platform_from_url } from './chat_platform.js'; 3 | 4 | test('detects chatgpt', t => { 5 | t.is(chat_platform_from_url('https://chatgpt.com/c/123'), 'chatgpt'); 6 | }); 7 | 8 | test('detects claude', t => { 9 | t.is(chat_platform_from_url('https://claude.ai/chat/abc'), 'claude'); 10 | }); 11 | 12 | test('detects grok', t => { 13 | t.is(chat_platform_from_url('https://grok.com/chat/xyz'), 'grok'); 14 | }); 15 | 16 | test('handles unknown', t => { 17 | t.is(chat_platform_from_url('https://example.com'), 'unknown'); 18 | }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smart-chatgpt", 3 | "version": "1.0.17", 4 | "description": "Integrate OpenAI's ChatGPT seamlessly in notes. Automatically saves links, allows marking threads as done and integrates with Dataview.", 5 | "author": "🌴 Brian", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "build": "node esbuild.js", 10 | "release": "node release.js" 11 | }, 12 | "dependencies": { 13 | "obsidian": "latest" 14 | }, 15 | "devDependencies": { 16 | "archiver": "^7.0.1", 17 | "axios": "^1.7.9", 18 | "dotenv": "^16.4.5", 19 | "esbuild": "^0.25.9", 20 | "readline": "^1.3.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/external-chat-thread/webview.css: -------------------------------------------------------------------------------- 1 | .sc-external-chat-webview { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 8px; 5 | } 6 | 7 | .sc-external-chat-webview-header { 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-between; 11 | font-size: 0.9rem; 12 | color: var(--text-muted); 13 | } 14 | 15 | .sc-external-chat-webview-frame { 16 | border: 1px solid var(--background-modifier-border); 17 | border-radius: 8px; 18 | overflow: hidden; 19 | min-height: 360px; 20 | background: var(--background-secondary); 21 | } 22 | 23 | .sc-external-chat-webview-frame webview { 24 | width: 100%; 25 | height: 100%; 26 | border: 0; 27 | } 28 | -------------------------------------------------------------------------------- /chatgpt_thread_link.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { is_chatgpt_thread_link } from './chatgpt_thread_link.js'; 3 | 4 | const valid_threads = [ 5 | 'https://chatgpt.com/c/123e4567-e89b-12d3-a456-426614174000', 6 | 'https://operator.chatgpt.com/c/123e4567-e89b-12d3-a456-426614174000', 7 | 'https://chatgpt.com/g/gpt-id/c/123e4567-e89b-12d3-a456-426614174000', 8 | 'https://chatgpt.com/codex/tasks/sample-task', 9 | 'https://sora.com/t/123e4567-e89b-12d3-a456-426614174000' 10 | ]; 11 | 12 | const invalid_threads = [ 13 | 'https://example.com/c/123e4567-e89b-12d3-a456-426614174000', 14 | 'https://chatgpt.com/', 15 | 'not a url' 16 | ]; 17 | 18 | test('recognizes valid chatgpt thread links', t => { 19 | for (const url of valid_threads) { 20 | t.true(is_chatgpt_thread_link(url), url); 21 | } 22 | }); 23 | 24 | test('rejects invalid chatgpt thread links', t => { 25 | for (const url of invalid_threads) { 26 | t.false(is_chatgpt_thread_link(url), url); 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Make .button-container more specific */ 2 | .sc-button-container { 3 | margin-bottom: 10px; 4 | } 5 | 6 | /* Codeblock container border and padding */ 7 | .sc-dynamic-codeblock { 8 | border: 1px solid var(--color-border); 9 | padding: 8px; 10 | margin: 8px 0; 11 | } 12 | 13 | /* Layout for the top row of codeblock UI */ 14 | .sc-top-row { 15 | display: flex; 16 | gap: 8px; 17 | margin-bottom: 8px; 18 | align-items: center; 19 | } 20 | 21 | /* Layout for the bottom row of codeblock UI */ 22 | .sc-bottom-row { 23 | display: flex; 24 | gap: 8px; 25 | margin-top: 8px; 26 | } 27 | 28 | /* Status text on top row */ 29 | .sc-status-text { 30 | margin-left: auto; 31 | } 32 | 33 | /* A hidden element class for toggling display. */ 34 | .sc-hidden { 35 | display: none; 36 | } 37 | 38 | /* The embedded ChatGPT webview. Use a custom property for height. */ 39 | .sc-webview { 40 | width: 100%; 41 | height: var(--sc-webview-height, 800px); 42 | } 43 | 44 | /* Link dropdown styling if needed */ 45 | .sc-link-dropdown { 46 | max-width: 300px; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/external-chat-thread/chatgpt_webview.js: -------------------------------------------------------------------------------- 1 | const build_html = ext_thread => { 2 | return `
`; 3 | }; 4 | 5 | export async function render(ext_thread, params = {}) { 6 | const frag = this.create_doc_fragment(build_html(ext_thread)); 7 | const container = frag.firstElementChild; 8 | post_process.call(this, ext_thread, container, params); 9 | return container; 10 | } 11 | 12 | export async function post_process(ext_thread, container, params = {}) { 13 | const app = ext_thread.env.plugin?.app || window.app; 14 | const frame = container.querySelector('.sc-external-chat-webview-frame'); 15 | const webview = document.createElement('webview'); 16 | webview.setAttribute('src', ext_thread.data.url); 17 | webview.setAttribute('partition', app.getWebviewPartition()); 18 | webview.setAttribute('allowpopups', ''); 19 | webview.setAttribute('useragent', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.191 Safari/537.36"); 20 | webview.setAttribute('webpreferences', 'nativeWindowOpen=yes, contextIsolation=yes'); 21 | webview.setAttribute('allowpopups', 'true'); 22 | webview.setAttribute('webpreferences', 'contextIsolation=no'); 23 | frame.replaceChildren(webview); 24 | 25 | return container; 26 | } 27 | 28 | -------------------------------------------------------------------------------- /chatgpt_thread_link.js: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | const SUPPORTED_DOMAINS = [ 4 | 'chatgpt.com', 5 | 'sora.com', 6 | 'sora.chatgpt.com' 7 | ]; 8 | const GPT_THREAD_REGEX = /^\/g\/[^/]+\/c\/[a-f0-9-]+\/?$/i; 9 | const CODEX_TASK_REGEX = /^\/codex\/tasks\/[a-z0-9-_]+\/?$/i; 10 | const CHAT_THREAD_REGEX = /^\/c\/[a-f0-9-]+\/?$/i; 11 | // sora 2 12 | const SORA_DRAFT_REGEX = /^\/d\/[a-z0-9-_]+\/?$/i; 13 | const SORA_PUB_REGEX = /^\/p\/s_[a-f0-9]+\/?$/i; 14 | // DEPRECATED sora 1 15 | const SORA_TASK_REGEX = /^\/t\/[a-f0-9-]+\/?$/i; 16 | 17 | /** 18 | * Determine if a URL points to a ChatGPT thread or task. 19 | * 20 | * @param {string} url - URL to test. 21 | * @returns {boolean} True when the URL matches a supported thread. 22 | */ 23 | export function is_chatgpt_thread_link(url) { 24 | try { 25 | const u = new URL(url); 26 | if (!SUPPORTED_DOMAINS.includes(u.hostname)) return false; 27 | const path = u.pathname; 28 | return ( 29 | CHAT_THREAD_REGEX.test(path) || 30 | GPT_THREAD_REGEX.test(path) || 31 | CODEX_TASK_REGEX.test(path) || 32 | SORA_DRAFT_REGEX.test(path) || 33 | SORA_PUB_REGEX.test(path) || 34 | SORA_TASK_REGEX.test(path) // DEPRECATED, but still used in some places 35 | ); 36 | } catch { 37 | return false; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/smart-chat/codeblock.js: -------------------------------------------------------------------------------- 1 | import codeblock_css from './codeblock.css' assert { type: 'css' }; 2 | 3 | const build_html = thread => `
4 |
5 |
6 | 7 |
8 | 9 | 10 |
11 |
12 |
13 |
14 |
`; 15 | 16 | /** 17 | * Render the smart chat codeblock container with controls. 18 | * @param {object} thread - Codeblock scope containing records and handlers. 19 | * @param {object} [params] 20 | * @returns {Promise} 21 | */ 22 | export async function render(thread, params = {}) { 23 | const frag = this.create_doc_fragment(build_html(thread)); 24 | this.apply_style_sheet(codeblock_css); 25 | const container = frag.querySelector('.sc-smart-chat-codeblock'); 26 | post_process.call(this, thread, container, params); 27 | return container; 28 | } 29 | 30 | /** 31 | * Attach interactivity to the rendered smart chat codeblock container. 32 | * @param {object} thread 33 | * @param {HTMLElement} container 34 | * @returns {Promise} 35 | */ 36 | export async function post_process(thread, container, params = {}) { 37 | const env = thread.env; 38 | const body = container.querySelector('.sc-smart-chat-body'); 39 | 40 | if(thread.data.url) { 41 | const webview = await env.smart_components.render_component('external_chat_thread_webview', thread, params); 42 | this.empty(body); 43 | body.appendChild(webview); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/collections/external_chat_threads.js: -------------------------------------------------------------------------------- 1 | import { Collection, CollectionItem } from 'smart-collections'; 2 | import { AjsonSingleFileCollectionDataAdapter } from 'smart-collections/adapters/ajson_single_file.js'; 3 | import { murmur_hash_32_alphanumeric } from 'smart-utils/create_hash.js'; 4 | import { chat_platform_from_url } from '../utils/chat_platform.js'; 5 | 6 | /** 7 | * External chat thread metadata tracked per platform URL. 8 | */ 9 | export class ExternalChatThread extends CollectionItem { 10 | static get defaults() { 11 | return { 12 | data: { 13 | key: '', 14 | url: '', 15 | platform: 'unknown', 16 | note_path: '', 17 | status: 'active', 18 | created_at: 0, 19 | updated_at: 0, 20 | last_opened_at: 0 21 | } 22 | }; 23 | } 24 | 25 | init() { 26 | if (!this.data.key && this.data.url) { 27 | this.data.key = `ext-chat-${murmur_hash_32_alphanumeric(this.data.url)}`; 28 | } 29 | if (!this.data.created_at) this.data.created_at = Date.now(); 30 | if (!this.data.updated_at) this.data.updated_at = this.data.created_at; 31 | if (!this.data.platform || this.data.platform === 'unknown') { 32 | this.data.platform = chat_platform_from_url(this.data.url); 33 | } 34 | } 35 | 36 | mark_done() { 37 | if (this.data.status === 'done') return; 38 | this.data.status = 'done'; 39 | this.touch(); 40 | } 41 | 42 | touch() { 43 | this.data.updated_at = Date.now(); 44 | } 45 | 46 | mark_open() { 47 | this.data.last_opened_at = Date.now(); 48 | this.touch(); 49 | } 50 | } 51 | 52 | /** 53 | * Collection managing external chat threads. 54 | */ 55 | export class ExternalChatThreads extends Collection { 56 | static get collection_key() { 57 | return 'external_chat_threads'; 58 | } 59 | } 60 | 61 | export default { 62 | class: ExternalChatThreads, 63 | collection_key: ExternalChatThreads.collection_key, 64 | item_type: ExternalChatThread, 65 | data_adapter: AjsonSingleFileCollectionDataAdapter 66 | }; 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # Smart Plugins License Agreement 2 | 3 | Copyright (c) 2025, Jobsi, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) as a substantial component of any product or service that: 10 | (a) is marketed for use with, or primarily interoperates with, Obsidian or any substantially similar note-taking or knowledge-management application; and 11 | (b) is offered as a general-purpose solution to multiple unrelated customers (whether for a fee or free of charge); and 12 | (c) directly competes with any commercial offering of the Licensor based on the Software that exists at the time such product or service is first made available to third parties, meaning a typical target user would reasonably view it as a replacement or alternative because it provides substantially the same core functionality. 13 | 14 | This restriction does not limit private use, internal use within a single organization, or bespoke or client-specific implementations (including for a fee) that are not marketed or distributed as a general-purpose product or service to multiple unrelated customers. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/components/external-chat-thread/webview.js: -------------------------------------------------------------------------------- 1 | import webview_css from './webview.css' assert { type: 'css' }; 2 | 3 | const platform_labels = { 4 | chatgpt: 'ChatGPT', 5 | claude: 'Claude', 6 | grok: 'Grok', 7 | unknown: 'Chat' 8 | }; 9 | 10 | /** 11 | * Construct base HTML for the external thread webview container. 12 | * @param {import('../../collections/external_chat_threads.js').ExternalChatThread} ext_thread 13 | * @returns {string} 14 | */ 15 | const build_html = ext_thread => { 16 | const label = platform_labels[ext_thread.data.platform] || platform_labels.unknown; 17 | return `
18 |
19 |
20 | ${label} 21 | 22 |
23 |
24 |
25 |
`; 26 | }; 27 | 28 | /** 29 | * @param {import('../../collections/external_chat_threads.js').ExternalChatThread} ext_thread 30 | * @param {object} [params] 31 | * @returns {Promise} 32 | */ 33 | export async function render(ext_thread, params = {}) { 34 | const frag = this.create_doc_fragment(build_html(ext_thread)); 35 | this.apply_style_sheet(webview_css); 36 | const container = frag.querySelector('.sc-external-chat-webview'); 37 | post_process.call(this, ext_thread, container, params); 38 | return container; 39 | } 40 | 41 | /** 42 | * @param {import('../../collections/external_chat_threads.js').ExternalChatThread} ext_thread 43 | * @param {HTMLElement} container 44 | * @param {object} [params] 45 | * @returns {Promise} 46 | */ 47 | export async function post_process(ext_thread, container, params = {}) { 48 | const frame = container.querySelector('.sc-external-chat-webview-frame'); 49 | const platform = ext_thread.data.platform; 50 | const component_key = `external_chat_thread_${platform}_webview`; 51 | const webview_component = await ext_thread.env.smart_components.render_component(component_key, ext_thread, params); 52 | this.empty(frame); 53 | frame.appendChild(webview_component); 54 | return container; 55 | } 56 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file esbuild.js 3 | * @description Minimal build script for bundling your Obsidian plugin with esbuild (ESM). 4 | * Run "npm run build" or "node esbuild.js". 5 | * 6 | * This script: 7 | * 1) Bundles main.js into "smart-chatgpt.js" using esbuild. 8 | * 2) Reads .env (if present) for OBSIDIAN_PLUGIN_FOLDER. 9 | * 3) Copies the plugin files into the Obsidian plugin directory (if configured). 10 | */ 11 | 12 | import esbuild from 'esbuild'; 13 | import fs from 'fs'; 14 | import path from 'path'; 15 | import dotenv from 'dotenv'; 16 | 17 | dotenv.config(); 18 | // if directory doesn't exist, create it 19 | if(!fs.existsSync(path.join(process.cwd(), 'dist'))) { 20 | fs.mkdirSync(path.join(process.cwd(), 'dist'), { recursive: true }); 21 | } 22 | 23 | const main_path = path.join(process.cwd(), 'dist', 'main.js'); 24 | const manifest_path = path.join(process.cwd(), 'manifest.json'); 25 | const styles_path = path.join(process.cwd(), 'styles.css'); 26 | // Update manifest.json version 27 | const package_json = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); 28 | const manifest_json = JSON.parse(fs.readFileSync(manifest_path)); 29 | manifest_json.version = package_json.version; 30 | fs.writeFileSync(manifest_path, JSON.stringify(manifest_json, null, 2)); 31 | // copy manifest and styles to dist 32 | fs.copyFileSync(manifest_path, path.join(process.cwd(), 'dist', 'manifest.json')); 33 | fs.copyFileSync(styles_path, path.join(process.cwd(), 'dist', 'styles.css')); 34 | 35 | const destination_vaults = process.env.DESTINATION_VAULTS.split(','); 36 | 37 | // get first argument as entry point 38 | const entry_point = process.argv[2] || 'main.js'; 39 | 40 | // Build the project 41 | esbuild.build({ 42 | entryPoints: [entry_point], 43 | outfile: 'dist/main.js', 44 | format: 'cjs', 45 | bundle: true, 46 | write: true, 47 | sourcemap: 'inline', 48 | target: "es2022", 49 | logLevel: "info", 50 | treeShaking: true, 51 | platform: 'node', 52 | preserveSymlinks: true, 53 | external: [ 54 | 'electron', 55 | 'obsidian', 56 | 'crypto', 57 | ], 58 | define: { 59 | }, 60 | }).then(() => { 61 | console.log('Build complete'); 62 | const release_file_paths = [manifest_path, styles_path, main_path]; 63 | for(let vault of destination_vaults) { 64 | const destDir = path.join(process.cwd(), '..', vault, '.obsidian', 'plugins', 'smart-chatgpt'); 65 | console.log(`Copying files to ${destDir}`); 66 | fs.mkdirSync(destDir, { recursive: true }); 67 | // create .hotreload file if it doesn't exist 68 | if(!fs.existsSync(path.join(destDir, '.hotreload'))) { 69 | fs.writeFileSync(path.join(destDir, '.hotreload'), ''); 70 | } 71 | release_file_paths.forEach(file_path => { 72 | fs.copyFileSync(file_path, path.join(destDir, path.basename(file_path))); 73 | }); 74 | console.log(`Copied files to ${destDir}`); 75 | } 76 | }).catch(() => process.exit(1)); 77 | -------------------------------------------------------------------------------- /src/views/sc_chatgpt.obsidian.js: -------------------------------------------------------------------------------- 1 | import {ItemView} from "obsidian"; 2 | export class SmartChatGPTView extends ItemView { 3 | constructor(leaf, plugin) { 4 | super(leaf); 5 | this.app = plugin.app; 6 | this.plugin = plugin; 7 | } 8 | static get view_type() { return 'smart-chatgpt-view'; } 9 | static get display_text() { return "Smart ChatGPT"; } 10 | static get icon_name() { return "bot"; } 11 | getViewType() { return this.constructor.view_type; } 12 | getDisplayText() { return this.constructor.display_text; } 13 | getIcon() { return this.constructor.icon_name; } 14 | static is_open(workspace) { return this.get_leaf(workspace)?.view instanceof this; } 15 | static get_leaf(workspace) { return workspace.getLeavesOfType(this.view_type)?.find((leaf) => leaf.view instanceof this); } 16 | /** 17 | * Retrieves the view instance if it exists. 18 | * @param {import("obsidian").Workspace} workspace 19 | * @returns {SmartObsidianView | undefined} 20 | */ 21 | static get_view(workspace) { 22 | const leaf = this.get_leaf(workspace); 23 | return leaf ? leaf.view : undefined; 24 | } 25 | static open(workspace, active = true) { 26 | if (this.get_leaf(workspace)) this.get_leaf(workspace).setViewState({ type: this.view_type, active }); 27 | else workspace.getRightLeaf(false).setViewState({ type: this.view_type, active }); 28 | if(workspace.rightSplit.collapsed) workspace.rightSplit.toggle(); 29 | } 30 | async onOpen() { 31 | this.app.workspace.onLayoutReady(this.initialize.bind(this)); 32 | } 33 | onload() { 34 | console.log("loading view"); 35 | this.initialize(); 36 | } 37 | initialize() { 38 | this.containerEl.empty(); 39 | // Create button container for inline layout 40 | const buttonContainer = this.containerEl.createEl("div", { 41 | cls: "button-container", 42 | }); 43 | buttonContainer.style.display = "flex"; 44 | buttonContainer.style.gap = "8px"; 45 | buttonContainer.style.marginBottom = "8px"; 46 | 47 | // insert button to refresh 48 | const refreshButton = buttonContainer.createEl("button", { 49 | text: "Refresh", 50 | }); 51 | refreshButton.addEventListener("click", () => { 52 | this.initialize(); 53 | }); 54 | 55 | // insert button to copy URL 56 | const copyUrlButton = buttonContainer.createEl("button", { 57 | text: "Copy URL", 58 | }); 59 | copyUrlButton.addEventListener("click", () => { 60 | const current_url = this.frame?.getAttribute("src"); 61 | if (current_url) { 62 | navigator.clipboard.writeText(current_url); 63 | // Optional: Show a notice that URL was copied 64 | if (this.plugin) { 65 | window.smart_env?.events?.emit('chatgpt:url_copied', { url: current_url }); 66 | this.plugin.notices.show("copied_chatgpt_url_to_clipboard"); 67 | } 68 | } 69 | }); 70 | 71 | // insert ChatGPT 72 | this.containerEl.appendChild(this.create()); 73 | } 74 | 75 | create() { 76 | this.frame = document.createElement("webview", {}); 77 | this.frame.setAttribute("allowpopups", ""); 78 | this.frame.setAttribute("useragent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.191 Safari/537.36"); 79 | this.frame.setAttribute("partition", this.plugin.app.getWebviewPartition()); 80 | this.frame.style.width = "100%"; 81 | this.frame.style.height = "100%"; 82 | this.frame.setAttribute("src", "https://chatgpt.com/"); 83 | return this.frame; 84 | } 85 | } -------------------------------------------------------------------------------- /smart_chat_codeblock.js: -------------------------------------------------------------------------------- 1 | export class SmartChatCodeblock { 2 | constructor({ plugin, file, line_start, line_end, container_el, source, ctx }) { 3 | this.plugin = plugin; 4 | this.file = file; 5 | this.line_start = line_start; 6 | this.line_end = line_end; 7 | this.container_el = container_el; 8 | this.source = source; 9 | this.ctx = ctx; 10 | // overridden by subclasses 11 | this._FALLBACK_URL = 'https://smartconnections.app/?utm_source=chat-codeblock-fallback'; 12 | } 13 | 14 | /** 15 | * Insert new url line after the start 16 | */ 17 | async _insert_link_into_codeblock(url) { 18 | if (!this.file) return; 19 | const timestamp_in_seconds = Math.floor(Date.now() / 1000); 20 | const new_line = `chat-active:: ${timestamp_in_seconds} ${url}`; 21 | if(this.ctx && this.ctx.replaceCode) { 22 | // Use the codeblock cm context to insert the new line (prevents flicker) 23 | this.ctx.replaceCode(new_line + '\n' + this.source); 24 | const {text, lineStart: line_start, lineEnd: line_end} = this.ctx.getSectionInfo(this.container_el) ?? {}; 25 | const updated_source = text.split('\n').slice(line_start + 1, line_end).join('\n'); 26 | this.source = updated_source; 27 | this.links = this._extract_links(this.source); 28 | this._build_dropdown(); // re-render the dropdown 29 | return; 30 | } 31 | // @ deprecated: fallback to reading the file 32 | await this.plugin.app.vault.process(this.file, (file_data) => { 33 | const [start, end] = this._find_codeblock_boundaries(file_data); 34 | if (start < 0 || end < 0) { 35 | console.warn('Cannot find codeblock to insert link:', url); 36 | return file_data; 37 | } 38 | const lines = file_data.split('\n'); 39 | lines.splice(start + 1, 0, new_line); 40 | return lines.join('\n'); 41 | }); 42 | } 43 | 44 | /** 45 | * Creates a dropdown for links, labeling done ones with "✓". 46 | */ 47 | _build_dropdown(parent_el=null) { 48 | if (!this.dropdown_el) { 49 | if(!parent_el) throw new Error('Parent element is required to build dropdown'); 50 | this.dropdown_el = parent_el.createEl('select', { cls: 'sc-link-dropdown' }); 51 | this.dropdown_el.addEventListener('change', () => { 52 | const new_link = this.dropdown_el.value; 53 | if (this.webview_el) { 54 | this.webview_el.setAttribute('src', new_link); 55 | this.current_url = new_link; 56 | } 57 | }); 58 | } 59 | this.dropdown_el.empty(); // Clear existing options 60 | 61 | 62 | this.add_dropdown_options(); 63 | this.dropdown_el.value = this.current_url || this.initial_link; 64 | } 65 | 66 | add_dropdown_options() { 67 | const new_chat = this.dropdown_el.createEl('option'); 68 | new_chat.value = this._FALLBACK_URL; 69 | new_chat.textContent = 'New chat'; 70 | // Add links from the codeblock 71 | for (const link_obj of this.links) { 72 | const option_el = this.dropdown_el.createEl('option'); 73 | option_el.value = link_obj.url; 74 | option_el.textContent = link_obj.done 75 | ? ('✓ ' + link_obj.url) 76 | : link_obj.url; 77 | } 78 | } 79 | 80 | _init_navigation_events() { 81 | if (!this.webview_el) return; 82 | this.webview_el.addEventListener('did-finish-load', () => { 83 | this.webview_el.setAttribute('data-did-finish-load', 'true'); 84 | }); 85 | 86 | this.webview_el.addEventListener('did-navigate', (ev) => { 87 | if (ev.url) this._debounce_handle_new_url(ev.url); 88 | }); 89 | 90 | this.webview_el.addEventListener('did-navigate-in-page', (ev) => { 91 | if (ev.url) this._debounce_handle_new_url(ev.url); 92 | }); 93 | } 94 | 95 | _debounce_handle_new_url(new_url) { 96 | clearTimeout(this._nav_timer); 97 | this._nav_timer = setTimeout(() => this._handle_new_url(new_url), 300); 98 | } 99 | 100 | async _handle_new_url(new_url) { 101 | const norm_new = this._normalize_url(new_url); 102 | const norm_last = this._normalize_url(this.last_detected_url); 103 | if (norm_new === norm_last) return; 104 | 105 | this.last_detected_url = new_url; 106 | this.current_url = new_url; 107 | 108 | // Auto-save new thread link if it's recognized 109 | if (this._is_thread_link(new_url)) { 110 | const link_to_save = this._normalize_url(new_url); 111 | const already_saved = await this._check_if_saved(link_to_save); 112 | if (!already_saved) { 113 | await this._insert_link_into_codeblock(link_to_save); 114 | this.plugin.env?.events?.emit('thread:auto_saved_link', { url: link_to_save }); 115 | this.plugin.notices.show(`Auto-saved new ${this.constructor.name} thread link.`); 116 | } 117 | } 118 | this._render_save_ui(new_url); 119 | } 120 | 121 | /** 122 | * Normalises a URL by stripping query / hash. 123 | * @param {string} url 124 | * @returns {string} 125 | */ 126 | _normalize_url(url) { 127 | try { 128 | const u = new URL(url); 129 | u.search = ''; 130 | u.hash = ''; 131 | return u.toString(); 132 | } catch (_) { 133 | return url; 134 | } 135 | } 136 | 137 | /** 138 | * Injects a