Skip to content

カレンダー ​

第6版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

カレンダー表示の見た目を補うカスタマイズをまとめます。祝日表示には 2 つの方式があります。

方式仕組み対応カレンダー向いている用途
CSS 生成方式日付を固定した CSS を拡張スタイルに置く(生成ツールあり)標準カレンダーのみ手軽に国民の祝日だけ出したい
祝日マスタ方式期限付きテーブルに祝日を登録し、拡張スクリプトで読み込んで表示標準カレンダー・FullCalendar会社独自の休日も管理したい、毎年 CSS を作り直したくない

祝日マスタ方式は、Google カレンダーの日本の祝日カレンダーから祝日マスタへ自動登録する仕組みと組み合わせられます。

標準カレンダーと FullCalendar

カレンダーには「標準」と「FullCalendar」の 2 種類があり、「テーブルの管理」の「カレンダー」タブの「カレンダータイプ」で切り替えます(SiteUtilities.cs#L16012-L16028)。1.5.8.1 では General.json の DefaultCalendarType が 2(FullCalendar)なので、カレンダータイプを設定していないサイトは FullCalendar で表示されます(General.json#L61、SiteSettings.cs#L410)。次の「今日」の強調と CSS 生成方式は標準カレンダーの HTML(#CalendarBody・.CalendarBody)に当てる CSS なので、FullCalendar では効きません。使うときはカレンダータイプを「標準」にしてください。

「今日」を強調表示する ​

標準カレンダーが対象です(FullCalendar では下の CSS は当たりません)。旧 UI のテーマではカレンダーの今日の日付に青枠が付いていましたが、新 UI では表示されません。今日のセルには today クラスが付いており、青枠の CSS も存在しますが、新 UI のスタイルで上書きされて無効になっています。

より優先度の高い CSS を用意すれば表示できます。!important を使う場合は次のとおりです。

css
.CalendarBody .today {
    border: solid 2px #00f !important;
}

!important を避けるなら box-shadow を使います。枠が内側に描かれるので、レイアウトへの影響も小さくなります。

css
.CalendarBody .today {
    box-shadow: inset 2px 2px 0 #00f, inset -2px -2px 0 #00f;
}

拡張スタイルとして置けば全ページに適用されます。背景色を付けるなど、.CalendarBody .today に好みのスタイルを当てて強調できます。

土日・祝日を CSS で表示する(標準カレンダー) ​

INFO

この方法はプリザンターの標準カレンダーで使えます。FullCalendar には対応していません。FullCalendar でも表示したい場合は、後述の祝日マスタ方式を使ってください。

標準カレンダーで土曜を水色・日曜をピンク、今日(25 日)を青枠で表示した様子

土日 ​

表の 6 列目・7 列目の背景色を変えます。前月・翌月のグレーアウト部分には適用しないよう :not(.other-month) で除外しています。

列の位置で指定するのは、日付のセル(td)に曜日のクラスが無いためです。1.5.8.1 の標準カレンダーで日付のセルに付くクラスは container・today・other-month だけで、sunday・saturday などの曜日のクラスは見出しの th にしか付きません(HtmlCalendar.cs)。日付は data-id に yyyy/M/d の形で入ります。

css
#CalendarBody #Grid tbody tr td:nth-child(6):not(.other-month){background-color:#add8e6;}
#CalendarBody #Grid tbody tr td:nth-child(7):not(.other-month){background-color:#ffc0cb;}

General.json の FirstDayOfWeek を変更している環境(公式マニュアルでは変更不可のパラメータとされています)では曜日の列位置が変わるので、数字を調整してください。

祝日 ​

日付のセルは data-id 属性に日付を yyyy/M/d 形式(月・日はゼロ埋めなし。例: 2024/1/1、2024/12/24)で持っているので(HtmlCalendar.cs#L522)、それで日付を指定し、背景色の変更と疑似要素によるラベル表示を行います。

css
#CalendarBody #Grid tbody tr td[data-id="2024/12/24"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/12/24"] div .day:after{content:"クリスマス";margin-left:5px;}

祝日の数だけこの 2 行を並べます。内閣府の祝日一覧を基にした 2024 年・2025 年分の例は次のとおりです。

CalendarStyle-Holiday2024.css
css
#CalendarBody #Grid tbody tr td[data-id="2024/1/1"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/1/1"] div .day:after{content:"元日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/1/8"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/1/8"] div .day:after{content:"成人の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/2/11"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/2/11"] div .day:after{content:"建国記念の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/2/12"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/2/12"] div .day:after{content:"休日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/2/23"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/2/23"] div .day:after{content:"天皇誕生日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/3/20"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/3/20"] div .day:after{content:"春分の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/4/29"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/4/29"] div .day:after{content:"昭和の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/3"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/3"] div .day:after{content:"憲法記念日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/4"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/4"] div .day:after{content:"みどりの日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/5"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/5"] div .day:after{content:"こどもの日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/6"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/5/6"] div .day:after{content:"休日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/7/15"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/7/15"] div .day:after{content:"海の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/8/11"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/8/11"] div .day:after{content:"山の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/8/12"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/8/12"] div .day:after{content:"休日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/9/16"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/9/16"] div .day:after{content:"敬老の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/9/22"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/9/22"] div .day:after{content:"秋分の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/9/23"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/9/23"] div .day:after{content:"休日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/10/14"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/10/14"] div .day:after{content:"スポーツの日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/11/3"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/11/3"] div .day:after{content:"文化の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/11/4"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/11/4"] div .day:after{content:"休日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2024/11/23"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2024/11/23"] div .day:after{content:"勤労感謝の日";margin-left:5px;}
CalendarStyle-Holiday2025.css
css
#CalendarBody #Grid tbody tr td[data-id="2025/1/1"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/1/1"] div .day:after{content:"元日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/1/13"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/1/13"] div .day:after{content:"成人の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/2/11"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/2/11"] div .day:after{content:"建国記念の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/2/23"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/2/23"] div .day:after{content:"天皇誕生日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/2/24"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/2/24"] div .day:after{content:"休日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/3/20"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/3/20"] div .day:after{content:"春分の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/4/29"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/4/29"] div .day:after{content:"昭和の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/3"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/3"] div .day:after{content:"憲法記念日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/4"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/4"] div .day:after{content:"みどりの日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/5"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/5"] div .day:after{content:"こどもの日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/6"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/5/6"] div .day:after{content:"休日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/7/21"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/7/21"] div .day:after{content:"海の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/8/11"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/8/11"] div .day:after{content:"山の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/9/15"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/9/15"] div .day:after{content:"敬老の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/9/23"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/9/23"] div .day:after{content:"秋分の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/10/13"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/10/13"] div .day:after{content:"スポーツの日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/11/3"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/11/3"] div .day:after{content:"文化の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/11/23"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/11/23"] div .day:after{content:"勤労感謝の日";margin-left:5px;}
#CalendarBody #Grid tbody tr td[data-id="2025/11/24"]:not(.other-month){background-color:#ffc0cb !important;}
#CalendarBody #Grid tbody tr td[data-id="2025/11/24"] div .day:after{content:"休日";margin-left:5px;}

CSS を自動生成するツール ​

内閣府の祝日 CSV から上記の CSS を生成し、拡張スタイルとして全サイトに適用するツール pleasanter-calendar-holiday-style-generator があります(リポジトリは移転しており、こちらが現在の場所です)。

  • 1.4 系以降のプリザンターがインストールされている環境が前提です。それ以前のバージョンでも .NET 8 Runtime を追加すれば使えます。
  • 実行時に内閣府のサイトからファイルを取得するため、オフライン環境では実行できません。

使い方(Windows の場合):

  1. リリースから zip を取得・解凍し、Implem.Pleasanter と同じ階層に HolidayStyleGenerator フォルダとして配置します。

  2. コマンドプロンプトで実行します。

    shell
    cd C:\pleasanter\HolidayStyleGenerator
    dotnet VehicleVision.PleasanterTools.HolidayStyleGenerator.dll
  3. Implem.Pleasanter\App_Data\Parameters\ExtendedStyles\CalendarStyle に CSS ファイル群が生成されていれば完了です。必要に応じてプリザンターのプロセスを再起動してください。

  • ジェネレータは相対パスで Implem.Pleasanter を探します。同じ階層にない場合は、Implem.CodeDefiner と同様に /p オプションでプリザンター本体のパスを指定します。
  • Azure の場合は、site フォルダに zip を解凍せずアップロードすると HolidayStyleGenerator フォルダに展開されるので、そこで dotnet VehicleVision.PleasanterTools.HolidayStyleGenerator.dll /p C:\home\site\wwwroot を実行します。

祝日マスタで独自の祝日を表示する ​

期限付きテーブルを「祝日マスタ」として使い、そのレコードを拡張スクリプトで読み込んで標準カレンダーと FullCalendar の両方に表示します。本体の改修は不要で、拡張スクリプトと拡張スタイルだけで実装します。

図を読み込み中…

  • 祝日マスタは、カレンダーを表示しているユーザーのログインセッションで読み込みます。API キーは不要ですが、ユーザーには祝日マスタサイトの閲覧権限が必要です。
  • 会社独自の休日もレコードを追加するだけで表示できます。

祝日マスタを作る ​

期限付きテーブルを 1 つ作り、祝日 1 日につき 1 レコード登録します。作成後にサイト ID を控えておきます。

項目用途設定例
タイトル表示する祝日名元日
開始祝日の日付(書式を「日付」にしておくと運用しやすい)2027/1/1

拡張スタイル ​

土曜・日曜・祝日の表示をまとめています。

css
/* 標準カレンダー。週の開始曜日が月曜日の場合 */
[id^="CalendarBody"] #Grid tbody tr > td:nth-last-child(2):not(.other-month) {
  background-color: #e8f3ff;
}

[id^="CalendarBody"] #Grid tbody tr > td:last-child:not(.other-month) {
  background-color: #fff0f3;
}

/* FullCalendar */
[id^="FullCalendar"].calendar-container .fc-day-sat:not(.fc-day-other) {
  background-color: #e8f3ff;
}

[id^="FullCalendar"].calendar-container .fc-day-sun:not(.fc-day-other) {
  background-color: #fff0f3;
}

/* 祝日は土日の色より優先する */
.pch-holiday {
  background-color: #ffc0cb !important;
}

.pch-holiday-label {
  color: #b00020;
  font-size: 0.85em;
  font-weight: 600;
  margin-left: 0.5em;
  white-space: nowrap;
}

/* 標準カレンダーの週表示 */
[id^="CalendarBody"] td.is-weekly.pch-holiday {
  position: relative;
}

[id^="CalendarBody"] td.is-weekly > .pch-holiday-label {
  position: absolute;
  right: 0.4em;
  top: 0.25em;
}

標準カレンダーの土日は列位置で判定しているため、General.json の FirstDayOfWeek を変えている環境ではセレクタを調整してください。FullCalendar はライブラリが付ける .fc-day-sat / .fc-day-sun を使うので列位置に依存しません。

拡張スクリプト ​

HOLIDAY_SITE_ID を祝日マスタのサイト ID に変えます。既定項目以外を使う場合は DATE_COLUMN と TITLE_COLUMN も変えてください。

js
(function () {
  'use strict';

  // ===== 環境に合わせて変更する設定 =====
  var HOLIDAY_SITE_ID = 123456;
  var DATE_COLUMN = 'StartTime';
  var TITLE_COLUMN = 'Title';
  var PAGE_SIZE = 200;

  var holidayMap = Object.create(null);
  var loaded = false;
  var loading = false;
  var callbacks = [];
  var renderTimer = null;

  function toDateKey(value) {
    // Dateへ変換せず、タイムゾーンによる日付ずれを避ける
    var match = String(value || '').match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
    if (!match) return null;
    return match[1] + '-' + String(match[2]).padStart(2, '0')
      + '-' + String(match[3]).padStart(2, '0');
  }

  function finishLoad(error, records) {
    loading = false;
    if (!error) {
      holidayMap = Object.create(null);
      records.forEach(function (record) {
        var key = toDateKey(record[DATE_COLUMN]);
        if (!key) return;

        var name = String(record[TITLE_COLUMN] || '休日');
        holidayMap[key] = holidayMap[key]
          ? holidayMap[key] + ' / ' + name
          : name;
      });
      loaded = true;
    } else {
      console.warn('祝日マスタを取得できませんでした。', error);
    }

    var waiting = callbacks.slice();
    callbacks.length = 0;
    waiting.forEach(function (callback) {
      callback(!error);
    });
  }

  function fetchPage(offset, records) {
    $p.apiGet({
      id: HOLIDAY_SITE_ID,
      data: {
        ApiVersion: 1.1,
        Offset: offset,
        PageSize: PAGE_SIZE
      },
      done: function (response) {
        var result = response && response.Response;
        var page = result && Array.isArray(result.Data) ? result.Data : [];
        Array.prototype.push.apply(records, page);

        var totalCount = Number(result && result.TotalCount) || records.length;
        if (page.length > 0 && records.length < totalCount) {
          fetchPage(offset + page.length, records);
        } else {
          finishLoad(null, records);
        }
      },
      fail: function (xhr) {
        finishLoad(xhr || new Error('API request failed'), records);
      }
    });
  }

  function loadHolidays(callback) {
    if (loaded) {
      callback(true);
      return;
    }
    callbacks.push(callback);
    if (loading) return;
    loading = true;
    fetchPage(0, []);
  }

  function appendLabel($target, name) {
    if (!$target.length || $target.children('.pch-holiday-label').length) return;
    $('<span>', {
      'class': 'pch-holiday-label',
      text: name
    }).appendTo($target);
  }

  function decorateStandardCalendar() {
    $('[id^="CalendarBody"] #Grid td[data-id]').each(function () {
      var $cell = $(this);
      var key = toDateKey($cell.attr('data-id'));
      var name = key && holidayMap[key];
      if (!name) return;

      $cell.addClass('pch-holiday').attr('data-holiday-name', name);
      var $day = $cell.find('.day').first();
      appendLabel($day.length ? $day : $cell, name);
    });
  }

  function decorateFullCalendar() {
    $('[id^="FullCalendar"].calendar-container [data-date]').each(function () {
      var $element = $(this);
      var key = toDateKey($element.attr('data-date'));
      var name = key && holidayMap[key];
      if (!name) return;

      $element.addClass('pch-holiday').attr('data-holiday-name', name);

      if ($element.hasClass('fc-daygrid-day')) {
        appendLabel($element.find('.fc-daygrid-day-top').first(), name);
      } else if ($element.hasClass('fc-col-header-cell')) {
        appendLabel($element.find('.fc-col-header-cell-cushion').first(), name);
      } else if ($element.hasClass('fc-list-day')) {
        appendLabel($element.find('.fc-list-day-text').first(), name);
      }
    });
  }

  function decorateCalendars() {
    decorateStandardCalendar();
    decorateFullCalendar();
  }

  function scheduleDecoration() {
    clearTimeout(renderTimer);
    renderTimer = setTimeout(function () {
      loadHolidays(function (success) {
        if (success) decorateCalendars();
      });
    }, 50);
  }

  // 標準カレンダーのAjax更新とFullCalendarの初期描画に対応
  var previousCalendarLoad = $p.events.on_calendar_load;
  $p.events.on_calendar_load = function () {
    if (typeof previousCalendarLoad === 'function') {
      previousCalendarLoad.apply(this, arguments);
    }
    scheduleDecoration();
  };

  // FullCalendar内の前後移動などによるDOM再描画にも対応
  $(function () {
    scheduleDecoration();
    var mainForm = document.getElementById('MainForm');
    if (!mainForm || !window.MutationObserver) return;

    var observer = new MutationObserver(function (mutations) {
      var calendarChanged = mutations.some(function (mutation) {
        var target = mutation.target;
        return target.nodeType === 1
          && $(target).closest(
            '[id^="CalendarBody"], [id^="FullCalendar"]'
          ).length > 0;
      });
      if (calendarChanged) scheduleDecoration();
    });
    observer.observe(mainForm, { childList: true, subtree: true });
  });
})();

スクリプトのポイント ​

  • 別サイトをログインセッションで取得: $p.apiGet に祝日マスタのサイト ID を渡します。ブラウザのセッションで API が呼ばれるので、API キーをスクリプトに埋め込む必要はありません。Offset と PageSize で全ページを取得し、結果は画面内にキャッシュするので、FullCalendar を前後移動するたびに API を呼ぶことはありません。
  • 日付は文字列のまま扱う: Date に変換すると値やブラウザのタイムゾーンによって前日にずれる可能性があるため、年月日部分を取り出して YYYY-MM-DD にそろえます。
  • 両カレンダーに共通の判定: 標準カレンダーは data-id="YYYY/M/D"、FullCalendar は data-date="YYYY-MM-DD" を持つ要素を探し、同じ holidayMap と .pch-holiday クラスで装飾します。同じ日に複数のレコードがあれば名称を「/」で連結します。
  • 再描画への追従: FullCalendar の前後移動はプリザンターへの再リクエストなしにライブラリ内で DOM が描き直されるため、$p.events.on_calendar_load に加えて MutationObserver でも再描画を検出します。既存の on_calendar_load があれば呼び出してから処理します。

対応する表示 ​

カレンダー対応する表示
標準カレンダー月、週(年表示は 1 セルが 1 か月なので対象外)
FullCalendar月、週、日、リスト

Google カレンダーの祝日を祝日マスタへ自動登録する ​

祝日マスタへ国民の祝日を毎年手入力する代わりに、Google カレンダーが公開している日本の祝日カレンダーをバックグラウンドサーバスクリプトで定期取得し、items.Upsert で登録します。本体の改修は不要です。

公開 iCal を使う ​

日本の祝日カレンダーのカレンダー ID は次のとおりです。

text
ja.japanese.official#holiday@group.v.calendar.google.com

公開 iCal の URL は https://calendar.google.com/calendar/ical/{エンコードしたカレンダーID}/public/basic.ics の形式です。ID に # や @ を含むので、encodeURIComponent でエンコードして組み込みます(JavaScript の文字列に書くとき @ の前にバックスラッシュは不要です)。公開 iCal なので、Google Cloud プロジェクト・API キー・OAuth クライアントは不要で、取得は読み取り専用です。

WARNING

この方法で取得できるのは公開カレンダーだけです。非公開カレンダーを取り込むには、Google Calendar API と OAuth 認証、または漏えいしないよう管理した「非公開 iCal 形式のアドレス」が必要です。

祝日マスタに項目を追加する ​

前節の祝日マスタに、同期管理用の項目を追加します。

項目用途設定例
タイトル祝日名元日
開始祝日の日付2027/1/1
完了祝日の終了2027/1/1 23:59
分類A同期キー(items.Upsert のキー)カレンダー ID と UID を連結した値
分類B取得元カレンダー IDja.japanese...google.com
分類C取得方式google-ical

分類 A をキーにすることで、同じイベントを再取得してもレコードは増えず、既存レコードが更新されます。

バックグラウンドサーバスクリプトを有効にする ​

バックグラウンドサーバスクリプトは既定で無効("BackgroundServerScript": false。Script.json#L3)なので、App_Data/Parameters/Script.json の次のキーを確認します(パラメータ設定であり本体の改修ではありません)。下の例は関係するキーだけの抜粋で、他のキーは既存のまま残します。変更後はプリザンターの再起動またはパラメータの再読み込みが必要です。

json
{
  "ServerScript": true,
  "BackgroundServerScript": true,
  "DisableServerScriptHttpClient": false
}

取込スクリプト ​

TARGET_SITE_ID を祝日マスタのサイト ID に変えます。

js
(function () {
  'use strict';

  // ===== 環境に合わせて変更する設定 =====
  var TARGET_SITE_ID = 123456;
  var CALENDAR_ID =
    'ja.japanese.official#holiday@group.v.calendar.google.com';
  var PAST_YEARS = 1;
  var FUTURE_YEARS = 2;

  function getProperty(block, name) {
    var pattern = new RegExp(
      '^' + name + '(?:;[^:]*)?:(.*)$',
      'mi'
    );
    var match = pattern.exec(block);
    return match ? match[1].replace(/\r$/, '') : '';
  }

  function decodeText(value) {
    return String(value || '').replace(/\\([nN,;\\])/g, function (_, char) {
      return char.toLowerCase() === 'n' ? '\n' : char;
    });
  }

  function toDateKey(value) {
    // 日本の祝日カレンダーは終日イベントなので先頭8桁を使用する
    var match = String(value || '').match(/^(\d{4})(\d{2})(\d{2})/);
    if (!match) return null;
    return match[1] + '-' + match[2] + '-' + match[3];
  }

  function inTargetYears(dateKey) {
    var currentYear = new Date().getFullYear();
    var year = Number(dateKey.substring(0, 4));
    return year >= currentYear - PAST_YEARS
      && year <= currentYear + FUTURE_YEARS;
  }

  function parseEvents(ics) {
    // RFC 5545の折り返し行を、プロパティ解析前に連結する
    var unfolded = String(ics || '')
      .replace(/\r\n[ \t]/g, '')
      .replace(/\n[ \t]/g, '');
    var pattern = /BEGIN:VEVENT\r?\n([\s\S]*?)END:VEVENT/g;
    var events = [];
    var match;

    while ((match = pattern.exec(unfolded)) !== null) {
      var block = match[1];
      var status = getProperty(block, 'STATUS').toUpperCase();
      var start = toDateKey(getProperty(block, 'DTSTART'));
      if (!start || status === 'CANCELLED' || !inTargetYears(start)) continue;

      events.push({
        uid: getProperty(block, 'UID'),
        title: decodeText(getProperty(block, 'SUMMARY')) || '休日',
        date: start
      });
    }
    return events;
  }

  function importEvent(event) {
    // カレンダーIDも含め、複数カレンダー間のUID衝突を避ける
    var sourceKey = CALENDAR_ID + '|' + (event.uid || event.date);
    var data = {
      Keys: ['ClassA'],
      Title: event.title,
      StartTime: event.date + 'T00:00:00',
      CompletionTime: event.date + 'T23:59:59',
      ClassA: sourceKey,
      ClassB: CALENDAR_ID,
      ClassC: 'google-ical'
    };

    // 祝日マスタ側の保護スクリプトへ、正規の同期処理であることを伝える
    context.UserData.holidayImportCalendarId = CALENDAR_ID;
    try {
      return items.Upsert(TARGET_SITE_ID, JSON.stringify(data));
    } finally {
      context.UserData.holidayImportCalendarId = null;
    }
  }

  function main() {
    var feedUrl = 'https://calendar.google.com/calendar/ical/'
      + encodeURIComponent(CALENDAR_ID)
      + '/public/basic.ics';

    httpClient.ResponseHeaders.Clear();
    httpClient.RequestUri = feedUrl;
    var ics = httpClient.Get();

    if (!httpClient.IsSuccess) {
      context.Log(
        '祝日カレンダーを取得できませんでした。status=' + httpClient.StatusCode
      );
      return;
    }

    var events = parseEvents(ics);
    var succeeded = 0;
    var failed = 0;

    for (var i = 0; i < events.length; i++) {
      try {
        if (importEvent(events[i])) {
          succeeded++;
        } else {
          failed++;
        }
      } catch (e) {
        failed++;
        context.Log(
          '祝日の登録に失敗しました。date=' + events[i].date
          + ', message=' + e.message
        );
      }
    }

    context.Log(
      '祝日取込が完了しました。対象=' + events.length
      + ', 成功=' + succeeded
      + ', 失敗=' + failed
    );
  }

  main();
})();
  • 折り返し行の連結: iCal では長いプロパティが次の行に折り返され、継続行は空白またはタブで始まります。parseEvents の先頭で改行と先頭空白を除いてから VEVENT を解析します。
  • 対象期間: 公開 iCal には過去のイベントも含まれる可能性があるため、現在年の 1 年前〜2 年後に限定しています(PAST_YEARS / FUTURE_YEARS で変更)。
  • Upsert のキー: カレンダー ID と UID を連結して分類 A に入れ、Keys: ['ClassA'] の items.Upsert で初回は作成、2 回目以降は更新になります。プリザンターの API キーは不要で、バックグラウンドサーバスクリプトの実行ユーザーの権限が適用されます。
  • 削除は同期しない: Google 側で削除されたイベントを自動削除する処理は、誤削除を避けるため含めていません。不要になった祝日は祝日マスタ側で確認して削除してください。

スクリプトを登録する ​

テナント管理のバックグラウンドサーバスクリプトに登録します。最初はスケジュールを待たず「今すぐ実行」で確認し、実行ログに対象件数・成功件数・失敗件数が記録されることを確かめます。

設定内容
実行ユーザー祝日マスタを作成・更新できるユーザー
スケジュール1 日 1 回、または 1 週間に 1 回
タイムアウト取得年数と環境に合わせて設定
無効オフ

Google 由来のレコードを読み取り専用にする ​

items.Upsert だけでは、取り込んだレコードを利用者が手動で編集でき、次回同期で Google 側の値に戻ってしまいます。そこで、分類 C が google-ical のレコードは画面からの編集を禁止し、分類 C が空欄のレコード(手動登録)は編集可能とします。画面の読み取り専用化(操作ミスを防ぐ UI 対策)と、作成前・更新前・削除前のサーバー側での拒否(一覧編集や API 経由の更新も防ぐ対策)の両方を設定します。

祝日マスタサイトに、条件「レコード読み込み時」「作成前」「更新前」「削除前」でサーバスクリプトを登録します。

js
(function () {
  'use strict';

  var SOURCE_TYPE = 'google-ical';

  function fieldValue(target, name) {
    if (!target || target[name] == null) return '';
    return String(target[name]);
  }

  var oldRecord = typeof saved !== 'undefined' ? saved : null;
  var sourceType = fieldValue(oldRecord, 'ClassC')
    || fieldValue(model, 'ClassC');
  if (sourceType !== SOURCE_TYPE) return;

  var calendarId = fieldValue(oldRecord, 'ClassB')
    || fieldValue(model, 'ClassB');
  var importCalendarId = context.UserData
    ? String(context.UserData.holidayImportCalendarId || '')
    : '';
  var isImporter = importCalendarId !== ''
    && importCalendarId === calendarId;

  if (context.Condition === 'WhenloadingRecord') {
    columns.Title.ReadOnly = true;
    columns.StartTime.ReadOnly = true;
    columns.CompletionTime.ReadOnly = true;
    columns.ClassA.ReadOnly = true;
    columns.ClassB.ReadOnly = true;
    columns.ClassC.ReadOnly = true;
    return;
  }

  if (
    context.Condition === 'BeforeCreate'
    || context.Condition === 'BeforeUpdate'
    || context.Condition === 'BeforeDelete'
  ) {
    if (!isImporter) {
      context.Error(
        'Googleカレンダーから取り込んだ祝日は編集・削除できません。'
      );
    }
  }
})();

取込スクリプトは items.Upsert の直前に context.UserData.holidayImportCalendarId へカレンダー ID を設定しています。context.UserData は同一リクエスト内のサーバスクリプト間で共有されるため、保護スクリプトは正規の同期処理だけを通せます。実行ユーザー ID で例外にすると、そのユーザーが画面や API から変更した場合も許可されてしまいますが、この方法ならユーザーではなく処理経路で判定できます。

会社独自の休日と独自カレンダー ​

  • 会社創立記念日などを手動登録するときは、分類 A〜C を空欄にします。同期キーも取得元も異なるため、次回同期で上書きされません。同じ日に Google の祝日と会社休日があれば、表示スクリプトが名称を連結して表示します。
  • 会社独自の休日カレンダーが Google カレンダーで公開されていれば、CALENDAR_ID をそのカレンダー ID に変えるだけで同じ仕組みを使えます。複数の公開カレンダーを取り込むときはカレンダー ID の配列をループします(同期キーにカレンダー ID を含めているので UID が衝突しません)。公開 iCal アドレスは公開設定されたカレンダーでのみ利用できます。

関連ページ ​

変更履歴

第6版カレンダーのレシピにスクリーンショットを追加し、標準カレンダー専用であることを明記
第5版リンク項目の列指定と JOIN の組み立て、一覧のスクロール読み込みの解説と、一覧・カレンダー・サイトメニューまわりの改修・設計メモを追加
第4版画面カスタマイズ集のコードを 1.5.8.1 のソースで検証し、動かなかったサンプルを修正
第3版画面カスタマイズ集にコード全体を収録し、サイト画像とサイト種別アイコンを両立するレシピを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「画面カスタマイズ集」セクションの記事を追加