note-connector 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/paths.js +4 -0
  2. package/dist/setup-dependencies.js +56 -13
  3. package/package.json +3 -2
  4. package/py/pyproject.toml +86 -0
  5. package/py/src/note_mcp/__init__.py +7 -0
  6. package/py/src/note_mcp/__main__.py +65 -0
  7. package/py/src/note_mcp/api/__init__.py +31 -0
  8. package/py/src/note_mcp/api/articles.py +1395 -0
  9. package/py/src/note_mcp/api/client.py +318 -0
  10. package/py/src/note_mcp/api/embeds.py +482 -0
  11. package/py/src/note_mcp/api/images.py +660 -0
  12. package/py/src/note_mcp/api/preview.py +142 -0
  13. package/py/src/note_mcp/api/public_notes.py +150 -0
  14. package/py/src/note_mcp/auth/__init__.py +9 -0
  15. package/py/src/note_mcp/auth/browser.py +574 -0
  16. package/py/src/note_mcp/auth/file_session.py +145 -0
  17. package/py/src/note_mcp/auth/session.py +240 -0
  18. package/py/src/note_mcp/browser/__init__.py +10 -0
  19. package/py/src/note_mcp/browser/config.py +21 -0
  20. package/py/src/note_mcp/browser/manager.py +182 -0
  21. package/py/src/note_mcp/browser/preview.py +68 -0
  22. package/py/src/note_mcp/browser/url_helpers.py +18 -0
  23. package/py/src/note_mcp/chatgpt/__init__.py +1 -0
  24. package/py/src/note_mcp/chatgpt/__main__.py +63 -0
  25. package/py/src/note_mcp/chatgpt/access_log.py +25 -0
  26. package/py/src/note_mcp/chatgpt/auth.py +52 -0
  27. package/py/src/note_mcp/chatgpt/images.py +92 -0
  28. package/py/src/note_mcp/chatgpt/login_once.py +26 -0
  29. package/py/src/note_mcp/chatgpt/middleware.py +31 -0
  30. package/py/src/note_mcp/chatgpt/tools.py +255 -0
  31. package/py/src/note_mcp/chatgpt/widgets.py +121 -0
  32. package/py/src/note_mcp/decorators.py +113 -0
  33. package/py/src/note_mcp/investigator/__init__.py +33 -0
  34. package/py/src/note_mcp/investigator/__main__.py +11 -0
  35. package/py/src/note_mcp/investigator/cli.py +313 -0
  36. package/py/src/note_mcp/investigator/core.py +653 -0
  37. package/py/src/note_mcp/investigator/mcp_tools.py +225 -0
  38. package/py/src/note_mcp/models.py +562 -0
  39. package/py/src/note_mcp/py.typed +0 -0
  40. package/py/src/note_mcp/server.py +944 -0
  41. package/py/src/note_mcp/utils/__init__.py +7 -0
  42. package/py/src/note_mcp/utils/file_parser.py +314 -0
  43. package/py/src/note_mcp/utils/html_to_markdown.py +477 -0
  44. package/py/src/note_mcp/utils/logging.py +119 -0
  45. package/py/src/note_mcp/utils/markdown.py +12 -0
  46. package/py/src/note_mcp/utils/markdown_to_html.py +826 -0
@@ -0,0 +1,145 @@
1
+ """File-based session management for Docker/headless environments.
2
+
3
+ Provides session storage using JSON files when keyring is not available.
4
+ This is a fallback mechanism for environments where system keyring
5
+ cannot be accessed (Docker containers, headless servers, etc.).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from note_mcp.models import Session
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Default session filename
22
+ SESSION_FILENAME = "session.json"
23
+
24
+
25
+ def _get_default_data_dir() -> Path:
26
+ """Get the default data directory for session storage.
27
+
28
+ Returns:
29
+ Path to data directory. Priority:
30
+ 1. NOTE_MCP_DATA_DIR environment variable
31
+ 2. /app/data (Docker)
32
+ 3. ~/.note-mcp (local)
33
+ """
34
+ # Check for environment variable first (Important #4)
35
+ env_dir = os.environ.get("NOTE_MCP_DATA_DIR")
36
+ if env_dir:
37
+ return Path(env_dir)
38
+
39
+ # Check for Docker environment
40
+ if Path("/app/data").exists() and os.access("/app/data", os.W_OK):
41
+ return Path("/app/data")
42
+
43
+ # Fall back to home directory
44
+ home = Path.home()
45
+ return home / ".note-mcp"
46
+
47
+
48
+ class FileBasedSessionManager:
49
+ """File-based session management for Docker/headless environments.
50
+
51
+ Stores session data in a JSON file instead of system keyring.
52
+ Suitable for environments where keyring is not available.
53
+
54
+ Attributes:
55
+ data_dir: Directory where session file is stored
56
+ session_file: Full path to the session JSON file
57
+ """
58
+
59
+ def __init__(self, data_dir: Path | None = None) -> None:
60
+ """Initialize FileBasedSessionManager.
61
+
62
+ Args:
63
+ data_dir: Session file storage directory.
64
+ Default: /app/data (Docker) or ~/.note-mcp (local)
65
+ """
66
+ self.data_dir = data_dir or _get_default_data_dir()
67
+ self.session_file = self.data_dir / SESSION_FILENAME
68
+
69
+ def save(self, session: Session) -> None:
70
+ """Save session to JSON file.
71
+
72
+ Args:
73
+ session: Session object to save
74
+
75
+ Raises:
76
+ OSError: If file cannot be written
77
+ """
78
+ # Ensure directory exists
79
+ self.data_dir.mkdir(parents=True, exist_ok=True)
80
+
81
+ # Write session data to file
82
+ session_data = session.model_dump()
83
+ with open(self.session_file, "w", encoding="utf-8") as f:
84
+ json.dump(session_data, f, ensure_ascii=False, indent=2)
85
+
86
+ logger.debug(f"Session saved to {self.session_file}")
87
+
88
+ def load(self) -> Session | None:
89
+ """Load session from JSON file.
90
+
91
+ Returns:
92
+ Session object if found and valid, None otherwise
93
+ """
94
+ if not self.session_file.exists():
95
+ logger.debug("No session file found")
96
+ return None
97
+
98
+ try:
99
+ with open(self.session_file, encoding="utf-8") as f:
100
+ session_data = json.load(f)
101
+
102
+ # Import here to avoid circular imports
103
+ from note_mcp.models import Session
104
+
105
+ return Session(**session_data)
106
+ except json.JSONDecodeError as e:
107
+ # File is corrupted - distinct from "no session"
108
+ logger.error(f"Session file corrupted (invalid JSON): {e}")
109
+ logger.info(f"Consider deleting corrupted file: {self.session_file}")
110
+ return None
111
+ except (TypeError, ValueError) as e:
112
+ # Invalid data structure
113
+ logger.error(f"Session file has invalid data structure: {e}")
114
+ logger.info(f"Consider deleting invalid file: {self.session_file}")
115
+ return None
116
+ except OSError as e:
117
+ logger.warning(f"Failed to read session file: {e}")
118
+ return None
119
+
120
+ def clear(self) -> bool:
121
+ """Clear session by deleting the session file.
122
+
123
+ Returns:
124
+ True if session was cleared successfully, False if deletion failed.
125
+ Returns True if no session file exists (nothing to clear).
126
+ """
127
+ if not self.session_file.exists():
128
+ logger.debug("No session file to clear")
129
+ return True
130
+
131
+ try:
132
+ self.session_file.unlink()
133
+ logger.debug(f"Session file deleted: {self.session_file}")
134
+ return True
135
+ except OSError as e:
136
+ logger.error(f"Failed to delete session file (security concern): {e}")
137
+ return False
138
+
139
+ def has_session(self) -> bool:
140
+ """Check if a session file exists.
141
+
142
+ Returns:
143
+ True if session file exists, False otherwise
144
+ """
145
+ return self.session_file.exists()
@@ -0,0 +1,240 @@
1
+ """Session management with keyring storage.
2
+
3
+ Provides secure session storage using the system keyring.
4
+ Includes diagnostic information when keyring is not available.
5
+
6
+ For Docker/headless environments where keyring is not available,
7
+ set USE_FILE_SESSION=1 to use file-based session storage.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import platform
15
+ from typing import TYPE_CHECKING
16
+
17
+ import keyring
18
+ from keyring.errors import PasswordDeleteError
19
+
20
+ from note_mcp.auth.file_session import FileBasedSessionManager
21
+ from note_mcp.models import Session
22
+
23
+ if TYPE_CHECKING:
24
+ pass
25
+
26
+
27
+ class KeyringError(Exception):
28
+ """Exception raised when keyring operations fail.
29
+
30
+ Includes diagnostic information to help users troubleshoot
31
+ keyring configuration issues.
32
+
33
+ Attributes:
34
+ message: Human-readable error message
35
+ os_info: Operating system information
36
+ backend_info: Keyring backend information
37
+ setup_instructions: Steps to configure keyring
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ message: str,
43
+ os_info: str,
44
+ backend_info: str | None = None,
45
+ setup_instructions: list[str] | None = None,
46
+ ) -> None:
47
+ """Initialize KeyringError with diagnostic information.
48
+
49
+ Args:
50
+ message: Human-readable error message
51
+ os_info: Operating system information
52
+ backend_info: Keyring backend information (if available)
53
+ setup_instructions: Steps to configure keyring
54
+ """
55
+ self.message = message
56
+ self.os_info = os_info
57
+ self.backend_info = backend_info
58
+ self.setup_instructions = setup_instructions or []
59
+ super().__init__(self._format_message())
60
+
61
+ def _format_message(self) -> str:
62
+ """Format the error message with diagnostic information."""
63
+ parts = [self.message]
64
+ parts.append(f"\nOS: {self.os_info}")
65
+ if self.backend_info:
66
+ parts.append(f"Backend: {self.backend_info}")
67
+ if self.setup_instructions:
68
+ parts.append("\nSetup instructions:")
69
+ for instruction in self.setup_instructions:
70
+ parts.append(f" - {instruction}")
71
+ return "\n".join(parts)
72
+
73
+
74
+ def _get_os_info() -> str:
75
+ """Get operating system information for diagnostics."""
76
+ return f"{platform.system()} {platform.release()}"
77
+
78
+
79
+ def _get_backend_info() -> str | None:
80
+ """Get keyring backend information for diagnostics."""
81
+ try:
82
+ backend = keyring.get_keyring()
83
+ return type(backend).__name__
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ def _get_setup_instructions() -> list[str]:
89
+ """Get setup instructions based on the current platform."""
90
+ system = platform.system()
91
+ instructions: list[str] = []
92
+
93
+ if system == "Linux":
94
+ instructions = [
95
+ "Install a keyring backend: sudo apt-get install gnome-keyring",
96
+ "Or install libsecret: sudo apt-get install libsecret-1-0",
97
+ "For headless servers, consider using keyrings.alt: pip install keyrings.alt",
98
+ "Then configure with: python -c 'import keyring; print(keyring.get_keyring())'",
99
+ ]
100
+ elif system == "Darwin":
101
+ instructions = [
102
+ "macOS should use Keychain automatically",
103
+ "If issues persist, try: security unlock-keychain",
104
+ "Check Keychain Access app for any locked keychains",
105
+ ]
106
+ elif system == "Windows":
107
+ instructions = [
108
+ "Windows should use Windows Credential Locker automatically",
109
+ "If issues persist, check Windows Credential Manager",
110
+ "Try running as administrator if access is denied",
111
+ ]
112
+ else:
113
+ instructions = [
114
+ "Install a compatible keyring backend",
115
+ "See: https://pypi.org/project/keyring/",
116
+ ]
117
+
118
+ return instructions
119
+
120
+
121
+ class SessionManager:
122
+ """Manages session storage using keyring or file-based backend.
123
+
124
+ Provides secure storage of session data in the system keyring.
125
+ When keyring is not available, provides clear diagnostic information.
126
+
127
+ For Docker/headless environments, set USE_FILE_SESSION=1 to use
128
+ file-based session storage instead of keyring.
129
+
130
+ Attributes:
131
+ service_name: The keyring service name used for storage
132
+ """
133
+
134
+ DEFAULT_SERVICE_NAME = "note-mcp"
135
+ SESSION_KEY = "session"
136
+
137
+ def __init__(self, service_name: str | None = None) -> None:
138
+ """Initialize SessionManager.
139
+
140
+ Args:
141
+ service_name: Keyring service name (default: "note-mcp")
142
+ """
143
+ self.service_name = service_name or self.DEFAULT_SERVICE_NAME
144
+
145
+ # Use file-based backend when USE_FILE_SESSION=1
146
+ if os.environ.get("USE_FILE_SESSION") == "1":
147
+ self._backend: FileBasedSessionManager | None = FileBasedSessionManager()
148
+ else:
149
+ self._backend = None # Use keyring directly
150
+
151
+ def save(self, session: Session) -> None:
152
+ """Save session to keyring or file backend.
153
+
154
+ Args:
155
+ session: Session object to save
156
+
157
+ Raises:
158
+ KeyringError: If keyring operation fails (when using keyring)
159
+ OSError: If file operation fails (when using file backend)
160
+ """
161
+ if self._backend:
162
+ self._backend.save(session)
163
+ return
164
+
165
+ try:
166
+ session_data = session.model_dump()
167
+ session_json = json.dumps(session_data)
168
+ keyring.set_password(self.service_name, self.SESSION_KEY, session_json)
169
+ except Exception as e:
170
+ raise KeyringError(
171
+ message=f"Failed to save session to keyring: {e}",
172
+ os_info=_get_os_info(),
173
+ backend_info=_get_backend_info(),
174
+ setup_instructions=_get_setup_instructions(),
175
+ ) from e
176
+
177
+ def load(self) -> Session | None:
178
+ """Load session from keyring or file backend.
179
+
180
+ Returns:
181
+ Session object if found and valid, None otherwise
182
+
183
+ Raises:
184
+ KeyringError: If keyring operation fails (when using keyring)
185
+ """
186
+ if self._backend:
187
+ return self._backend.load()
188
+
189
+ try:
190
+ session_json = keyring.get_password(self.service_name, self.SESSION_KEY)
191
+ except Exception as e:
192
+ raise KeyringError(
193
+ message=f"Failed to load session from keyring: {e}",
194
+ os_info=_get_os_info(),
195
+ backend_info=_get_backend_info(),
196
+ setup_instructions=_get_setup_instructions(),
197
+ ) from e
198
+
199
+ if session_json is None:
200
+ return None
201
+
202
+ try:
203
+ session_data = json.loads(session_json)
204
+ return Session(**session_data)
205
+ except (json.JSONDecodeError, TypeError, ValueError):
206
+ # Invalid JSON or invalid session data
207
+ return None
208
+
209
+ def clear(self) -> None:
210
+ """Clear session from keyring or file backend.
211
+
212
+ Does not raise an error if no session exists.
213
+ """
214
+ if self._backend:
215
+ self._backend.clear()
216
+ return
217
+
218
+ try:
219
+ keyring.delete_password(self.service_name, self.SESSION_KEY)
220
+ except PasswordDeleteError:
221
+ # Session didn't exist, which is fine
222
+ pass
223
+ except Exception:
224
+ # Silently ignore other errors on clear
225
+ pass
226
+
227
+ def has_session(self) -> bool:
228
+ """Check if a session exists in keyring or file backend.
229
+
230
+ Returns:
231
+ True if a valid session exists, False otherwise
232
+ """
233
+ if self._backend:
234
+ return self._backend.has_session()
235
+
236
+ try:
237
+ session_json = keyring.get_password(self.service_name, self.SESSION_KEY)
238
+ return session_json is not None
239
+ except Exception:
240
+ return False
@@ -0,0 +1,10 @@
1
+ """Browser module for note-mcp.
2
+
3
+ Provides Playwright-based browser automation for login and preview.
4
+ """
5
+
6
+ from note_mcp.browser.config import HEADLESS_ENV_VAR, get_headless_mode
7
+ from note_mcp.browser.manager import BrowserManager
8
+ from note_mcp.browser.preview import show_preview
9
+
10
+ __all__ = ["BrowserManager", "show_preview", "get_headless_mode", "HEADLESS_ENV_VAR"]
@@ -0,0 +1,21 @@
1
+ """Browser configuration utilities.
2
+
3
+ Provides configuration functions for browser automation.
4
+ """
5
+
6
+ import os
7
+
8
+ # Environment variable name for headless mode configuration
9
+ HEADLESS_ENV_VAR = "NOTE_MCP_TEST_HEADLESS"
10
+
11
+
12
+ def get_headless_mode() -> bool:
13
+ """Get headless mode from NOTE_MCP_TEST_HEADLESS environment variable.
14
+
15
+ Default: True (headless mode for CI/CD stability)
16
+ Set NOTE_MCP_TEST_HEADLESS=false to show browser window for debugging.
17
+
18
+ Returns:
19
+ True if headless mode is enabled (default)
20
+ """
21
+ return os.environ.get(HEADLESS_ENV_VAR, "true").lower() != "false"
@@ -0,0 +1,182 @@
1
+ """Browser manager for Playwright automation.
2
+
3
+ Provides singleton browser instance management with page reuse.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import atexit
10
+ import logging
11
+ from typing import TYPE_CHECKING, ClassVar
12
+
13
+ from playwright.async_api import Page, async_playwright
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ if TYPE_CHECKING:
18
+ from playwright.async_api import Browser, BrowserContext, Playwright
19
+
20
+
21
+ class BrowserManager:
22
+ """Manages Playwright browser instance as singleton.
23
+
24
+ Provides page reuse and proper cleanup on exit.
25
+ Uses asyncio.Lock for thread-safe access.
26
+
27
+ Class Attributes:
28
+ _instance: Singleton instance
29
+ _browser: Playwright Browser instance
30
+ _context: Browser context
31
+ _page: Reusable page instance
32
+ _lock: Async lock for thread-safe access
33
+ """
34
+
35
+ _instance: ClassVar[BrowserManager | None] = None
36
+ _playwright: Playwright | None = None
37
+ _browser: Browser | None = None
38
+ _context: BrowserContext | None = None
39
+ _page: Page | None = None
40
+ _lock: asyncio.Lock | None = None
41
+
42
+ def __init__(self) -> None:
43
+ """Initialize browser manager.
44
+
45
+ Should not be called directly. Use get_instance() instead.
46
+ """
47
+ pass
48
+
49
+ @classmethod
50
+ def get_instance(cls) -> BrowserManager:
51
+ """Get singleton instance of BrowserManager.
52
+
53
+ Returns:
54
+ BrowserManager singleton instance
55
+ """
56
+ if cls._instance is None:
57
+ cls._instance = cls()
58
+ cls._lock = asyncio.Lock()
59
+ # Register cleanup on exit
60
+ atexit.register(cls._sync_cleanup)
61
+ return cls._instance
62
+
63
+ @classmethod
64
+ def _sync_cleanup(cls) -> None:
65
+ """Synchronous cleanup for atexit hook."""
66
+ if cls._browser is not None:
67
+ try:
68
+ loop = asyncio.get_event_loop()
69
+ if loop.is_running():
70
+ # Schedule cleanup if loop is running
71
+ loop.create_task(cls._async_cleanup())
72
+ else:
73
+ # Run cleanup directly if loop is not running
74
+ loop.run_until_complete(cls._async_cleanup())
75
+ except RuntimeError as e:
76
+ # Only handle "no event loop" errors, re-raise others
77
+ error_msg = str(e).lower()
78
+ if "no running event loop" in error_msg or "no current event loop" in error_msg:
79
+ asyncio.run(cls._async_cleanup())
80
+ else:
81
+ raise
82
+
83
+ @classmethod
84
+ async def _async_cleanup(cls) -> None:
85
+ """Async cleanup for browser resources."""
86
+ if cls._browser is not None:
87
+ await cls._safe_close_browser()
88
+ cls._browser = None
89
+ if cls._playwright is not None:
90
+ await cls._safe_stop_playwright()
91
+ cls._playwright = None
92
+ cls._context = None
93
+ cls._page = None
94
+
95
+ @classmethod
96
+ async def _safe_close_browser(cls) -> None:
97
+ """Safely close browser, logging errors at debug level."""
98
+ try:
99
+ if cls._browser is not None:
100
+ await cls._browser.close()
101
+ except Exception as e: # noqa: BLE001 - intentionally broad for cleanup
102
+ logger.debug("Browser cleanup error (non-fatal): %s", e, exc_info=True)
103
+
104
+ @classmethod
105
+ async def _safe_stop_playwright(cls) -> None:
106
+ """Safely stop playwright, logging errors at debug level."""
107
+ try:
108
+ if cls._playwright is not None:
109
+ await cls._playwright.stop()
110
+ except Exception as e: # noqa: BLE001 - intentionally broad for cleanup
111
+ logger.debug("Playwright cleanup error (non-fatal): %s", e, exc_info=True)
112
+
113
+ async def _ensure_browser(self, headless: bool | None = None) -> None:
114
+ """Ensure browser is started.
115
+
116
+ Args:
117
+ headless: If True, run in headless mode. If False, show browser window.
118
+ If None, use the default from config (NOTE_MCP_TEST_HEADLESS env var).
119
+ """
120
+ if self._playwright is None:
121
+ self.__class__._playwright = await async_playwright().start()
122
+ playwright = self._playwright
123
+ assert playwright is not None
124
+
125
+ if self._browser is None:
126
+ if headless is None:
127
+ from note_mcp.browser.config import get_headless_mode
128
+
129
+ headless = get_headless_mode()
130
+ self.__class__._browser = await playwright.chromium.launch(headless=headless)
131
+ browser = self._browser
132
+ assert browser is not None
133
+
134
+ if self._context is None:
135
+ self.__class__._context = await browser.new_context()
136
+ context = self._context
137
+ assert context is not None
138
+
139
+ if self._page is None or self._page.is_closed():
140
+ self.__class__._page = await context.new_page()
141
+
142
+ async def get_page(self, headless: bool | None = None) -> Page:
143
+ """Get a browser page, creating if necessary.
144
+
145
+ Reuses existing page if available and not closed.
146
+ Uses lock for thread-safe access.
147
+
148
+ Args:
149
+ headless: If True, run in headless mode. If False, show browser window.
150
+ If None, use the default from config (NOTE_MCP_TEST_HEADLESS env var).
151
+
152
+ IMPORTANT: This parameter is only used when launching a NEW browser.
153
+ If a browser already exists, this parameter is SILENTLY IGNORED.
154
+ Call close() first if you need to change the headless mode.
155
+
156
+ Returns:
157
+ Playwright Page instance
158
+ """
159
+ if self._lock is None:
160
+ self.__class__._lock = asyncio.Lock()
161
+ lock = self._lock
162
+ assert lock is not None
163
+
164
+ async with lock:
165
+ # Check if existing page is still valid
166
+ if self._page is not None and not self._page.is_closed():
167
+ return self._page
168
+
169
+ # Create new browser/page if needed
170
+ await self._ensure_browser(headless=headless)
171
+ assert self._page is not None
172
+ return self._page
173
+
174
+ async def close(self) -> None:
175
+ """Close browser and cleanup resources."""
176
+ if self._lock is None:
177
+ self.__class__._lock = asyncio.Lock()
178
+ lock = self._lock
179
+ assert lock is not None
180
+
181
+ async with lock:
182
+ await self._async_cleanup()
@@ -0,0 +1,68 @@
1
+ """Browser-based article preview using API access token.
2
+
3
+ Provides functionality to show article preview in browser via API.
4
+ This approach is faster and more stable than the editor-based approach.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from playwright.async_api import Error as PlaywrightError
12
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
13
+
14
+ from note_mcp.api.articles import build_preview_url, get_preview_access_token
15
+ from note_mcp.browser.manager import BrowserManager
16
+
17
+ if TYPE_CHECKING:
18
+ from note_mcp.models import Session
19
+
20
+
21
+ async def show_preview(
22
+ session: Session,
23
+ article_key: str,
24
+ ) -> None:
25
+ """Show article preview in browser via API.
26
+
27
+ Gets preview access token via API and navigates directly
28
+ to preview URL. Faster and more stable than editor-based approach.
29
+
30
+ Args:
31
+ session: Authenticated session with username
32
+ article_key: Article key (e.g., "n1234567890ab")
33
+
34
+ Raises:
35
+ NoteAPIError: If token fetch fails
36
+ RuntimeError: If browser navigation fails
37
+ """
38
+ # Get preview access token via API
39
+ access_token = await get_preview_access_token(session, article_key)
40
+
41
+ # Build preview URL
42
+ preview_url = build_preview_url(article_key, access_token)
43
+
44
+ # Get browser page
45
+ manager = BrowserManager.get_instance()
46
+ page = await manager.get_page()
47
+
48
+ # Inject session cookies into browser context
49
+ playwright_cookies: list[dict[str, Any]] = []
50
+ for name, value in session.cookies.items():
51
+ playwright_cookies.append(
52
+ {
53
+ "name": name,
54
+ "value": value,
55
+ "domain": ".note.com",
56
+ "path": "/",
57
+ }
58
+ )
59
+ await page.context.add_cookies(playwright_cookies) # type: ignore[arg-type]
60
+
61
+ # Navigate directly to preview URL (no editor involved)
62
+ try:
63
+ await page.goto(preview_url, wait_until="domcontentloaded")
64
+ await page.wait_for_load_state("networkidle")
65
+ except PlaywrightTimeoutError as e:
66
+ raise RuntimeError(f"Preview page load timed out: {preview_url}") from e
67
+ except PlaywrightError as e:
68
+ raise RuntimeError(f"Browser navigation failed: {e}") from e
@@ -0,0 +1,18 @@
1
+ """URL helper utilities for browser automation.
2
+
3
+ Provides common URL validation and manipulation functions.
4
+ """
5
+
6
+
7
+ def validate_article_edit_url(current_url: str, article_key: str) -> bool:
8
+ """Validate that the current URL is a valid article edit page.
9
+
10
+ Args:
11
+ current_url: The current browser URL
12
+ article_key: The expected article key (e.g., "n1234567890ab")
13
+
14
+ Returns:
15
+ True if the URL is a valid article edit page, False otherwise
16
+ """
17
+ valid_patterns = [f"/notes/{article_key}", f"/n/{article_key}", "editor.note.com"]
18
+ return any(pattern in current_url for pattern in valid_patterns)
@@ -0,0 +1 @@
1
+ """ChatGPT Apps SDK connector layer for note-connector."""