note-connector 0.2.5 → 0.2.6

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 +456 -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 +557 -0
  39. package/py/src/note_mcp/py.typed +0 -0
  40. package/py/src/note_mcp/server.py +905 -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,1395 @@
1
+ """Article operations for note.com API.
2
+
3
+ Provides functions for creating, updating, and managing articles.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import html
9
+ import logging
10
+ import uuid
11
+ from collections.abc import Callable
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from note_mcp.api.client import NoteAPIClient
15
+ from note_mcp.api.embeds import resolve_embed_keys
16
+ from note_mcp.api.images import _resolve_numeric_note_id
17
+ from note_mcp.models import (
18
+ Article,
19
+ ArticleInput,
20
+ ArticleListResult,
21
+ ArticleStatus,
22
+ BulkDeletePreview,
23
+ BulkDeleteResult,
24
+ DeletePreview,
25
+ DeleteResult,
26
+ ErrorCode,
27
+ NoteAPIError,
28
+ Session,
29
+ from_api_response,
30
+ )
31
+ from note_mcp.utils import markdown_to_html
32
+
33
+ if TYPE_CHECKING:
34
+ pass
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # =============================================================================
39
+ # Issue #174: Generic API Execution Helper Functions
40
+ # =============================================================================
41
+
42
+
43
+ async def _execute_get[T](
44
+ session: Session,
45
+ endpoint: str,
46
+ response_parser: Callable[[dict[str, Any]], T],
47
+ *,
48
+ params: dict[str, Any] | None = None,
49
+ ) -> T:
50
+ """Execute GET request and parse response.
51
+
52
+ Common pattern for API operations that:
53
+ 1. Open NoteAPIClient context
54
+ 2. Execute GET request
55
+ 3. Parse response with provided parser
56
+
57
+ Args:
58
+ session: Authenticated session
59
+ endpoint: API endpoint path
60
+ response_parser: Function to parse response dict into result type
61
+ params: Optional query parameters
62
+
63
+ Returns:
64
+ Parsed result of type T
65
+ """
66
+ async with NoteAPIClient(session) as client:
67
+ response = await client.get(endpoint, params=params)
68
+ return response_parser(response)
69
+
70
+
71
+ async def _execute_post[T](
72
+ session: Session,
73
+ endpoint: str,
74
+ response_parser: Callable[[dict[str, Any]], T],
75
+ *,
76
+ payload: dict[str, Any] | None = None,
77
+ ) -> T:
78
+ """Execute POST request and parse response.
79
+
80
+ Common pattern for API operations that:
81
+ 1. Open NoteAPIClient context
82
+ 2. Execute POST request with payload
83
+ 3. Parse response with provided parser
84
+
85
+ Args:
86
+ session: Authenticated session
87
+ endpoint: API endpoint path
88
+ response_parser: Function to parse response dict into result type
89
+ payload: JSON payload for request (optional, defaults to None)
90
+
91
+ Returns:
92
+ Parsed result of type T
93
+
94
+ Raises:
95
+ NoteAPIError: If API request fails (401, 403, 404, 429, 5xx)
96
+ """
97
+ async with NoteAPIClient(session) as client:
98
+ response = await client.post(endpoint, json=payload)
99
+ return response_parser(response)
100
+
101
+
102
+ async def _execute_delete(
103
+ session: Session,
104
+ endpoint: str,
105
+ ) -> None:
106
+ """Execute DELETE request.
107
+
108
+ Common pattern for API delete operations that:
109
+ 1. Open NoteAPIClient context
110
+ 2. Execute DELETE request
111
+
112
+ Args:
113
+ session: Authenticated session
114
+ endpoint: API endpoint path
115
+
116
+ Returns:
117
+ None
118
+
119
+ Raises:
120
+ NoteAPIError: If API request fails (401, 403, 404, 429, 5xx)
121
+ """
122
+ async with NoteAPIClient(session) as client:
123
+ await client.delete(endpoint)
124
+
125
+
126
+ # =============================================================================
127
+ # Issue #114: API-only Image Insertion Helper Functions
128
+ # =============================================================================
129
+
130
+ # Default image dimensions used by note.com's editor
131
+ NOTE_DEFAULT_IMAGE_WIDTH: int = 620
132
+ NOTE_DEFAULT_IMAGE_HEIGHT: int = 457
133
+
134
+ # =============================================================================
135
+ # Issue #141: Delete Draft Constants
136
+ # =============================================================================
137
+
138
+ # Maximum pages to fetch when listing all drafts (safety limit for pagination)
139
+ # 1 page = ~10 articles, so 100 pages = ~1000 articles
140
+ DELETE_ALL_DRAFTS_MAX_PAGES: int = 100
141
+
142
+ # Number of articles to show in preview when confirm=False
143
+ DELETE_ALL_DRAFTS_PREVIEW_LIMIT: int = 10
144
+
145
+
146
+ def generate_image_html(
147
+ image_url: str,
148
+ caption: str = "",
149
+ width: int = NOTE_DEFAULT_IMAGE_WIDTH,
150
+ height: int = NOTE_DEFAULT_IMAGE_HEIGHT,
151
+ ) -> str:
152
+ """Generate note.com figure HTML for an image.
153
+
154
+ Creates HTML in the format expected by note.com's editor.
155
+ The default dimensions (620x457) match note.com's standard image size.
156
+
157
+ Args:
158
+ image_url: CDN URL of the uploaded image
159
+ caption: Optional caption text (default: empty)
160
+ width: Image width in pixels (default: 620)
161
+ height: Image height in pixels (default: 457)
162
+
163
+ Returns:
164
+ HTML string: <figure name="..." id="..."><img ...><figcaption>...</figcaption></figure>
165
+ """
166
+ element_id = str(uuid.uuid4())
167
+ # Escape caption and URL to prevent XSS attacks
168
+ escaped_caption = html.escape(caption)
169
+ escaped_url = html.escape(image_url)
170
+ return (
171
+ f'<figure name="{element_id}" id="{element_id}">'
172
+ f'<img src="{escaped_url}" alt="" width="{width}" height="{height}" '
173
+ f'contenteditable="false" draggable="false">'
174
+ f"<figcaption>{escaped_caption}</figcaption></figure>"
175
+ )
176
+
177
+
178
+ def append_image_to_body(existing_body: str, image_html: str) -> str:
179
+ """Append image HTML to article body.
180
+
181
+ Simply appends the image HTML to the end of the existing body.
182
+ Use this when inserting images via API without browser automation.
183
+
184
+ Args:
185
+ existing_body: Current HTML body of the article
186
+ image_html: Generated figure HTML to append
187
+
188
+ Returns:
189
+ Updated HTML body with image appended at the end
190
+ """
191
+ return existing_body + image_html
192
+
193
+
194
+ async def get_article_raw_html(
195
+ session: Session,
196
+ article_id: str,
197
+ ) -> Article:
198
+ """Get article with raw HTML body (no conversion to Markdown).
199
+
200
+ Unlike get_article(), this returns the HTML body as-is without
201
+ converting to Markdown. Use this when you need to manipulate
202
+ the HTML content directly (e.g., appending image HTML).
203
+
204
+ Args:
205
+ session: Authenticated session
206
+ article_id: Article key (e.g., "n1234567890ab").
207
+ Note: Key format is required due to note.com API limitations.
208
+ The /v3/notes/ endpoint does not support numeric IDs.
209
+
210
+ Returns:
211
+ Article object with raw HTML body
212
+
213
+ Raises:
214
+ NoteAPIError: If API request fails or numeric ID is provided
215
+ """
216
+ # Issue #154: /v3/notes/ endpoint does not support numeric IDs
217
+ if article_id.isdigit():
218
+ raise NoteAPIError(
219
+ code=ErrorCode.INVALID_INPUT,
220
+ message=(
221
+ f"Numeric article ID '{article_id}' is not supported. "
222
+ "Please use the article key format (e.g., 'n1234567890ab'). "
223
+ "You can get the article key from create_draft() or list_articles()."
224
+ ),
225
+ details={"article_id": article_id},
226
+ )
227
+
228
+ article = await _execute_get(
229
+ session,
230
+ f"/v3/notes/{article_id}",
231
+ _parse_article_response,
232
+ )
233
+
234
+ # Issue #209: Check if article was deleted
235
+ # note.com API returns status='deleted' instead of 404 for deleted articles.
236
+ # We treat this as ARTICLE_NOT_FOUND because the content is no longer accessible.
237
+ if article.status == ArticleStatus.DELETED:
238
+ raise NoteAPIError(
239
+ code=ErrorCode.ARTICLE_NOT_FOUND,
240
+ message="Article has been deleted (status='deleted')",
241
+ details={"article_id": article_id},
242
+ )
243
+
244
+ return article
245
+
246
+
247
+ async def update_article_raw_html(
248
+ session: Session,
249
+ article_id: str,
250
+ title: str,
251
+ html_body: str,
252
+ tags: list[str] | None = None,
253
+ ) -> Article:
254
+ """Update article with raw HTML body (no Markdown conversion).
255
+
256
+ Unlike update_article(), this saves the HTML body directly without
257
+ converting from Markdown. Use this when the body is already in HTML
258
+ format (e.g., after appending image HTML).
259
+
260
+ Args:
261
+ session: Authenticated session
262
+ article_id: ID of the article to update
263
+ title: Article title
264
+ html_body: HTML body content (not Markdown)
265
+ tags: Optional list of tags
266
+
267
+ Returns:
268
+ Updated Article object
269
+
270
+ Raises:
271
+ NoteAPIError: If API request fails
272
+ """
273
+ # Resolve to numeric ID (API requirement)
274
+ numeric_id = await _resolve_numeric_note_id(session, article_id)
275
+
276
+ # Build payload with raw HTML body (no conversion)
277
+ payload: dict[str, Any] = {
278
+ "name": title,
279
+ "body": html_body,
280
+ "body_length": len(html_body),
281
+ "index": False,
282
+ "is_lead_form": False,
283
+ }
284
+
285
+ # Add tags if provided
286
+ hashtags = _normalize_tags(tags)
287
+ if hashtags:
288
+ payload["hashtags"] = hashtags
289
+
290
+ return await _execute_post(
291
+ session,
292
+ f"/v1/text_notes/draft_save?id={numeric_id}&is_temp_saved=true",
293
+ _create_draft_save_parser(article_id, numeric_id, title, html_body),
294
+ payload=payload,
295
+ )
296
+
297
+
298
+ def _parse_article_response(response: dict[str, Any]) -> Article:
299
+ """Parse API response and convert to Article.
300
+
301
+ Handles the common pattern of extracting article data from
302
+ API response and converting it to Article model.
303
+
304
+ Args:
305
+ response: Raw API response dict with "data" key
306
+
307
+ Returns:
308
+ Article object parsed from response data
309
+
310
+ Raises:
311
+ NoteAPIError: If "data" key is missing from response
312
+ """
313
+ article_data = response.get("data")
314
+ if article_data is None:
315
+ raise NoteAPIError(
316
+ code=ErrorCode.API_ERROR,
317
+ message="Invalid API response: missing 'data' key",
318
+ details={"response": response},
319
+ )
320
+ return from_api_response(article_data)
321
+
322
+
323
+ def _normalize_tags(tags: list[str] | None) -> list[dict[str, Any]] | None:
324
+ """Normalize tags to API format for draft_save.
325
+
326
+ Removes leading '#' and converts to hashtag dict format.
327
+ This format is used by POST /v1/text_notes/draft_save.
328
+
329
+ Args:
330
+ tags: List of tags (may include '#' prefix)
331
+
332
+ Returns:
333
+ List of hashtag dicts for API, or None if no tags
334
+ """
335
+ if not tags:
336
+ return None
337
+ normalized = [tag.lstrip("#") for tag in tags]
338
+ return [{"hashtag": {"name": tag}} for tag in normalized]
339
+
340
+
341
+ def _normalize_tags_for_publish(tags: list[str] | None) -> list[str] | None:
342
+ """Normalize tags to API format for publish.
343
+
344
+ Ensures tags have '#' prefix as required by PUT /v1/text_notes/{id}.
345
+ This format is used when publishing articles.
346
+
347
+ Args:
348
+ tags: List of tags (may or may not include '#' prefix)
349
+
350
+ Returns:
351
+ List of hashtag strings with '#' prefix, or None if no tags
352
+ """
353
+ if not tags:
354
+ return None
355
+ return [f"#{tag.lstrip('#')}" for tag in tags]
356
+
357
+
358
+ def _build_article_payload(
359
+ article_input: ArticleInput,
360
+ html_body: str | None = None,
361
+ include_body: bool = True,
362
+ ) -> dict[str, Any]:
363
+ """Build common article payload for API requests.
364
+
365
+ Args:
366
+ article_input: Article content and metadata
367
+ html_body: Pre-converted HTML body (optional)
368
+ include_body: Whether to include body in payload
369
+
370
+ Returns:
371
+ Payload dict for note.com API
372
+ """
373
+ payload: dict[str, Any] = {
374
+ "name": article_input.title,
375
+ "index": False,
376
+ "is_lead_form": False,
377
+ }
378
+
379
+ if include_body and html_body is not None:
380
+ payload["body"] = html_body
381
+ payload["body_length"] = len(html_body)
382
+
383
+ hashtags = _normalize_tags(article_input.tags)
384
+ if hashtags:
385
+ payload["hashtags"] = hashtags
386
+
387
+ return payload
388
+
389
+
390
+ def _is_article_key_format(article_id: str) -> bool:
391
+ """Check if article_id is in key format (e.g., "n12345abcdef").
392
+
393
+ Key format starts with "n" followed by alphanumeric characters.
394
+ Pure numeric IDs (e.g., "12345") are NOT considered keys.
395
+
396
+ Args:
397
+ article_id: Article identifier to check
398
+
399
+ Returns:
400
+ True if article_id is in key format, False otherwise
401
+ """
402
+ return article_id.startswith("n") and not article_id.isdigit()
403
+
404
+
405
+ def _validate_draft_save_response(
406
+ response: dict[str, Any],
407
+ article_id: str,
408
+ ) -> None:
409
+ """Validate draft_save API response.
410
+
411
+ Issue #155: draft_save returns {result, note_days_count, updated_at},
412
+ not full article data. We validate by checking for "result" field.
413
+
414
+ Args:
415
+ response: Raw API response dict
416
+ article_id: Article ID for error context
417
+
418
+ Raises:
419
+ NoteAPIError: If response is invalid or missing required fields
420
+ """
421
+ article_data = response.get("data", {})
422
+ if not article_data or "result" not in article_data:
423
+ raise NoteAPIError(
424
+ code=ErrorCode.API_ERROR,
425
+ message="Article update failed: API returned empty response",
426
+ details={"article_id": article_id, "response": response},
427
+ )
428
+
429
+
430
+ def _create_draft_save_parser(
431
+ article_id: str,
432
+ numeric_id: str,
433
+ title: str,
434
+ html_body: str,
435
+ article_key: str = "",
436
+ ) -> Callable[[dict[str, Any]], Article]:
437
+ """Create a parser for draft_save response.
438
+
439
+ Issue #174: Factory function to create response parser with context.
440
+ draft_save returns minimal response, so we construct Article from inputs.
441
+
442
+ Args:
443
+ article_id: Original article ID (for error context)
444
+ numeric_id: Resolved numeric ID
445
+ title: Article title
446
+ html_body: HTML body content
447
+ article_key: Article key (optional, derived from article_id if not provided)
448
+
449
+ Returns:
450
+ Parser function that validates response and returns Article
451
+ """
452
+
453
+ def parser(response: dict[str, Any]) -> Article:
454
+ _validate_draft_save_response(response, article_id)
455
+ key = article_key if article_key else (article_id if _is_article_key_format(article_id) else "")
456
+ return Article(
457
+ id=numeric_id,
458
+ key=key,
459
+ title=title,
460
+ body=html_body,
461
+ status=ArticleStatus.DRAFT,
462
+ )
463
+
464
+ return parser
465
+
466
+
467
+ def _parse_create_response(response: dict[str, Any]) -> tuple[str, str, dict[str, Any]]:
468
+ """Parse response from article creation endpoint.
469
+
470
+ Issue #174: Extract article_id and article_key from create response.
471
+
472
+ Args:
473
+ response: Raw API response from /v1/text_notes
474
+
475
+ Returns:
476
+ Tuple of (article_id, article_key, article_data)
477
+
478
+ Raises:
479
+ NoteAPIError: If required fields are missing
480
+ """
481
+ article_data = response.get("data", {})
482
+ article_id = article_data.get("id")
483
+ article_key = article_data.get("key")
484
+
485
+ if not article_id:
486
+ raise NoteAPIError(
487
+ code=ErrorCode.API_ERROR,
488
+ message="Article creation failed: API returned no article ID",
489
+ details={"response": response},
490
+ )
491
+ if not article_key:
492
+ raise NoteAPIError(
493
+ code=ErrorCode.API_ERROR,
494
+ message="Article creation failed: API returned no article key",
495
+ details={"article_id": article_id, "response": response},
496
+ )
497
+
498
+ return str(article_id), str(article_key), article_data
499
+
500
+
501
+ async def create_draft(
502
+ session: Session,
503
+ article_input: ArticleInput,
504
+ ) -> Article:
505
+ """Create a new draft article.
506
+
507
+ Uses the note.com API to create the draft directly.
508
+ Converts Markdown body to HTML as required by the API.
509
+
510
+ Note: This function performs multiple API calls:
511
+ 1. POST /v1/text_notes - Creates the article entry (without body)
512
+ 2. GET /v2/embed_by_external_api - For each embed URL, fetches server key
513
+ 3. POST /v1/text_notes/draft_save - Saves the body content with resolved keys
514
+
515
+ The body is sent only via draft_save to preserve HTML structure.
516
+ Embed URLs (YouTube, Twitter, note.com) are processed to obtain
517
+ server-registered keys required for iframe rendering.
518
+
519
+ Args:
520
+ session: Authenticated session
521
+ article_input: Article content and metadata
522
+
523
+ Returns:
524
+ Created Article object
525
+
526
+ Raises:
527
+ NoteAPIError: If API request fails
528
+ """
529
+ # Convert Markdown to HTML for API (embeds get random keys initially)
530
+ html_body = markdown_to_html(article_input.body)
531
+
532
+ # Step 1 payload: without body to avoid sanitization
533
+ create_payload = _build_article_payload(article_input, include_body=False)
534
+
535
+ # Step 1: Create the article entry (without body)
536
+ # The body is saved separately via draft_save to preserve <br> tags
537
+ article_id, article_key, article_data = await _execute_post(
538
+ session,
539
+ "/v1/text_notes",
540
+ _parse_create_response,
541
+ payload=create_payload,
542
+ )
543
+
544
+ # Step 2: Resolve embed keys via API
545
+ # Replace random keys with server-registered keys for iframe rendering
546
+ resolved_html = await resolve_embed_keys(session, html_body, article_key)
547
+
548
+ # Step 3: Save the body content with draft_save
549
+ # Use resolved HTML with server-registered embed keys
550
+ save_payload = _build_article_payload(article_input, resolved_html)
551
+
552
+ async with NoteAPIClient(session) as client:
553
+ await client.post(
554
+ f"/v1/text_notes/draft_save?id={article_id}&is_temp_saved=true",
555
+ json=save_payload,
556
+ )
557
+
558
+ # Parse response
559
+ # Note: POST /v1/text_notes returns empty 'status' field for newly created articles.
560
+ # Since this function specifically creates drafts, we set status to 'draft' explicitly.
561
+ # This is Article 6 compliant: we know the expected state from the function's semantics.
562
+ status_str = article_data.get("status")
563
+ if not status_str:
564
+ logger.warning(
565
+ "create_draft API returned empty status, setting to 'draft'. Response: %s",
566
+ article_data,
567
+ )
568
+ article_data["status"] = ArticleStatus.DRAFT.value
569
+
570
+ return from_api_response(article_data)
571
+
572
+
573
+ async def update_article(
574
+ session: Session,
575
+ article_id: str,
576
+ article_input: ArticleInput,
577
+ ) -> Article:
578
+ """Update an existing article.
579
+
580
+ Uses the note.com API to update the article.
581
+ Converts Markdown body to HTML as required by the API.
582
+ Embed URLs (YouTube, Twitter, note.com) are processed to obtain
583
+ server-registered keys required for iframe rendering.
584
+
585
+ Args:
586
+ session: Authenticated session
587
+ article_id: ID of the article to update (numeric or key format)
588
+ article_input: New article content and metadata
589
+
590
+ Returns:
591
+ Updated Article object
592
+
593
+ Raises:
594
+ NoteAPIError: If API request fails
595
+ """
596
+ from note_mcp.api.embeds import _EMBED_FIGURE_PATTERN
597
+
598
+ # Resolve to numeric ID (API requirement)
599
+ numeric_id = await _resolve_numeric_note_id(session, article_id)
600
+
601
+ # Convert Markdown to HTML for API (embeds get random keys initially)
602
+ html_body = markdown_to_html(article_input.body)
603
+
604
+ # Check if HTML contains embeds that need key resolution
605
+ # Issue #146: Only fetch article key when embeds are present
606
+ has_embeds = bool(_EMBED_FIGURE_PATTERN.search(html_body))
607
+
608
+ # Determine final HTML and article key for result construction
609
+ final_html = html_body
610
+ article_key_for_result = article_id if _is_article_key_format(article_id) else ""
611
+
612
+ if has_embeds:
613
+ # Resolve article key for embed resolution
614
+ article_key = article_id if _is_article_key_format(article_id) else ""
615
+
616
+ if not article_key:
617
+ # Numeric ID: fetch article to get key since draft_save doesn't return it
618
+ # Issue #155: draft_save returns {result, note_days_count, updated_at}, not article data
619
+ fetched_article = await get_article_via_api(session, str(numeric_id))
620
+ article_key = fetched_article.key
621
+ # Preserve fetched key in result (Issue #155 review feedback)
622
+ article_key_for_result = article_key
623
+
624
+ if article_key:
625
+ # Resolve embed keys via API
626
+ # Replace random keys with server-registered keys for iframe rendering
627
+ final_html = await resolve_embed_keys(session, html_body, str(article_key))
628
+ else:
629
+ # Fallback: proceed without embed resolution if key not available
630
+ logger.warning(
631
+ "Embed resolution skipped: article does not have a key. Embeds in article %s may not render correctly.",
632
+ article_id,
633
+ extra={"article_id": article_id},
634
+ )
635
+
636
+ # Build payload and save via draft_save endpoint
637
+ payload = _build_article_payload(article_input, final_html)
638
+
639
+ return await _execute_post(
640
+ session,
641
+ f"/v1/text_notes/draft_save?id={numeric_id}&is_temp_saved=true",
642
+ _create_draft_save_parser(
643
+ article_id,
644
+ numeric_id,
645
+ article_input.title,
646
+ final_html,
647
+ article_key_for_result,
648
+ ),
649
+ payload=payload,
650
+ )
651
+
652
+
653
+ async def get_article_via_api(
654
+ session: Session,
655
+ article_id: str,
656
+ ) -> Article:
657
+ """Get article content by ID via API.
658
+
659
+ Retrieves article content directly from the note.com API.
660
+ Faster and more reliable than browser-based retrieval.
661
+
662
+ Args:
663
+ session: Authenticated session
664
+ article_id: Article key (e.g., "n1234567890ab").
665
+ Note: Key format is required due to note.com API limitations.
666
+ The /v3/notes/ endpoint does not support numeric IDs.
667
+
668
+ Returns:
669
+ Article object with title, body (as Markdown), and status
670
+
671
+ Raises:
672
+ NoteAPIError: If API request fails or numeric ID is provided
673
+ """
674
+ # Issue #154: /v3/notes/ endpoint does not support numeric IDs
675
+ if article_id.isdigit():
676
+ raise NoteAPIError(
677
+ code=ErrorCode.INVALID_INPUT,
678
+ message=(
679
+ f"Numeric article ID '{article_id}' is not supported. "
680
+ "Please use the article key format (e.g., 'n1234567890ab'). "
681
+ "You can get the article key from create_draft() or list_articles()."
682
+ ),
683
+ details={"article_id": article_id},
684
+ )
685
+
686
+ from note_mcp.utils.html_to_markdown import html_to_markdown
687
+
688
+ article = await _execute_get(
689
+ session,
690
+ f"/v3/notes/{article_id}",
691
+ _parse_article_response,
692
+ )
693
+
694
+ # Issue #209: Check if article was deleted
695
+ # note.com API returns status='deleted' instead of 404 for deleted articles.
696
+ # We treat this as ARTICLE_NOT_FOUND because the content is no longer accessible.
697
+ if article.status == ArticleStatus.DELETED:
698
+ raise NoteAPIError(
699
+ code=ErrorCode.ARTICLE_NOT_FOUND,
700
+ message="Article has been deleted (status='deleted')",
701
+ details={"article_id": article_id},
702
+ )
703
+
704
+ # Convert HTML body to Markdown for consistent output
705
+ if article.body:
706
+ article = Article(
707
+ id=article.id,
708
+ key=article.key,
709
+ title=article.title,
710
+ body=html_to_markdown(article.body),
711
+ status=article.status,
712
+ tags=article.tags,
713
+ eyecatch_image_key=article.eyecatch_image_key,
714
+ prev_access_key=article.prev_access_key,
715
+ created_at=article.created_at,
716
+ updated_at=article.updated_at,
717
+ published_at=article.published_at,
718
+ url=article.url,
719
+ )
720
+
721
+ return article
722
+
723
+
724
+ async def get_article(
725
+ session: Session,
726
+ article_id: str,
727
+ ) -> Article:
728
+ """Get article content by ID.
729
+
730
+ Retrieves article content via API.
731
+ Use this to retrieve existing content before editing.
732
+
733
+ Recommended workflow:
734
+ 1. get_article(article_id) - retrieve current content
735
+ 2. Edit content as needed
736
+ 3. update_article(article_id, ...) - save changes
737
+
738
+ Args:
739
+ session: Authenticated session
740
+ article_id: ID of the article to retrieve
741
+
742
+ Returns:
743
+ Article object with title, body (as Markdown), and status
744
+
745
+ Raises:
746
+ NoteAPIError: If API request fails
747
+ """
748
+ return await get_article_via_api(session, article_id)
749
+
750
+
751
+ async def list_articles(
752
+ session: Session,
753
+ status: ArticleStatus | None = None,
754
+ page: int = 1,
755
+ limit: int = 10,
756
+ ) -> ArticleListResult:
757
+ """List articles for the authenticated user.
758
+
759
+ Uses the note_list/contents endpoint which returns both drafts and
760
+ published articles for the authenticated user.
761
+
762
+ Args:
763
+ session: Authenticated session
764
+ status: Filter by article status (draft, published, or None for all)
765
+ page: Page number (1-indexed)
766
+ limit: Number of articles per page (max 10)
767
+
768
+ Returns:
769
+ ArticleListResult containing articles and pagination info
770
+
771
+ Raises:
772
+ NoteAPIError: If API request fails
773
+ """
774
+ # Build query parameters for note_list endpoint
775
+ # This endpoint returns both drafts and published articles
776
+ params: dict[str, Any] = {
777
+ "page": page,
778
+ }
779
+
780
+ # Add status filter if specified
781
+ # Note: The note_list endpoint uses "publish_status" parameter
782
+ if status is not None:
783
+ params["publish_status"] = status.value
784
+
785
+ # Use note_list/contents endpoint for authenticated user's articles
786
+ # This endpoint requires authentication and returns both drafts and published
787
+ async with NoteAPIClient(session) as client:
788
+ response = await client.get("/v2/note_list/contents", params=params)
789
+
790
+ # Parse response
791
+ data = response.get("data", {})
792
+
793
+ # The endpoint returns notes (not contents) in data
794
+ contents = data.get("notes", [])
795
+ total_count = data.get("totalCount", len(contents))
796
+ is_last_page = data.get("isLastPage", True)
797
+
798
+ # Convert each article
799
+ articles: list[Article] = []
800
+ for item in contents:
801
+ article = from_api_response(item)
802
+ articles.append(article)
803
+
804
+ # Apply limit client-side if needed
805
+ articles = articles[:limit]
806
+
807
+ return ArticleListResult(
808
+ articles=articles,
809
+ total=total_count,
810
+ page=page,
811
+ has_more=not is_last_page,
812
+ )
813
+
814
+
815
+ async def publish_article(
816
+ session: Session,
817
+ article_id: str | None = None,
818
+ article_input: ArticleInput | None = None,
819
+ tags: list[str] | None = None,
820
+ ) -> Article:
821
+ """Publish an article.
822
+
823
+ Either publishes an existing draft or creates and publishes a new article.
824
+
825
+ Args:
826
+ session: Authenticated session
827
+ article_id: ID of existing draft to publish (mutually exclusive with article_input)
828
+ article_input: New article content to create and publish (mutually exclusive with article_id)
829
+ tags: Tags to set on the article when publishing an existing draft (optional).
830
+ For new articles, use article_input.tags instead.
831
+
832
+ Returns:
833
+ Published Article object
834
+
835
+ Raises:
836
+ ValueError: If neither or both article_id and article_input are provided
837
+ NoteAPIError: If API request fails
838
+ """
839
+ if article_id is None and article_input is None:
840
+ raise ValueError("Either article_id or article_input must be provided")
841
+
842
+ if article_id is not None and article_input is not None:
843
+ raise ValueError("Cannot provide both article_id and article_input")
844
+
845
+ if article_id is not None:
846
+ # Publish existing draft
847
+ # Issue #250: Validate article_id format BEFORE making any API calls
848
+ # to prevent data inconsistency (article published but error returned)
849
+ if article_id.isdigit():
850
+ raise NoteAPIError(
851
+ code=ErrorCode.INVALID_INPUT,
852
+ message=(
853
+ f"Numeric article ID '{article_id}' is not supported. "
854
+ "Please use the article key format (e.g., 'n1234567890ab')."
855
+ ),
856
+ details={"article_id": article_id},
857
+ )
858
+
859
+ # Issue #250: Use PUT /v1/text_notes/{numeric_id} instead of
860
+ # non-existent POST /v3/notes/{id}/publish endpoint
861
+ numeric_id = await _resolve_numeric_note_id(session, article_id)
862
+
863
+ async with NoteAPIClient(session) as client:
864
+ # Fetch article title (required for both draft_save and PUT)
865
+ article_response = await client.get(f"/v3/notes/{article_id}")
866
+ article_data = article_response.get("data", {})
867
+ # For drafts, title is in note_draft.name; for published, it's in name
868
+ article_title = article_data.get("name", "")
869
+ if not article_title:
870
+ note_draft = article_data.get("note_draft")
871
+ if isinstance(note_draft, dict):
872
+ article_title = note_draft.get("name", "")
873
+ # For drafts, prefer note_draft.body which has full HTML including headings
874
+ # data.body may be a stripped/sanitized version
875
+ # Use `or ""` to handle None values (key exists but value is None)
876
+ note_draft = article_data.get("note_draft")
877
+ if isinstance(note_draft, dict) and note_draft.get("body"):
878
+ article_body = note_draft.get("body") or ""
879
+ else:
880
+ article_body = article_data.get("body") or ""
881
+
882
+ # Publish the article
883
+ # Issue #252: PUT /v1/text_notes/{id} requires 'free_body' (not 'body')
884
+ # and hashtags in ["#tag1", "#tag2"] format (not dict format)
885
+ payload: dict[str, Any] = {
886
+ "name": article_title,
887
+ "free_body": article_body,
888
+ "body_length": len(article_body),
889
+ "status": "published",
890
+ "index": False,
891
+ }
892
+
893
+ # Add tags if provided (using publish format with # prefix)
894
+ if tags:
895
+ hashtags = _normalize_tags_for_publish(tags)
896
+ if hashtags:
897
+ payload["hashtags"] = hashtags
898
+
899
+ response = await client.put(f"/v1/text_notes/{numeric_id}", json=payload)
900
+
901
+ # Validate API response for logical failure
902
+ data = response.get("data", {})
903
+ if data.get("result") is False:
904
+ raise NoteAPIError(
905
+ code=ErrorCode.API_ERROR,
906
+ message="Failed to publish article: API returned failure",
907
+ details={"article_id": article_id, "response": response},
908
+ )
909
+
910
+ return await get_article_via_api(session, article_id)
911
+
912
+ # Create and publish new article
913
+ assert article_input is not None # Type narrowing
914
+ html_body = markdown_to_html(article_input.body)
915
+
916
+ new_article_payload: dict[str, Any] = {
917
+ "name": article_input.title,
918
+ "body": html_body,
919
+ "status": "published",
920
+ }
921
+
922
+ # Add tags if present (using dict format for /v3/notes endpoint)
923
+ new_article_hashtags = _normalize_tags(article_input.tags)
924
+ if new_article_hashtags:
925
+ new_article_payload["hashtags"] = new_article_hashtags
926
+
927
+ return await _execute_post(
928
+ session,
929
+ "/v3/notes",
930
+ _parse_article_response,
931
+ payload=new_article_payload,
932
+ )
933
+
934
+
935
+ # =============================================================================
936
+ # Issue #134: Preview Access Token Functions
937
+ # =============================================================================
938
+
939
+
940
+ async def get_preview_access_token(
941
+ session: Session,
942
+ article_key: str,
943
+ ) -> str:
944
+ """Get preview access token for a draft article.
945
+
946
+ Calls the note.com API to obtain a preview access token that allows
947
+ viewing draft articles without editor access.
948
+
949
+ Args:
950
+ session: Authenticated session
951
+ article_key: Article key (e.g., "n1234567890ab")
952
+
953
+ Returns:
954
+ 32-character hex preview access token
955
+
956
+ Raises:
957
+ NoteAPIError: If API request fails or token is missing from response
958
+
959
+ Example:
960
+ token = await get_preview_access_token(session, "n1234567890ab")
961
+ url = build_preview_url("n1234567890ab", token)
962
+ """
963
+ async with NoteAPIClient(session) as client:
964
+ response = await client.post(
965
+ f"/v2/notes/{article_key}/access_tokens",
966
+ json={"key": article_key},
967
+ )
968
+
969
+ data = response.get("data", {})
970
+ token = data.get("preview_access_token")
971
+
972
+ if not token:
973
+ raise NoteAPIError(
974
+ code=ErrorCode.API_ERROR,
975
+ message=(
976
+ "Failed to get preview access token. "
977
+ "Possible causes: article does not exist, article is already published, "
978
+ "or insufficient permissions."
979
+ ),
980
+ details={"article_key": article_key, "response": response},
981
+ )
982
+
983
+ return str(token)
984
+
985
+
986
+ def build_preview_url(article_key: str, preview_access_token: str) -> str:
987
+ """Build direct preview URL from access token.
988
+
989
+ Constructs a URL that allows direct access to the draft article preview
990
+ without going through the editor UI.
991
+
992
+ Args:
993
+ article_key: Article key (e.g., "n1234567890ab")
994
+ preview_access_token: 32-character hex token from API
995
+
996
+ Returns:
997
+ Direct preview URL
998
+
999
+ Example:
1000
+ url = build_preview_url("n123abc", "token123...")
1001
+ # url = "https://note.com/preview/n123abc?prev_access_key=token123..."
1002
+ """
1003
+ return f"https://note.com/preview/{article_key}?prev_access_key={preview_access_token}"
1004
+
1005
+
1006
+ # =============================================================================
1007
+ # Issue #141: Delete Draft Functions
1008
+ # =============================================================================
1009
+
1010
+
1011
+ async def delete_draft(
1012
+ session: Session,
1013
+ article_key: str,
1014
+ *,
1015
+ confirm: bool = False,
1016
+ ) -> DeleteResult | DeletePreview:
1017
+ """Delete a draft article.
1018
+
1019
+ Deletes a draft article from note.com. Only draft articles can be deleted;
1020
+ published articles will raise an error.
1021
+
1022
+ This function implements a two-step confirmation flow:
1023
+ 1. When confirm=False: Returns a DeletePreview with article info
1024
+ 2. When confirm=True: Actually deletes the article
1025
+
1026
+ Args:
1027
+ session: Authenticated session
1028
+ article_key: Key of the article to delete (format: nXXXXXXXXXXXX)
1029
+ confirm: Confirmation flag (must be True to execute deletion)
1030
+
1031
+ Returns:
1032
+ DeletePreview when confirm=False (shows what will be deleted)
1033
+ DeleteResult when confirm=True (deletion result)
1034
+
1035
+ Raises:
1036
+ NoteAPIError: If article is published, not found, or API fails
1037
+
1038
+ Example:
1039
+ # Step 1: Preview what will be deleted
1040
+ preview = await delete_draft(session, "n1234567890ab", confirm=False)
1041
+ print(f"Will delete: {preview.article_title}")
1042
+
1043
+ # Step 2: Actually delete
1044
+ result = await delete_draft(session, "n1234567890ab", confirm=True)
1045
+ print(f"Deleted: {result.message}")
1046
+ """
1047
+ return await delete_article(session, article_key, confirm=confirm, allow_published=False)
1048
+
1049
+
1050
+ async def delete_article(
1051
+ session: Session,
1052
+ article_key: str,
1053
+ *,
1054
+ confirm: bool = False,
1055
+ allow_published: bool = True,
1056
+ ) -> DeleteResult | DeletePreview:
1057
+ """Delete an article (draft or published).
1058
+
1059
+ Deletes an article from note.com. By default, both draft and published
1060
+ articles can be deleted. Set allow_published=False to restrict to drafts only.
1061
+
1062
+ This function implements a two-step confirmation flow:
1063
+ 1. When confirm=False: Returns a DeletePreview with article info
1064
+ 2. When confirm=True: Actually deletes the article
1065
+
1066
+ Args:
1067
+ session: Authenticated session
1068
+ article_key: Key of the article to delete (format: nXXXXXXXXXXXX)
1069
+ confirm: Confirmation flag (must be True to execute deletion)
1070
+ allow_published: If False, raises error when article is published
1071
+
1072
+ Returns:
1073
+ DeletePreview when confirm=False (shows what will be deleted)
1074
+ DeleteResult when confirm=True (deletion result)
1075
+
1076
+ Raises:
1077
+ NoteAPIError: If article is published (and allow_published=False),
1078
+ not found, or API fails
1079
+
1080
+ Example:
1081
+ # Step 1: Preview what will be deleted
1082
+ preview = await delete_article(session, "n1234567890ab", confirm=False)
1083
+ print(f"Will delete: {preview.article_title}")
1084
+
1085
+ # Step 2: Actually delete
1086
+ result = await delete_article(session, "n1234567890ab", confirm=True)
1087
+ print(f"Deleted: {result.message}")
1088
+ """
1089
+ # Import here to avoid circular imports
1090
+ from note_mcp.models import (
1091
+ DELETE_ERROR_PUBLISHED_ARTICLE,
1092
+ DeletePreview,
1093
+ DeleteResult,
1094
+ )
1095
+
1096
+ # Step 1: Fetch article info to validate and get details
1097
+ article = await _execute_get(
1098
+ session,
1099
+ f"/v3/notes/{article_key}",
1100
+ _parse_article_response,
1101
+ )
1102
+
1103
+ # Check if article is published and not allowed
1104
+ if article.status == ArticleStatus.PUBLISHED and not allow_published:
1105
+ raise NoteAPIError(
1106
+ code=ErrorCode.API_ERROR,
1107
+ message=DELETE_ERROR_PUBLISHED_ARTICLE,
1108
+ details={"article_key": article_key, "status": article.status.value},
1109
+ )
1110
+
1111
+ # Issue #209: Check if article was already deleted
1112
+ # note.com API returns status='deleted' instead of 404 for deleted articles.
1113
+ # We treat this as ARTICLE_NOT_FOUND because attempting to delete an
1114
+ # already deleted article is nonsensical.
1115
+ if article.status == ArticleStatus.DELETED:
1116
+ raise NoteAPIError(
1117
+ code=ErrorCode.ARTICLE_NOT_FOUND,
1118
+ message="Article has been deleted (status='deleted')",
1119
+ details={"article_key": article_key},
1120
+ )
1121
+
1122
+ # If confirm=False, return preview without deleting
1123
+ if not confirm:
1124
+ status_label = "公開記事" if article.status == ArticleStatus.PUBLISHED else "下書き記事"
1125
+ return DeletePreview(
1126
+ article_id=article.id,
1127
+ article_key=article.key,
1128
+ article_title=article.title,
1129
+ status=article.status,
1130
+ message=(
1131
+ f"{status_label}「{article.title}」を削除しますか?confirm=True を指定して再度呼び出してください。"
1132
+ ),
1133
+ )
1134
+
1135
+ # Step 2: Execute deletion (confirm=True)
1136
+ # Note: The delete endpoint requires /n/ prefix before the article key
1137
+ await _execute_delete(session, f"/v1/notes/n/{article_key}")
1138
+
1139
+ status_label = "公開記事" if article.status == ArticleStatus.PUBLISHED else "下書き記事"
1140
+ return DeleteResult(
1141
+ success=True,
1142
+ article_id=article.id,
1143
+ article_key=article.key,
1144
+ article_title=article.title,
1145
+ message=f"{status_label}「{article.title}」({article.key})を削除しました。",
1146
+ )
1147
+
1148
+
1149
+ async def unpublish_article(
1150
+ session: Session,
1151
+ article_key: str,
1152
+ ) -> Article:
1153
+ """Unpublish an article (revert published article to draft).
1154
+
1155
+ Changes a published article's status back to draft. The article content
1156
+ is preserved. Only published articles can be unpublished.
1157
+
1158
+ Args:
1159
+ session: Authenticated session
1160
+ article_key: Key of the article to unpublish (format: nXXXXXXXXXXXX)
1161
+
1162
+ Returns:
1163
+ Article object with updated draft status
1164
+
1165
+ Raises:
1166
+ NoteAPIError: If article is already a draft, not found, or API fails
1167
+
1168
+ Example:
1169
+ article = await unpublish_article(session, "n1234567890ab")
1170
+ print(f"Reverted to draft: {article.title}")
1171
+ """
1172
+ # Validate article key format
1173
+ if article_key.isdigit():
1174
+ raise NoteAPIError(
1175
+ code=ErrorCode.INVALID_INPUT,
1176
+ message=(
1177
+ f"Numeric article ID '{article_key}' is not supported. "
1178
+ "Please use the article key format (e.g., 'n1234567890ab')."
1179
+ ),
1180
+ details={"article_key": article_key},
1181
+ )
1182
+
1183
+ # Fetch article info
1184
+ article = await _execute_get(
1185
+ session,
1186
+ f"/v3/notes/{article_key}",
1187
+ _parse_article_response,
1188
+ )
1189
+
1190
+ # Check status
1191
+ if article.status == ArticleStatus.DELETED:
1192
+ raise NoteAPIError(
1193
+ code=ErrorCode.ARTICLE_NOT_FOUND,
1194
+ message="Article has been deleted (status='deleted')",
1195
+ details={"article_key": article_key},
1196
+ )
1197
+
1198
+ if article.status == ArticleStatus.DRAFT:
1199
+ raise NoteAPIError(
1200
+ code=ErrorCode.API_ERROR,
1201
+ message="Article is already a draft",
1202
+ details={"article_key": article_key, "status": article.status.value},
1203
+ )
1204
+
1205
+ # Resolve numeric ID for PUT endpoint
1206
+ numeric_id = await _resolve_numeric_note_id(session, article_key)
1207
+
1208
+ # Get article body for PUT
1209
+ async with NoteAPIClient(session) as client:
1210
+ article_response = await client.get(f"/v3/notes/{article_key}")
1211
+ article_data = article_response.get("data", {})
1212
+ article_title = article_data.get("name", "")
1213
+ if not article_title:
1214
+ note_draft = article_data.get("note_draft")
1215
+ if isinstance(note_draft, dict):
1216
+ article_title = note_draft.get("name", "")
1217
+ article_body = article_data.get("body") or ""
1218
+
1219
+ # PUT with status=draft to unpublish
1220
+ payload: dict[str, Any] = {
1221
+ "name": article_title,
1222
+ "free_body": article_body,
1223
+ "body_length": len(article_body),
1224
+ "status": "draft",
1225
+ "index": False,
1226
+ }
1227
+
1228
+ response = await client.put(f"/v1/text_notes/{numeric_id}", json=payload)
1229
+
1230
+ # Validate API response
1231
+ data = response.get("data", {})
1232
+ if data.get("result") is False:
1233
+ raise NoteAPIError(
1234
+ code=ErrorCode.API_ERROR,
1235
+ message="Failed to unpublish article: API returned failure",
1236
+ details={"article_key": article_key, "response": response},
1237
+ )
1238
+
1239
+ return await get_article_via_api(session, article_key)
1240
+
1241
+
1242
+ async def delete_all_drafts(
1243
+ session: Session,
1244
+ *,
1245
+ confirm: bool = False,
1246
+ ) -> BulkDeleteResult | BulkDeletePreview:
1247
+ """Delete all draft articles.
1248
+
1249
+ Deletes all draft articles for the authenticated user.
1250
+ Implements a two-step confirmation flow for safety.
1251
+
1252
+ This function:
1253
+ 1. Fetches all drafts using list_articles(status=DRAFT)
1254
+ 2. When confirm=False: Returns a BulkDeletePreview listing all drafts
1255
+ 3. When confirm=True: Sequentially deletes each draft
1256
+
1257
+ Args:
1258
+ session: Authenticated session
1259
+ confirm: Confirmation flag (must be True to execute deletion)
1260
+
1261
+ Returns:
1262
+ BulkDeletePreview when confirm=False (shows what will be deleted)
1263
+ BulkDeleteResult when confirm=True (deletion results with success/failure counts)
1264
+
1265
+ Example:
1266
+ # Step 1: Preview what will be deleted
1267
+ preview = await delete_all_drafts(session, confirm=False)
1268
+ print(f"Will delete {preview.total_count} drafts")
1269
+
1270
+ # Step 2: Actually delete all
1271
+ result = await delete_all_drafts(session, confirm=True)
1272
+ print(f"Deleted: {result.deleted_count}, Failed: {result.failed_count}")
1273
+ """
1274
+ from note_mcp.models import (
1275
+ ArticleSummary,
1276
+ BulkDeletePreview,
1277
+ BulkDeleteResult,
1278
+ FailedArticle,
1279
+ )
1280
+
1281
+ # Step 1: Get all drafts (paginate through all pages)
1282
+ article_summaries: list[ArticleSummary] = []
1283
+ page = 1
1284
+
1285
+ async with NoteAPIClient(session) as client:
1286
+ while page <= DELETE_ALL_DRAFTS_MAX_PAGES:
1287
+ response = await client.get(
1288
+ "/v2/note_list/contents",
1289
+ params={"publish_status": "draft", "page": page},
1290
+ )
1291
+
1292
+ data = response.get("data", {})
1293
+ notes = data.get("notes", [])
1294
+
1295
+ # No more notes, stop pagination
1296
+ if not notes:
1297
+ break
1298
+
1299
+ # Build article summaries for this page
1300
+ # Article 6: Required fields (id, key) must be present, skip invalid notes
1301
+ for note in notes:
1302
+ note_id = note.get("id")
1303
+ note_key = note.get("key")
1304
+
1305
+ # Skip notes with missing required fields (Article 6 compliance)
1306
+ if not note_id or not note_key:
1307
+ logger.warning(
1308
+ "Skipping note with missing required field(s)",
1309
+ extra={
1310
+ "note_id": note_id,
1311
+ "note_key": note_key,
1312
+ "note_name": note.get("name"),
1313
+ },
1314
+ )
1315
+ continue
1316
+
1317
+ article_summaries.append(
1318
+ ArticleSummary(
1319
+ article_id=str(note_id),
1320
+ article_key=str(note_key),
1321
+ # title is display-only, empty string is valid
1322
+ title=str(note.get("name") or ""),
1323
+ )
1324
+ )
1325
+
1326
+ page += 1
1327
+
1328
+ total_count = len(article_summaries)
1329
+
1330
+ # If no drafts, return early
1331
+ if total_count == 0:
1332
+ if not confirm:
1333
+ return BulkDeletePreview(
1334
+ total_count=0,
1335
+ articles=[],
1336
+ message="削除対象の下書きがありません。",
1337
+ )
1338
+ return BulkDeleteResult(
1339
+ success=True,
1340
+ total_count=0,
1341
+ deleted_count=0,
1342
+ failed_count=0,
1343
+ deleted_articles=[],
1344
+ failed_articles=[],
1345
+ message="削除対象の下書きがありません。",
1346
+ )
1347
+
1348
+ # If confirm=False, return preview
1349
+ if not confirm:
1350
+ return BulkDeletePreview(
1351
+ total_count=total_count,
1352
+ articles=article_summaries[:DELETE_ALL_DRAFTS_PREVIEW_LIMIT],
1353
+ message=f"{total_count}件の下書き記事を削除しますか?confirm=True を指定して再度呼び出してください。",
1354
+ )
1355
+
1356
+ # Step 2: Execute deletion (confirm=True)
1357
+ deleted_articles: list[ArticleSummary] = []
1358
+ failed_articles: list[FailedArticle] = []
1359
+
1360
+ async with NoteAPIClient(session) as client:
1361
+ for summary in article_summaries:
1362
+ try:
1363
+ await client.delete(f"/v1/notes/n/{summary.article_key}")
1364
+ deleted_articles.append(summary)
1365
+ except NoteAPIError as e:
1366
+ failed_articles.append(
1367
+ FailedArticle(
1368
+ article_id=summary.article_id,
1369
+ article_key=summary.article_key,
1370
+ title=summary.title,
1371
+ error=e.message,
1372
+ )
1373
+ )
1374
+
1375
+ deleted_count = len(deleted_articles)
1376
+ failed_count = len(failed_articles)
1377
+ success = failed_count == 0
1378
+
1379
+ # Build result message
1380
+ if failed_count == 0:
1381
+ message = f"{deleted_count}件の下書き記事を削除しました。"
1382
+ else:
1383
+ message = (
1384
+ f"{total_count}件中{deleted_count}件の下書き記事を削除しました。{failed_count}件の削除に失敗しました。"
1385
+ )
1386
+
1387
+ return BulkDeleteResult(
1388
+ success=success,
1389
+ total_count=total_count,
1390
+ deleted_count=deleted_count,
1391
+ failed_count=failed_count,
1392
+ deleted_articles=deleted_articles,
1393
+ failed_articles=failed_articles,
1394
+ message=message,
1395
+ )