はじめに
外部サービスや手順書へのリンクを、サイト一覧の下にカードとして並べるカスタマイズです。表示するリンクを記録テーブルで管理し、現在のサイトとログインユーザーの部署に合わせて絞り込みます。
枠の大きさや背景を別に作らず、標準のサイトカードと同じDOM構造・クラスを利用します。添付画像があればカードのアイコンとして使い、未添付や読込失敗時は既定のアイコンを表示します。プリザンター本体の変更やブラウザへのAPIキー埋め込みは不要です。
ブックマークマスタを用意する
リンクの保存元として、記録テーブル(Results)を1つ用意します。コードは次の項目を使います。物理名はこの組み合わせに揃え、表示名は運用に合わせて変えられます。
| 物理名 | 表示名の例 | 設定・内容 |
|---|---|---|
| Title | ブックマーク名 | カードに表示する名前 |
| ClassA | URL | リンク先のURL。空欄のレコードは表示しない |
| ClassB | 表示先サイトID | カードを置くサイトのIDを文字列で保存する分類項目。単一選択 |
| ClassC | 対象部署 | 複数選択。選択肢一覧を[[Depts]]にして、対象部署を選ぶ |
| AttachmentsA | アイコン画像 | 添付ファイル項目を有効化。添付数の上限を1件にし、画像を許可する |
表示先サイトのIDと、ログインユーザーの部署IDの両方をAPIのフィルターへ渡します。部署欄が空だから全員に表示する、という処理はありません。部署未設定ユーザーを含める場合も、その扱いを別途設計してください。
ClassBに入れるのはブックマークマスタ自身のIDではなく、ブックマークを表示したいサイトのIDです。コード中のbookmarkSiteIdには、ブックマークマスタ自身のサイトIDを設定します。
スクリプトとスタイルを登録する
配布例のbookmarkSiteId = 100はダミー値です。作成したマスタのサイトIDへ変更してください。
Extensionsへ登録する場合は、次の2件を用意します。
| 項目 | スクリプト | スタイル |
|---|---|---|
| ExtensionType | Script | Style |
| ExtensionName | Bookmarks.js | Bookmark.css |
| ExtensionSettings |
{}のJSON文字列 |
{}のJSON文字列 |
| Body | スクリプト全文 | CSS全文 |
| Disabled | 初回はtrue、検証後にfalse | 初回はtrue、検証後にfalse |
スクリプトは$p.tableName() === 'Sites'と#SiteMenuの存在を確認してから動くため、通常のレコード一覧・編集画面にはカードを追加しません。更新後はパラメータを再読込して、表示先のサイトメニューを開き直します。
標準のサイトカードへ寄せる
追加する一覧にnav-sites、カードにnav-siteを付けます。カード内もsite-icon・title・heading・conditionsを使い、標準テーマの枠・背景・余白・ホバー表示を引き継ぎます。
CSSで追加するのは一覧の回り込み解除、既定アイコンと添付画像の表示などです。カード全体の幅・高さ・背景色を固定値で再定義しません。既定アイコンはmaterial-symbols-outlinedを利用します。このフォントを読み込んでいない環境では、次のリンク要素を共通HTMLのhead末尾へ追加してください。
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">
導入方法はGoogle FontsのMaterial Symbolsガイドに基づきます(参照日2026-11-04)。
フォントを外部から取得できない環境では、組織内で提供するフォントのURLや標準で利用できるアイコンへ置き換えます。
添付画像をアイコンにする
ブックマークのレコードを開き、「アイコン画像」に画像を1件添付して保存します。カードの画像は48px枠で縦横比を保ちます。画像がない場合、画像ファイルとして扱わない拡張子の場合、読込に失敗した場合は既定アイコンへ戻ります。
コードは添付メタデータから画像名とGUIDを確認し、アプリケーション配下のbinaries/{GUID}/showを画像の取得先にします。ApplicationPathを使うため、アプリがサブディレクトリに配置されている場合もそのパスを引き継ぎます。画像を公開URLへコピーしたり、APIキーを画像URLへ付けたりはしません。添付の閲覧権限も必要です。
ファイル名で判定する拡張子はPNG・JPEG・GIF・WebP・SVG・AVIF・ICO・BMPです。これはサーバーのアップロード制限やMIME検証を変更するものではありません。実際に許可する形式は運用側で制限してください。複数の添付メタデータが返った場合は、削除されていない最初の画像を使います。
再取得と描画を整理する
- APIの
OffsetとTotalCountを見て、複数ページのレコードを取得します。ページ順はTitle・ResultIdの昇順です。 - カードは
DocumentFragmentへ組み立て、取得完了時にまとめて一覧へ差し込みます。レコードごとに表示中のDOMへ追加する処理を避けます。 - 同じレコードIDを重複表示せず、同じ画面でスクリプトが二重初期化されても一覧を増やしません。
- 平坦なAPI項目と
ClassHash・AttachmentsHash形式の双方を読み取ります。添付情報がJSON文字列で返る場合にも対応します。 - 画像には遅延読込と非同期デコードを指定します。一覧APIとは別に画像取得の通信が発生します。
- 部署・権限・マスタの変更を持ち越さないため、ページをまたぐ永続キャッシュは使いません。取得エラー時に無制限で再試行する処理もありません。
通信回数が常に1回になるわけではありません。1ページに収まる件数なら一覧APIは1回で、複数ページならその分だけ増えます。全体の表示速度はAPIの応答や画像サイズにも依存します。表示秒数の改善率を測定した機能ではありません。
表示条件とアクセス権は別
サイト・部署のフィルターは表示対象を選ぶ条件であり、それだけをアクセス制御として使うものではありません。マスタの閲覧権限とリンク先の認証・権限を適切に設定してください。
名称はtextContentで表示し、名称にHTMLを混ぜても実行しません。URLはhttp:・https:・mailto:・tel:だけを許可します。javascript:などは表示しません。別タブで開くリンクにはnoopener noreferrerを指定します。
確認と復旧
Pleasanter 1.5.9.1のv2テーマceruleanで、標準のサイトカードと同じ209px×140pxの表示を確認しました。記事用のDocker画面は、例示用の3件を返すサンプルレスポンスで描画しています。本番のマスタや業務リンクは載せていません。
汎用化した配布コードに対し、平坦・Hash形式、添付画像のメタデータと読込失敗時の復帰、ページング、二重初期化、URLの制限、名称を文字列として表示する処理の自動テストを実行しました。画像の実ファイルを添付して保存し、APIから取得する一連の操作は、この検証では実施していません。すべてのテーマ・端末で同じ寸法になる保証もありません。
元へ戻す場合は、専用ScriptとStyleを無効化してパラメータを再読込します。マスタのレコードや添付ファイルを削除する必要はありません。
スクリプト全文
(function () {
'use strict';
// Replace 100 with the site ID of your bookmark master table.
const bookmarkSiteId = 100;
$(function () {
if ($p.tableName() !== 'Sites') return;
const host = document.getElementById('SiteMenu');
if (!host || host.querySelector('[data-vhub-bookmarks]')) return;
const nav = document.createElement('nav');
nav.className = 'cf vhub-bookmarks';
nav.dataset.vhubBookmarks = '';
nav.setAttribute('aria-label', 'ブックマーク');
const list = document.createElement('ul');
list.className = 'nav-sites bookmarks';
nav.append(list);
host.append(nav);
nav.hidden = true;
const fragment = document.createDocumentFragment();
const seen = new Set();
const siteId = $p.id();
const deptId = $p.deptId();
function add(item) {
const id = String(item.ResultId);
if (seen.has(id)) return;
seen.add(id);
const raw = String(item.ClassA ?? item.ClassHash?.ClassA ?? '').trim();
if (!raw) return;
let url;
try { url = new URL(raw, location.href); } catch { return; }
if (!['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) return;
const li = document.createElement('li');
li.className = 'nav-site bookmarks';
li.dataset.value = id;
li.dataset.type = 'Bookmarks';
const link = document.createElement('a');
link.href = url.href;
link.target = '_blank';
link.rel = 'noopener noreferrer';
const iconBox = document.createElement('div');
iconBox.className = 'site-icon';
const icon = document.createElement('span');
icon.className = 'material-symbols-outlined bookmark-icon';
icon.textContent = 'switch_access_shortcut';
icon.setAttribute('aria-hidden', 'true');
iconBox.append(icon);
let attachments = item.AttachmentsA ?? item.AttachmentsHash?.AttachmentsA ?? [];
try { if (typeof attachments === 'string') attachments = JSON.parse(attachments); } catch { attachments = []; }
const attachment = Array.isArray(attachments) && attachments.find(a =>
a && !a.Deleted && /^[0-9a-f]{32}$/i.test(a.Guid ?? '') && /\.(png|jpe?g|gif|webp|svg|avif|ico|bmp)$/i.test(a.Name ?? a.FileName ?? ''));
if (attachment) {
const image = document.createElement('img');
const basePath = document.getElementById('ApplicationPath')?.value || '/';
image.src = basePath + 'binaries/' + attachment.Guid + '/show';
image.alt = '';
image.width = 48;
image.height = 48;
image.loading = 'lazy';
image.decoding = 'async';
image.className = 'bookmark-image';
icon.hidden = true;
image.addEventListener('error', function () { image.remove(); icon.hidden = false; }, {once: true});
iconBox.append(image);
}
const title = document.createElement('span');
title.className = 'title';
title.textContent = String(item.Title ?? '');
link.title = title.textContent;
const heading = document.createElement('div');
heading.className = 'heading';
const conditions = document.createElement('div');
conditions.className = 'conditions';
const badge = document.createElement('span');
badge.className = 'reference material-symbols-outlined';
badge.textContent = 'bookmarks';
badge.setAttribute('aria-hidden', 'true');
conditions.append(badge);
link.append(iconBox, title, heading, conditions);
li.append(link);
fragment.append(li);
}
function finish() {
if (!host.isConnected) return;
list.replaceChildren(fragment);
nav.hidden = list.children.length === 0;
}
function load(offset) {
$p.apiGet({
id: bookmarkSiteId,
data: {
Offset: offset,
View: {
ColumnFilterHash: { ClassB: siteId, ClassC: JSON.stringify([String(deptId)]) },
ColumnSorterHash: { Title: 'asc', ResultId: 'asc' }
}
},
done: function (data) {
const response = data?.Response;
if (data?.StatusCode !== 200 || !Array.isArray(response?.Data)) {
finish();
return;
}
const rows = response.Data;
rows.forEach(add);
const next = offset + rows.length;
if (rows.length && Number(response.TotalCount) > next) load(next);
else finish();
},
fail: finish
});
}
load(0);
});
})();
スタイル全文
/* Reuse the standard Sites card layout, theme, size and hover treatment. */
#SiteMenu .vhub-bookmarks { clear: both; }
#SiteMenu .vhub-bookmarks[hidden] { display: none; }
#SiteMenu .vhub-bookmarks .site-icon .bookmark-icon {
display: inline-block;
font-size: 48px;
line-height: 1;
color: var(--primaryColor);
}
#SiteMenu .vhub-bookmarks .bookmark-icon[hidden] { display: none; }
#SiteMenu .vhub-bookmarks .site-icon .bookmark-image { width: 48px; height: 48px; object-fit: contain; }
まとめ
リンクの管理を記録テーブルにまとめ、標準のサイトカードに近い表示で並べられます。サイト・部署の表示条件、マスタとリンク先のアクセス権、添付画像の権限をそれぞれ確認して利用します。