この記事を作った動機

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

動作の様子

TODOs

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

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

  • free ページの実装

    • フロントエンド
      • 共通のUIについて、使い方の説明について、ドキュメントを書いた。
        • overlayWindow
        • messagebox
        • toolsbar
        • page
      • オブジェクト変形用に汎用的なコンポーネントを定義する。カーソルがオブジェクトをホバーしているときに出現するようにする。タッチでは、一回タップで出現。(サイズ変更、移動)
        • 移動について実装
        • △ リサイズ機能についての実装
          • 右下について実装 (とりあえずこれだけ)
      • 編集モードを各アイテムの要素の種類ごとに実装。汎用的には、ダブルクリックされた場合に編集モードに移行するようにする。タッチではダブルタッチで移行する.
      • 実際に変更があったら、変更内容をリアルタイムで保存するようにする。
        • 別のページを開いたときとかに、バグって変なタイミングでデータを保存しようとするのを止める
        • セーブする際に、ページのUUIDが一致するか確かめてからセーブする。
      • 書式設定に関する edit toggleable を作る。文章をどこに寄せるかとか、フォントサイズとか、フォントカラーとかそういう細かい設定ができるようにする。
      • アイテムが何もないページにおいて、アイテムの追加時に、どんなアイテムタイプが利用可能か取得するのに失敗するのをどうにかする。
      • リアルタイムセーブがバグることについて調査する。別のページを開いたとき、Freeページコンポーネント自体やその変数の中身が、どういう状態になっているのかを調べる。
      • Metadata を表示できるようにする
      • Free ページの要素について、基本要素の項目にアイテムの色をRGBAで設定できるようにする。
        • とりあえず、まだ色を変更するUIがないが、RGBAとしてCSSスタイルを設定してアイテムごとの色を反映するようにFreeページの実装を更新はした。
        • 色を設定するUIや項目へのアクセスボタンを実装する。
  • 編集機能、データセーブ(更新)の実装

    • 別のページを開いても未保存のデータのバッファが管理されるように各ページタイプを実装する
    • selector の改善
      • Selector に未保存の表示を行う
    • free ページにおいて、保存が成功するまでは、変更があってから保存されるまでの間に、バッファーを保持するようにする。
  • バックエンド割り込みの実装

    • バックエンドからフロントエンドの方向の割り込みをすでにドキュメントである程度定義したとおりに実装する。例えば、ページ内容に変更があったら通知したりする。
    • helper.commonとして、sendIntteruptを実装する。
    • helper.commonsendIntteruptについて、ドキュメントを書いた。
    • 割り込みが必要なコマンド群から、割り込みを送るようにする。また各コマンドのドキュメントに割り込みについて説明を書く。これに伴いテンプレートも割り込みについて記述するように、更新を行う。
    • 割り込みについては、専用のドキュメントページを作り、そこに記述する
    • フロントエンド側を割り込みを受けつけるように、改修していく。自動的に、フロントエンドが、バックエンドの割り込みによって連動して機能することを確認する。例えば、ページが作成されたとき、内容が変更されたとき、ノートブックが削除された時などに、データサーバが接続しているすべてのフロントエンドに、その通知が行く。各コンポーネントが、必要な割り込みイベントを、listen するような形をとる。
    • 割り込みの仕組みについてのドキュメントの記述が見つからない場合、新たに定義し、それをもとに実装を進める。
    • ドキュメントに書いた割り込みが実際に機能するよう、各コマンドに割り込みを送るように実装していく。
    • データサーバからくる割り込みとコマンドレスポンスの分離について考える。具体的には割り込みやコマンドレスポンスには名前を割り当てて、識別するか、持っているkey次第で分類するかという案がある。
      • データサーバからやってくるデータが何なのか識別できるように名前を付ける。割り込みやコマンドに対するレスポンスなど複数の種類のデータが来ることが考えられ、それをはっきりと識別するために、名前を付けたいと思う。
    • 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 を複数ページ選択に対応させる

やったことの詳細

マークダウンページをバッファ機能に対応させた

 とりあえず、セーブされてないデータある状態で別のページを開いたりして作業しても内容が失われないように実装を行った。具体的には、Editor画面で編集があった場合は、それをバッファーに登録しておき、また変更があるごとにバッファーが更新される。

 それで、別のページを開いてから、もともと編集していた未保存の変更のあるページでは、バッファーがあることが分かると、その内容を復元して表示する。またセーブされた場合はバッファーが削除される。

 バッファー管理に使われているのはsrc\modules\window.tsxの中にある以下のものである。ちなみに地味にいろいろ実装をミスっていたので修正したりもしてた。例えば、removeBufferと書いていながら、全然ちゃんと対象のバッファーを取り除けてなかったりしたので、修正したりしている。

export type AnUnsavedBuffer = {
    notebookName        : string,
    notebookUUID        : string,
    pageID              : string,
    pageUUID            : string,
    pageType            : string,
    UUID                : string, // to identify each buffers.
    timestamp           : Date,
    bufferContentString : string,
}

// NOTE: normally single page will add single buffer and update constantly if there are any modification.
export type unsavedBuffers = {
    buffers             : AnUnsavedBuffer[],
    addBuffer           : (unsavedBuffer:AnUnsavedBuffer) => void,
    removeBuffer        : (unsavedBuffer:AnUnsavedBuffer) => void,
    updateBuffer        : (unsavedBuffer:AnUnsavedBuffer) => void,      // not to create buffer, to update exist buffer
    getBuffers          : (pageID:string,pageUUID:string,notebookName:string) => AnUnsavedBuffer[],
    getUnsavedPageList  : () => string[],                               // for selector compornent.
}

export const useUnsavedBuffersStore = create<unsavedBuffers>((set,get) => ({
    buffers: [],
    addBuffer: (unsavedBuffer:AnUnsavedBuffer) => {
        const oldBuffer = get().buffers
        const newBuffer = []
        for(const aBuffer of oldBuffer){
            // when the buffer has already existed, abort the update
            if(aBuffer.UUID == unsavedBuffer.UUID) return
            newBuffer.push(aBuffer) 
        }
        newBuffer.push(unsavedBuffer)
        set({buffers:newBuffer})    
    },
    removeBuffer: (unsavedBuffer:AnUnsavedBuffer) => {
        const oldBuffer = get().buffers
        const newBuffer = []
        for(const aBuffer of oldBuffer){
            if(aBuffer.pageUUID == unsavedBuffer.pageUUID) continue
            newBuffer.push(aBuffer) 
        }
        set({buffers:newBuffer}) 
    },
    getBuffers: (pageID:string,pageUUID:string,notebookName:string) => {
        const allBuffer = get().buffers
        const buffers = []
        for(const aBuffer of allBuffer){
            if(
                aBuffer.pageID == pageID && 
                aBuffer.pageUUID == pageUUID &&
                aBuffer.notebookName == notebookName 
            ) 
                buffers.push(aBuffer) 
        }
        return buffers
    },
    updateBuffer: (unsavedBuffer:AnUnsavedBuffer) => {
        const oldBuffer = get().buffers
        const newBuffer = []
        for(const aBuffer of oldBuffer){
            // update
            if(aBuffer.UUID == unsavedBuffer.UUID) {
                // make sure the timestamp is the time of the last modify.
                unsavedBuffer.timestamp = new Date()
                newBuffer.push(unsavedBuffer)
                continue
            }
            newBuffer.push(aBuffer) 
        }
        set({buffers:newBuffer})    
    },
    getUnsavedPageList: () => {
        const buffers = get().buffers
        const bufferList:AnUnsavedBuffer[] = []

        for(const aBuffer of buffers){
            let find = false
            for(const AnBuffer of bufferList){
                if(AnBuffer.pageID == aBuffer.pageID){
                    find = true
                    break
                }
            }
            if(find) continue
            bufferList.push(aBuffer)
        }
        
        return bufferList
    }
})) 

Selectorにおいて未保存の表示をするようにした

 Selectorで、未保存のページがあるかどうかわかるように、useUnsavedBuffersStoreを使って状態に応じて色を付けるように実装をしてみた。

 具体的には、赤色になっていれば、未保存であることを意味し、緑色になっていれば、未保存で現在開いているページということになる。また、既に保存されている場合は、白色に近いグレーで選択されたものは表示され、選択されてない場合は背景色がつかないようになっている。

未保存表示の例

 ほかにも、ノートブック選択画面において、未保存のページが含まれるものは、赤色で示されるようにした。

ノートブック選択画面の未保存と保存済みの表示の例

割り込みを一部機能するようにした

 フロントエンド側の実装を進めることで、以前バックエンド側で実装した割り込みを使い、とりあえずSelectorにおいて、ノートブックやページが、作成、削除されたときに、選択肢が同期されるようにできた。

free ページについて実装の方針を変更

 アイテムのリサイズ機能について、右上がうまく実装できなかったことを受けて、うまく動いてそうな右下だけ残して、リサイズボタンを一時的に撤去した。

 具体的な課題としては、アイテムを移動したら、アイテムの情報を更新する必要があるが、それがうまくいかないという現象があった。アイテムのサイズを更新する分には問題が起こっていなかったが、アイテムの位置とサイズを同時に更新すると問題が起こるという感じである。

 ちなみに今こうして書いていて、じゃあいったんサイズだけ更新して、そのあと位置も更新すれば、また違った結果が得られるのではないかというのもあったが、アイテムのリサイズ機能だけに時間を投資していてもらちがあかないので、いったんそこはスキップとした。

freeページのリサイズ機能をシンプルに右下だけにした

データサーバからくるデータの形式の変更

 responseTypeというキーを追加し、コマンドに対する応答なのか、割り込みなのかはっきりと識別できるようにした。まだフロントエンド側は特段特別なことはしていないが、バックエンド側だけ先にやってみた。

コマンドレスポンスの例

{
    "responseType"  : "commandResponse",
    "status": "ok",
    "errorMessage": "nothing",
    "UUID":"UUID string",
    "command": "deletePage",
    "data":{ }
}

割り込みの例

{
    "responseType"  : "interrupt",
    "event" : "updatePage",
    "UUID"  : "UUID string",
    "data"  : { }
}

Free ページのバグ修正

 Free ページにおいて、アイテムが一つも存在しないページを開くと、アイテム追加において正常に利用可能なアイテムタイプの表示に失敗することが分かった。

 具体的には、アイテムが最初 Free ページ初期化時に表示されたときにしか、アイテムタイプを登録していなかったために、最初からページ内に存在しない、または別の Free ページを開いて表示したことのないタイプのアイテムは、useFreePageElementStore に正しく登録されていないため、選択できず、追加できないという状態になっていた。

問題のアイテムタイプ追加の流れ

  1. 何かしらの Free ページが開かれる
  2. もし、アイテムがすでに存在している場合、それを表示しようとする
  3. 表示するとともに、表示を担うコンポーネント内に、アイテムの種類の登録処理が入っており、特定のアイテムタイプが存在することが、useFreePageElementStoreに登録される
  4. 結果的に、アプリ自体を開いてから、表示したことのある Free ページのアイテムタイプだけが、利用可能なアイテムとして表示されてしまう。

修正後のアイテムタイプ追加の流れ

  1. 何かしら Free ページが開かれる
  2. Free ページがそもそもアイテムを表示しようとする前に、各要素のコンポーネントからエクスポートされている登録情報を使い、すべてのアイテムタイプをuseFreePageElementStoreに登録する
  3. 表示されたことがないアイテムタイプでも、新しいアイテムとして登録可能になる
  4. Free ページが閉じられるときは、アイテムタイプはuseFreePageElementStoreから全部削除される

Free ページでセーブ機能を追加

 実際に内容が変更されたら、リアルタイムで変更内容をセーブするようにした。ただし弊害として、セーブするタイミングがおかしい場合というのがあり、例えば、別のページに移動したときなどに、別のページ自身としてぐちゃぐちゃなデータをセーブしようとして、変なところにごみデータをぶちまけてしまうということが確認された。

 それでは困るので、とりあえずバックエンド側のupdatePageコマンド側で、pageIDpageTypeUUIDが本当に正しいのか、既にデータサーバ側にあるページのメタデータと情報を比べるようにした。UUIDの検証については、たぶんうまくいっていない気がするので、もう少しそこについてはデバックがいるものと思われる。

  • pageIDpageType が一致しているか調べる
    # check the extention
    if(pageType == "markdown" and not "md" in pageID):
        await errorResponse(
            websocket,
            request,
            "The page type mismatch.",
            [notebookName,pageID,pageType,pagePath]
            )
        return
    elif(not pageType == "markdown" and not "json" in pageID):
        await errorResponse(
            websocket,
            request,
            "The page type mismatch.",
            [notebookName,pageID,pageType,pagePath]
            )
        return
  • 既にあるデータと新しいデータ間でページ間のUUIDが一致しているか調べる
    # check the page UUID is match to the new data
    try:
        with open(pagePath,"rt",encoding="utf-8") as oldData:
            if(pageType == "markdown"):
                datastring = oldData.read()
                splitResult1 = datastring.split("++++")
                if(len(splitResult1) < 3):
                    await errorResponse(
                        websocket,
                        request,
                        "The old data string is malformed. Unable to find the metadata.",
                        [notebookName,pageID,pageType,pagePath,splitResult1,updateDataString]
                        )
                    return
                OLDpageMetadataJSON = json.loads(splitResult1[1])
                if(OLDpageMetadataJSON["UUID"] != pageMetadataJSON["UUID"]):
                    await errorResponse(
                        websocket,
                        request,
                        "The page UUID does not match to the old one.",
                        [notebookName,pageID,pageType,pagePath,pageMetadataJSON,OLDpageMetadataJSON],
                        error
                        )
                    return
            else:
                oldData = json.loads(oldData.read())
                if(oldData["UUID"] != pageMetadataJSON["UUID"]):
                    await errorResponse(
                        websocket,
                        request,
                        "The page UUID does not match to the old one.",
                        [notebookName,pageID,pageType,pagePath,pageMetadataJSON,oldData],
                        error
                        )
                    return
    except Exception as error:
        await errorResponse(
            websocket,
            request,
            "Failed to compare page UUID string.",
            [notebookName,pageID,pageType,pagePath,pageMetadataJSON,updateDataString],
            error
            )
        return

 どうしてそういうことになるのかわからないが、すぐ思いつく原因としては、Freeページコンポーネントの挙動が思った通りでないのかもしれないというのがあった。私の現在のイメージだと、別のページに移動したら、前動かしていた、コンポーネント自体が破棄されて、その内容も次開くときは最初から初期化するって感じであったが、たぶん違うような気がしてきた。この点について、深く調べていく必要がありそうである。

 ほかにも気になる挙動としては、Freeページを開いたとき、閉じたときにそれぞれ変更をしていないにもかかわらず、セーブしようとする挙動が出ている点である。なんか、変更があったかどうかを独立して管理する変数を用意したほうがいいかもしれないと思ったりもした。

Free ページにおいて、Properties 項目の追加

 細かい書式設定とか、各アイテムタイプ固有の操作について、汎用的な入れ物として、Properties という項目を toggleable として edit に追加してみた。まだ詳しい中身は作っていないが、選択された要素のタイプに対して、機能が展開されるようにする予定。

properties項目を追加した様子

 具体的には、textViewなどが、自分から詳細な設定項目の画面を設定しに行く。zustandでとりあえず、各アイテムタイプに必要なUIを追加できるように、ストアを作ってはおいた。ただすごく粗削りでまだ動くところまで行ってないので、これについては微調整をさらにしていき、実際に動くかテストする予定。

export type aProperty = {
    name        : string,
    UUID        : string,
    itemType    : string,
    settingItems: (({ name,settingElementWithCallbackForSave }:{ name:string,settingElementWithCallbackForSave:React.JSX.Element[] }) => JSX.Element)[]
    settingUI   : ({ name, items }: { name: string; items: React.JSX.Element[];}) => JSX.Element
}

export type properties = { 
    properties      : aProperty[],
    addProperty     : (property:aProperty) => void,
    removeProperty  : (property:aProperty) => void,
    getProperties   : (itemType:string) => aProperty[],
    cleanProperty   : () => void
}

export const useFreePagePropertiesStore = create<properties>((set,get) => ({
    properties: [],
    addProperty: (property:aProperty) => {
        const oldProperties = get().properties
        const newProperties = []
        for(const anProperty of oldProperties){
            if(anProperty.UUID == property.UUID) return
            newProperties.push(anProperty)
        }
        newProperties.push(property)
        set({properties:newProperties})
    },
    removeProperty: (property:aProperty) => {
        const oldProperties = get().properties
        const newProperties = []
        for(const anProperty of oldProperties){
            if(anProperty.UUID == property.UUID) continue
            newProperties.push(anProperty)
        }
        set({properties:newProperties})
    },
    getProperties   : (itemType:string) => {
        const targets = []
        const properties = get().properties
        for(const property of properties){
            if(property.itemType == itemType){
                targets.push(property)
            }
        }
        return targets
    },
    cleanProperty: () => {
        set({properties:[]})
    }
}))

// const test:aProperty = {
//     name:"test",
//     UUID:"test",
//     settingUI: Aproperty
// }

// <div className="aProperty">
//     <div className="name">Format</div>
//     <div className="items">
//         <div className="anItem">
//             <div className="itemName ml-auto">FontSize</div>
//             <input type="range" id="fontsize" min={10} max={100}></input>
//         </div>
//         <div className="anItem"></div>
//     </div>
// </div>

// This can be nested.
// items arg has to be AnItemForAproperty element array
export function Aproperty({ name,items }:{ name:string,items:React.JSX.Element[] }){
    return <div className="aProperty">
        <div className="name">{name}</div>
        <div className="items">{items}</div>
    </div>
}

// This have to be inside of Aproperty element
export function AnItemForAproperty({ name,settingElementWithCallbackForSave }:{ name:string,settingElementWithCallbackForSave:React.JSX.Element[] }){
    return <div className="anItem">
        <div className="itemName ml-auto">{name}</div>
        <div className="settingElement">{settingElementWithCallbackForSave}</div>
    </div>
}

参考にしたサイトとか