この記事を作った動機

 2025/11/6 から、2025/11/13 に至るまでの間で OneNote 代替に関して、実装をするなど研究を進めているうちにわかったことなどを簡易的に記録する。

動作の様子

TODOs

TODOs

 2025/11/13 になる間やる項目たち。やりきれなかった分は次に持ち越し。

メイン(持ち越し、新規混合)

  • バックエンドの実装
    • ターゲット層を明確にする
    • ネットワーク監視について実装を思いついた案に基づいて進める
    • バックエンドの設定についてフロントエンドにUIを設けたい
  • フロントエンドのネットワーク周り
    • useDatabaseStoreuseNetworkStoreに名前を変更する。
    • send関数をuseDatabaseStoreuseNetworkStore)内にあるものに置き換え、旧来のsend関数の実装を廃止する。
  • free ページの実装
    • フロントエンド
      • 共通のUIについて、使い方の説明について、ドキュメントを書いた。
        • overlayWindow
        • messagebox
        • toolsbar
        • page
      • 実際に変更があったら、変更内容をリアルタイムで保存するようにする。
        • 別のページを開いたときとかに、バグって変なタイミングでデータを保存しようとするのを止める
        • セーブする際に、ページのUUIDが一致するか確かめてからセーブする。
      • 書式設定に関する edit toggleable を作る。文章をどこに寄せるかとか、フォントサイズとか、フォントカラーとかそういう細かい設定ができるようにする。
      • アイテムが何もないページにおいて、アイテムの追加時に、どんなアイテムタイプが利用可能か取得するのに失敗するのをどうにかする。
      • リアルタイムセーブがバグることについて調査する。別のページを開いたとき、Freeページコンポーネント自体やその変数の中身が、どういう状態になっているのかを調べる。
      • Metadata を表示できるようにする
      • Free ページの要素について、基本要素の項目にアイテムの色をRGBAで設定できるようにする。
        • とりあえず、まだ色を変更するUIがないが、RGBAとしてCSSスタイルを設定してアイテムごとの色を反映するようにFreeページの実装を更新はした。
        • 色を設定するUIや項目へのアクセスボタンを実装する。 -> すべてのアイテム共通の設定項目であるため、汎用に設定項目を表示できるpropertiesとは独立して、commonsの一部として実装する。
        • 設定UIで色の一部が正しく表示できていないバグをなおす。 -> padStart
        • commonsが正しく色を更新できないバグを何とかする。
      • z-index をちゃんと更新するようにする
      • useFreePageItemsStore の items や activeItems の配列をページごとに分離して管理するようにする。
      • どうしてもzustandを使ったグローバルなアイテム管理が暴走する場合は、Freeコンポーネント内部でアイテムを管理するように実装を変更する。 -> とりあえず zustand を使った実装で続行。
      • 範囲選択の実装 -> activeItemsを操作する
  • 編集機能、データセーブ(更新)の実装
    • free ページにおいて、保存が成功するまでは、変更があってから保存されるまでの間に、バッファーを保持するようにする。
  • バックエンド割り込みの実装
    • updatePageの割り込みがあった時に、どのページがセーブされたかIDをUUIDをデータとして含める
    • updatePageがあったら差分を扱えるように、先にセーブされたデータをバッファーにためるようにして、diffをマージできるようにする
  • データサーバに接続されているかどうかなどの、ステータスインジゲーターを作る
  • データサーバのupdatePageコマンドのUUID検証ロジックについて正しく機能しているか調べる。
  • データサーバの実装
    • addTag deleteTag コマンドの実装
    • queryTag コマンドの実装

前回からの持ち越し

持ち越し分

  • 内部構造を説明する図とかドキュメントを作る

    • フロントエンド
      • drawio で説明する図を作る
        • 他人が構造の理解に必要な分を記録できたか
    • バックエンド(更新)
      • 新たに追加した、task の仕組みについて、ドキュメントを整備する。
      • フロントエンドを拡張するうえで重要そうな zustand のデータストアの仕様リストを作り、記録を行う。
  • OverlayWindow において、リサイズやサイズ制限をオプションで付ける。デフォルトでは無効にして、とりあえず selector においてそれが機能するようにする。

  • 全体的な未保存、保存、バッファーの管理について考える

  • pageType などは現状のままで、増やさず、free と markdown に専念する

  • tasks -> jobs で定期的に実行されるバックエンドの関数群において、pageCleaner.py を実装する。各ノートにある、参照が消され、deleted.json に記録されていて、一定期間たったページを時期が来たら本当にファイルシステム上から削除する実装をする。コード自体は、tasks.py の設定に依存し、現状では10秒ごとに消す時が来ていないかポーリングする簡易的な実装である。

  • 再接続機能が正しく機能しない場合を検証する

    • WebSokcet が正常に閉じられなかった場合、自動的にそれを検出して再接続するようにフロントエンドを改修する。
    • WebSocket が正常に閉じられていないとき、誤作動を起こす件について、データサーバ側を改修することで、ある程度改善を行った。再接続機能改善の試みを詳細は参考にすること。
      • except asyncio.CancelledErrormainLoop 内で機能していない可能性について調べる。
      • 実はなんか思ってるのと違う WebSocket の閉じ方をしているかもしれないことについて調べる。
opening handshake failed
Traceback (most recent call last):
  File "C:\Users\username\AppData\Roaming\Python\Python312\site-packages\websockets\http11.py", line 138, in parse
    request_line = yield from parse_line(read_line)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\username\AppData\Roaming\Python\Python312\site-packages\websockets\http11.py", line 309, in parse_line
    line = yield from read_line(MAX_LINE_LENGTH)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\username\AppData\Roaming\Python\Python312\site-packages\websockets\streams.py", line 46, in read_line
    raise EOFError(f"stream ends after {p} bytes, before end of line")
EOFError: stream ends after 0 bytes, before end of line

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\username\AppData\Roaming\Python\Python312\site-packages\websockets\server.py", line 545, in parse
    request = yield from Request.parse(
              ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\username\AppData\Roaming\Python\Python312\site-packages\websockets\http11.py", line 140, in parse
    raise EOFError("connection closed while reading HTTP request line") from exc
EOFError: connection closed while reading HTTP request line

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\username\AppData\Roaming\Python\Python312\site-packages\websockets\asyncio\server.py", line 356, in conn_handler
    await connection.handshake(
  File "C:\Users\username\AppData\Roaming\Python\Python312\site-packages\websockets\asyncio\server.py", line 207, in handshake
    raise self.protocol.handshake_exc
websockets.exceptions.InvalidMessage: did not receive a valid HTTP request

他の事項

  • File API 周りの実装
  • マルチメディアの対応
  • selector を複数ページ選択に対応させる
  • タグ機能を機能するようにする
    • バックエンド側のコマンドを整備
    • フロントエンド側がコマンドを実行するようにする
    • 割り込みの実装を定義して tag に変更があったことを通知するようにする

やったことの詳細

保存される page や notebook の JSON データを human readable にする

 もともとの実装だと、dataserver 側は単にデータを一行の JSON として保存しているだけで、可読性がひどいので、ちゃんと改行やインデントが入るように実装を変更した。

改修前のコード

import json
...
json.dumps(notebookMatadata)
...

改修後のコード

import json
...
json.dumps(notebookMatadata,indent=4)
...

改修前の Notebook metadata.json の例

{"name": "newnote", "createDate": "2025/9/26", "updateDate": "2025/11/6", "id": "5f3215a7-88f1-4065-9a01-98d630d99607", "pages": ["\u30c6\u30b9\u30c8.md", "\u3075\u3055\u304a.md", "test.md", "testa.md", "aaaaasdadaaaswe.md", "oneNoteStyle/husahusa.json", "oneNoteStyle/dssss.json"], "files": []}

改修後の Notebook metadata.json の例

{
    "name": "newnote",
    "createDate": "2025/9/26",
    "updateDate": "2025/11/12",
    "id": "5f3215a7-88f1-4065-9a01-98d630d99607",
    "pages": [
        "\u30c6\u30b9\u30c8.md",
        "\u3075\u3055\u304a.md",
        "test.md",
        "testa.md",
        "aaaaasdadaaaswe.md",
        "oneNoteStyle/husahusa.json",
        "oneNoteStyle/dssss.json"
    ],
    "files": []
}

Free ページでメタデータを表示するように改修した

 Free ページにおいて、メタデータを表示するように実装した。

Freeページでメタデータを表示するようにした例

  • src\modules\pages\free\main.tsx
...
    useEffect(() => {
        setMetadataToAppState({
            createDate:jsondata.createDate,
            files:jsondata.files,
            tags:jsondata.tags,
            updateDate:jsondata.updateDate,
            UUID:jsondata.UUID
        })

        return () => {
            setMetadataToAppState(null)
        }
    },[jsondata])
...

フロントエンドのネットワーク周りの改修

やったことリスト

  • ユーザーに対し、背景を赤くすることで dataserver との接続が切れていることを表す。
  • 再接続ループが常に Websocket がオープンしたときに、ポーリングするようになっていたのを修正した。errorcloseイベントが発火した時だけ、再接続を試みるように修正した。
  • useDatabaseEffectからuseNetworkEffectに名前を変更した。
  • useDatabaseStore内に、send関数を実装した。
  • useDatabaseStore内に、isDisconnectという項目をブール型で用意し、dataserver と接続されているか独立して状態を管理し、ほかのコンポーネントから参照できるようにした。
  • requestHistoryという配列を用意し、Arequestという型をもってして送信した文字列や時間、コマンドリクエストのUUIDを管理するようにした。コマンドにdataserverから応答ががあった時は、requestHistoryの配列から送信した情報が削除され、正常にコマンドのやり取りができたことになる。
  • useDatabaseStoreuseNetworkStoreに名前を変更するTODOを立てた。

未接続状態の表示の実装例

export default function Window(){    
    ...
    const isDisconnect = useDatabaseStore((s) => s.isDisconnect)
    let style = {
        background: ""
    }
    if(isDisconnect){
        style.background = "rgba(255,0,0,0.2)"
    }
    console.log(isDisconnect)
    ...
}

新しいsend関数

 dataserver にリクエストを送る関数sendを、useDatabaseStore内に実装した。徐々にこの関数に実装を置き換えていく予定。

 今までのsend関数の実装には問題があり、いちいちwebsocketをリクエストを行ったコンポーネントから引数として直接渡しているため、再接続などがあった時に、その新しいwebsocketを参照できずに、古い接続が途切れたwebsocketを使って再送信を試みるという問題が起こっていた。以下は今までのsend関数の流れである。

  1. 各コンポーネントがwebsocketの参照をuseDatabaseStoreから持っている。
  2. send関数が実行されたとき、useDatabaseStoreから持ってきたwebsocketの参照が"コピー"される。useDatabaseStorewebsocketの参照が変わっても、既に実行されているsend関数のwebsocketの参照が更新されない。
  3. send関数が送信失敗を検出し、再送信しようとするときに、既にとじらえていて利用不能なwebsocketの参照が利用される。
  4. すべての再送信の試みにおいて失敗する。

 そこで、websocketの参照が直接存在するuseDatabaseStore内にsend関数を移動することで、常に最新の接続されたweboscketの参照を使うようにしたいというのがあった。

  • 旧来の実装
export function send(websocket:WebSocket, request:string, attempt=5){
  // const addHistory      = useNetworkRequestManager((s) => s.addRequest)
  // const isHistoryExists = useNetworkRequestManager((s) => s.isRequestExist)
  // const setWebsoketState = useDatabaseStore.setState
  const CurrentHistory:Arequest = {
    requestJSONstring: request,
    UUID: JSON.parse(request).UUID,
    requestTimestamp: new Date
  }
  // addHistory(history)
  requestHistory.push(CurrentHistory)
  
  if(attempt == 0){
    console.log("on websocket send data, all attempts are failed. stop")
    console.log(request)
    return
  }
  // console.log(websocket.readyState)
  if(
    websocket.readyState != WebSocket.CONNECTING  && 
    websocket.readyState != WebSocket.CLOSED      &&
    websocket.readyState != WebSocket.CLOSING
  ){
    console.log(request)
    websocket.send(request)
    
    // check the request on the timeout state
    setTimeout(() => {
      for(const history of requestHistory){
        if(history.UUID == CurrentHistory.UUID){
          // when failed request

          break
        }
      }
    },timeoutInterval * 1000)
    return
  }

  // 古い websocket を参照し続けながら、再試行回数上限を迎えるまで、永遠に再送信に失敗し続ける
  setTimeout(() => { send(websocket,request,attempt - 1) },interval * 1000)
}
  • 新しい実装
export const useDatabaseStore = create<Networks>((set, get) => ({
  websocket: null,
  serverIP: "ws://localhost:50097",
  isDisconnect: true,

  changeServer: (ip: string) => {
    set({ serverIP: ip });
  },

  closeConnection: () => {
    const ws = get().websocket;
    ws?.close();
    set({ websocket: null, isDisconnect: true });
  },

  getWebsocket: () => get().websocket,

  send: (request:string, attempt) => {
    if(attempt == null) attempt = 5

    // 常に再送信時には最新の websocket を参照している
    const websocket = get().websocket 
    if(websocket == null){
      // retry
      setTimeout(() => { get().send(request,attempt - 1) },interval * 1000)
      return
    }

    const CurrentHistory:Arequest = {
      requestJSONstring: request,
      UUID: JSON.parse(request).UUID,
      requestTimestamp: new Date
    }
    requestHistory.push(CurrentHistory)
    
    if(attempt == 0){
      console.log("on websocket send data, all attempts are failed. stop")
      console.log(request)
      return
    }
    if(
      websocket.readyState != WebSocket.CONNECTING  && 
      websocket.readyState != WebSocket.CLOSED      &&
      websocket.readyState != WebSocket.CLOSING
    ){
      console.log(request)
      websocket.send(request)
      
      // check the request on the timeout state
      setTimeout(() => {
        console.log(requestHistory)
        for(const history of requestHistory){
          if(history.UUID == CurrentHistory.UUID){
            // when failed request
            set({ isDisconnect:true })
            
            // retry
            setTimeout(() => { get().send(request,attempt - 1) },interval * 1000)
            break
          }
        }
      },timeoutInterval * 1000)
      return
    }

    // retry
    setTimeout(() => { get().send(request,attempt - 1) },interval * 1000)
  },
}));

isDisconnect の実装の詳細

  • true : 未接続
  • false: dataserver に接続済み

 isDisconnectuseDatabaseStore内にあるブール型の変数で、dataserver とフロントエンドが接続されているか独立して状態を管理し、ほかのコンポーネントから参照できるようにしたものである。
 isDisconnectは、websocketが閉じられたりエラーの時に、reconnectLoopに入ったり、send関数でタイムアウトが検出されたりすると、trueになり、何かしらの形でwebsocketの接続が復旧し、openイベントが発火したときにはfalseになる。

参考にしたサイトとか