掲示物のタイトルが IMG_1234 のまま教室のテレビに出る
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
ファイル名から作るタイトルは、空文字の場合だけ固定文字列で埋めていたため、IMG_1234 のような機械が付けた名前はそのまま教室のテレビの盤面に表示されていた。
結論
ファイル名から作るタイトルは、「空かどうか」しか見ていませんでした。 空でなければファイル名をそのまま使い、空のときだけ "掲示物" という固定文字列で埋めていたため、カメラやスキャナが自動で付けた IMG_1234 のような名前もそのまま教室のテレビの盤面に表示されていました。
直し方は、ファイル名が「機械が付けた名前」のパターンに一致するかを正規表現で判定し、一致したら空文字を返すようにすることです。空文字は「名前なし」として扱われ、盤面には何も表示されません。
症状
掲示物をアップロードすると、タイトル欄には拡張子を外したファイル名が自動で入ります。カメラで撮った写真やスキャナで取り込んだ原稿は IMG_1234 や scan_20261005、poster のような名前のまま保存されることが多く、そのタイトルがそのまま教室のテレビの盤面に出ていました。
名前を省略して保存した場合は、"掲示物" という固定文字列で埋められ、これも同じように盤面にそのまま表示されていました。どちらのケースも、人が読んで意味の通じる文字列ではありません。
原因
titleFromFileName() は、ファイル名の拡張子を外して trim するだけの関数でした。
// 変更前
export function titleFromFileName(name: string): string {
const base = name.replace(/\.[^.]+$/, "").trim();
return base || "掲示物";
}
ファイル名の内容は見ておらず、base が truthy かどうかの二値判定しかしていません。IMG_1234 のような文字列も base としては truthy なので、そのままタイトルとして返っていました。
保存側の parsePosterRecord() も同じ考え方で、保存されたタイトルが空文字列か空白だけの場合にのみ "掲示物" にフォールバックしていました。
// 変更前
const title =
typeof r.title === "string" && r.title.trim()
? r.title.trim().slice(0, MAX_POSTER_TITLE_LENGTH)
: "掲示物";
そして盤面データへ変換する recordsToSignagePosters() は、title: r.title をそのまま渡していました。r.title がファイル名であっても "掲示物" という固定文字列であっても、区別せずに盤面側へ送られていました。
直し方
ファイル名が「機械が付けた名前」かどうかを判定する正規表現の配列を追加しました。
const MACHINE_NAME_PATTERNS: readonly RegExp[] = [
// 数字・記号・日付の区切りだけ(例: 20261005_123456 / 2026-10-05 / 001)。
/^[\d\s_.\-()()::年月日時分秒]+$/,
// カメラ・スマホ・スキャナ・汎用の英語名 + 番号(例: IMG_1234 / DSC01234 / PXL_20261005_… / image (1) / poster)。
/^(img|image|images|dsc|dscn|dscf|dcim|pxl|mvimg|photo|pic|picture|scan|scanned|scanner|document|doc|file|download|downloads|untitled|new|poster|pdf|jpeg|jpg|png|heic|cimg|sam|wp|p)[\s_\-.]*[a-z]?[\d\s_\-.()]*(copy|コピー)?[\s_\-.]*[\d()]*$/i,
// 日本語の汎用名 + 番号(例: スキャン_20261005 / 画像 2 / 写真(3) / 名称未設定 / 無題のドキュメント)。
/^(スキャン|画像|写真|書類|文書|ドキュメント|名称未設定|無題|新規|新しい|掲示物)[^\p{L}]*$/u,
/^(名称未設定|無題)/,
// スクリーンショット(例: Screenshot 2026-10-05 at 9.12.33 / スクリーンショット 2026-10-05 9.12.33)。
/^(screenshot|screen shot|スクリーンショット|スクショ)/i,
// 英数字だけの長い乱数・UUID(例: 3f9a…-…)。
/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i,
/^(?=.*\d)(?=.*[A-Za-z])[0-9A-Za-z]{20,}$/,
];
titleFromFileName() は、正規化と trim のあとにこのパターンへ照合し、一致したら空文字を返します。
// 変更後
export function titleFromFileName(name: string): string {
const base = name
.replace(/\.[^.]+$/, "")
.normalize("NFC")
.trim();
if (!base) return "";
return MACHINE_NAME_PATTERNS.some((re) => re.test(base)) ? "" : base;
}
保存側のデフォルトも "掲示物" から空文字に変わりました。
// 変更後
const title = typeof r.title === "string" ? r.title.trim().slice(0, MAX_POSTER_TITLE_LENGTH) : "";
盤面へ変換する側では、空文字を null に変換してから渡します。
// 変更後
title: r.title || null,
空文字と null を分けたのがここの要点です。 保存形では「タイトルは必ず文字列」という型を保ったまま空文字で「名前なし」を表現し、盤面に渡す直前でだけ null に変換しています。盤面側のレンダリングはタイトルが無いことを null で判定するため、型を変えずに「表示するかどうか」を切り替えられます。
気づけなかった理由
titleFromFileName() の変更前の実装を見直すと、判定軸は「base が空文字かどうか」の一点だけでした。空でなければ何であれそのまま使う、という作りです。
この作りだと、「名前を省略した」場合と「名前として意味を持たない文字列をアップロードした」場合を区別できません。前者は想定されてフォールバックが用意されていましたが、後者はbaseが truthy になってしまうため、素通りしていました。ファイル名が存在すること自体は正常なデータなので、型チェックやビルドでは検出できず、実際に教室のテレビに表示されるまで気づかれない種類の不整合でした。
掲示物エディタの他の不整合については buildSignagePayloadForClassを使わない盤面プレビューは自動ブロックが実機と食い違う にも書いています。
よくある質問
Q1「機械が付けた名前」とは具体的に何を指しますか?
カメラ・スマホ・スキャナが自動で付ける連番や日付だけの名前(IMG_1234、DSC01234、scan_20261005)、スクリーンショットの既定名、OSが付ける「名称未設定」「無題」、UUIDや長い英数字の乱数列を指します。これらはいずれも、そのまま教室のテレビに出しても人には意味が通じません。
Q2名前を機械が付けた名前と判定したあと、タイトルはどうなりますか?
タイトルは空文字になります。エディタ側はこの状態を「名前なし」として扱い、名前の入力欄にカーソルを置いて入力を促します。盤面側では空文字が null に変換され、テレビの画面にはタイトル自体が表示されません。
Q3人が付けた名前はそのまま使われますか?
はい。「進路だより10月号」のような人が付けた名前は、どの判定パターンにも一致しないため、そのままタイトルとして使われます。判定は機械が付けた名前のパターンに一致するかどうかだけを見ており、人が付けた名前の形を限定していません。
Q4以前はどう表示されていましたか?
名前が空の場合は「掲示物」という固定文字列で埋められ、そのまま盤面に表示されていました。機械が付けたファイル名についてはそもそも判定する仕組みが無く、IMG_1234 のような文字列もそのままタイトルとして盤面に出ていました。
確認した環境
- Next.js 16.3(apps/web/package.json、2026-10-05時点)
- 2026-10-05 にmain取り込み(UX発見の記録は2026-10-04)
この記事の根拠
- TypeScriptファイル 113〜145行目コミット 879a898
- TypeScriptファイル 41〜54行目コミット 879a898
- TypeScriptファイル 133〜150行目コミット 879a898
- TypeScriptファイル 165〜186行目コミット 879a898
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。