note-connector 0.2.10 → 0.2.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "note-connector",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/py/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "note-connector"
3
- version = "0.2.10"
3
+ version = "0.2.12"
4
4
  description = "note-connector: MCP server and ChatGPT connector for note.com"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -733,3 +733,168 @@ async def upload_eyecatch_base64(
733
733
  if tmp_path is not None:
734
734
  with contextlib.suppress(OSError):
735
735
  os.unlink(tmp_path)
736
+
737
+
738
+ # =============================================================================
739
+ # Chunked Base64 Image Upload
740
+ # =============================================================================
741
+
742
+ # Max chunk size for safe MCP transport (64KB base64)
743
+ MAX_CHUNK_SIZE: int = 64 * 1024
744
+
745
+ # In-memory chunk buffer: upload_id -> chunk state
746
+ # Each entry: {"chunks": {index: bytes}, "total_chunks": int, "mime_type": str, "note_id": str}
747
+ _chunk_buffer: dict[str, dict[str, Any]] = {}
748
+
749
+
750
+ def _reset_chunk_state(upload_id: str) -> None:
751
+ """Clear chunk buffer state for an upload_id."""
752
+ _chunk_buffer.pop(upload_id, None)
753
+
754
+
755
+ def _get_chunk_state(upload_id: str) -> dict[str, Any] | None:
756
+ """Get chunk buffer state for an upload_id."""
757
+ return _chunk_buffer.get(upload_id)
758
+
759
+
760
+ async def upload_eyecatch_chunked(
761
+ session: Session,
762
+ upload_id: str,
763
+ note_id: str,
764
+ mime_type: str,
765
+ chunk: str,
766
+ chunk_index: int,
767
+ total_chunks: int,
768
+ ) -> Image | None:
769
+ """Upload an eyecatch image in base64 chunks.
770
+
771
+ Receives base64-encoded chunks of an image, buffers them, and on the
772
+ final chunk assembles the complete image, validates it, and uploads
773
+ to note.com as an eyecatch.
774
+
775
+ This is designed for ChatGPT where large base64 strings may be
776
+ truncated during MCP tool argument transmission.
777
+
778
+ Args:
779
+ session: Authenticated session
780
+ upload_id: Unique ID for this upload session (generated by ChatGPT)
781
+ note_id: The note ID to associate the image with
782
+ mime_type: MIME type of the image
783
+ chunk: Base64-encoded chunk data
784
+ chunk_index: 0-based index of this chunk
785
+ total_chunks: Total number of chunks expected
786
+
787
+ Returns:
788
+ Image object on final chunk success, None for intermediate chunks
789
+
790
+ Raises:
791
+ NoteAPIError: If validation fails or API request fails
792
+ """
793
+ # Validate parameters
794
+ if not upload_id.strip():
795
+ raise NoteAPIError(
796
+ code=ErrorCode.INVALID_INPUT,
797
+ message="upload_id が空です。",
798
+ )
799
+
800
+ if total_chunks < 1:
801
+ raise NoteAPIError(
802
+ code=ErrorCode.INVALID_INPUT,
803
+ message=f"total_chunks は1以上である必要があります: {total_chunks}",
804
+ )
805
+
806
+ if chunk_index < 0 or chunk_index >= total_chunks:
807
+ raise NoteAPIError(
808
+ code=ErrorCode.INVALID_INPUT,
809
+ message=(f"chunk_index が範囲外です: {chunk_index} (有効範囲: 0〜{total_chunks - 1})"),
810
+ )
811
+
812
+ if not chunk.strip():
813
+ raise NoteAPIError(
814
+ code=ErrorCode.INVALID_BASE64,
815
+ message=f"chunk {chunk_index + 1}/{total_chunks} が空です。",
816
+ )
817
+
818
+ # Validate MIME type (only on first chunk to avoid repeat checks)
819
+ if chunk_index == 0:
820
+ _validate_mime_type(mime_type)
821
+
822
+ # Initialize or get chunk state
823
+ state = _get_chunk_state(upload_id)
824
+ if state is None:
825
+ state = {
826
+ "chunks": {},
827
+ "total_chunks": total_chunks,
828
+ "mime_type": mime_type,
829
+ "note_id": note_id,
830
+ }
831
+ _chunk_buffer[upload_id] = state
832
+ else:
833
+ # Validate consistency
834
+ if state["total_chunks"] != total_chunks:
835
+ raise NoteAPIError(
836
+ code=ErrorCode.INVALID_INPUT,
837
+ message=(f"total_chunks が一致しません: (前回: {state['total_chunks']}, 今回: {total_chunks})"),
838
+ )
839
+ if state["mime_type"] != mime_type:
840
+ raise NoteAPIError(
841
+ code=ErrorCode.INVALID_INPUT,
842
+ message=(f"mime_type が一致しません: (前回: {state['mime_type']}, 今回: {mime_type})"),
843
+ )
844
+
845
+ # Store raw base64 chunk (decode happens after assembly to avoid
846
+ # base64 alignment issues when splitting across 3-byte boundaries)
847
+ state["chunks"][chunk_index] = chunk.strip()
848
+
849
+ received = len(state["chunks"])
850
+ logger.debug(
851
+ "Chunk %d/%d for upload_id=%s, received=%d/%d",
852
+ chunk_index + 1,
853
+ total_chunks,
854
+ upload_id,
855
+ received,
856
+ total_chunks,
857
+ )
858
+
859
+ # Return early if not all chunks received
860
+ if received < total_chunks:
861
+ return None
862
+
863
+ # All chunks received - assemble base64 and decode
864
+ try:
865
+ # Concatenate base64 chunks in order
866
+ chunks_list = state["chunks"]
867
+ assembled_b64 = "".join(chunks_list[i] for i in range(total_chunks))
868
+
869
+ # Decode the complete base64 string
870
+ image_bytes = _decode_base64_image(assembled_b64)
871
+
872
+ # Validate the assembled image
873
+ _validate_image_bytes(image_bytes, mime_type)
874
+
875
+ # Determine file extension
876
+ extension = MIME_TO_EXTENSION.get(mime_type, ".bin")
877
+
878
+ # Write to temp file and upload
879
+ tmp_path: str | None = None
880
+ try:
881
+ fd, tmp_path = tempfile.mkstemp(suffix=extension)
882
+ os.close(fd)
883
+
884
+ with open(tmp_path, "wb") as f:
885
+ f.write(image_bytes)
886
+
887
+ image = await _upload_image_internal(
888
+ session=session,
889
+ file_path=tmp_path,
890
+ note_id=note_id,
891
+ image_type=ImageType.EYECATCH,
892
+ )
893
+ return image
894
+ finally:
895
+ if tmp_path is not None:
896
+ with contextlib.suppress(OSError):
897
+ os.unlink(tmp_path)
898
+ finally:
899
+ # Always clean up state
900
+ _reset_chunk_state(upload_id)
@@ -26,6 +26,7 @@ from note_mcp.api.images import (
26
26
  insert_image_via_api,
27
27
  upload_body_image,
28
28
  upload_eyecatch_base64,
29
+ upload_eyecatch_chunked,
29
30
  upload_eyecatch_image,
30
31
  )
31
32
  from note_mcp.api.preview import get_preview_html
@@ -326,6 +327,63 @@ async def note_set_eyecatch_base64(
326
327
  return "アイキャッチ画像を設定しました。"
327
328
 
328
329
 
330
+ @mcp.tool()
331
+ @require_session
332
+ @handle_api_error
333
+ async def note_set_eyecatch_base64_chunked(
334
+ session: Session,
335
+ upload_id: Annotated[str, "アップロードセッションを識別する一意なID(UUID推奨)"],
336
+ note_id: Annotated[str, "アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)"],
337
+ mime_type: Annotated[str, "画像のMIMEタイプ(image/png, image/jpeg, image/webp など)"],
338
+ chunk: Annotated[str, "base64エンコードされた画像データのチャンク"],
339
+ chunk_index: Annotated[int, "このチャンクの0ベースのインデックス"],
340
+ total_chunks: Annotated[int, "全チャンク数"],
341
+ ) -> str:
342
+ """base64画像を分割(チャンク)で送信し、アイキャッチ画像を設定します。
343
+
344
+ 大きなbase64画像をChatGPT経由で安全に転送するためのツールです。
345
+ base64文字列を複数チャンクに分割して順次送信し、全チャンクが
346
+ 揃った時点で画像を組み立ててnote.comにアップロードします。
347
+
348
+ 使い方:
349
+ 1. ChatGPT側でbase64を分割(例: 50KBずつ)
350
+ 2. upload_id(UUID)を生成
351
+ 3. 各チャンクを note_set_eyecatch_base64_chunked で送信
352
+ 4. 全チャンク送信後、自動的に画像が組み立てられアップロードされる
353
+
354
+ 対応形式: PNG, JPEG, WebP, GIF
355
+ 最大合計サイズ: 10MB
356
+ 1チャンクあたり推奨サイズ: 64KB以下
357
+
358
+ Args:
359
+ upload_id: アップロードセッションの一意なID
360
+ note_id: アイキャッチ画像を設定する記事のID
361
+ mime_type: 画像のMIMEタイプ
362
+ chunk: base64エンコードされたチャンクデータ
363
+ chunk_index: このチャンクのインデックス(0始まり)
364
+ total_chunks: 全チャンク数
365
+
366
+ Returns:
367
+ 中間チャンクでは受信状況、最終チャンクでは設定結果
368
+ """
369
+ image = await upload_eyecatch_chunked(
370
+ session=session,
371
+ upload_id=upload_id,
372
+ note_id=note_id,
373
+ mime_type=mime_type,
374
+ chunk=chunk,
375
+ chunk_index=chunk_index,
376
+ total_chunks=total_chunks,
377
+ )
378
+
379
+ if image is None:
380
+ return f"チャンク {chunk_index + 1}/{total_chunks} を受信しました。 upload_id: {upload_id}"
381
+
382
+ if image.url:
383
+ return f"アイキャッチ画像を設定しました。URL: {image.url}"
384
+ return "アイキャッチ画像を設定しました。"
385
+
386
+
329
387
  @mcp.tool()
330
388
  @require_session
331
389
  @handle_api_error