この記事を作った動機

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

動作の様子 (フロントエンド)

TODOs

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

主な項目

  • ページ作成機能の実装

    • linux だけでなく windows 環境において、再帰的にフォルダを作成できるようにする。 -> winpath = someFullAbsolutePath.replace("//","/").replace("/","\")
    • mkdir helper 関数の実装
  • 編集機能、データセーブ(更新)の実装

    • バッファーのデータストアを実装
    • 別のページを開いても未保存のデータのバッファが管理されるようにする
  • 内部構造を説明する図とかドキュメントを作る

    • フロントエンド
      • 共通のUIについて、使い方の説明について、ドキュメントを書いた。
        • overlayWindow
        • messagebox
        • toolsbar
        • page
      • drawio で説明する図を作る
        • とりあえず図を書くための準備をした。
        • 他人が構造の理解に必要な分を記録できたか
      • z-index について、どのような割り当てになっているか記録
    • バックエンド(更新)
      • 新たに追加した、task の仕組みについて、ドキュメントを整備する。
  • free ページの実装

    • フロントエンド
      • オブジェクト変形用に汎用的なコンポーネントを定義する。カーソルがオブジェクトをホバーしているときに出現するようにする。タッチでは、一回タップで出現。(サイズ変更、移動)
      • 編集モードを各アイテムの要素の種類ごとに実装。汎用的には、クリックされた場合に編集モードに移行するようにする。タッチではダブルタッチで移行する。
    • バックエンド
  • selector の改善

    • ノートブックはドロップダウンで選択させて、選択されたノートブックの階層構造やページリストを表示するようにする
    • Selector に未保存の表示を行う
  • OverlayWindow において、リサイズやサイズ制限をオプションで付ける。デフォルトでは無効にして、とりあえず selector においてそれが機能するようにする。

  • Free ページの要素について、基本要素の項目にアイテムの色をRGBAで設定できるようにする。

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

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

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

  • バックエンド割り込みの実装

    • バックエンドからフロントエンドの方向の割り込みをすでにドキュメントである程度定義したとおりに実装する。例えば、ページ内容に変更があったら通知したりする。
    • helper.commonとして、sendIntteruptを実装する。
    • 割り込みが必要なコマンド群から、割り込みを送るようにする。また各コマンドのドキュメントに割り込みについて説明を書く。これに伴いテンプレートも割り込みについて記述するように、更新を行う。
    • フロントエンド側を割り込みを受けつけるように、改修していく。自動的に、フロントエンドが、バックエンドの割り込みによって連動して機能することを確認する。例えば、ページが作成されたとき、内容が変更されたとき、ノートブックが削除された時などに、データサーバが接続しているすべてのフロントエンドに、その通知が行く。
    • 割り込みの仕組みについてのドキュメントの記述が見つからない場合、新たに定義し、それをもとに実装を進める。
  • エラーハンドリングにおける共通のerrorResponse関数を helper.common に実装したので、それをなるべく使うように全体のコードを更新していく。特にフロントエンドと関係のあるところで使う。

  • エラーハンドリングにおいて、except の エラーのメッセージを握りつぶさない。

except Exception as e:
    print("[commandName] ERROR: error message")
    print(e)
  • 再接続機能が正しく機能しない場合を検証する

他の事項

  • File API 周りの実装
  • マルチメディアの対応
  • messagebox の実装

やったことの詳細

errorResponse 関数の仕様を変更

 helper.commonerrorResponse関数で、Python のExceptionをメッセージとして表示する仕様に変更した。ドキュメントもそれに応じて書き直し、引数について説明を追加した。なお、exception メッセージは強制ではなく、任意ではある。

 「エラーハンドリングにおいて、except の エラーのメッセージを握りつぶさない。」の TODO があったので、より一貫性のある実装としてまとめる意味合いもこの仕様変更にはある。

async def errorResponse(websocket,request:dict,errorMessage:str,variablesList:list,exception = None):
    print("{} ERROR: {}".format(request["command"],errorMessage))

    variableState = {}
    print("{} ERROR: variable state --------------------------".format(request["command"]))
    for aVar in variablesList:
        # print variable name and data
        # https://stackoverflow.com/questions/18425225/getting-the-name-of-a-variable-as-a-string
        # f'{aaa=}'.split("=")[0]
        varName = f'{aVar=}'.split("=")[0]
        print("{}:\n{}\n".format(varName,aVar))
        variableState[varName] = str(aVar)
    print("{} ERROR: variable state end --------------------------".format(request["command"]))
    print("{} ERROR: exception message --------------------------".format(request["command"]))
    print(exception)
    print("{} ERROR: exception message end --------------------------".format(request["command"]))

    responseString = json.dumps({
        "status"        : "error",
        "errorMessage"  : errorMessage,
        "UUID"          : request["UUID"],
        "command"       : request["command"],
        "data"          : variableState
    })
    await websocket.send(responseString)
    print(">>> " + responseString)

mkdir 関数の実装

 バックエンド内において、共通でフォルダ作成に使える、helper.commonmkdir関数を実装した。ドキュメントも書いた。

# arg:
#   absoluteFolderPath : full folder path to create
# return value
#   OK      : False
#   Error   : True will be returned when failed to create folder.
def mkdir(absoluteFolderPath:str):

    if(checkTheAbsolutePath(absoluteFolderPath)):
        print("mkdir helper: This is malformed path")
        print(absoluteFolderPath)
        return True

    if(platform.system() == "Windows"):
        winpath = absoluteFolderPath.replace("//","/").replace("/","\\")
        command = ["mkdir",winpath]
        print("winpath     : " + winpath)
    else:
        command = ["mkdir -p " + absoluteFolderPath]
    result = subprocess.run(command,shell=True,capture_output=True)
    print("folder path : " + absoluteFolderPath)

    if(result.returncode != 0):
        print("mkdir helper: Unable to create the folder.")
        print(str(result.stdout))
        print(str(result.stderr))
        return True

    return False

checkTheAbsolutePath 関数の実装

 helper.commoncheckTheAbsolutePath関数を実装した。ドキュメントも書いた。この関数の実装の意図としては、いちいち各 helper 関数ごとにパスをチェックするのは面倒だったので、まとめて処理しようと思ったというのがある。具体的には、mkdir関数内で、deleteDataSafely関数内で行われていることと同じことをいちいち書くのは面倒だというのがあった。

# arg:
#   absolutePath    : file or folder path
# return value
#   OK      : False will be returned.
#   Error   : True  will be returned when the path is malformed.
def checkTheAbsolutePath(absolutePath:str):
    # check the absoluteDataPath is something malisuous or not.
    if(absolutePath == "/" or absolutePath == "C:\\"):
        print("checkTheAbsolutePath: Failed. Try to delete root directory.")
        return True
    
    # check the absoluteDataPath is relative path or not.
    if(absolutePath[0] == "."):
        print("checkTheAbsolutePath: Failed. A relative path is specified. Use absolute path.")
        return True

    # check the absoluteDataPath try to delete outside content of the notebooks or not.
    isNotebookPath = False
    for aNotebookStoreRoot in loadSettings.settings["NotebookRootFolder"]:
        if(aNotebookStoreRoot in absolutePath):
            isNotebookPath = True
            break
    if(not isNotebookPath):
        print("checkTheAbsolutePath: Failed. The absoluteDataPath is outside of the notebook store.")
        return True
    
    return False

errorResponse 関数の仕様を変更

 変数名と変数の内容を表示するという仕様は、現状ではすぐには成り立たせるのが難しいことが分かったため、変数の内容だけ表示するように変更した。

 具体的な問題としては、引数としてリストで渡している変数の名前を以下の実装で正しく取得できないことが分かったというのがある。この実装では、for ループで一時的に内容を読みだすために使っているaVarの変数の名前が、引数として渡した変数の名前として現れてしまう。

...
    variableState = {}
    print("{} ERROR: variable state --------------------------".format(request["command"]))
    for aVar in variablesList:
        # print variable name and data
        # https://stackoverflow.com/questions/18425225/getting-the-name-of-a-variable-as-a-string
        # f'{aaa=}'.split("=")[0]
        varName = f'{aVar=}'.split("=")[0]
        print("{}:\n{}\n".format(varName,aVar))
        variableState[varName] = str(aVar)
...

 今回は、シンプルにする方向性で修正を考え、結果として変数名は表示せずに、変数の内容だけ表示し、変数の内容をリスト形式にまとめてフロントエンドに送るようにした。

...
    variableState = []
    print("{} ERROR: variable state --------------------------".format(request["command"]))
    for aVar in variablesList:
        # print variable name and data
        # https://stackoverflow.com/questions/18425225/getting-the-name-of-a-variable-as-a-string
        # f'{aaa=}'.split("=")[0]
        print("{}\n".format(aVar))
        variableState.append(str(aVar))
...

selector の実装を変更

 全部のノートブックの情報を表示するのではなく、選択されたノートブックに分だけの表示になるように変更した。

selectorの新しい実装

free ページの実装を進めた

  • free ページの基本的なデータ読み込みから、セーブ、終了までのフローができた。ちなみに undo、redo についてはまだ free ページにおいては実装する気がない。

    1. バックエンドからデータを取得し、free ページが読み込まれる。この時 useFreePageItemsStore の items 配列にデータが追加されていく。
    2. useFreePageItemsStore を経由して、テキストを編集した、新たなアイテムを追加した、アイテムを消したなどの変更があると、useFreePageItemStoreEffect により、自動的に updatePage コマンドを使って内容をセーブする。
    3. 終了時には、useFreePageItemsStore の items の内容が初期化されて、次の表示のために準備される。
  • 編集機能として何が欲しいか書き出してみた。

    • add item
    • remove item
    • resize item
    • move item
    • range selection
    • drop item
  • useFreePageItemStoreEffect を廃止した
     useFreePageItemStoreEffect として hooks をまとめていると、zustand の挙動が怪しくなり、ReactのHooksに準拠していないというエラーになったので、free ページの main.tsx にとりあえずその中身は移動しておくことにした。

  • 右クリック menu の実装
     右クリックしたときのメニューを部分的に実装した。

    部分的なメニューの実装例

messageBox 向けに画像を作った

 これについては、後からいくらでもできるということで、そこまで深く考えて作っておらずとりあえず機能する程度に仕上げた。

ok

ok

error

error

warning

warning

info

info

messageBox を実際に動くようにした

info

バッファーのデータストアを実装

 保存されていないページのデータを全体で管理するためのストアを作り、型定義をした。

// TODO: create buffer store for each page
// TODO: make all pages use the buffer store to prevent from losing the unsaved data.
// TODO: if there are unsaved buffers, ask the user to save or throw away the data before the frontend is closed.
// https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal
export type AnUnsavedBuffer = {
    notebookName        : string,
    pageID              : 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) => 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.UUID == unsavedBuffer.UUID) continue
            newBuffer.push(aBuffer) 
        }
        newBuffer.push(unsavedBuffer)
        set({buffers:newBuffer}) 
    },
    getBuffers: (pageID:string) => {
        const allBuffer = get().buffers
        const buffers = []
        for(const aBuffer of allBuffer){
            if(aBuffer.pageID == pageID) 
                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) 
        }
        newBuffer.push(unsavedBuffer)
        set({buffers:newBuffer})    
    },
    getUnsavedPageList: () => {
        const buffers = get().buffers
        const pageIDlist:string[] = []

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

フロントエンドの共通のUIの実装に関して、ドキュメントを書き始めた

 フロントエンドに存在している、共通のUIパーツは使い方をドキュメントとしてまとめておくことにした。現状では以下の項目について、記録をした。

  • OverlayWindow
  • toolsBar
  • messageBox

 以下は記録の例である。

フロントエンドのUIの実装について記録を始めた例

参考にしたサイトとか