iCal フィード(カレンダー購読 URL)の設計
プリザンターには、サイトのレコードを iCalendar(.ics)として配信する機能はありません。このページは、サイトごとに購読用 URL を発行し、カレンダーアプリから定期的に取り込めるようにする本体改修の設計メモです。
前提にした現行実装(1.5.8.1)は、フォーム機能の「推測できない URL」と、バックグラウンドサーバースクリプトの「実行ユーザーを指定して Context を作る」処理です。どちらもソースで確認した内容を下に書きます。
要件
| 要件 | 内容 |
|---|---|
| サイト単位 | 1 つのサイトに紐づいたフィードを配信する |
| 推測できない URL | フォーム機能と同じく 32 桁の 16 進数を URL に含める |
| 実行ユーザー | フィードを作るときに使うユーザーを設定ごとに指定する(権限とタイムゾーンに効く) |
| タイムゾーン | 実行ユーザーのタイムゾーンで日時を変換し、終日かどうかを判定する |
| ビュー | 保存済みビューを選び、そのフィルタでレコードを絞る |
| 複数設定 | 1 サイトに複数の設定(ビュー × ユーザーの組み合わせ)を持てる |
| 件名・説明の書式 | 標準(画面表示タイトル・内容)か、[列名] を埋め込むテンプレートかを選べる |
前提にした現行実装
フォーム機能の URL
フォーム機能を有効にしたサイトは、Sites テーブルの Form 列に 32 文字の値を持ちます(SiteModel.cs)。公開 URL は /forms/{guid}/new で、サイト設定画面では小文字にして表示します(SiteUtilities.cs)。
- 値はブラウザ側で、フォームを有効にするチェックボックスを ON にした時点で作られます(sitesettings.js)。
$p.createGuid()はcrypto.randomUUID()からハイフンを除いて大文字にした値を返し、使えない環境(HTTP 接続など)ではMath.random()で作ったテンプレートにフォールバックします(util.js)。- リクエストを受けると、
Contextがルートのguidを大文字にして保持し(Context.cs)、Sites.Form = guidでサイトを引いてIdとIsFormを設定します(Context.cs、Context.cs)。 - このとき
Form.jsonのEnabled、または契約設定の拡張機能Formのどちらかが有効でないとサイトは解決されません(Context.cs)。
図を読み込み中…
推測できない値はサーバー側で作る
フォームの GUID はブラウザで作られるため、crypto.randomUUID() が使えない HTTP 環境では Math.random() 由来の値になります。フィードの URL は認証の代わりになるので、新しく作る機能ではサーバー側で RandomNumberGenerator や Guid.NewGuid() から作る設計にします。
実行ユーザーを指定した Context
バックグラウンドサーバースクリプトは、テナント ID とユーザー ID から認証済みの Context を作ります(BackgroundServerScriptJob.cs)。
SiteInfo.Userでユーザーの所属部署を取るnew Context(tenantId, userId, deptId, request: false, setAuthenticated: true)で作るSetTenantProperties(force: true)でテナントの設定を読み込むAbsoluteUriにパラメータService.AbsoluteUriを入れるTimeZoneInfoをUserModelから取り直して設定する
HTTP リクエストを伴わない Context では、URL 生成に使う AbsoluteUri とユーザーのタイムゾーンを明示的に入れている点が重要です。iCal ではリクエストはありますが、ログインしていない(Cookie の無い)カレンダーアプリからのアクセスなので、同じ手順で実行ユーザーの Context を作ります。
ビュー・カレンダー列・書式の既存部品
| 部品 | 現行実装 | 使い道 |
|---|---|---|
| ビュー一覧 | SiteSettings.Views(SiteSettings.cs) | 設定の ViewId でビューを選ぶ |
| 開始・終了の列 | View.GetCalendarFromColumn / GetCalendarToColumn(View.cs) | カレンダービューと同じ列を DTSTART / DTEND に使う |
| 時刻の有無 | カレンダー表示は開始列の EditorFormat が Ymdhm のときだけ時刻を出す(HtmlCalendar.cs) | Ymd を終日イベントにする |
[列名] の抽出 | SiteSettings.IncludedColumns(SiteSettings.cs) | テンプレートに含まれる列を取り出す |
| 閲覧権限 | Permissions.CanRead(context, siteId)(Permissions.cs) | 実行ユーザーがサイトを読めるか確認する |
URL
GET /ical/{guid}.ics{guid}は 32 桁の 16 進数。認証の代わりになる秘密の値として扱う- 拡張子
.icsを付けるとカレンダーアプリが形式を判別しやすい - 応答は
Content-Type: text/calendar; charset=utf-8
ルートは既存の Default({controller}/{action})より前に登録します。1.5.8.1 の Startup.cs では FormBinaries → Default → Others → Item → Binaries の順に登録されています(Startup.cs)。
endpoints.MapControllerRoute(
name: "ICal",
pattern: "ical/{guid}.ics",
defaults: new { Controller = "ICal", Action = "Get" },
constraints: new { Guid = "[A-Fa-f0-9]{32}" });フォームは 1 サイト 1 つの値(Sites.Form)ですが、iCal は 1 サイトに複数の設定を持たせるので、GUID を 1 対多で持つ仕組みが必要です。
設定の保存先
案 A: 専用テーブル(推奨)
図を読み込み中…
| 列 | 説明 |
|---|---|
Guid | URL に含める値。一意インデックスを付け、WHERE Guid = @guid で引く |
TenantId | 実行ユーザーの Context を作るのに使う(SiteId から逆引きしてもよい) |
UserId | 実行ユーザー |
ViewId | 適用するビュー。0 か、ビューが削除されていれば既定のビュー(フィルタなし) |
SummaryStyle / SummaryFormat | Standard か Custom。Custom のときのテンプレート(例: [Title] ([ClassA])) |
DescriptionStyle / DescriptionFormat | 同上(例: [Body] と担当者を並べる) |
Disabled | 無効フラグ |
テーブルは CodeDefiner の定義に追加します。
案 B: SiteSettings の JSON に入れる
SiteSettings に SettingList<ICalSetting> を追加する方法です。スキーマ変更は要りませんが、URL から設定を引くために全サイトの SiteSettings を読むか、DB ごとに書き方の違う JSON 検索が必要になります。
| 観点 | 案 A(専用テーブル) | 案 B(SiteSettings) |
|---|---|---|
| GUID の検索 | インデックスで引ける | 全行の JSON を走査 |
| スキーマ変更 | テーブル追加(CodeDefiner) | なし |
| DB の方言 | 影響なし | JSON 検索は方言差あり |
| 似た既存機能 | Binaries の Guid 検索 | リマインダーの設定 |
処理の流れ
図を読み込み中…
[AllowAnonymous]
public class ICalController : Controller
{
[HttpGet]
public IActionResult Get(string guid)
{
var setting = ICalSettingsRepository.GetByGuid(guid);
if (setting == null || setting.Disabled) return NotFound();
var context = CreateContext(tenantId: setting.TenantId, userId: setting.UserId);
if (!Permissions.CanRead(context: context, siteId: setting.SiteId)) return NotFound();
var ss = SiteSettingsUtilities.Get(context: context, siteId: setting.SiteId);
var ics = ICalUtilities.Generate(context: context, ss: ss, setting: setting);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "calendar.ics");
}
}ICalSettingsRepository・ICalUtilities は新設するクラスの仮の名前です。
日時とタイムゾーン
開始列の EditorFormat | iCal の型 | 例 |
|---|---|---|
Ymd | DATE(終日) | DTSTART;VALUE=DATE:20260301 |
Ymdhm / Ymdhms | DATE-TIME | DTSTART;TZID=Asia/Tokyo:20260301T090000 |
- DB の値は
ToLocal(context)で実行ユーザーのタイムゾーンに変換してから出力する。TZID付きのローカル時刻にするならVTIMEZONEも出力する(自前で組み立てるか、Ical.Net などのライブラリを使う) - 終日イベントの
DTENDは RFC 5545 では翌日を指定する - 実行ユーザーのタイムゾーン設定を変えると、同じ URL でも出力される時刻が変わる
SUMMARY と DESCRIPTION の書式
| スタイル | SUMMARY | DESCRIPTION |
|---|---|---|
| 標準 | 画面表示タイトル(Items.Title を結合した ItemTitle) | 内容(Body) |
| カスタム | [列名] を埋め込んだテンプレート | 同左 |
カスタムでは ss.IncludedColumns(format) でテンプレート中の列を取り出し、SELECT に追加してまとめて取得してから [列名] を値で置き換えます。カレンダー表示用の既存の取得処理は Body を取らないので、標準の DESCRIPTION を出すときも Body を SELECT に足す必要があります。内容や説明項目には HTML・Markdown が入ることがあるので、出力前にタグを除去します。
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example//Pleasanter iCal//JA
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:pleasanter-{SiteId}-{Id}@example.com
DTSTAMP:20260225T024100Z
DTSTART;VALUE=DATE:20260301
DTEND;VALUE=DATE:20260302
SUMMARY:タスク名
DESCRIPTION:内容
END:VEVENT
END:VCALENDAR管理画面
リマインダーと同じ「一覧に追加・編集・削除・並べ替え」の形(SettingList)で、サイトの管理画面にタブを追加します。
| 項目 | 説明 |
|---|---|
| タイトル | 管理用の名前 |
| 実行ユーザー | 権限とタイムゾーンに効く |
| ビュー | 適用するフィルタ |
| SUMMARY / DESCRIPTION のスタイルと書式 | 標準かカスタムか、カスタムのテンプレート |
| 無効 | フィードを止める |
| URL | 自動生成(読み取り専用・コピーボタン)。再発行ボタンも用意する |
注意点
| 項目 | 内容 |
|---|---|
| URL の秘密性 | URL が漏れると誰でも読める。再発行(GUID の作り直し)を用意する |
| 実行ユーザーの無効化 | 実行ユーザーが無効・削除されたら 404 か空のカレンダーを返す |
| ビューの削除 | 既定のビュー(全件)に戻る。意図しない公開にならないよう、削除時は設定も無効にする案もある |
| ポーリング負荷 | カレンダーアプリは定期的に取りに来る。Cache-Control や ETag を返す |
| テナントの特定 | 設定に TenantId を持たせるか、SiteId から逆引きする |