週間アラーム(早速のTauriの習作アプリケーション)

今更の話だけど、コロナ禍でリモートワークなど自分も含めて自宅で活動することが多くなり、自宅などでも適切なタイムスケジュールでメリハリを付ける必要が増えてきたと思います。

そのためには会社や学校と同じように指定した時間割でアラーム(チャイム)を鳴らす設備があると便利かと思い、既存のアプリケーションを探して使用しておりました(スマホ/タブレット用アプリ)。

使用したアプリはスケジューリングした指定時刻に好きな音源を鳴らすことが出来て便利だったが、カレンダーとの関連が無く、日祝日など休みの日にもチャイムが鳴ってしまうのでその度にOFFにするのが面倒でした。

ならば作ってみようネタにしようということで、構想から(・・・というのが10月くらいのお話)。

  1. 平日<休日<曜日個別<祝祭日<個別指定日 の優先度でスケジューリングしたアラームタイムテーブルで運用
  2. アプリケーションの見た目は時計表示機能をもつGUIでスケジューリング等の設定を行う
  3. デフォルト音源(フリーの音源素材)のほか任意のMP3音源をアラームとして使用可能
  4. ターゲット機器はPC。ただしアラーム用に占有出来るよう安価なもの

4のターゲット機器は、安価なものとしてRasberryPiにUSBスピーカーを接続した構成とした(今となってはRaspberryPi自体が入手困難な貴重なデバイスですが・・・w)

ターゲットはLinuxだけど、GUIなどデバッグはMacOSでやりたいのでマルチプラットフォームGUIのTauriが最適ということで、アプリケーションフレームワークとしてTauriを採用という、まさに取って付けたような結論ありきの流れ。。

TauriであればフロントエンドはWebと同等なので3の音源再生も造作も無しと。

で、一通りの機能を実装して完成したアプリケーションが左のレポジトリ。MacOSでの実行画面が下記になります。折角Bootstrapを使用したフロントエンドなのにこのデザインセンスよwww。とりあえずは実用的には動けばいいや的なものになっています。

スケジュール管理画面
タイムテーブル編集状態

アラームスケジュールの設定は左記のように、デフォルトで平日<休日といった優先度の低い日程が定められており、ユーザーはスケジュールを特に変更したい日を個別に追加する。

それぞれのスジュール日程に対してアラームセットと呼ばれるタイムテーブルを設定する。アラームセットは個別データとなっているので、別のスケジュール日程に同じアラームセットを適用することが可能(左下画面)。

画面は省略するが、祝祭について、2023年まではデフォルト設定してあるが、それ以降はユーザーで追加する(祝祭日でーたをCSVファイルで一括登録も可能)。

しかし、さすがに工夫無しで使用するとUIがWEBアプリのそのまんまになってしまいますね。もう少しデスクトップアプリっぽくしたかったのですが。。。

あと、マルチプラットフォームGUIで苦労した所として、Linux環境だけ音源再生出来ない問題がありました。

もともとWEBアプリでは、ページロード後にユーザー操作無しには音声再生出来ないような仕組みになっている(広告アプリなどが勝手に大音量で鳴らし始めたら大変なので)ので、同じエンジンを使用しているTauriでも初期には同様の問題があったらしいが、デスクトップアプリでそれでは不便だろうとのことで対策されているはず?なので、本家GithubのDiscussionに質問を投げているが、イマイチ反応は無いですねーΣ(゚д゚lll)

一応自前で解決しているが、何か原因が良く分からない解決方法でした(笑)

これをキッカケに本家のGithubに爪痕でも残せたら、、、と思ってたけど。うーん高い壁なのかなw
(機械翻訳のいい加減な英文投稿でうまく伝わらなかったのかもwww)

Tauriアプリケーション開発メモ(Webpack+Typescript+React)

いままで覚えるのが面倒とかいう理由でフロントエンドのフレームワークを使用せずに素のTypescriptでゴリゴリ書いていたが、そろそろ限界というかネタ不足と言うことで、TauriアプリケーションにおいてReactをフロント側のフレームワークとして使用することにしました。

ということで、Typescript+Reactをフロントエンドに使用したTauriアプリケーション開発準備メモ。

以下のような手順でアプリケーションを作成します。

  1. プロジェクトディレクトリの作成
  2. フロントエンドの準備
  3. Tauri (Rust)プロジェクトの作成
  4. コンパイル

プロジェクトディレクトリの作成

アプリケーション名に準ずるディレクトリを作成して、移動します。

フロントエンドの準備

npmの初期化を行い、Webpack、Typescript、 Sass、Reactをインストールします

$ npm init -y
$ npm install -D webpack webpack-cli webpack-dev-server
$ npm install -D typescript ts-loader sass style-loader css-loader postcss-loader sass-loader
$ nom install -D babel-loader @babel/core @babel/preset-env
$ npm install -S react react-dom @types/react @types/react-dom
$ npm install -D @babel/preset-react

package.jsonに デバッグ/ビルド script を追加

scripts: {
    "build": "webpack",
    "dev": "webpack serve"
},

typescriptの設定初期化

$ npx tsc --init --target es6 \
                --module es2022 \
                --lib es2022,DOM \
                --sourceMap true \
                --moduleResolution node \
                --allowSyntheticDefaultImports true \
                --resolveJsonModule true \
                --jsx react

webpack.config.js作成

webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: './src/index.tsx',
  output: {
    path: path.join(__dirname, "dist"),
    filename: "main.js",
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env', '@babel/react']
            },
          },
          {
            loader: 'ts-loader',
            options: {
              configFile: path.resolve(__dirname, 'tsconfig.json'),
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
        ],
      },
      {
        test: /\.scss$/,
        use: [{
          loader: 'style-loader'
        }, {
          loader: 'css-loader'
        }, {
          loader: 'postcss-loader',
        }, {
          loader: 'sass-loader'
        }]
      },
      {
        test:/\.SVG$/,
        type: 'asset/source',
      },
      {
        test:/.(gif|svg|png|jpg|jpeg|JPG)$/,
        type: 'asset', /**  default <8kb: inline, >8kb: resource  */
        generator: {
          filename: 'image/[hash].[ext]',
        }
      },
      {
        test:/.(mp3|wav|aac)$/,
        type: 'asset/resource',
        generator: {
          filename: 'audio/[hash].[ext]'
        }
      },
      {
        test:/.(woff|woff2|eot|ttf|otf)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[hash].[ext]'
        }
      }
    ]
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'dist'),
    },
    port: 3000,
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json", ".wasm"]
  },
  experiments: {
    outputModule: true,
    asyncWebAssembly: true,
  },
  target: 'web',
};

ソースディレクトリ(src)と生成物ディレクトリ(dist)の作成とテンプレ作成

dist/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tauri App Title</title>
  <script async type="module" src="main.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';

new Promise(resolv => window.onload = resolv).then(() => {
    const root: ReactDOM.Root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(
        <div>sample contents</div>
    );
});

Tauriプロジェクトの作成

$ cargo tauri init

ターゲットディレクトリを../distとするほか、フロントエンドが先に作成されているのでデフォルト通りでOK。

コンパイル&デバッグ

$ cargo tauri dev

その他UI構築にbootstrapを使いたい場合は
$ npm install -D bootstrap @types/bootstrap react-bootstrap @types/react-bootstrap
と、当然Tauri APIを使用するなら
$ npm install -D @tauri-apps/api
など・・・・とな。

TauriによるRustデスクトップアプリケーション開発

Webpack(Typescript、Sass)フロントエンド開発環境

Rustと関連クレートをインストールしたら、アプリケーションプロジェクトを作成します。

通常のRustプロジェクトであれば、cargo init [project name]とするのですが、Tauri開発環境ではフロントエンドとの同時進行開発となるので、まずはプロジェクトのトップディレクトリの作成から

$ mkdir [project name]
$ cd [project name]

として作業を始めます。ここではプロジェクト名を単純に「proj」としておきます。

次に下記コマンド cargo tauri init で対話的にプロジェクトを作成します。

  • プロジェクト名
  • タイトル
  • フロントエンドのアセットディレクトリ。生成されるRustプロジェクトからの相対パスとなるので、../dist の様にする。
    この中にあるindex.htmlがフロントエンドデザインの起点となる
  • 開発用サーバーの設定。上記アセットと同じパスにしても良いが、本番構成と同じTauiの内部サーバーを使用するので毎回コンパイルが必要となるため、開発用サーバーを立ち上げてそのURLを記載する方が便利かも。(後述のwebpack-dev-server)を使用するのが良いと思います。
proj$ cargo tauri init
What is your app name? · [project name]
What should the window title be? · [project name]
? Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that w
Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created? · ../dist
What is the url of your dev server? · ../dist または http://localhost:8080

プロジェクト名その他は後からでも変更可能のようです。(src-tauri/tairu.conf.json)

設定したアセットディレクトリと、その中にindex.htmlを作成します。内容はとりあえずテンプレート的な感じで。(VScodeなら1文字目に’!’を入力して[tab]キーでドーンとテンプレートが入力されます)

proj$ mkdir dist
proj$ touch dist/index.html
proj
├── dist
│   └── index.html
└── src-tauri
    ├── Cargo.lock
    ├── Cargo.toml
    ├── build.rs
    ├── icons
    │   ├── 128x128.png
    │   ├── ........
    │   ├── 32x32.png
    │   ├── icon.icns
    │   ├── icon.ico
    │   └── icon.png
    ├── src
    │   └── main.rs
    └── tauri.conf.json		

ここまでのディレクトリ構成は左のようになります。

src-tauri以下がRustのプロジェクトです。dist以下がフロントエンドを構成するアセットディレクトリで、ここからWEBアプリと同様なノリで進めることが出来ます。

ちなみにこの段階で、tauriアプリとしてコンパイル&実行が出来ます。

proj$ cargo tauri dev

しかし、まだ真っ白なウインドウだけのアプリですが・・・・w

また、プロジェクト初期化の段階で開発サーバーにhttp://localhostとか設定していた場合は、まだサーバーを立ち上げていないので何も起こりません。

ここからフロントエンドの開発環境を整えます。まずはプロジェクトのトップディレクトリでnodejsの初期化と必要なものを色々とインストールします

proj$ npm init -y
proj$ npm install --save-dev webpack webpack-cli webpack-dev-server
proj$ npm install --save-dev typescript sass ts-loader sass-loader css-loader
proj$ npm install --save-dev postcss-loader style-loader
proj$ npm install --save-dev @tauri-apps/api @tauri-apps/cli

片っ端からインストールしている感じがしないでもないですが、これだけあれば足りるでしょうか。

次にTypescriptの初期化。この辺は好みがあるかもなので、テキトウに。。。

proj$ npx tsc --init --target es6 --module ES2020 --lib ES2020,DOM --sourceMap true --moduleResolution node --allowSyntheticDefaultImports true --resolveJsonModule true

webpack.config.jsも通常のWEBアプリと同様の設定としました。

webpack.config.js

const path = require("path");
module.exports = {
  mode: "development",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader"
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
        ],
      },
      {
        test: /\.scss$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader'
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: function () {
                  return [require('autoprefixer')];
                }
              }
            }
          },
          {
            loader: 'sass-loader'
          }
        ]
      },
      {
        test:/\.xml$/,
        loader: 'xml-loader',
      },
      {
        test:/\.html$/,
        loader: 'html-loader',
      },
      {
        test:/\.SVG$/,
        //loader: 'svg-inline-loader'
        type: 'asset/source',
      },
      {
        // https://ics.media/entry/16329/
        test:/.(gif|svg|png|jpg|jpeg|JPG)$/,
        // type: 'asset/inline',
        type: 'asset', /**  default <8kb: inline, >8kb: resource  */
        generator: {
          filename: 'image/[hash].[ext]',
        }
      },
      {
        test:/.(mp3|wav|aac)$/,
        type: 'asset/resource',
        generator: {
          filename: 'audio/[hash].[ext]'
        }
      },
      {
        test:/.(woff|woff2|eot|ttf|otf)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[hash].[ext]'
        }
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json", ".wasm"]
  },
  experiments: {
    outputModule: true,
    asyncWebAssembly: true,
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'dist')
    },
    port: 8080,
  }
}

webpackはデフォルトではソースはsrc/index.js、生成物はdist/main.mjs(モジュール版)となっているので、上記のファイル構成に対してトップへsrcディレクトリを追加します

proj$ mkdir src
proj$ touch src/index.js

そしてdist/index.htmlに埋め込むために次の一行をHEADの最後に追加

  <script type="module" src="./main.mjs"></script>

package.jsonに実行環境のスクリプトを追加

  "scripts": {
    "build": "webpack",
    "dev": "webpack-dev-server"
  },

src-tauri/tauri.config.json を変更

  • beforeBuildCommand : Build前に実行されるコマンド、この場合Webpackコンパイルのみ
  • beforeDevCommand:デバッグ時に事前に実行されるコマンド。webpack-dev-server起動、webpackコンパイルもリアルタイムに行われる。
  • devPath:webpack-dev-serverに置き換える。これによりデバッグ状態でもフロントエンドの編集がリアルタイムに反映されるようになる
  "build": {
    "beforeBuildCommand": "npm run build",
    "beforeDevCommand": "npm run dev",
    "devPath": "http://localhost:8080/",
    "distDir": "../dist"
  },

しかし、実際のデバッグはVScode上で行われるので、.vscodeディレクトリと各種設定を行う(VScodeのデバッグでは上記tauri.config.jsonは使われないようです)

proj
├── .vscode
│   ├── launch.json
│   └── tasks.json
└── src-tauri
・・・・・
.vscode/*

launch.json

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
      {
        "type": "lldb",
        "request": "launch",
        "name": "Tauri Development Debug",
        "cargo": {
          "args": [
            "build",
            "--manifest-path=./src-tauri/Cargo.toml",
            "--no-default-features"
          ]
        },
        // task for the `beforeDevCommand` if used, must be configured in `.vscode/tasks.json`
        "preLaunchTask": "dist:dev"
      },
      {
        "type": "lldb",
        "request": "launch",
        "name": "Tauri Production Debug",
        "cargo": {
          "args": ["build", "--release", "--manifest-path=./src-tauri/Cargo.toml"]
        },
        // task for the `beforeBuildCommand` if used, must be configured in `.vscode/tasks.json`
        "preLaunchTask": "dist:build"
      }
    ]
  }

tasks.json

{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
      {
        "label": "dist:dev",
        "type": "shell",
        // `dev` keeps running in the background
        // ideally you should also configure a `problemMatcher`
        // see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson
        "isBackground": true,
        // change this to your `beforeDevCommand`:
        "command": "yarn",
        "args": ["dev"],
      },
      {
        "label": "dist:build",
        "type": "shell",
        // change this to your `beforeBuildCommand`:
        "command": "yarn",
        "args": ["build"],
      }
    ]
  }

コレで、VScodeで開発・デバッグが出来るようになりました。

フロントエンドの開発が殆どWEBアプリのそれと同じなのが良いですね。ていうか、特にバックエンドの要らないアプリだとjavascriptだけでデスクトップアプリが出来てしまうという。逆に殆どをRsutによるバックエンドだけにしてもアプリケーションを構成出来たり、アプリの用途や開発者の好みでどちら寄りにも出来てしまいそうなのが面白い。

TauriによるRustデスクトップアプリ開発環境構築メモ

Rustのインストール

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 全てデフォルトで作成するので下記のように、フルオートでインストール出来る
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# 下記コマンドでパスを設定
$ source $HOME/.cargo/env

Cargoで必要なモジュールをインストール

$ cargo install cargo-generate
$ cargo install cargo-update
$ cargo install cargo-edit

VScodeなどによるデバッグ用に下記ツールをインストール

$ rustup component add rls rust-analysis rust-src

ツールのアップデート

$ rustup update
$ cargo install-update --all

Tauri関連のインストール

$ cargo install kauri-cli
$ npm install --save-dev @tauri-apps/cli

とりあえずこんな感じで、MacOS、Windows、Linux(Ubuntu)でやってみました。次は簡単なアプリケーションを作成してみる

Rustアプリケーション開発(Tauri)

以前の記事でチョットだけ触れたRsut言語ですが、チェックしていたTauri(デスクトップアプリケーション開発フレームワーク)が6月16日にバージョン1.0で正式公開されたのを機に、当方でもアプリ開発のツールとして導入することにしました。

Rustのデスクトップアプリと言うか、その構成はWEBアプリに近く、Rust作成するバックエンドコアを軸にUIなどのフロントエンドは各OSが持つブラウザエンジンが担当する。従ってフロントエンドはブラウザアプリと同様に主にHTML5で書くことになり、さらに同じソースファイルでマルチプラットフォームを実現するというコンセプトだそうです。

現在対応中のプラットフォームは

  • Windows(7以降)
  • MacOS
  • Linux

で、その他AndroidやiOSへの対応もすすんでいるという(こちらが導入の目的だったりする)。

自分の触手が動いたのは上記のマルチプラットフォームが第1に上げられるが、その他

  • フロントエンドの開発がWEBアプリのそのままで、Webpack、Sass、Typescriptなどの環境がそのまま使え、あまり新しいことを覚える必要が無い
  • 最新の言語Rustで開発出来る事。これが目的の人には待望の環境かも知れないですね。

ということで久々なのに短めですが、次回よりまた開発環境の構築などからメモしていこうと思います。

新ポータルサイト運用開始

Djangoで運用してきたmarochanetポータルサイトですが、本日半年かけて独学で構築してきたFastAPI版のポータルサイトとしてリニューアルしました。

左のプレビューリンクですが、さすがオールWEBPACKによるシングルページアプリケーション。文章が拾われない・・・・というか一応トップの画像を拾っていると言うことはJSが動いているところまでプレビュー出来ているのか。

ユーザーログイン機能や各種リンクと、まだまだ機能が少ないなりにもDjango版(こちらも殆ど無機能)と同等以上は役割を果たせるのではということで運用に踏み切りました。

但し、アカウント管理などセキュリティー部分は自作なのでDjango版と比べて信頼性の面ではイマイチということで暫くはログとにらめっこしながらの運用となりそうですw

右がブランドアイコン。所以は非公開ですが、「− Glutton Orca − 暴食のシャチ」という感じで。

前職でファーストペンギンにエライ目に遭ったという苦い経験もあり《ファーストペンギンを屠るものw》という称号も持たせています。

あんまり強そうではないのはご愛敬ということで。

新しいポータルにはポートフォリオということで活動内容を簡単に紹介するような内容を充実させていく予定です。

こちらのブログでチマチマ書いてきたことをまとめる感じですかね。

Glutton Orcaというか、、中二?。

たまには旅行記事

毎日毎日暑い日が続くので、避暑ドライブということで蓼科へ行ってきました。これといって目的があるわけでも無く、じゃらんでテキトウに良い感じのペンションを探しつつの行き先選びだった。

結果的な行程は左のとおり、

  1. 起点諏訪インターチェンジ
  2. ビーナスラインを通って道の駅蓼科湖
  3. 八ヶ岳ロープウェイで山頂駅へ、坪庭周回
  4. 森のペンションリトルフットで一泊
  5. はとざわ園芸直売で野菜購入
  6. もう一度八ヶ岳へ行き、蓼科アミューズメント水族館
  7. 白樺湖観光センターを起点に白樺湖一周散歩
  8. 伊那丸富士見台駐車場で景色を堪能?
  9. 起点の諏訪インターまで戻りー帰路

C,D,E,Fは同じ道をかなり行ったり来たりしてますw

道の駅蓼科湖

不意に見つけた道の駅。蓼科湖は1周20分くらいの小さな湖。

蓼科湖 湖畔
周回歩道

八ヶ岳山頂駅坪庭

チェックインギリギリまでの時間でロープウェイを上り、山頂の坪庭ハイキングコースを周回

山頂駅案内図
こんな道だけなら良かったが・・・
並装だと結構ヤバい下り石段

森のペンションリトルフット

じゃらんで予約したペンションで一泊。

ひっそりとした場所にあるペンション
ワイン付き夕食を頂きました
翌朝窓の近くにいた蜘蛛
朝食はテラスでした
翌朝散歩で再びロープウェイ乗り場

蓼科アミューズメント水族館

ロープウェイ乗り場の近くにまさかの水族館があったので、、

白樺湖周回散歩

1周3.8km

とにかくスワンボートの多さに、、
蓼科山
カヌー教室? 次回来たときはチャレンジしよう
それほど多くない白樺並木

ビーナスライン〜車山

このXP感

八ヶ岳ロープウェイ頂上駅の気温が17度!別世界。。。

ES6とDOMの件:Typescript編

小さな規模のアプリしか作らないこともあり、フロントエンドに関しては頑なにフレームワークを使わない勢の私は、FastAPI、webpack、wasm(Rust)までに手を出しつつも、未だに素のTypescriptで何とかしようとしている(Typescriptの段階で「素」では無いかも知れないが)。

とくにDOMに関しては以前の記事にあるようなヘルパ関数を使用して、比較的構造的に組み立てられる仕組みを自作し、利用している。下記のように作成・追加したエレメントを編集(属性や子要素を追加する)する関数で、引数の関数の中でさらに要素を追加して属性設定やさらに子要素追加するなど、入れ子構造でコーディング出来る。

/**
 * ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
 */
 HTMLElement.prototype.editElement = function(func) {
  func(this);
  return this;
} 
/**
 * ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
 */
HTMLElement.prototype.appendChain = function(child) {
  if(child instanceof HTMLElement){
    this.appendChild(child);
  } else if (typeof(child) === "string") {
    this.append(child);
  }
  return this;
}
/**
 * ③ 子要素をクリアしたのち自身を返す
 */
HTMLElement.prototype.clearElement = function() {
  this.innerHTML = "";
  return this;
}
/**
 * ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
 */
HTMLElement.prototype.appendElement = function(tag, func) {
  const elm = document.createElement(tag);
  if(func) func(elm);
  this.appendChild(elm);
  return this;
}
/**
 * ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
 */
Document.prototype.createElementEdit = function(tag, func) {
  const elm = document.createElement(tag);
  if(func) func(elm);
  return elm;
}

  let body = document.querySelector('article').clearElement();
  body.editElement(bdy => {
    bdy.className = 'bg-secondary';
    bdy.appendElement('p', para => {
      para.style.color = 'red';
      para.textContent = 'test string';
    });
  });

<article class="bg-secondary">
  <p style="color: red;">
   test string
  </p>
</article>

これをTypescriptに移植するのは結構面倒だったので、今回のメインネタにしました。究極には全部anyの型にして押し切ってしまえば動くと言えば動くんですが、せっかくならしっかり作ってエディタサポートの恩恵も得られて何ならjavascriptよりも使いやすくしてしまおうということで下記のように移植しました。

/**
 * インターフェース定義:組込みのHTMLElementやDocumentに追加するために declare global する
 */
declare global {
    interface HTMLElement {
        appendElement<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement
        editElement<T extends HTMLElement>(func: (elem: T) =>  void): T
        appendChain<T extends HTMLElement>(child: HTMLElement | string): T
        clearElement<T extends HTMLElement>(): T
    }
    interface Document {
        createElementEdit<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K]
    }
}
/**
 * ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
 */
HTMLElement.prototype.editElement = function<T extends HTMLElement>(func: (elem : T) => void) : T {
    console.log(this.constructor.name)
    func(this as T);
    return this as T;
}
/**
 * ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
 */
HTMLElement.prototype.appendChain = function<T extends HTMLElement>(child: HTMLElement | string) : T {
    if(child instanceof HTMLElement){
        this.appendChild(child);
    } else if (typeof(child) === "string"){
        this.appendChild(document.createTextNode(child));
    } else {
        throw new TypeError("parameter node: must be string or HTMLElement.");
    }
    return this as T;
}
/**
 * ③ 子要素をクリアしたのち自身を返す
 */
HTMLElement.prototype.clearElement = function<T extends HTMLElement>() : T {
    assertIsInstanceOf(this, HTMLElement);
    this.innerHTML = "";
    return this as T;
}
/**
 * ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
 */
HTMLElement.prototype.appendElement = function<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement {
    assertIsInstanceOf(this, HTMLElement);
    const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
    if(func) func(elm);
    this.appendChild(elm);
    return this;
}
/**
 * ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
 */
Document.prototype.createElementEdit = function<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] {
    const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
    func(elm);
    return elm;
}

いろいろ試行錯誤した結果、上記のように落ち着きました。Typescirptでメソッド追加するためにはInterfaceを定義する必要があるけど、HTMLElementなど組込みのクラスに対して普通にインターフェースを追加で定義しようとしてもエラーになるようなので、上記のようにdeclare globalで定義する必要があるようです。あとは普通にprototypeで追加していくのだが、これも型定義が割と面倒で、createElement系などは引数のタグ名で戻り値の型が決まる変則ジェネリクスなので、typescriptのパッケージの中身を参考にほぼコピペしました(④、⑤)。

また、①や②は、関数でエレメントを編集したり、子要素を追加して自身を返す関数で、メソッドチェーン用に使用していたのですが、HTMLElementへのインターフェースとして実装している関係でthisが常にHTMLElementになってしまう問題があります。例えば<a></a>のリンクタグを追加しようとしても、createElementしたときはHTMLAncorElementであっても関数内で利用する時はHTMLElementとして扱われるのでthis.hrefメンバーが使えないなど。この場合、一旦unknownにしてHTMLAnchorElementにキャストし直す(element as unknown as HTMLAnchorElement)か、下記のような関数でアサートすることで解決出来ます。

/**
 * val が 指定のクラス cls のインスタンスであることをアサートする関数
 * 例えば HTMLELementにキャストされた下位クラス(HTMLInputELementなど)を、もとのクラスとしてアサート
 */
export function assertIsInstanceOf<T>(val: any, cls: new() => T) : asserts val is T {
    if(!(val instanceof cls)){
        throw new Error(`${val} is not instance of cls`);
    }
}

例)

document.createElement('a').editElement(link => {
   link.href = '#'; // ----  × ここではまだlinkはHTMLElementでhrefメンバがないのでエラー
   assertIsInstanceOf(link, HTMLAnchorElement);
   link.href = '#'; // ----  ○ HTMLAnchorElementとしてアサートされているのでOK
});

とは言ってもイチイチアサートするのも面倒な仕様なので、イマイチ移植できていない感があります。
まぁ、実際使用していると、こういう引数が長くなりがちな関数に対してメソッドチェーンを利用しても読みにくくなってしまうだけなので、これらは使用しないということで封印の方向で進んでいますw
エレメントをサクッと作成するdocument.createElementEdit(tag, func)と、構造的にサクッと子要素を追加するelement.appendElement(tag, fuc)だけあれば全然問題がないという結論に達しつつある感じ。

Webpackその他

メモ記事とはいえなかなか書いてる時間がとれず一月以上の間が空いてしまった。FastAPIによるバックエンド実装からTypescriptとSASSによるフロントエンド実装の勉強に入った所、WebAssemblyとその有力言語のRustに興味が写ってしまい、そのあたりを勉強しながらフロントエンドに組み込む技術を調査していたら、Webpackという技術に突き当たった。

WebAssemblyとRustに関しては別の機会に書くとして、今回はWebpack。いままで何度か単語程度に見かけはしたが、なんとなくお腹一杯感でスルーしていたが、必要に応じて調べてみると目からウロコ的な技術だった。

従来の静的コンテンツ(ブラウザ側で処理をする所謂フロントエンド)は、HTMLベースでスタールシート(CSS)やJavascriptを必要に応じて読み込む形式だったが、Webpack化することで、大まかには一つのJavascriptにまとめられる。

例えば下記のようなindex.htmlでは

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
  <style type="text/css">
    @import "/static/css/bootstrap-custom.css";
  </style>
  <script type="module">
    import { Alert, Button, Carousel, Collapse, Dropdown, Modal, Offcanvas, Popover, ScrollSpy, Tab, Toast, Tooltip } from "/static/js/bootstrap.esm.js"
  </script>
</head>
<body class="base">
  <script type="module">
    import {init} from "/static/js/app.js"
    window.addEventListener("load", init());
  </script>
</body>
</html>

6行目〜でbootstrapのカスタムスタイルシート(これは別途SASSでコンパイルしている)を読み込んで、その下9行目〜でbootstrap用のスクリプトをモジュールとして読み込んでいる。Bodyタグを読み込んだあとで14行目〜でシングルページアプリケーション用スクリプトapp.jsを読み込んでページロード完了後にinitを実行(window.addEventListener)。app.jsもTypescriptのソースから別途コンパイルしておりアプリケーションの規模によっては多くのモジュールに分かれてロードされる。

こういった構成のものが、Webpack化することで下記のように出来る

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
    <script src="/static/dist/main.js"></script>
</head>
<body class="base">
</body>
</html>

6行目のmain.js を読み込むだけとなる。main.jsはWebpackによってコンパイルされたjavascriptで読み込むべきリソースが全てパック化された生成物となる。ソースファイルは一般にindex.jsとされ、下記のような内容となる

import '../scss/bootstrap-custom.scss';
import 'bootstrap/dist/js/bootstrap.esm.js';

import {init, build} from '../ts/app';

init();

window.addEventListener("load", () => {
  build();
});

一行目でbootstrapのカスタムスタイルシートを読み込むが、ここではscssのソースファイルを直接インポートして、WebpackのプラグインでSassコンパイラを起動する。生成したスタイルシートをインポートすると呼び出し側のHTMLのHEADタグ内にSTYLEタグを生成して直接コーディングするため、CSSファイルは生成されない。

2行目はbootstrap用のスクリプトをインポート。4行目でシングルページアプリケーションスクリプトを読み込むが、これもTypescriptのソースを直接していして内部でコンパイラを起動している。また、従来構成と違って読み込んだ時点ではBODYタグが生成されていないため、DOM関連の初期化があると失敗する。DOM関連の初期化はloadイベントをトリガとして始めるよう工夫が必要。

とりあえず、Webpackを導入するとこうなるというだけのメモで、Webpackの具体的な設定などはまた別の機会に。

FastAPI認証機構の追加

小規模djangoページの置き替えの為にFastAPIアプリケーションの勉強と実装実験を行っているが、次に用意したものは管理ページなどへアクセスするためのユーザー認証機構。前述の通りFastAPIはAPIの提供に特化したシンプルなフレームワークなので実用的な機能などは自前で準備する必要がある。かといって何も情報が無いかと言えばそうでも無く、公式のドキュメントやチュートリアルに必要そうなものが一通りまとめてあるので(例えばセキュリティの項目)、それをチマチマ実装していくという実に勉強になる仕組みが用意されている。

ドキュメント・チュートリアルで今回参考(コピペ)にしたのもは主に次の二点

  • セキュリティ
    • パスワード(およびハッシュ化)によるOAuth2、JWTトークンによるBearer
    • ユーザー認証の基本的な構造サンプル
  • SQL database(データベースアクセス)
    • SQLAlchemyを使用してFastAPIのパス構文に接続する方法

以上を組み合わせてユーザー情報をデータベースに保存し、ユーザー認証を行うような仕組みが実現出来る。

ただし、この段階ではAPIとしての実装なので実際に使用できるようにするには、フロントエンドの実装が必要となる。Djangoではバックエンド寄りの実装でHTMLテンプレートを用いてのUIだったが、今回のUIはFastAPI対応ではせっかくなのでシングルページの実装としてjavascript(Typescript)での実装としている。

こんな感じのログイン画面を準備してみました。ユーザー追加の画面や実際の管理画面は未実装だが、同じようなノリで追々追加していきます。

あと、このままではトークンの期限が切れる度に再認証になるので、セッションIDによるリフレッシュトークンの実装で同じ端末からはなるべくログインし続けられるような機構の導入を検討している(実装の手段はともかく見た目くらいはDjangoと同じくらいにしたいw)。

bootstrapをほぼ素で利用しているだけで何となく見た目だけでも良い感じなのが嬉しいですね。