プレビュー
テーマ3種類 × 配色2種類 × 明暗から選べます。選んだ組み合わせが入った状態でコードが書き出されるので、書き換えは要りません。
コード
すべてのファイルを、同じ名前でスクリプトエディタに作ってから貼り付けてください。style.html は、上で選んだ見た目で書き出されます。
/* * wiki — サーバー側。開発では役割ごとに分けているファイルを、 * 貼る手間を減らすため1つにまとめている。最新版は script-techo.com にある。 *//* ============================================================ コード.gs ============================================================ *//**
* wiki の入口と、全部の呼び出しに共通する部分。
* 画面との約束は API.md にある。画面はこの約束しか知らない。
*
* 誰が入れるかはこのコードでは決めない。ウェブアプリの「アクセスできるユーザー」で決める。
* ここで決めるのは「誰が書いたか」と「全体の設定をいじれるのは誰か」だけ。
*/
var VERSION = '1.1.2';
var PROP = PropertiesService.getScriptProperties();
/*
* スクリプトのプロパティは、1回の実行で1度だけまとめて読む。
* 読み書きには1日の回数の枠があり、1つずつ読むと、参加していない人の呼び出しだけで枠を使い切られるため。
* ロックを取ったあとは読み直す(ほかの実行が書いた値を見るため)。
*/
var PROPS_ = null;
function props_() {
if (!PROPS_) PROPS_ = PROP.getProperties();
return PROPS_;
}
function prop_(key) {
var v = props_()[key];
return v === undefined ? null : v;
}
function setProp_(key, value) {
PROP.setProperty(key, String(value));
props_()[key] = String(value);
}
function delProp_(key) {
PROP.deleteProperty(key);
delete props_()[key];
}
var LIMIT = {
title: 120,
tag: 30,
tags: 8,
comment: 2000,
note: 120,
name: 40,
site: 30,
html: 500000,
members: 100,
devices: 10,
driveReserve: 1024 * 1024 * 1024,
revisions: 30,
file: 10 * 1024 * 1024,
uploadDay: 300 * 1024 * 1024,
pages: 2000
};
var OWNER_NAME = '管理者';
/* ------------------------------------------------------------ 入口 */
function doGet() {
setup_();
var tpl = HtmlService.createTemplateFromFile('index');
return tpl
.evaluate()
.setTitle(readSite_().name)
.addMetaTag('viewport', 'width=device-width, initial-scale=1')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.DEFAULT);
}
/** index.html から style.html を読み込むために使う */
function include(name) {
if (['style', 'script'].indexOf(name) < 0) throw new Error('読み込めないファイルです: ' + name);
return HtmlService.createHtmlOutputFromFile(name).getContent();
}
/* ------------------------------------------------------------ 返す形 */
function ok_(o) {
var out = { ok: true };
if (o) Object.keys(o).forEach(function (k) { out[k] = o[k]; });
return out;
}
function ng_(code, message, extra) {
var out = { ok: false, code: code, message: message };
if (extra) Object.keys(extra).forEach(function (k) { out[k] = extra[k]; });
return out;
}
/** 呼び出し側に投げる「止める」印。api_ が受け止めて ng_ に変える */
function stop_(code, message, extra) {
var e = new Error(message);
e.__wiki = ng_(code, message, extra);
return e;
}
/**
* 見た目(テーマ・配色)。index.html の <html> に書かれているものを読む。
* コードの中に持たない。配るときにサイトが書き出した値が、そのまま正本になる。
*/
var LOOK_ = null;
function look_() {
if (LOOK_) return LOOK_;
var html;
try {
html = HtmlService.createHtmlOutputFromFile('index').getContent().slice(0, 600);
} catch {
// 読めない環境では既定の見た目として扱う
html = '';
}
var theme = (html.match(/data-app-theme="([a-z]+)"/) || [])[1];
var palette = (html.match(/data-app-palette="([01])"/) || [])[1];
LOOK_ = {
theme: ['material', 'shadcn', 'ledger'].indexOf(theme) >= 0 ? theme : 'material',
palette: palette === '1' ? 1 : 0
};
return LOOK_;
}
/* ------------------------------------------------------- 誰が呼んだか */
var UID_RE = /^[0-9a-zA-Z_-]{4,64}$/;
/** 設置した人かどうか。この方法で本人と分かるのは設置者だけ(ほかの人は空文字が返る) */
function isInstaller_() {
var email;
try {
email = String(Session.getActiveUser().getEmail() || '');
} catch {
// 権限がないと例外になる環境もある。その場合は設置者ではないものとして扱う
return false;
}
if (!email) return false;
return email.toLowerCase() === String(prop_('ownerEmail') || '').toLowerCase();
}
/**
* 端末の ID から本人を割り出す。
* 設置者は毎回 Google アカウントで確かめ、端末の ID では引かない。
* 設置者が一度開いた共用の端末が、Google からログアウトしたあとも設置者として通らないようにするため。
*/
function whoIs_(uid) {
var members = readMembers_();
if (isInstaller_()) {
var owner = firstBy_(members, function (m) { return m.owner; });
if (owner) {
if (prop_('ownerNameChecked') && !owner.uids.length) return owner;
return patchMember_(owner, function (cur) {
// 前の版で設置者の行に入れていた端末の ID は、もう使わないので消す
cur.uids = [];
if (!prop_('ownerNameChecked')) fixOwnerName_(cur);
});
}
}
// 前の版では設置者の行にも端末の ID を入れていたので、設置者の行は端末で引かない
return firstBy_(members, function (m) { return !m.owner && m.uids.indexOf(uid) >= 0; });
}
/**
* 画面から呼べる関数はすべてこれを通す。
* 形式 → 回数の上限 → 本人の割り出し → 管理者かどうか → 本体、の順で確かめる。
*/
function api_(uid, opts, fn) {
try {
setup_();
if (typeof uid !== 'string' || !UID_RE.test(uid)) return ng_('INVALID', 'この端末の ID が正しくありません');
var bucket = (opts && opts.write) ? 'w' : 'r';
var limit = bucket === 'w' ? 30 : 120;
if (!underLimit_(bucket + ':' + uid, limit, 60)) {
return ng_('LIMIT', '操作が続けて行われました。少し待ってからお試しください');
}
MEMBER_VERSION_SEEN_ = memberVersion_();
var me = whoIs_(uid);
if (!me) return ng_('NEED_JOIN', 'まだ参加していません');
if (opts && opts.admin && me.role !== 'admin') return ng_('FORBIDDEN', 'この操作は管理者だけができます');
me = touch_(me);
if (!me) return ng_('NEED_JOIN', 'まだ参加していません');
if (opts && opts.write) {
return withLock_(function () {
// ロックを待つ間にメンバー表が書き換わっていたら、自分の行を読み直す。古い行のまま書き戻さないため
var cur = memberVersion_() === MEMBER_VERSION_SEEN_ ? me : freshMember_(me.id);
// 待つ間に消されたり、この端末が外されたりしていたら通さない(設置者は Google アカウントで確かめる)
if (!cur || (cur.owner ? !isInstaller_() : cur.uids.indexOf(uid) < 0)) {
return ng_('NEED_JOIN', 'まだ参加していません');
}
if (opts.admin && cur.role !== 'admin') return ng_('FORBIDDEN', 'この操作は管理者だけができます');
try {
return fn(cur);
} finally {
bumpVersion_(); // 控えを捨てる。失敗した呼び出しや例外でも、途中まで書いている場合があるため
}
});
}
return fn(me);
} catch (e) {
if (e && e.__wiki) return e.__wiki;
console.error(e);
return ng_('INVALID', 'うまく処理できませんでした。もう一度お試しください');
}
}
/* ロックの中で控えの番号を上げたかどうか。確定のあとにもう一度上げる */
var LOCK_DEPTH_ = 0;
var DATA_DIRTY_ = false;
var MEMBERS_DIRTY_ = false;
/** api_ が本人を割り出したときのメンバー表の番号。ロックの中で変わっていたら読み直す */
var MEMBER_VERSION_SEEN_ = '';
/**
* 書き込みは1つずつ。ロックが取れないときは BUSY を返して、画面に再試行させる。
* after は、書いた内容を確定させたあと、ロックを手放す前に呼ぶ。
*/
function withLock_(fn, waitMs, after) {
// 同じ実行の中で取り直したときの振る舞いは公式に書かれていないので、取り直さない
if (LOCK_DEPTH_ > 0) return fn();
var lock = LockService.getScriptLock();
// 保存は数秒で終わる。ここで待ちきれると BUSY になり、「先に保存されています」を出せないので長めに待つ
if (!lock.tryLock(waitMs || 30000)) return ng_('BUSY', 'ほかの人の保存と重なりました。少し待ってからお試しください');
PROPS_ = null;
LOCK_DEPTH_++;
try {
return fn();
} finally {
// 途中で例外になっても、そこまで書いた分は確定させてから手放す
try {
// 表への書き込みは実行の終わりにまとめて送られる。確定させてから手放さないと、次にロックを取った実行が古い値を読む
SpreadsheetApp.flush();
// 確定の前に、ロックの外で読んだ実行が古い内容を控えたかもしれないので、番号を上げ直して捨てる
if (DATA_DIRTY_) setProp_('dataVersion', String(Number(dataVersion_()) + 1));
if (MEMBERS_DIRTY_) setProp_('memberVersion', String(Number(memberVersion_()) + 1));
DATA_DIRTY_ = MEMBERS_DIRTY_ = false;
if (after) after();
} finally {
LOCK_DEPTH_--;
lock.releaseLock();
}
}
}
/* ------------------------------------------------------------ 読み込みの控え
* 表を読む往復は1回 0.2〜1秒かかる。中身が変わっていない間は、作った結果をそのまま使う。
* 「変わったかどうか」は、書き込みのたびに1つ増える番号で見る。 */
function dataVersion_() { return String(prop_('dataVersion') || '0'); }
function bumpVersion_() {
setProp_('dataVersion', String(Number(dataVersion_()) + 1));
if (LOCK_DEPTH_ > 0) DATA_DIRTY_ = true;
}
function cacheGet_(key) {
try {
var hit = CacheService.getScriptCache().get(key);
return hit ? JSON.parse(hit) : null;
} catch {
return null;
}
}
function cachePut_(key, value) {
try {
var text = JSON.stringify(value);
// CacheService は1つ 100KB まで。入り切らないものは控えない
if (text.length > 90000) return;
CacheService.getScriptCache().put(key, text, 300);
} catch {
// 控えられなくても、読み直せばよいだけ
}
}
/** 控えを使って作る。key が同じで番号も同じなら、前の結果をそのまま返す */
function cached_(key, build) {
var full = key + ':' + dataVersion_();
var hit = cacheGet_(full);
if (hit) return hit;
var made = build();
cachePut_(full, made);
return made;
}
/**
* CacheService で数える簡単な上限。キャッシュが消えたら数え直しになるが、暴走を止めるには足りる。
* 区切りの時刻をキーに入れて数える。期限を数えるたびに延ばすと、間を空けて叩き続けられたときに解けなくなる。
*/
function underLimit_(key, max, seconds) {
var cache = CacheService.getScriptCache();
var k = limitKey_(key, seconds);
var n = Number(cache.get(k) || 0) + 1;
cache.put(k, String(n), seconds);
return n <= max;
}
/** 数えずに、上限に届いているかだけを見る */
function overLimit_(key, max, seconds) {
return Number(CacheService.getScriptCache().get(limitKey_(key, seconds)) || 0) >= max;
}
function limitKey_(key, seconds) {
return 'lim:' + key + ':' + Math.floor(Date.now() / 1000 / seconds);
}
/* ------------------------------------------------- どこで時間を使ったか
* 本番は1往復で 1.3 秒かかる。遅いときにどの段で使ったかを見るための印。
* 画面からは使わない(`debug: true` を渡したときだけ戻り値に入る)。 */
var MARKS_ = [];
function mark_(label) { MARKS_.push([label, Date.now()]); }
function marks_() {
var out = [];
for (var i = 1; i < MARKS_.length; i++) out.push(MARKS_[i][0] + ':' + (MARKS_[i][1] - MARKS_[i - 1][1]));
return out.join(' ');
}
/* ------------------------------------------------------------ 小道具 */
function uuid_() { return Utilities.getUuid().replace(/-/g, ''); }
function id_(prefix) { return prefix + uuid_().slice(0, 10); }
/** 秒までだと、同じ秒に2回保存したときに衝突を見逃すのでミリ秒まで持つ */
function now_() { return Utilities.formatDate(new Date(), 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); }
function today_() { return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd'); }
function str_(v, max) { return String(v == null ? '' : v).trim().slice(0, max); }
function bool_(v) { return v === true || v === 'true' || v === 1; }
function csv_(v) { return String(v || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean); }
function firstBy_(list, fn) {
for (var i = 0; i < list.length; i++) if (fn(list[i])) return list[i];
return null;
}
function unique_(list) {
var seen = {}, out = [];
list.forEach(function (v) { if (v && !seen[v]) { seen[v] = true; out.push(v); } });
return out;
}
function plain_(html) {
return String(html || '')
.replace(/<[^>]+>/g, ' ')
.replace(/ /g, ' ')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.replace(/\s+/g, ' ')
.trim();
}
function escHtml_(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function json_(v, fallback) {
try {
var out = JSON.parse(String(v || ''));
return out && typeof out === 'object' ? out : fallback;
} catch {
return fallback;
}
}
function isoDate_(v) { return /^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? String(v) : ''; }/* ============================================================ files.gs ============================================================ *//** 添付ファイルと、本文に入れた画像。中身は Drive に置き、表には目録だけを持つ。 */
function filesOf_(pageId) {
return readTable_('files').filter(function (r) { return String(r.page_id) === String(pageId); })
.map(function (r) {
return {
id: String(r.id), name: String(r.name), mime: String(r.mime),
size: Number(r.size) || 0, driveId: String(r.drive_id), _row: r._row
};
});
}
function fileView_(f) {
return { id: f.id, name: f.name, mime: f.mime, size: f.size };
}
function uploadFile(uid, id, input) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var data = input || {};
var name = str_(data.name, 200) || 'ファイル';
var mime = str_(data.mime, 120) || 'application/octet-stream';
var base64 = String(data.base64 || '');
if (!base64) return ng_('INVALID', 'ファイルの中身がありません');
// base64 は元の約 4/3 の長さになる
if (base64.length * 3 / 4 > LIMIT.file) return ng_('INVALID', '1つのファイルは10MBまでです');
if (!underLimit_('u:' + me.id, 20, 3600)) return ng_('LIMIT', 'アップロードが続いています。少し待ってからお試しください');
var bytes;
try {
bytes = Utilities.base64Decode(base64);
} catch {
return ng_('INVALID', 'ファイルを読み取れませんでした');
}
// 端末の ID を作り直せば人ごとの上限はすり抜けられるので、Drive が埋まらないように全体でも絞る
var day = json_(prop_('uploadDay'), null);
if (!day || day.date !== today_()) day = { date: today_(), bytes: 0 };
if (day.bytes + bytes.length > LIMIT.uploadDay) {
return ng_('LIMIT', '今日アップロードできる量(全員で' + Math.round(LIMIT.uploadDay / 1024 / 1024) + 'MB)を超えます。明日またお試しください');
}
// Drive が埋まると、設置者の Gmail もメールを受け取れなくなる。ゴミ箱のファイルも使用量に入る
if (DriveApp.getStorageLimit() - DriveApp.getStorageUsed() - bytes.length < LIMIT.driveReserve) {
return ng_('LIMIT', '設置した人の Google ドライブの空きが少ないため、アップロードを止めています');
}
var blob = Utilities.newBlob(bytes, mime, name);
var file = folder_().createFile(blob);
day.bytes += bytes.length;
setProp_('uploadDay', JSON.stringify(day));
var meta = {
id: id_('f'), page_id: p.id, name: name, mime: mime,
size: String(file.getSize()), drive_id: file.getId(), at: now_(), by: me.id
};
appendRow_('files', meta);
return ok_({ file: { id: meta.id, name: name, mime: mime, size: Number(meta.size) } });
});
}
function deleteFile(uid, id, fileId) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var f = firstBy_(filesOf_(p.id), function (x) { return x.id === String(fileId); });
if (!f) return ng_('NOT_FOUND', 'この添付ファイルは見つかりませんでした');
trashFile_(f.driveId);
deleteRows_('files', [f._row]);
return ok_();
});
}
function downloadFile(uid, id, fileId) {
return api_(uid, {}, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var f = firstBy_(filesOf_(p.id), function (x) { return x.id === String(fileId); });
if (!f) return ng_('NOT_FOUND', 'この添付ファイルは見つかりませんでした');
var blob = DriveApp.getFileById(f.driveId).getBlob();
return ok_({ name: f.name, mime: f.mime, base64: Utilities.base64Encode(blob.getBytes()) });
});
}
/**
* 本文の画像。
* この画面はサンドボックスされた iframe の中で動くので、Drive の URL を img に直接書くと止められる。
* 中身を base64 で渡して、画面側で入れる。
*/
function getImage(uid, id, fileId) {
return api_(uid, {}, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var f = firstBy_(filesOf_(p.id), function (x) { return x.id === String(fileId); });
if (!f) return ng_('NOT_FOUND', 'この画像は見つかりませんでした');
if (f.mime.indexOf('image/') !== 0) return ng_('INVALID', '画像ではありません');
var blob = DriveApp.getFileById(f.driveId).getBlob();
return ok_({ dataUrl: 'data:' + f.mime + ';base64,' + Utilities.base64Encode(blob.getBytes()) });
});
}/* ============================================================ members.gs ============================================================ *//**
* 参加と、自分の設定、管理者の操作。
* 誰が開けるかはウェブアプリの「アクセスできるユーザー」で決まる。ここでやるのは名前の管理だけ。
*/
function loaded_(me) {
return {
me: meView_(me),
site: readSite_(),
pages: metas_(me),
members: readMembers_().map(memberView_),
templates: TEMPLATES.map(function (t) { return { id: t.id, name: t.name, desc: t.desc }; }),
trashCount: trashCount_(me),
serverTime: now_()
};
}
/** 参加前でも呼べる唯一の関数。まだ誰か分からないときは me: null を返す */
function bootstrap(uid) {
try {
setup_();
if (typeof uid !== 'string' || !UID_RE.test(uid)) return ng_('INVALID', 'この端末の ID が正しくありません');
if (!underLimit_('b:' + uid, 60, 60)) return ng_('LIMIT', '読み込みが続いています。少し待ってからお試しください');
var me = whoIs_(uid);
if (!me) {
return ok_(stranger_());
}
me = touch_(me);
if (!me) return ok_(stranger_());
return ok_(cached_('boot:' + me.id, function () { return loaded_(me); }));
} catch (e) {
console.error(e);
return ng_('INVALID', 'うまく読み込めませんでした。開き直してください');
}
}
/** まだ参加していない人に返す形。合言葉があるときは、通るまでメンバーの名前も見せない */
function stranger_() {
var required = !!joinCode_();
return {
me: null, site: readSite_(), joinCodeRequired: required,
members: required ? [] : readMembers_().map(memberView_)
};
}
function join(uid, name, code) {
try {
setup_();
if (typeof uid !== 'string' || !UID_RE.test(uid)) return ng_('INVALID', 'この端末の ID が正しくありません');
if (!underLimit_('j:' + uid, 10, 600)) return ng_('LIMIT', 'やり直しが続いています。少し待ってからお試しください');
var known = whoIs_(uid);
if (known) return ok_(loaded_(known));
var bad = checkJoinCode_(uid, code);
if (bad) return bad;
return withLock_(function () {
// ロックの外で読んだ古いメンバー表を、新しい番号で控えないよう、先に読み直す
invalidate_('members');
var hit = whoIs_(uid);
if (hit) return ok_(loaded_(hit));
var raw = str_(name, LIMIT.name * 2);
var n = cleanName_(raw);
if (!n) return ng_('INVALID', '名前を入力してください');
if (n.length > LIMIT.name) return ng_('INVALID', '名前は' + LIMIT.name + '文字までです');
invalidate_('members');
var members = readMembersFromSheet_();
if (members.length >= LIMIT.members) return ng_('LIMIT', 'メンバーは' + LIMIT.members + '人までです');
// 同じ名前がすでにあれば、その人の端末として足す(機種変更や2台目)。
// 前の版で入った名前はそろえていないので、そのままの形で一致する人を先に探す
// (そろえると同じになる名前の人が2人いても、自分の名前を打てば自分になるように)
var same = firstBy_(members, function (m) { return m.name === raw; }) ||
firstBy_(members, function (m) { return cleanName_(m.name) === n; });
// 設置者と管理者の名前、設置者の最初の名前は名乗れない(名前だけで管理者になれてしまうため)
var taken = n === OWNER_NAME || (same && (same.owner || same.role === 'admin'));
if (taken) return ng_('INVALID', 'この名前は使えません。別の名前にしてください');
var at = now_();
// 端末の ID は呼ぶ側が自由に作れるので、新しい人を作る数は全体でも絞る。
// 名前を確かめたあとで数える。前で数えると、空打ちで枠を埋めて本人の入り直しを止められる
if (!same && !underLimit_('j:new', 20, 600)) return ng_('LIMIT', '参加が続いています。少し待ってから開き直してください');
var me = same || {
id: id_('m'), name: n, role: 'member', owner: false, email: '', uids: [],
joinedAt: at, lastSeenAt: at, fav: [], watch: [], checks: {}, _row: 0
};
addDevice_(me, uid);
me.lastSeenAt = at;
writeMember_(me);
bumpVersion_();
return ok_(loaded_(me));
});
} catch (e) {
console.error(e);
return ng_('INVALID', '参加できませんでした。もう一度お試しください');
}
}
function setDisplayName(uid, name) {
return api_(uid, { write: true }, function (me) {
var n = cleanName_(str_(name, LIMIT.name * 2));
if (!n) return ng_('INVALID', '表示名を入力してください');
if (n.length > LIMIT.name) return ng_('INVALID', '表示名は' + LIMIT.name + '文字までです');
if (n === OWNER_NAME && !me.owner) return ng_('INVALID', 'その名前は使えません');
invalidate_('members');
var others = readMembersFromSheet_().filter(function (m) { return m.id !== me.id; });
if (firstBy_(others, function (m) { return cleanName_(m.name) === n; })) {
return ng_('INVALID', 'その名前はほかの人が使っています');
}
me.name = n;
writeMember_(me);
return ok_({ me: meView_(me) });
});
}
function setEmail(uid, email) {
return api_(uid, { write: true }, function (me) {
var v = str_(email, 200).toLowerCase();
if (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return ng_('INVALID', 'メールアドレスの形式が正しくありません');
me.email = v;
writeMember_(me);
return ok_({ me: meView_(me) });
});
}
/** 変えられるのは名前だけ。見た目は index.html に書かれている */
function setSite(uid, site) {
return api_(uid, { write: true, admin: true }, function () {
var name = str_((site || {}).name, LIMIT.site + 1);
if (!name) return ng_('INVALID', 'wiki の名前を入力してください');
if (name.length > LIMIT.site) return ng_('INVALID', 'wiki の名前は' + LIMIT.site + '文字までです');
writeSetting_('site.name', name);
return ok_({ site: readSite_() });
});
}
function listMembers(uid) {
return api_(uid, { admin: true }, function () {
var pages = readPages_();
return ok_({
reviewMail: !!reviewTrigger_(),
joinCode: joinCode_(),
members: readMembers_().map(function (m) {
return {
id: m.id, name: m.name, role: m.role, owner: !!m.owner, email: m.email,
joinedAt: m.joinedAt, lastSeenAt: m.lastSeenAt, devices: m.owner ? 0 : m.uids.length,
drafts: pages.filter(function (p) { return !p.deletedAt && p.status === 'draft' && p.createdBy === m.id; }).length
};
})
});
});
}
/* ------------------------------------------------------------ 参加の合言葉
* 参加は名乗るだけでできるので、URL を広く公開したときは、知らない人も入れる。
* 合言葉を決めると、新しく入る人と、別の端末から入り直す人に求める。入っている端末と設置者には求めない。 */
var JOIN_CODE = { min: 8, max: 40, badPerDevice: 5, badAll: 50, seconds: 600 };
/** 合言葉にまぜると当てられやすくなる言葉。そろえた形(小文字・区切りなし)で入れる */
var COMMON_CODES = ['password', 'passwd', 'qwerty', 'asdfgh', 'abcdefg', 'letmein', 'welcome', 'iloveyou', 'admin', 'wiki'];
function joinCode_() { return String(prop_('joinCode') || ''); }
/**
* 前後の空白と、全角・半角や大文字・小文字の違いは問わない。
* 口で伝えると区切りは省かれやすいので、空白・ハイフン・長音・下線も取り除いて比べる。
*/
function normalizeCode_(s) {
return String(s || '').normalize('NFKC').toLowerCase().replace(/[\s\-\u2010-\u2015\u2212\u30FC_]/g, '');
}
/** 合言葉を決め直すたびに増える番号。端末ごとの数えるキーに入れて、決め直したら数え直しにする */
function joinCodeGen_() { return String(prop_('joinCodeGen') || '0'); }
function joinCodeWindow_() { return Math.floor(Date.now() / 1000 / JOIN_CODE.seconds); }
/** この枠の全体の間違いの数。キャッシュから追い出されて数え直しにならないよう、プロパティに持つ */
function joinCodeBad_() {
var rec = String(prop_('joinCodeBad') || '').split(':');
return Number(rec[0]) === joinCodeWindow_() ? Number(rec[1]) || 0 : 0;
}
function joinCodeBlocked_(uid) {
var c = JOIN_CODE;
return Date.now() < Number(prop_('joinCodeLockedUntil') || 0) ||
joinCodeBad_() >= c.badAll ||
overLimit_('jc:' + joinCodeGen_() + ':' + uid, c.badPerDevice, c.seconds);
}
/** 決め直したら、それまでの間違いと止まりを捨てる。古い合言葉への当て推量は、新しい合言葉の手がかりにならない */
function resetJoinCodeLimits_() {
delProp_('joinCodeBad');
delProp_('joinCodeLockedUntil');
setProp_('joinCodeGen', String(Number(joinCodeGen_()) + 1));
}
/** 合っていれば null。間違いは端末ごとと全体で数え、上限に届いたら正しくても通さない(総当たりを止めるため) */
function checkJoinCode_(uid, code) {
var required = joinCode_();
if (!required) return null;
// 開いたあとに合言葉が決まると、画面に欄がないまま空で送られる。空は何も明かさないので数えずに、欄を出させる
if (code === undefined || code === null || (typeof code === 'string' && !normalizeCode_(code))) {
return ng_('NEED_CODE', '合言葉を入力してください');
}
var c = JOIN_CODE;
var blocked = ng_('LIMIT', '合言葉の間違いが続いています。少し待ってからお試しください');
// 上限に届いたあとは、ロックを取らせずに断る
if (joinCodeBlocked_(uid)) return blocked;
// 見る・比べる・数えるを1つずつにする。同時に投げられると、古い数を読んで上限を越えて試せるため
return withLock_(function () {
if (joinCodeBlocked_(uid)) return blocked;
// 文字列でないものや、長すぎるものは比べずに間違いとして数える
var fits = typeof code === 'string' && code.length <= c.max * 4;
// ロックを待つ間に合言葉が決め直されていたら、新しいほうと比べる
var current = joinCode_();
if (!current) return null;
if (fits && normalizeCode_(code) === normalizeCode_(current)) return null;
underLimit_('jc:' + joinCodeGen_() + ':' + uid, c.badPerDevice, c.seconds);
var win = joinCodeWindow_();
var n = joinCodeBad_() + 1;
setProp_('joinCodeBad', win + ':' + n);
if (n >= c.badAll) setProp_('joinCodeLockedUntil', String((win + 1) * c.seconds * 1000));
return ng_('INVALID', '合言葉が違います');
}, 3000);
}
/** 空文字でなくす。変えても、入っている人はそのまま使える */
function setJoinCode(uid, code) {
return api_(uid, { write: true, admin: true }, function () {
var v = String(code || '').trim();
if (v.length > JOIN_CODE.max) return ng_('INVALID', '合言葉は' + JOIN_CODE.max + '文字までです');
if (v) {
var bad = weakCode_(v);
if (bad) return ng_('INVALID', bad);
setProp_('joinCode', v);
} else {
delProp_('joinCode');
}
resetJoinCodeLimits_();
return ok_({ joinCode: joinCode_() });
});
}
/**
* 当てられやすい合言葉なら理由を返す。
* 記号と空白を除いた形で判定する(「1234.5678」や「!!!!!!!!」を通さないため)。
* 長さは、そろえる前とあとの短いほうで数える(「㍿」のように、そろえると長くなる文字があるため)。
*/
function weakCode_(v) {
var core = normalizeCode_(v).replace(/[\p{P}\p{S}\p{Cf}]/gu, '');
var min = JOIN_CODE.min;
if (Math.min(v.length, core.length) < min) return '合言葉は、記号と空白を除いて' + min + '文字以上にしてください';
if (/^\p{Nd}+$/u.test(core)) return '数字だけの合言葉は当てられやすいので、文字をまぜてください';
if (new Set(Array.from(core)).size < 4) return '同じ文字の繰り返しは当てられやすいので、違う文字を4種類以上まぜてください';
var site = normalizeCode_(readSite_().name).replace(/[\p{P}\p{S}\p{Cf}]/gu, '');
if (site.length >= 3 && core.indexOf(site) >= 0) return 'wiki の名前が入った合言葉は当てられやすいので、別の言葉にしてください';
if (COMMON_CODES.some(function (w) { return core.indexOf(w) >= 0; })) {
return 'よく使われる言葉が入った合言葉は当てられやすいので、別の言葉にしてください';
}
return '';
}
/** 読み間違えにくい文字(i・l・o・0・1 を除く)で、4文字ずつ区切った12文字を作る */
function makeJoinCode(uid) {
return api_(uid, { write: true, admin: true }, function () {
var chars = 'abcdefghjkmnpqrstuvwxyz23456789';
var out = '';
// Math.random は推測されうるので、Utilities.getUuid の乱数を使う
while (out.length < 12) {
var hex = Utilities.getUuid().replace(/-/g, '');
// UUID の13文字目(版)と17文字目(種類)は決まった値なので使わない
var rnd = hex.slice(0, 12) + hex.slice(13, 16) + hex.slice(17);
for (var i = 0; i + 1 < rnd.length && out.length < 12; i += 2) {
var b = parseInt(rnd.substr(i, 2), 16);
// 248 = 31 × 8。それ以上も使うと、前の8文字が出やすくなる
if (b < 248) out += chars.charAt(b % chars.length);
}
}
out = out.slice(0, 4) + '-' + out.slice(4, 8) + '-' + out.slice(8);
setProp_('joinCode', out);
resetJoinCodeLimits_();
return ok_({ joinCode: out });
});
}
function setRole(uid, memberId, role) {
return api_(uid, { write: true, admin: true }, function () {
// 行番号で書くので、ロックの中で表から読み直す
invalidate_('members');
var members = readMembersFromSheet_();
var m = memberById_(members, memberId);
if (!m) return ng_('NOT_FOUND', 'このメンバーは見つかりませんでした');
if (m.owner) return ng_('INVALID', '設置した人はいつも管理者です');
m.role = role === 'admin' ? 'admin' : 'member';
writeMember_(m);
return ok_({ members: readMembers_().map(memberView_) });
});
}
/** 端末のひも付けを外すだけ。同じ URL を開けばまた参加できる(名前の整理用) */
function removeMember(uid, memberId) {
return api_(uid, { write: true, admin: true }, function (me) {
// 行番号で消すので、ロックの中で表から読み直す
invalidate_('members');
var members = readMembersFromSheet_();
var m = memberById_(members, memberId);
if (!m) return ng_('NOT_FOUND', 'このメンバーは見つかりませんでした');
if (m.owner) return ng_('INVALID', '設置した人は消せません');
if (m.id === me.id) return ng_('INVALID', '自分は消せません');
deleteRows_('members', [m._row]);
bumpMemberVersion_();
return ok_({ members: readMembers_().map(memberView_) });
});
}/* ============================================================ pages.gs ============================================================ *//** ページの読み書き。画面から呼ぶ関数と、その土台になる部分。 */
var EMOJIS = ['👍', '🙏', '🎉', '👀', '✅'];
var MAX_DEPTH = 2;
var TEMPLATES = [
{ id: 't0', name: '白紙', desc: '型を使わずに書く', html: '' },
{
id: 't1', name: '議事録', desc: '決まったこと・持ち越し・参加者',
html: '<h2>決まったこと</h2><ul><li></li></ul><h2>持ち越し</h2><ul><li></li></ul><h2>参加者</h2><p></p>'
},
{
id: 't2', name: '手順書', desc: '目的・手順・困ったとき',
html: '<h2>このページの目的</h2><p></p><h2>手順</h2><ol><li></li></ol><h2>困ったとき</h2><p></p>'
},
{
id: 't3', name: 'トラブルの報告', desc: '起きたこと・影響・原因・次からの対策',
html: '<h2>起きたこと</h2><p></p><h2>影響</h2><p></p><h2>原因</h2><p></p><h2>次からの対策</h2><ul><li></li></ul>'
},
{ id: 't4', name: 'よくある質問', desc: '質問と答えを並べる', html: '<h2>Q. </h2><p>A. </p><h2>Q. </h2><p>A. </p>' },
{
id: 't5', name: 'チェックリスト', desc: '毎回使う作業の確認',
html: '<h2>はじめる前に</h2><ul><li data-list="unchecked"></li></ul><h2>作業</h2><ul><li data-list="unchecked"></li></ul>'
}
];
/* ------------------------------------------------------------ 読み出し */
function readPages_() {
return readTable_('pages').map(function (r) {
return {
id: String(r.id), parentId: String(r.parent_id || ''), order: Number(r.order) || 0, title: String(r.title),
status: r.status === 'published' ? 'published' : 'draft',
visibility: r.visibility === 'members' ? 'members' : 'all',
members: csv_(r.members), tags: csv_(r.tags),
deprecated: bool_(r.deprecated), deprecatedNote: String(r.deprecated_note || ''),
owner: String(r.owner || ''), reviewDue: isoDate_(r.review_due),
createdAt: String(r.created_at || ''), createdBy: String(r.created_by || ''),
updatedAt: String(r.updated_at || ''), updatedBy: String(r.updated_by || ''),
changeNote: String(r.change_note || ''),
bodyRow: Number(r.body_row) || 0, textRow: Number(r.text_row) || 0, links: csv_(r.links),
commentCount: Number(r.comment_count) || 0, reactionCount: Number(r.reaction_count) || 0,
revisionCount: Number(r.revision_count) || 0, watcherCount: Number(r.watcher_count) || 0,
views30d: Number(r.views_30d) || 0, viewers30d: Number(r.viewers_30d) || 0,
reactions: json_(r.reactions, {}), textLength: Number(r.text_len) || 0,
deletedAt: String(r.deleted_at || ''), deletedBy: String(r.deleted_by || ''), _row: r._row
};
});
}
function writePage_(p) {
var row = {
id: p.id, parent_id: p.parentId, order: String(p.order), title: p.title, status: p.status,
visibility: p.visibility, members: p.members.join(','), tags: p.tags.join(','),
deprecated: p.deprecated ? 'true' : 'false', deprecated_note: p.deprecatedNote,
owner: p.owner, review_due: p.reviewDue, created_at: p.createdAt, created_by: p.createdBy,
updated_at: p.updatedAt, updated_by: p.updatedBy, change_note: p.changeNote,
body_row: String(p.bodyRow || ''), text_row: String(p.textRow || ''), text_len: String(p.textLength || 0),
links: p.links.join(','),
comment_count: String(p.commentCount || 0), reaction_count: String(p.reactionCount || 0),
revision_count: String(p.revisionCount || 0), watcher_count: String(p.watcherCount || 0),
views_30d: String(p.views30d || 0), viewers_30d: String(p.viewers30d || 0),
reactions: JSON.stringify(p.reactions || {}),
deleted_at: p.deletedAt, deleted_by: p.deletedBy
};
if (p._row) updateRow_('pages', p._row, row);
else p._row = appendRow_('pages', row);
return p;
}
/**
* 本文を保存する。
* - 本文そのものは bodies の1行に分けて入れる
* - 探すための文字(タグを外したもの)は texts の1行に入れる
* - 本文から他のページへのリンクを拾って、ページの行に持たせる(リンク元をたどるため)
* セルは5万文字までなので、探すための文字は先頭だけにする。
*/
var TEXT_KEEP = 135000; // texts の3セル分(1セルは4.5万字まで)
function writeContent_(p, html) {
var text = plain_(html).slice(0, TEXT_KEEP);
var links = [];
String(html).replace(/#\/page\/([\w-]+)/g, function (all, id) {
if (links.indexOf(id) < 0) links.push(id);
return all;
});
p.links = links;
p.textLength = text.length;
p.bodyRow = writeChunks_('bodies', p.bodyRow, p.id, html);
p.textRow = writeChunks_('texts', p.textRow, p.id, text);
return p;
}
function readContent_(p) { return readChunks_('bodies', p.bodyRow); }
function canSee_(p, me) {
if (p.status === 'draft' && p.createdBy !== me.id) return false;
if (p.visibility === 'members' && p.members.indexOf(me.id) < 0) return false;
return true;
}
function livePages_(me) {
return readPages_().filter(function (p) { return !p.deletedAt && canSee_(p, me); });
}
function findPage_(pages, id, me, deleted) {
var p = firstBy_(pages, function (x) { return x.id === String(id); });
if (!p || !canSee_(p, me)) return null;
if (Boolean(p.deletedAt) !== Boolean(deleted)) return null;
return p;
}
function notFound_() { return ng_('NOT_FOUND', 'このページは見つかりませんでした'); }
/** 件数はページの行に、お気に入りと通知は自分の行に持っているので、ほかの表は読まない */
function meta_(p, me) {
return {
id: p.id, parentId: p.parentId, order: p.order, title: p.title, status: p.status,
visibility: p.visibility, members: p.members.slice(), tags: p.tags.slice(),
deprecated: p.deprecated, deprecatedNote: p.deprecatedNote, owner: p.owner, reviewDue: p.reviewDue,
createdAt: p.createdAt, createdBy: p.createdBy, updatedAt: p.updatedAt, updatedBy: p.updatedBy,
changeNote: p.changeNote,
favorite: me.fav.indexOf(p.id) >= 0, watching: me.watch.indexOf(p.id) >= 0,
watcherCount: p.watcherCount, commentCount: p.commentCount, reactionCount: p.reactionCount,
revisionCount: p.revisionCount,
readMinutes: Math.max(1, Math.ceil(p.textLength / 500)),
views30d: p.views30d, viewers30d: p.viewers30d
};
}
function metas_(me) {
return livePages_(me).map(function (p) { return meta_(p, me); });
}
/* ------------------------------------------------------------ 1ページ */
function getPage(uid, id) {
return api_(uid, {}, function (me) {
return cached_('page:' + String(id) + ':' + me.id, function () { return buildPage_(me, id); });
});
}
/** 1ページ分の中身をまとめる。控えが効いている間は呼ばれない */
function buildPage_(me, id) {
var pages = readPages_();
var p = findPage_(pages, id, me, false);
if (!p) return notFound_();
var html = sanitize_(readContent_(p));
var visible = pages.filter(function (q) { return !q.deletedAt && q.id !== p.id && canSee_(q, me); });
var related = [];
visible.forEach(function (q) {
if (related.length >= 5) return;
var shared = null;
q.tags.forEach(function (t) { if (!shared && p.tags.indexOf(t) >= 0) shared = t; });
if (shared) related.push({ id: q.id, title: q.title, reason: '同じタグ: ' + shared });
});
var backlinks = visible.filter(function (q) { return q.links.indexOf(p.id) >= 0; })
.map(function (q) { return { id: q.id, title: q.title }; });
return ok_({
page: meta_(p, me),
html: html,
attachments: filesOf_(p.id).map(fileView_),
comments: readTable_('comments').filter(function (r) { return String(r.page_id) === p.id; }).map(commentView_),
reactions: reactionsOf_(p, me),
revisions: readTable_('revisions').filter(function (r) { return String(r.page_id) === p.id; })
.sort(function (a, b) { return String(b.at).localeCompare(String(a.at)); })
.map(function (r) { return { id: String(r.id), at: String(r.at), by: String(r.by), title: String(r.title), note: String(r.note || '') }; }),
backlinks: backlinks,
related: related,
checks: checksOf_(p.id, me)
});
}
function createPage(uid, opts) {
return api_(uid, { write: true }, function (me) {
var input = opts || {};
var pages = readPages_();
if (pages.filter(function (p) { return !p.deletedAt; }).length >= LIMIT.pages) {
return ng_('LIMIT', 'ページ数が上限に達しました');
}
var parentId = str_(input.parentId, 40);
if (parentId) {
var parent = findPage_(pages, parentId, me, false);
if (!parent) return ng_('NOT_FOUND', '親のページが見つかりませんでした');
if (parent.parentId) return ng_('INVALID', '2階層より深くは作れません');
}
var tpl = firstBy_(TEMPLATES, function (t) { return t.id === input.templateId; }) || TEMPLATES[0];
var at = now_();
var html = sanitize_(tpl.html);
var p = {
id: id_('p'), parentId: parentId, order: nextOrder_(pages, parentId),
title: str_(input.title, LIMIT.title) || '新しいページ',
status: 'draft', visibility: 'all', members: [], tags: [], deprecated: false, deprecatedNote: '',
owner: me.id, reviewDue: '', createdAt: at, createdBy: me.id, updatedAt: at, updatedBy: me.id,
changeNote: '', bodyRow: 0, textRow: 0, links: [], commentCount: 0, reactionCount: 0,
revisionCount: 0, watcherCount: 0, views30d: 0, viewers30d: 0, reactions: {}, textLength: 0,
deletedAt: '', deletedBy: '', _row: 0
};
writeContent_(p, html);
writePage_(p);
return ok_({ page: meta_(p, me), html: html });
});
}
function nextOrder_(pages, parentId) {
var max = 0;
pages.forEach(function (p) {
if (!p.deletedAt && p.parentId === parentId && p.order > max) max = p.order;
});
return max + 100;
}
function savePage(uid, id, patch) {
return api_(uid, { write: true }, function (me) {
var input = patch || {};
var pages = readPages_();
var p = findPage_(pages, id, me, false);
if (!p) return notFound_();
if (!bool_(input.force) && input.baseUpdatedAt && String(input.baseUpdatedAt) !== p.updatedAt) {
var byName = memberById_(readMembers_(), p.updatedBy);
return ng_('CONFLICT', (byName ? byName.name : 'ほかの人') + 'さんが先に保存しています', { by: p.updatedBy, at: p.updatedAt });
}
var title = str_(input.title, LIMIT.title + 1);
if (title.length > LIMIT.title) return ng_('INVALID', '題名は' + LIMIT.title + '文字までです');
var html = String(input.html == null ? '' : input.html);
if (html.length > LIMIT.html) return ng_('INVALID', '本文が長すぎます。500KB までにしてください');
var tags = unique_((Array.isArray(input.tags) ? input.tags : []).map(function (t) { return str_(t, LIMIT.tag + 1); }));
if (tags.length > LIMIT.tags) return ng_('INVALID', 'タグは' + LIMIT.tags + '個までです');
for (var i = 0; i < tags.length; i++) {
if (tags[i].length > LIMIT.tag) return ng_('INVALID', 'タグは1つ' + LIMIT.tag + '文字までです');
}
var autosave = bool_(input.autosave);
if (autosave && p.status !== 'draft') return ng_('INVALID', '自動保存は下書きのときだけです');
var parentId = input.parentId === undefined ? p.parentId : str_(input.parentId, 40);
if (parentId !== p.parentId) {
var bad = checkParent_(pages, p, parentId, me);
if (bad) return bad;
}
var members = readMembers_();
var visibility = input.visibility === 'members' ? 'members' : 'all';
var list = visibility === 'members'
? unique_((Array.isArray(input.members) ? input.members : []).map(String).filter(function (m) { return memberById_(members, m); }))
: [];
// 保存した本人は必ず入れる(空でも)。入れないと自分で開けなくなる
if (visibility === 'members' && list.indexOf(me.id) < 0) list.unshift(me.id);
if (list.length > LIMIT.members) return ng_('INVALID', 'メンバーは' + LIMIT.members + '人までです');
var ownerMember = memberById_(members, input.owner);
var clean = sanitize_(html);
var wasDraft = p.status === 'draft';
if (!autosave) pushRevision_(p);
if (parentId !== p.parentId) {
p.order = nextOrder_(pages, parentId);
p.parentId = parentId;
}
p.title = title || '無題のページ';
p.tags = tags;
p.status = input.status === 'published' ? 'published' : 'draft';
p.visibility = visibility;
p.members = list;
p.owner = ownerMember ? ownerMember.id : p.owner;
p.reviewDue = isoDate_(input.reviewDue);
p.deprecated = bool_(input.deprecated);
p.deprecatedNote = p.deprecated ? str_(input.deprecatedNote, LIMIT.note) : '';
p.changeNote = autosave ? p.changeNote : str_(input.changeNote, LIMIT.note);
p.updatedAt = now_();
p.updatedBy = me.id;
writeContent_(p, clean);
writePage_(p);
var notified = 0;
if (!autosave && p.status === 'published') notified = notifyUpdate_(p, me, members);
mark_('notify');
var res = ok_({ page: meta_(p, me), notified: notified, published: wasDraft && p.status === 'published' });
mark_('meta');
// 表の名前や処理の順番が分かるので、設置者にだけ返す
if (bool_(input.debug) && isInstaller_()) res.timing = marks_();
return res;
});
}
function checkParent_(pages, p, parentId, me) {
if (!parentId) return null;
if (parentId === p.id) return ng_('INVALID', '自分自身の下には置けません');
var parent = findPage_(pages, parentId, me, false);
if (!parent) return ng_('NOT_FOUND', '移動先のページが見つかりませんでした');
if (parent.parentId) return ng_('INVALID', '2階層より深くは置けません');
var hasKids = pages.some(function (q) { return !q.deletedAt && q.parentId === p.id; });
if (hasKids) return ng_('INVALID', '中にページがあるページは、ほかのページの下に置けません');
return null;
}
/**
* 前の内容を履歴に残す。
* 本文を書き写すと重いので、前の本文が入っている行をそのまま履歴に渡し、
* 新しい本文は別の行に入れる(呼んだあとに p.bodyRow は 0 になる)。
* 30件を超えたときだけ履歴の表を読んで、古いものを消す。
*/
function pushRevision_(p) {
if (!p.bodyRow) return;
appendRow_('revisions', {
id: id_('r'), page_id: p.id, at: p.updatedAt, by: p.updatedBy,
title: p.title, note: p.changeNote, body_row: String(p.bodyRow)
});
p.bodyRow = 0;
p.revisionCount = p.revisionCount + 1;
if (p.revisionCount <= LIMIT.revisions) return;
var mine = readTable_('revisions').filter(function (r) { return String(r.page_id) === p.id; })
.sort(function (a, b) { return String(a.at).localeCompare(String(b.at)); });
var extra = mine.length - LIMIT.revisions;
if (extra <= 0) return;
var drop = mine.slice(0, extra);
drop.forEach(function (r) { clearChunks_('bodies', Number(r.body_row) || 0); });
deleteRows_('revisions', drop.map(function (r) { return r._row; }));
p.revisionCount = mine.length - extra;
}
function movePage(uid, id, parentId, index) {
return api_(uid, { write: true }, function (me) {
var pages = readPages_();
var p = findPage_(pages, id, me, false);
if (!p) return notFound_();
var target = str_(parentId, 40);
if (target !== p.parentId) {
var bad = checkParent_(pages, p, target, me);
if (bad) return bad;
}
var siblings = pages.filter(function (q) { return !q.deletedAt && q.parentId === target && q.id !== p.id; })
.sort(function (a, b) { return a.order - b.order; });
// index は「見えている兄弟」の中での位置。見えないページの並びは崩さない
var visible = siblings.filter(function (q) { return canSee_(q, me); });
var at = Math.max(0, Math.min(visible.length, Math.floor(Number(index) || 0)));
var before = visible[at];
var pos = before ? siblings.indexOf(before) : siblings.length;
siblings.splice(pos, 0, p);
p.parentId = target;
siblings.forEach(function (q, k) {
var order = (k + 1) * 100;
if (q.order !== order || q === p) {
q.order = order;
writePage_(q);
}
});
return ok_({ pages: metas_(me) });
});
}
function deletePage(uid, id) {
return api_(uid, { write: true }, function (me) {
var pages = readPages_();
var p = findPage_(pages, id, me, false);
if (!p) return notFound_();
var hasKids = pages.some(function (q) { return !q.deletedAt && q.parentId === p.id; });
if (hasKids) return ng_('HAS_CHILDREN', '中にページがあるため削除できません。先に中のページを移動するか削除してください');
p.deletedAt = now_();
p.deletedBy = me.id;
writePage_(p);
return ok_({ trashCount: trashCount_(me) });
});
}
function trashCount_(me) {
return readPages_().filter(function (p) { return p.deletedAt && canSee_(p, me); }).length;
}
function listTrash(uid) {
return api_(uid, {}, function (me) {
var items = readPages_().filter(function (p) { return p.deletedAt && canSee_(p, me); })
.sort(function (a, b) { return String(b.deletedAt).localeCompare(String(a.deletedAt)); })
.map(function (p) { return { id: p.id, title: p.title, deletedAt: p.deletedAt, deletedBy: p.deletedBy }; });
return ok_({ items: items });
});
}
function restorePage(uid, id) {
return api_(uid, { write: true }, function (me) {
var pages = readPages_();
var p = findPage_(pages, id, me, true);
if (!p) return notFound_();
var parent = firstBy_(pages, function (q) { return q.id === p.parentId; });
if (p.parentId && (!parent || parent.deletedAt)) p.parentId = '';
p.order = nextOrder_(pages, p.parentId);
p.deletedAt = '';
p.deletedBy = '';
writePage_(p);
return ok_({ page: meta_(p, me), trashCount: trashCount_(me) });
});
}
function purgePage(uid, id) {
return api_(uid, { write: true }, function (me) {
var pages = readPages_();
var p = findPage_(pages, id, me, true);
if (!p) return notFound_();
readTable_('revisions').filter(function (r) { return String(r.page_id) === p.id; })
.forEach(function (r) { clearChunks_('bodies', Number(r.body_row) || 0); });
filesOf_(p.id).forEach(function (f) { trashFile_(f.driveId); });
clearChunks_('bodies', p.bodyRow);
clearChunks_('texts', p.textRow);
['revisions', 'files', 'comments', 'views'].forEach(function (table) {
deleteWhere_(table, function (r) { return String(r.page_id) === p.id; });
});
deleteRows_('pages', [p._row]);
return ok_({ trashCount: trashCount_(me) });
});
}
function getRevision(uid, id, revId) {
return api_(uid, {}, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var r = firstBy_(readTable_('revisions'), function (x) {
return String(x.id) === String(revId) && String(x.page_id) === p.id;
});
if (!r) return ng_('NOT_FOUND', 'この版は見つかりませんでした');
return ok_({
revision: {
id: String(r.id), at: String(r.at), by: String(r.by), title: String(r.title), note: String(r.note || ''),
html: sanitize_(readChunks_('bodies', Number(r.body_row) || 0))
},
currentHtml: sanitize_(readContent_(p))
});
});
}
function restoreRevision(uid, id, revId) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var r = firstBy_(readTable_('revisions'), function (x) {
return String(x.id) === String(revId) && String(x.page_id) === p.id;
});
if (!r) return ng_('NOT_FOUND', 'この版は見つかりませんでした');
var html = sanitize_(readChunks_('bodies', Number(r.body_row) || 0));
pushRevision_(p);
p.title = String(r.title) || p.title;
p.changeNote = '以前の版に戻した';
p.updatedAt = now_();
p.updatedBy = me.id;
writeContent_(p, html);
writePage_(p);
return ok_({ page: meta_(p, me), html: html });
});
}
function logView(uid, id) {
return api_(uid, {}, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
// 同じ人が同じページを開いた記録は1時間に1回だけ。連打でロックを取り合わないように、ロックの前で見る
var cache = CacheService.getScriptCache();
var seenKey = 'view:' + me.id + ':' + p.id;
if (cache.get(seenKey)) return ok_();
cache.put(seenKey, '1', 3600);
// ページの行を丸ごと書き戻すので、保存と重ならないようにロックの中で読み直す。混んでいたら今回は数えない
withLock_(function () {
invalidate_('pages');
var cur = firstBy_(readPages_(), function (q) { return q.id === p.id; });
if (!cur) return;
appendRow_('views', { page_id: cur.id, at: now_(), by: me.id });
cur.views30d = cur.views30d + 1;
writePage_(cur);
pruneViews_();
}, 2000);
return ok_();
});
}
/**
* 30日より古い閲覧の記録を消し、ページごとの「回数」と「人数」を数え直す。
* 1日1回だけ。ふだんの保存や閲覧では、この表は読まない。
*/
function pruneViews_() {
if (prop_('viewsPrunedOn') === today_()) return;
setProp_('viewsPrunedOn', today_());
var since = Date.now() - 30 * 86400000;
// 1行ずつ消すと、古い記録が多い日はロックを長く握るので、残す分だけを書き直す
var kept = readTable_('views').filter(function (r) { return new Date(String(r.at)).getTime() >= since; });
rewriteTable_('views', kept);
var views = {}, seen = {}, viewers = {};
kept.forEach(function (r) {
var pid = String(r.page_id);
views[pid] = (views[pid] || 0) + 1;
var key = pid + '|' + String(r.by);
if (seen[key]) return;
seen[key] = true;
viewers[pid] = (viewers[pid] || 0) + 1;
});
readPages_().forEach(function (q) {
var v = views[q.id] || 0, u = viewers[q.id] || 0;
if (q.views30d === v && q.viewers30d === u) return;
q.views30d = v;
q.viewers30d = u;
writePage_(q);
});
}
/**
* 添付ファイルの中身で探す。
* PDF や Word の中の言葉は Drive の索引でしか探せないので、ここだけ Drive に聞く。
* 索引に入るまで少し時間がかかるので、上げた直後のファイルは出ないことがある。
* 権限が足りない環境では空を返し、呼び出し側はファイル名の一致だけを使う。
*/
function filesByContent_(query) {
if (query.length < 2) return {};
var key = 'find:' + Utilities.base64EncodeWebSafe(query).replace(/=+$/, '');
var hit = cacheGet_(key);
if (hit) return hit;
var out = {};
try {
var folderId = prop_('folderId');
var safe = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
var found = DriveApp.searchFiles("fullText contains '" + safe + "' and trashed = false and '" + folderId + "' in parents");
var guard = 0;
while (found.hasNext() && guard++ < 100) out[found.next().getId()] = true;
mark_('drive:search');
} catch (e) {
console.warn('添付の中身は探せませんでした: ' + e);
}
cachePut_(key, out);
return out;
}
function searchPages(uid, q) {
return api_(uid, {}, function (me) {
var query = str_(q, 100).toLowerCase();
if (!query) return ok_({ results: [], suggest: [] });
var results = [];
var pages = livePages_(me);
var texts = {};
readTable_('texts').forEach(function (r) {
texts[String(r.key)] = String(r.t1 || '') + String(r.t2 || '') + String(r.t3 || '');
});
// 添付の中身は Drive の索引でしか探せない。ページごとに「中身が一致したファイル名」を引く
var inside = filesByContent_(query);
pages.forEach(function (p) {
var hits = [];
var body = texts[p.id] || '';
var at = body.toLowerCase().indexOf(query);
if (p.title.toLowerCase().indexOf(query) >= 0) hits.push('title');
if (p.tags.join(' ').toLowerCase().indexOf(query) >= 0) hits.push('tag');
if (at >= 0) hits.push('body');
var files = filesOf_(p.id).filter(function (f) {
return f.name.toLowerCase().indexOf(query) >= 0 || inside[f.driveId];
}).map(function (f) { return f.name; });
if (files.length) hits.push('file');
if (!hits.length) return;
var snippet = at >= 0
? (at > 40 ? '…' : '') + body.slice(Math.max(0, at - 40), at + 80) + (at + 80 < body.length ? '…' : '')
: body.slice(0, 100);
results.push({ id: p.id, title: p.title, snippet: snippet, hits: hits, files: files });
});
results.sort(function (a, b) {
return (b.hits.indexOf('title') >= 0 ? 1 : 0) - (a.hits.indexOf('title') >= 0 ? 1 : 0);
});
var suggest = [];
if (!results.length) {
var chars = unique_(query.replace(/\s/g, '').split(''));
var need = Math.max(1, Math.ceil(chars.length / 2));
suggest = pages.map(function (p) {
var score = 0;
chars.forEach(function (c) { if (p.title.toLowerCase().indexOf(c) >= 0) score++; });
return { p: p, score: score };
}).filter(function (x) { return x.score >= need; })
.sort(function (a, b) { return b.score - a.score; })
.slice(0, 5)
.map(function (x) { return { id: x.p.id, title: x.p.title }; });
}
return ok_({ results: results.slice(0, 50), suggest: suggest });
});
}/* ============================================================ sanitize.gs ============================================================ *//**
* 本文の掃除。許したタグと属性だけを残す。
* 画面側でも表示の直前に DOMPurify を通すが、保存する中身もここで必ず通す
* (保存された HTML は、あとで別の画面や書き出しからも読まれるため)。
*/
var ALLOW_TAGS = {
p: 1, br: 1, h2: 1, h3: 1, ul: 1, ol: 1, li: 1, blockquote: 1, pre: 1, code: 1,
strong: 1, b: 1, em: 1, s: 1, a: 1, span: 1, img: 1, hr: 1, div: 1,
table: 1, thead: 1, tbody: 1, tr: 1, th: 1, td: 1
};
/** 中身ごと落とすもの */
var DROP_WITH_BODY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
var ALLOW_ATTR = { href: 1, class: 1, 'data-list': 1, 'data-callout': 1, 'data-drive-id': 1, colspan: 1, rowspan: 1 };
// ql-code-block* は Quill がコードブロックに付けるもの。記事の画面でも同じ見た目にするために残す
var ALLOW_CLASS = { wtable: 1, 'mk-hl': 1, 'mk-imp': 1, 'mk-sub': 1, 'ql-code-block-container': 1, 'ql-code-block': 1 };
var ALLOW_URL = /^(?:https?:\/\/|mailto:|#\/page\/|#\/file\/)/i;
var LIST_VALUES = /^(bullet|ordered|checked|unchecked)$/;
var CALLOUT_VALUES = /^(warn|point|note|pre)$/;
var DRIVE_ID = /^[\w-]{1,80}$/;
function escAttr_(v) {
return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function cleanAttrs_(tag, attrText) {
var out = '';
var re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
var m;
while ((m = re.exec(attrText)) !== null) {
var name = m[1].toLowerCase();
var value = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] || '';
if (!ALLOW_ATTR[name]) continue;
// 属性値の中の実体参照を戻してから確かめる(javascript: のような書き方を通さないため)
var raw = decodeEntities_(value).trim();
if (name === 'href') {
// 制御文字や空白を挟んで javascript: を隠す書き方があるので、確かめる前に落とす
// eslint-disable-next-line no-control-regex
var url = raw.replace(/[ - ]/g, '');
if (!ALLOW_URL.test(url)) continue;
out += ' href="' + escAttr_(url) + '"';
continue;
}
if (name === 'class') {
var keep = raw.split(/\s+/).filter(function (c) { return ALLOW_CLASS[c]; });
if (!keep.length) continue;
out += ' class="' + escAttr_(keep.join(' ')) + '"';
continue;
}
if (name === 'data-list') {
if (!LIST_VALUES.test(raw)) continue;
out += ' data-list="' + raw + '"';
continue;
}
if (name === 'data-callout') {
if (!CALLOUT_VALUES.test(raw)) continue;
out += ' data-callout="' + raw + '"';
continue;
}
if (name === 'data-drive-id') {
if (!DRIVE_ID.test(raw)) continue;
out += ' data-drive-id="' + raw + '"';
continue;
}
if (name === 'colspan' || name === 'rowspan') {
var n = parseInt(raw, 10);
if (!(n >= 1 && n <= 20)) continue;
out += ' ' + name + '="' + n + '"';
}
}
return out;
}
function decodeEntities_(s) {
return String(s)
.replace(/&#x([0-9a-f]+);?/gi, function (_, hex) { return String.fromCharCode(parseInt(hex, 16)); })
.replace(/&#(\d+);?/g, function (_, dec) { return String.fromCharCode(parseInt(dec, 10)); })
.replace(/"/gi, '"')
.replace(/'/gi, "'")
.replace(/</gi, '<')
.replace(/>/gi, '>')
.replace(/&/gi, '&');
}
function sanitize_(html) {
var out = String(html == null ? '' : html);
out = out.replace(/<!--[\s\S]*?-->/g, '');
DROP_WITH_BODY.forEach(function (tag) {
out = out.replace(new RegExp('<' + tag + '\\b[\\s\\S]*?(?:<\\/' + tag + '\\s*>|$)', 'gi'), '');
});
// 閉じられていないタグの残骸を落とす
out = out.replace(/<\/?[a-zA-Z][^>]*$/g, '');
return out.replace(/<(\/)?([a-zA-Z][a-zA-Z0-9]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g, function (all, slash, name, attrs) {
var tag = String(name).toLowerCase();
if (!ALLOW_TAGS[tag]) return '';
if (slash) return '</' + tag + '>';
if (tag === 'br' || tag === 'hr' || tag === 'img') return '<' + tag + cleanAttrs_(tag, attrs) + '>';
return '<' + tag + cleanAttrs_(tag, attrs) + '>';
});
}/* ============================================================ social.gs ============================================================ *//** お気に入り・通知・リアクション・コメント・チェックと、メールのお知らせ。 */
/** 入っていれば外す、無ければ入れる。戻り値は入れたかどうか */
function toggleIn_(list, value) {
var at = list.indexOf(value);
if (at >= 0) {
list.splice(at, 1);
return false;
}
list.push(value);
return true;
}
function toggleFavorite(uid, id) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var on = toggleIn_(me.fav, p.id);
writeMember_(me);
return ok_({ favorite: on });
});
}
function toggleWatch(uid, id) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var on = toggleIn_(me.watch, p.id);
writeMember_(me);
p.watcherCount = Math.max(0, p.watcherCount + (on ? 1 : -1));
writePage_(p);
return ok_({ watching: on, watcherCount: p.watcherCount });
});
}
/** リアクションはページの行に { 絵文字: [メンバーID] } の形で持つ */
function reactionsOf_(p, me) {
var out = [];
EMOJIS.forEach(function (emoji) {
var list = p.reactions[emoji] || [];
if (!list.length) return;
out.push({ emoji: emoji, count: list.length, mine: list.indexOf(me.id) >= 0 });
});
return out;
}
function react(uid, id, emoji) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var mark = String(emoji);
if (EMOJIS.indexOf(mark) < 0) return ng_('INVALID', 'その絵文字は使えません');
var list = p.reactions[mark] || [];
toggleIn_(list, me.id);
if (list.length) p.reactions[mark] = list;
else delete p.reactions[mark];
p.reactionCount = EMOJIS.reduce(function (n, e) { return n + (p.reactions[e] || []).length; }, 0);
writePage_(p);
return ok_({ reactions: reactionsOf_(p, me) });
});
}
function commentView_(r) {
return {
id: String(r.id), at: String(r.at), by: String(r.by), text: String(r.text),
resolved: bool_(r.resolved), mentions: csv_(r.mentions)
};
}
function addComment(uid, id, text) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var body = str_(text, LIMIT.comment + 1);
if (!body) return ng_('INVALID', 'コメントを入力してください');
if (body.length > LIMIT.comment) return ng_('INVALID', 'コメントは' + LIMIT.comment + '文字までです');
if (!underLimit_('c:' + me.id, 20, 600)) return ng_('LIMIT', 'コメントが続いています。少し待ってからお試しください');
var members = readMembers_();
var mentions = members.filter(function (m) {
return m.id !== me.id && body.indexOf('@' + m.name) >= 0;
}).map(function (m) { return m.id; });
var c = { id: id_('c'), page_id: p.id, at: now_(), by: me.id, text: body, resolved: 'false', mentions: mentions.join(',') };
appendRow_('comments', c);
p.commentCount = p.commentCount + 1;
writePage_(p);
var notified = notifyComment_(p, me, members, mentions, body);
return ok_({ comment: commentView_(c), notified: notified });
});
}
function resolveComment(uid, id, commentId, resolved) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var rows = readTable_('comments').filter(function (r) { return String(r.page_id) === p.id; });
var hit = firstBy_(rows, function (r) { return String(r.id) === String(commentId); });
if (!hit) return ng_('NOT_FOUND', 'このコメントは見つかりませんでした');
hit.resolved = bool_(resolved) ? 'true' : 'false';
updateRow_('comments', hit._row, hit);
p.commentCount = rows.filter(function (r) { return !bool_(r.resolved); }).length;
writePage_(p);
return ok_({ comments: rows.map(commentView_) });
});
}
function setCheck(uid, id, index, on) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
var idx = Math.floor(Number(index));
if (!(idx >= 0 && idx < 1000)) return ng_('INVALID', 'チェックの位置が正しくありません');
var list = me.checks[p.id] || [];
var at = list.indexOf(idx);
if (bool_(on) && at < 0) list.push(idx);
if (!bool_(on) && at >= 0) list.splice(at, 1);
if (list.length) me.checks[p.id] = list;
else delete me.checks[p.id];
writeMember_(me);
return ok_({ checks: checksOf_(p.id, me) });
});
}
function resetChecks(uid, id) {
return api_(uid, { write: true }, function (me) {
var p = findPage_(readPages_(), id, me, false);
if (!p) return notFound_();
delete me.checks[p.id];
writeMember_(me);
return ok_({ checks: {} });
});
}
function checksOf_(pageId, me) {
var out = {};
(me.checks[String(pageId)] || []).forEach(function (idx) { out[String(idx)] = true; });
return out;
}
/* ------------------------------------------------------------ メール */
/*
* 更新とコメントのお知らせの絞り方。参加は名乗るだけでできるので、作ったメンバーで連打されると
* 設置者の Gmail の1日の枠(個人は100通)が尽き、見直しのお知らせも届かなくなる。
*/
var MAIL = {
reserve: 20, // 見直しのお知らせのために残す通数
perMember: 30, // メンバー1人がきっかけで送れる宛先の数(1日)。管理者は数えない
members: 40, // 管理者以外がきっかけで送れる宛先の数(1日、全員あわせて)。メンバーは名乗るだけで作れるため
perAddress: 10, // 1つの受信箱に届く数(1日)
addresses: 300, // 1日に数えておける受信箱の数。数の記録は PropertiesService の1つの値(9KB)に入れるため
mentions: 5, // 1回のコメントで知らせる @ の人数
samePageSeconds: 3600 // 同じページの同じ種類のお知らせを、同じ人へ送る間隔
};
/**
* 宛先はアドレスを自分で入れた人だけ。送れない分は送らない(保存は止めない)。
* opts.notice のときは、上の MAIL の決まりで絞る。opts.by はきっかけになった人、opts.pageId はページ、
* opts.kind はお知らせの種類(更新とコメントは別に数える)。
*/
function sendMail_(to, subject, body, html, opts) {
var o = opts || {};
// 同じ受信箱に届くアドレスを何人かが入れていても、1回のお知らせでは1通にする
var seen = {};
var unique = to.filter(function (address) {
if (!address) return false;
var k = mailKey_(address);
if (seen[k]) return false;
seen[k] = true;
return true;
});
if (!unique.length) return 0;
var room = MailApp.getRemainingDailyQuota() - (o.notice ? MAIL.reserve : 0);
var counter = o.notice ? mailCounter_() : null;
var byMember = counter && o.by && o.by.role !== 'admin';
if (byMember) {
room = Math.min(room, MAIL.perMember - (counter.n[o.by.id] || 0), MAIL.members - counter.members);
}
var cache = CacheService.getScriptCache();
var keyOf = function (address) { return 'mail:' + o.kind + ':' + o.pageId + ':' + mailKey_(address); };
var known = counter ? Object.keys(counter.to).length : 0;
var list = unique.filter(function (address) {
if (!o.notice) return true;
var k = mailKey_(address);
if (counter.to[k] === undefined) {
if (known >= MAIL.addresses) return false;
known++;
}
if ((counter.to[k] || 0) >= MAIL.perAddress) return false;
return !o.pageId || !cache.get(keyOf(address));
}).slice(0, Math.max(0, room));
if (!list.length) return 0;
// 送る前に数を書く。数を残せないまま送ると、上限が効かなくなるので送らない
if (counter) {
list.forEach(function (address) {
var k = mailKey_(address);
counter.to[k] = (counter.to[k] || 0) + 1;
});
if (byMember) {
counter.n[o.by.id] = (counter.n[o.by.id] || 0) + list.length;
counter.members += list.length;
}
try {
setProp_('mailCount', JSON.stringify(counter));
} catch (e) {
console.error('お知らせの数を記録できなかったので、送りませんでした: ' + e);
return 0;
}
}
var site = readSite_();
var sent = 0;
list.forEach(function (address) {
try {
MailApp.sendEmail({ to: address, subject: subject, body: body, htmlBody: html || undefined, name: site.name });
sent++;
if (o.notice && o.pageId) cache.put(keyOf(address), '1', MAIL.samePageSeconds);
} catch (e) {
console.warn('メールを送れませんでした: ' + e);
}
});
return sent;
}
/** 今日送ったお知らせの数。メンバーごと(n)、管理者以外の合計(members)、受信箱ごと(to)。日付が変わったら数え直す */
function mailCounter_() {
var c = json_(prop_('mailCount'), null);
if (c && c.date === today_() && c.n && c.to) return c;
return { date: today_(), n: {}, members: 0, to: {} };
}
/**
* 同じ受信箱に届くアドレスを同じものとして扱う。小文字にして + から後ろを外し、Gmail では . も外す。
* a+1@gmail.com のような別名を並べて、受信箱ごとの上限をすり抜けられないようにするため。
*/
function mailBox_(address) {
var a = String(address || '').toLowerCase();
var at = a.lastIndexOf('@');
if (at < 0) return a;
var local = a.slice(0, at).split('+')[0];
var domain = a.slice(at + 1);
if (domain === 'googlemail.com') domain = 'gmail.com';
if (domain === 'gmail.com') local = local.split('.').join('');
return local + '@' + domain;
}
/** 数えるための短い鍵。アドレスをそのまま鍵にすると、記録が PropertiesService の1つの値(9KB)を超える */
function mailKey_(address) {
var digest = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, mailBox_(address));
return Utilities.base64Encode(digest).slice(0, 12);
}
/* --------------------------------------------------- メールの見た目
* メールソフトは外部の CSS も CSS 変数も読まないので、色と余白はその場に書く。
* 表組みで作るのは、古いメールソフトでも崩れないようにするため。 */
var MAIL_COLORS = {
material: ['#5B3FA8', '#00696A'],
shadcn: ['#18181B', '#2563EB'],
ledger: ['#26251C', '#1F5B37']
};
function mailAccent_() {
var look = look_();
var list = MAIL_COLORS[look.theme] || MAIL_COLORS.material;
return list[look.palette === 1 ? 1 : 0];
}
var MAIL_FONT = "-apple-system,BlinkMacSystemFont,'Hiragino Sans','Noto Sans JP',sans-serif";
/** opt: { kind, title, lead, meta: [[見出し, 中身]], quote, url, button, note } */
function mailHtml_(opt) {
var accent = mailAccent_();
var site = readSite_();
var metaRows = (opt.meta || []).filter(function (row) { return row[1]; }).map(function (row) {
return '<tr>' +
'<td style="padding:3px 14px 3px 0;font-size:12px;color:#78716C;white-space:nowrap">' + escHtml_(row[0]) + '</td>' +
'<td style="padding:3px 0;font-size:13px;color:#292524">' + escHtml_(row[1]) + '</td></tr>';
}).join('');
return '<!doctype html><html><body style="margin:0;padding:0;background:#F5F5F4">' +
// 受信箱の一覧に出る文(本文では隠す)
'<div style="display:none;max-height:0;overflow:hidden;opacity:0">' + escHtml_(opt.lead || '') + '</div>' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F5F5F4;padding:24px 12px">' +
'<tr><td align="center">' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#FFFFFF;' +
'border:1px solid #E7E5E4;border-radius:12px;overflow:hidden;font-family:' + MAIL_FONT + '">' +
'<tr><td style="padding:14px 22px;border-bottom:1px solid #E7E5E4">' +
'<table role="presentation" cellpadding="0" cellspacing="0"><tr>' +
'<td style="padding-right:9px"><div style="width:18px;height:18px;border-radius:5px;background:' + accent + '"></div></td>' +
'<td style="font-size:13.5px;font-weight:700;color:#1C1917;font-family:' + MAIL_FONT + '">' + escHtml_(site.name) + '</td>' +
'</tr></table></td></tr>' +
'<tr><td style="padding:22px 22px 6px">' +
'<div style="font-size:11.5px;font-weight:700;letter-spacing:.08em;color:' + accent + '">' + escHtml_(opt.kind) + '</div>' +
'<h1 style="margin:8px 0 0;font-size:19px;line-height:1.5;color:#1C1917;font-weight:700">' + escHtml_(opt.title) + '</h1>' +
(opt.lead ? '<p style="margin:10px 0 0;font-size:13.5px;line-height:1.85;color:#57534E">' + escHtml_(opt.lead) + '</p>' : '') +
'</td></tr>' +
(metaRows ? '<tr><td style="padding:14px 22px 0">' +
'<table role="presentation" cellpadding="0" cellspacing="0" width="100%">' + metaRows + '</table></td></tr>' : '') +
(opt.quote ? '<tr><td style="padding:16px 22px 0">' +
'<div style="padding:12px 14px;background:#FAFAF9;border-left:3px solid ' + accent + ';border-radius:0 8px 8px 0;' +
'font-size:13.5px;line-height:1.9;color:#292524;white-space:pre-wrap">' + escHtml_(opt.quote) + '</div></td></tr>' : '') +
'<tr><td style="padding:22px">' +
'<a href="' + escHtml_(opt.url) + '" style="display:inline-block;padding:11px 20px;background:' + accent + ';' +
'color:#FFFFFF;font-size:13.5px;font-weight:700;text-decoration:none;border-radius:8px">' + escHtml_(opt.button) + '</a>' +
'</td></tr>' +
'<tr><td style="padding:14px 22px 18px;border-top:1px solid #E7E5E4;font-size:11.5px;line-height:1.8;color:#A8A29E">' +
escHtml_(opt.note) + '</td></tr>' +
'</table></td></tr></table></body></html>';
}
function stampJa_(iso) {
var d = new Date(iso);
if (isNaN(d.getTime())) return '';
return Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy年M月d日 HH:mm');
}
function pageUrl_(p) {
return ScriptApp.getService().getUrl() + '?page=' + p.id;
}
function watchersOf_(p, me, members) {
return members.filter(function (m) {
return m.id !== me.id && m.email && m.watch.indexOf(p.id) >= 0 && canSee_(p, m);
});
}
function notifyUpdate_(p, me, members) {
var watchers = watchersOf_(p, me, members);
if (!watchers.length) return 0;
var url = pageUrl_(p);
var stop = 'このお知らせを止めるには、ページの「通知」を押すか、設定でメールアドレスを消してください。';
var body = [
'「' + p.title + '」が更新されました。',
'',
'更新した人: ' + me.name,
p.changeNote ? '変更のメモ: ' + p.changeNote : '',
'',
url,
'',
stop
].filter(function (line) { return line !== ''; }).join('\n');
var html = mailHtml_({
kind: '更新のお知らせ',
title: p.title,
lead: me.name + ' さんがこのページを保存しました。',
meta: [['更新した人', me.name], ['変更のメモ', p.changeNote], ['担当', whoName_(members, p.owner)], ['見直し期限', p.reviewDue]],
url: url,
button: 'ページを開く',
note: stop
});
return sendMail_(watchers.map(function (m) { return m.email; }), '「' + p.title + '」が更新されました', body, html,
{ notice: true, by: me, pageId: p.id, kind: 'update' });
}
function whoName_(members, id) {
var m = memberById_(members, id);
return m ? m.name : '';
}
function notifyComment_(p, me, members, mentions, text) {
var to = {};
watchersOf_(p, me, members).forEach(function (m) { to[m.email] = true; });
// 知らせるのは、本文に出てきた順に、アドレスがあってページを見られる人から5人まで
mentions
.map(function (id) { return memberById_(members, id); })
.filter(function (m) { return m && m.email && canSee_(p, m); })
.sort(function (a, b) { return text.indexOf('@' + a.name) - text.indexOf('@' + b.name); })
.slice(0, MAIL.mentions)
.forEach(function (m) { to[m.email] = true; });
var list = Object.keys(to);
if (!list.length) return 0;
var url = pageUrl_(p);
var body = [
'「' + p.title + '」に ' + me.name + ' さんがコメントしました。',
'',
text,
'',
url
].join('\n');
var html = mailHtml_({
kind: mentions.length ? '名前を呼ばれました' : 'コメントのお知らせ',
title: p.title,
lead: me.name + ' さんがコメントしました。',
meta: [['書いた人', me.name]],
quote: text,
url: url,
button: 'コメントを見る',
note: 'このお知らせは、ページの「通知」をオンにしている人と、@ で呼ばれた人に届きます。'
});
return sendMail_(list, '「' + p.title + '」にコメントがつきました', body, html, { notice: true, by: me, pageId: p.id, kind: 'comment' });
}
/* ------------------------------------------------------ 見直し期限の通知 */
/**
* 1日1回動かす。期限の7日前と当日に、担当者へメールを送る。
* 名前が _ で終わらないので画面からも呼べてしまう。登録済みのトリガーから起きたときだけ送る。
*/
function trigger_reviewMail(e) {
if (!e || !e.triggerUid) return;
var uid = String(e.triggerUid);
var known = reviewTriggers_().some(function (t) { return String(t.getUniqueId()) === uid; });
if (!known) {
console.warn('登録されていないトリガーの ID で呼ばれたので、お知らせは送りませんでした');
return;
}
setup_();
var today = today_();
// トリガーの ID が知られても、重なって2つ動いても、送るのは1日1回まで(見るのと書くのをロックの中で1つずつ)
var first = withLock_(function () {
if (prop_('reviewMailOn') === today) return false;
setProp_('reviewMailOn', today);
return true;
}, 30000);
if (first !== true) return;
var members = readMembers_();
var site = readSite_();
var soon = Utilities.formatDate(new Date(Date.now() + 7 * 86400000), Session.getScriptTimeZone(), 'yyyy-MM-dd');
// ページも担当者も名乗るだけで作れるので、受信箱ごとに1日1通にまとめ、全体でも MAIL.reserve 通までにする
var groups = {};
readPages_().forEach(function (p) {
if (p.deletedAt || p.deprecated || p.status !== 'published') return;
if (p.reviewDue !== today && p.reviewDue !== soon) return;
var owner = memberById_(members, p.owner);
if (!owner || !owner.email || !canSee_(p, owner)) return;
var key = mailKey_(owner.email);
var g = groups[key] || (groups[key] = { owner: owner, items: [] });
g.items.push({ p: p, when: p.reviewDue === today ? '今日' : '1週間後' });
});
var list = Object.keys(groups).map(function (k) { return groups[k]; });
// 枠が足りないときは、管理者が担当のページを先に送る
list.sort(function (a, b) { return (b.owner.role === 'admin' ? 1 : 0) - (a.owner.role === 'admin' ? 1 : 0); });
list.slice(0, MAIL.reserve).forEach(function (g) { sendReviewMail_(g, site); });
}
/** 1人の担当者へ、期限が近いページをまとめて1通で知らせる */
function sendReviewMail_(g, site) {
var LIST_MAX = 20;
var first = g.items[0];
var many = g.items.length > 1;
var shown = g.items.slice(0, LIST_MAX);
var rest = g.items.length - shown.length;
var lead = '内容が今でも正しいか確かめて、編集画面で次の期限を入れてください。';
var subject = many
? '見直し期限が近いページが' + g.items.length + '件あります'
: '「' + first.p.title + '」の見直し期限が' + first.when + 'です';
var url = many ? ScriptApp.getService().getUrl() : pageUrl_(first.p);
var lines = shown.map(function (it) { return '・' + it.p.title + '(' + it.p.reviewDue + '、' + it.when + ')'; });
if (rest > 0) lines.push('ほか ' + rest + '件');
var body = [site.name + 'で、担当のページの見直し期限が近づいています。', ''].concat(lines, ['', lead, '', url]).join('\n');
sendMail_([g.owner.email], subject, body, mailHtml_({
kind: '見直しのお知らせ',
title: many ? '見直し期限が近いページが' + g.items.length + '件あります' : first.p.title,
lead: lead,
meta: many
? shown.map(function (it) { return [it.p.reviewDue + '(' + it.when + ')', it.p.title]; })
.concat(rest > 0 ? [['ほか', rest + '件']] : [])
: [['見直し期限', first.p.reviewDue + '(' + first.when + ')'], ['担当', g.owner.name], ['最終更新', stampJa_(first.p.updatedAt)]],
url: url,
button: many ? 'wiki を開く' : 'ページを開いて確かめる',
note: '担当を変えるときは、編集画面の「担当者」から選び直してください。'
}));
}
/** 登録済みのお知らせのトリガー。登録が重なって2つ以上できていることもある */
function reviewTriggers_() {
return ScriptApp.getProjectTriggers().filter(function (t) { return t.getHandlerFunction() === 'trigger_reviewMail'; });
}
function reviewTrigger_() {
return reviewTriggers_()[0] || null;
}
/**
* スクリプトを動かしているアカウントが設置者かどうか。
* インストール型トリガーは作った人のアカウントで動く。「実行するユーザー」が「自分」でなかったり、
* 別のアカウントでデプロイし直されたりしていると、作ったトリガーは設置者ではない人のものになる。
*/
function runsAsOwner_() {
var owner = String(prop_('ownerEmail') || '').toLowerCase();
var effective = String(Session.getEffectiveUser().getEmail() || '').toLowerCase();
return !!owner && effective === owner;
}
var WRONG_DEPLOY = 'デプロイの「実行するユーザー」を「自分」にして、設置した人のアカウントでデプロイしてから切り替えてください';
/**
* 毎朝のお知らせの登録と解除。画面から切り替える。
* 設置者の Gmail から設置者の枠で送るので、切り替えられるのは設置者だけ。ほかの管理者は状態だけ見られる。
*/
function setReviewMail(uid, on) {
return api_(uid, { write: true, admin: true }, function (me) {
if (!me.owner || !isInstaller_()) return ng_('FORBIDDEN', '毎朝のお知らせは、設置した人だけが切り替えられます');
if (!runsAsOwner_()) return ng_('INVALID', WRONG_DEPLOY);
var list = reviewTriggers_();
if (on && !list.length) ScriptApp.newTrigger('trigger_reviewMail').timeBased().atHour(8).everyDays(1).create();
if (!on) list.forEach(function (t) { ScriptApp.deleteTrigger(t); });
return ok_({ reviewMail: !!reviewTrigger_() });
});
}
/**
* エディタから1回実行しても同じことができる(画面を開けないときのため)。
* 画面からも呼べてしまうので、設置者がエディタで実行したときだけ動かす。
*/
function installTrigger() {
setup_();
var result = !isInstaller_() ? '設置した人がエディタから実行してください'
: !runsAsOwner_() ? WRONG_DEPLOY
: withLock_(function () {
if (reviewTrigger_()) return '登録済みです';
ScriptApp.newTrigger('trigger_reviewMail').timeBased().atHour(8).everyDays(1).create();
return '毎朝のお知らせを登録しました';
});
var message = typeof result === 'string' ? result : result.message;
// エディタの実行では戻り値が画面に出ないので、実行ログに残す
console.info(message);
return message;
}/* ============================================================ store.gs ============================================================ *//**
* 保存先。
* - ページの情報・メンバー・コメント・本文はスプレッドシート
* - 添付ファイルと画像は Drive のフォルダ
* どちらも設置した人の Google アカウントの中にできる。
*/
/*
* 表の作り。1回の読み込みは 0.2〜0.7 秒かかるので、読む表の数を減らす形にしている。
* - 件数(コメント・リアクション・履歴・通知・閲覧)は pages の列に持つ
* - 個人の状態(お気に入り・通知・チェック)は members の1行に持つ
* - 本文と探すための文字は別の表に置き、行番号で1行だけ読む
*/
var SHEETS = {
pages: ['id', 'parent_id', 'order', 'title', 'status', 'visibility', 'members', 'tags', 'deprecated', 'deprecated_note',
'owner', 'review_due', 'created_at', 'created_by', 'updated_at', 'updated_by', 'change_note',
'body_row', 'text_row', 'text_len', 'links', 'comment_count', 'reaction_count', 'revision_count', 'watcher_count',
'views_30d', 'viewers_30d', 'reactions', 'deleted_at', 'deleted_by'],
members: ['id', 'name', 'role', 'owner', 'email', 'uids', 'joined_at', 'last_seen_at', 'fav', 'watch', 'checks'],
comments: ['id', 'page_id', 'at', 'by', 'text', 'resolved', 'mentions'],
revisions: ['id', 'page_id', 'at', 'by', 'title', 'note', 'body_row'],
files: ['id', 'page_id', 'name', 'mime', 'size', 'drive_id', 'at', 'by'],
views: ['page_id', 'at', 'by'],
settings: ['key', 'value'],
// 本文(ページと履歴で同じ表を使う。履歴に回すときは行をそのまま渡す)
bodies: ['key', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7', 'b8', 'b9', 'b10'],
// 探すための文字(本文から作る控え)
texts: ['key', 't1', 't2', 't3']
};
/* ------------------------------------------------------------ 初期化 */
/** 最初に開いたときだけ、スプレッドシートと Drive のフォルダを作る */
function setup_() {
// 表とフォルダの ID だけで見ると、ほかの実行が準備を終える前に通ってしまう。最後に立てる印で見る
if (prop_('setupDone')) return;
var lock = LockService.getScriptLock();
if (!lock.tryLock(20000)) throw new Error('準備中です。少し待ってから開き直してください');
PROPS_ = null;
try {
if (prop_('setupDone')) return;
if (!prop_('bookId')) {
var book = SpreadsheetApp.create('wiki のデータ');
book.setSpreadsheetTimeZone(Session.getScriptTimeZone());
setProp_('bookId', book.getId());
}
if (!prop_('folderId')) {
setProp_('folderId', DriveApp.createFolder('wiki のファイル').getId());
}
if (!prop_('ownerEmail')) {
setProp_('ownerEmail', String(Session.getEffectiveUser().getEmail() || ''));
}
setProp_('version', VERSION);
Object.keys(SHEETS).forEach(function (name) { sheet_(name); });
seedFirstRun_();
SpreadsheetApp.flush();
setProp_('setupDone', 'true');
} finally {
lock.releaseLock();
}
}
/** 作った直後に、設置した人を管理者として入れ、テンプレートのページを1枚だけ置く */
function seedFirstRun_() {
if (readMembers_().length) return;
var email = String(prop_('ownerEmail') || '');
var at = now_();
// メールアドレスの一部を名前にすると、参加前の人にも見えてしまう
writeMember_({
id: id_('m'), name: OWNER_NAME, role: 'admin', owner: true,
email: email, uids: [], joinedAt: at, lastSeenAt: at, fav: [], watch: [], checks: {}, _row: 0
});
setProp_('ownerNameChecked', 'true');
writeSetting_('site.name', 'チームの wiki');
}
/* 1回の実行の中では開き直さない。スプレッドシートを開くのも、表を読むのも往復がかかるため。
* GAS は呼び出しのたびにグローバルが消えるので、この控えが古くなることはない。 */
var BOOK_ = null;
var SHEET_ = {};
var VALUES_ = {};
function book_() {
if (!BOOK_) {
BOOK_ = SpreadsheetApp.openById(prop_('bookId'));
mark_('open');
}
return BOOK_;
}
function folder_() { return DriveApp.getFolderById(prop_('folderId')); }
function sheet_(name) {
if (SHEET_[name]) return SHEET_[name];
var book = book_();
var sh = book.getSheetByName(name);
if (sh) {
SHEET_[name] = sh;
ensureHeaders_(sh, name);
return sh;
}
sh = book.insertSheet(name);
var headers = SHEETS[name];
// 数字や日付に読み替えられないよう、はじめに全部を「書式なしテキスト」にしておく
sh.getRange(1, 1, 5000, headers.length).setNumberFormat('@');
sh.getRange(1, 1, 1, headers.length).setValues([headers]);
sh.setFrozenRows(1);
SHEET_[name] = sh;
return sh;
}
/** あとから列が増えたときに、見出しの行をそろえる */
function ensureHeaders_(sh, name) {
var headers = SHEETS[name];
if (sh.getLastColumn() >= headers.length) return;
var range = sh.getRange(1, 1, 1, headers.length);
range.setNumberFormat('@');
range.setValues([headers]);
}
/** 書き込んだ表は、次に読むときに取り直す */
function invalidate_(name) { delete VALUES_[name]; }
/* ------------------------------------------------------------ 表の読み書き */
/** 先頭が `=` の文字列は数式にされてしまうので、`'` を付けて文字のまま入れる */
function cell_(v) {
if (v === null || v === undefined) return '';
var s = String(v);
return s.charAt(0) === '=' ? "'" + s : s;
}
function values_(name) {
if (VALUES_[name]) return VALUES_[name];
var sh = sheet_(name);
var last = sh.getLastRow();
VALUES_[name] = last < 2 ? [] : sh.getRange(2, 1, last - 1, SHEETS[name].length).getValues();
mark_('read:' + name);
return VALUES_[name];
}
/** 呼ぶたびに作り直す。呼び出し側が中身を書き換えても、控えには残さない */
function readTable_(name) {
var headers = SHEETS[name];
var values = values_(name);
var out = [];
for (var i = 0; i < values.length; i++) {
var row = values[i];
if (!String(row[0] == null ? '' : row[0]).length) continue;
var obj = { _row: i + 2 };
for (var c = 0; c < headers.length; c++) obj[headers[c]] = row[c] == null ? '' : row[c];
out.push(obj);
}
return out;
}
function appendRow_(name, obj) {
var headers = SHEETS[name];
var sh = sheet_(name);
var row = Math.max(sh.getLastRow(), 1) + 1;
var range = sh.getRange(row, 1, 1, headers.length);
range.setNumberFormat('@');
range.setValues([headers.map(function (h) { return cell_(obj[h]); })]);
invalidate_(name);
mark_('append:' + name);
return row;
}
function updateRow_(name, rowNumber, obj) {
var headers = SHEETS[name];
var range = sheet_(name).getRange(rowNumber, 1, 1, headers.length);
range.setNumberFormat('@');
range.setValues([headers.map(function (h) { return cell_(obj[h]); })]);
invalidate_(name);
mark_('update:' + name);
}
/* ---------------------------------------------- 長い文字(本文・探す文字)
* セルは5万文字までなので、分けて入れる。行番号で1行だけ読むので、
* ほかのページの本文は読み込まない。 */
var CHUNK = 45000;
var NEXT_ROW_ = {};
function chunkCols_(name) { return SHEETS[name].length - 1; }
/** row が 0 なら新しい行に足す。戻り値は入れた行番号 */
function writeChunks_(name, row, key, text) {
var cols = chunkCols_(name);
var value = String(text == null ? '' : text);
var parts = [];
for (var i = 0; i < cols; i++) parts.push(cell_(value.substr(i * CHUNK, CHUNK)));
var sh = sheet_(name);
var at = row;
if (!at) {
NEXT_ROW_[name] = Math.max(NEXT_ROW_[name] || 0, sh.getLastRow()) + 1;
at = NEXT_ROW_[name];
}
var range = sh.getRange(at, 1, 1, cols + 1);
range.setNumberFormat('@');
range.setValues([[cell_(key)].concat(parts)]);
mark_('write:' + name);
return at;
}
function readChunks_(name, row) {
if (!row) return '';
var cols = chunkCols_(name);
var values = sheet_(name).getRange(row, 2, 1, cols).getValues()[0];
mark_('read1:' + name);
return values.map(function (v) { return v == null ? '' : String(v); }).join('');
}
/** 行は消さずに中身だけ消す。消すと下の行がずれて、ほかの行番号が合わなくなる */
function clearChunks_(name, row) {
if (!row) return;
sheet_(name).getRange(row, 1, 1, chunkCols_(name) + 1).clearContent();
}
/** 行を消す。まとめて消すときは下から消さないと行番号がずれる */
function deleteRows_(name, rowNumbers) {
var sh = sheet_(name);
rowNumbers.slice().sort(function (a, b) { return b - a; }).forEach(function (r) { sh.deleteRow(r); });
invalidate_(name);
}
/** 表の中身を rows だけにする。消す行が多いときに deleteRow を繰り返さないため */
function rewriteTable_(name, rows) {
var headers = SHEETS[name];
var sh = sheet_(name);
var last = sh.getLastRow();
if (last >= 2) sh.getRange(2, 1, last - 1, headers.length).clearContent();
if (rows.length) {
var range = sh.getRange(2, 1, rows.length, headers.length);
range.setNumberFormat('@');
range.setValues(rows.map(function (r) { return headers.map(function (h) { return cell_(r[h]); }); }));
}
invalidate_(name);
}
function deleteWhere_(name, fn) {
var rows = readTable_(name).filter(fn).map(function (r) { return r._row; });
if (rows.length) deleteRows_(name, rows);
return rows.length;
}
/* ------------------------------------------------------------ 設定 */
function readSettings_() {
var out = {};
readTable_('settings').forEach(function (r) { out[String(r.key)] = String(r.value); });
return out;
}
function writeSetting_(key, value) {
var hit = firstBy_(readTable_('settings'), function (r) { return String(r.key) === key; });
if (hit) updateRow_('settings', hit._row, { key: key, value: value });
else appendRow_('settings', { key: key, value: value });
}
/** アプリの設定は名前だけ。見た目は index.html に書かれていて、コードでは扱わない */
function readSite_() {
return { name: readSettings_()['site.name'] || 'wiki' };
}
/* ------------------------------------------------------------ メンバー */
/* メンバーはめったに変わらないのに、どの呼び出しでも読む。
* メンバーを書き換えたときだけ増える番号を付けて控える。 */
function memberVersion_() { return String(prop_('memberVersion') || '0'); }
function bumpMemberVersion_() {
setProp_('memberVersion', String(Number(memberVersion_()) + 1));
if (LOCK_DEPTH_ > 0) MEMBERS_DIRTY_ = true;
}
/**
* 番号は表を読む前に1回だけ読む。読んだあとに取り直すと、確定前の古い中身を新しい番号で控えてしまう。
* この控えの行番号(_row)で書くのは、ロックの中で番号が変わっていないと確かめたときだけ。
*/
function readMembers_() {
var version = memberVersion_();
var hit = cacheGet_('members:' + version);
if (hit) return hit;
var list = readMembersFromSheet_();
cachePut_('members:' + version, list);
return list;
}
function readMembersFromSheet_() {
return readTable_('members').map(function (r) {
return {
id: String(r.id), name: String(r.name), role: r.role === 'admin' ? 'admin' : 'member',
owner: bool_(r.owner), email: String(r.email || ''), uids: csv_(r.uids),
joinedAt: String(r.joined_at || ''), lastSeenAt: String(r.last_seen_at || ''),
fav: csv_(r.fav), watch: csv_(r.watch), checks: json_(r.checks, {}), _row: r._row
};
});
}
function writeMember_(m) {
bumpMemberVersion_();
var row = {
id: m.id, name: m.name, role: m.role, owner: m.owner ? 'true' : 'false', email: m.email,
uids: m.uids.join(','), joined_at: m.joinedAt, last_seen_at: m.lastSeenAt,
fav: m.fav.join(','), watch: m.watch.join(','), checks: JSON.stringify(m.checks || {})
};
if (m._row) updateRow_('members', m._row, row);
else m._row = appendRow_('members', row);
return m;
}
/** 最後に使った時刻。毎回書くと重いので、1時間ずれていたときだけ書く */
function touch_(me) {
var last = me.lastSeenAt ? new Date(me.lastSeenAt).getTime() : 0;
if (Date.now() - last < 3600000) return me;
// 混んでいて書けなかったら、しばらく試さない。毎回ロックを待つと、その人の読み込みが遅くなる
var cache = CacheService.getScriptCache();
if (cache.get('touchBusy:' + me.id)) return me;
var out = patchMember_(me, function (cur) { cur.lastSeenAt = now_(); });
if (out === me) cache.put('touchBusy:' + me.id, '1', 300);
return out;
}
/** ロックの中で読み直す。ほかの実行が書いて確定させたあとの値を使うため */
function freshMember_(id) {
invalidate_('members');
return memberById_(readMembersFromSheet_(), id);
}
/**
* ロックの外から呼ばれる、メンバー行の小さな書き込み。表から読み直したその人の行だけを直す。
* ほかの書き込みと重なったら今回は書かずに m をそのまま返す(最後に使った時刻や端末のひも付けは、次の機会でよい)。
* その人が消されていたら null。
*/
function patchMember_(m, change) {
var gone = false;
var out = withLock_(function () {
var cur = freshMember_(m.id);
if (!cur) {
gone = true;
return null;
}
change(cur);
writeMember_(cur);
return cur;
}, 500, function () { MEMBER_VERSION_SEEN_ = memberVersion_(); });
if (gone) return null;
return out && out.id ? out : m;
}
/** 端末は LIMIT.devices 台まで。超えたら古いものから外す(断ると、本人が新しい端末で入れなくなる) */
function addDevice_(m, uid) {
if (m.uids.indexOf(uid) < 0) m.uids.push(uid);
if (m.uids.length > LIMIT.devices) m.uids = m.uids.slice(-LIMIT.devices);
}
/**
* 前の版では、設置者の最初の名前がメールアドレスの @ より前だった。1回だけ直す。
* ロックの中(patchMember_ の中)で呼ぶ。ほかの人が「管理者」を名乗っていたら、その人の名前に印を付けてから直す。
*/
function fixOwnerName_(owner) {
setProp_('ownerNameChecked', 'true');
var local = String(prop_('ownerEmail') || '').split('@')[0];
if (!local || owner.name !== local) return;
readMembersFromSheet_().forEach(function (m) {
if (m.id === owner.id || cleanName_(m.name) !== OWNER_NAME) return;
m.name = OWNER_NAME + '(' + m.id.slice(-4) + ')';
writeMember_(m);
});
owner.name = OWNER_NAME;
bumpVersion_(); // 起動の控えにメンバーの名前が入っているので捨てる
}
/**
* 名前は全角・半角と空白をそろえてから比べて、そろえた形で入れる。
* 全角スペースの打ち間違いで同じ人が2人になったり、見た目が同じ別の文字(互換漢字など)で「管理者」を名乗れたりしないように。
*/
function cleanName_(s) {
return String(s || '').normalize('NFKC')
// 見えない文字(ゼロ幅・向きを変える文字・異体字セレクタ・ハングルの埋め字など、表示されない文字)は取り除く
.replace(/[\p{Default_Ignorable_Code_Point}\p{Cf}]/gu, '')
.replace(/[\p{Cc}\s]+/gu, ' ')
.trim();
}
function memberById_(members, id) {
return firstBy_(members, function (m) { return m.id === String(id); });
}
function memberView_(m) {
return { id: m.id, name: m.name, role: m.role, owner: !!m.owner };
}
/**
* 本人に返す形。同じ名前で入れば同じ人として扱うので、ここにアドレスをそのまま入れると他人に読まれる。
* 伏せた形だけを返す。全部見えるのは管理者のメンバー一覧だけ。
*/
function meView_(m) {
return { id: m.id, name: m.name, role: m.role, owner: !!m.owner, emailHint: maskEmail_(m.email) };
}
/** @ より前は2文字だけ見せる。2文字以下なら1文字、1文字なら見せない */
function maskEmail_(email) {
var at = String(email || '').indexOf('@');
if (at < 0) return '';
var keep = at > 2 ? 2 : at - 1;
return email.slice(0, keep) + '***' + email.slice(at);
}
/* --------------------------------------------------- 添付ファイル(Drive) */
function trashFile_(fileId) {
if (!fileId) return;
try {
DriveApp.getFileById(fileId).setTrashed(true);
} catch {
console.warn('消せないファイル: ' + fileId);
}
}
<!doctype html><!-- 見た目(テーマ・配色・明暗)はこの行で決まる。サイトで選んだものが入った状態で配られる --><html lang="ja" data-app-theme="material" data-app-palette="0" data-app-mode="light"> <head> <base target="_top"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" integrity="sha384-ecIckRi4QlKYya/FQUbBUjS4qp65jF/J87Guw5uzTbO1C1Jfa/6kYmd6dXUF6D7i" crossorigin="anonymous"> <?!= include('style'); ?> </head> <body> <header class="hdr"> <button class="icon-btn menu-btn" data-act="drawer" aria-label="メニュー">≡</button> <div class="logo" data-act="home"> <span class="logo-mark"></span> <span class="logo-text"></span> </div> <div class="hdr-search" data-act="palette"> <span class="search-icon"></span> <input id="q" type="text" placeholder="ページ・本文・添付を探す" readonly> <span class="kbd" id="kbdHint">⌘K</span> </div> <div class="hdr-right"> <button class="icon-btn search-btn" data-act="palette" aria-label="探す"><span class="search-icon"></span></button> <div class="hdr-fav" id="favWrap"><button class="fav-btn" data-act="favPop" id="favBtn" title="お気に入り(自分だけの一覧)"><span class="st">★</span><span class="lb-fav">お気に入り</span><span class="n" id="favN"></span></button></div> <button class="btn btn-primary new-btn" data-act="new" aria-label="新しいページ">+<span class="lb"> ページ</span></button> <button class="icon-btn" style="display:flex" data-act="settings" title="設定" aria-label="設定">⚙</button> <span class="avatar" id="me">…</span> </div> </header> <div class="shell"> <div class="scrim" data-act="closeDrawer"></div> <aside class="side" id="side"> <nav class="tree" id="tree"></nav> </aside> <main class="main" id="main"> <div class="wrap"> <div class="sk" style="height:24px;width:280px;margin-bottom:14px"></div> <div class="sk" style="height:14px;width:180px;margin-bottom:28px"></div> <div class="grid2"> <div class="sk" style="height:300px;border-radius:var(--app-r)"></div> <div class="sk" style="height:300px;border-radius:var(--app-r)"></div> </div> </div> </main> </div> <div id="palette"></div> <div id="modals"></div> <div id="settings"></div> <div id="netbar"></div> <div id="block"></div> <div id="toast"></div> <script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js" integrity="sha384-utBUCeG4SYaCm4m7GQZYr8Hy8Fpy3V4KGjBZaf4WTKOcwhCYpt/0PfeEe3HNlwx8" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.15/dist/purify.min.js" integrity="sha384-uUMu9JDY09vBzRf9SPcK2VgUj+W/70J6Soc+Dded5P474ElQ63iv9j5N3DE7Kp3N" crossorigin="anonymous"></script><script>/* ============================================================ Wiki クライアント(配布版) 画面の組み立ては元の wiki のまま。通信だけを API.md の約束に合わせている。 ============================================================ */(function () {'use strict';var EMOJI = ['👍', '🙏', '🎉', '👀', '✅'];var S = { uid: '', user: '', me: null, site: null, pages: [], popular: {}, popularUsers: {}, templates: [], members: [], route: 'home', pageId: null, tagName: '', q: '', term: '', results: null, searching: false, listFilter: '', listTag: '', idxFilter: '', idxSort: 'order', idxTag: '', imgCache: {}, imgLoading: {}, creating: null, enterBody: false, expanded: {}, activeToc: '', loading: true, dirty: false, editSeq: 0, draft: null, bodyCache: {}, logged: {}, quill: null, picker: null, parentPick: null, modal: null, palette: null, history: null, diffOn: true, reactPop: false, favPop: false, trash: null, trashCount: 0, searchSuggest: [], mentionOpen: false, tableEdit: null, autoTimer: null, autosaving: false, autoStopped: false, drag: null, viewTimer: null, palTimer: null, joining: null, initialPage: '', memberRows: null, reviewMail: null};/* ------------------------------------------------------------ 通信 */var NET = { pending: 0, slow: null };function friendlyFailure(e) { var m = String((e && e.message) || e || ''); if (/maximum execution time/i.test(m)) return '時間がかかりすぎて止まりました。もう一度お試しください'; if (/NetworkError|HTTP 0|Failed to fetch/i.test(m)) return 'サーバーにつながりませんでした。通信を確かめてから、もう一度お試しください'; return 'うまく処理できませんでした。もう一度お試しください';}/** 先頭にこの端末の ID を付けて呼ぶ。{ ok: false } は code 付きの Error にして reject する */function run(name) { var args = Array.prototype.slice.call(arguments, 1); netStart(); return new Promise(function (resolve, reject) { google.script.run .withSuccessHandler(function (res) { netEnd(); if (res && res.ok) { resolve(res); return; } var err = new Error((res && res.message) || '処理できませんでした。もう一度お試しください'); err.code = (res && res.code) || 'UNKNOWN'; err.detail = res || {}; if (err.code === 'NEED_JOIN') lostMembership(); reject(err); }) .withFailureHandler(function (e) { netEnd(); console.error(name, e); var err = new Error(friendlyFailure(e)); err.code = 'NETWORK'; reject(err); })[name].apply(null, [S.uid].concat(args)); });}/* 通信中は画面のいちばん上に細い線を出す。「押したのに何も起きない」を無くすため。 */function netStart() { NET.pending++; var bar = document.getElementById('netbar'); if (bar) { bar.classList.remove('done'); bar.classList.add('on'); } if (!NET.slow) NET.slow = setTimeout(function () { var b = document.getElementById('netbar'); if (NET.pending > 0 && b) b.classList.add('slow'); }, 2500);}function netEnd() { NET.pending = Math.max(0, NET.pending - 1); if (NET.pending > 0) return; clearTimeout(NET.slow); NET.slow = null; var bar = document.getElementById('netbar'); if (!bar) return; bar.classList.add('done'); setTimeout(function () { bar.classList.remove('on'); bar.classList.remove('slow'); bar.classList.remove('done'); }, 240);}/* 時間のかかる操作は、終わるまで消えないお知らせを出して結果に差し替える */function progressToast(msg) { var el = document.createElement('div'); el.className = 'toast'; el.style.pointerEvents = 'auto'; el.innerHTML = '<span class="spin"></span>' + esc(msg); var box = document.getElementById('toast'); if (box) box.appendChild(el); var slow = setTimeout(function () { el.innerHTML = '<span class="spin"></span>' + esc(msg) + '<span class="sub">通信が混み合っています。そのままお待ちください</span>'; }, 4000); return { done: function (text, kind) { clearTimeout(slow); el.className = 'toast' + (kind ? ' ' + kind : ''); el.innerHTML = (kind ? '<span class="dot"></span>' : '<span class="tick">✓</span>') + esc(text); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, kind ? 4200 : 2600); }, drop: function () { clearTimeout(slow); if (el.parentNode) el.parentNode.removeChild(el); } };}function fail(err) { if (err && err.code === 'AUTH') return; toast((err && err.message) ? err.message : '通信に失敗しました。時間をおいて試してください', 'error');}/* ------------------------------------------------------------ 端末の記憶 */var store = { get: function (key) { try { return window.localStorage.getItem(key); } catch { return null; } }, set: function (key, value) { try { window.localStorage.setItem(key, value); return true; } catch { return false; } }, remove: function (key) { try { window.localStorage.removeItem(key); } catch { /* 保存できない環境では覚えない */ } }};/* この端末の ID。誰が書いたかを見分けるために使う(本人確認ではない) */function deviceUid() { var id = store.get('wiki.uid'); if (id && /^[0-9a-zA-Z_-]{4,64}$/.test(id)) return id; id = (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID().replace(/-/g, '') : String(Date.now()) + Math.random().toString(36).slice(2, 12); store.set('wiki.uid', id); return id;}/* ------------------------------------------------------------ 小道具 */function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; });}/* 本文はサーバーでも許可リストに通しているが、画面に入れる直前にもう一度通す */var PURIFY = { ALLOWED_TAGS: ['p', 'br', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'b', 'em', 's', 'a', 'span', 'img', 'hr', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td'], ALLOWED_ATTR: ['href', 'class', 'data-list', 'data-callout', 'data-drive-id', 'colspan', 'rowspan'], ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#\/page\/|#\/file\/)/i};var CLASS_OK = ['wtable', 'mk-hl', 'mk-imp', 'mk-sub', 'ql-code-block-container', 'ql-code-block'];var purifyReady = false;function sanitize(html) { if (!purifyReady) { window.DOMPurify.addHook('uponSanitizeAttribute', function (node, data) { if (data.attrName === 'class') data.attrValue = data.attrValue.split(/\s+/).filter(function (c) { return CLASS_OK.indexOf(c) >= 0; }).join(' '); if (data.attrName === 'data-list' && !/^(bullet|ordered|checked|unchecked)$/.test(data.attrValue)) data.keepAttr = false; if (data.attrName === 'data-callout' && !/^(warn|point|note|pre)$/.test(data.attrValue)) data.keepAttr = false; if (data.attrName === 'data-drive-id' && !/^[\w-]{1,80}$/.test(data.attrValue)) data.keepAttr = false; }); window.DOMPurify.addHook('afterSanitizeAttributes', function (node) { var href = node.tagName === 'A' ? node.getAttribute('href') || '' : ''; if (href && href.indexOf('#/') !== 0) { node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noopener noreferrer'); } }); purifyReady = true; } return window.DOMPurify.sanitize(String(html || ''), PURIFY);}/** サーバーのページ情報を、画面が使う名前にそろえる */function normalize(m) { var p = Object.assign({}, m); p.parent_id = m.parentId || ''; p.sort_order = m.order; p.updated_at = m.updatedAt; p.updated_by = m.updatedBy; p.created_at = m.createdAt; p.created_by = m.createdBy; p.review_due = m.reviewDue || ''; p.change_note = m.changeNote || ''; p.deprecated_note = m.deprecatedNote || ''; p.revision_count = m.revisionCount || 0; p.read_minutes = m.readMinutes || 1; p.week_viewers = m.viewers30d || 0; p.watcher_count = m.watcherCount || 0; S.popular[m.id] = m.views30d || 0; S.popularUsers[m.id] = m.viewers30d || 0; return p;}function byId(id) { for (var i = 0; i < S.pages.length; i++) if (S.pages[i].id === id) return S.pages[i]; return null; }function upsert(m) { var p = normalize(m); for (var i = 0; i < S.pages.length; i++) if (S.pages[i].id === p.id) { S.pages[i] = p; return p; } S.pages.push(p); return p;}function kids(pid) { return S.pages.filter(function (p) { return p.parent_id === pid; }) .sort(function (a, b) { return a.sort_order - b.sort_order; });}function chain(id) { var out = [], cur = byId(id), guard = 0; while (cur && guard++ < 8) { out.unshift(cur); cur = cur.parent_id ? byId(cur.parent_id) : null; } return out;}function pathLabel(id) { var names = chain(id).slice(0, -1).map(function (p) { return p.title; }); return names.length ? names.join(' / ') : 'ルート';}function memberOf(id) { for (var i = 0; i < S.members.length; i++) if (S.members[i].id === id) return S.members[i]; return null; }function who(id) { var m = memberOf(id); return m ? m.name : '退会したメンバー'; }function isAdmin() { return !!(S.me && S.me.role === 'admin'); }function rel(iso) { var d = (Date.now() - new Date(iso).getTime()) / 60000; if (isNaN(d)) return ''; if (d < 1) return 'たった今'; if (d < 60) return Math.floor(d) + '分前'; if (d < 1440) return Math.floor(d / 60) + '時間前'; if (d < 43200) return Math.floor(d / 1440) + '日前'; return Math.floor(d / 43200) + 'か月前';}function stamp(iso) { var d = new Date(iso); if (isNaN(d.getTime())) return ''; var z = function (n) { return String(n).padStart(2, '0'); }; return d.getFullYear() + '.' + z(d.getMonth() + 1) + '.' + z(d.getDate()) + ' ' + z(d.getHours()) + ':' + z(d.getMinutes());}function isNew(p) { return Date.now() - new Date(p.updated_at).getTime() < 259200000; }function ext(name) { var a = String(name).split('.'); return a.length > 1 ? a.pop() : 'file'; }function sizeLabel(b) { return b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1024)) + ' KB'; }function hashOf(s) { var h = 0; for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 997; return h; }/* 色はテーマの分類色(--app-cat-1〜6)から、名前で決まった番号を使う */function catVars(name) { var n = hashOf(String(name)) % 6 + 1; return ['var(--app-cat-' + n + '-soft)', 'var(--app-cat-' + n + ')']; }function avatarOf(name) { var c = catVars(name); return { bg: c[0], fg: c[1], initial: String(name).slice(0, 2) };}function tagColor(name) { return catVars(name); }function tagHtml(name, small) { var c = tagColor(name); return '<span class="tag' + (small ? ' sm' : '') + (S.route === 'tag' && S.tagName === name ? ' on' : '') + '" style="background:' + c[0] + ';color:' + c[1] + '" data-act="tag" data-id="' + esc(name) + '">' + esc(name) + '</span>';}function badges(p, big) { var k = big ? ' badge-lg' : ''; var out = ''; if (p.status === 'draft') out += '<span class="badge badge-draft' + k + '">下書き</span>'; if (p.visibility === 'members') out += '<span class="badge badge-lock' + k + '">限定</span>'; if (isNew(p) && !p.deprecated && p.status !== 'draft') out += '<span class="badge badge-new' + k + '">NEW</span>'; if (p.deprecated) out += '<span class="badge badge-dep' + k + '">' + (big ? '廃止された情報' : '廃止') + '</span>'; if (dueState(p) === 'over' && !p.deprecated) out += '<span class="badge badge-stale' + k + '">' + (big ? '見直し期限切れ' : '期限切れ') + '</span>'; return out;}function dueDays(p) { if (!p || !p.review_due) return 0; return Math.floor((new Date(p.review_due + 'T00:00:00').getTime() - Date.now()) / 86400000);}function dueState(p) { if (!p || !p.review_due || p.status === 'draft') return ''; var d = dueDays(p); return d < 0 ? 'over' : d <= 30 ? 'soon' : 'ok';}function dueLabel(p) { if (!p || !p.review_due) return ''; var d = dueDays(p), date = p.review_due.replace(/-/g, '.'); if (d < 0) return date + '(' + Math.abs(d) + '日過ぎ)'; if (d <= 30) return date + '(あと' + d + '日)'; return date;}function staleList() { return S.pages.filter(function (p) { var s = dueState(p); return (s === 'over' || s === 'soon') && !p.deprecated; }).sort(function (a, b) { return String(a.review_due).localeCompare(String(b.review_due)); });}function toast(msg, kind, undo) { var el = document.createElement('div'); el.className = 'toast' + (kind ? ' ' + kind : ''); el.innerHTML = '<span class="dot"></span>' + esc(msg); if (undo) { var b = document.createElement('button'); b.className = 'undo'; b.textContent = '元に戻す'; b.onclick = function () { el.remove(); undo(); }; el.appendChild(b); el.style.pointerEvents = 'auto'; } var box = document.getElementById('toast'); if (box) box.appendChild(el); setTimeout(function () { el.remove(); }, undo ? 7000 : 3600);}function visibleTags() { var count = {}; S.pages.forEach(function (p) { (p.tags || []).forEach(function (t) { count[t] = (count[t] || 0) + 1; }); }); return Object.keys(count).sort(function (a, b) { return count[b] - count[a] || a.localeCompare(b, 'ja'); }) .map(function (t) { return { name: t, count: count[t] }; });}function favorites() { return S.pages.filter(function (p) { return p.favorite; }); }function renderFav() { var wrap = document.getElementById('favWrap'); var btn = document.getElementById('favBtn'); var n = document.getElementById('favN'); if (!wrap || !btn || !n) return; var favs = favorites(); n.textContent = favs.length ? String(favs.length) : ''; btn.classList.toggle('open', !!S.favPop); var old = wrap.querySelector('.fav-pop'); if (old) old.parentNode.removeChild(old); if (!S.favPop) return; var body = favs.length ? favs.map(function (p) { return '<div class="fav-row" data-act="open" data-id="' + p.id + '"><span class="st">★</span>' + '<span class="nm">' + esc(p.title) + '</span><span class="pt">' + esc(pathLabel(p.id)) + '</span></div>'; }).join('') : '<div class="none">まだ何も入っていません。<br>記事の右上にある ☆ を押すと、ここにたまります。</div>'; var pop = document.createElement('div'); pop.className = 'fav-pop'; pop.setAttribute('data-stop', '1'); pop.innerHTML = '<div class="ph">よく開くページ<i></i><span>' + favs.length + '件</span></div>' + body; wrap.appendChild(pop);}/* ------------------------------------------------------------ 起動 */function renderGate(kind, detail) { var text = { forbidden: ['この wiki は開けません', 'この Google アカウントには見る権限がありません。設置した人に、ウェブアプリのアクセス範囲を確かめてもらってください。'], lib: ['画面の部品を読み込めませんでした', '通信が不安定か、ネットワークの設定で外部のファイルが止められている可能性があります。時間をおいて開き直してください。'], error: ['wiki を開けませんでした', detail || 'サーバーにつながりませんでした。'] }[kind]; document.body.innerHTML = '<div class="gate"><div class="gate-card"><span class="logo-mark"></span>' + '<h1>' + esc(text[0]) + '</h1><p>' + esc(text[1]) + '</p>' + (kind === 'error' ? '<button class="btn btn-primary" id="gateReload">もう一度読み込む</button>' : '') + '</div></div>'; var b = document.getElementById('gateReload'); if (b) b.addEventListener('click', function () { window.location.reload(); });}/* ------------------------------------------------------------ 参加 * 誰が入れるかはウェブアプリのアクセス範囲で決まっている。ここで決めるのは名前だけ。 * 名前は書いた人を見分けるためのもので、本人確認ではない。 */function renderJoin(members, codeRequired) { var name = (S.joining && S.joining.name) || ''; var list = members.filter(function (m) { return m.name && m.role !== 'admin' && !m.owner; }); S.joinCodeRequired = !!codeRequired; document.body.innerHTML = '<div class="gate"><div class="gate-card join"><span class="logo-mark"></span>' + '<h1>' + esc((S.site && S.site.name) || 'Wiki') + '</h1>' + '<p>はじめまして。表示する名前を決めてください。書いた人や担当者として、この名前が出ます。</p>' + (codeRequired ? '<p class="join-hint">この wiki に入るには合言葉が要ります。管理者に聞いてください。前に使っていた名前で入り直すときも同じです。</p>' + '<div class="set-row"><input type="text" id="joinCode" maxlength="40" placeholder="合言葉" autocomplete="off"></div>' : '') + '<div class="set-row"><input type="text" id="joinName" value="' + esc(name) + '" maxlength="40" placeholder="例: 山田 花子" autocomplete="name">' + '<button class="btn btn-primary" data-act="join">はじめる</button></div>' + (list.length ? '<p class="join-hint">前に使っていた名前があれば、そちらを押してください。同じ人として続きから使えます。</p>' + '<div class="join-list">' + list.map(function (m) { return '<button class="btn btn-sm" data-act="joinAs" data-id="' + esc(m.name) + '">' + esc(m.name) + '</button>'; }).join('') + '</div>' : '') + // 画面を丸ごと書き換えるので、案内(toast)の置き場所もここで作る '</div></div><div id="toast"></div>'; var input = document.getElementById('joinName'); var codeInput = document.getElementById('joinCode'); var onEnter = function (e) { if (e.key === 'Enter' && !e.isComposing) joinWiki(input.value); }; if (input) input.addEventListener('keydown', onEnter); if (codeInput) codeInput.addEventListener('keydown', onEnter); if (codeInput) codeInput.focus(); else if (input) input.focus();}function joinWiki(name) { var n = String(name || '').trim(); var codeInput = document.getElementById('joinCode'); var code = codeInput ? codeInput.value.trim() : ''; if (S.joinCodeRequired && !code) { toast('合言葉を入れてください', 'warn'); return; } if (!n) { toast('名前を入れてください', 'warn'); return; } S.joining = { name: n }; var btn = document.querySelector('[data-act="join"]'); if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spin"></span>用意しています…'; } run('join', n, code).then(function (data) { document.body.innerHTML = BODY_HTML; bindDom(); applyInitial(data); }).catch(function (e) { // 開いたあとに管理者が合言葉を決めると、欄がないまま送られる。欄を出して描き直す if (e.code === 'NEED_CODE') { renderJoin([], true); toast(e.message, 'warn'); return; } if (btn) { btn.disabled = false; btn.textContent = 'はじめる'; } fail(e); });}/** 使っている途中で、この端末の参加が管理者に解除されたとき。書きかけの本文は写せるように残す */function lostMembership() { if (S.lost || !S.me) return; S.lost = true; clearTimeout(S.autoTimer); var text = S.quill ? S.quill.getText() : ''; document.getElementById('modals').innerHTML = '<div class="modal-bg"><div class="modal" data-stop="1"><h3>この端末の参加が解除されました</h3>' + '<p>管理者がメンバーの整理をしたようです。画面を開き直すと、名前を決めるところからやり直せます。</p>' + (text.trim() ? '<p class="muted" style="margin:0 0 6px">書きかけの本文(コピーして控えてください)</p><textarea class="md-in" readonly>' + esc(text) + '</textarea>' : '') + '</div></div>'; S.modal = { locked: true };}/* 見た目は index.html の <html> に書かれている。画面からは変えない(配るときに決まる) */function applySite() { applySiteName();}function applySiteName() { var name = (S.site && S.site.name) || 'Wiki'; document.title = name; var els = document.querySelectorAll('.logo-text'); for (var i = 0; i < els.length; i++) els[i].textContent = name;}var BODY_HTML = '';function boot() { BODY_HTML = document.body.innerHTML; var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); var hint = document.getElementById('kbdHint'); if (hint) hint.textContent = mac ? '⌘K' : 'Ctrl K'; if (!window.DOMPurify || !window.Quill) { renderGate('lib'); return; } registerBlots(); bindGlobal(); S.uid = deviceUid(); google.script.url.getLocation(function (loc) { S.initialPage = (loc.parameter && loc.parameter.page) || ''; run('bootstrap').then(applyInitial).catch(function (e) { renderGate('error', e.message); }); });}function applyInitial(data) { if (!data.me) { S.site = data.site; applySite(data.site); renderJoin(data.members || [], data.joinCodeRequired); return; } S.lost = false; S.joining = null; S.me = data.me; S.user = data.me.id; S.site = data.site; S.members = data.members || []; S.templates = data.templates || []; S.trashCount = data.trashCount || 0; S.popular = {}; S.popularUsers = {}; S.pages = (data.pages || []).map(normalize); applySite(data.site); S.loading = false; applyMe(); if (!S.booted) { S.booted = true; kids('').forEach(function (p) { S.expanded[p.id] = true; }); installPrefetch(); } if (S.route === 'edit') return; // 編集中は描き直さない render(); if (S.initialPage && byId(S.initialPage)) { var id = S.initialPage; S.initialPage = ''; openPage(id); }}/* 一覧の行にカーソルを置いた時点で本文を先に取っておく(開いた瞬間に出る) */function installPrefetch() { document.addEventListener('mouseover', function (e) { if (!e.target || !e.target.closest) return; var row = e.target.closest('[data-act="open"][data-id]'); if (!row) return; var id = row.getAttribute('data-id'); if (!id || S.bodyCache[id]) return; clearTimeout(S.preTimer); S.preTimer = setTimeout(function () { if (!S.bodyCache[id]) loadBody(id); }, 200); });}/* ------------------------------------------------------------ 描画 */function render(keepScroll) { renderTree(); renderFav(); var main = document.getElementById('main'); var prevScroll = main.scrollTop; var html = ''; if (S.creating) html = viewCreating(); else if (S.route === 'home') html = viewHome(); else if (S.route === 'view') html = viewPage(); else if (S.route === 'edit') html = viewEdit(); else if (S.route === 'search') html = viewSearch(); else if (S.route === 'recent') html = viewRecent(); else if (S.route === 'popular') html = viewPopular(); else if (S.route === 'tag') html = viewTag(); else if (S.route === 'guide') html = viewGuide(); else if (S.route === 'stale') html = viewStale(); else if (S.route === 'trash') html = viewTrash(); main.innerHTML = html; main.classList.toggle('editing', S.route === 'edit' || !!S.creating); // 画面が切り替わったときだけ動かす(同じ画面の描き直しでちらつかせない) if (!keepScroll && main.firstElementChild && S.route !== 'edit') { main.firstElementChild.classList.add('view-in'); var lists = main.querySelectorAll('.card-bd, .cat-grid'); for (var i = 0; i < lists.length && i < 4; i++) lists[i].classList.add('stagger'); } // 待ったあとに本文が届いたときは、本文だけを出す(骨組みからの入れ替わりが分かるように) if (S.enterBody) { S.enterBody = false; var body = main.querySelector('.wiki-body'); if (body) body.classList.add('view-in'); var extras = main.querySelectorAll('.card-bd'); for (var k = 0; k < extras.length && k < 3; k++) extras[k].classList.add('stagger'); } main.scrollTop = keepScroll ? prevScroll : 0; if (S.route === 'edit' && !S.creating) mountEditor(); hydrateImages(main); document.getElementById('q').value = S.route === 'search' ? S.term : '';}/* ------------------------------------------------ ページを作っている間 * 型を選んだあと、サーバーにページを作るまで少し待つ。 * その間なにも変わらないと止まって見えるので、これから出る画面の形を先に見せる。 */function viewCreating() { var name = S.creating && S.creating.name ? S.creating.name : ''; function bar(w, h, mb, r) { return '<div class="sk" style="height:' + h + 'px;width:' + w + ';margin:0 0 ' + mb + 'px' + (r ? ';border-radius:var(--app-r-sm)' : '') + '"></div>'; } return '<div class="edit-shell">' + '<div class="edit-main">' + '<div class="edit-top">' + bar('132px', 30, 0, 8) + bar('220px', 30, 0, 8) + '<div style="flex:1"></div>' + bar('88px', 30, 0, 8) + bar('104px', 30, 0, 8) + '</div>' + '<div class="edit-canvas"><div class="edit-paper">' + bar('56%', 38, 22) + bar('100%', 34, 20, 8) + bar('22%', 15, 14) + bar('100%', 13, 9) + bar('94%', 13, 9) + bar('72%', 13, 24) + bar('28%', 15, 14) + bar('100%', 13, 9) + bar('88%', 13, 9) + bar('62%', 13, 24) + bar('24%', 15, 14) + bar('100%', 13, 9) + bar('80%', 13, 0) + '<div class="creating-note"><span class="spin sm"></span>' + esc(name ? '「' + name + '」の型でページを用意しています…' : 'ページを用意しています…') + '</div>' + '</div></div>' + '</div>' + '<div class="edit-side">' + '<div class="panel first">' + bar('40%', 12, 12) + bar('100%', 30, 10, 8) + bar('100%', 30, 0, 8) + '</div>' + '<div class="panel">' + bar('34%', 12, 12) + bar('100%', 30, 10, 8) + bar('80%', 13, 0) + '</div>' + '<div class="panel">' + bar('46%', 12, 12) + bar('100%', 30, 0, 8) + '</div>' + '</div>' + '</div>';}/** 通信が終わるまで、クリックやキー操作を受け付けないようにする */function blockUI(on) { var el = document.getElementById('block'); if (el) el.classList.toggle('on', !!on); document.body.classList.toggle('busy', !!on);}function renderTree() { var box = document.getElementById('tree'); if (!box) return; var html = '<div class="tree-home' + (S.route === 'home' ? ' on' : '') + '" data-act="home"><span class="dot"></span>ホーム</div>'; var cs = cats(); html += '<div class="side-sec">カテゴリ<i></i><b>' + (cs.length > 1 ? 'ドラッグで並べ替え' : '') + '</b></div>'; cs.forEach(function (c) { var ch = kids(c.id); var over = ch.concat([c]).filter(function (x) { return dueState(x) === 'over'; }).length; html += '<div class="cat-row' + (S.pageId === c.id ? ' on' : '') + '" draggable="true" data-row="' + c.id + '"' + ' data-act="open" data-id="' + c.id + '">' + '<span class="grip">⠿</span>' + '<span class="nm">' + esc(c.title) + '</span>' + (over ? '<span class="wd" title="見直し期限切れ ' + over + '件"></span>' : '') + '<span class="ct">' + ch.length + '</span></div>'; }); if (!cs.length) html += '<div class="tree-empty">カテゴリがまだありません</div>'; html += '<div class="side-sec">よく見るもの<i></i></div>' + navRow('recent', '最近の更新', '') + navRow('popular', 'よく読まれている', '') + navRow('stale', '見直しが必要なページ', staleList().length); var tags = visibleTags(); if (tags.length) { html += '<div class="side-sec">タグ<i></i></div><div class="tag-row" style="padding:2px 10px 0">' + tags.slice(0, 14).map(function (t) { return tagHtml(t.name, true); }).join('') + '</div>'; } html += '<div class="side-sec">その他<i></i></div>' + navRow('guide', 'この wiki の使い方', '') + navRow('trash', 'ゴミ箱', S.trashCount || 0); box.innerHTML = html;}function navRow(act, label, n) { return '<div class="nav-row' + (S.route === act ? ' on' : '') + '" data-act="' + act + '">' + '<span class="nm">' + label + '</span>' + (n ? '<span class="ct">' + n + '</span>' : '') + '</div>';}/** カテゴリ=最上位のページ。階層は2段までなので、この下は必ず末端。 */function cats() { return kids(''); }/* ------------------------------------------- カテゴリの中のページ一覧 */var IDX_FILTER_MIN = 10;function indexSection(p) { var ch = kids(p.id); if (!ch.length) return ''; var count = {}; ch.forEach(function (x) { (x.tags || []).forEach(function (t) { count[t] = (count[t] || 0) + 1; }); }); var names = Object.keys(count).sort(); return '<section class="card idx" style="margin-top:20px">' + '<div class="card-hd"><h2>このカテゴリのページ</h2><span class="sub">' + ch.length + '件</span></div>' + '<div class="idx-bar">' + // 数件なら目で探せるので、絞り込み欄は多いときだけ出す(探す入口を増やしすぎない) (ch.length > IDX_FILTER_MIN ? '<div class="idx-search"><span class="search-icon">○</span>' + '<input id="idxFilter" type="search" placeholder="この中を絞り込む" value="' + esc(S.idxFilter) + '" autocomplete="off"></div>' : '') + '<div class="idx-sort">' + sortBtn('order', '順番') + sortBtn('upd', '更新') + sortBtn('name', '名前') + '</div>' + '</div>' + (names.length > 1 ? '<div class="idx-tags">' + '<span class="idx-tag' + (S.idxTag ? '' : ' on') + '" data-act="idxTag" data-id="">すべて</span>' + names.map(function (t) { return '<span class="idx-tag' + (S.idxTag === t ? ' on' : '') + '" data-act="idxTag" data-id="' + esc(t) + '">' + esc(t) + ' ' + count[t] + '</span>'; }).join('') + '</div>' : '') + '<div class="card-bd">' + idxRowsHtml(p) + '</div></section>';}function idxRowsHtml(p) { var ch = kids(p.id); var f = S.idxFilter.trim().toLowerCase(); var list = ch.filter(function (x) { if (S.idxTag && (x.tags || []).indexOf(S.idxTag) < 0) return false; if (!f) return true; return (x.title + ' ' + (x.tags || []).join(' ')).toLowerCase().indexOf(f) >= 0; }); if (S.idxSort === 'upd') list.sort(function (a, b) { return String(b.updated_at).localeCompare(String(a.updated_at)); }); else if (S.idxSort === 'name') list.sort(function (a, b) { return String(a.title).localeCompare(String(b.title), 'ja'); }); if (!list.length) { return '<div class="empty"><b>該当するページがありません</b>' + '<p>絞り込みを外すと、このカテゴリの' + ch.length + '件が出ます。</p></div>'; } return list.map(idxRowHtml).join('');}function sortBtn(v, label) { return '<span class="sb' + ((S.idxSort || 'order') === v ? ' on' : '') + '" data-act="idxSort" data-id="' + v + '">' + label + '</span>';}/** 並べ替え・タグ絞り込み・本文検索の3つを、スクロール位置を保ったまま差し替える */function updateIdx() { var p = byId(S.pageId); var idx = document.querySelector('.idx'); if (!p || !idx) return; idx.querySelectorAll('.idx-sort .sb').forEach(function (el) { el.classList.toggle('on', el.getAttribute('data-id') === (S.idxSort || 'order')); }); idx.querySelectorAll('.idx-tag').forEach(function (el) { el.classList.toggle('on', el.getAttribute('data-id') === S.idxTag); }); var body = idx.querySelector('.card-bd'); if (body) body.innerHTML = idxRowsHtml(p);}function idxRowHtml(p) { return '<div class="idx-row' + (p.deprecated ? ' dep' : '') + '" data-act="open" data-id="' + p.id + '">' + '<div class="idx-t">' + '<div class="idx-tt"><span class="nm">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="idx-sub">' + (p.tags || []).slice(0, 3).map(function (t) { return tagHtml(t, true); }).join('') + '<span class="mt">担当 ' + esc(who(p.owner || p.updated_by)) + '</span>' + '</div>' + '</div>' + '<span class="idx-when">' + rel(p.updated_at) + '</span></div>';}/* ------------------------------------------------------- 共通の行 */function rowHtml(p) { var av = avatarOf(who(p.updated_by)); return '<div class="row' + (p.deprecated ? ' dep' : '') + '" data-act="open" data-id="' + p.id + '">' + '<span class="av" style="background:' + av.bg + ';color:' + av.fg + '">' + esc(av.initial) + '</span>' + '<div class="t"><div class="tt"><span class="name">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="path">' + esc(pathLabel(p.id)) + '</div></div>' + '<span class="when">' + rel(p.updated_at) + '</span></div>';}function popularSorted() { return S.pages.filter(function (p) { return S.popular[p.id]; }) .sort(function (a, b) { return S.popular[b.id] - S.popular[a.id]; });}function popRowHtml(p, i, max) { var w = Math.max(8, Math.round(S.popular[p.id] / max * 96)); return '<div class="row' + (p.deprecated ? ' dep' : '') + '" data-act="open" data-id="' + p.id + '">' + '<span class="rank' + (i < 3 ? ' top' : '') + '">' + (i + 1) + '</span>' + '<div class="t"><div class="tt"><span class="name">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="metrics"><span class="bar" style="width:' + w + 'px"></span>' + '<span>' + S.popular[p.id] + '回 / ' + (S.popularUsers[p.id] || 0) + '人</span></div></div></div>';}function listRowHtml(p) { return '<div class="list-row' + (p.deprecated ? ' dep' : '') + '" data-act="open" data-id="' + p.id + '">' + '<span class="sq"></span><span class="name">' + esc(p.title) + '</span>' + badges(p) + '<span class="path">' + esc(pathLabel(p.id)) + '</span>' + '<span class="who">' + esc(who(p.updated_by)) + '</span>' + '<span class="when">' + rel(p.updated_at) + '</span></div>';}/* ------------------------------------------------------ 機能ガイド */function gdTag(n) { var c = tagColor(n); return '<span class="tag" style="background:' + c[0] + ';color:' + c[1] + '">' + n + '</span>';}function gdBlock(no, id, title, where, cap, demo, lead, steps, note) { return '<section class="gd" id="' + id + '">' + '<div class="gd-hd"><span class="gd-no">' + no + '</span><h2>' + title + '</h2>' + '<span class="gd-where">' + where + '</span></div>' + '<div class="gd-cols">' + '<div class="gd-demo"><span class="gd-cap">' + cap + '</span>' + demo + '</div>' + '<div class="gd-how"><p class="gd-lead">' + lead + '</p>' + steps.map(function (s) { return '<div class="gd-step"><span class="k">' + s[0] + '</span><span class="ar">→</span>' + '<span class="v">' + s[1] + '</span></div>'; }).join('') + (note ? '<p class="gd-note">' + note + '</p>' : '') + '</div>' + '</div></section>';}function gdGroup(id, title, desc) { return '<div class="gd-grp" id="' + id + '"><h2>' + title + '</h2><p>' + desc + '</p></div>';}function gdAvatar(name) { var av = avatarOf(name); return '<span class="av" style="flex:none;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:' + av.bg + ';color:' + av.fg + ';font-size:11px;font-weight:600">' + esc(name.slice(0, 1)) + '</span>';}function viewGuide() { var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); var kbd = mac ? '⌘K' : 'Ctrl K'; var box = '<span style="flex:none;width:17px;height:17px;margin-top:4px;border:1.5px solid var(--app-border-strong);border-radius:calc(var(--app-r-sm) / 2);background:var(--app-surface)"></span>'; var palRows = '<div class="pal-row pal-sel"><span class="ic">#</span><div class="t">' + '<div class="nm"><span class="txt">会議室の予約ルール</span><span class="badge badge-new">NEW</span></div>' + '<div class="sub">手順書 / 設備</div></div><span class="go">Enter</span></div>' + '<div class="pal-row"><span class="ic">#</span><div class="t">' + '<div class="nm"><span class="txt">在宅勤務の始め方</span></div>' + '<div class="sub">手順書 / 在宅</div>' + '<div class="snip">…在宅の日は<mark>会議</mark>用のヘッドセットを用意します…</div></div>' + '<span class="go">Enter</span></div>'; var html = '<div class="wrap">' + '<h1 class="sec-h">この wiki の使い方</h1>' + '<p class="sub">画面のどこを押すと何が起きるかを、実際の画面をそのまま並べて説明します。左が見た目、右が起きること。</p>' + '<div class="gd-idx">' + '<a href="#gg-find"><em>07</em>見たいページを探す</a>' + '<a href="#gg-write"><em>07</em>書く・直す</a>' + '<a href="#gg-react"><em>04</em>反応を返す・知らせる</a>' + '<a href="#gg-fresh"><em>05</em>古い情報で困らない</a>' + '<a href="#gg-safe"><em>04</em>やり直せる・守る</a>' + '<a href="#gg-out"><em>—</em>まだできないこと</a>' + '</div>'; /* ------------------------------------------------------------ 探す */ html += gdGroup('gg-find', '見たいページを探す', '「あるはずなのに見つからない」をなくすための機能です'); html += gdBlock('01', 'gd-tag', 'タグ', '記事のタイトルの下 / 編集画面', '記事のタイトルの下', '<div class="tag-row">' + ['手順', '設備', '入社'].map(gdTag).join('') + '</div>' + '<div class="gd-frame"><div class="tag-input-box">' + '<span class="tag-chip" style="background:' + tagColor('手順')[0] + ';color:' + tagColor('手順')[1] + '"><b>手順</b><i>×</i></span>' + '<span class="tag-chip" style="background:' + tagColor('設備')[0] + ';color:' + tagColor('設備')[1] + '"><b>設備</b><i>×</i></span>' + '<input value="入" readonly></div>' + '<div class="tag-sugg">' + ['入社', 'よくある質問'].map(gdTag).join('') + '</div></div>', '「設備」「入社」のような目印です。1つのページにいくつか付けられるので、置き場所が1か所しかなくても別の切り口から探せます。', [['タグを押す', 'そのタグが付いたページだけが並びます'], ['編集画面で文字を打つ', 'すでにあるタグが候補に出ます。Enter で追加(1ページ8つまで)'], ['ホームの一覧で選ぶ', '一覧がそのタグの付いたページだけになります']], 'よく使われているタグから順に、ホームの一覧の上に出ます。'); html += gdBlock('02', 'gd-search', '打ちながら出る検索', '画面の上にある検索窓', '検索窓に「会議」と打った瞬間', '<div class="pal"><div class="pal-in"><span style="color:var(--app-faint);font-size:13px">検索</span>' + '<input value="会議" readonly><span class="kbd">Enter で全文検索</span></div>' + '<div class="pal-bd"><div class="pal-sec">ページ</div>' + palRows + '</div>' + '<div class="pal-ft"><span><b>↑↓</b>選択</span><span><b>Enter</b>開く</span><span><b>Esc</b>閉じる</span></div></div>', '文字を打っている間に、候補が下に出ます。Enter を押して結果を待つ必要はありません。', [['1文字打つ', 'ページ名がその場で候補に出ます'], ['そのまま Enter', '本文や添付ファイルの名前まで探した結果が開きます'], ['↑↓ で選んで Enter', 'マウスを使わずにそのページを開けます']], 'ページ名の候補はすぐ出ます。本文の中を探した結果は、少し遅れて下に追加されます。'); html += gdBlock('03', 'gd-palette', 'キーボードだけで操作(' + kbd + ')', 'どの画面からでも ' + kbd, kbd + ' を押した状態', '<div class="pal"><div class="pal-in"><span style="color:var(--app-faint);font-size:13px">></span>' + '<input value="へん" readonly><span class="kbd">' + kbd + '</span></div>' + '<div class="pal-bd"><div class="pal-sec">操作</div>' + '<div class="pal-row pal-sel"><span class="ic">✎</span><div class="t"><div class="nm">' + '<span class="txt">このページを編集</span></div></div><span class="go">Enter</span></div>' + '<div class="pal-row"><span class="ic">+</span><div class="t"><div class="nm">' + '<span class="txt">新しいページをつくる</span></div></div></div>' + '<div class="pal-sec">ページ</div>' + '<div class="pal-row"><span class="ic">#</span><div class="t"><div class="nm">' + '<span class="txt">共有ドライブの整理ルール</span></div><div class="sub">ルート</div></div></div>' + '</div></div>', kbd + ' を押すと、どの画面からでも小さな窓が開きます。ページを探すのも、新しく作るのも、印刷するのもここからできます。', [[kbd + ' を押す', 'どの画面からでも開きます(Windows は Ctrl K)'], ['やりたいことを打つ', '「編集」「履歴」「印刷」などの操作もページと一緒に候補に出ます'], ['Esc を押す', '閉じます。開いていた画面はそのままです']], '記事を開いているときだけ、そのページに対する操作(編集・お気に入り・履歴・印刷)が候補に出ます。'); html += gdBlock('04', 'gd-tree', 'カテゴリと、その中の一覧', '画面左のカテゴリ / カテゴリを開いた画面', '左のカテゴリと、開いた中身', '<div class="gd-frame flat" style="padding:6px;max-width:250px;margin:0 0 10px">' + '<div class="cat-row on"><span class="grip">⠿</span><span class="nm">手順書</span>' + '<span class="wd"></span><span class="ct">4</span></div>' + '<div class="cat-row"><span class="grip">⠿</span><span class="nm">議事録</span><span class="ct">2</span></div>' + '<div class="cat-row drag-over-after"><span class="grip">⠿</span><span class="nm">よくある質問</span><span class="ct">0</span></div>' + '</div>' + '<div class="gd-frame" style="padding:0;overflow:hidden">' + '<div class="idx-bar" style="padding:10px 12px 0"><div class="idx-search"><span class="search-icon">○</span>' + '<input value="" placeholder="この中を絞り込む" readonly></div>' + '<div class="idx-sort"><span class="sb on">順番</span><span class="sb">更新</span><span class="sb">名前</span></div></div>' + '<div style="padding:6px 12px 10px">' + '<div class="idx-row"><div class="idx-t"><div class="idx-tt">' + '<span class="nm">在宅勤務の始め方</span><span class="badge badge-new">NEW</span></div>' + '<div class="idx-sub">' + gdTag('在宅') + '<span class="mt">担当 田中 翔</span></div></div>' + '<span class="idx-when">5時間前</span></div>' + '<div class="idx-row"><div class="idx-t"><div class="idx-tt">' + '<span class="nm">会議室の予約ルール</span><span class="badge badge-stale">期限切れ</span></div>' + '<div class="idx-sub">' + gdTag('設備') + '<span class="mt">担当 鈴木 健</span></div></div>' + '<span class="idx-when">3日前</span></div>' + '</div></div>', 'サイドバーに並ぶのはカテゴリだけです。ページの名前は、カテゴリを開いた本文側の一覧に出ます。' + '狭いサイドバーで名前が切れて読めない、という状態をなくすためです。', [['カテゴリを押す', 'そのカテゴリの説明と、中のページ一覧が出ます'], ['一覧の並べ替え', '更新順や名前順に並べ替えたり、タグで絞ったりできます(ページが多いカテゴリには絞り込み欄も出ます)'], ['カテゴリをつかんで動かす', '上下の境目に落とすと、並び順が変わります'], ['スマホやキーボードで並べ替える', '編集画面の「並び順」の ↑ ↓ で動かせます']], '階層はカテゴリと、その中のページの2段までです。' + 'それ以上分けたくなったら、たいていは新しいカテゴリを作るか、タグで分けたほうが探しやすくなります。'); html += gdBlock('05', 'gd-before', '読む前に分かること', '記事のタイトルの下 / 記事の一番下', '記事のタイトルの下と、本文の下', '<div class="gd-frame"><div class="meta" style="padding:0;border:0;display:flex;align-items:center;gap:9px;font-size:12px;color:var(--app-muted)">' + '高橋 陽子 が 3日前に更新<span class="meta-dot">·</span><span style="color:var(--app-faint)">担当 高橋 陽子</span>' + '<span class="meta-dot">·</span><span style="color:var(--app-faint)">見直し 2027.03.01</span></div>' + '<div class="art-stats" style="margin-top:10px"><span>約3分で読めます</span><span>30日で 12人が参照</span></div></div>' + '<div class="gd-frame" style="padding:4px 6px">' + '<div class="rel-row"><span style="flex:none;width:12px;height:12px;border-radius:calc(var(--app-r-sm) / 2);border:1.5px solid var(--app-faint);opacity:.7"></span>' + '<span class="nm">在宅勤務の始め方</span><span class="rs">続けて開かれている</span></div>' + '<div class="rel-row"><span style="flex:none;width:12px;height:12px;border-radius:calc(var(--app-r-sm) / 2);border:1.5px solid var(--app-faint);opacity:.7"></span>' + '<span class="nm">新しいメンバーを迎える手順</span><span class="rs">同じタグ</span></div></div>', 'タイトルの下には、誰がいつ直したか、誰に聞けばいいか、いつまで正しいかだけを出します。何分で読めるかや何人が見たかは本文の下です。', [['記事を開く', '更新した人・担当・見直し期限がタイトルの下に出ます'], ['記事の一番下を見る', '一緒に見られているページが並びます'], ['「印刷」を押す', '紙に合わせた見た目で印刷できます(A4)']], '現場に貼る手順書などは、印刷しても見た目が崩れません。'); html += gdBlock('06', 'gd-zero', '見つからなかったとき', '検索結果が0件の画面', 'ヒットしなかったとき', '<div class="gd-frame"><div class="empty" style="padding:18px 10px">' + '<b style="font-size:13.5px">「ヘッドホン」は見つかりませんでした</b>' + '<p style="font-size:12px">言い方が違うだけかもしれません。似たページを下に出しました。</p>' + '<p style="margin-top:12px;display:flex;gap:8px;justify-content:center">' + '<span class="btn btn-sm btn-primary">「ヘッドホン」のページをつくる</span></p></div></div>' + '<div class="gd-frame" style="padding:4px 6px">' + '<div class="rel-row"><span style="flex:none;color:var(--app-faint);font-size:11px">→</span>' + '<span class="nm">在宅勤務の始め方</span><span class="rs">手順書</span></div></div>', '探し方が悪いのでなく、言い方が違うことがよくあります。その場で次の手を出します。', [['似たページを見る', '名前が近いページが下に並びます'], ['「ページをつくる」', '探した言葉をタイトルにした新しいページを作れます']], null); html += gdBlock('07', 'gd-backlink', 'どこからリンクされているか', '記事の下のほう', '参照しているページ一覧', '<div class="gd-frame" style="padding:4px 6px">' + '<div class="rel-row"><span style="flex:none;color:var(--app-faint);font-size:11px">←</span>' + '<span class="nm">新しいメンバーを迎える手順</span><span class="rs">手順書</span></div>' + '<div class="rel-row"><span style="flex:none;color:var(--app-faint);font-size:11px">←</span>' + '<span class="nm">在宅勤務の始め方</span><span class="rs">手順書</span></div></div>', 'このページをリンクしているページが一覧で分かります。直す前・消す前に影響範囲が読めます。', [['記事の下を見る', '参照元のページが並びます'], ['消す前に確認', '参照されているページがあれば、先にそちらを直せます'], ['リンクの作り方', '編集画面の「ページリンク」ボタンから']], null); /* ------------------------------------------------------------ 書く */ html += gdGroup('gg-write', '書く・直す', '「ちゃんと書く余裕がない」まま止まらないための機能です'); html += gdBlock('08', 'gd-tpl', '型を選んで書き始める', '「+ ページ」を押した直後', '型を選ぶ画面', '<div class="modal pick" style="animation:none"><div class="pick-hd"><b>どの型で書き始めますか</b></div>' + '<div class="pick-bd">' + '<div class="tpl-row"><span class="ic">議事</span><div><b>議事録</b><span>決まったこと・持ち越し・参加者</span></div></div>' + '<div class="tpl-row"><span class="ic">手順</span><div><b>手順書</b><span>目的・手順・困ったとき</span></div></div>' + '<div class="tpl-row"><span class="ic">トラ</span><div><b>トラブルの報告</b><span>起きたこと・影響・原因・次からの対策</span></div></div>' + '<div class="tpl-row"><span class="ic">白紙</span><div><b>白紙</b><span>型を使わずに書く</span></div></div>' + '</div></div>', '何を書けばいいか迷うときは、型を選ぶと見出しが用意された状態から始められます。', [['型を選ぶ', '見出しが入った状態で編集画面が開きます'], ['「白紙」', '何もない状態で始まります']], null); html += gdBlock('09', 'gd-draft', '下書きのまま保存する', '編集画面の上', '下書きを編集しているときの上のボタン', '<div class="gd-frame" style="display:flex;justify-content:flex-end;gap:8px">' + '<span class="btn btn-sm">やめる</span><span class="btn btn-sm">下書きを保存</span>' + '<span class="btn btn-sm btn-primary">公開する</span></div>' + '<div class="notice" style="margin:0"><span class="dot" style="flex:none;width:7px;height:7px;border-radius:50%;background:var(--app-warn);margin-top:6px"></span>' + '<div><b style="font-size:13px">下書き — 自分にだけ見えています</b>' + '<p style="margin:4px 0 0;font-size:12px;color:var(--app-muted);line-height:1.7">公開すると、サイドバーと検索結果に出るようになります。</p></div></div>', '書きかけのまま保存できます。公開するまで、ほかの人には見えません。', [['「下書きを保存」', '自分にだけ見えます。一覧にも検索にも出ません'], ['「公開する」', 'その場で保存して、全員に見えるようになります'], ['下書きのまま置いておく', 'ホームに「下書き○件」と、自分にだけ出ます']], '下書きを何度保存しても、ほかの人にメールは届きません。'); html += gdBlock('10', 'gd-attach', 'ファイルを付ける', '編集画面の右側 / 記事の下', 'ファイル欄', '<div class="gd-frame">' + '<div class="file-row"><span class="ext">pdf</span><span class="fname">会議室の見取り図.pdf</span>' + '<span class="fsize">240 KB</span><span class="rm">削除</span></div>' + '<div class="file-row"><span class="ext">xlsx</span><span class="fname">予約表_記入例.xlsx</span>' + '<span class="fsize">180 KB</span><span class="rm">削除</span></div>' + '<div class="drop" style="margin-top:6px"><b>ファイルをドロップ</b><span>または クリックして選択</span></div></div>', 'PDF や Excel をそのまま付けられます。本文の途中に置きたいときは、ツールバーの「添付を入れる」から選ぶと、付けるのとリンクを入れるのが一度で済みます。', [['編集画面の右の欄にファイルを落とす', '一覧に追加されます'], ['ツールバーの「添付を入れる」', 'ファイルを選ぶと、添付してカーソルの位置にリンクが入ります'], ['ファイル名を押す', '手元に保存されます'], ['ファイル名で検索', '「添付」と付いて出てきます']], 'ファイルは wiki を設置した人の Google ドライブに保存され、このページを見られる人だけが開けます。'); html += gdBlock('11', 'gd-conflict', '同じページを同時に直したとき', '保存を押したとき', '保存したときに出る確認', '<div class="modal" style="animation:none;max-width:100%">' + '<h3>ほかの人が先に保存しています</h3>' + '<p style="margin-bottom:16px">このページは 2分前に 鈴木 健 さんが保存しました。いま書いている内容はそのまま残っています。</p>' + '<div class="foot"><button class="btn">相手の内容を読み込み直す</button>' + '<button class="btn btn-primary">自分の内容で上書きする</button></div></div>', '編集画面を開いたまま時間が経つと、その間にほかの人が保存していることがあります。気づかず上書きしないように確認が出ます。', [['保存を押す', '先に保存された変更があれば、上書きの前に知らせます'], ['「読み込み直す」', '相手の変更を取り込んでから、自分の書き直しができます'], ['「上書きする」', '相手の内容は履歴に残るので、あとから戻せます']], null); html += gdBlock('12', 'gd-check', 'チェックリスト', '編集画面のツールバー / 記事本文', '記事を開いたとき', '<div class="gd-frame" style="font-size:13.5px;line-height:2">' + '<div style="display:flex;gap:9px;align-items:flex-start"><span style="flex:none;display:flex;align-items:center;justify-content:center;width:17px;height:17px;margin-top:4px;border-radius:calc(var(--app-r-sm) / 2);background:var(--app-accent);color:var(--app-onaccent);font-size:11px">✓</span>' + '<span style="color:var(--app-faint);text-decoration:line-through">アカウントを発行する</span></div>' + '<div style="display:flex;gap:9px;align-items:flex-start">' + box + '<span>貸与品をそろえる</span></div>' + '<div style="display:flex;gap:9px;align-items:flex-start">' + box + '<span>座席表を更新する</span></div></div>' + '<div class="gd-frame ck-foot" style="margin:0;border-top:1px solid var(--app-border)"><span class="bar"><i style="width:33%"></i></span>' + '<span class="n">1 / 3 完了</span><span class="note">自分にだけ見えます</span>' + '<span class="act">全部外す</span></div>', '毎回同じ手順で進める作業は、記事のままチェックしながら進められます。コピーして使う必要はありません。', [['本文の四角を押す', 'チェックが入ります。自分の状態なので、ほかの人には見えません'], ['「全部外す」', '次に同じ作業を始めるときにリセットできます'], ['作るとき', '編集画面のチェックリストボタンで行を作ります']], null); html += gdBlock('13', 'gd-write2', '書式の道具箱', '編集画面の上のツールバー', 'ツールバーでできること', '<div class="gd-frame"><div data-callout="warn" style="margin:0 0 10px">予約の取り消しは、前日までにしてください。</div>' + '<div data-callout="point" style="margin:0 0 10px">空いていれば、予約なしで使って構いません。</div>' + '<table class="wtable" style="margin:0"><tr><th>部屋</th><th>人数</th></tr>' + '<tr><td>A(大)</td><td>12人</td></tr><tr><td>B(中)</td><td>6人</td></tr></table></div>', '長い手順書でも読みやすい形にできるだけの道具を入れてあります。', [['注意ボックス', '注意(赤)・ポイント(黄)・補足(グレー)・前提(青)の4種類'], ['カードの中で Enter', 'カードの外に出て普通の行になります。中で改行するときは Shift+Enter'], ['同じ種類をもう一度選ぶ', 'そのカードが外れて普通の段落に戻ります'], ['表', '押すと表が入り、その場で行・列の追加と削除、見出しの切り替えができます(表をダブルクリックでも開きます)'], ['その他', '取り消し線・区切り線・添付ファイルのリンク・元に戻す/やり直す']], '文字色と背景色はあえて入れていません。マーカー・重要・補足で意味を揃えるためです。'); html += gdBlock('14', 'gd-md', 'AI に書かせた文章を、そのまま入れる', '編集画面のツールバー「Markdown」', '読み込む画面', '<div class="modal" style="animation:none;max-width:100%"><h3 style="margin:0 0 10px">マークダウンを読み込む</h3>' + '<div class="md-ask"><button type="button" class="btn btn-sm" data-act="mdPrompt">AI に頼む文をコピー</button>' + '<span class="muted">押すとコピーできます</span></div>' + '<div class="md-in" style="min-height:0;white-space:pre-wrap">' + '## 会議室の使い方\n\n- 予約は前日までに\n- [ ] 使い終わったら鍵を返す\n\n| 部屋 | 人数 |\n| --- | --- |\n| A | 12人 |' + '</div></div>', 'マークダウンを覚える必要はありません。AI に「マークダウンで書いて」と頼んだ文章を、書式を崩さずに本文へ入れるための入口です。', [['「AI に頼む文をコピー」', 'Gemini や ChatGPT に貼り、書きたいことを足して送ります'], ['返ってきた文を貼る', '見出し・箇条書き・チェックリスト・表・引用・コードが、このアプリの書式に変わります'], ['本文に直接貼っても同じ', 'マークダウンだと分かれば、貼った時点で書式に変わります'], ['.md ファイルから読む', 'ほかのツールから移すときに使います']], '画像は入りません(リンクとして残ります)。字下げした入れ子の箇条書きは1段にそろいます。'); html += gdBlock('15', 'gd-autosave', '自動保存と変更メモ', '編集画面の上と下', '編集画面の上下', '<div class="gd-frame" style="display:flex;align-items:center;gap:10px">' + '<span class="muted" style="font-size:11.5px">保存先:手順書</span>' + '<span class="memo" style="flex:1;display:flex;align-items:center;padding:0 10px;height:30px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm);font-size:12px;color:var(--app-muted)">取り消しの締め切りを前日に直した</span>' + '<span class="btn btn-sm btn-primary">保存する</span></div>' + '<div class="gd-frame" style="display:flex;align-items:center;gap:14px;font-size:11px;color:var(--app-faint)">' + '<span>312文字</span><span>下書き(自動保存あり)</span>' + '<span style="margin-left:auto;color:var(--app-accent)">自動保存しました 14:32</span></div>', '下書きの間は勝手に保存します。公開中のページは、自分が保存を押すまで誰にも見えません。', [['下書きを書く', '変更があれば20秒ごとに自動保存し、下に時刻が出ます'], ['公開中のページを直す', '自動保存はしません。途中の文章を人に見せないためです'], ['上の欄に一行書く', '「何を直したか」が履歴に残り、あとから読む人の手がかりになります']], null); /* ------------------------------------------------------ 反応が返る */ html += gdGroup('gg-react', '反応を返す・知らせる', '書いた人に何も返らないと、続きは書かれません'); html += gdBlock('16', 'gd-fav', 'お気に入り', '記事の右上 ☆ / 画面右上「お気に入り」', '記事の右上のボタン', '<div class="gd-frame" style="display:flex;justify-content:flex-end"><div class="art-tools">' + '<button class="tool on">★</button><button class="tool">通知</button>' + '<button class="tool">履歴 4</button><button class="tool">印刷</button>' + '<button class="btn btn-sm">編集</button></div></div>' + '<div style="display:flex;justify-content:flex-end"><div style="position:relative;width:270px">' + '<div style="display:flex;justify-content:flex-end"><span class="fav-btn open">' + '<span class="st">★</span>お気に入り<span class="n">2</span></span></div>' + '<div class="fav-pop" style="position:static;width:auto;margin-top:8px;animation:none">' + '<div class="ph">よく開くページ<i></i><span>2件</span></div>' + '<div class="fav-row"><span class="st">★</span><span class="nm">会議室の予約ルール</span>' + '<span class="pt">手順書</span></div>' + '<div class="fav-row"><span class="st">★</span><span class="nm">新しいメンバーを迎える手順</span>' + '<span class="pt">手順書</span></div></div></div></div>', '毎週開くページは ★ を付けておくと、画面右上からいつでも呼び出せます。', [['記事の右上の ☆ を押す', '★ になり、右上の「お気に入り」に入ります'], ['右上の「お気に入り」を押す', '一覧が開きます。押すとそのページへ移動します'], ['もう一度 ★ を押す', '一覧から外れます']], 'ホームの「よく読まれている」は全員の集計です。こちらは自分だけの一覧で、ほかの人には見えません。'); html += gdBlock('17', 'gd-react', '絵文字で反応する', '記事の一番下', '本文の下', '<div class="reactions">' + '<span class="react mine">👍<b>4</b></span><span class="react">🙏<b>2</b></span>' + '<span class="react">✅<b>1</b></span><span class="react react-add">+</span></div>' + '<div class="react-pop" style="align-self:flex-start">' + EMOJI.map(function (e) { return '<span>' + e + '</span>'; }).join('') + '</div>', '「読みました」「助かりました」を1クリックで返せます。コメントを書くほどではないときに。', [['絵文字を押す', '自分の1票が入り、枠に色が付きます'], ['+ を押す', '絵文字を選べます。押された絵文字だけが並びます'], ['自分の票をもう一度押す', '取り消せます']], null); html += gdBlock('18', 'gd-cmt', 'コメントで聞く(@ で呼ぶ)', '記事の一番下', 'コメント欄', '<div class="gd-frame" style="padding:2px 4px">' + '<div class="cmt">' + gdAvatar('佐藤 美咲') + '<div class="bd"><div class="hd"><b>佐藤 美咲</b><span>2日前</span><span class="act">解決にする</span></div>' + '<p>予約の取り消しの締め切り、前日で合っていますか?</p></div></div>' + '<div class="cmt done">' + gdAvatar('高橋 陽子') + '<div class="bd"><div class="hd"><b>高橋 陽子</b><span>先週</span><span class="act">未解決に戻す</span></div>' + '<p>見取り図が古いままです</p></div></div></div>', '本文を直す前に「ここ古いですよね?」と書ける場所です。人のページに直接手を入れるより気楽です。', [['コメントを書く', 'ページの下に残り、通知をオンにしている人にメールが届きます'], ['@ を打つ', 'メンバーの候補が出て、選んだ人にメールが届きます'], ['「解決にする」を押す', '打ち消し線になり、件数から外れます']], null); html += gdBlock('19', 'gd-notify', '更新をメールで受け取る', '記事の右上「通知」', 'オンにした状態', '<div class="gd-frame" style="display:flex;justify-content:flex-end">' + '<div class="art-tools"><button class="tool">☆</button>' + '<button class="tool watch on">通知オン</button><button class="tool">履歴 4</button></div></div>' + '<div class="gd-frame gd-chat"><span class="ic">✉</span><div class="bd">' + '<b>' + esc((S.site && S.site.name) || 'Wiki') + '</b>' + '<p>件名: 「会議室の予約ルール」が更新されました</p></div></div>', '気になるページで「通知」を押しておくと、更新やコメントがあったときにメールが届きます。自分で見に行かなくても気づけます。', [['「通知」を押す', 'オンになります。メールを受け取るには、設定でアドレスを入れておきます'], ['自分が保存したとき', '「○人にメールで通知」と画面に出ます'], ['下書きの保存', 'メールは届きません。公開したときだけ届きます']], 'メールは wiki を設置した人の Gmail から届きます。'); /* -------------------------------------------------- 古くならない */ html += gdGroup('gg-fresh', '古い情報で困らない', '古い手順のまま作業してしまう事故を防ぐための機能です'); html += gdBlock('20', 'gd-review', '担当者と見直し期限', '編集画面の右側 / ホーム', 'ホームの一番上', '<div class="gd-frame" style="padding:0;overflow:hidden">' + '<div class="card-hd"><h2>見直しが必要なページ</h2><span class="sub">期限切れ 1件</span></div>' + '<div class="card-bd">' + '<div class="row"><span class="rank" style="background:var(--app-error-soft);color:var(--app-error)">!</span>' + '<div class="t"><div class="tt"><span class="name">会議室の予約ルール</span>' + '<span class="badge badge-stale">期限切れ</span></div>' + '<div class="path">手順書 · 担当 鈴木 健</div></div>' + '<span class="when" style="color:var(--app-error);font-weight:600">10日過ぎ</span></div>' + '<div class="row"><span class="rank" style="background:var(--app-warn-soft);color:var(--app-warn)">•</span>' + '<div class="t"><div class="tt"><span class="name">新しいメンバーを迎える手順</span></div>' + '<div class="path">手順書 · 担当 高橋 陽子</div></div>' + '<span class="when">あと5日</span></div></div></div>' + '<div class="gd-frame"><div class="dep-row"><div class="t"><b>担当者</b>' + '<span>内容の相談先として記事に表示されます</span></div>' + '<span class="sel" style="display:inline-flex;align-items:center">高橋 陽子</span></div>' + '<div class="tag-sugg"><span class="muted" style="margin-right:2px">よく使う期限:</span>' + '<span class="tag sm" style="background:var(--app-head);color:var(--app-muted)">3か月後</span>' + '<span class="tag sm" style="background:var(--app-head);color:var(--app-muted)">6か月後</span>' + '<span class="tag sm" style="background:var(--app-head);color:var(--app-muted)">1年後</span></div></div>', '「これ誰に聞けば?」と「この内容はいつまで正しい?」をページに持たせます。', [['編集画面で担当者を選ぶ', '記事の名前の横に「担当 ○○」と出ます'], ['見直し期限を入れる', '過ぎるとホームの一番上と記事上部に警告が出ます'], ['期限が近づく', '担当者に7日前と当日、メールでお知らせします']], '期限はページごとに手で決めます(手順書は短く、用語集は長くなど)。'); html += gdBlock('21', 'gd-dep', '古くなった情報に目印を付ける', '編集画面の右側 / 一覧・記事の上', '「廃止」のスイッチ', '<div class="gd-frame"><div class="dep-row"><div class="t"><b>このページを「廃止」にする</b>' + '<span>一覧では薄く表示され、記事の上に案内が出ます。</span></div>' + '<span class="switch on"><i></i></span></div>' + '<input class="dep-note" value="新しい接続手順に移りました。「在宅勤務の始め方」を見てください" readonly></div>' + '<div class="notice" style="margin:0"><span class="dot" style="flex:none;width:7px;height:7px;border-radius:50%;background:var(--app-warn);margin-top:6px"></span>' + '<div><b style="font-size:13px">この情報は廃止されています</b>' + '<p style="margin:4px 0 0;font-size:12px;color:var(--app-muted);line-height:1.7">新しい接続手順に移りました。「在宅勤務の始め方」を見てください</p></div></div>', '使わなくなった情報も、消してしまうと検索でたどり着いた人が困ります。残したまま「これは古い」と分かるようにします。', [['スイッチを入れる', '一覧で薄くなり「廃止」の目印が付きます'], ['理由と新しいページを書く', '記事の一番上に案内として出ます(本文はそのまま読めます)'], ['検索する', '結果には出ますが、古いと分かる形で出ます']], null); html += gdBlock('22', 'gd-hist', '前の内容に戻す', '記事の右上「履歴」', '履歴の一覧', '<div class="gd-frame flat" style="padding:4px;background:var(--app-surface)">' + '<div class="hist-row on"><b>高橋 陽子</b><span>3日前 12:40</span><span class="now">現在の内容</span></div>' + '<div class="hist-row"><b>鈴木 健</b><span>先週 火曜 09:12</span></div>' + '<div class="hist-row"><b>高橋 陽子</b><span>3週間前 18:05</span></div></div>' + '<div class="gd-frame" style="display:flex;align-items:center;gap:12px">' + '<span style="flex:1;font-size:12px;color:var(--app-muted)">3週間前の版を表示中</span>' + '<button class="btn btn-sm btn-primary">この内容に戻す</button></div>', '保存するたびに前の内容が残ります。誰がいつ何を変えたか見えるので、人のページも安心して直せます。', [['「履歴」を押す', '保存された順に並びます'], ['ひとつ選ぶ', 'その時点の内容が読めます(今の内容は消えません)'], ['「この内容に戻す」', 'その内容に戻せます。戻したことも履歴に残ります']], '間違って直してしまっても、ここから戻せます。'); html += gdBlock('23', 'gd-diff', 'どこが変わったか見る', '履歴の画面の「差分で見る」', '差分表示を入れた状態', '<div class="gd-frame">' + '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">' + '<span class="sw on"><span class="track"><i></i></span>差分で見る</span>' + '<span class="diff-legend" style="margin:0 0 0 auto"><span><i style="background:var(--app-success-soft)"></i>追加</span>' + '<span><i style="background:var(--app-error-soft)"></i>削除</span></span></div>' + '<div class="diff"><div class="ln"><span class="sg"> </span><span class="tx">会議室は予約した人が使えます。</span></div>' + '<div class="ln del"><span class="sg">−</span><span class="tx">予約は紙の予約表に書いてください。</span></div>' + '<div class="ln add"><span class="sg">+</span><span class="tx">予約はカレンダーの「会議室」から入れてください。</span></div>' + '<div class="ln"><span class="sg"> </span><span class="tx">1時間を超える場合は、理由を書きます。</span></div></div></div>', '変わったところだけ色が付きます。全部を読み直さなくても、どこが直ったか分かります。', [['「差分で見る」を入れる', '前の内容との違いだけに色が付きます'], ['緑の行', '足された行です。赤い打ち消し線は消された行です'], ['「差分で見る」を切る', 'その時点の本文をそのまま読めます']], null); html += gdBlock('24', 'gd-lock', '決まった人にだけ見せる', '編集画面の右側 / 一覧の目印', '公開範囲の設定', '<div class="gd-frame"><div class="dep-row"><div class="t"><b>閲覧できる人</b>' + '<span>選んだ人だけが閲覧・編集できます。</span></div>' + '<span class="seg"><button>全員</button><button class="on">指定メンバー</button></span></div>' + '<div class="member-row"><span class="member on fixed">佐藤 美咲(自分)</span><span class="member on">高橋 陽子</span><span class="member">鈴木 健</span></div></div>' + '<div class="tag-row"><span class="badge badge-lock badge-lg">限定</span>' + '<span class="badge badge-draft badge-lg">下書き</span>' + '<span class="badge badge-dep badge-lg">廃止</span>' + '<span class="badge badge-new badge-lg">NEW</span></div>', '評価や採用のように、全員に見せたくないページは、見せる相手を選べます。', [['「指定メンバー」にする', '選んだ人以外の一覧・検索から消えます'], ['人を選ぶ', 'その人だけが開けます。自分は外せません'], ['「限定」の目印', '見える人には、限られたページだと分かります']], 'この wiki は、名前を自分で決めて使う作りです。ほかの人の名前を名乗ることもできるので、「指定メンバー」は鍵ではなく目隠しだと思ってください。' + 'wiki を設置した人は、Google ドライブから全部のページを読めます。人に見られて困る情報は置かないでください。'); /* --------------------------------------------- やり直せる・守る */ html += gdGroup('gg-safe', 'やり直せる・守る', '間違っても戻せると思えれば、人は手を入れられます'); html += gdBlock('25', 'gd-trash', 'ゴミ箱から戻す', 'サイドバーの一番下「ゴミ箱」 / ' + kbd, 'ゴミ箱の画面', '<div class="gd-frame" style="padding:2px 6px">' + '<div class="row"><span class="rank" style="background:var(--app-head);color:var(--app-faint)">✕</span>' + '<div class="t"><div class="tt"><span class="name">古い連絡網</span></div>' + '<div class="path">ルート · 佐藤 美咲 が 2日前に削除</div></div>' + '<span style="flex:none;display:flex;gap:8px"><span class="btn btn-sm">元に戻す</span>' + '<span class="btn btn-sm btn-quiet">完全に削除</span></span></div></div>', '削除してもすぐには消えません。ゴミ箱に残るので、元の場所に戻せます。', [['削除を押す', '一覧と検索からは消えますが、ゴミ箱に残ります'], ['ゴミ箱で「元に戻す」', '元のカテゴリに戻ります(カテゴリが無ければ最上位へ)'], ['「完全に削除」', 'ここだけは戻せません。確認が出ます']], null); html += gdBlock('26', 'gd-undo', '押した直後の取り消し', '画面下のお知らせ', '操作の直後', '<div style="display:flex;justify-content:center"><div class="toast" style="position:static;animation:none;pointer-events:auto">' + '<span class="dot"></span>「手順書」を移動しました' + '<button class="undo">元に戻す</button></div></div>' + '<div style="display:flex;justify-content:center"><div class="toast" style="position:static;animation:none;pointer-events:auto">' + '<span class="dot"></span>「古い連絡網」をゴミ箱に入れました' + '<button class="undo">元に戻す</button></div></div>', 'ドラッグで違う場所に落とした、タグを間違って外した。そういうときは押した直後に戻せます。', [['カテゴリをドラッグで移動', '下に「元に戻す」が出ます(数秒間)'], ['タグを外す・チェックを全部外す', '同じように戻せます'], ['編集画面の一番下で削除', 'その場で戻せます。途中で閉じてもゴミ箱にあります']], null); html += gdBlock('27', 'gd-saving', '保存できたか分かる', '画面のいちばん上 / 画面下のお知らせ', '保存を押した直後', '<div class="gd-frame" style="padding:0;overflow:hidden">' + '<div style="height:2px;background:linear-gradient(90deg,var(--app-accent) 0 38%,transparent 38%)"></div>' + '<div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 14px">' + '<span class="btn btn-sm">やめる</span>' + '<span class="btn btn-sm btn-primary"><span class="spin"></span>保存しています…</span></div></div>' + '<div style="display:flex;justify-content:center;margin-top:10px">' + '<div class="toast" style="position:static;animation:none">' + '<span class="tick">✓</span>保存しました(3人にメールで通知)</div></div>', '「押したのに動いているか分からない」時間を無くしました。通信している間は、画面のいちばん上に細い線が流れます。', [['保存を押している間', 'ボタンが「保存しています…」に変わり、二重に押せなくなります'], ['終わったとき', '画面の下に「✓ 保存しました」と出ます。メールで知らせた人数もここに出ます'], ['時間がかかっているとき', '上の線の色が変わり、「通信が混み合っています」の一文が付きます']], null); html += gdBlock('28', 'gd-mobile', 'スマホで使う', 'スマホのブラウザ', 'スマホで開いたとき', '<div class="gd-frame" style="max-width:250px;margin:0 auto;padding:10px 12px">' + '<div style="display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid var(--app-border)">' + '<span style="font-size:14px;color:var(--app-faint)">≡</span>' + '<b style="font-size:12.5px">' + esc((S.site && S.site.name) || 'Wiki') + '</b>' + '<span class="fav-btn" style="margin-left:auto;height:24px;padding:0 7px;font-size:11px">探す</span>' + '<span class="fav-btn" style="height:24px;padding:0 7px;font-size:11px"><span class="st">★</span>2</span>' + '<span class="btn btn-sm btn-primary" style="height:24px;padding:0 8px;font-size:11px">+</span></div>' + '<div style="padding-top:10px"><b style="font-size:15px;letter-spacing:-.02em">会議室の予約ルール</b>' + '<div style="margin-top:6px;display:flex;gap:6px"><span class="tool" style="height:24px;font-size:11px">☆</span>' + '<span class="tool" style="height:24px;font-size:11px">通知</span>' + '<span class="btn btn-sm btn-primary" style="height:24px;font-size:11px">編集</span></div>' + '<p style="margin:10px 0 0;font-size:12px;line-height:1.8;color:var(--app-muted)">会議室は予約した人が使えます。予約のない時間は…</p></div></div>', '外出先でも、読む・探す・書く・直す・消すまでできます。', [['スマホで開く', 'カテゴリやゴミ箱は ≡ を押すと左から出ます'], ['書く・直す', '右上の + で新しいページ、記事の「編集」で編集画面が開きます。ツールバーは横にずらせます'], ['ページの設定', '編集画面の上の「設定」を押すと、タグ・担当・公開範囲・並び順・削除の欄まで移動します']], '長い文章や表の編集は、画面の広いパソコンのほうが楽です。'); /* -------------------------------------------------- まだできないこと */ html += '<div class="gd-grp" id="gg-out"><h2>まだできないこと</h2><p>要望が多いものから順に足していきます</p></div>' + '<section class="card"><div class="card-bd">' + '<div class="gd-out"><b>見直し期限の自動延長</b><span>「確認しました」ボタンを1回押すと次の期限を自動で入れる。今は編集画面で手入力です</span></div>' + '<div class="gd-out"><b>言い換えの登録</b><span>「会議室」と「ミーティングルーム」のように、違う言い方でも同じページが見つかるようにする</span></div>' + '<div class="gd-out"><b>全ページの書き出し</b><span>バックアップと、ほかのツールへの持ち出し用</span></div>' + '<div class="gd-out"><b>表のセルの結合</b><span>今は行・列の追加と削除、見出しの切り替えまでです</span></div>' + '<div class="gd-out"><b>同じページを同時に編集</b><span>今は片方が保存したときに確認が出ます(11)</span></div>' + '</div></section>' + '<p class="muted" style="margin:20px 0 0">使っていて困ったところ・分かりにくいところは、そのページのコメントに書いてください。</p>'; return html + '</div>';}/* ---------------------------------------------------- ウェルカムページ */function viewHome() { var now = new Date(); var wd = ['日', '月', '火', '水', '木', '金', '土'][now.getDay()]; var hour = now.getHours(); var greet = (hour < 11 ? 'おはようございます、' : hour < 18 ? 'こんにちは、' : 'おつかれさまです、') + who(S.user) + ' さん'; var head = '<h1 class="greet">' + esc(greet) + '</h1>'; if (!S.pages.length) { return '<div class="wrap">' + head + '<p class="sub">' + now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日(' + wd + ')</p>' + '<div class="card"><div class="empty"><b>まだページがありません</b>' + '<p>最初の1ページが、探せる場所のはじまりになります。</p>' + '<p style="margin-top:16px"><button class="btn btn-primary" data-act="new">最初のページをつくる</button></p></div></div></div>'; } var week = S.pages.filter(function (p) { return Date.now() - new Date(p.updated_at).getTime() < 604800000; }).length; var recent = S.pages.slice().sort(function (a, b) { return String(b.updated_at).localeCompare(String(a.updated_at)); }); var pop = popularSorted(); var max = pop.length ? S.popular[pop[0].id] : 1; var favs = favorites(); var drafts = S.pages.filter(function (p) { return p.status === 'draft' && p.created_by === S.user; }); var stale = staleList(); return '<div class="wrap">' + head + '<p class="sub">' + now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日(' + wd + ') · 全' + S.pages.length + 'ページ・今週' + week + '件更新' + (drafts.length ? ' · <span style="color:var(--app-warn)">下書き' + drafts.length + '件</span>' : '') + '</p>' + '<div class="home">' + '<section class="card home-cats"><div class="card-hd"><h2>カテゴリ</h2>' + '<span class="sub">全' + S.pages.length + 'ページ</span></div><div class="cat-grid">' + cats().map(function (c) { var ch = kids(c.id); var over = ch.concat([c]).filter(function (x) { return dueState(x) === 'over'; }).length; return '<div class="cat-card" data-act="open" data-id="' + c.id + '">' + '<div class="cc-t">' + esc(c.title) + '</div>' + '<div class="cc-n"><span>' + ch.length + ' ページ</span>' + (over ? '<span class="cc-w">見直し ' + over + '</span>' : '') + '</div></div>'; }).join('') + '</div></section>' + (stale.length ? '<section class="card home-stale"><div class="card-hd"><h2>見直しが必要なページ</h2>' + '<span class="sub">期限切れ ' + stale.filter(function (p) { return dueState(p) === 'over'; }).length + '件・間もなく ' + stale.filter(function (p) { return dueState(p) === 'soon'; }).length + '件</span>' + '<span class="more" data-act="stale">すべて見る →</span></div><div class="card-bd">' + stale.slice(0, 4).map(staleRowHtml).join('') + '</div>' + '<div class="card-ft">担当者には、期限の7日前と当日にメールでお知らせします</div></section>' : '') + (favs.length ? '<section class="card home-fav"><div class="card-hd"><h2>お気に入り</h2>' + '<span class="sub">自分がよく開くページ</span></div><div class="card-bd">' + favs.map(function (p) { return '<div class="row" data-act="open" data-id="' + p.id + '">' + '<span class="rank top" style="background:var(--app-warn-soft);color:var(--app-star)">★</span>' + '<div class="t"><div class="tt"><span class="name">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="path">' + esc(pathLabel(p.id)) + '</div></div>' + '<span class="when">' + rel(p.updated_at) + '</span></div>'; }).join('') + '</div></section>' : '') + '<div class="grid2 home-recent">' + '<section class="card"><div class="card-hd"><h2>最近更新された記事</h2><span class="sub">上位5件</span>' + '<span class="more" data-act="recent">すべて見る →</span></div><div class="card-bd">' + recent.slice(0, 5).map(rowHtml).join('') + '</div></section>' + '<section class="card"><div class="card-hd"><h2>よく読まれている記事</h2><span class="sub">直近30日・上位5件</span>' + '<span class="more" data-act="popular">すべて見る →</span></div><div class="card-bd">' + (pop.length ? pop.slice(0, 5).map(function (p, i) { return popRowHtml(p, i, max); }).join('') : '<div class="empty"><b>まだ集計データがありません</b><p>ページが開かれると、ここに並びます。</p></div>') + '</div></section>' + '</div>' + '<section class="card home-list" id="homeList">' + homeListInner() + '</section>' + '</div></div>';}/* 絞り込みの入力中は一覧だけを描き直す(入力欄ごと描き直すと変換中の文字が消える) */function homeListInner() { var lf = S.listFilter.trim().toLowerCase(); var listed = S.pages.filter(function (p) { if (S.listTag && (p.tags || []).indexOf(S.listTag) < 0) return false; if (!lf) return true; return (p.title + ' ' + pathLabel(p.id) + ' ' + (p.tags || []).join(' ')).toLowerCase().indexOf(lf) >= 0; }); listed.sort(function (a, b) { return pathLabel(a.id).localeCompare(pathLabel(b.id), 'ja') || a.sort_order - b.sort_order; }); var tags = visibleTags(); return '<div class="card-hd"><h2>ページ一覧</h2>' + '<span class="sub" id="listCount">' + (lf || S.listTag ? listed.length + '件が一致' : '全' + listed.length + '件') + '</span>' + '<span class="list-search"><span class="search-icon"></span>' + '<input id="listFilter" type="search" placeholder="絞り込む(Enterで全文検索)" value="' + esc(S.listFilter) + '" autocomplete="off"></span></div>' + (tags.length ? '<div class="tag-row" style="padding:10px 16px;border-bottom:1px solid var(--app-border)">' + '<span class="muted" style="margin-right:2px">タグ:</span>' + '<span class="tag' + (S.listTag ? '' : ' on') + '" data-act="listTag" data-id="">すべて</span>' + tags.map(function (t) { var c = tagColor(t.name); return '<span class="tag' + (S.listTag === t.name ? ' on' : '') + '" style="background:' + c[0] + ';color:' + c[1] + '" data-act="listTag" data-id="' + esc(t.name) + '">' + esc(t.name) + ' <b style="opacity:.6;font-weight:500">' + t.count + '</b></span>'; }).join('') + '</div>' : '') + '<div class="card-bd" id="listBody">' + homeListRows(listed) + '</div>' + '<div class="card-ft" style="display:flex;align-items:center;gap:10px">' + '<span id="listHint">' + (lf ? 'Enter で本文と添付ファイルの名前まで検索' : 'タイトル・カテゴリ・タグで絞り込み') + '</span>' + '<span style="margin-left:auto;display:flex;gap:12px">' + '<span class="more" data-act="trash">ゴミ箱' + (S.trashCount ? '(' + S.trashCount + ')' : '') + '</span></span>' + '</div>';}function homeListRows(listed) { return listed.length ? listed.map(listRowHtml).join('') : '<div class="empty"><b>一致するページはありません</b><p>Enter を押すと、本文と添付ファイルの名前まで検索します。</p></div>';}function updateHomeList() { var body = document.getElementById('listBody'); if (!body) return; var lf = S.listFilter.trim().toLowerCase(); var listed = S.pages.filter(function (p) { if (S.listTag && (p.tags || []).indexOf(S.listTag) < 0) return false; if (!lf) return true; return (p.title + ' ' + pathLabel(p.id) + ' ' + (p.tags || []).join(' ')).toLowerCase().indexOf(lf) >= 0; }).sort(function (a, b) { return pathLabel(a.id).localeCompare(pathLabel(b.id), 'ja') || a.sort_order - b.sort_order; }); body.classList.remove('stagger'); body.innerHTML = homeListRows(listed); document.getElementById('listCount').textContent = lf || S.listTag ? listed.length + '件が一致' : '全' + listed.length + '件'; document.getElementById('listHint').textContent = lf ? 'Enter で本文と添付ファイルの名前まで検索' : 'タイトル・カテゴリ・タグで絞り込み';}function staleRowHtml(p) { var over = dueState(p) === 'over'; return '<div class="row" data-act="open" data-id="' + p.id + '">' + '<span class="rank' + (over ? ' over' : '') + '" style="' + (over ? 'background:var(--app-error-soft);color:var(--app-error)' : 'background:var(--app-warn-soft);color:var(--app-warn)') + '">' + (over ? '!' : '•') + '</span>' + '<div class="t"><div class="tt"><span class="name">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="path">' + esc(pathLabel(p.id)) + ' · 担当 ' + esc(who(p.owner)) + '</div></div>' + '<span class="when" style="' + (over ? 'color:var(--app-error);font-weight:600' : '') + '">' + dueLabelHtml(p) + '</span></div>';}/** 狭い画面では日付を隠して「あと○日」だけ出せるように分けておく */function dueLabelHtml(p) { var d = dueDays(p), date = String(p.review_due || '').replace(/-/g, '.'); if (!date) return ''; if (d > 30) return esc(date); return '<span class="dt">' + esc(date) + '</span>' + (d < 0 ? '(' + Math.abs(d) + '日過ぎ)' : '(あと' + d + '日)');}/* ------------------------------------- 見直しが必要なページ / ゴミ箱 */function crumbsOf(label) { return '<div class="crumbs"><span data-act="home">ホーム</span><span class="sep">/</span><span style="color:var(--app-ink)">' + label + '</span></div>';}function viewStale() { var list = staleList(); var mine = list.filter(function (p) { return p.owner === S.user; }); return '<div class="wrap-narrow">' + crumbsOf('見直しが必要なページ') + '<h1 class="sec-h">見直しが必要なページ</h1>' + '<p class="muted" style="margin:0 0 18px">' + list.length + '件・期限の近い順' + (mine.length ? ' · <span style="color:var(--app-warn)">自分が担当 ' + mine.length + '件</span>' : '') + '</p>' + '<div class="card"><div class="card-bd">' + (list.length ? list.map(staleRowHtml).join('') : '<div class="empty"><b>期限切れのページはありません</b><p>全部のページが見直し済みです。</p></div>') + '</div><div class="card-ft">見直しが終わったら、編集画面で次の期限を設定してください</div></div></div>';}function viewTrash() { var items = S.trash; return '<div class="wrap-narrow">' + crumbsOf('ゴミ箱') + '<h1 class="sec-h">ゴミ箱</h1>' + '<p class="muted" style="margin:0 0 18px">削除したページはここに残ります。元の場所に戻せます。</p>' + '<div class="card"><div class="card-bd">' + (items === null ? '<div class="sk" style="height:52px;margin:8px;border-radius:var(--app-r-sm)"></div>' : items.length ? items.map(function (t) { return '<div class="row">' + '<span class="rank" style="background:var(--app-head);color:var(--app-faint)">✕</span>' + '<div class="t"><div class="tt"><span class="name">' + esc(t.title) + '</span></div>' + '<div class="path">' + esc(who(t.deletedBy)) + ' が ' + rel(t.deletedAt) + 'に削除</div></div>' + '<span style="flex:none;display:flex;gap:8px">' + '<button class="btn btn-sm" data-act="restore" data-id="' + t.id + '">元に戻す</button>' + '<button class="btn btn-sm btn-quiet" data-act="purge" data-id="' + t.id + '">完全に削除</button></span></div>'; }).join('') : '<div class="empty"><b>ゴミ箱は空です</b><p>削除したページはここから戻せます。</p></div>') + '</div><div class="card-ft">完全に削除すると、添付ファイルも含めて戻せなくなります</div></div></div>';}/* ------------------------------------------------------------ 一覧 */function viewRecent() { var recent = S.pages.slice().sort(function (a, b) { return String(b.updated_at).localeCompare(String(a.updated_at)); }); return '<div class="wrap-narrow">' + crumbsOf('最近更新された記事') + '<h1 class="sec-h">最近更新された記事</h1><p class="muted" style="margin:0 0 18px">' + recent.length + '件・更新の新しい順</p>' + '<div class="card"><div class="card-bd">' + recent.map(function (p) { var av = avatarOf(who(p.updated_by)); return '<div class="row' + (p.deprecated ? ' dep' : '') + '" data-act="open" data-id="' + p.id + '">' + '<span class="av" style="background:' + av.bg + ';color:' + av.fg + '">' + esc(av.initial) + '</span>' + '<div class="t"><div class="tt"><span class="name">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="path">' + esc(pathLabel(p.id)) + ' · ' + esc(who(p.updated_by)) + '</div></div>' + '<span class="when" style="text-align:right">' + rel(p.updated_at) + '<br><span style="font-family:var(--app-mono);font-size:10.5px">' + stamp(p.updated_at) + '</span></span></div>'; }).join('') + '</div></div></div>';}function viewPopular() { var pop = popularSorted(); var max = pop.length ? S.popular[pop[0].id] : 1; return '<div class="wrap-narrow">' + crumbsOf('よく読まれている記事') + '<h1 class="sec-h">よく読まれている記事</h1><p class="muted" style="margin:0 0 18px">' + pop.length + '件・直近30日の閲覧数順</p>' + '<div class="card"><div class="card-bd">' + (pop.length ? pop.map(function (p, i) { return popRowHtml(p, i, max); }).join('') : '<div class="empty"><b>まだ集計データがありません</b><p>ページが開かれると、ここに並びます。</p></div>') + '</div></div></div>';}function viewTag() { var list = S.pages.filter(function (p) { return (p.tags || []).indexOf(S.tagName) >= 0; }) .sort(function (a, b) { return String(b.updated_at).localeCompare(String(a.updated_at)); }); return '<div class="wrap-narrow">' + crumbsOf('タグ') + '<h1 class="sec-h">' + tagHtml(S.tagName) + ' のページ</h1>' + '<p class="muted" style="margin:0 0 18px">' + list.length + '件</p>' + '<div class="card"><div class="card-bd">' + (list.length ? list.map(rowHtml).join('') : '<div class="empty"><b>ページがありません</b><p>このタグは使われていません。</p></div>') + '</div></div></div>';}/* ------------------------------------------------------------ 閲覧 */function viewPage() { var p = byId(S.pageId); if (!p) return '<div class="wrap-narrow"><div class="empty"><b>ページが見つかりません</b><p>削除された可能性があります。</p></div></div>'; var c = S.bodyCache[p.id]; var crumbs = chain(p.id).map(function (x, i, arr) { return '<span data-act="open" data-id="' + x.id + '"' + (i === arr.length - 1 ? ' style="color:var(--app-ink)"' : '') + '>' + esc(x.title) + '</span>' + (i < arr.length - 1 ? '<span class="sep">/</span>' : ''); }).join(''); var body, toc = ''; if (!c) { body = '<div class="sk" style="height:14px;margin-bottom:14px"></div>' + '<div class="sk" style="height:14px;width:88%;margin-bottom:14px"></div>' + '<div class="sk" style="height:14px;width:94%;margin-bottom:14px"></div>' + '<div class="sk" style="height:14px;width:70%"></div>'; } else { var built = buildBody(c.bodyHtml); body = built.html; toc = built.toc.map(function (t) { return '<a data-act="toc" data-id="' + t.id + '" class="' + (t.level === 'h3' ? 'lv3' : '') + (S.activeToc === t.id ? ' on' : '') + '">' + esc(t.text) + '</a>'; }).join(''); } var av = avatarOf(who(p.updated_by)); var att = c ? c.attachments || [] : []; return '<div class="wrap-wide">' + '<div class="crumbs">' + crumbs + '</div>' + '<div class="view-grid"><div>' + '<article class="article">' + (p.status === 'draft' ? '<div class="notice"><span class="dot"></span><div>' + '<b>下書き — 自分にだけ見えています</b><p>公開すると、サイドバーと検索結果に出るようになります。' + '<button class="btn btn-sm" style="margin-left:10px" data-act="publish">公開する</button></p></div></div>' : '') + (p.deprecated ? '<div class="notice"><span class="dot"></span><div><b>この情報は廃止されています</b><p>' + esc(p.deprecated_note || 'この内容は現在使われていません。参照する前に担当者へ確認してください。') + '</p></div></div>' : '') + (dueState(p) === 'over' && !p.deprecated ? '<div class="notice"><span class="dot"></span><div>' + '<b>見直し期限を' + Math.abs(dueDays(p)) + '日過ぎています</b><p>内容が今でも正しいか、担当の ' + esc(who(p.owner)) + ' さんに確認してください。' + '<button class="btn btn-sm" style="margin-left:10px" data-act="edit">確認して期限を更新</button></p></div></div>' : '') + (badges(p, true) ? '<div class="chips">' + badges(p, true) + '</div>' : '') + '<div class="head"><h1 class="page-h"' + (p.deprecated ? ' style="color:var(--app-faint)"' : '') + '>' + esc(p.title) + '</h1>' + '<div class="art-tools">' + '<button class="tool' + (p.favorite ? ' on' : '') + '" data-act="fav" title="お気に入り">' + (p.favorite ? '★' : '☆') + '</button>' + '<button class="tool watch' + (p.watching ? ' on' : '') + '" data-act="watch" title="更新をメールで受け取る">' + (p.watching ? '通知オン' : '通知') + '</button>' + '<button class="tool" data-act="history" title="更新履歴と差分">履歴' + (p.revision_count ? ' ' + p.revision_count : '') + '</button>' + '<button class="tool" data-act="print" title="印刷 / PDF">印刷</button>' + '<button class="btn btn-sm btn-primary" data-act="edit">編集</button>' + '</div></div>' + // 読む前に要るのは「いつ・誰が直したか」「誰に聞くか」「まだ正しいか」だけ。数字は記事の下に回す '<div class="meta"><span class="av" style="width:22px;height:22px;background:' + av.bg + ';color:' + av.fg + '">' + esc(av.initial) + '</span>' + '<span title="' + esc(stamp(p.updated_at)) + '">' + esc(who(p.updated_by)) + ' が ' + rel(p.updated_at) + 'に更新</span>' + (p.owner ? '<span class="meta-dot">·</span><span class="stamp">担当 ' + esc(who(p.owner)) + '</span>' : '') + (p.review_due ? '<span class="meta-dot">·</span><span class="stamp ' + (dueState(p) === 'over' ? 'over' : dueState(p) === 'soon' ? 'soon' : '') + '">見直し ' + esc(dueLabel(p)) + '</span>' : '') + '</div>' + ((p.tags || []).length ? '<div class="tag-row" style="margin-top:14px">' + p.tags.map(function (t) { return tagHtml(t); }).join('') + '</div>' : '') + '<div class="body wiki-body" id="body">' + body + '</div>' + (c ? checkFootHtml(c) : '') + (c ? reactionsHtml(c.reactions || []) : '') + '<div class="art-stats"><span>約' + (p.read_minutes || 1) + '分で読めます</span>' + (p.week_viewers ? '<span>30日で ' + p.week_viewers + '人が参照</span>' : '') + (p.watcher_count ? '<span>' + p.watcher_count + '人が通知を受け取っています</span>' : '') + '<span>最終更新 ' + stamp(p.updated_at) + '</span></div>' + '<div class="print-foot">' + esc((S.site && S.site.name) || 'Wiki') + ' — ' + esc(pathLabel(p.id)) + ' / ' + esc(p.title) + ' 最終更新 ' + stamp(p.updated_at) + '(' + esc(who(p.updated_by)) + ')</div>' + '</article>' + (att.length ? '<section class="card" style="margin-top:20px"><div class="card-hd"><h2>添付ファイル</h2></div><div class="card-bd">' + att.map(function (a) { return '<div class="file-row"><span class="ext">' + esc(ext(a.name)) + '</span>' + '<a class="fname" href="#" data-act="download" data-id="' + esc(a.id) + '">' + esc(a.name) + '</a>' + '<span class="fsize">' + esc(sizeLabel(a.size)) + '</span></div>'; }).join('') + '</div></section>' : '') + indexSection(p) + (c && (c.backlinks || []).length ? '<section class="card" style="margin-top:20px"><div class="card-hd"><h2>このページを参照しているページ</h2>' + '<span class="sub">' + c.backlinks.length + '件・消す前にここを確認</span></div><div class="card-bd">' + c.backlinks.map(function (r) { return '<div class="rel-row" data-act="open" data-id="' + r.id + '"><span style="flex:none;font-size:11px;color:var(--app-faint)">←</span>' + '<span class="nm">' + esc(r.title) + '</span><span class="rs">' + esc(pathLabel(r.id)) + '</span></div>'; }).join('') + '</div></section>' : '') + (c ? commentsHtml(c.comments || []) : '') + (c && (c.related || []).length ? '<section class="rel-quiet"><div class="rq-hd"><h3>関連ページ</h3>' + '<span class="sub">一緒に見られているページ</span></div>' + c.related.map(function (r) { return '<div class="rel-row" data-act="open" data-id="' + r.id + '"><span class="sq" style="flex:none;width:12px;height:12px;border-radius:calc(var(--app-r-sm) / 2);border:1.5px solid var(--app-faint);opacity:.7"></span>' + '<span class="nm">' + esc(r.title) + '</span><span class="rs">' + esc(r.reason) + '</span></div>'; }).join('') + '</section>' : '') + '</div>' + '<aside class="toc">' + (toc ? '<div class="cap">このページの目次</div>' + toc : '') + '</aside>' + '</div></div>';}function reactionsHtml(list) { return '<div class="reactions">' + list.map(function (r) { return '<span class="react' + (r.mine ? ' mine' : '') + '" data-act="react" data-id="' + esc(r.emoji) + '">' + esc(r.emoji) + '<b>' + r.count + '</b></span>'; }).join('') + (S.reactPop ? '<span class="react-pop">' + EMOJI.map(function (e) { return '<span data-act="react" data-id="' + e + '">' + e + '</span>'; }).join('') + '</span>' : '<span class="react react-add" data-act="reactPop" title="リアクションを追加">+</span>') + '</div>';}/** @名前 を強調する。名前はメンバー表にあるものだけ */function mentionHtml(text) { var out = esc(text); S.members.forEach(function (m) { var at = '@' + esc(m.name); if (out.indexOf(at) >= 0) out = out.split(at).join('<b class="mention">' + at + '</b>'); }); return out;}function commentsHtml(list) { var open = list.filter(function (c) { return !c.resolved; }); var mine = avatarOf(who(S.user)); return '<section class="card" style="margin-top:20px"><div class="card-hd"><h2>コメント</h2>' + '<span class="sub">' + (open.length ? open.length + '件が未解決' : '未解決なし') + '</span></div>' + '<div class="card-bd">' + (list.length ? list.map(function (c) { var av = avatarOf(who(c.by)); return '<div class="cmt' + (c.resolved ? ' done' : '') + '">' + '<span class="av" style="background:' + av.bg + ';color:' + av.fg + '">' + esc(av.initial) + '</span>' + '<div class="bd"><div class="hd"><b>' + esc(who(c.by)) + '</b><span>' + rel(c.at) + '</span>' + '<span class="act" data-act="resolve" data-id="' + c.id + '">' + (c.resolved ? '未解決に戻す' : '解決にする') + '</span></div>' + '<p>' + mentionHtml(c.text) + '</p></div></div>'; }).join('') : '<div class="empty" style="padding:26px 20px"><b>コメントはありません</b>' + '<p>直す前に「ここ古いですよね?」と置いておく場所です。</p></div>') + '<div class="cmt-form"><span class="av" style="background:' + mine.bg + ';color:' + mine.fg + '">' + esc(mine.initial) + '</span>' + '<div class="field">' + '<textarea id="cmtText" placeholder="気づいたこと、直したほうがよさそうな点(@ で人を呼べます)"></textarea>' + '<div class="mention-pop" id="mentionPop"></div></div></div>' + '<div class="cmt-foot"><button class="btn btn-sm btn-primary" data-act="addComment">コメントする</button>' + '<span class="hint">ウォッチしている人と、@ で呼んだ人にメールで知らせます</span></div>' + '</div></section>';}/** 見出しに id とアンカーリンクを差し込み、目次を作る */function buildBody(html) { var toc = []; var checks = (S.bodyCache[S.pageId] || {}).checks || {}; var ci = -1; var out = sanitize(html).replace(/<li data-list="(checked|unchecked)"([^>]*)>/g, function () { ci++; var on = !!checks[ci]; return '<li class="ck-li' + (on ? ' on' : '') + '" data-act="check" data-id="' + ci + '">' + '<span class="ckbox">' + (on ? '✓' : '') + '</span>'; }); out = out.replace(/<(h2|h3)([^>]*)>([\s\S]*?)<\/\1>/g, function (m, tag, attrs, inner) { var id = 'sec-' + (toc.length + 1); toc.push({ id: id, level: tag, text: inner.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim() }); return '<' + tag + ' id="' + id + '">' + inner + '<a class="hd-anchor" data-act="anchor" data-id="' + id + '" title="この見出しへのリンク">#</a></' + tag + '>'; }); return { html: out || '<p style="color:var(--app-faint)">本文はまだありません。</p>', toc: toc, checkCount: ci + 1 };}function checkFootHtml(c) { var total = (String(c.bodyHtml || '').match(/data-list="(checked|unchecked)"/g) || []).length; if (!total) return ''; var done = Object.keys(c.checks || {}).filter(function (k) { return Number(k) < total; }).length; return '<div class="ck-foot"><span class="bar"><i style="width:' + Math.round(done / total * 100) + '%"></i></span>' + '<span class="n">' + done + ' / ' + total + ' 完了</span>' + '<span class="note">チェックは自分にだけ見えます</span>' + (done ? '<span class="act" data-act="resetChecks">全部外す</span>' : '') + '</div>';}/* ------------------------------------------------------------ 編集 */function activeMembers() { return S.members.slice(); }function viewEdit() { var p = byId(S.pageId); if (!p || !S.draft) return '<div class="wrap-narrow"><div class="empty"><b>編集できません</b><p>ページを開き直してください。</p></div></div>'; var d = S.draft; var loc = d.parentId ? chain(d.parentId).map(function (x) { return x.title; }).join(' / ') : 'ルート'; var depth = (d.parentId ? chain(d.parentId).length : 0) + 1; var known = visibleTags().map(function (t) { return t.name; }).filter(function (t) { return d.tags.indexOf(t) < 0; }); var members = S.members.slice(); if (d.owner && !members.some(function (m) { return m.id === d.owner; })) { members = members.concat([{ id: d.owner, name: who(d.owner) }]); } return '<div class="edit-shell">' + '<div class="edit-main">' + '<div class="edit-top">' + '<span class="where">保存先:<b>' + esc(loc) + '</b>' + '<span class="muted">(' + (depth <= 1 ? 'カテゴリ' : 'カテゴリの中のページ') + ')</span></span>' + '<span class="chg" data-act="pickParent">場所を変える</span>' + '<span class="sp"></span>' + '<input class="memo" id="changeNote" maxlength="120" placeholder="何を直したか(任意・履歴に残ります)" value="' + esc(d.changeNote || '') + '">' + '<span class="acts">' + '<button class="btn to-side" data-act="toSide">設定</button>' + '<button class="btn" data-act="cancel">やめる</button>' + (d.status === 'draft' ? '<button class="btn" data-act="save">下書きを保存</button>' + '<button class="btn btn-primary" data-act="publishEdit">公開する</button>' : '<button class="btn btn-primary" data-act="save">保存する</button>') + '</span>' + '</div>' + '<div class="edit-tools" id="editTools"></div>' + '<div class="edit-canvas" id="editCanvas"><div class="edit-paper">' + '<input class="title-input" id="title" maxlength="120" value="' + esc(d.title) + '" placeholder="タイトル">' + (d.tags.length ? '<div class="tag-line">' + d.tags.map(function (t) { var c = tagColor(t); return '<span class="tag sm" style="background:' + c[0] + ';color:' + c[1] + '">' + esc(t) + '</span>'; }).join('') + '</div>' : '') + '<div id="editor"></div>' + '</div></div>' + '<div class="edit-foot"><span id="editCount">0文字</span>' + '<span>' + (d.status === 'draft' ? '下書き(自動保存あり)' : '公開中(保存は手動)') + '</span>' + '<span id="autoState" style="color:var(--app-accent)"></span>' + '<span class="st' + (S.dirty ? ' dirty' : '') + '" id="editState">' + (S.dirty ? '未保存の変更があります' : '変更なし') + '</span>' + '</div>' + '</div>' + '<aside class="edit-side">' + '<div class="side-h">このページの設定</div>' + '<section class="panel first"><h2>タグ</h2>' + '<div class="tag-input-box" data-act="focusTag">' + d.tags.map(function (t) { var c = tagColor(t); return '<span class="tag-chip" style="background:' + c[0] + ';color:' + c[1] + '"><b>' + esc(t) + '</b>' + '<i data-act="rmTag" data-id="' + esc(t) + '">×</i></span>'; }).join('') + '<input id="tagInput" placeholder="' + (d.tags.length ? '追加(Enter)' : '例: 手順 Enterで追加') + '" autocomplete="off"></div>' + (known.length ? '<div class="tag-sugg"><span class="muted" style="margin-right:2px">よく使うタグ:</span>' + known.slice(0, 10).map(function (t) { var c = tagColor(t); return '<span class="tag sm" style="background:' + c[0] + ';color:' + c[1] + '" data-act="addTag" data-id="' + esc(t) + '">+ ' + esc(t) + '</span>'; }).join('') + '</div>' : '') + '</section>' + '<section class="panel"><h2>担当者と見直し期限</h2>' + '<div class="dep-row"><div class="t"><b>担当者</b>' + '<span>内容の相談先として記事に表示されます</span></div>' + '<select class="sel" id="ownerSel">' + members.map(function (m) { return '<option value="' + esc(m.id) + '"' + (d.owner === m.id ? ' selected' : '') + '>' + esc(m.name) + (m.id === S.user ? '(自分)' : '') + '</option>'; }).join('') + '</select></div>' + '<div class="dep-row" style="margin-top:12px"><div class="t"><b>次の見直し期限</b>' + '<span>過ぎるとホームの「見直しが必要なページ」に並びます</span></div>' + '<input type="date" class="sel" id="reviewDue" value="' + esc(d.reviewDue || '') + '"></div>' + '<div class="tag-sugg"><span class="muted" style="margin-right:2px">よく使う期限:</span>' + [['3か月後', 90], ['6か月後', 182], ['1年後', 365], ['期限なし', -1]].map(function (o) { return '<span class="tag sm" style="background:var(--app-head);color:var(--app-muted)" data-act="setDue" data-id="' + o[1] + '">' + o[0] + '</span>'; }).join('') + '</div>' + '</section>' + '<section class="panel"><div class="dep-row"><div class="t">' + (d.status === 'draft' ? '<b>公開状態:下書き</b><span>自分にだけ見えます。上の「公開する」を押すと、全員の一覧と検索結果に出ます。</span></div>' : '<b>公開状態:公開中</b><span>全員の一覧と検索結果に出ています。</span></div>' + '<button class="btn btn-sm" data-act="toDraft">下書きに戻す</button>') + '</div></section>' + '<section class="panel"><div class="dep-row"><div class="t">' + '<b>閲覧できる人</b><span>' + (d.visibility === 'members' ? '選んだ人だけが閲覧・編集できます。' : 'メンバー全員が閲覧・編集できます。') + '</span></div>' + '<span class="seg"><button class="' + (d.visibility === 'members' ? '' : 'on') + '" data-act="vis" data-id="all">全員</button>' + '<button class="' + (d.visibility === 'members' ? 'on' : '') + '" data-act="vis" data-id="members">指定メンバー</button></span></div>' + (d.visibility === 'members' ? '<div class="member-row">' + activeMembers().map(function (m) { var mine = m.id === S.user; var on = d.members.indexOf(m.id) >= 0 || mine; return '<span class="member' + (on ? ' on' : '') + (mine ? ' fixed' : '') + '"' + (mine ? '' : ' data-act="member" data-id="' + esc(m.id) + '"') + '>' + esc(m.name) + (mine ? '(自分)' : '') + '</span>'; }).join('') + '</div>' : '') + '</section>' + '<section class="panel"><div class="dep-row"><div class="t">' + '<b>このページを「廃止」にする</b><span>内容は消さずに残したまま、一覧と閲覧画面に廃止チップと注意書きを表示します。</span></div>' + '<span class="switch' + (d.deprecated ? ' on' : '') + '" data-act="dep"><i></i></span></div>' + (d.deprecated ? '<input class="dep-note" id="depNote" maxlength="120" value="' + esc(d.note) + '" placeholder="代わりに参照するページや、廃止の理由(任意)">' : '') + '</section>' + '<section class="panel"><h2>添付ファイル</h2>' + d.attachments.map(function (a) { return '<div class="file-row"><span class="ext">' + esc(ext(a.name)) + '</span>' + '<span class="fname">' + esc(a.name) + '</span>' + '<span class="fsize">' + esc(sizeLabel(a.size)) + '</span>' + '<span class="rm" data-act="rmFile" data-id="' + esc(a.id) + '">削除</span></div>'; }).join('') + '<div class="drop" id="drop"><b>ここにファイルをドラッグ、またはクリックして選ぶ</b><span>1ファイル10MBまで</span></div>' + '</section>' + orderPanelHtml(p, d) + (S.newPageId === p.id ? '' : '<section class="panel"><div class="dep-row"><div class="t">' + '<b>このページを削除</b><span>ゴミ箱に入ります。あとから元に戻せます。</span></div>' + '<button class="btn btn-sm btn-quiet danger" data-act="askDelete">削除</button></div></section>') + '</aside></div>';}/** 並び順。ドラッグできない端末(スマホ・キーボード)でも並べ替えられるようにする */function orderPanelHtml(p, d) { var sibs = kids(p.parent_id || ''); var at = sibs.map(function (x) { return x.id; }).indexOf(p.id); if (sibs.length < 2 || at < 0) return ''; var moved = (d.parentId || '') !== (p.parent_id || ''); return '<section class="panel"><div class="dep-row"><div class="t">' + '<b>並び順</b><span>' + (moved ? '場所を変えたので、保存してから並べ替えてください。' : (p.parent_id ? 'カテゴリの中で ' : 'カテゴリの並びで ') + (at + 1) + ' / ' + sibs.length + ' 番目。すぐに反映されます。') + '</span></div>' + '<span class="seg">' + '<button data-act="orderUp"' + (moved || at === 0 ? ' disabled' : '') + ' aria-label="上へ">↑ 上へ</button>' + '<button data-act="orderDown"' + (moved || at === sibs.length - 1 ? ' disabled' : '') + ' aria-label="下へ">↓ 下へ</button>' + '</span></div></section>';}/** 編集中の内容をDOMから draft に取り込む(再描画で失わないため) */function syncDraft() { if (!S.draft) return; if (S.quill) S.draft.html = stripImageData(S.quill.root.innerHTML); var t = document.getElementById('title'); if (t) S.draft.title = t.value; var n = document.getElementById('depNote'); if (n) S.draft.note = n.value; var o = document.getElementById('ownerSel'); if (o) S.draft.owner = o.value; var rd = document.getElementById('reviewDue'); if (rd) S.draft.reviewDue = rd.value; var cn = document.getElementById('changeNote'); if (cn) S.draft.changeNote = cn.value;}/* ------------------------------------------------------------ 検索 */function viewSearch() { if (S.searching) { return '<div class="wrap-narrow"><h1 class="sec-h">「' + esc(S.term) + '」の検索結果</h1>' + '<p class="muted" style="margin:0 0 20px">検索しています…</p><div class="card"><div class="card-bd">' + '<div class="sk" style="height:52px;margin:8px;border-radius:var(--app-r-sm)"></div>' + '<div class="sk" style="height:52px;margin:8px;border-radius:var(--app-r-sm)"></div>' + '<div class="sk" style="height:52px;margin:8px;border-radius:var(--app-r-sm)"></div></div></div></div>'; } var res = S.results || []; return '<div class="wrap-narrow"><h1 class="sec-h">「' + esc(S.term) + '」の検索結果</h1>' + '<p class="muted" style="margin:0 0 20px">' + (res.length ? res.length + '件・本文と添付ファイルの名前を検索' : '') + '</p>' + '<div class="card"><div class="card-bd">' + (res.length ? res.map(function (r) { var p = byId(r.id) || {}; var chip = r.hitBody ? '<span class="badge chip-body">本文</span>' : r.hitFiles.length ? '<span class="badge chip-file">添付 ' + esc(r.hitFiles[0]) + '</span>' : r.hitTitle ? '' : '<span class="badge badge-draft">タグ</span>'; return '<div class="result" data-act="open" data-id="' + r.id + '">' + '<div class="rt"><b>' + mark(r.title, S.term) + '</b>' + chip + (p.deprecated ? '<span class="badge badge-dep">廃止</span>' : '') + (p.status === 'draft' ? '<span class="badge badge-draft">下書き</span>' : '') + (p.visibility === 'members' ? '<span class="badge badge-lock">限定</span>' : '') + '</div>' + '<div class="rp">' + esc(pathLabel(r.id)) + '</div>' + '<div class="rs">' + mark(r.snippet, S.term) + '</div></div>'; }).join('') : '<div class="empty"><b>「' + esc(S.term) + '」は見つかりませんでした</b>' + '<p>言い方が違うだけかもしれません。' + ((S.searchSuggest || []).length ? '似たページを下に出しました。' : '') + '</p>' + '<p style="margin-top:16px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap">' + '<button class="btn btn-primary" data-act="newFromTerm">「' + esc(S.term) + '」のページをつくる</button></p></div>') + '</div>' + (!res.length && (S.searchSuggest || []).length ? '<div class="card-ft" style="border-top:1px solid var(--app-border)">このあたりを探していましたか?</div>' + '<div class="card-bd">' + S.searchSuggest.map(function (r) { return '<div class="rel-row" data-act="open" data-id="' + r.id + '"><span style="flex:none;color:var(--app-faint);font-size:11px">→</span>' + '<span class="nm">' + esc(r.title) + '</span><span class="rs">' + esc(pathLabel(r.id)) + '</span></div>'; }).join('') + '</div>' : '') + '</div></div>';}/** API の検索結果を画面の形にそろえる */function searchRows(list) { return (list || []).map(function (r) { var hits = r.hits || []; return { id: r.id, title: r.title, snippet: r.snippet || '', hitTitle: hits.indexOf('title') >= 0, hitBody: hits.indexOf('body') >= 0, hitFiles: hits.indexOf('file') >= 0 ? r.files || [] : [] }; });}function mark(text, term) { var raw = String(text || ''), k = String(term || ''); if (!k) return esc(raw); var i = raw.toLowerCase().indexOf(k.toLowerCase()); if (i < 0) return esc(raw); return esc(raw.slice(0, i)) + '<mark>' + esc(raw.slice(i, i + k.length)) + '</mark>' + esc(raw.slice(i + k.length));}/* --------------------------------------------- コマンドパレット(⌘K) */var COMMANDS = [ { id: 'new', label: '新しいページをつくる', hint: '+', act: function () { newPage(); } }, { id: 'home', label: 'ホームへ移動', hint: '⌂', act: function () { go('home', { pageId: null }); } }, { id: 'recent', label: '最近更新された記事', hint: '↻', act: function () { go('recent'); } }, { id: 'popular', label: 'よく読まれている記事', hint: '★', act: function () { go('popular'); } }, { id: 'stale', label: '見直しが必要なページ', hint: '⚠', act: function () { go('stale'); } }, { id: 'trash', label: 'ゴミ箱(削除したページを戻す)', hint: '✕', act: function () { openTrash(); } }, { id: 'guide', label: '機能ガイド(この wiki の使い方)', hint: '?', act: function () { go('guide'); } }, { id: 'settings', label: '設定を開く', hint: '⚙', act: function () { openSettings(); } }, { id: 'fav', label: 'このページをお気に入りに入れる/外す', hint: '☆', needPage: true, act: function () { toggleFav(); } }, { id: 'watch', label: 'このページの更新通知を切り替える', hint: '◉', needPage: true, act: function () { toggleWatch(); } }, { id: 'history', label: 'このページの更新履歴と差分', hint: '≡', needPage: true, act: function () { openHistory(); } }, { id: 'print', label: 'このページを印刷 / PDF', hint: '⎙', needPage: true, act: function () { window.print(); } }, { id: 'edit', label: 'このページを編集', hint: '✎', needPage: true, act: function () { startEdit(); } }];function openPalette(seed) { S.palette = { q: seed || '', sel: 0, results: null, loading: false }; renderPalette();}function closePalette() { S.palette = null; clearTimeout(S.palTimer); document.getElementById('palette').innerHTML = '';}function paletteItems() { var pal = S.palette; var q = (pal.q || '').trim(); var lower = q.toLowerCase(); var items = []; COMMANDS.forEach(function (c) { if (c.needPage && !(S.route === 'view' && S.pageId)) return; if (lower && c.label.toLowerCase().indexOf(lower) < 0) return; items.push({ kind: 'cmd', sec: '操作', label: c.label, hint: c.hint, run: c.act }); }); if (lower) { S.pages.filter(function (p) { return (p.title + ' ' + pathLabel(p.id) + ' ' + (p.tags || []).join(' ')).toLowerCase().indexOf(lower) >= 0; }).slice(0, 8).forEach(function (p) { items.push({ kind: 'page', sec: 'ページ', page: p, run: function () { openPage(p.id); } }); }); (pal.results || []).forEach(function (r) { if (items.some(function (it) { return it.page && it.page.id === r.id; })) return; items.push({ kind: 'hit', sec: '本文・添付の中', hit: r, run: function () { openPage(r.id); } }); }); } else { favorites().slice(0, 5).forEach(function (p) { items.push({ kind: 'page', sec: 'お気に入り', page: p, run: function () { openPage(p.id); } }); }); S.pages.slice().sort(function (a, b) { return String(b.updated_at).localeCompare(String(a.updated_at)); }) .slice(0, 5).forEach(function (p) { if (items.some(function (it) { return it.page && it.page.id === p.id; })) return; items.push({ kind: 'page', sec: '最近更新', page: p, run: function () { openPage(p.id); } }); }); } return items;}function renderPalette() { var pal = S.palette; if (!pal) return; document.getElementById('palette').innerHTML = '<div class="pal-bg" data-act="closePalette"><div class="pal" data-stop="1">' + '<div class="pal-in"><span class="search-icon" style="position:static"></span>' + '<input id="palQ" type="text" placeholder="ページを探す・操作を実行する" autocomplete="off">' + '<span class="kbd">esc</span></div>' + '<div class="pal-bd" id="palBd"></div>' + '<div class="pal-ft"><span><b>↑</b><b>↓</b> 選択</span><span><b>Enter</b> 開く</span>' + '<span style="margin-left:auto" id="palHint"></span></div>' + '</div></div>'; var input = document.getElementById('palQ'); input.value = pal.q || ''; input.focus(); input.setSelectionRange(input.value.length, input.value.length); input.oninput = function () { S.palette.q = input.value; S.palette.sel = 0; S.palette.results = null; renderPalList(true); scheduleFullSearch(); }; renderPalList(true);}function renderPalList(reset) { var pal = S.palette; if (!pal) return; var bd = document.getElementById('palBd'); if (!bd) return; var items = paletteItems(); if (pal.sel >= items.length) pal.sel = Math.max(0, items.length - 1); var keep = reset ? 0 : bd.scrollTop; var html = '', sec = ''; items.forEach(function (it, i) { if (it.sec !== sec) { sec = it.sec; html += '<div class="pal-sec">' + esc(sec) + '</div>'; } var cls = 'pal-row' + (i === pal.sel ? ' pal-sel' : ''); if (it.kind === 'cmd') { html += '<div class="' + cls + '" data-act="palRun" data-id="' + i + '">' + '<span class="ic">' + it.hint + '</span><div class="t"><div class="nm"><span class="txt">' + esc(it.label) + '</span></div></div>' + '<span class="go">Enter</span></div>'; } else if (it.kind === 'page') { var p = it.page; html += '<div class="' + cls + '" data-act="palRun" data-id="' + i + '">' + '<span class="ic">' + (p.favorite ? '★' : '⌸') + '</span><div class="t">' + '<div class="nm"><span class="txt">' + mark(p.title, pal.q) + '</span>' + badges(p) + '</div>' + '<div class="sub">' + esc(pathLabel(p.id)) + ((p.tags || []).length ? ' · ' + esc(p.tags.join(', ')) : '') + '</div></div>' + '<span class="go">Enter</span></div>'; } else { var r = it.hit; html += '<div class="' + cls + '" data-act="palRun" data-id="' + i + '">' + '<span class="ic">🔎</span><div class="t"><div class="nm"><span class="txt">' + esc(r.title) + '</span>' + (r.hitFiles.length && !r.hitBody ? '<span class="badge chip-file">添付</span>' : '') + '</div>' + '<div class="sub">' + esc(pathLabel(r.id)) + '</div>' + '<div class="snip">' + mark(r.snippet, pal.q) + '</div></div><span class="go">Enter</span></div>'; } }); bd.innerHTML = (items.length ? html : '<div class="pal-empty">一致するものがありません</div>') + (pal.loading ? '<div class="pal-sec">本文・添付の中を検索しています…</div>' : ''); bd.scrollTop = keep; var hint = document.getElementById('palHint'); if (hint) hint.textContent = pal.q ? '本文と添付の名前も自動で探します' : '文字を打つと絞り込みます'; if (!reset) palScrollToSel();}function palScrollToSel() { var bd = document.getElementById('palBd'); var sel = bd && bd.querySelector('.pal-row.pal-sel'); if (!bd || !sel) return; var top = sel.offsetTop, bottom = top + sel.offsetHeight; if (top - 22 < bd.scrollTop) bd.scrollTop = Math.max(0, top - 22); else if (bottom + 8 > bd.scrollTop + bd.clientHeight) bd.scrollTop = bottom + 8 - bd.clientHeight;}function scheduleFullSearch() { clearTimeout(S.palTimer); var q = (S.palette.q || '').trim(); if (q.length < 2) { S.palette.loading = false; return; } S.palette.loading = true; S.palTimer = setTimeout(function () { run('searchPages', q).then(function (res) { if (!S.palette || (S.palette.q || '').trim() !== q) return; S.palette.results = searchRows(res.results).slice(0, 6); S.palette.loading = false; renderPalList(); }).catch(function () { if (S.palette) { S.palette.loading = false; renderPalList(); } }); }, 260);}function paletteMove(d) { var pal = S.palette; if (!pal) return; var rows = document.querySelectorAll('#palBd .pal-row'); if (!rows.length) return; pal.sel = (pal.sel + d + rows.length) % rows.length; for (var i = 0; i < rows.length; i++) { if (i === pal.sel) rows[i].classList.add('pal-sel'); else rows[i].classList.remove('pal-sel'); } palScrollToSel();}function paletteRun(i) { var items = paletteItems(); var it = items[typeof i === 'number' ? i : S.palette.sel]; var q = (S.palette.q || '').trim(); closePalette(); if (it) it.run(); else if (q) doSearch(q);}/* ------------------------------------------------- 履歴 と 差分表示 */function openHistory() { var p = byId(S.pageId); if (!p) return; S.history = { revs: (S.bodyCache[p.id] || {}).revisions || [], sel: 'current', body: null, loading: false }; renderHistory();}function closeHistory() { S.history = null; document.getElementById('modals').innerHTML = ''; }function renderHistory() { var h = S.history; if (!h) return; var p = byId(S.pageId); var cache = S.bodyCache[p.id] || {}; var rows = '<div class="hist-row' + (h.sel === 'current' ? ' on' : '') + '" data-act="histSel" data-id="current">' + '<b>' + esc(who(p.updated_by)) + '</b><span>' + stamp(p.updated_at) + '</span>' + (p.change_note ? '<span class="memo">' + esc(p.change_note) + '</span>' : '') + '<span class="now">現在の内容</span></div>' + h.revs.map(function (r) { return '<div class="hist-row' + (h.sel === r.id ? ' on' : '') + '" data-act="histSel" data-id="' + esc(r.id) + '">' + '<b>' + esc(who(r.by)) + '</b><span>' + stamp(r.at) + '</span>' + (r.note ? '<span class="memo">' + esc(r.note) + '</span>' : '') + '</div>'; }).join(''); var bd; if (h.sel === 'current') { bd = '<div class="hist-bar"><span class="t">現在の内容です。左の履歴を選ぶと、変更前の内容と差分が見られます。</span></div>' + '<div class="wiki-body">' + sanitize(cache.bodyHtml || '') + '</div>'; } else if (h.loading || !h.body) { bd = '<div class="sk" style="height:14px;margin-bottom:12px"></div><div class="sk" style="height:14px;width:86%;margin-bottom:12px"></div>' + '<div class="sk" style="height:14px;width:92%"></div>'; } else { var diff = h.diffOn === undefined ? S.diffOn : h.diffOn; bd = '<div class="hist-bar">' + '<span class="t">' + esc(who(h.body.by)) + ' が ' + stamp(h.body.at) + ' に保存した内容' + (diff ? ' — この版から現在までの変更' : '') + '</span>' + '<span class="sw' + (diff ? ' on' : '') + '" data-act="diffToggle"><span class="track"><i></i></span>差分で見る</span>' + '<button class="btn btn-sm" data-act="restoreRev">この内容に戻す</button></div>' + (diff ? diffHtml(h.body.html, h.currentHtml || cache.bodyHtml || '') : '<div class="wiki-body">' + sanitize(h.body.html) + '</div>'); } document.getElementById('modals').innerHTML = '<div class="modal-bg" data-act="closeHistory"><div class="modal wide" data-stop="1">' + '<div class="mh"><h3>更新履歴</h3><span class="sub muted">' + esc(p.title) + '</span><span class="sp"></span>' + '<span class="x" data-act="closeHistory">✕</span></div>' + '<div class="hist"><div class="hist-list">' + rows + '</div><div class="hist-bd" id="histBd">' + bd + '</div></div>' + '</div></div>'; hydrateImages(document.getElementById('modals'));}function histSelect(id) { var h = S.history; h.sel = id; if (id === 'current') { h.body = null; renderHistory(); return; } h.loading = true; h.body = null; renderHistory(); run('getRevision', S.pageId, id).then(function (res) { if (!S.history || S.history.sel !== id) return; S.history.loading = false; S.history.body = res.revision; S.history.currentHtml = res.currentHtml; renderHistory(); }).catch(function (e) { if (S.history) { S.history.loading = false; renderHistory(); } fail(e); });}/** HTML を「行」に落とす(差分の単位) */function linesOf(html) { var d = document.createElement('div'); d.innerHTML = sanitize(html); var out = []; Array.prototype.forEach.call(d.children, function (el) { var tag = el.tagName.toLowerCase(); if (tag === 'ul' || tag === 'ol') { Array.prototype.forEach.call(el.children, function (li) { // 番号は並び順で変わるので、比べるのは内容だけにする(1件消しただけで後ろが全部変わって見えないように) var t = li.textContent.replace(/\s+/g, ' ').trim(); if (t) out.push((tag === 'ol' ? '① ' : '・') + t); }); } else if (tag === 'pre') { String(el.textContent).split('\n').forEach(function (t) { if (t.trim()) out.push(' ' + t.trim()); }); } else if (tag === 'table') { // 表ごと1行にすると、セル1つの修正が表全体の変更に見えるので行ごとに分ける Array.prototype.forEach.call(el.querySelectorAll('tr'), function (tr) { var cells = Array.prototype.map.call(tr.children, function (td) { return td.textContent.replace(/\s+/g, ' ').trim(); }); if (cells.some(Boolean)) out.push('|' + cells.join(' | ') + '|'); }); } else { var t = el.textContent.replace(/\s+/g, ' ').trim(); if (t) out.push((tag === 'h2' ? '■ ' : tag === 'h3' ? '□ ' : '') + t); } }); return out;}/** LCS ベースの行差分 */function diffLines(a, b) { var n = a.length, m = b.length; if (n > 300 || m > 300) { return a.map(function (x) { return { t: 'del', x: x }; }).concat(b.map(function (x) { return { t: 'add', x: x }; })); } var dp = []; for (var i = 0; i <= n; i++) dp.push(new Array(m + 1).fill(0)); for (i = n - 1; i >= 0; i--) { for (var j = m - 1; j >= 0; j--) { dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); } } var out = []; i = 0; j = 0; while (i < n && j < m) { if (a[i] === b[j]) { out.push({ t: 'same', x: a[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: 'del', x: a[i] }); i++; } else { out.push({ t: 'add', x: b[j] }); j++; } } while (i < n) out.push({ t: 'del', x: a[i++] }); while (j < m) out.push({ t: 'add', x: b[j++] }); return out;}function diffHtml(oldHtml, newHtml) { var d = diffLines(linesOf(oldHtml), linesOf(newHtml)); var changed = d.filter(function (x) { return x.t !== 'same'; }).length; if (!changed) return '<div class="diff-none">この版から内容は変わっていません。</div>'; var adds = d.filter(function (x) { return x.t === 'add'; }).length; var dels = d.filter(function (x) { return x.t === 'del'; }).length; var html = '<div class="diff-legend"><span><i style="background:var(--app-success-soft)"></i>追加 ' + adds + '行</span>' + '<span><i style="background:var(--app-error-soft)"></i>削除 ' + dels + '行</span>' + '<span>変更のない行は折りたたんでいます</span></div><div class="diff">'; // 変更の周辺2行だけ残し、続く同一行は折りたたむ var keep = d.map(function (x, i) { if (x.t !== 'same') return true; for (var k = Math.max(0, i - 2); k <= Math.min(d.length - 1, i + 2); k++) if (d[k].t !== 'same') return true; return false; }); var skipped = 0; var skipRow = function (n) { return '<div class="ln"><span class="sg">⋯</span><span class="tx" style="color:var(--app-faint);font-size:11.5px">' + n + '行 省略</span></div>'; }; d.forEach(function (x, i) { if (!keep[i]) { skipped++; return; } if (skipped) { html += skipRow(skipped); skipped = 0; } var sg = x.t === 'add' ? '+' : x.t === 'del' ? '−' : ' '; html += '<div class="ln ' + x.t + '"><span class="sg">' + sg + '</span><span class="tx">' + esc(x.x) + '</span></div>'; }); if (skipped) html += skipRow(skipped); return html + '</div>';}function restoreRevision() { var h = S.history; if (!h || !h.body) return; run('restoreRevision', S.pageId, h.body.id).then(function (res) { var p = upsert(res.page); S.bodyCache[p.id] = null; closeHistory(); loadBody(p.id, true); render(); toast('この内容に戻しました'); }).catch(fail);}/* ------------------------------------------------------------ 遷移 */function go(route, opts) { if (!leaveGuard(function () { go(route, opts); })) return; S.route = route; if (opts && 'pageId' in opts) S.pageId = opts.pageId; document.body.classList.remove('drawer'); render();}function openPage(id) { if (S.creating) return; if (!leaveGuard(function () { openPage(id); })) return; var p = byId(id); if (!p) { toast('リンク先のページは見つかりませんでした', 'error'); return; } chain(id).forEach(function (c) { S.expanded[c.id] = true; }); S.route = 'view'; S.pageId = id; S.activeToc = ''; S.reactPop = false; S.favPop = false; S.idxFilter = ''; S.idxSort = 'order'; S.idxTag = ''; document.body.classList.remove('drawer'); render(); loadBody(id); clearTimeout(S.viewTimer); S.viewTimer = setTimeout(function () { logView(id); }, 3000);}function loadBody(id, force) { if (S.bodyCache[id] && !force) return; return run('getPage', id).then(function (res) { upsert(res.page); S.bodyCache[id] = { bodyHtml: res.html || '', attachments: res.attachments || [], comments: res.comments || [], reactions: res.reactions || [], revisions: res.revisions || [], backlinks: res.backlinks || [], related: res.related || [], checks: res.checks || {}, updated_at: res.page.updatedAt }; if (S.route === 'view' && S.pageId === id) { S.enterBody = true; render(true); } return S.bodyCache[id]; }).catch(function (e) { if (e.code === 'NOT_FOUND') { S.pages = S.pages.filter(function (p) { return p.id !== id; }); if (S.pageId === id && S.route === 'view') { S.pageId = null; S.route = 'home'; render(); } } fail(e); });}function logView(id) { var last = S.logged[id]; if (last && Date.now() - last < 1800000) return; S.logged[id] = Date.now(); // 閲覧の記録は失敗しても利用者に知らせない(数字が1つずれるだけ) run('logView', id).catch(function (e) { console.warn('logView', e); });}function leaveGuard(after) { if (S.route !== 'edit' || !S.dirty) return true; openModal({ title: '未保存の変更があります', body: 'このページを離れると、編集した内容は失われます。', label: '破棄して移動', onOk: function () { S.dirty = false; closeModal(); afterLeaveEdit(true); if (after) after(); } }); return false;}/* -------------------------------------------- お気に入り / 通知 / 反応 */function toggleFav() { var id = S.pageId; run('toggleFavorite', id).then(function (res) { var p = byId(id); if (p) p.favorite = res.favorite; render(true); toast(res.favorite ? 'お気に入りに追加しました' : 'お気に入りから外しました'); }).catch(fail);}function toggleWatch() { var id = S.pageId; run('toggleWatch', id).then(function (res) { var p = byId(id); if (p) { p.watching = res.watching; p.watcher_count = res.watcherCount; } render(true); toast(res.watching ? '更新されたらメールでお知らせします' : '通知を止めました'); }).catch(fail);}function react(emoji) { var id = S.pageId; S.reactPop = false; run('react', id, emoji).then(function (res) { if (S.bodyCache[id]) S.bodyCache[id].reactions = res.reactions; render(true); }).catch(fail);}function addComment() { var el = document.getElementById('cmtText'); var text = el ? el.value.trim() : ''; if (!text) { toast('コメントを入力してください', 'warn'); return; } var id = S.pageId; var btn = document.querySelector('[data-act="addComment"]'); if (btn) btn.disabled = true; run('addComment', id, text).then(function (res) { var c = S.bodyCache[id]; if (c) c.comments = (c.comments || []).concat([res.comment]); render(true); toast(res.notified ? 'コメントしました(' + res.notified + '人にメールで通知)' : 'コメントしました'); }).catch(function (e) { if (btn) btn.disabled = false; fail(e); });}function resolveComment(cid) { var id = S.pageId; var c = S.bodyCache[id]; if (!c) return; var target = (c.comments || []).filter(function (x) { return x.id === cid; })[0]; run('resolveComment', id, cid, target ? !target.resolved : true).then(function (res) { c.comments = res.comments; render(true); }).catch(fail);}/** 記事の画面から、本文はそのままで公開にする */function pagePayload(p, c, extra) { return Object.assign({ title: p.title, html: c.bodyHtml || '', tags: p.tags || [], status: p.status, visibility: p.visibility, members: p.members || [], owner: p.owner, reviewDue: p.review_due, parentId: p.parent_id, deprecated: p.deprecated, deprecatedNote: p.deprecated_note, changeNote: '', baseUpdatedAt: p.updated_at }, extra);}function publishPage() { var p = byId(S.pageId); var c = S.bodyCache[p.id]; if (!c) { toast('本文を読み込んでいます。少し待ってからもう一度押してください'); return; } run('savePage', p.id, pagePayload(p, c, { status: 'published', changeNote: '公開' })).then(function (res) { upsert(res.page); c.updated_at = res.page.updatedAt; render(true); toast(res.notified ? '公開しました(' + res.notified + '人にメールで通知)' : '公開しました'); }).catch(function (e) { if (e.code === 'CONFLICT') { toast('ほかの人が先に保存していました。読み込み直したので、もう一度押してください', 'warn'); loadBody(p.id, true); return; } fail(e); });}/* ------------------------------------------------------------ 編集操作 */function startEdit() { var p = byId(S.pageId); if (!p) return; var cached = S.bodyCache[p.id]; if (!cached) { toast('本文を読み込んでいます。少し待ってからもう一度押してください'); return; } S.draft = { title: p.title, html: cached.bodyHtml || '', attachments: (cached.attachments || []).slice(), tags: (p.tags || []).slice(), status: p.status || 'published', visibility: p.visibility || 'all', members: (p.members || []).slice(), deprecated: !!p.deprecated, note: p.deprecated_note || '', owner: p.owner || S.user, reviewDue: p.review_due || '', changeNote: '', parentId: p.parent_id || '', updated_at: p.updated_at }; S.dirty = false; S.route = 'edit'; S.quill = null; S.autoStopped = false; S.editSeq++; render();}function newPage() { if (S.creating) return; if (!leaveGuard(newPage)) return; if (!S.templates.length) { createFrom(''); return; } document.getElementById('modals').innerHTML = '<div class="modal-bg top" data-act="closeModal"><div class="modal pick" data-stop="1">' + '<div class="pick-hd"><b>どの型で書き始めますか</b>' + '<div class="muted" style="margin-top:2px">あとから自由に書き換えられます</div></div>' + '<div class="pick-bd">' + S.templates.map(function (t) { return '<div class="tpl-row" data-act="tpl" data-id="' + esc(t.id) + '">' + '<span class="ic">' + esc(t.name.slice(0, 2)) + '</span><div><b>' + esc(t.name) + '</b><span>' + esc(t.desc) + '</span></div></div>'; }).join('') + '</div></div></div>'; S.modal = { kind: 'tpl' };}function createFrom(templateId, title) { closeModal(); var cur = S.route === 'view' ? byId(S.pageId) : null; // 開いているのがカテゴリならその中に、カテゴリの中のページなら同じカテゴリに作る var parentId = cur ? (cur.parent_id || cur.id) : ''; var tpl = S.templates.filter(function (t) { return t.id === templateId; })[0]; // 先に「これから編集画面が出る」形を見せて、その間の操作は止めておく S.creating = { name: tpl && !/白紙/.test(tpl.name) ? tpl.name : '' }; blockUI(true); render(); run('createPage', { parentId: parentId, title: title || '新しいページ', templateId: templateId }).then(function (res) { var page = upsert(res.page); S.bodyCache[page.id] = { bodyHtml: res.html || '', attachments: [], comments: [], reactions: [], related: [], revisions: [], backlinks: [], checks: {}, updated_at: page.updated_at }; S.pageId = page.id; S.newPageId = page.id; S.creating = null; blockUI(false); startEdit(); if (!title) { S.draft.title = ''; render(); } if (tpl && !/白紙/.test(tpl.name)) toast('「' + tpl.name + '」の型で作成しました'); }).catch(function (e) { S.creating = null; blockUI(false); render(); fail(e); });}/** 作っただけで何も書かずに離れた新しいページは、ゴミ箱にも残さず消す */function afterLeaveEdit(skipRender) { var newId = S.newPageId; S.newPageId = null; S.draft = null; S.quill = null; clearTimeout(S.autoTimer); if (newId && S.pageId === newId) { run('deletePage', newId).then(function () { return run('purgePage', newId); }) .catch(function (e) { console.warn('discard new page', e); }); S.pages = S.pages.filter(function (p) { return p.id !== newId; }); delete S.bodyCache[newId]; S.pageId = null; S.route = 'home'; } else { S.route = 'view'; } if (!skipRender) render();}function cancelEdit() { if (!S.dirty) { afterLeaveEdit(); return; } openModal({ title: '編集を破棄しますか', body: '保存していない変更は失われます。', label: '破棄する', onOk: function () { S.dirty = false; closeModal(); afterLeaveEdit(); } });}function draftPayload(d, extra) { var members = d.visibility === 'members' ? d.members.filter(function (m) { return m !== S.user; }).concat([S.user]) : []; return Object.assign({ title: (d.title || '').trim() || '無題のページ', html: d.html, tags: d.tags, status: d.status, visibility: d.visibility, members: members, owner: d.owner, reviewDue: d.reviewDue || '', parentId: d.parentId || '', deprecated: d.deprecated, deprecatedNote: d.note || '', changeNote: d.changeNote || '', baseUpdatedAt: d.updated_at }, extra);}/** opts.force: 衝突を承知で上書きする / opts.publish: 下書きをこの保存で公開にする */function saveEdit(opts) { opts = opts || {}; var p = byId(S.pageId); if (!p || !S.draft) return; syncDraft(); var d = S.draft; clearTimeout(S.autoTimer); S.autoTimer = null; var publishing = !!opts.publish && d.status === 'draft'; var btn = document.querySelector(publishing ? '[data-act="publishEdit"]' : '[data-act="save"]'); if (btn && btn.disabled) return; var btnLabel = btn ? btn.textContent : '保存する'; if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spin"></span>保存しています…'; } var prog = progressToast(publishing ? '公開しています…' : d.status === 'draft' ? '下書きを保存しています…' : '保存してみんなに反映しています…'); var extra = {}; if (opts.force) extra.force = true; if (publishing) extra.status = 'published'; run('savePage', p.id, draftPayload(d, extra)).then(function (res) { if (btn) { btn.disabled = false; btn.textContent = btnLabel; } var moved = d.parentId !== (p.parent_id || ''); var page = upsert(res.page); S.bodyCache[page.id] = null; S.dirty = false; S.newPageId = null; S.draft = null; S.quill = null; S.route = 'view'; if (moved) chain(page.id).forEach(function (x) { S.expanded[x.id] = true; }); render(); loadBody(page.id, true); var word = publishing ? '公開しました' : '保存しました'; prog.done(res.notified ? word + '(' + res.notified + '人にメールで通知)' : word); }).catch(function (e) { if (btn) { btn.disabled = false; btn.textContent = btnLabel; } if (e.code === 'CONFLICT') { prog.drop(); askConflict(e.detail, opts); return; } prog.done(e.message || '保存できませんでした。もう一度お試しください', 'error'); });}/* ------------------------------------------------ 先に保存されていたとき * 勝手に上書きも、勝手に破棄もしない。書いた内容は画面に残したまま選んでもらう。 */function askConflict(res, opts) { var whoName = who(res.by); openModal({ title: 'ほかの人が先に保存しています', cls: 'conf', label: '自分の内容で上書きする', okClass: 'btn-primary', html: '<p style="margin:0 0 14px;font-size:13px;line-height:1.85;color:var(--app-muted)">' + esc(whoName) + ' さんが ' + esc(rel(res.at)) + 'に このページを保存しました。' + 'いま書いている内容はそのまま残っています。どうするか選んでください。</p>' + '<div class="conf-opts">' + '<div class="conf-opt"><b>自分の内容で上書きする</b>' + '<span>' + esc(whoName) + ' さんの変更は履歴に残るので、あとから見比べられます。</span></div>' + '<div class="conf-opt" data-act="confDiff"><b>先に何が変わったか見る</b>' + '<span>履歴を開いて、' + esc(whoName) + ' さんの変更点を確かめます。編集内容はそのままです。</span></div>' + '<div class="conf-opt" data-act="confReload"><b>相手の内容を読み込み直す</b>' + '<span>いま書いた内容は消えます。先にコピーしておいてください。</span></div>' + '</div>', onOk: function () { closeModal(); saveEdit(Object.assign({}, opts, { force: true })); } });}function conflictShowDiff() { closeModal(); var id = S.pageId; // 相手の保存を含めた履歴を取り直してから開く。編集中の下書きの基準(draft.updated_at)は変えない run('getPage', id).then(function (res) { var c = S.bodyCache[id] || {}; c.revisions = res.revisions || []; c.bodyHtml = res.html || ''; S.bodyCache[id] = c; upsert(res.page); openHistory(); }).catch(fail);}function conflictReload() { closeModal(); var id = S.pageId; clearTimeout(S.autoTimer); S.dirty = false; S.draft = null; S.quill = null; S.newPageId = null; S.route = 'view'; S.bodyCache[id] = null; render(); loadBody(id, true); toast('最新の内容を読み込みました');}function askDelete() { var p = byId(S.pageId); if (!p) return; if (kids(p.id).length) { toast('中にページがあるため削除できません。先に中のページを移動するか削除してください', 'error'); return; } var editing = S.route === 'edit'; openModal({ title: '「' + p.title + '」を削除しますか', body: '削除してもゴミ箱に残るので、あとから元に戻せます。一覧と検索結果からはすぐに消えます。' + (editing && S.dirty ? '保存していない変更は消えます。' : ''), label: '削除する', onOk: function () { run('deletePage', p.id).then(function (res) { closeModal(); if (editing) { clearTimeout(S.autoTimer); S.autoTimer = null; S.dirty = false; S.draft = null; S.quill = null; S.newPageId = null; } S.pages = S.pages.filter(function (x) { return x.id !== p.id; }); delete S.bodyCache[p.id]; S.trashCount = res.trashCount; S.pageId = null; S.route = 'home'; render(); toast('「' + p.title + '」をゴミ箱に入れました', null, function () { restorePage(p.id); }); }).catch(function (e) { closeModal(); fail(e); }); } });}/* ------------------------------------------------------------ タグ操作 */function addTag(name) { var t = String(name || '').trim().replace(/^[##]/, '').slice(0, 30); if (!t || !S.draft) return; if (S.draft.tags.indexOf(t) >= 0) return; if (S.draft.tags.length >= 8) { toast('タグは8個までです', 'warn'); return; } syncDraft(); S.draft.tags.push(t); markDirty(); render(true); var i = document.getElementById('tagInput'); if (i) i.focus();}function removeTag(name) { syncDraft(); S.draft.tags = S.draft.tags.filter(function (t) { return t !== name; }); markDirty(); render(true); toast('タグ「' + name + '」を外しました', null, function () { if (!S.draft) return; syncDraft(); if (S.draft.tags.indexOf(name) < 0) S.draft.tags.push(name); render(true); });}/* --------------------------------------- 鮮度管理 / チェック / ゴミ箱 */function setDue(days) { if (!S.draft) return; syncDraft(); if (days < 0) S.draft.reviewDue = ''; else { var d = new Date(Date.now() + days * 86400000); var z = function (n) { return String(n).padStart(2, '0'); }; S.draft.reviewDue = d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate()); } markDirty(); render(true);}function toggleCheck(index) { var id = S.pageId, c = S.bodyCache[id]; if (!c) return; var i = Number(index); var on = !(c.checks && c.checks[i]); c.checks = Object.assign({}, c.checks); if (on) c.checks[i] = true; else delete c.checks[i]; render(true); run('setCheck', id, i, on).then(function (res) { if (S.bodyCache[id]) S.bodyCache[id].checks = res.checks; }).catch(fail);}function resetChecks() { var id = S.pageId, c = S.bodyCache[id]; if (!c) return; var before = Object.assign({}, c.checks || {}); c.checks = {}; render(true); run('resetChecks', id).catch(fail); toast('チェックを全部外しました', null, function () { var cc = S.bodyCache[id]; if (!cc) return; cc.checks = before; render(true); Object.keys(before).forEach(function (k) { run('setCheck', id, Number(k), true).catch(fail); }); });}function openTrash() { S.trash = null; go('trash'); run('listTrash').then(function (res) { S.trash = res.items || []; S.trashCount = S.trash.length; if (S.route === 'trash') render(); }).catch(fail);}function restorePage(id) { run('restorePage', id).then(function (res) { upsert(res.page); S.trash = (S.trash || []).filter(function (t) { return t.id !== id; }); S.trashCount = res.trashCount; render(true); toast('「' + res.page.title + '」を元に戻しました'); }).catch(fail);}function askPurge(id) { var t = (S.trash || []).filter(function (x) { return x.id === id; })[0]; openModal({ title: '「' + (t ? t.title : 'このページ') + '」を完全に削除しますか', body: '添付ファイルも含めて、この操作は取り消せません。', label: '完全に削除', onOk: function () { run('purgePage', id).then(function (res) { closeModal(); S.trash = (S.trash || []).filter(function (x) { return x.id !== id; }); S.trashCount = res.trashCount; render(); toast('完全に削除しました'); }).catch(function (e) { closeModal(); fail(e); }); } });}/* ---------------------------------------------------- 検索0件からの作成 */function createWithTitle(title) { var blank = S.templates.filter(function (t) { return /白紙/.test(t.name); })[0]; createFrom(blank ? blank.id : '', title);}/* ------------------------------------------------------------ @メンション */var MENTION_RE = /@([^\s@]*)$/;function mentionCandidates() { var el = document.getElementById('cmtText'); if (!el) return null; var upto = el.value.slice(0, el.selectionStart || el.value.length); var m = upto.match(MENTION_RE); if (!m) return null; var q = m[1].toLowerCase(); var list = activeMembers().filter(function (x) { return x.id !== S.user && (!q || x.name.toLowerCase().replace(/\s/g, '').indexOf(q.replace(/\s/g, '')) >= 0); }).slice(0, 5); return { frag: m[1], list: list };}function renderMention() { var box = document.getElementById('mentionPop'); var c = mentionCandidates(); if (!box) return; if (!c || !c.list.length) { box.innerHTML = ''; box.classList.remove('on'); return; } box.innerHTML = c.list.map(function (m) { var av = avatarOf(m.name); return '<div class="mrow" data-act="mention" data-id="' + esc(m.id) + '">' + '<span class="av" style="width:20px;height:20px;font-size:10px;background:' + av.bg + ';color:' + av.fg + '">' + esc(av.initial) + '</span>' + '<b>' + esc(m.name) + '</b><span>' + (m.role === 'admin' ? '管理者' : 'メンバー') + '</span></div>'; }).join(''); box.classList.add('on');}function insertMention(memberId) { var el = document.getElementById('cmtText'); var c = mentionCandidates(); if (!el || !c) return; var pos = el.selectionStart || el.value.length; var before = el.value.slice(0, pos).replace(MENTION_RE, '@' + who(memberId) + ' '); el.value = before + el.value.slice(pos); el.focus(); el.setSelectionRange(before.length, before.length); renderMention();}/* -------------------------------------------------- 保存先(階層)変更 */function openParentPicker() { S.parentPick = { q: '' }; renderParentPicker();}function renderParentPicker() { var self = S.pageId; var q = (S.parentPick.q || '').trim().toLowerCase(); var hasKids = kids(self).length > 0; // 2段までなので、選べるのはルートとカテゴリだけ。中にページがあるページはカテゴリのまま var rows = hasKids ? [] : cats().filter(function (c) { return c.id !== self; }); if (q) rows = rows.filter(function (p) { return p.title.toLowerCase().indexOf(q) >= 0; }); var cur = S.draft ? S.draft.parentId : ''; document.getElementById('modals').innerHTML = '<div class="modal-bg top" data-act="closeParentPicker"><div class="modal pick" data-stop="1">' + '<div class="pick-hd"><b>保存する場所を選ぶ</b>' + '<input id="parentQ" type="search" placeholder="カテゴリ名で絞り込む" value="' + esc(S.parentPick.q) + '" autocomplete="off"></div>' + '<div class="pick-bd">' + '<div class="row loc-row' + (cur === '' ? ' on' : '') + '" data-act="setParent" data-id="">' + '<span class="sq"></span><div class="t"><div class="tt"><span class="name">ルート(最上位)</span></div>' + '<div class="path">新しいカテゴリになります</div></div></div>' + rows.map(function (p) { return '<div class="row loc-row' + (cur === p.id ? ' on' : '') + '" data-act="setParent" data-id="' + p.id + '">' + '<span class="sq"></span>' + '<div class="t"><div class="tt"><span class="name">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="path">このカテゴリの中に保存されます</div></div></div>'; }).join('') + (hasKids ? '<div class="empty"><b>このページの中にページがあります</b><p>カテゴリのまま動かせません。先に中のページを移動してください。</p></div>' : '') + (q && !rows.length && !hasKids ? '<div class="empty"><b>一致するカテゴリはありません</b><p>ルートを選ぶか、別の言い方で探してください。</p></div>' : '') + '</div></div></div>'; var input = document.getElementById('parentQ'); if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); input.addEventListener('input', function () { S.parentPick.q = input.value; renderParentPicker(); }); }}function setParent(id) { if (!S.draft) return; syncDraft(); S.draft.parentId = id || ''; closeParentPicker(); markDirty(); render(true);}function closeParentPicker() { S.parentPick = null; document.getElementById('modals').innerHTML = ''; }/* ------------------------------------------- サイドバーのドラッグ移動 */function bindTreeDrag() { var tree = document.getElementById('tree'); var clear = function () { Array.prototype.forEach.call(tree.querySelectorAll('.cat-row'), function (r) { r.classList.remove('dragging', 'drag-over-in', 'drag-over-before', 'drag-over-after'); }); }; tree.addEventListener('dragstart', function (e) { var row = e.target.closest('.cat-row'); if (!row) return; S.drag = { id: row.getAttribute('data-row') }; row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', S.drag.id); } catch { /* 古いブラウザは setData を受け付けないが、移動は S.drag で足りる */ } }); tree.addEventListener('dragend', function () { S.drag = null; clear(); }); tree.addEventListener('dragover', function (e) { if (!S.drag) return; var row = e.target.closest('.cat-row'); if (!row) return; e.preventDefault(); var id = row.getAttribute('data-row'); clear(); if (id === S.drag.id) { S.drag.target = null; return; } var box = row.getBoundingClientRect(); var zone = (e.clientY - box.top) / box.height < 0.5 ? 'before' : 'after'; // カテゴリ同士は入れ子にしない S.drag.target = id; S.drag.zone = zone; row.classList.add('drag-over-' + zone); }); tree.addEventListener('drop', function (e) { if (!S.drag || !S.drag.target) return; e.preventDefault(); dropPage(S.drag.id, S.drag.target, S.drag.zone); });}function dropPage(dragId, targetId, zone) { var drag = byId(dragId), target = byId(targetId); S.drag = null; if (!drag || !target) { renderTree(); return; } var sibs = cats().filter(function (x) { return x.id !== dragId; }); var index = sibs.map(function (x) { return x.id; }).indexOf(targetId) + (zone === 'after' ? 1 : 0); var from = cats().map(function (x) { return x.id; }).indexOf(dragId); if (index === from) { renderTree(); return; } movePageTo(drag, '', index, function () { movePageTo(drag, '', from); });}function movePageStep(delta) { var p = byId(S.pageId); if (!p) return; var ids = kids(p.parent_id || '').map(function (x) { return x.id; }); var from = ids.indexOf(p.id); var to = from + delta; if (from < 0 || to < 0 || to >= ids.length) return; movePageTo(p, p.parent_id || '', to, function () { movePageTo(p, p.parent_id || '', from); });}/** 同じ親の中での並びを変える。index は移動するページを除いた並びでの位置 */function movePageTo(p, parentId, index, undo) { if (S.route === 'edit') syncDraft(); // 描き直しで編集中の本文を失わないように run('movePage', p.id, parentId, index).then(function (res) { if (S.route === 'edit') syncDraft(); S.pages = (res.pages || []).map(normalize); render(true); if (undo) toast('「' + p.title + '」を移動しました', null, undo); else toast('移動を元に戻しました'); }).catch(function (e) { renderTree(); fail(e); });}/* ------------------------------------------------ マークダウンの読み込み * AI に書かせた文章をそのまま本文にするための入口。 * 出す HTML は、このアプリが保存している形に合わせる。Quill の貼り付けは * その形をそのまま書式に戻せるので、変換はここだけで完結する。 * 入れ子の箇条書きは1段に潰す(保存できる書式に段差が無いため)。 *//* md:start */var MD_ALERTS = { NOTE: 'note', INFO: 'note', TIP: 'point', IMPORTANT: 'point', WARNING: 'warn', CAUTION: 'warn' };var MD_URL = /^(?:https?:\/\/|mailto:|#\/page\/|#\/file\/)/i;function mdEsc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');}/** 貼られた文字がマークダウンかどうか。素のテキストを勝手に変えないための判定 */function looksMarkdown(text) { var t = String(text == null ? '' : text); if (!t.trim()) return false; var block = /^ {0,3}#{1,6} +\S/m.test(t) || /^ {0,3}(?:```|~~~)/m.test(t) || /^ {0,3}> +\S/m.test(t) || /^ {0,3}\|.*\|[^\S\n]*$/m.test(t) || /^ {0,3}(?:[-*+]|\d+[.)]) +\S[\s\S]*?\n {0,3}(?:[-*+]|\d+[.)]) +\S/m.test(t); if (block) return true; var inline = 0; if (/\*\*[^\s*][^*\n]*\*\*/.test(t)) inline++; if (/\[[^\]\n]+\]\([^)\s]+\)/.test(t)) inline++; if (/`[^`\n]+`/.test(t)) inline++; return inline >= 2;}/** 表のセルは文字だけを持つ(表の編集画面が文字として読み書きするため) */function mdPlain(s) { return String(s == null ? '' : s) .replace(/`([^`]*)`/g, '$1') .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/\*\*|__|~~|==/g, '') .trim();}function mdLink(label, url) { var href = String(url || '').replace(/^<|>$/g, ''); if (!MD_URL.test(href.replace(/&/g, '&'))) return label; return '<a href="' + href.replace(/"/g, '"') + '">' + (label || href) + '</a>';}function mdInline(src) { var codes = []; var t = String(src == null ? '' : src); // コードの中は書式として読まないので、先に取り出しておく t = t.replace(/(`+)([^`]+?)\1/g, function (all, tick, body) { codes.push(body); return '\uFFFC' + (codes.length - 1) + '\uFFFC'; }); t = mdEsc(t); // 画像は添付にしないと保存できないので、名前を残したリンクにする t = t.replace(/!\[([^\]]*)\]\(\s*((?:[^()\s]|\([^()]*\))+)[^)]*\)/g, function (all, alt, url) { return mdLink(mdEsc(mdPlain(alt)) || url, url); }); t = t.replace(/\[([^\]]+)\]\(\s*((?:[^()\s]|\([^()]*\))+)[^)]*\)/g, function (all, label, url) { return mdLink(label, url); }); t = t.replace(/<((?:https?:\/\/|mailto:)[^\s&]+)>/g, function (all, url) { return mdLink(url, url); }); t = t.replace(/\*\*([^\s*][^*]*?)\*\*/g, '<strong>$1</strong>'); t = t.replace(/(^|[^\w])__([^\s_][^_]*?)__(?!\w)/g, '$1<strong>$2</strong>'); t = t.replace(/~~([^\s~][^~]*?)~~/g, '<s>$1</s>'); t = t.replace(/==([^\s=][^=]*?)==/g, '<span class="mk-hl">$1</span>'); t = t.replace(/(^|[^\w*])\*([^\s*][^*]*?)\*(?!\*)/g, '$1<em>$2</em>'); t = t.replace(/(^|[^\w_])_([^\s_][^_]*?)_(?!\w)/g, '$1<em>$2</em>'); return t.replace(/\uFFFC(\d+)\uFFFC/g, function (all, i) { return '<code>' + mdEsc(codes[Number(i)]) + '</code>'; });}function mdToHtml(src) { var lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').replace(/\t/g, ' ').split('\n'); var out = []; var para = []; var list = null; var blank = false; function flushPara() { if (!para.length) return; out.push('<p>' + para.map(mdInline).join('<br>') + '</p>'); para = []; } function flushList() { if (!list) return; out.push('<' + list.tag + '>' + list.items.join('') + '</' + list.tag + '>'); list = null; } function flush() { flushPara(); flushList(); } for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (/^ {0,3}(?:```|~~~)/.test(line)) { flush(); var fence = line.trim().slice(0, 3); var code = []; for (i++; i < lines.length && lines[i].trim().slice(0, 3) !== fence; i++) code.push(lines[i]); out.push('<pre>' + mdEsc(code.join('\n')) + '</pre>'); blank = false; continue; } if (!line.trim()) { flushPara(); blank = true; continue; } var head = line.match(/^ {0,3}(#{1,6}) +(.*?)\s*#*\s*$/); if (head) { flush(); var tag = head[1].length <= 2 ? 'h2' : 'h3'; out.push('<' + tag + '>' + mdInline(head[2]) + '</' + tag + '>'); blank = false; continue; } if (/^ {0,3}([-*_])[^\S\n]*(?:\1[^\S\n]*){2,}$/.test(line)) { flush(); out.push('<hr>'); blank = false; continue; } // 見出しの下線(=== のみ。--- は区切り線と見分けがつかないので扱わない) if (para.length && /^ {0,3}={3,}\s*$/.test(line)) { var title = para.pop(); flushPara(); out.push('<h2>' + mdInline(title) + '</h2>'); blank = false; continue; } if (/^ {0,3}>/.test(line)) { flush(); var quote = []; for (; i < lines.length && /^ {0,3}>/.test(lines[i]); i++) quote.push(lines[i].replace(/^ {0,3}> ?/, '')); i--; var alert = quote.length ? quote[0].match(/^\[!([A-Za-z]+)\]\s*(.*)$/) : null; if (alert && MD_ALERTS[alert[1].toUpperCase()]) { var kind = MD_ALERTS[alert[1].toUpperCase()]; var body = quote.slice(1); if (alert[2].trim()) body.unshift(alert[2]); body.filter(function (t) { return t.trim(); }) .forEach(function (t) { out.push('<div data-callout="' + kind + '">' + mdInline(t) + '</div>'); }); } else { out.push('<blockquote>' + quote.map(mdInline).join('<br>') + '</blockquote>'); } blank = false; continue; } // 表(2行目が区切りの行になっているものだけ) if (/\|/.test(line) && i + 1 < lines.length && /^[\s|:-]*-{2,}[\s|:-]*$/.test(lines[i + 1]) && /\|/.test(lines[i + 1])) { flush(); var rows = []; var headRow = mdRow(line); i += 2; for (; i < lines.length && lines[i].trim() && /\|/.test(lines[i]); i++) rows.push(mdRow(lines[i])); i--; var cols = headRow.length; rows.forEach(function (r) { cols = Math.max(cols, r.length); }); out.push('<table class="wtable">' + mdRowHtml(headRow, cols, 'th') + rows.map(function (r) { return mdRowHtml(r, cols, 'td'); }).join('') + '</table>'); blank = false; continue; } var item = line.match(/^(\s*)([-*+]|\d+[.)]) +(.*)$/); if (item) { flushPara(); var text = item[3]; var check = text.match(/^\[([ xX])\] +(.*)$/); var kindName = check ? (check[1] === ' ' ? 'unchecked' : 'checked') : (/^\d/.test(item[2]) ? 'ordered' : 'bullet'); var listTag = kindName === 'ordered' ? 'ol' : 'ul'; if (list && (list.tag !== listTag || (blank && list.kind !== kindName))) flushList(); if (!list) list = { tag: listTag, kind: kindName, items: [] }; list.kind = kindName; list.items.push('<li data-list="' + kindName + '">' + mdInline(check ? check[2] : text) + '</li>'); blank = false; continue; } // 箇条書きの続き(字下げして書かれた行)は、その項目に足す if (list && !blank && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] = list.items[list.items.length - 1].replace(/<\/li>$/, ' ' + mdInline(line.trim()) + '</li>'); continue; } flushList(); if (blank) flushPara(); para.push(line.trim()); blank = false; } flush(); return out.join('');}function mdRow(line) { return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function (c) { return mdPlain(c); });}function mdRowHtml(cells, cols, tag) { var row = cells.slice(); while (row.length < cols) row.push(''); return '<tr>' + row.map(function (c) { return '<' + tag + '>' + mdEsc(c) + '</' + tag + '>'; }).join('') + '</tr>';}/* md:end *//* AI に頼むときの言い方。Gemini は指示が短いと本文を直接返したり、 * 見出しや表を勝手な書き方にすることがあるので、出し方と使える記号を先に決めておく。 */var MD_PROMPT = [ '社内 wiki に貼るための文章を、マークダウンで書いてください。', '', '【出し方】', '- 返事は ```markdown で始まり ``` で終わるコードブロック1つだけにする', '- コードブロックの外には、あいさつ・説明・補足を書かない', '- 文章は日本語。です・ます調でそろえる', '', '【使う記号(これ以外は使わない)】', '- 見出しは ## と ### の2段だけ。# は使わない', '- 箇条書きは - 、手順は 1. 。字下げした入れ子は作らない', '- チェックリストは - [ ] (持ち物や確認事項のときだけ)', '- 強調は **太字** だけ。斜体・絵文字・HTML タグは使わない', '- リンクは [文字](https://…)。画像は入れない', '- 表は | で作り、2行目を |---|---| にする。セルの中に書式を入れない', '- 引用は > 、コードは ``` で囲む、区切りは ---', '', '【書いてほしいこと】', '(ここに、書きたいことや元になるメモを貼る)'].join('\n');function copyText(text, okMessage) { var done = function () { toast(okMessage || 'コピーしました'); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, function () { copyFallback(text, done); }); return; } copyFallback(text, done);}/** clipboard が使えない環境(古い端末・権限なし)は、選択して execCommand で写す */function copyFallback(text, done) { var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; document.body.appendChild(ta); ta.select(); var ok; try { ok = document.execCommand('copy') === true; } catch { ok = false; } document.body.removeChild(ta); if (ok) done(); else toast('コピーできませんでした。文字を選んで Ctrl+C を押してください', 'error');}/** ツールバーの「Markdown」。貼り付け欄と .md ファイルの両方から入れられる */function openMarkdownImport() { openModal({ kind: 'mdImport', cls: 'mdimp', title: 'マークダウンを読み込む', html: '<p class="md-lead">AI に「マークダウンで書いて」と頼んだ文章を、そのまま貼り付けてください。' + '見出し・箇条書き・チェックリスト・表・引用・コードは、このアプリの書式に変わります。</p>' + '<div class="md-ask"><button type="button" class="btn" data-act="mdPrompt">AI に頼む文をコピー</button>' + '<span class="muted">Gemini や ChatGPT に貼って、書きたいことを足して使います</span></div>' + '<textarea id="mdIn" class="md-in" placeholder="## 見出し - 項目 - 項目"></textarea>' + '<div class="md-ask"><button type="button" class="btn" data-act="mdFile">.md ファイルから読む</button>' + '<span class="muted">画像は入りません。リンクとして残ります</span></div>', label: '本文に入れる', okClass: 'btn-primary', onOk: function () { var el = document.getElementById('mdIn'); var text = el ? el.value : ''; if (!text.trim()) { toast('読み込む文章がありません', 'error'); return; } closeModal(); insertMarkdown(text); } }); setTimeout(function () { var el = document.getElementById('mdIn'); if (el) el.focus(); }, 30);}function pickMarkdownFile() { var input = document.createElement('input'); input.type = 'file'; input.accept = '.md,.markdown,.txt,text/markdown,text/plain'; input.onchange = function () { var file = input.files && input.files[0]; if (!file) return; if (file.size > 2 * 1024 * 1024) { toast('2MBを超えるファイルは読み込めません', 'error'); return; } var reader = new FileReader(); reader.onload = function () { var el = document.getElementById('mdIn'); if (el) { el.value = String(reader.result || ''); el.focus(); } }; reader.readAsText(file); }; input.click();}/** 変換した中身を、カーソルの位置に入れる(元に戻すで1回分として戻せる) */function insertMarkdown(text) { var q = S.quill; if (!q) return; var html = mdToHtml(text); if (!html) { toast('読み込む文章がありません', 'error'); return; } var range = q.getSelection(true); var at = range ? range.index : Math.max(0, q.getLength() - 1); if (range && range.length) q.deleteText(range.index, range.length, 'user'); q.clipboard.dangerouslyPasteHTML(at, sanitize(html), 'user'); markDirty(); toast('マークダウンを読み込みました');}/* ------------------------------------------------------------ 添付 */var MAX_FILE = 10 * 1024 * 1024;/** insertLink が true なら、添付したあと本文のカーソル位置にリンクを入れる */function pickFile(insertLink) { var input = document.createElement('input'); input.type = 'file'; input.onchange = function () { if (input.files && input.files[0]) uploadFile(input.files[0], insertLink === true); }; input.click();}function readBase64(file, done) { var reader = new FileReader(); reader.onload = function () { done(String(reader.result)); }; reader.readAsDataURL(file);}function uploadFile(file, insertLink) { if (file.size > MAX_FILE) { toast(file.name + ' は10MBを超えています。分割するか圧縮してください', 'error'); return; } var prog = progressToast(file.name + ' を添付しています…'); var seq = S.editSeq, pageId = S.pageId; readBase64(file, function (dataUrl) { run('uploadFile', pageId, { name: file.name, mime: file.type || 'application/octet-stream', base64: dataUrl.split(',')[1] }) .then(function (res) { prog.done(file.name + ' を添付しました'); if (!S.draft || S.editSeq !== seq) return; var at = insertLink && S.quill ? S.quill.getSelection(true) : null; syncDraft(); S.draft.attachments.push(res.file); render(true); if (insertLink && S.quill) insertAttachLink(res.file.id, at); }).catch(function (e) { prog.done(e.message || '添付できませんでした', 'error'); }); });}function removeFile(fileId) { var f = (S.draft.attachments || []).filter(function (a) { return a.id === fileId; })[0]; openModal({ title: '「' + (f ? f.name : 'このファイル') + '」を削除しますか', body: '添付ファイルはゴミ箱に入らず、すぐに消えます。本文からこのファイルへのリンクがあれば、開けなくなります。', label: '削除する', onOk: function () { closeModal(); run('deleteFile', S.pageId, fileId).then(function () { if (!S.draft) return; syncDraft(); S.draft.attachments = S.draft.attachments.filter(function (a) { return a.id !== fileId; }); render(true); toast('添付ファイルを削除しました'); }).catch(fail); } });}function downloadFile(pageId, fileId) { var prog = progressToast('ファイルを用意しています…'); run('downloadFile', pageId, fileId).then(function (res) { var bin = atob(res.base64); var bytes = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([bytes], { type: res.mime || 'application/octet-stream' })); a.download = res.name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000); prog.done(res.name + ' を保存しました'); }).catch(function (e) { prog.done(e.message || 'ファイルを開けませんでした', 'error'); });}/* ------------------------------------------------------- 表(テーブル) */function newGrid(rows, cols) { var g = []; for (var r = 0; r < rows; r++) { var row = []; for (var c = 0; c < cols; c++) row.push({ t: '', head: r === 0 }); g.push(row); } return g;}function gridToHtmlInner(g) { return g.map(function (row) { return '<tr>' + row.map(function (cell) { var tag = cell.head ? 'th' : 'td'; return '<' + tag + '>' + esc(cell.t || '') + '</' + tag + '>'; }).join('') + '</tr>'; }).join('');}/** 結合したセルは読み込むときに分けて、どの行も同じ列数にそろえる */function htmlToGrid(html) { var box = document.createElement('table'); box.innerHTML = sanitize('<table>' + String(html || '') + '</table>').replace(/^<table>|<\/table>$/g, ''); var g = Array.prototype.map.call(box.querySelectorAll('tr'), function (tr) { var row = []; Array.prototype.forEach.call(tr.children, function (td) { var span = Math.max(1, Math.min(20, Number(td.getAttribute('colspan') || 1))); for (var i = 0; i < span; i++) row.push({ t: i ? '' : td.textContent || '', head: td.tagName === 'TH' }); }); return row; }); if (!g.length) return newGrid(3, 3); var cols = Math.max.apply(null, g.map(function (r) { return r.length; })); g.forEach(function (r) { while (r.length < cols) r.push({ t: '', head: false }); }); return g;}function insertTable() { if (!S.quill) return; var r = S.quill.getSelection(true); S.quill.insertEmbed(r ? r.index : 0, 'wtable', gridToHtmlInner(newGrid(3, 3)), 'user'); S.quill.insertText((r ? r.index : 0) + 1, '\n', 'user'); markDirty(); setTimeout(function () { openTableEditor(lastTableNode()); }, 30);}function insertDivider() { if (!S.quill) return; var r = S.quill.getSelection(true); var at = r ? r.index : 0; S.quill.insertEmbed(at, 'divider', true, 'user'); S.quill.setSelection(at + 1, 0, 'silent'); markDirty();}function lastTableNode() { var all = document.querySelectorAll('.ql-editor table.wtable'); return all.length ? all[all.length - 1] : null;}function openTableEditor(node) { if (!node) return; S.tableEdit = { node: node, grid: htmlToGrid(node.innerHTML), sel: null }; renderTableEditor();}function closeTableEditor() { S.tableEdit = null; document.getElementById('modals').innerHTML = '';}function renderTableEditor() { var t = S.tableEdit; if (!t) return; var cols = t.grid[0].length; var sel = t.sel; var rowsHtml = t.grid.map(function (row, r) { return '<tr>' + row.map(function (cell, c) { var tag = cell.head ? 'th' : 'td'; var on = sel && sel.r === r && sel.c === c; return '<' + tag + ' class="' + (on ? 'sel' : '') + '" data-r="' + r + '" data-c="' + c + '">' + '<input value="' + esc(cell.t || '') + '" data-r="' + r + '" data-c="' + c + '"></' + tag + '>'; }).join('') + '</tr>'; }).join(''); document.getElementById('modals').innerHTML = '<div class="modal-bg" data-act="closeTable"><div class="modal wide" data-stop="1" style="max-width:720px">' + '<div class="mh"><h3>表を編集</h3><span class="sub muted">セルを選ぶと、その位置で行や列を足せます</span>' + '<span class="sp"></span><span class="x" data-act="closeTable">✕</span></div>' + '<div class="tbl-bar">' + '<button class="btn btn-sm" data-act="tRow" data-id="1">行を追加</button>' + '<button class="btn btn-sm" data-act="tRow" data-id="-1">行を削除</button>' + '<button class="btn btn-sm" data-act="tCol" data-id="1">列を追加</button>' + '<button class="btn btn-sm" data-act="tCol" data-id="-1">列を削除</button>' + '<span style="width:1px;height:20px;background:var(--app-border)"></span>' + '<button class="btn btn-sm" data-act="tHead"' + (sel ? '' : ' disabled') + '>見出しの行にする/戻す</button>' + '<span class="muted" style="margin-left:auto">' + t.grid.length + '行 × ' + cols + '列</span>' + '</div>' + '<div class="tbl-wrap"><table class="wtable edit">' + rowsHtml + '</table></div>' + '<div class="foot" style="padding:12px 18px;border-top:1px solid var(--app-border)">' + '<button class="btn" data-act="closeTable">やめる</button>' + '<button class="btn btn-primary" data-act="tApply">表を反映</button></div>' + '</div></div>'; var wrap = document.querySelector('.tbl-wrap'); if (!wrap) return; wrap.addEventListener('input', function (e) { var i = e.target; if (i.tagName !== 'INPUT') return; var cell = t.grid[Number(i.getAttribute('data-r'))][Number(i.getAttribute('data-c'))]; if (cell) cell.t = i.value; }); wrap.addEventListener('focusin', function (e) { var td = e.target.closest('td,th'); if (!td) return; t.sel = { r: Number(td.getAttribute('data-r')), c: Number(td.getAttribute('data-c')) }; Array.prototype.forEach.call(wrap.querySelectorAll('td,th'), function (x) { x.classList.toggle('sel', x === td); }); var head = document.querySelector('[data-act="tHead"]'); if (head) head.disabled = false; });}function focusTableCell() { var s = S.tableEdit && S.tableEdit.sel; if (!s) return; var input = document.querySelector('.tbl-wrap input[data-r="' + s.r + '"][data-c="' + s.c + '"]'); if (input) input.focus();}function tableRow(delta) { var t = S.tableEdit; var cols = t.grid[0].length; if (delta > 0) { var row = []; for (var c = 0; c < cols; c++) row.push({ t: '', head: false }); var at = t.sel ? t.sel.r + 1 : t.grid.length; t.grid.splice(at, 0, row); if (t.sel) t.sel = { r: at, c: t.sel.c }; } else if (t.grid.length > 1) { var drop = t.sel ? t.sel.r : t.grid.length - 1; t.grid.splice(drop, 1); t.sel = null; } renderTableEditor(); focusTableCell();}function tableCol(delta) { var t = S.tableEdit; var cols = t.grid[0].length; if (delta > 0) { var at = t.sel ? t.sel.c + 1 : cols; t.grid.forEach(function (row) { row.splice(at, 0, { t: '', head: row[0] && row[0].head }); }); if (t.sel) t.sel = { r: t.sel.r, c: at }; } else if (cols > 1) { var drop = t.sel ? t.sel.c : cols - 1; t.grid.forEach(function (row) { row.splice(drop, 1); }); t.sel = null; } renderTableEditor(); focusTableCell();}function tableHead() { var t = S.tableEdit; if (!t || !t.sel) return; var row = t.grid[t.sel.r]; var next = !row[0].head; row.forEach(function (cell) { cell.head = next; }); renderTableEditor(); focusTableCell();}function applyTable() { var t = S.tableEdit; if (!t || !t.node || !S.quill) { closeTableEditor(); return; } var blot = Quill.find(t.node); var index = blot ? S.quill.getIndex(blot) : null; var html = gridToHtmlInner(t.grid); if (index === null) { t.node.innerHTML = html; } else { S.quill.deleteText(index, 1, 'user'); S.quill.insertEmbed(index, 'wtable', html, 'user'); } markDirty(); closeTableEditor(); updateEditCount();}/* ------------------------------------------- 本文への添付リンク挿入 *//** ツールバーの「添付を入れる」。添付がまだなければ、そのままファイルを選んで本文にリンクを入れる */function openAttachPicker() { var files = (S.draft && S.draft.attachments) || []; if (!files.length) { pickFile(true); return; } S.modal = { kind: 'attachPick' }; document.getElementById('modals').innerHTML = '<div class="modal-bg top" data-act="closeModal"><div class="modal pick" data-stop="1">' + '<div class="pick-hd"><b>本文に入れるファイルを選ぶ</b>' + '<div class="muted" style="margin-top:2px">右の「添付ファイル」にあるファイルを本文中から呼べます</div></div>' + '<div class="pick-bd">' + '<div class="file-row" data-act="attachNew" style="cursor:pointer"><span class="ext">+</span>' + '<span class="fname">新しいファイルを選んで入れる</span><span class="fsize">10MBまで</span></div>' + (files.length ? files.map(function (a) { return '<div class="file-row" data-act="insAttach" data-id="' + esc(a.id) + '" style="cursor:pointer">' + '<span class="ext">' + esc(ext(a.name)) + '</span><span class="fname">' + esc(a.name) + '</span>' + '<span class="fsize">' + esc(sizeLabel(a.size)) + '</span></div>'; }).join('') : '<div class="empty" style="padding:26px 16px"><b>添付ファイルがありません</b>' + '<p>右の「添付ファイル」にドラッグしてから選んでください。</p></div>') + '</div></div></div>';}function insertAttachLink(fileId, range) { var files = (S.draft && S.draft.attachments) || []; var f = files.filter(function (a) { return a.id === fileId; })[0]; closeModal(); if (!f || !S.quill) return; var r = range || S.quill.getSelection(true); var at = r ? Math.min(r.index, S.quill.getLength() - 1) : 0; S.quill.insertText(at, f.name, { link: '#/file/' + f.id }, 'user'); S.quill.setSelection(at + f.name.length, 0, 'silent'); S.quill.format('link', false, 'user'); markDirty();}/* ------------------------------------------------------------ 自動保存 * 下書きのときだけ。書いている間は20秒に1回までにする。 */function scheduleAutosave() { if (!S.draft || S.draft.status !== 'draft' || S.autoStopped || S.autoTimer) return; S.autoTimer = setTimeout(function () { S.autoTimer = null; autosaveDraft(); }, 20000);}function autosaveDraft() { if (S.route !== 'edit' || !S.draft || S.draft.status !== 'draft' || !S.dirty || S.autosaving) return; var p = byId(S.pageId); if (!p) return; syncDraft(); var d = S.draft, seq = S.editSeq; S.autosaving = true; showAutosave('自動保存しています…'); run('savePage', p.id, draftPayload(d, { autosave: true, changeNote: '' })).then(function (res) { S.autosaving = false; showAutosave(''); if (!S.draft || S.editSeq !== seq) return; upsert(res.page); S.draft.updated_at = res.page.updatedAt; var st = document.getElementById('editState'); if (st && S.quill && stripImageData(S.quill.root.innerHTML) === d.html) { S.dirty = false; st.textContent = '自動保存しました ' + new Date().toTimeString().slice(0, 5); st.classList.remove('dirty'); } }).catch(function (e) { S.autosaving = false; if (e.code === 'CONFLICT') { // 勝手に上書きしない。手で保存したときに確認を出す S.autoStopped = true; showAutosave('ほかの人が保存したため、自動保存を止めました'); return; } showAutosave(''); if (e.code !== 'AUTH') console.warn('autosave', e); });}function showAutosave(text) { var el = document.getElementById('autoState'); if (!el) return; el.innerHTML = text ? (S.autosaving ? '<span class="spin sm"></span>' : '') + esc(text) : '';}/* ------------------------------------------------------ Quill */function registerBlots() { if (!window.Quill || window.__wikiBlots) return; var Inline = Quill.import('blots/inline'); ['hl', 'imp', 'sub'].forEach(function (n) { var C = class extends Inline { static formats() { return true; } }; C.blotName = n; C.className = 'mk-' + n; C.tagName = 'span'; Quill.register(C, true); }); var Block = Quill.import('blots/block'); var Callout = class extends Block { static create(value) { var n = super.create(); n.setAttribute('data-callout', CALLOUTS[value] ? value : 'warn'); return n; } static formats(node) { return node.getAttribute('data-callout') || 'warn'; } }; Callout.blotName = 'callout'; Callout.tagName = 'DIV'; Quill.register(Callout, true); var BlockEmbed = Quill.import('blots/block/embed'); var Table = class extends BlockEmbed { static create(value) { var n = super.create(); n.setAttribute('class', 'wtable'); n.innerHTML = sanitize('<table>' + (value || gridToHtmlInner(newGrid(3, 3))) + '</table>').replace(/^<table[^>]*>|<\/table>$/g, ''); return n; } static value(node) { return node.innerHTML; } }; Table.blotName = 'wtable'; Table.tagName = 'TABLE'; Table.className = 'wtable'; Quill.register(Table, true); var Divider = class extends BlockEmbed { static create() { return super.create(); } static value() { return true; } }; Divider.blotName = 'divider'; Divider.tagName = 'HR'; Quill.register(Divider, true); // 画像は URL でなく添付ファイルの ID を持つ(中身は表示するときに受け取って入れる) var EmbedBlot = Quill.import('parchment').EmbedBlot; var DriveImage = class extends EmbedBlot { static create(value) { var n = super.create(); var id = String((value && value.id) || value || ''); n.setAttribute('data-drive-id', id); n.setAttribute('alt', ''); if (S.imgCache[id]) n.setAttribute('src', S.imgCache[id]); else n.setAttribute('class', 'img-wait'); return n; } static value(node) { return node.getAttribute('data-drive-id') || ''; } }; DriveImage.blotName = 'image'; DriveImage.tagName = 'IMG'; Quill.register(DriveImage, true); window.__wikiBlots = true;}var CALLOUTS = { warn: '注意', point: 'ポイント', note: '補足', pre: '前提' };/** * 注意カードの中で Enter を押したら、カードから出て普通の行になるようにする。 * カードの中で改行したいときは Shift+Enter。 */function fixCalloutEnter(q) { var list = q.keyboard && q.keyboard.bindings && (q.keyboard.bindings['Enter'] || q.keyboard.bindings[13]); if (!list || !list.unshift) return; list.unshift({ key: 'Enter', shiftKey: false, collapsed: true, format: ['callout'], handler: function (range) { var qq = this.quill; var line = qq.getLine(range.index)[0]; var text = line && line.domNode ? line.domNode.textContent : ''; if (!text.trim()) { qq.formatLine(range.index, 1, 'callout', false, 'user'); // 空のカードはその場で外す return false; } qq.insertText(range.index, '\n', 'user'); qq.formatLine(range.index + 1, 1, 'callout', false, 'user'); qq.setSelection(range.index + 1, 0, 'silent'); return false; } });}var CALLOUT_HINTED = false;function calloutHint() { if (CALLOUT_HINTED) return; CALLOUT_HINTED = true; toast('カードの中で改行するときは Shift+Enter。Enter を押すとカードの外に出ます');}function mountEditor() { var host = document.getElementById('editor'); if (!host || !window.Quill) return; registerBlots(); var q = new Quill(host, { theme: 'snow', placeholder: '本文を書く', formats: ['header', 'bold', 'list', 'blockquote', 'code-block', 'code', 'link', 'image', 'hl', 'imp', 'sub', 'callout', 'wtable', 'strike', 'divider'], modules: { history: { delay: 700, maxStack: 200, userOnly: true }, toolbar: { container: [ [{ header: [2, 3, false] }], ['bold', 'strike', 'hl', 'imp', 'sub'], [{ list: 'bullet' }, { list: 'ordered' }, { list: 'check' }], [{ callout: ['warn', 'point', 'note', 'pre'] }], ['blockquote', 'code-block', 'wtable', 'divider'], ['link', 'pagelink', 'image', 'attach', 'md'], ['clean', 'undo', 'redo'] ], handlers: { image: insertImage, pagelink: function () { openPicker(); }, attach: function () { openAttachPicker(); }, md: function () { openMarkdownImport(); }, wtable: function () { insertTable(); }, divider: function () { insertDivider(); }, callout: function (value) { var cur = this.quill.getFormat().callout; this.quill.format('callout', cur === value ? false : value, 'user'); if (cur !== value) calloutHint(); }, undo: function () { this.quill.history.undo(); }, redo: function () { this.quill.history.redo(); }, list: function (value) { var v = value === 'check' ? 'unchecked' : value; var cur = this.quill.getFormat().list; var same = cur === v || (v === 'unchecked' && (cur === 'checked' || cur === 'unchecked')); this.quill.format('list', same ? false : v, 'user'); } } }, clipboard: { matchVisual: false } } }); var KEEP = ['bold', 'link', 'header', 'list', 'code', 'code-block', 'blockquote', 'hl', 'imp', 'sub', 'callout', 'strike']; q.clipboard.addMatcher(Node.ELEMENT_NODE, function (node, delta) { delta.ops.forEach(function (op) { if (op.attributes) { Object.keys(op.attributes).forEach(function (key) { if (KEEP.indexOf(key) < 0) delete op.attributes[key]; }); } }); return delta; }); // 外から貼った画像は、添付として保存できないので落とす(画像は「画像」ボタンから入れる) q.clipboard.addMatcher('IMG', function (node, delta) { var Delta = Quill.import('delta'); return node.getAttribute('data-drive-id') ? delta : new Delta(); }); // 書式なしで貼られた文字がマークダウンなら、書式に直して入れる。 // Quill より先に受け取る必要があるので、編集領域の親で捕まえる。 host.addEventListener('paste', function (e) { if (!S.quill || !e.clipboardData) return; var types = Array.prototype.slice.call(e.clipboardData.types || []); if (types.indexOf('text/html') >= 0) return; var text = e.clipboardData.getData('text/plain'); if (!looksMarkdown(text)) return; e.preventDefault(); e.stopPropagation(); insertMarkdown(text); }, true); if (S.draft.html) q.clipboard.dangerouslyPasteHTML(sanitize(S.draft.html)); q.history.clear(); q.on('text-change', function (d, o, source) { if (source === 'user') markDirty(); updateEditCount(); }); S.quill = q; fixCalloutEnter(q); hydrateImages(q.root); var slot = document.getElementById('editTools'); var tb = host.parentNode.querySelector('.ql-toolbar'); if (slot && tb) slot.appendChild(tb); updateEditCount(); var title = document.getElementById('title'); if (title) title.addEventListener('input', function () { S.draft.title = title.value; markDirty(); }); var memo = document.getElementById('changeNote'); if (memo) memo.addEventListener('input', function () { S.draft.changeNote = memo.value; }); var canvasEl = document.getElementById('editCanvas'); if (canvasEl) canvasEl.addEventListener('dblclick', function (e) { var table = e.target.closest('table.wtable'); if (table) { e.preventDefault(); openTableEditor(table); } }); var note = document.getElementById('depNote'); if (note) note.addEventListener('input', function () { S.draft.note = note.value; markDirty(); }); var owner = document.getElementById('ownerSel'); if (owner) owner.addEventListener('change', function () { S.draft.owner = owner.value; markDirty(); }); var due = document.getElementById('reviewDue'); if (due) due.addEventListener('change', function () { S.draft.reviewDue = due.value; markDirty(); }); var tagInput = document.getElementById('tagInput'); if (tagInput) { tagInput.addEventListener('keydown', function (e) { if (e.isComposing) return; if (e.key === 'Enter' || e.key === ',' || e.key === '、') { e.preventDefault(); addTag(tagInput.value); tagInput.value = ''; } else if (e.key === 'Backspace' && !tagInput.value && S.draft.tags.length) { e.preventDefault(); removeTag(S.draft.tags[S.draft.tags.length - 1]); } }); } var drop = document.getElementById('drop'); if (drop) { drop.addEventListener('click', function () { pickFile(false); }); drop.addEventListener('dragover', function (e) { e.preventDefault(); drop.classList.add('over'); }); drop.addEventListener('dragleave', function () { drop.classList.remove('over'); }); drop.addEventListener('drop', function (e) { e.preventDefault(); drop.classList.remove('over'); Array.prototype.forEach.call(e.dataTransfer.files || [], uploadFile); }); }}function updateEditCount() { var el = document.getElementById('editCount'); if (!el || !S.quill) return; el.textContent = S.quill.getText().replace(/\s/g, '').length + '文字';}function markDirty() { S.dirty = true; var el = document.getElementById('editState'); if (el) { el.textContent = '未保存の変更があります'; el.classList.add('dirty'); } scheduleAutosave();}function insertImage() { var input = document.createElement('input'); input.type = 'file'; input.accept = 'image/png,image/jpeg,image/gif,image/webp'; input.onchange = function () { var file = input.files && input.files[0]; if (!file) return; if (file.size > MAX_FILE) { toast('画像は10MBまでです', 'error'); return; } var prog = progressToast('画像をアップロードしています…'); var seq = S.editSeq; readBase64(file, function (dataUrl) { run('uploadFile', S.pageId, { name: file.name, mime: file.type, base64: dataUrl.split(',')[1] }).then(function (res) { prog.done(file.name + ' を入れました'); if (!S.quill || S.editSeq !== seq) return; S.imgCache[res.file.id] = dataUrl; // 今選んだ画像は手元にあるのでそれを使う syncDraft(); S.draft.attachments.push(res.file); var range = S.quill.getSelection(true) || { index: S.quill.getLength() - 1 }; S.quill.insertEmbed(range.index, 'image', res.file.id, 'user'); S.quill.setSelection(range.index + 1, 0, 'user'); markDirty(); }).catch(function (e) { prog.done(e.message || '画像を入れられませんでした', 'error'); }); }); }; input.click();}/* ------------------------------------------------------------ 本文の画像 * このアプリはサンドボックスされた iframe の中で動くため、Drive の画像URLを * <img src> に直接書くとブラウザに遮断される。本文には添付ファイルの ID だけを持たせ、 * 表示するときに中身を受け取って入れる。 */function hydrateImages(root) { var host = root || document; var pageId = S.pageId; Array.prototype.forEach.call(host.querySelectorAll('img[data-drive-id]'), function (img) { var id = img.getAttribute('data-drive-id'); var hit = S.imgCache[id]; if (hit) { if (img.getAttribute('src') !== hit) img.setAttribute('src', hit); img.classList.remove('img-wait', 'img-ng'); return; } img.classList.add('img-wait'); img.removeAttribute('src'); if (S.imgLoading[id] || !pageId) return; S.imgLoading[id] = true; run('getImage', pageId, id).then(function (res) { S.imgLoading[id] = false; if (!/^data:image\/(png|jpeg|gif|webp|svg\+xml);base64,/.test(res.dataUrl || '')) { applyImage(id, ''); return; } S.imgCache[id] = res.dataUrl; applyImage(id, res.dataUrl); }).catch(function () { S.imgLoading[id] = false; applyImage(id, ''); }); });}function applyImage(id, dataUrl) { Array.prototype.forEach.call(document.querySelectorAll('img[data-drive-id]'), function (img) { if (img.getAttribute('data-drive-id') !== id) return; img.classList.remove('img-wait'); if (dataUrl) { img.setAttribute('src', dataUrl); img.classList.remove('img-ng'); } else img.classList.add('img-ng'); });}/** 保存する本文には画像の中身を持たせない(ファイルIDだけにする) */function stripImageData(html) { var d = document.createElement('div'); d.innerHTML = String(html || ''); Array.prototype.forEach.call(d.querySelectorAll('img'), function (img) { var id = img.getAttribute('data-drive-id'); if (!id) { img.parentNode.removeChild(img); return; } img.removeAttribute('src'); img.classList.remove('img-wait', 'img-ng'); if (!img.getAttribute('class')) img.removeAttribute('class'); }); return d.innerHTML;}/* ------------------------------------------------- ページリンクの挿入 */function openPicker(query) { S.picker = { q: query || '' }; renderPicker();}function renderPicker() { var q = (S.picker.q || '').trim().toLowerCase(); var cand = S.pages.filter(function (p) { return p.id !== S.pageId && (!q || (p.title + ' ' + pathLabel(p.id)).toLowerCase().indexOf(q) >= 0); }).slice(0, 30); document.getElementById('modals').innerHTML = '<div class="modal-bg top" data-act="closePicker"><div class="modal pick" data-stop="1">' + '<div class="pick-hd"><b>リンクするページを選ぶ</b>' + '<input id="pickQ" type="search" placeholder="ページ名で絞り込む" value="' + esc(S.picker.q) + '" autocomplete="off"></div>' + '<div class="pick-bd">' + (cand.length ? cand.map(function (p) { return '<div class="row" data-act="pick" data-id="' + p.id + '"><div class="t">' + '<div class="tt"><span class="name">' + esc(p.title) + '</span>' + badges(p) + '</div>' + '<div class="path">' + esc(pathLabel(p.id)) + '</div></div></div>'; }).join('') : '<div class="empty"><b>一致するページはありません</b><p>別の言い方で探してください。</p></div>') + '</div></div></div>'; var input = document.getElementById('pickQ'); if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); input.addEventListener('input', function () { S.picker.q = input.value; renderPicker(); }); }}function insertPageLink(id) { var p = byId(id); closePicker(); if (!p || !S.quill) return; var sel = S.quill.getSelection(true) || { index: Math.max(0, S.quill.getLength() - 1), length: 0 }; if (sel.length) S.quill.deleteText(sel.index, sel.length, 'user'); S.quill.insertText(sel.index, p.title, { link: '#/page/' + p.id }, 'user'); S.quill.setSelection(sel.index + p.title.length, 0, 'user'); S.quill.format('link', false, 'user'); markDirty();}function closePicker() { S.picker = null; document.getElementById('modals').innerHTML = ''; }/* ------------------------------------------------------------ モーダル */function openModal(opt) { if (S.modal && S.modal.locked) return; S.modal = opt; document.getElementById('modals').innerHTML = '<div class="modal-bg" data-act="closeModal"><div class="modal' + (opt.wide ? ' wide' : '') + (opt.cls ? ' ' + opt.cls : '') + '" data-stop="1">' + '<h3>' + esc(opt.title) + '</h3>' + (opt.html ? opt.html : '<p>' + esc(opt.body) + '</p>') + '<div class="foot"><button class="btn" data-act="closeModal">やめる</button>' + '<button class="btn ' + (opt.okClass || 'btn-danger') + '" data-act="modalOk">' + esc(opt.label) + '</button></div></div></div>';}function closeModal() { if (S.modal && S.modal.locked) return; S.modal = null; document.getElementById('modals').innerHTML = '';}/* ------------------------------------------------------------ 設定パネル * 右からスライドして出す設定カード。セクションを配列で持たせているので、 * 増やすときはここに1つ足す。show が false のセクションは出さない。 */var SETTINGS_SECTIONS = [ { id: 'profile', title: '表示名', sub: 'コメントや更新履歴に表示される名前です。', body: function () { return '<div class="set-row"><input type="text" id="displayNameInput" value="' + esc(S.set.name) + '" maxlength="40">' + '<button class="btn btn-primary" data-act="saveDisplayName">保存</button></div>' + (S.set.nameSaved ? '<div class="set-hint">保存しました</div>' : ''); } }, { id: 'site', title: 'wiki の名前', sub: 'ヘッダーとメールに使う名前です。変えると全員の画面に反映されます。', show: isAdmin, body: function () { return '<div class="set-row"><input type="text" id="siteNameInput" value="' + esc(S.set.site.name) + '" maxlength="30">' + '<button class="btn btn-primary" data-act="askSite">保存</button></div>' + (S.set.siteSaved ? '<div class="set-hint">保存しました</div>' : ''); } }, { id: 'mail', title: '更新のお知らせ', sub: '通知をオンにしたページが更新されたときに、メールを受け取れます。受け取るときだけアドレスを入れてください。', body: function () { // サーバーは伏せた形しか返さない。入力欄には入れず、変えるときだけ打ってもらう var hint = S.me && S.me.emailHint; return '<div class="set-row"><input type="email" id="mailInput" value="' + esc(S.set.email) + '" maxlength="200" placeholder="' + (hint ? '別のアドレスに変えるときだけ入力' : 'メールを受け取らないなら空のまま') + '">' + '<button class="btn btn-primary" data-act="saveEmail">保存</button></div>' + (hint ? '<div class="set-hint">受け取り中: ' + esc(hint) + ' <button class="btn btn-sm btn-quiet" data-act="stopEmail">受け取りをやめる</button></div>' : '') + (S.set.mailSaved ? '<div class="set-hint">保存しました</div>' : '') + '<div class="set-hint">メールは、この wiki を設置した人の Gmail から届きます。</div>'; } }, { id: 'review', title: '見直し期限のお知らせ', sub: '期限が近いページの担当者に、毎朝メールを送ります。送るのは設置した人の Gmail からです。', show: isAdmin, body: function () { if (S.reviewMail === null) return '<div class="sk" style="height:34px;border-radius:var(--app-r-sm)"></div>'; // 設置者の Gmail から設置者の枠で送るので、切り替えるのは設置者だけ。ほかの管理者には状態だけ見せる var mine = !!(S.me && S.me.owner); return '<div class="dep-row"><div class="t"><b>毎朝のお知らせを送る</b>' + '<span>期限の7日前と当日に届きます。切り替えは設置した人のトリガーに反映されます。</span></div>' + (mine ? '<span class="switch' + (S.reviewMail ? ' on' : '') + '" data-act="reviewMail"><i></i></span>' : '<span class="muted">' + (S.reviewMail ? 'オン' : 'オフ') + '</span>') + '</div>' + (mine ? '' : '<div class="set-hint">切り替えられるのは、設置した人だけです。</div>'); } }, { id: 'joinCode', title: '参加の合言葉', sub: '決めておくと、新しく参加する人と、別の端末から入り直す人に合言葉を聞きます。URL を広く公開するときに使ってください。', show: isAdmin, body: function () { if (S.joinCode === null) return '<div class="sk" style="height:34px;border-radius:var(--app-r-sm)"></div>'; // 画面共有やのぞき見で漏れないように、ふだんは伏せておく var shown = S.joinCode && S.showJoinCode ? esc(S.joinCode) : '••••••••'; // 自分で決めた言葉は当てられやすいので、作るほうを主にする return '<div class="set-row"><button class="btn btn-primary" data-act="makeJoinCode">' + (S.joinCode ? '合言葉を作り直す' : '合言葉を作る') + '</button>' + '<span class="set-hint" style="margin:0">読み間違えにくい12文字を作って、そのまま決めます</span></div>' + '<div class="set-row"><input type="text" id="joinCodeInput" value="' + esc(S.set.joinCode) + '" maxlength="40" placeholder="' + '自分で決めるときは、記号を除いて8文字以上' + '" autocomplete="off">' + '<button class="btn" data-act="saveJoinCode">保存</button></div>' + '<div class="set-hint">' + (S.joinCode ? '今の合言葉: ' + shown + ' <button class="btn btn-sm btn-quiet" data-act="toggleJoinCode">' + (S.showJoinCode ? '隠す' : '表示') + '</button>' + '<button class="btn btn-sm btn-quiet" data-act="clearJoinCode">合言葉をなくす</button>' : '今は合言葉なしです。URL を知っていて、アクセス範囲に入っている人は誰でも参加できます。') + '</div>' + '<div class="set-hint">変えても、すでに参加している人はそのまま使えます。口で伝えるなら、ひらがなと英数字だけにすると打ち間違いが減ります。</div>'; } }, { id: 'members', title: 'メンバー', sub: '名前は本人が決めたものです。誰が開けるかは、ウェブアプリのアクセス範囲(デプロイの設定)で決まります。', show: isAdmin, body: function () { var rows = S.memberRows; if (rows === null) return '<div class="sk" style="height:44px;border-radius:var(--app-r-sm)"></div>'; return '<div class="set-members">' + rows.map(function (m) { var av = avatarOf(m.name); var mine = m.id === S.user; return '<div class="set-member">' + '<span class="av" style="background:' + av.bg + ';color:' + av.fg + '">' + esc(av.initial) + '</span>' + '<div class="t"><b>' + esc(m.name) + (m.owner ? '(設置した人)' : mine ? '(自分)' : '') + '</b>' + '<span>' + lastSeenLabel(m.lastSeenAt) + (m.devices > 1 ? ' · 端末 ' + m.devices + '台' : '') + (m.drafts ? ' · 下書き ' + m.drafts + '件' : '') + (m.email ? ' · メール受け取り中' : '') + '</span></div>' + '<span class="acts">' + (m.owner ? '<span class="muted">管理者</span>' : '<button class="btn btn-sm btn-quiet" data-act="role" data-id="' + esc(m.id) + '">' + (m.role === 'admin' ? '管理者' : 'メンバー') + '</button>' + (mine ? '' : '<button class="btn btn-sm btn-quiet danger" data-act="removeMember" data-id="' + esc(m.id) + '">消す</button>')) + '</span></div>'; }).join('') + '</div>' + '<div class="set-hint">消しても、同じ URL を開けばまた参加できます。名前の整理に使ってください。</div>'; } }];function openSettings() { S.settingsOpen = true; S.set = { name: S.me ? S.me.name : '', nameSaved: false, email: '', mailSaved: false, joinCode: '', site: Object.assign({}, S.site), siteSaved: false }; S.memberRows = null; S.reviewMail = null; S.joinCode = null; S.showJoinCode = false; renderSettings(); if (isAdmin()) loadMembers();}function lastSeenLabel(at) { if (!at) return 'まだ開いていません'; var r = rel(at); return r === 'たった今' ? '今ひらいています' : r + 'に利用';}function loadMembers() { run('listMembers').then(function (res) { // 設置者を先頭に、あとは最近使った順 S.memberRows = (res.members || []).slice().sort(function (a, b) { return Number(b.owner) - Number(a.owner) || String(b.lastSeenAt).localeCompare(String(a.lastSeenAt)); }); S.reviewMail = res.reviewMail === true; S.joinCode = res.joinCode || ''; if (S.settingsOpen) renderSettings(); }).catch(fail);}/** 毎朝のお知らせ。トリガーの登録と解除をそのまま切り替える */function toggleReviewMail() { if (S.reviewMail === null || !(S.me && S.me.owner)) return; var next = !S.reviewMail; S.reviewMail = next; renderSettings(); run('setReviewMail', next).then(function (res) { S.reviewMail = res.reviewMail === true; if (S.settingsOpen) renderSettings(); toast(S.reviewMail ? '毎朝のお知らせを登録しました' : '毎朝のお知らせを止めました'); }).catch(function (e) { S.reviewMail = !next; if (S.settingsOpen) renderSettings(); fail(e); });}function closeSettings() { var box = document.getElementById('settings'); var panel = box.querySelector('.set-panel'); var scrim = box.querySelector('.set-scrim'); S.settingsOpen = false; if (!panel) return; panel.classList.remove('on'); if (scrim) scrim.classList.remove('on'); setTimeout(function () { if (!S.settingsOpen) box.innerHTML = ''; }, 220);}function renderSettings() { var box = document.getElementById('settings'); if (!S.settingsOpen) { box.innerHTML = ''; return; } var keep = box.querySelector('.set-bd'); var scroll = keep ? keep.scrollTop : 0; var joinCodeInput = document.getElementById('joinCodeInput'); if (joinCodeInput) S.set.joinCode = joinCodeInput.value; var siteInput = document.getElementById('siteNameInput'); if (siteInput) S.set.site.name = siteInput.value; var nameInput = document.getElementById('displayNameInput'); if (nameInput) S.set.name = nameInput.value; var mailInput = document.getElementById('mailInput'); if (mailInput) S.set.email = mailInput.value; box.innerHTML = '<div class="set-scrim on" data-act="closeSettings"></div>' + '<div class="set-panel on">' + '<div class="set-hd"><h2>設定</h2><button class="icon-btn" style="display:flex" data-act="closeSettings" aria-label="閉じる">✕</button></div>' + '<div class="set-bd">' + SETTINGS_SECTIONS.filter(function (s) { return !s.show || s.show(); }).map(function (s) { return '<section class="set-sec"><h3>' + esc(s.title) + '</h3><p class="sub">' + esc(s.sub) + '</p>' + s.body() + '</section>'; }).join('') + '</div>' + '</div>'; var bd = box.querySelector('.set-bd'); if (bd) bd.scrollTop = scroll; var input = document.getElementById('displayNameInput'); if (input) input.onkeydown = function (e) { if (e.key === 'Enter' && !e.isComposing) saveDisplayName(); };}function saveDisplayName() { var input = document.getElementById('displayNameInput'); var name = input ? input.value.trim() : ''; if (!name) { toast('表示名を入力してください', 'warn'); return; } run('setDisplayName', name).then(function (res) { S.me = Object.assign({}, S.me, res.me); S.members = S.members.map(function (m) { return m.id === res.me.id ? Object.assign({}, m, { name: res.me.name }) : m; }); if (S.memberRows) S.memberRows = S.memberRows.map(function (m) { return m.id === res.me.id ? Object.assign({}, m, { name: res.me.name }) : m; }); S.set.name = res.me.name; S.set.nameSaved = true; applyMe(); renderSettings(); if (S.route !== 'edit') render(true); toast('表示名を変更しました'); }).catch(fail);}function askSaveSite() { var input = document.getElementById('siteNameInput'); var name = input ? input.value.trim() : ''; if (!name) { toast('wiki の名前を入力してください', 'warn'); return; } openModal({ title: '全員の画面に反映しますか', body: '「' + name + '」に変わります。ヘッダーの表示と、お知らせメールの送り主の名前に使われます。', label: '反映する', okClass: 'btn-primary', onOk: function () { run('setSite', { name: name }).then(function (res) { closeModal(); S.site = res.site; S.set.site = Object.assign({}, res.site); S.set.siteSaved = true; applySite(res.site); renderSettings(); if (S.route !== 'edit') render(true); toast('wiki の名前を変更しました'); }).catch(function (e) { closeModal(); fail(e); }); } });}function saveEmail() { var input = document.getElementById('mailInput'); var value = input ? input.value.trim() : ''; if (!value && S.me && S.me.emailHint) { toast('新しいアドレスを入力してください。やめるときは「受け取りをやめる」を押します', 'warn'); return; } sendEmail(value);}function sendEmail(value) { run('setEmail', value).then(function (res) { S.me = Object.assign({}, S.me, res.me); // 描き直しは入力中の値を引き継ぐので、先に空にしておく var input = document.getElementById('mailInput'); if (input) input.value = ''; S.set.email = ''; S.set.mailSaved = true; renderSettings(); toast(res.me.emailHint ? '更新のお知らせを受け取ります' : 'メールの受け取りをやめました'); }).catch(fail);}function saveJoinCode() { var input = document.getElementById('joinCodeInput'); var value = input ? input.value.trim() : ''; if (!value) { toast('合言葉を入力してください。なくすときは「合言葉をなくす」を押します', 'warn'); return; } sendJoinCode(value);}/* 1回押しただけで守りが外れたり、配った合言葉が使えなくなったりしないように、確かめてから送る */function askClearJoinCode() { openModal({ title: '合言葉をなくしますか', body: 'URL を知っていて、アクセス範囲に入っている人は、誰でも参加できるようになります。', label: 'なくす', onOk: function () { closeModal(); sendJoinCode(''); } });}function askMakeJoinCode() { if (!S.joinCode) { sendJoinCode(null); return; } openModal({ title: '合言葉を作り直しますか', body: '今の合言葉は使えなくなります。すでに参加している人は、そのまま使えます。', label: '作り直す', okClass: 'btn-primary', onOk: function () { closeModal(); sendJoinCode(null); } });}/** value が null なら、サーバーに作ってもらう */function sendJoinCode(value) { var call = value === null ? run('makeJoinCode') : run('setJoinCode', value); call.then(function (res) { S.joinCode = res.joinCode || ''; // 作ったときは伝えるために見せる S.showJoinCode = value === null; // 描き直しは入力中の値を引き継ぐので、先に空にしておく var input = document.getElementById('joinCodeInput'); if (input) input.value = ''; S.set.joinCode = ''; renderSettings(); toast(S.joinCode ? '合言葉を決めました。新しく参加する人に伝えてください' : '合言葉をなくしました'); }).catch(fail);}function removeMember(memberId) { var row = (S.memberRows || []).filter(function (m) { return m.id === memberId; })[0]; openModal({ title: (row ? row.name : 'この人') + ' さんを消しますか', body: '書いたページやコメントは残ります。同じ URL を開けば、また名前を決めて参加できます。', label: '消す', onOk: function () { run('removeMember', memberId).then(function (res) { closeModal(); S.members = res.members || S.members; loadMembers(); toast('メンバーから消しました'); }).catch(function (e) { closeModal(); fail(e); }); } });}function toggleRole(memberId) { var row = (S.memberRows || []).filter(function (m) { return m.id === memberId; })[0]; if (!row) return; var next = row.role === 'admin' ? 'member' : 'admin'; openModal({ title: row.name + ' さんを' + (next === 'admin' ? '管理者にしますか' : '一般のメンバーにしますか'), body: next === 'admin' ? '管理者は、wiki の名前を変えたり、メンバーを整理したりできます。' : 'wiki の設定とメンバーの整理ができなくなります。', label: '変更する', okClass: 'btn-primary', onOk: function () { run('setRole', memberId, next).then(function (res) { closeModal(); S.members = res.members || S.members; if (memberId === S.user) S.me = Object.assign({}, S.me, { role: next }); loadMembers(); }).catch(function (e) { closeModal(); fail(e); }); } });}function applyMe() { var me = document.getElementById('me'); if (!me || !S.me) return; var av = avatarOf(S.me.name); me.textContent = av.initial; me.title = S.me.name; me.style.background = av.bg; me.style.color = av.fg;}/* ------------------------------------------------------------ 検索実行 */function doSearch(term) { if (!leaveGuard(function () { doSearch(term); })) return; S.term = term; S.q = term; S.route = 'search'; S.searching = true; S.results = null; S.searchSuggest = []; document.body.classList.remove('drawer'); render(); run('searchPages', term).then(function (res) { if (S.term !== term) return; S.searching = false; S.results = searchRows(res.results); S.searchSuggest = res.suggest || []; if (S.route === 'search') render(); }).catch(function (e) { S.searching = false; S.results = []; render(); fail(e); });}/* ------------------------------------------------------------ イベント *//* 触っている間だけ「押している」印を付ける。 * iOS の Safari は :active が当てにならないので、CSS だけに任せない。 */var PRESSABLE = '.btn,.icon-btn,.tool,.react,.pill,.tab,.fav-btn,.seg button,.row,.list-row,.idx-row,.nav-row,.cat-row,.cat-card,.fav-row,.rel-row,.result,.pal-row,.tpl-row,.file-row,.tag[data-act]';function bindPress() { var pressed = null; var off = function () { if (pressed) pressed.classList.remove('is-pressed'); pressed = null; }; document.addEventListener('touchstart', function (e) { var el = e.target && e.target.closest ? e.target.closest(PRESSABLE) : null; if (!el) return; off(); pressed = el; el.classList.add('is-pressed'); }, { passive: true }); ['touchend', 'touchcancel', 'touchmove', 'scroll'].forEach(function (name) { document.addEventListener(name, off, { passive: true }); });}/** 画面の骨組みに直接つける分。参加の画面から戻ったときは、要素が作り直されるので付け直す */function bindDom() { bindTreeDrag(); document.getElementById('main').addEventListener('scroll', onScroll, { passive: true });}function bindGlobal() { bindDom(); bindPress(); document.addEventListener('click', function (e) { if (S.lost) return; var link = e.target.closest('a[href^="#/"]'); if (link) { e.preventDefault(); if (link.closest('.ql-editor')) return; // 編集中の本文ではリンクを開かない var href = link.getAttribute('href'); var target = href.split('/').pop(); if (href.indexOf('#/page/') === 0) openPage(target); else if (href.indexOf('#/file/') === 0 && S.pageId) downloadFile(S.pageId, target); return; } var el = e.target.closest('[data-act]'); if (!el) return; var act = el.getAttribute('data-act'); var id = el.getAttribute('data-id'); switch (act) { case 'home': go('home', { pageId: null }); break; case 'open': openPage(id); break; case 'confDiff': conflictShowDiff(); break; case 'confReload': conflictReload(); break; case 'idxSort': S.idxSort = id; updateIdx(); break; case 'idxTag': S.idxTag = id; updateIdx(); break; case 'recent': go('recent'); break; case 'guide': go('guide'); break; case 'stale': go('stale'); break; case 'trash': if (leaveGuard(openTrash)) openTrash(); break; case 'settings': openSettings(); break; case 'closeSettings': closeSettings(); break; case 'saveDisplayName': saveDisplayName(); break; case 'askSite': askSaveSite(); break; case 'saveEmail': saveEmail(); break; case 'stopEmail': sendEmail(''); break; case 'saveJoinCode': saveJoinCode(); break; case 'clearJoinCode': askClearJoinCode(); break; case 'makeJoinCode': askMakeJoinCode(); break; case 'toggleJoinCode': S.showJoinCode = !S.showJoinCode; renderSettings(); break; case 'role': toggleRole(id); break; case 'removeMember': removeMember(id); break; case 'join': joinWiki((document.getElementById('joinName') || {}).value); break; case 'joinAs': joinWiki(id); break; case 'restore': restorePage(id); break; case 'purge': askPurge(id); break; case 'check': toggleCheck(id); break; case 'resetChecks': resetChecks(); break; case 'setDue': setDue(Number(id)); break; case 'newFromTerm': createWithTitle(S.term); break; case 'mention': insertMention(id); break; case 'download': e.preventDefault(); downloadFile(S.pageId, id); break; case 'closeTable': if (e.target === el || el.classList.contains('x') || el.tagName === 'BUTTON') closeTableEditor(); break; case 'tRow': tableRow(Number(id)); break; case 'tCol': tableCol(Number(id)); break; case 'tHead': tableHead(); break; case 'tApply': applyTable(); break; case 'insAttach': insertAttachLink(id); break; case 'attachNew': closeModal(); pickFile(true); break; case 'popular': go('popular'); break; case 'tag': S.tagName = id; go('tag'); break; case 'listTag': S.listTag = id; render(true); break; case 'new': newPage(); break; case 'tpl': createFrom(id); break; case 'edit': startEdit(); break; case 'save': saveEdit(); break; case 'cancel': cancelEdit(); break; case 'askDelete': askDelete(); break; case 'publish': publishPage(); break; case 'fav': toggleFav(); break; case 'watch': toggleWatch(); break; case 'print': window.print(); break; case 'react': react(id); break; case 'reactPop': S.reactPop = true; render(true); break; case 'favPop': S.favPop = !S.favPop; renderFav(); break; case 'addComment': addComment(); break; case 'resolve': resolveComment(id); break; case 'history': openHistory(); break; case 'histSel': histSelect(id); break; case 'diffToggle': S.history.diffOn = !(S.history.diffOn === undefined ? S.diffOn : S.history.diffOn); S.diffOn = S.history.diffOn; renderHistory(); break; case 'restoreRev': restoreRevision(); break; case 'closeHistory': if (e.target === el || el.classList.contains('x')) closeHistory(); break; case 'palette': openPalette(S.route === 'search' ? S.term : ''); break; case 'palRun': paletteRun(Number(id)); break; case 'closePalette': if (e.target === el) closePalette(); break; case 'addTag': addTag(id); break; case 'rmTag': removeTag(id); break; case 'focusTag': if (e.target === el) { var ti = document.getElementById('tagInput'); if (ti) ti.focus(); } break; case 'publishEdit': saveEdit({ publish: true }); break; case 'toDraft': syncDraft(); S.draft.status = 'draft'; S.autoStopped = false; markDirty(); render(true); break; case 'toSide': var side = document.querySelector('.edit-side'); if (side) side.scrollIntoView({ behavior: 'smooth', block: 'start' }); break; case 'orderUp': movePageStep(-1); break; case 'orderDown': movePageStep(1); break; case 'vis': syncDraft(); S.draft.visibility = id; if (id === 'members' && !S.draft.members.length) S.draft.members = [S.user]; markDirty(); render(true); break; case 'member': syncDraft(); var ms = S.draft.members.slice(); var at = ms.indexOf(id); if (at >= 0) ms.splice(at, 1); else ms.push(id); S.draft.members = ms; markDirty(); render(true); break; case 'dep': syncDraft(); S.draft.deprecated = !S.draft.deprecated; markDirty(); render(true); break; case 'rmFile': removeFile(id); break; case 'toc': case 'anchor': scrollToId(id); break; case 'pickParent': openParentPicker(); break; case 'setParent': setParent(id); break; case 'closeParentPicker': if (e.target === el) closeParentPicker(); break; case 'pick': insertPageLink(id); break; case 'closePicker': if (e.target === el) closePicker(); break; case 'closeModal': if (e.target === el || el.tagName === 'BUTTON') closeModal(); break; case 'mdPrompt': copyText(MD_PROMPT, 'AI に頼む文をコピーしました'); break; case 'reviewMail': toggleReviewMail(); break; case 'mdFile': pickMarkdownFile(); break; case 'modalOk': if (S.modal && S.modal.onOk) S.modal.onOk(); break; case 'drawer': document.body.classList.toggle('drawer'); break; case 'closeDrawer': document.body.classList.remove('drawer'); break; } }); document.addEventListener('click', function (e) { if (S.reactPop && !e.target.closest('.reactions')) { S.reactPop = false; render(true); } if (S.favPop && !e.target.closest('#favWrap')) { S.favPop = false; renderFav(); } }, true); document.addEventListener('input', function (e) { var id = e.target && e.target.id; if (id === 'idxFilter') { S.idxFilter = e.target.value; var ip = byId(S.pageId), host = document.querySelector('.idx .card-bd'); if (ip && host) host.innerHTML = idxRowsHtml(ip); } if (id === 'listFilter') { S.listFilter = e.target.value; updateHomeList(); } if (id === 'cmtText') renderMention(); }); document.addEventListener('keydown', function (e) { if (S.lost) return; var typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable; if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); if (S.palette) closePalette(); else openPalette(''); return; } if (S.palette) { if (e.isComposing) return; if (e.key === 'ArrowDown') { e.preventDefault(); paletteMove(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); paletteMove(-1); return; } if (e.key === 'Enter') { e.preventDefault(); paletteRun(); return; } if (e.key === 'Escape') { e.preventDefault(); closePalette(); return; } } if (e.key === '/' && !typing) { e.preventDefault(); openPalette(''); return; } if (e.key === 'Escape') { if (S.tableEdit) { closeTableEditor(); return; } if (S.favPop) { S.favPop = false; renderFav(); return; } if (S.history) closeHistory(); else if (S.picker) closePicker(); else if (S.parentPick) closeParentPicker(); else if (S.modal) closeModal(); else if (S.settingsOpen) closeSettings(); else document.body.classList.remove('drawer'); return; } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { if (S.route === 'edit') { e.preventDefault(); saveEdit(); } return; } if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && e.target.id === 'cmtText') { e.preventDefault(); addComment(); return; } if (e.key === 'Enter' && !e.isComposing && e.target.id === 'listFilter') { var term = e.target.value.trim(); if (term) doSearch(term); } }); window.addEventListener('beforeunload', function (e) { if (S.route === 'edit' && S.dirty) { e.preventDefault(); e.returnValue = ''; } });}var rafId = null;function onScroll() { if (S.route !== 'view' || rafId) return; rafId = requestAnimationFrame(function () { rafId = null; var main = document.getElementById('main'); var top = main.getBoundingClientRect().top + 90; var links = document.querySelectorAll('.toc a'); var cur = ''; Array.prototype.forEach.call(links, function (a) { var el = document.getElementById(a.getAttribute('data-id')); if (el && el.getBoundingClientRect().top <= top) cur = a.getAttribute('data-id'); }); if (cur && cur !== S.activeToc) { S.activeToc = cur; Array.prototype.forEach.call(links, function (a) { a.classList.toggle('on', a.getAttribute('data-id') === cur); }); } });}function scrollToId(id) { var el = document.getElementById(id); var main = document.getElementById('main'); if (!el || !main) return; main.scrollTop += el.getBoundingClientRect().top - main.getBoundingClientRect().top - 20; S.activeToc = id; Array.prototype.forEach.call(document.querySelectorAll('.toc a'), function (a) { a.classList.toggle('on', a.getAttribute('data-id') === id); });}boot();})();</script> </body></html>
<style>/* wiki の見た目。前半が色と形の変数(テーマ: material)、後半が画面の組み立て。 色や余白を変えるときは前半の変数を直す。 */[data-app-theme="material"]{--app-r:16px;--app-r-sm:12px;--app-r-btn:999px;--app-r-badge:8px;--app-bw:0px;--app-shadow:0 1px 2px rgba(0,0,0,.10), 0 4px 14px rgba(0,0,0,.07);--app-shadow-btn:0 1px 3px rgba(0,0,0,.20), 0 3px 8px rgba(0,0,0,.10);--app-shadow-modal:0 8px 28px rgba(0,0,0,.22), 0 2px 8px rgba(0,0,0,.12);--app-pad:18px 20px;--app-gap:16px;--app-row-pad:18px 28px;--app-cell-fs:13.5px;--app-btn-h:44px;--app-btn-pad:0 26px;--app-btn-fs:14px;--app-btn-fw:600;--app-btn-h-sm:38px;--app-btn-pad-sm:0 18px;--app-input-h:50px;--app-input-r:12px;--app-input-bw:1px;--app-input-fill:1;--app-label-fs:12.5px;--app-label-ls:0.01em;--app-label-tt:none;--app-label-fw:500;--app-head-weight:500;--app-num-font:inherit;--app-num-ls:-0.02em;--app-font:Roboto, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', sans-serif;--app-mono:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;--app-prose-fs:15px;--app-prose-lh:1.9;--app-h2-fs:20px;--app-h3-fs:16.5px;--app-hdr-h:60px;--app-side-w:272px;--app-ease:cubic-bezier(.2,.8,.3,1);--app-ease-out:cubic-bezier(.16,1,.3,1);--app-dur-1:120ms;--app-dur-2:200ms;--app-dur-3:280ms;--app-lift:-1px;--app-press:0.97}@media (max-width:640px){[data-app-theme="material"]{--app-tap:48px;--app-btn-h:48px;--app-btn-h-sm:40px;--app-btn-fs:15px;--app-btn-pad:0 20px;--app-btn-pad-sm:0 14px;--app-input-h:48px;--app-input-fs:16px;--app-cell-fs:15px;--app-label-fs:13px;--app-prose-fs:16px;--app-hdr-h:52px;--app-pad:16px 16px;--app-row-pad:14px 16px;--app-gap:14px;--app-h2-fs:19px;--app-h3-fs:16px}}[data-app-theme="material"][data-app-palette="0"]{--app-bg:#F7F7F8;--app-surface:#FFFFFF;--app-head:#F1EFF5;--app-border:#E2E0E6;--app-accent:#5B3FA8;--app-onaccent:#FFFFFF;--app-soft:#EDE6FA;--app-faint:#6F6A76;--app-border-strong:#C9C5D0;--app-accent-hover:#4A3290;--app-ink:#1C1B1F;--app-muted:#4A4550;--app-error:#B3261E;--app-error-soft:#FCEDEB;--app-overlay:rgba(20,18,24,.34);--app-warn:#8A5300;--app-warn-soft:#FFF1DB;--app-warn-line:#F0C987;--app-info:#1F5FA8;--app-info-soft:#E6F0FB;--app-info-line:#B6CCEA;--app-success:#1E6B45;--app-success-soft:#E3F3EA;--app-success-line:#A8D5BC;--app-error-line:#F0B3AD;--app-mark:#FFF0A8;--app-star:#C48A0A;--app-cat-1:#5B3FA8;--app-cat-1-soft:#EDE6FA;--app-cat-2:#1E6B45;--app-cat-2-soft:#E3F3EA;--app-cat-3:#9A4A12;--app-cat-3-soft:#FCEBDD;--app-cat-4:#0F6466;--app-cat-4-soft:#DDF1F1;--app-cat-5:#1F5FA8;--app-cat-5-soft:#E6F0FB;--app-cat-6:#6B5B1E;--app-cat-6-soft:#F2EDD8;color-scheme:light}[data-app-theme="material"][data-app-palette="0"][data-app-mode="dark"]{--app-bg:#141218;--app-surface:#1D1B20;--app-head:#2B2930;--app-border:#49454F;--app-accent:#D0BCFF;--app-onaccent:#381E72;--app-soft:#4F378B;--app-faint:#A29CAA;--app-border-strong:#605B69;--app-accent-hover:#E2D5FF;--app-ink:#E9E3EE;--app-muted:#CAC4D0;--app-error:#F2B8B5;--app-error-soft:#4E1C19;--app-overlay:rgba(0,0,0,.52);--app-warn:#F2C078;--app-warn-soft:#3B2A10;--app-warn-line:#6B4E1E;--app-info:#9CC3F2;--app-info-soft:#172A40;--app-info-line:#2E4A6E;--app-success:#8FD4AE;--app-success-soft:#133224;--app-success-line:#2A5B42;--app-error-line:#7A2F2A;--app-mark:#4A3D0F;--app-star:#F2C14E;--app-cat-1:#D0BCFF;--app-cat-1-soft:#3A2C66;--app-cat-2:#8FD4AE;--app-cat-2-soft:#133224;--app-cat-3:#F5B48A;--app-cat-3-soft:#3D2413;--app-cat-4:#86D3D4;--app-cat-4-soft:#123436;--app-cat-5:#9CC3F2;--app-cat-5-soft:#172A40;--app-cat-6:#D8C98A;--app-cat-6-soft:#2F2A14;color-scheme:dark}@media (prefers-color-scheme: dark){[data-app-theme="material"][data-app-palette="0"][data-app-mode="auto"]{--app-bg:#141218;--app-surface:#1D1B20;--app-head:#2B2930;--app-border:#49454F;--app-accent:#D0BCFF;--app-onaccent:#381E72;--app-soft:#4F378B;--app-faint:#A29CAA;--app-border-strong:#605B69;--app-accent-hover:#E2D5FF;--app-ink:#E9E3EE;--app-muted:#CAC4D0;--app-error:#F2B8B5;--app-error-soft:#4E1C19;--app-overlay:rgba(0,0,0,.52);--app-warn:#F2C078;--app-warn-soft:#3B2A10;--app-warn-line:#6B4E1E;--app-info:#9CC3F2;--app-info-soft:#172A40;--app-info-line:#2E4A6E;--app-success:#8FD4AE;--app-success-soft:#133224;--app-success-line:#2A5B42;--app-error-line:#7A2F2A;--app-mark:#4A3D0F;--app-star:#F2C14E;--app-cat-1:#D0BCFF;--app-cat-1-soft:#3A2C66;--app-cat-2:#8FD4AE;--app-cat-2-soft:#133224;--app-cat-3:#F5B48A;--app-cat-3-soft:#3D2413;--app-cat-4:#86D3D4;--app-cat-4-soft:#123436;--app-cat-5:#9CC3F2;--app-cat-5-soft:#172A40;--app-cat-6:#D8C98A;--app-cat-6-soft:#2F2A14;color-scheme:dark}}[data-app-theme="material"][data-app-palette="1"]{--app-bg:#F1F7F6;--app-surface:#FFFFFF;--app-head:#D8E9E6;--app-border:#BFCCC9;--app-accent:#00696A;--app-onaccent:#FFFFFF;--app-soft:#CFEDEC;--app-faint:#5B6B69;--app-border-strong:#A3B3B0;--app-accent-hover:#005354;--app-ink:#1C1B1F;--app-muted:#4A4550;--app-error:#B3261E;--app-error-soft:#FCEDEB;--app-overlay:rgba(20,18,24,.34);--app-warn:#8A5300;--app-warn-soft:#FFF1DB;--app-warn-line:#F0C987;--app-info:#1F5FA8;--app-info-soft:#E6F0FB;--app-info-line:#B6CCEA;--app-success:#1E6B45;--app-success-soft:#E3F3EA;--app-success-line:#A8D5BC;--app-error-line:#F0B3AD;--app-mark:#FFF0A8;--app-star:#C48A0A;--app-cat-1:#5B3FA8;--app-cat-1-soft:#EDE6FA;--app-cat-2:#1E6B45;--app-cat-2-soft:#E3F3EA;--app-cat-3:#9A4A12;--app-cat-3-soft:#FCEBDD;--app-cat-4:#0F6466;--app-cat-4-soft:#DDF1F1;--app-cat-5:#1F5FA8;--app-cat-5-soft:#E6F0FB;--app-cat-6:#6B5B1E;--app-cat-6-soft:#F2EDD8;color-scheme:light}[data-app-theme="material"][data-app-palette="1"][data-app-mode="dark"]{--app-bg:#0E1514;--app-surface:#1A211F;--app-head:#243230;--app-border:#3F4947;--app-accent:#82D5D2;--app-onaccent:#003736;--app-soft:#00504F;--app-faint:#93A3A0;--app-border-strong:#566260;--app-accent-hover:#A5E6E3;--app-ink:#E9E3EE;--app-muted:#CAC4D0;--app-error:#F2B8B5;--app-error-soft:#4E1C19;--app-overlay:rgba(0,0,0,.52);--app-warn:#F2C078;--app-warn-soft:#3B2A10;--app-warn-line:#6B4E1E;--app-info:#9CC3F2;--app-info-soft:#172A40;--app-info-line:#2E4A6E;--app-success:#8FD4AE;--app-success-soft:#133224;--app-success-line:#2A5B42;--app-error-line:#7A2F2A;--app-mark:#4A3D0F;--app-star:#F2C14E;--app-cat-1:#D0BCFF;--app-cat-1-soft:#3A2C66;--app-cat-2:#8FD4AE;--app-cat-2-soft:#133224;--app-cat-3:#F5B48A;--app-cat-3-soft:#3D2413;--app-cat-4:#86D3D4;--app-cat-4-soft:#123436;--app-cat-5:#9CC3F2;--app-cat-5-soft:#172A40;--app-cat-6:#D8C98A;--app-cat-6-soft:#2F2A14;color-scheme:dark}@media (prefers-color-scheme: dark){[data-app-theme="material"][data-app-palette="1"][data-app-mode="auto"]{--app-bg:#0E1514;--app-surface:#1A211F;--app-head:#243230;--app-border:#3F4947;--app-accent:#82D5D2;--app-onaccent:#003736;--app-soft:#00504F;--app-faint:#93A3A0;--app-border-strong:#566260;--app-accent-hover:#A5E6E3;--app-ink:#E9E3EE;--app-muted:#CAC4D0;--app-error:#F2B8B5;--app-error-soft:#4E1C19;--app-overlay:rgba(0,0,0,.52);--app-warn:#F2C078;--app-warn-soft:#3B2A10;--app-warn-line:#6B4E1E;--app-info:#9CC3F2;--app-info-soft:#172A40;--app-info-line:#2E4A6E;--app-success:#8FD4AE;--app-success-soft:#133224;--app-success-line:#2A5B42;--app-error-line:#7A2F2A;--app-mark:#4A3D0F;--app-star:#F2C14E;--app-cat-1:#D0BCFF;--app-cat-1-soft:#3A2C66;--app-cat-2:#8FD4AE;--app-cat-2-soft:#133224;--app-cat-3:#F5B48A;--app-cat-3-soft:#3D2413;--app-cat-4:#86D3D4;--app-cat-4-soft:#123436;--app-cat-5:#9CC3F2;--app-cat-5-soft:#172A40;--app-cat-6:#D8C98A;--app-cat-6-soft:#2F2A14;color-scheme:dark}}/* ============================================================ 動き。すべての配布ツールに入る。 速さと曲線はテーマのトークン(--app-dur-* / --app-ease*)で決まるので、 ここには「何がどう動くか」だけを書く。値は直接書かない。 - 標準(material): 弾む曲線、やや長め - モダン(shadcn): 直線に近い曲線、短め - 帳票(ledger): 移動量ゼロに近い 使う側はクラスを付けるだけ。 .view-in 画面が切り替わったとき(下から出る) .stagger 並んだ行を少しずつ遅らせて出す .pop-in その場で出る(確認の窓・吹き出し) .sheet-in 下から出る(スマホのシート) .slide-in 右から出る(設定などの面) .skeleton 読み込み中の灰色(光が流れる) .spin 回す(保存中など) .pulse 注意を引く(期限切れなど・控えめに) ============================================================ */@keyframes app-fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; }}@keyframes app-pop { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; }}@keyframes app-sheet { from { transform: translateY(12%); opacity: 0.6; } to { transform: none; opacity: 1; }}@keyframes app-slide { from { transform: translateX(6%); opacity: 0.7; } to { transform: none; opacity: 1; }}@keyframes app-shimmer { to { background-position: 200% 0; }}@keyframes app-spin { to { transform: rotate(360deg); }}@keyframes app-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; }}@keyframes app-check { from { transform: scale(0.6); } 60% { transform: scale(1.12); } to { transform: none; }}/* ------------------------------------------------------------ 出てくる */.view-in { animation: app-fade-up var(--app-dur-3) var(--app-ease-out) both;}.pop-in { animation: app-pop var(--app-dur-2) var(--app-ease-out) both;}.sheet-in { animation: app-sheet var(--app-dur-3) var(--app-ease-out) both;}.slide-in { animation: app-slide var(--app-dur-3) var(--app-ease-out) both;}/* 並んだ行を少しずつ遅らせる。長い一覧で重くならないよう、先頭8つまで */.stagger > *:nth-child(-n + 8) { animation: app-fade-up var(--app-dur-2) var(--app-ease-out) both;}.stagger > *:nth-child(1) { animation-delay: 0ms;}.stagger > *:nth-child(2) { animation-delay: 20ms;}.stagger > *:nth-child(3) { animation-delay: 40ms;}.stagger > *:nth-child(4) { animation-delay: 60ms;}.stagger > *:nth-child(5) { animation-delay: 80ms;}.stagger > *:nth-child(6) { animation-delay: 100ms;}.stagger > *:nth-child(7) { animation-delay: 120ms;}.stagger > *:nth-child(8) { animation-delay: 140ms;}/* ------------------------------------------------------------ 待つ */.skeleton,.sk { background: linear-gradient( 90deg, var(--app-head) 0%, var(--app-soft) 40%, var(--app-head) 80% ); background-size: 200% 100%; animation: app-shimmer 1.4s linear infinite;}.spin { animation: app-spin 0.7s linear infinite;}.pulse { animation: app-pulse 1.8s var(--app-ease) infinite;}/* ------------------------------------------------------------ 触る */.btn,.icon-btn,.tool,.pill,.tab,.seg button,.react,.fav-btn { transition: background var(--app-dur-1) var(--app-ease), color var(--app-dur-1) var(--app-ease), border-color var(--app-dur-1) var(--app-ease), box-shadow var(--app-dur-2) var(--app-ease), transform var(--app-dur-1) var(--app-ease);}.btn:active,.icon-btn:active,.tool:active,.pill:active,.tab:active,.seg button:active,.react:active,.fav-btn:active,.btn.is-pressed,.icon-btn.is-pressed,.tool.is-pressed,.pill.is-pressed,.tab.is-pressed,.seg button.is-pressed,.react.is-pressed,.fav-btn.is-pressed { transform: scale(var(--app-press));}/* 触る端末。:active が効かないので、画面側が付ける .is-pressed で出す */.row.is-pressed,.list-row.is-pressed,.idx-row.is-pressed,.nav-row.is-pressed,.cat-row.is-pressed,.fav-row.is-pressed,.rel-row.is-pressed,.result.is-pressed,.pal-row.is-pressed,.tpl-row.is-pressed,.file-row.is-pressed { background: var(--app-head);}.cat-card.is-pressed,.tag.is-pressed { transform: scale(var(--app-press));}/* 押せる行。触れている間だけ色を変える */.row,.list-row,.idx-row,.nav-row,.cat-row,.cat-card,.fav-row,.rel-row,.result,.pal-row,.tpl-row,.file-row { transition: background var(--app-dur-1) var(--app-ease), border-color var(--app-dur-1) var(--app-ease), transform var(--app-dur-1) var(--app-ease);}.cat-card:hover { transform: translateY(var(--app-lift));}.cat-card:active,.tpl-row:active { transform: scale(var(--app-press));}/* チェックの四角。animation にすると、一覧を描き直すたびに全部が再生されるので transition */.ck-li .ckbox,.check { transition: background var(--app-dur-1) var(--app-ease), border-color var(--app-dur-1) var(--app-ease), transform var(--app-dur-2) var(--app-ease-out);}.ck-li.on .ckbox,.check.on { transform: scale(1.06);}/* 1回だけ再生する。画面側でクラスを付け外しする */.pop-once { animation: app-pop var(--app-dur-2) var(--app-ease-out);}/* ------------------------------------------------------------ 止める 端末の「視差効果を減らす/動きを減らす」が有効なときは動かさない */@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }}/* ---- wiki の画面。元の wiki の配置のまま、値を --app-* に置き換えたもの ---- */*{box-sizing:border-box}html,body{margin:0;padding:0;height:100%}body{font-family:var(--app-font);color:var(--app-ink);background:var(--app-bg);-webkit-font-smoothing:antialiased;font-size:13px}a{color:var(--app-accent);text-decoration:none;text-underline-offset:2px}a:hover{color:var(--app-accent-hover);text-decoration:underline}button,input,textarea{font-family:inherit}::selection{background:var(--app-soft);color:var(--app-accent-hover)}:focus-visible{outline:2px solid var(--app-accent);outline-offset:2px}input::placeholder{color:var(--app-faint)}input[type=search]::-webkit-search-cancel-button{display:none}/* ------------------------------------------------------------ header */.hdr{position:fixed;top:0;left:0;right:0;height:var(--app-hdr-h);display:flex;align-items:center;gap:14px;padding:0 16px;background:var(--app-surface);border-bottom:1px solid var(--app-border);z-index:20}.icon-btn{display:none;align-items:center;justify-content:center;width:28px;height:28px;border:1px solid var(--app-border);background:var(--app-surface);border-radius:var(--app-r-sm);cursor:pointer;color:var(--app-muted);font-size:14px;line-height:1}.logo{flex:none;display:flex;align-items:center;gap:9px;cursor:pointer;user-select:none;padding:5px 8px 5px 5px;border-radius:var(--app-r-sm);transition:background .15s ease}.logo:hover{background:var(--app-bg)}.logo-mark{width:20px;height:20px;border-radius:calc(var(--app-r-sm) / 2);background:linear-gradient(140deg,var(--app-accent),var(--app-accent-hover));box-shadow:var(--app-shadow)}.logo-text{font-size:13.5px;font-weight:700;letter-spacing:-.01em;white-space:nowrap}.hdr-search{position:relative;flex:1;max-width:440px;margin:0 auto;display:flex;align-items:center}.hdr-search input{width:100%;height:30px;padding:0 42px 0 32px;font-size:13px;color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm);transition:background .15s ease,border-color .15s ease,box-shadow .15s ease}.hdr-search input:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.search-icon{position:absolute;left:11px;width:11px;height:11px;border:1.5px solid var(--app-faint);border-radius:50%;pointer-events:none}.kbd{position:absolute;right:10px;font-family:var(--app-mono);font-size:11px;white-space:nowrap;color:var(--app-faint);border:1px solid var(--app-border-strong);border-radius:calc(var(--app-r-sm) / 2);padding:1px 5px;pointer-events:none}.hdr-right{flex:none;margin-left:auto;display:flex;align-items:center;gap:10px}.hdr-fav{position:relative;display:flex}.fav-btn{display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 11px;border:1px solid var(--app-border);border-radius:var(--app-r-sm);background:var(--app-surface);color:var(--app-muted);font-size:12.5px;font-weight:600;cursor:pointer;transition:background .12s ease,color .12s ease,border-color .12s ease}.fav-btn:hover{background:var(--app-bg);color:var(--app-ink)}.fav-btn.open{border-color:var(--app-faint);color:var(--app-ink);background:var(--app-bg)}.fav-btn .st{color:var(--app-star);font-size:12px}.fav-btn .qm{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border:1px solid currentColor;border-radius:50%;font-size:9px;opacity:.8}.fav-btn .n{font-size:11px;color:var(--app-faint);font-variant-numeric:tabular-nums}.fav-pop{position:absolute;top:38px;right:0;z-index:66;width:308px;padding:6px;background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow-modal);animation:popin .14s ease}.fav-pop .ph{display:flex;align-items:center;gap:8px;padding:7px 10px 5px;font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--app-faint)}.fav-pop .ph i{flex:1;height:1px;background:var(--app-border);font-style:normal}.fav-pop .fav-row{padding:8px 10px;font-size:13px}.fav-pop .fav-row .pt{flex:none;font-size:10.5px;color:var(--app-faint);max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fav-pop .none{padding:20px 14px;text-align:center;font-size:12px;line-height:1.85;color:var(--app-faint)}.avatar{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--app-head);color:var(--app-muted);font-size:11.5px;font-weight:600;text-transform:uppercase}.btn{flex:none;white-space:nowrap;height:30px;padding:0 14px;font-size:13px;font-weight:500;color:var(--app-muted);background:var(--app-surface);border:1px solid var(--app-border-strong);border-radius:var(--app-r-sm);cursor:pointer;transition:background .14s ease,color .14s ease}.btn:hover{background:var(--app-head);color:var(--app-ink)}.btn-primary{color:var(--app-onaccent);background:var(--app-accent);border-color:var(--app-accent);font-weight:600;box-shadow:var(--app-shadow)}.btn-primary:hover{background:var(--app-accent-hover);color:var(--app-onaccent)}.btn:disabled{opacity:.42;cursor:default;box-shadow:none}.btn:disabled:hover{background:var(--app-surface);border-color:var(--app-border)}.btn-quiet{border-color:transparent;background:transparent}.btn-quiet:hover{color:var(--app-error);background:var(--app-error-soft)}.btn-danger{color:var(--app-surface);background:var(--app-error);border-color:var(--app-error);font-weight:600}.btn-danger:hover{opacity:.9;color:var(--app-surface);background:var(--app-error)}.btn-sm{height:28px;padding:0 12px}/* ----------------------------------------------------------- layout */.shell{display:flex;height:100%;padding-top:var(--app-hdr-h)}.side{width:var(--app-side-w);flex:none;display:flex;flex-direction:column;background:var(--app-bg);border-right:1px solid var(--app-border);overflow:hidden}.side-top{padding:14px 14px 10px}.side-top input{width:100%;height:28px;padding:0 12px;font-size:12.5px;color:var(--app-ink);background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.side-top input:focus{border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.tree{flex:1;overflow:auto;padding:18px 10px 28px}.main{flex:1;min-width:0;overflow:auto;background:var(--app-bg)}.wrap{max-width:1080px;margin:0 auto;padding:40px 32px 64px}.wrap-narrow{max-width:880px;margin:0 auto;padding:34px 32px 88px}.wrap-wide{max-width:1120px;margin:0 auto;padding:34px 32px 88px}.scrim{display:none;position:fixed;inset:var(--app-hdr-h) 0 0 0;background:var(--app-overlay);z-index:30}/* ------------------------------------------------------------- tree */.tree-home{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:var(--app-r-sm);cursor:pointer;font-size:13px;font-weight:600;transition:background .12s ease}.tree-home:hover{background:var(--app-head)}.tree-home.on{background:var(--app-ink);color:var(--app-surface)}.tree-home .dot{width:14px;height:14px;border-radius:calc(var(--app-r-sm) / 2);border:1.5px solid currentColor;opacity:.75}.tree-label{display:flex;align-items:center;gap:8px;padding:16px 10px 6px;font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--app-faint)}.tree-label i{flex:1;height:1px;background:var(--app-border);font-style:normal}.tree-row{display:flex;align-items:center;border-radius:var(--app-r-sm);cursor:pointer;font-size:13px;line-height:1.4;transition:background .12s ease}.tree-row:hover{background:var(--app-head)}.tree-row.on{background:var(--app-head);color:var(--app-accent);font-weight:700}.tree-row .chev{width:20px;flex:none;text-align:center;font-size:8px;color:var(--app-faint);padding:6px 0;user-select:none}.tree-row .name{flex:1;min-width:0;padding:6px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-row.dep .name{color:var(--app-faint)}.tree-empty{padding:14px 10px;font-size:12px;color:var(--app-faint);line-height:1.7}.cat-row{display:flex;align-items:center;gap:8px;padding:7px 8px 7px 4px;border-radius:var(--app-r-sm);cursor:pointer;font-size:13px;transition:background .12s ease}.cat-row:hover{background:var(--app-head)}.cat-row.on{background:var(--app-head);color:var(--app-accent)}.cat-row .grip{flex:none;width:14px;text-align:center;font-size:10px;color:var(--app-border-strong);cursor:grab;user-select:none}.cat-row:hover .grip{color:var(--app-faint)}.cat-row .nm{flex:1;min-width:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cat-row .ct{flex:none;min-width:18px;text-align:right;font-size:11px;color:var(--app-faint);font-variant-numeric:tabular-nums}.cat-row.on .ct{color:var(--app-accent)}.cat-row .wd{flex:none;width:6px;height:6px;border-radius:50%;background:var(--app-error)}.cat-row.dragging{opacity:.4}.cat-row.drag-over-before{box-shadow:inset 0 2px 0 var(--app-accent)}.cat-row.drag-over-after{box-shadow:inset 0 -2px 0 var(--app-accent)}.nav-row{display:flex;align-items:center;gap:8px;padding:6px 8px 6px 22px;border-radius:var(--app-r-sm);cursor:pointer;font-size:12.5px;color:var(--app-muted);transition:background .12s ease}.nav-row:hover{background:var(--app-head)}.nav-row.on{background:var(--app-head);color:var(--app-accent);font-weight:600}.nav-row .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-row .ct{flex:none;font-size:11px;color:var(--app-faint);font-variant-numeric:tabular-nums}.side-hit{display:flex;flex-direction:column;gap:1px;padding:6px 10px;border-radius:var(--app-r-sm);cursor:pointer}.side-hit:hover,.side-hit.on{background:var(--app-head)}.side-hit .nm{font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.side-hit .pt{font-size:10.5px;color:var(--app-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}/* ------------------------------------------------- カテゴリの一覧表示 */.cat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(178px,1fr));gap:10px;padding:14px 16px 16px}.cat-card{padding:12px 14px;border:1px solid var(--app-border);border-radius:var(--app-r);background:var(--app-surface);cursor:pointer;transition:border-color .12s ease,box-shadow .12s ease}.cat-card:hover{border-color:var(--app-accent);box-shadow:var(--app-shadow)}.cat-card .cc-t{font-size:14px;font-weight:700;letter-spacing:-.01em;line-height:1.45;text-wrap:pretty}.cat-card .cc-n{display:flex;align-items:center;gap:8px;margin-top:5px;font-size:11.5px;color:var(--app-faint)}.cat-card .cc-w{color:var(--app-error)}.idx-bar{display:flex;align-items:center;gap:10px;padding:12px 16px 0}.idx-search{position:relative;flex:1;min-width:0}.idx-search input{width:100%;height:30px;padding:0 12px 0 28px;font-size:12.5px;color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.idx-search input:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.idx-search .search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);font-size:9px;color:var(--app-faint);pointer-events:none}.idx-sort{display:flex;flex:none;gap:2px;padding:2px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.idx-sort .sb{padding:4px 10px;border-radius:calc(var(--app-r-sm) / 2);font-size:11.5px;color:var(--app-faint);cursor:pointer;white-space:nowrap}.idx-sort .sb:hover{color:var(--app-muted)}.idx-sort .sb.on{background:var(--app-surface);color:var(--app-ink);font-weight:600;box-shadow:var(--app-shadow)}.idx-tags{display:flex;flex-wrap:wrap;gap:6px;padding:10px 16px 0}.idx-tag{padding:3px 9px;border:1px solid var(--app-border);border-radius:var(--app-r-badge);font-size:11px;color:var(--app-faint);cursor:pointer;white-space:nowrap}.idx-tag:hover{border-color:var(--app-faint);color:var(--app-muted)}.idx-tag.on{background:var(--app-ink);border-color:var(--app-ink);color:var(--app-surface)}.idx-row{display:flex;align-items:flex-start;gap:14px;padding:11px 10px;cursor:pointer;border-radius:var(--app-r-sm)}.idx-row:hover{background:var(--app-bg)}.idx-row+.idx-row{border-top:1px solid var(--app-border)}.idx-row .idx-t{flex:1;min-width:0}.idx-row .idx-tt{display:flex;align-items:center;flex-wrap:wrap;gap:7px}.idx-row .nm{font-size:14px;font-weight:600;line-height:1.5;text-wrap:pretty}.idx-row.dep .nm{color:var(--app-faint);text-decoration:line-through}.idx-row .idx-sub{display:flex;align-items:center;flex-wrap:wrap;gap:7px;margin-top:5px}.idx-row .idx-sub .mt{font-size:11px;color:var(--app-faint)}.idx-row .idx-when{flex:none;padding-top:3px;font-size:11px;color:var(--app-faint)}/* ------------------------------------------------------------ cards */.card{background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow);overflow:hidden}.card-hd{display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--app-border)}.card-hd h2{margin:0;font-size:13.5px;font-weight:700;letter-spacing:-.01em}.card-hd .sub{margin:0;font-size:11px;color:var(--app-faint)}.card-hd .more{margin-left:auto;font-size:11.5px;font-weight:600;color:var(--app-accent);cursor:pointer;padding:3px 7px;border-radius:calc(var(--app-r-sm) / 2);transition:background .12s ease}.card-hd .more:hover{background:var(--app-soft)}.card-bd{padding:6px}.card-ft{padding:7px 16px;border-top:1px solid var(--app-border);font-size:11px;color:var(--app-faint)}.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:20px}.row{display:flex;align-items:center;gap:12px;padding:7px 10px;border-radius:var(--app-r-sm);cursor:pointer;transition:background .12s ease}.row:hover{background:var(--app-bg)}.row .t{flex:1;min-width:0}.row .tt{display:flex;align-items:center;gap:6px;min-width:0}.row .name{min-width:0;font-size:13px;font-weight:600;line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.row.dep .name{color:var(--app-faint)}.row .path{font-size:11px;color:var(--app-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.row .when{flex:none;font-size:11px;color:var(--app-faint)}.rank{flex:none;display:flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:calc(var(--app-r-sm) / 2);font-family:var(--app-mono);font-size:11px;font-weight:500;font-variant-numeric:tabular-nums;color:var(--app-faint);background:var(--app-head)}.rank.top{color:var(--app-accent);background:var(--app-soft)}.bar{height:4px;border-radius:2px;background:var(--app-accent);opacity:.85}.metrics{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--app-faint);font-variant-numeric:tabular-nums}.av{flex:none;display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;font-size:11px;font-weight:600;text-transform:uppercase}.badge{flex:none;display:inline-flex;align-items:center;height:15px;padding:0 5px;border-radius:calc(var(--app-r-sm) / 2);font-size:9.5px;font-weight:600}.badge-new{background:var(--app-soft);color:var(--app-accent);font-family:var(--app-mono);font-size:9px;font-weight:500;letter-spacing:.06em}.badge-dep{background:var(--app-warn-soft);color:var(--app-warn)}.badge-lg{height:19px;padding:0 8px;font-size:10.5px}.list-search{margin-left:auto;position:relative;display:flex;align-items:center;width:300px;max-width:46%}.list-search input{width:100%;height:28px;padding:0 10px 0 30px;font-size:12.5px;color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.list-search input:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.list-search .search-icon{left:10px}.list-row{display:flex;align-items:center;gap:12px;padding:7px 10px;border-radius:var(--app-r-sm);cursor:pointer}.list-row:hover{background:var(--app-bg)}.list-row .sq{flex:none;width:12px;height:12px;border-radius:calc(var(--app-r-sm) / 2);border:1.5px solid var(--app-faint);opacity:.7}.list-row .name{flex:none;min-width:0;max-width:44%;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.list-row .path{flex:1;min-width:0;font-size:11px;color:var(--app-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.list-row .who{flex:none;font-size:11px;color:var(--app-faint)}.list-row .when{flex:none;width:64px;text-align:right;font-size:11px;color:var(--app-faint)}h1.page-h{flex:1;min-width:260px;margin:0;font-size:28px;font-weight:700;letter-spacing:-.025em;line-height:1.4;text-wrap:pretty}h1.sec-h{margin:0 0 4px;font-size:20px;font-weight:700;letter-spacing:-.02em}.greet{margin:0 0 5px;font-size:24px;font-weight:700;letter-spacing:-.02em;line-height:1.4}.sub{margin:0 0 22px;font-size:13px;color:var(--app-muted)}.muted{font-size:12px;color:var(--app-faint)}.crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:6px;font-size:12px;color:var(--app-faint);margin-bottom:16px}.crumbs span[data-act]{cursor:pointer;padding:3px 7px;border-radius:calc(var(--app-r-sm) / 2);transition:background .12s ease}.crumbs span[data-act]:hover{background:var(--app-head)}.crumbs .sep{color:var(--app-border-strong)}/* --------------------------------------------------------- 記事ページ */.view-grid{display:grid;grid-template-columns:minmax(0,1fr) 200px;gap:32px;align-items:start}.article{background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow);padding:28px 32px 36px}.article .chips{display:flex;align-items:center;gap:8px;margin-bottom:10px}.article .head{display:flex;align-items:flex-start;gap:20px;margin-bottom:14px;flex-wrap:wrap}.article .actions{flex:none;display:flex;gap:8px;padding-top:6px}.article .meta{display:flex;align-items:center;gap:9px;padding-bottom:22px;border-bottom:1px solid var(--app-border);font-size:12px;color:var(--app-muted)}.article .meta .stamp{color:var(--app-faint)}.article .body{padding-top:26px}.notice{display:flex;align-items:flex-start;gap:10px;margin-bottom:18px;padding:12px 14px;border:1px solid var(--app-warn-line);background:var(--app-warn-soft);border-radius:var(--app-r)}.notice .dot{flex:none;width:6px;height:6px;margin-top:7px;border-radius:50%;background:var(--app-warn)}.notice b{display:block;font-size:12.5px;color:var(--app-warn);margin-bottom:3px}.notice p{margin:0;font-size:12.5px;line-height:1.8;color:var(--app-muted)}.toc{position:sticky;top:6px}.toc .cap{font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--app-faint);padding:0 0 10px 12px}.toc a{display:block;padding:6px 10px 6px 12px;border-left:2px solid var(--app-border);font-size:12.5px;line-height:1.55;color:var(--app-muted);cursor:pointer;text-decoration:none}.toc a:hover{color:var(--app-ink);text-decoration:none}.toc a.on{color:var(--app-accent);border-left-color:var(--app-accent);font-weight:600}.toc a.lv3{margin-left:14px}/* ----------------------------------------------------------- 本文体裁 */.wiki-body{font-size:14.5px;line-height:1.85;color:var(--app-ink)}.wiki-body h2{font-size:19px;font-weight:700;letter-spacing:-.01em;line-height:1.5;margin:40px 0 12px;scroll-margin-top:24px}.wiki-body h3{font-size:16px;font-weight:600;line-height:1.55;margin:28px 0 8px;scroll-margin-top:24px}.wiki-body p{margin:0 0 16px}.wiki-body ul,.wiki-body ol{margin:0 0 16px;padding-left:0;list-style:none}.wiki-body ul>li{position:relative;padding-left:22px;margin:0 0 7px}.wiki-body ul>li::before{content:"";position:absolute;left:6px;top:.72em;width:5px;height:5px;border-radius:50%;background:var(--app-faint)}.wiki-body ol{counter-reset:o}.wiki-body ol>li{counter-increment:o;position:relative;padding-left:30px;margin:0 0 7px}.wiki-body ol>li::before{content:counter(o);position:absolute;left:0;top:.15em;display:flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:calc(var(--app-r-sm) / 2);background:var(--app-soft);color:var(--app-accent);font-family:var(--app-mono);font-size:11px;font-weight:500}/* Quill は箇条書きもチェックリストも <ol> で出すので、行の印は data-list とクラスで決める */.wiki-body ol>li[data-list="bullet"]{counter-increment:none;padding-left:22px}.wiki-body ol>li[data-list="bullet"]::before{content:"";display:block;left:6px;top:.72em;width:5px;height:5px;background:var(--app-faint);border-radius:50%}.wiki-body ol>li.ck-li{counter-increment:none}.wiki-body ol>li.ck-li::before{content:none}.wiki-body blockquote{margin:0 0 16px;padding:12px 16px;border-radius:var(--app-r-sm);background:var(--app-bg);border-left:3px solid var(--app-border-strong);color:var(--app-muted)}.wiki-body pre{margin:0 0 16px;padding:14px 16px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r);font-family:var(--app-mono);font-size:12.5px;line-height:1.7;overflow:auto;white-space:pre-wrap}.wiki-body code{font-family:var(--app-mono);font-size:12px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:calc(var(--app-r-sm) / 2);padding:1px 5px}.wiki-body pre code{background:none;border:0;padding:0}/* Quill のコードブロック。記事の画面では pre と同じ見た目にする */.wiki-body .ql-code-block-container{margin:0 0 16px;padding:14px 16px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r);font-family:var(--app-mono);font-size:12.5px;line-height:1.7;overflow:auto}.wiki-body .ql-code-block{white-space:pre-wrap;word-break:break-word}.wiki-body img{max-width:100%;height:auto;display:block;margin:0 0 16px;border-radius:var(--app-r);border:1px solid var(--app-border)}.wiki-body img.img-wait,.ql-editor img.img-wait{min-height:120px;background:var(--app-bg) linear-gradient(90deg,transparent 0,var(--app-border) 50%,transparent 100%);background-size:200% 100%;animation:imgwait 1.2s ease-in-out infinite}.wiki-body img.img-ng,.ql-editor img.img-ng{min-height:64px;background:var(--app-bg);position:relative}.wiki-body img.img-ng::after,.ql-editor img.img-ng::after{content:"画像を読み込めませんでした";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--app-faint)}@keyframes imgwait{0%{background-position:100% 0}100%{background-position:-100% 0}}.wiki-body a[href^="#/page/"]{color:var(--app-accent);text-decoration:none;font-weight:500;background:var(--app-soft);border-radius:calc(var(--app-r-sm) / 2);padding:1px 6px 1px 5px}.wiki-body a[href^="#/page/"]::before{content:"↗";font-size:.82em;margin-right:4px;opacity:.75}.wiki-body a[href^="#/page/"]:hover{background:var(--app-accent);color:var(--app-onaccent);text-decoration:none}.hd-anchor{margin-left:8px;font-weight:400;color:var(--app-faint);text-decoration:none;opacity:0;transition:opacity .12s ease}.wiki-body h2:hover .hd-anchor,.wiki-body h3:hover .hd-anchor{opacity:1}.hd-anchor:hover{color:var(--app-accent);text-decoration:none}.mk-hl{background:var(--app-mark);border-radius:3px;padding:1px 3px}.mk-imp{color:var(--app-error);font-weight:700}.mk-sub{color:var(--app-muted);font-size:.9em}mark{background:var(--app-mark);color:var(--app-ink);padding:0 2px;border-radius:3px}/* ------------------------------------------------------------- 編集 */.edit-bar{display:flex;align-items:center;gap:12px;margin-bottom:16px}/* ------------------------------------------- 編集(2カラム / note風) */.main.editing{overflow:hidden;display:flex}.edit-shell{flex:1;min-width:0;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 340px;background:var(--app-surface)}.edit-main{display:flex;flex-direction:column;min-width:0;min-height:0}.edit-top{flex:none;display:flex;align-items:center;gap:12px;min-height:54px;padding:0 24px;border-bottom:1px solid var(--app-border);background:var(--app-surface)}.edit-top .where{display:flex;align-items:center;gap:7px;flex:0 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:var(--app-faint)}.edit-top .where .muted{white-space:nowrap}.edit-top .where b{font-weight:600;color:var(--app-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.edit-top .chg{flex:none;font-size:11px;color:var(--app-faint);cursor:pointer;padding:3px 7px;border-radius:calc(var(--app-r-sm) / 2)}.edit-top .chg:hover{color:var(--app-accent);background:var(--app-soft)}.edit-top .btn{flex:none}@media (max-width:1400px){.edit-top .where .muted{display:none}}@media (max-width:1240px){.edit-top .memo{max-width:190px}}.edit-top .sp{flex:1}.edit-tools{flex:none;background:var(--app-surface)}.bub,.plus,.ins{display:none}.edit-tools .ql-toolbar.ql-snow{display:flex;flex-wrap:wrap;align-items:center;gap:2px;padding:6px 20px;background:var(--app-surface);border-bottom:1px solid var(--app-border)}.edit-canvas{flex:1;min-height:0;overflow:auto;background:var(--app-surface)}.edit-paper{max-width:760px;margin:0 auto;padding:0 34px}.edit-paper .title-input{width:100%;padding:38px 0 4px;font-size:31px;font-weight:700;letter-spacing:-.03em;line-height:1.42;color:var(--app-ink);background:transparent;border:0;outline:none}.edit-paper .title-input::placeholder{color:var(--app-faint)}.edit-paper .tag-line{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:10px 0 0;font-size:11.5px;color:var(--app-faint)}.edit-canvas .ql-container.ql-snow{font-size:16.5px}.edit-canvas .ql-editor{min-height:52vh;padding:14px 0 45vh;line-height:1.95}.edit-canvas .ql-editor.ql-blank::before{left:0;font-size:16.5px;color:var(--app-faint)}.edit-canvas .ql-editor:focus,.edit-canvas .ql-container{outline:none;border:0}.edit-paper .tag-line .tag{cursor:default}.edit-canvas .ql-editor h2{font-size:21px}.edit-canvas .ql-editor h3{font-size:17.5px}.edit-foot{flex:none;display:flex;align-items:center;gap:16px;height:34px;padding:0 24px;border-top:1px solid var(--app-border);background:var(--app-surface);font-size:11px;color:var(--app-faint);font-variant-numeric:tabular-nums}.edit-foot .st{margin-left:auto}.edit-foot .st.dirty{color:var(--app-warn)}.edit-side{overflow:auto;min-width:0;padding:16px 16px 72px;border-left:1px solid var(--app-border);background:var(--app-bg)}.edit-side .side-h{padding:2px 4px 10px;font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--app-faint)}.edit-side .panel{margin-top:10px;border-radius:var(--app-r);box-shadow:none}.edit-side .panel.first{margin-top:0}@media (max-width:1080px){ .main.editing{overflow:auto;display:block} .edit-shell{display:block} .edit-canvas{overflow:visible} .edit-side{border-left:0;border-top:1px solid var(--app-border)}}.edit-bar .where{font-size:12px;color:var(--app-faint)}.edit-bar .dirty{margin-left:auto;font-size:12px;color:var(--app-warn)}.editor-card{background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow);overflow:hidden}.editor-card .title-input{width:100%;height:60px;padding:0 24px;font-size:25px;font-weight:700;letter-spacing:-.025em;color:var(--app-ink);background:transparent;border:0;border-bottom:1px solid var(--app-border);outline:none}.panel{margin-top:20px;background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow);padding:14px 16px}.panel h2{margin:0 0 8px;font-size:13px;font-weight:700}.panel.first{margin-top:0;margin-bottom:16px}.loc-row{cursor:pointer}.loc-row.on{background:var(--app-soft)}.loc-row.on .name{color:var(--app-accent)}.loc-row .sq{flex:none;width:12px;height:12px;border-radius:calc(var(--app-r-sm) / 2);border:1.5px solid var(--app-faint);opacity:.7}.dep-row{display:flex;align-items:center;gap:12px}.dep-row .t{flex:1}.dep-row .t b{display:block;font-size:13px;font-weight:700}.dep-row .t span{display:block;margin-top:2px;font-size:11.5px;color:var(--app-faint);line-height:1.7}.switch{flex:none;position:relative;width:34px;height:20px;border-radius:var(--app-r-badge);background:var(--app-border-strong);cursor:pointer;transition:background .15s ease}.switch.on{background:var(--app-warn)}.switch i{position:absolute;top:1px;left:1px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:var(--app-shadow);transition:left .15s ease}.switch.on i{left:15px}.dep-note{width:100%;height:30px;margin-top:10px;padding:0 10px;font-size:12.5px;color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.dep-note:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.file-row{display:flex;align-items:center;gap:12px;padding:7px 8px;border-radius:var(--app-r-sm)}.file-row:hover{background:var(--app-bg)}.ext{flex:none;display:flex;align-items:center;justify-content:center;min-width:34px;height:22px;padding:0 6px;border-radius:calc(var(--app-r-sm) / 2);background:var(--app-head);font-family:var(--app-mono);font-size:10px;font-weight:500;color:var(--app-muted);text-transform:uppercase}.file-row .fname{flex:1;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-row .fsize{flex:none;font-size:11px;color:var(--app-faint);font-variant-numeric:tabular-nums}.file-row .rm{flex:none;font-size:12px;color:var(--app-faint);cursor:pointer;padding:2px 6px;border-radius:calc(var(--app-r-sm) / 2)}.file-row .rm:hover{color:var(--app-error);background:var(--app-error-soft)}.drop{margin-top:10px;padding:20px;text-align:center;border:1.5px dashed var(--app-border-strong);border-radius:var(--app-r);cursor:pointer;transition:background .15s ease,border-color .15s ease}.drop:hover,.drop.over{border-color:var(--app-accent);background:var(--app-soft)}.drop b{display:block;font-size:13px;font-weight:500;color:var(--app-muted)}.drop span{display:block;margin-top:4px;font-size:11px;color:var(--app-faint)}/* ------------------------------------------------------------ 検索 */.result{padding:12px 14px;border-radius:var(--app-r);cursor:pointer;transition:background .12s ease}.result:hover{background:var(--app-bg)}.result .rt{display:flex;align-items:center;gap:10px;margin-bottom:4px}.result .rt b{font-size:14px;font-weight:700;color:var(--app-accent)}.result .rp{font-size:11px;color:var(--app-faint);margin-bottom:6px}.result .rs{font-size:12.5px;line-height:1.8;color:var(--app-muted)}.chip-body{background:var(--app-accent);color:var(--app-onaccent)}.chip-file{background:var(--app-mark);color:var(--app-ink)}.empty{padding:48px 20px;text-align:center}.empty b{display:block;font-size:15px;font-weight:700;margin-bottom:8px}.empty p{margin:0;font-size:13px;color:var(--app-muted);line-height:1.85}/* --------------------------------------------------- モーダル / toast */.modal-bg{position:fixed;inset:0;z-index:85;background:var(--app-overlay);display:flex;align-items:center;justify-content:center;padding:24px}.modal-bg.top{align-items:flex-start;padding-top:80px}.modal{width:100%;max-width:420px;background:var(--app-surface);border-radius:var(--app-r);box-shadow:var(--app-shadow-modal);padding:22px;animation:popin .16s ease}.modal.pick{max-width:460px;padding:0;overflow:hidden}.modal h3{margin:0 0 8px;font-size:15.5px;font-weight:700;line-height:1.5}.modal p{margin:0 0 24px;font-size:13px;line-height:1.85;color:var(--app-muted)}.modal .foot{display:flex;justify-content:flex-end;gap:8px}.pick-hd{padding:14px 16px 12px;border-bottom:1px solid var(--app-border)}.pick-hd b{display:block;font-size:13px;font-weight:700;margin-bottom:8px}.pick-hd input{width:100%;height:30px;padding:0 10px;font-size:12.5px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.pick-hd input:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.pick-bd{max-height:320px;overflow:auto;padding:6px}#toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:70;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}.toast{display:flex;align-items:center;gap:10px;max-width:540px;padding:10px 16px;border-radius:var(--app-r);background:var(--app-ink);color:var(--app-surface);font-size:13px;line-height:1.6;box-shadow:var(--app-shadow-modal);animation:toastin .18s ease}.toast .dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--app-accent)}.toast .undo{margin-left:6px;height:24px;padding:0 10px;border:1px solid color-mix(in srgb,currentColor 30%,transparent);border-radius:calc(var(--app-r-sm) / 2);background:transparent;color:inherit;font-family:var(--app-font);font-size:12px;font-weight:600;cursor:pointer}.toast .undo:hover{background:color-mix(in srgb,currentColor 16%,transparent)}.toast.error{background:var(--app-error);color:var(--app-surface)}.toast.warn{background:var(--app-warn);color:var(--app-surface)}.toast.error .dot,.toast.warn .dot{background:currentColor}.toast .tick{flex:none;width:16px;height:16px;border-radius:50%;background:var(--app-accent);color:var(--app-onaccent);font-size:10px;line-height:16px;text-align:center}.toast .sub{margin-left:2px;padding-left:10px;border-left:1px solid color-mix(in srgb,currentColor 24%,transparent);font-size:11.5px;opacity:.8}#netbar{position:fixed;left:0;top:0;height:2px;width:100%;z-index:90;pointer-events:none;opacity:0;overflow:hidden;transition:opacity .18s}#netbar.on{opacity:1}#netbar::after{content:"";display:block;height:100%;width:38%;background:var(--app-accent);animation:netslide 1.1s ease-in-out infinite}#netbar.slow::after{background:var(--app-warn);animation-duration:1.8s}#netbar.done::after{width:100%;animation:none}@keyframes netslide{0%{transform:translateX(-100%)}100%{transform:translateX(265%)}}.spin{display:inline-block;flex:none;width:12px;height:12px;border-radius:50%;border:2px solid color-mix(in srgb,currentColor 34%,transparent);border-top-color:currentColor;animation:spin .7s linear infinite}.spin.sm{width:10px;height:10px;border-color:var(--app-border);border-top-color:var(--app-accent);margin-right:5px;vertical-align:-1px}.btn .spin{margin-right:7px;vertical-align:-2px;border-color:color-mix(in srgb,currentColor 38%,transparent);border-top-color:currentColor}@keyframes spin{to{transform:rotate(360deg)}}/* ------------------------------------------------ 通信中は操作を止める */#block{position:fixed;inset:0;z-index:80;display:none;cursor:progress}#block.on{display:block}body.busy{user-select:none}.modal.conf{max-width:520px}.conf-opts{display:flex;flex-direction:column;gap:8px}.conf-opt{padding:11px 13px;border:1px solid var(--app-border);border-radius:var(--app-r);background:var(--app-bg)}.conf-opt[data-act]{cursor:pointer}.conf-opt[data-act]:hover{border-color:var(--app-accent);background:var(--app-surface)}.conf-opt b{display:block;font-size:13px;font-weight:600;margin-bottom:3px}.conf-opt span{display:block;font-size:11.5px;line-height:1.7;color:var(--app-faint)}.creating-note{display:flex;align-items:center;gap:8px;margin-top:26px;padding-top:16px;border-top:1px solid var(--app-border);font-size:12.5px;color:var(--app-faint)}/* ------------------------------------------------------- skeleton等 */.sk{background:linear-gradient(90deg,var(--app-head) 0,var(--app-bg) 80px,var(--app-head) 160px);background-size:200px 100%;animation:sk 1.2s linear infinite;border-radius:calc(var(--app-r-sm) / 2)}@keyframes sk{0%{background-position:-200px 0}100%{background-position:calc(200px + 100%) 0}}@keyframes toastin{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}@keyframes popin{from{opacity:0;transform:translateY(6px) scale(.99)}to{opacity:1;transform:none}}@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms !important;transition-duration:.01ms !important}}/* --------------------------------------------------------- Quill 上書き */.ql-toolbar.ql-snow{border:0;border-bottom:1px solid var(--app-border);padding:8px 10px;background:var(--app-bg);font-family:var(--app-font)}.ql-container.ql-snow{border:0;font-family:var(--app-font);font-size:14.5px}.ql-editor{min-height:380px;line-height:1.85;padding:20px 24px}.ql-editor.ql-blank::before{color:var(--app-faint);font-style:normal;left:24px}.ql-snow .ql-stroke{stroke:var(--app-muted)}.ql-snow .ql-fill{fill:var(--app-muted)}.ql-snow.ql-toolbar button:hover{background:var(--app-head);border-radius:calc(var(--app-r-sm) / 2)}.ql-snow .ql-active .ql-stroke{stroke:var(--app-accent)}.ql-snow .ql-active .ql-fill{fill:var(--app-accent)}.ql-toolbar button.ql-hl,.ql-toolbar button.ql-imp,.ql-toolbar button.ql-sub,.ql-toolbar button.ql-pagelink{width:auto !important;padding:0 9px;font-size:12.5px;font-weight:500;color:var(--app-muted);border-radius:calc(var(--app-r-sm) / 2)}.ql-toolbar button.ql-hl::after{content:"マーカー"}.ql-toolbar button.ql-attach,.ql-toolbar button.ql-wtable,.ql-toolbar button.ql-divider,.ql-toolbar button.ql-md,.ql-toolbar button.ql-undo,.ql-toolbar button.ql-redo{width:auto !important;padding:0 9px;font-size:12.5px;font-weight:500;color:var(--app-muted);border-radius:calc(var(--app-r-sm) / 2)}.ql-toolbar button.ql-attach::after{content:"添付を入れる"}.ql-toolbar button.ql-wtable::after{content:"表"}.ql-toolbar button.ql-divider::after{content:"区切り線"}.ql-toolbar button.ql-md::after{content:"Markdown"}.ql-toolbar button.ql-undo::after{content:"元に戻す"}.ql-toolbar button.ql-redo::after{content:"やり直す"}.ql-toolbar button.ql-mdimport{width:auto !important;padding:0 9px;font-size:12.5px;font-weight:500;color:var(--app-muted);border-radius:calc(var(--app-r-sm) / 2)}.ql-toolbar button.ql-mdimport::after{content:"Markdown"}.ql-snow .ql-picker.ql-callout{width:112px;font-family:var(--app-font)}.ql-snow .ql-picker.ql-callout .ql-picker-label::before{content:"注意ボックス"}.ql-snow .ql-picker.ql-callout .ql-picker-item[data-value="warn"]::before,.ql-snow .ql-picker.ql-callout .ql-picker-label[data-value="warn"]::before{content:"注意(赤)"}.ql-snow .ql-picker.ql-callout .ql-picker-item[data-value="point"]::before,.ql-snow .ql-picker.ql-callout .ql-picker-label[data-value="point"]::before{content:"ポイント(黄)"}.ql-snow .ql-picker.ql-callout .ql-picker-item[data-value="note"]::before,.ql-snow .ql-picker.ql-callout .ql-picker-label[data-value="note"]::before{content:"補足(グレー)"}.ql-snow .ql-picker.ql-callout .ql-picker-item[data-value="pre"]::before,.ql-snow .ql-picker.ql-callout .ql-picker-label[data-value="pre"]::before{content:"前提(青)"}/* --------------------------------------------- 注意ボックス / 表 / 区切り */.wiki-body [data-callout],.ql-editor [data-callout]{position:relative;margin:16px 0;padding:12px 14px 12px 46px;border-radius:var(--app-r);border:1px solid;font-size:13.5px;line-height:1.85}.wiki-body [data-callout]::before,.ql-editor [data-callout]::before{position:absolute;left:14px;top:12px;font-size:11px;font-weight:700;letter-spacing:.02em;writing-mode:vertical-rl;text-orientation:upright;line-height:1}.wiki-body [data-callout="warn"],.ql-editor [data-callout="warn"]{background:var(--app-error-soft);border-color:var(--app-error-line);color:var(--app-ink)}.wiki-body [data-callout="warn"]::before,.ql-editor [data-callout="warn"]::before{content:"注意";color:var(--app-error)}.wiki-body [data-callout="point"],.ql-editor [data-callout="point"]{background:var(--app-warn-soft);border-color:var(--app-warn-line);color:var(--app-ink)}.wiki-body [data-callout="point"]::before,.ql-editor [data-callout="point"]::before{content:"ポイント";color:var(--app-warn)}.wiki-body [data-callout="note"],.ql-editor [data-callout="note"]{background:var(--app-head);border-color:var(--app-border-strong);color:var(--app-ink)}.wiki-body [data-callout="note"]::before,.ql-editor [data-callout="note"]::before{content:"補足";color:var(--app-muted)}.wiki-body [data-callout="pre"],.ql-editor [data-callout="pre"]{background:var(--app-info-soft);border-color:var(--app-info-line);color:var(--app-ink)}.wiki-body [data-callout="pre"]::before,.ql-editor [data-callout="pre"]::before{content:"前提";color:var(--app-info)}.wiki-body hr,.ql-editor hr{margin:26px 0;border:0;border-top:1px solid var(--app-border)}table.wtable{width:100%;margin:18px 0;border-collapse:collapse;font-size:13px;background:var(--app-surface)}table.wtable th,table.wtable td{border:1px solid var(--app-border-strong);padding:7px 10px;text-align:left;vertical-align:top;line-height:1.7}table.wtable th{background:var(--app-bg);font-weight:700}.ql-editor table.wtable{cursor:pointer}.ql-editor table.wtable:hover{outline:2px solid var(--app-soft);outline-offset:2px}.tbl-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 18px;border-bottom:1px solid var(--app-border);background:var(--app-bg)}.tbl-wrap{max-height:min(56vh,460px);overflow:auto;padding:18px}table.wtable.edit th,table.wtable.edit td{padding:0;user-select:none}table.wtable.edit input{width:100%;min-width:88px;padding:7px 9px;border:0;background:transparent;font-family:var(--app-font);font-size:13px;color:var(--app-ink);outline:none}table.wtable.edit th input{font-weight:700}table.wtable.edit td.sel,table.wtable.edit th.sel{background:var(--app-soft);box-shadow:inset 0 0 0 1.5px var(--app-accent)}.edit-top .memo{flex:1 1 150px;min-width:0;max-width:290px;height:30px;padding:0 10px;font-size:12px;color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.edit-top .memo:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.hist-row .memo{display:block;margin-top:3px;font-size:11px;color:var(--app-muted);line-height:1.6}.ql-toolbar button.ql-imp::after{content:"重要"}.ql-toolbar button.ql-sub::after{content:"補足"}.ql-toolbar button.ql-pagelink::after{content:"ページリンク"}.ql-toolbar button.ql-hl.ql-active,.ql-toolbar button.ql-imp.ql-active,.ql-toolbar button.ql-sub.ql-active{color:var(--app-accent);background:var(--app-soft)}.ql-snow .ql-picker.ql-header{width:104px;font-family:var(--app-font)}.ql-snow .ql-picker.ql-header .ql-picker-label::before,.ql-snow .ql-picker.ql-header .ql-picker-item::before{content:"本文"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before{content:"見出し大"}.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before{content:"見出し小"}/* ============================================ v2 追加分 ============ *//* --------------------------------------------------------- タグ */.tag{display:inline-flex;align-items:center;height:19px;padding:0 8px;border-radius:var(--app-r-badge);font-size:11px;font-weight:600;background:var(--app-head);color:var(--app-muted);cursor:pointer;white-space:nowrap;transition:filter .12s ease}.tag:hover{filter:brightness(.96)}.tag.sm{height:16px;padding:0 6px;font-size:10px;font-weight:500}.tag.on{outline:1.5px solid currentColor;outline-offset:-1.5px}.tag-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.tag-input-box{display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-height:32px;padding:5px 8px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.tag-input-box input{flex:1;min-width:120px;height:22px;border:0;background:transparent;font-size:12.5px;color:var(--app-ink);outline:none}.tag-chip{display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 4px 0 8px;border-radius:var(--app-r-badge);font-size:11.5px;font-weight:600}.tag-chip b{font-weight:600}.tag-chip i{font-style:normal;cursor:pointer;opacity:.55;padding:0 3px}.tag-chip i:hover{opacity:1}.tag-sugg{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}/* ------------------------------------------------- サイドバー追加 */.side-sec{display:flex;align-items:center;gap:8px;padding:18px 10px 6px;font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--app-faint)}.side-sec i{flex:1;height:1px;background:var(--app-border);font-style:normal}.side-sec b{font-weight:600;color:var(--app-faint)}.fav-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:var(--app-r-sm);cursor:pointer;font-size:12.5px;transition:background .12s ease}.fav-row:hover{background:var(--app-head)}.fav-row .st{flex:none;color:var(--app-star);font-size:11px}.fav-row .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-row.drag-over-in{background:var(--app-soft);box-shadow:inset 0 0 0 1.5px var(--app-accent)}.tree-row.drag-over-before{box-shadow:inset 0 2px 0 var(--app-accent)}.tree-row.drag-over-after{box-shadow:inset 0 -2px 0 var(--app-accent)}.tree-row.dragging{opacity:.4}.tree-row .grip{width:12px;flex:none;text-align:center;font-size:9px;color:var(--app-border-strong);cursor:grab;user-select:none}.tree-row:hover .grip{color:var(--app-faint)}/* -------------------------------------------------- 記事ヘッダー追加 */.art-tools{flex:none;display:flex;align-items:center;gap:4px;padding-top:4px;flex-wrap:nowrap;justify-content:flex-end;margin-left:auto}.tool{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:28px;padding:0 9px;border:1px solid transparent;border-radius:var(--app-r-sm);background:transparent;color:var(--app-muted);font-size:12.5px;font-weight:500;white-space:nowrap;cursor:pointer;transition:background .12s ease,color .12s ease}.tool:hover{background:var(--app-bg);color:var(--app-ink)}.tool.on{color:var(--app-star);background:var(--app-warn-soft)}.tool.watch.on{color:var(--app-accent);background:var(--app-soft)}.meta-dot{color:var(--app-border-strong)}.reactions{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:28px;padding-top:18px;border-top:1px solid var(--app-border)}.react{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:1px solid var(--app-border);border-radius:var(--app-r-badge);background:var(--app-surface);font-size:13px;cursor:pointer;transition:background .12s ease,border-color .12s ease}.react:hover{background:var(--app-bg)}.react.mine{background:var(--app-soft);border-color:var(--app-accent)}.react b{font-size:11.5px;font-weight:600;color:var(--app-muted);font-variant-numeric:tabular-nums}.react.mine b{color:var(--app-accent)}.react-add{color:var(--app-faint);font-size:14px;padding:0 11px}.react-pop{display:flex;gap:4px;padding:5px;background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r-badge);box-shadow:var(--app-shadow-modal)}.react-pop span{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-size:15px;cursor:pointer}.react-pop span:hover{background:var(--app-bg)}/* --------------------------------------------------------- コメント */.cmt{display:flex;gap:10px;padding:12px 10px;border-radius:var(--app-r)}.cmt:hover{background:var(--app-bg)}.cmt.done{opacity:.55}.cmt .bd{flex:1;min-width:0}.cmt .hd{display:flex;align-items:center;gap:8px;margin-bottom:3px}.cmt .hd b{font-size:12.5px;font-weight:600}.cmt .hd span{font-size:11px;color:var(--app-faint)}.cmt .hd .act{margin-left:auto;font-size:11px;color:var(--app-faint);cursor:pointer;padding:2px 6px;border-radius:calc(var(--app-r-sm) / 2)}.cmt .hd .act:hover{color:var(--app-accent);background:var(--app-soft)}.cmt p{margin:0;font-size:13px;line-height:1.8;color:var(--app-ink);white-space:pre-wrap;word-break:break-word}.cmt p .mention{color:var(--app-accent);font-weight:600;background:var(--app-soft);border-radius:calc(var(--app-r-sm) / 2);padding:0 3px}.mention-pop{display:none;position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:20;padding:5px;background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow-modal)}.mention-pop.on{display:block}.mention-pop .mrow{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:var(--app-r-sm);font-size:12.5px;cursor:pointer}.mention-pop .mrow:hover{background:var(--app-bg)}.mention-pop .mrow b{font-weight:600}.mention-pop .mrow span:last-child{margin-left:auto;font-size:11px;color:var(--app-faint)}.cmt.done p{text-decoration:line-through;color:var(--app-faint)}.cmt-form{display:flex;gap:10px;padding:12px 10px 4px}.cmt-form .field{flex:1;min-width:0;position:relative}.cmt-form textarea{flex:1;width:100%;box-sizing:border-box;min-height:60px;padding:9px 11px;font-size:13px;line-height:1.7;color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r);resize:vertical}.cmt-form textarea:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.cmt-foot{display:flex;align-items:center;gap:10px;padding:0 10px 10px 56px}.cmt-foot .hint{font-size:11px;color:var(--app-faint)}/* ------------------------------------------------------- 関連ページ */.rel-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:var(--app-r-sm);cursor:pointer;transition:background .12s ease}.rel-row:hover{background:var(--app-bg)}.rel-row .nm{flex:1;min-width:0;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rel-row .rs{flex:none;font-size:11px;color:var(--app-faint)}/* このカテゴリのページ(実線カード)とは違い、ほかの素のセクションと同じ仕切り線だけで区切る */.rel-quiet{margin-top:22px;padding-top:16px;border-top:1px solid var(--app-border)}.rel-quiet .rq-hd{display:flex;align-items:baseline;gap:8px;padding:0 2px 8px}.rel-quiet .rq-hd h3{margin:0;font-size:13.5px;font-weight:700;letter-spacing:-.01em}.rel-quiet .rq-hd .sub{font-size:11px;color:var(--app-faint)}.rel-quiet .rel-row .nm{font-size:13px;font-weight:600}.rel-quiet .rel-row{transition:background .12s ease,transform .12s ease,box-shadow .12s ease}.rel-quiet .rel-row:hover{background:var(--app-surface);transform:translateY(-1px);box-shadow:var(--app-shadow)}/* --------------------------------------------------------------- 設定パネル */.set-scrim{position:fixed;inset:var(--app-hdr-h) 0 0 0;z-index:64;background:var(--app-overlay);opacity:0;pointer-events:none;transition:opacity .18s ease}.set-scrim.on{opacity:1;pointer-events:auto}.set-panel{position:fixed;top:var(--app-hdr-h);right:0;bottom:0;z-index:65;width:380px;max-width:92vw;background:var(--app-surface);border-left:1px solid var(--app-border);box-shadow:var(--app-shadow-modal);transform:translateX(101%);transition:transform .24s cubic-bezier(.2,.7,.3,1);display:flex;flex-direction:column}.set-panel.on{transform:none}.set-hd{flex:none;display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--app-border)}.set-hd h2{margin:0;font-size:15px;font-weight:700;flex:1}.set-bd{flex:1;overflow:auto;padding:6px 0}.set-sec{padding:16px 18px;border-bottom:1px solid var(--app-border)}.set-sec:last-child{border-bottom:none}.set-sec h3{margin:0 0 3px;font-size:12.5px;font-weight:700}.set-sec .sub{margin:0 0 12px;font-size:11.5px;color:var(--app-faint);line-height:1.6}.set-row{display:flex;align-items:center;gap:10px}.set-row input[type="text"],.set-row input[type="email"]{flex:1;height:34px;padding:0 11px;border:1px solid var(--app-border-strong);border-radius:var(--app-r-sm);background:var(--app-bg);font-size:13px;color:var(--app-ink);font-family:inherit}.set-row input[type="text"]:focus,.set-row input[type="email"]:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.set-hint{margin-top:8px;font-size:11px;color:var(--app-faint)}.icon-btn[data-act="settings"]{transition:transform .35s cubic-bezier(.2,.8,.3,1)}.icon-btn[data-act="settings"]:active{transform:rotate(45deg)}/* ----------------------------------------------- コマンドパレット/検索 */.pal-bg{position:fixed;inset:0;z-index:80;background:var(--app-overlay);display:flex;align-items:flex-start;justify-content:center;padding:88px 24px 24px}.pal{width:100%;max-width:560px;background:var(--app-surface);border-radius:var(--app-r);box-shadow:var(--app-shadow-modal);overflow:hidden;animation:popin .14s ease}.pal-in{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--app-border)}.pal-in input{flex:1;height:28px;font-size:15px;color:var(--app-ink);background:transparent;border:0;outline:none}.pal-in .kbd{position:static}.pal-bd{position:relative;max-height:392px;overflow:auto;padding:6px}.pal-sec{padding:10px 12px 4px;font-size:10.5px;font-weight:600;letter-spacing:.08em;color:var(--app-faint)}.pal-row{display:flex;align-items:flex-start;gap:10px;padding:8px 12px;border-radius:var(--app-r-sm);cursor:pointer}.pal-row .ic{margin-top:1px}.pal-row .ic{flex:none;display:flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:calc(var(--app-r-sm) / 2);background:var(--app-head);color:var(--app-muted);font-size:11px}.pal-row .t{flex:1;min-width:0}.pal-row .nm{display:flex;align-items:center;gap:6px;font-size:13px;font-weight:600;min-width:0}.pal-row .nm span.txt{min-width:0;overflow:hidden;text-omit:clip;text-overflow:ellipsis;white-space:nowrap}.pal-row .sub{font-size:11px;color:var(--app-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pal-row .snip{margin-top:3px;font-size:11.5px;line-height:1.6;color:var(--app-muted)}.pal-row .go{flex:none;font-family:var(--app-mono);font-size:10px;color:var(--app-faint);opacity:0}.pal-row.pal-sel{background:var(--app-soft)}.pal-row.pal-sel .go{opacity:1}.pal-row.pal-sel .ic{background:var(--app-accent);color:var(--app-onaccent)}.pal-ft{display:flex;align-items:center;gap:14px;padding:8px 16px;border-top:1px solid var(--app-border);font-size:11px;color:var(--app-faint)}.pal-ft span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap}.pal-ft b{font-family:var(--app-mono);font-weight:500;border:1px solid var(--app-border-strong);border-radius:calc(var(--app-r-sm) / 2);padding:0 4px}.pal-empty{padding:28px 16px;text-align:center;font-size:12.5px;color:var(--app-muted)}/* --------------------------------------------------- 履歴 / 差分 */.modal.wide{max-width:920px;padding:0;overflow:hidden}.mh{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--app-border)}.mh h3{margin:0;font-size:14px;font-weight:700}.mh .sp{flex:1}.mh .x{cursor:pointer;color:var(--app-faint);font-size:15px;padding:2px 6px;border-radius:calc(var(--app-r-sm) / 2)}.mh .x:hover{background:var(--app-bg);color:var(--app-ink)}.hist{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:480px;max-height:min(88vh,760px)}.hist-list{border-right:1px solid var(--app-border);overflow:auto;padding:6px;background:var(--app-bg)}.hist-row{padding:9px 10px;border-radius:var(--app-r-sm);cursor:pointer}.hist-row:hover{background:var(--app-head)}.hist-row.on{background:var(--app-surface);box-shadow:var(--app-shadow)}.hist-row b{display:block;font-size:12.5px;font-weight:600}.hist-row span{display:block;margin-top:2px;font-size:11px;color:var(--app-faint)}.hist-row .now{display:inline-flex;align-items:center;height:15px;padding:0 5px;margin-top:4px;border-radius:calc(var(--app-r-sm) / 2);background:var(--app-soft);color:var(--app-accent);font-size:9.5px;font-weight:600}.hist-bd{overflow:auto;padding:20px 24px;min-height:0}.hist-bar{display:flex;align-items:center;gap:12px;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--app-border)}.hist-bar .t{flex:1;font-size:12px;color:var(--app-muted)}.sw{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--app-muted);cursor:pointer;user-select:none}.sw .track{position:relative;width:32px;height:18px;border-radius:var(--app-r-badge);background:var(--app-border-strong);transition:background .15s ease}.sw .track i{position:absolute;top:1px;left:1px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:var(--app-shadow);transition:left .15s ease}.sw.on .track{background:var(--app-accent)}.sw.on .track i{left:15px}.sw.on{color:var(--app-accent);font-weight:600}.diff{font-size:13px;line-height:1.8}.diff .ln{display:flex;gap:10px;padding:2px 8px;border-radius:calc(var(--app-r-sm) / 2)}.diff .ln .sg{flex:none;width:12px;font-family:var(--app-mono);font-size:11px;color:var(--app-faint);text-align:center;user-select:none}.diff .ln .tx{flex:1;white-space:pre-wrap;word-break:break-word}.diff .add{background:var(--app-success-soft)}.diff .add .sg{color:var(--app-success)}.diff .del{background:var(--app-error-soft);color:var(--app-muted)}.diff .del .sg{color:var(--app-error)}.diff .del .tx{text-decoration:line-through;text-decoration-color:var(--app-error-line)}.diff-legend{display:flex;gap:14px;margin-bottom:14px;font-size:11.5px;color:var(--app-faint)}.diff-legend span{display:inline-flex;align-items:center;gap:5px}.diff-legend i{width:10px;height:10px;border-radius:3px;font-style:normal}.diff-none{padding:28px 0;text-align:center;font-size:12.5px;color:var(--app-faint)}/* ------------------------------------------------------ テンプレート */.tpl-row{display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:var(--app-r);cursor:pointer}.tpl-row:hover{background:var(--app-bg)}.tpl-row .ic{flex:none;display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:var(--app-r-sm);background:var(--app-soft);color:var(--app-accent);font-size:12px;font-weight:700}.tpl-row b{display:block;font-size:13px;font-weight:600}.tpl-row span{display:block;margin-top:2px;font-size:11.5px;color:var(--app-faint)}/* ------------------------------------------- 下書き / 権限バッジ・UI */.badge-draft{background:var(--app-head);color:var(--app-muted)}.badge-lock{background:var(--app-info-soft);color:var(--app-info)}.badge-stale{background:var(--app-warn-soft);color:var(--app-warn)}.article .meta .stamp.over{color:var(--app-error);font-weight:600}.article .meta .stamp.soon{color:var(--app-warn)}.sel{flex:none;height:30px;padding:0 8px;font-size:12.5px;color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm);font-family:var(--app-font)}.sel:focus{background:var(--app-surface);border-color:var(--app-accent);box-shadow:0 0 0 3px var(--app-soft);outline:none}.wiki-body li.ck-li{list-style:none;position:relative;padding-left:26px;cursor:pointer}.wiki-body li.ck-li .ckbox{position:absolute;left:0;top:.28em;display:flex;align-items:center;justify-content:center;width:17px;height:17px;border:1.5px solid var(--app-border-strong);border-radius:calc(var(--app-r-sm) / 2);background:var(--app-surface);font-size:11px;color:var(--app-onaccent);transition:background .12s ease,border-color .12s ease}.wiki-body li.ck-li:hover .ckbox{border-color:var(--app-accent)}.wiki-body li.ck-li.on .ckbox{background:var(--app-accent);border-color:var(--app-accent)}.wiki-body li.ck-li.on{color:var(--app-faint);text-decoration:line-through;text-decoration-color:var(--app-border-strong)}.ck-foot{display:flex;align-items:center;gap:12px;margin-top:22px;padding-top:14px;border-top:1px solid var(--app-border);font-size:11.5px;color:var(--app-faint)}.ck-foot .bar{flex:none;width:120px;height:5px;border-radius:var(--app-r-badge);background:var(--app-head);overflow:hidden}.ck-foot .bar i{display:block;height:100%;background:var(--app-accent);border-radius:var(--app-r-badge);transition:width .2s ease}.ck-foot .n{font-weight:600;color:var(--app-muted);font-variant-numeric:tabular-nums}.ck-foot .act{margin-left:auto;cursor:pointer;padding:3px 7px;border-radius:calc(var(--app-r-sm) / 2)}.ck-foot .act:hover{color:var(--app-accent);background:var(--app-soft)}.seg{display:inline-flex;padding:2px;background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm)}.seg button{height:24px;padding:0 12px;border:0;background:transparent;border-radius:calc(var(--app-r-sm) / 2);font-size:12px;font-weight:500;color:var(--app-muted);cursor:pointer}.seg button.on{background:var(--app-surface);color:var(--app-ink);font-weight:600;box-shadow:var(--app-shadow)}/* --------------------------------------------------------- 機能ガイド */.gd-idx{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 8px}.gd-idx a{display:inline-flex;align-items:center;gap:7px;height:30px;white-space:nowrap;padding:0 12px;border:1px solid var(--app-border);border-radius:var(--app-r-badge);background:var(--app-surface);box-shadow:var(--app-shadow);font-size:12px;font-weight:600;color:var(--app-muted);text-decoration:none;transition:color .12s ease,border-color .12s ease}.gd-idx a:hover{color:var(--app-accent);border-color:var(--app-accent);text-decoration:none}.gd-idx a em{font-style:normal;font-family:var(--app-mono);font-size:10px;font-weight:500;color:var(--app-faint)}.gd-grp{display:flex;align-items:baseline;gap:12px;margin:34px 0 12px;scroll-margin-top:12px}.gd-grp h2{margin:0;font-size:16px;font-weight:700;letter-spacing:-.02em}.gd-grp p{margin:0;font-size:12px;color:var(--app-faint)}.gd{background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow);margin-bottom:14px;overflow:hidden;scroll-margin-top:12px}.gd-hd{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--app-border)}.gd-no{flex:none;font-family:var(--app-mono);font-size:10.5px;font-weight:500;color:var(--app-faint)}.gd-hd h2{margin:0;font-size:14.5px;white-space:nowrap;font-weight:700;letter-spacing:-.015em}.gd-where{margin-left:auto;font-size:11.5px;color:var(--app-faint);text-align:right}.gd-cols{display:grid;grid-template-columns:minmax(0,1.06fr) minmax(0,1fr)}.gd-demo{position:relative;display:flex;flex-direction:column;justify-content:center;gap:12px;padding:28px 22px 22px;background:var(--app-bg);border-right:1px solid var(--app-border);user-select:none}.gd-demo *{cursor:default !important}.gd-cap{position:absolute;top:9px;left:14px;font-size:9.5px;font-weight:600;letter-spacing:.07em;color:var(--app-faint)}.gd-frame{background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow);padding:8px 10px}.gd-frame.flat{box-shadow:none;background:var(--app-surface)}.gd-demo .pal{max-width:100%;box-shadow:var(--app-shadow-modal)}.gd-demo .modal{max-width:100%;box-shadow:var(--app-shadow-modal)}.gd-demo .reactions{margin-top:0;padding-top:0;border-top:0}.gd-how{padding:16px 20px 18px}.gd-lead{margin:0 0 12px;font-size:12.5px;line-height:1.9;color:var(--app-muted)}.gd-step{display:grid;grid-template-columns:auto 12px minmax(0,1fr);gap:9px;align-items:baseline;padding:8px 0;border-top:1px dashed var(--app-border)}.gd-step .k{display:inline-block;padding:2px 8px;border-radius:calc(var(--app-r-sm) / 2);background:var(--app-head);font-size:11.5px;font-weight:600;color:var(--app-ink)}.gd-step .ar{font-size:11px;color:var(--app-faint);text-align:center}.gd-step .v{font-size:12.5px;line-height:1.75;color:var(--app-muted)}.gd-note{margin:12px 0 0;padding-top:11px;border-top:1px solid var(--app-border);font-size:11.5px;line-height:1.85;color:var(--app-faint)}.gd-chat{display:flex;gap:9px;align-items:flex-start}.gd-chat .ic{flex:none;display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--app-soft);color:var(--app-accent);font-size:10px;font-weight:700}.gd-chat .bd b{display:block;font-size:12px;font-weight:700}.gd-chat .bd p{margin:3px 0 0;font-size:12px;line-height:1.7;color:var(--app-muted)}.gd-out{display:grid;grid-template-columns:186px minmax(0,1fr);gap:14px;padding:9px 10px;border-radius:var(--app-r-sm)}.gd-out:hover{background:var(--app-bg)}.gd-out b{font-size:12.5px;font-weight:700}.gd-out span{font-size:12px;line-height:1.8;color:var(--app-muted)}@media (max-width:1180px){.gd-cols{grid-template-columns:minmax(0,1fr)}.gd-demo{border-right:0;border-bottom:1px solid var(--app-border)}.gd-out{grid-template-columns:minmax(0,1fr);gap:2px}}.member-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.member{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border:1px solid var(--app-border);border-radius:var(--app-r-badge);font-size:12px;color:var(--app-muted);cursor:pointer;background:var(--app-surface)}.member.on{border-color:var(--app-accent);background:var(--app-soft);color:var(--app-accent);font-weight:600}.member.fixed{opacity:.6;cursor:default}/* ------------------------------------------------------ 印刷 */@media print{ @page{size:A4;margin:16mm 14mm} html,body{height:auto;background:#fff} .hdr,.side,.scrim,.art-tools,.reactions,#toast,#modals,#palette,.toc,.card-hd .more,.list-search,.idx-bar,.idx-tags{display:none !important} .shell{display:block;padding-top:0;height:auto} .main{overflow:visible;background:#fff} .wrap,.wrap-narrow,.wrap-wide{max-width:100%;padding:0} .view-grid{display:block} .article{border:0;border-radius:0;box-shadow:none;padding:0} .card,.panel{border:1px solid #ddd;box-shadow:none;break-inside:avoid} h1.page-h{font-size:22pt} .wiki-body{font-size:10.5pt;line-height:1.7} .wiki-body h2{font-size:13pt;break-after:avoid} .wiki-body h3{font-size:11.5pt;break-after:avoid} .wiki-body pre,.wiki-body blockquote,.wiki-body li,.wiki-body img{break-inside:avoid} .wiki-body table.wtable{break-inside:auto;font-size:9.5pt} .wiki-body table.wtable th,.wiki-body table.wtable td{border:1px solid #bbb} .wiki-body table.wtable tr{break-inside:avoid} .wiki-body [data-callout]{break-inside:avoid;border:1px solid #bbb;background:#f7f7f5 !important;color:#000 !important} .wiki-body a[href^="#/page/"]{background:none;color:#000;padding:0;font-weight:600} .wiki-body a[href^="#/page/"]::before{content:"→ "} .hd-anchor{display:none} .print-foot{display:block !important;margin-top:14mm;padding-top:4mm;border-top:1px solid #ddd;font-size:8.5pt;color:#666}}.print-foot{display:none}/* ------------------------------------------------------ レスポンシブ */@media (max-width:1080px){ .view-grid{grid-template-columns:minmax(0,1fr)} .toc{display:none}}@media (max-width:900px){ .side{position:fixed;top:var(--app-hdr-h);bottom:0;left:0;z-index:40;transform:translateX(-101%);transition:transform .2s cubic-bezier(.2,.7,.3,1);box-shadow:var(--app-shadow-modal)} body.drawer .side{transform:none} body.drawer .scrim{display:block} .icon-btn{display:inline-flex} .hdr-search{display:none} .wrap,.wrap-narrow,.wrap-wide{padding-left:18px;padding-right:18px} .article{padding:22px 20px 28px} .list-search{width:100%;max-width:100%;margin:8px 0 0} .card-hd{flex-wrap:wrap}}@media (max-width:760px){ /* 折り返すと区切りの点が行末に残って読みにくい。狭いときは点をやめて間隔で分ける */ .article .meta{gap:4px 14px} .article .meta .meta-dot{display:none} .article .actions,.art-tools [data-act="edit"],.art-tools [data-act="askDelete"],.hdr-right [data-act="new"]{display:none} .hdr-right{gap:6px} .fav-btn{padding:0 9px;font-size:12px} .article .head{flex-wrap:wrap} .art-tools{width:100%;justify-content:flex-start;margin-left:0} .article .meta{flex-wrap:wrap;row-gap:4px} .grid2{grid-template-columns:minmax(0,1fr)} .ck-foot{flex-wrap:wrap;row-gap:8px} .fav-pop{position:fixed;left:12px;right:12px;top:calc(var(--app-hdr-h) + 8px);width:auto} .cmt-foot{padding-left:10px;flex-wrap:wrap} .row .when,.list-row .when{font-size:10.5px}}@media (max-width:600px){ .article .actions{display:none} .list-row .path,.list-row .who{display:none} h1.page-h{font-size:24px}}/* ---- テーマごとの差(TOOL-UI.md §2 の派生規則) ---- */[data-app-theme="ledger"] .card,[data-app-theme="ledger"] .article,[data-app-theme="ledger"] .panel,[data-app-theme="ledger"] .modal,[data-app-theme="ledger"] .pal,[data-app-theme="ledger"] .set-panel{box-shadow:none}[data-app-theme="ledger"] .toast{background:var(--app-surface);color:var(--app-ink);border:1px solid var(--app-ink)}[data-app-theme="ledger"] .btn{font-weight:700}.num,.fsize,.ct,.n{font-variant-numeric:tabular-nums}/* ---- 配布版で足したもの(招待リンクの入口と、設定のメンバー管理・見た目) ---- */.gate{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px 16px;background:var(--app-bg)}.gate-card{width:100%;max-width:420px;padding:28px 26px;background:var(--app-surface);border:1px solid var(--app-border);border-radius:var(--app-r);box-shadow:var(--app-shadow);text-align:center}.gate-card .logo-mark{display:inline-block;margin-bottom:14px}.gate-card h1{margin:0 0 10px;font-size:17px;font-weight:700;color:var(--app-ink)}.gate-card p{margin:0 0 18px;font-size:13px;line-height:1.85;color:var(--app-muted)}.gate-card p:last-child{margin-bottom:0}.set-opt{display:flex;align-items:center;gap:10px;margin-top:10px;flex-wrap:wrap}.set-opt .lb{width:52px;flex:none;font-size:12px;color:var(--app-muted)}.set-members{border-top:1px solid var(--app-border)}.set-member{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--app-border);flex-wrap:wrap}.set-member .av{flex:none;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600}.set-member .t{flex:1;min-width:0}.set-member .t b{display:block;font-size:13px;color:var(--app-ink)}.set-member .t span{display:block;font-size:11px;color:var(--app-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.set-member .acts{display:flex;gap:6px;flex-wrap:wrap;margin-left:38px}.set-member.off{opacity:.55}.set-member .t{flex-basis:calc(100% - 38px)}/* 元の画面では幅が広いときも項目の中で折り返して読みにくかったので、項目ごとに折り返す */.meta{flex-wrap:wrap;row-gap:4px}.meta .stamp{white-space:nowrap}/* ---- 導線の見直し(2026-09-16)とスマホでの作成・編集・削除 ---- */.home{display:flex;flex-direction:column;gap:20px}.art-tools .btn-primary{margin-left:4px}.art-stats{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:18px;font-size:11.5px;color:var(--app-faint)}.edit-top .acts{flex:none;display:flex;align-items:center;gap:8px}.edit-top .to-side{display:none}.btn-quiet.danger{color:var(--app-error)}.seg button:disabled{opacity:.4;cursor:default}.search-btn{position:relative}.search-btn .search-icon{position:static;display:block}@media (max-width:1080px){ .edit-top{position:sticky;top:0;z-index:5} .edit-tools{position:sticky;top:var(--edit-top-h,54px);z-index:4} .edit-top .to-side{display:inline-flex;align-items:center}}@media (max-width:760px){ .hdr-right [data-act="new"]{display:inline-flex} .art-tools [data-act="edit"]{display:inline-flex} .new-btn{padding:0 10px} .new-btn .lb{display:none} .fav-btn .lb-fav{display:none} .home-stale{order:1} .home-fav{order:2} .home-recent{order:3} .home-cats{order:4} .home-list{order:5} .cat-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding:12px} .cat-card{padding:9px 11px} .cat-card .cc-t{font-size:13px} .edit-top{flex-wrap:wrap;gap:8px;padding:8px 14px;min-height:0} .edit-top .sp{display:none} .edit-top .where{flex:1 1 auto} .edit-top .memo{order:3;flex:1 1 100%;max-width:none} .edit-top .acts{order:4;flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap} .edit-tools{position:static} .edit-tools .ql-toolbar.ql-snow{flex-wrap:nowrap;overflow-x:auto;padding:6px 10px;-webkit-overflow-scrolling:touch} .edit-tools .ql-toolbar.ql-snow .ql-formats{flex:none} .edit-paper{padding:0 16px} .edit-paper .title-input{padding-top:20px;font-size:23px} .edit-canvas .ql-container.ql-snow{font-size:16px} .edit-canvas .ql-editor{min-height:40vh;padding-bottom:18vh} .edit-foot{height:auto;min-height:34px;flex-wrap:wrap;gap:4px 12px;padding:6px 14px} .edit-side{padding:14px 12px 40px} .dep-row{flex-wrap:wrap} .modal-bg{padding:12px} .modal .foot{flex-wrap:wrap}}@media (max-width:760px){ .row .when .dt{display:none}}@media (max-width:480px){ /* 帯に入れるものを絞る。自分の印は設定から分かるので出さない */ .hdr{gap:6px;padding-left:10px;padding-right:10px} .hdr-right{gap:4px} .hdr .avatar{display:none} .logo-text{max-width:112px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}/* ---- 参加の画面(招待制をやめ、名前を決めてもらう) ---- */.gate-card.join{max-width:460px;text-align:left}.gate-card.join h1{margin-bottom:6px}.gate-card.join .set-row{margin-top:16px}.join-hint{margin:16px 0 8px;font-size:11.5px;color:var(--app-faint);line-height:1.7}.join-list{display:flex;flex-wrap:wrap;gap:6px}/* ---- ヘッダーの検索欄を画面の中央に置く ---- * 横並びのままだと「左のロゴと右のボタンの、余った幅の中央」に来るので、 * 左右の幅が違うぶんだけ中央からずれる。左・検索・右の3列にして、左右に同じ幅を持たせる。 */@media (min-width:901px){ .hdr{display:grid;grid-template-columns:1fr auto 1fr;gap:14px} .logo{justify-self:start} .hdr-search{width:clamp(280px,38vw,520px);max-width:100%;margin:0} .hdr-right{justify-self:end;margin-left:0}}/* ---- マークダウンの読み込み ---- */.modal.mdimp{max-width:640px;padding:26px 28px 24px}.modal.mdimp h3{margin-bottom:10px}.modal.mdimp .foot{margin-top:22px}.modal .md-lead{margin:0 0 18px;font-size:13px;line-height:1.85;color:var(--app-muted)}.md-ask{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;margin:14px 0}.md-ask .muted{margin:0;font-size:12px;line-height:1.6}.md-in{width:100%;min-height:260px;padding:12px 13px;font-family:var(--app-mono);font-size:12.5px;line-height:1.8; color:var(--app-ink);background:var(--app-bg);border:1px solid var(--app-border);border-radius:var(--app-r-sm);resize:vertical}.md-in:focus{outline:none;border-color:var(--app-accent)}@media (max-width:640px){.modal.mdimp{padding:22px 18px 18px}.md-in{min-height:200px;font-size:16px}}/* ============================================================ 狭い画面の共通ルール。すべての配布ツールに入る。 ツールごとの CSS のあとに置いて、狭いときだけ上書きする。 考え方 - 押せるものは指の大きさ(--app-tap = 48px)を下回らない - 入力の文字は 16px 以上。これより小さいと iOS が勝手に拡大して画面がずれる - 狭い画面では箱(カード)で区切らない。線と余白と文字の大きさで区切る - 箱に意味がある場所(選ぶ対象・数える対象・押す対象そのもの)は `.card.keep-card` と書けばカードのまま残る ============================================================ *//* ------------------------------------------------------- 共通の下敷き */html { -webkit-text-size-adjust: 100%;}@media (max-width: 640px) { body { font-size: var(--app-cell-fs); } /* 画面の上の帯は高さが限られるので、そこだけ 44px(それでも指で押せる) */ .hdr .btn, .hdr .fav-btn, .topbar .btn { min-height: 44px; } /* 印だけのボタンは正方形。高さだけ足すと幅 28px のまま縦に伸びる */ .icon-btn, .hdr .icon-btn, .topbar .icon-btn { width: 44px; min-width: 44px; height: 44px; min-height: 44px; } /* 押せるものは指で押せる大きさに */ .btn, .seg button, .pill, .tab { min-height: var(--app-tap); } /* 高さを広げるぶん、中身を上下の中央に置く。 display:flex だけでは align-items が既定の stretch になり、文字が上に寄る */ .btn, .icon-btn, .tool, .react, .seg button, .pill, .tab, .fav-btn { display: inline-flex; align-items: center; justify-content: center; line-height: 1; } /* 記事の上に並ぶ小さな操作。押せる大きさにしつつ、枠を出して「押せる」と分かるようにする */ .tool { min-height: 40px; padding: 0 12px; border-color: var(--app-border); font-size: 13px; } .react { min-height: 40px; } .btn-sm, .tool.sm { min-height: var(--app-btn-h-sm); } /* 入力は拡大されない文字の大きさに */ input, select, textarea { font-size: var(--app-input-fs); } input:not([type='checkbox']):not([type='radio']), select { min-height: var(--app-input-h); } /* 押せる小さな印(×・削除・☆など)は、見た目を変えずに当たりを広げる */ .tap-area { position: relative; } .tap-area::after { content: ''; position: absolute; inset: 50% auto auto 50%; width: var(--app-tap); height: var(--app-tap); transform: translate(-50%, -50%); } /* 箱をやめ、区切りは見出しの大きさ・余白・線で出す */ body { background: var(--app-surface); } .card, .panel, .article { background: transparent; border: 0; border-radius: 0; box-shadow: none; margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; } .article { border: 0; border-radius: 0; box-shadow: none; padding-left: 0; padding-right: 0; } .card + .card, .panel + .panel, .card + .grid2, .grid2 + .card { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--app-border); } .card > .card-hd, .card > .card-bd, .card > .card-ft, .panel > .panel-bd { padding-left: 0; padding-right: 0; } .card > .card-hd { border-bottom: 0; padding-top: 0; padding-bottom: 4px; } /* 線のない区切りは見出しの大きさで示す */ .card-hd h2, .panel > h2 { font-size: var(--app-h2-fs); letter-spacing: -0.01em; } .card-hd .sub { font-size: 12px; } /* 一覧の文字。名前と補足の差を大きさで出す */ .row .name, .list-row .name, .idx-row .nm, .rel-row .nm, .fav-row .nm, .result b { font-size: 15px; } .row .path, .list-row .path, .idx-row .idx-sub, .rel-row .rs, .result .rp { font-size: 12px; } .row .when, .list-row .when, .idx-row .idx-when { font-size: 11.5px; } /* 絞り込みの札は押す対象なので大きくする。記事に出る札は押せないので変えない */ .idx-tag, .sb, .chip { min-height: 34px; display: inline-flex; align-items: center; padding-left: 12px; padding-right: 12px; } .tag[data-act] { min-height: 30px; display: inline-flex; align-items: center; } /* 行そのものが押す対象なので、高さを確保する */ .row, .list-row, .idx-row, .nav-row, .cat-row, .fav-row, .rel-row { min-height: var(--app-tap); } /* 箱に意味がある場所(押す対象そのもの・選ぶ対象)はカードのまま残す */ .card.keep-card, .panel.keep-card { background: var(--app-surface); border: 1px solid var(--app-border); border-radius: var(--app-r); box-shadow: var(--app-shadow); padding-left: 0; padding-right: 0; } .wrap, .wrap-narrow, .wrap-wide { padding-left: 16px; padding-right: 16px; padding-top: 22px; } /* 横に長い表は、端まで使って横にずらせるようにする */ .table-wrap, .tbl-wrap { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; overflow-x: auto; -webkit-overflow-scrolling: touch; } /* 右から出す設定などの面は、狭い画面では全面にする */ .set-panel, .side-panel { width: 100%; max-width: 100%; } /* 確認の窓は下から出す。ボタンは縦に積み、主要な操作を上に */ .modal { max-width: 100%; } .modal .foot { flex-direction: column-reverse; align-items: stretch; } .modal .foot .btn { width: 100%; }}/* 380px 以下では、本文の1行の文字数を優先して左右の余白を詰める */@media (max-width: 380px) { .wrap, .wrap-narrow, .wrap-wide { padding-left: 12px; padding-right: 12px; }}/* 指で操作する端末。hover は当てにできず、iOS は :active も当てにならないので、 押した反応は画面側が付ける .is-pressed(motion.css)で出す。 ここでは、端末が勝手に出す灰色の点滅だけ止める。 */@media (hover: none) { a, button, .btn, .icon-btn, .tool, .row, .list-row, .cat-card, [data-act] { -webkit-tap-highlight-color: transparent; }}</style>
このツールの解説
どんなツールか
社内の手順や決まりごとを、ページとして書きためる wiki です。 新しいサービスと契約できない、データを社外に置けない、という条件でも使えます。
Google アカウントがあれば動きます。月額はかからず、使う人数が増えても費用は変わりません。 データは設置した人の Google ドライブの中に置かれ、やめるときは自分で消せます。
書きかけは下書きのまま置けます。保存するたびに前の内容が履歴に残ります。
上のプレビューは見本のデータで動いています。ページを開く、書く、保存する、履歴から戻すまで触れます。 再読み込みすると元に戻ります。
- ページを書く(見出し、箇条書き、チェックリスト、注意カード、表、画像、添付)
- 下書きのまま保存する。公開するまで、ほかの人には見えません
- 履歴から前の内容に戻す。誰がいつ何を変えたかも残ります
- 本文と、添付ファイルの中身を探す
- AI に書かせた文章を、マークダウンのまま取り込む
- コメントと絵文字で反応する。
@で人を呼べます - 担当者と見直し期限を決めて、期限が来たらメールで知らせる
- スマホで読む、書く、直す、消す
AI に下書きさせる
wiki が続かない理由は、たいてい書く時間がないことです。 このツールには、AI に書かせた文章をそのまま取り込む入口を入れてあります。
編集画面のツールバーの「Markdown」を押すと、読み込む画面が出ます。 「AI に頼む文をコピー」を押すと、頼み方の文がコピーされます。Gemini や ChatGPT に貼り、書きたいことを足して送ってください。 返ってきた文章を貼り付けると、見出し・箇条書き・チェックリスト・表・引用・コードが、このアプリの書式に変わります。本文に直接貼っても同じです。
使う人がマークダウンを書ける必要はありません。AI がマークダウンで返してくるから入れてあります。
頼み方の文には、使う記号を絞る指示を入れてあります。 Gemini の軽いモデルは、指示が短いと前置きを付けたり、このアプリが扱えない書き方を混ぜたりします。
画像は入りません。リンクとして残ります。字下げした入れ子の箇条書きは1段にそろいます。
見た目を決める
プレビューの上に、標準・モダン・帳票の3つと、色と明暗の切り替えがあります。 選んだ見た目のまま、下のコードが書き出されます。コードを書き換える必要はありません。
色と余白は style.html の前半に変数(--app-accent など)としてまとまっています。
あとから変えたくなったときは、この部分だけを直せば全体の印象が変わります。
入れ方
貼り付けるファイルは3つです。Google アカウントのほかに、用意するものはありません。
- Google Apps Script で新しいプロジェクトを作ります
コード.gsの中身を、最初からあるコード.gsに貼り付けます- 「+」から HTML ファイルを
indexという名前で作り、index.htmlの中身を貼り付けます - 同じように HTML ファイルを
styleという名前で作り、style.htmlの中身を貼り付けます - 「プロジェクトの設定」でタイムゾーンを「東京」にします(日付の扱いに使います)
- 「デプロイ」→「新しいデプロイ」→「ウェブアプリ」を選びます
- 実行するユーザー: 自分
- アクセスできるユーザー: Google アカウントを持つ全員(社内だけで使うなら、組織を選びます)
- 出てきた URL を自分で開いて、Google の承認画面を通します。自分で作ったスクリプトなので「確認されていません」と出ます。「詳細」から進めます
- 開いた URL を、使う人に渡します
初めて開いたときに、データを入れるスプレッドシートと、添付ファイル用のフォルダが Google ドライブに作られます。
見直し期限のお知らせを使うときは、wiki の設定(右上の歯車)で「毎朝のお知らせを送る」をオンにします。 期限が近いページの担当者に、毎朝メールが届きます。スクリプトエディタを開く必要はありません。 切り替えられるのは設置した人だけです。メールは設置した人の Gmail から、設置した人の1日の枠で送られるためです。 デプロイの「実行するユーザー」が「自分」になっていないときは、お知らせを登録しません。
承認の画面でつまずいたとき、アクセス範囲の選び方、新しい版への差し替え方は導入でつまずいたらにまとめてあります。
誰が使えるか
入れる人を決めるのは、デプロイの「アクセスできるユーザー」の設定です。wiki 側では制限していません。 「Google アカウントを持つ全員」にすると、URL を知っていてログインしている人が入れます。 組織を選べば、その組織の人だけになります。「全員」にすると、ログインしていない人も入れます。
wiki を設置した人は、自動的に管理者になります。設置した人かどうかは、開くたびに Google アカウントで確かめます。最初の名前は「管理者」で、設定から変えられます。 ほかの人は、初めて開いたときに表示する名前を決めます。 名前はその端末に覚えられるので、次からはそのまま開けます。
管理者は、設定で「参加の合言葉」を決められます。決めると、新しく参加する人と、別の端末から入り直す人に合言葉を聞きます。 「合言葉を作る」を押すと、読み間違えにくい12文字を作ります。自分で決めることもできますが、記号と空白を除いて8文字以上にしてください。数字だけのもの、同じ文字の繰り返し、wiki の名前やよく使われる言葉が入ったものは使えません。 合言葉のハイフンや空白は、打っても省いても通ります。合言葉を決め直すと、それまでの間違いの数は数え直しになります。 すでに参加している人は、そのまま使えます。URL を「Google アカウントを持つ全員」や「全員」に公開するときは、合言葉を決めてください。 合言葉を何度も間違えると、しばらく参加できなくなります。全員あわせて10分に50回までです。
管理者ができるのは、wiki の名前を変えること、参加の合言葉を決めること、メンバーの名前を整理すること、ほかの人を管理者にすることです。 ページを書く、直す、消すは、参加している人なら誰でもできます。
気をつけてほしいこと
名前は自己申告です。合言葉を知っている人なら、ほかの人の名前を名乗ることもできます。使えないのは、管理者と設置者の名前だけです。 ほかの人の名前で入ると、その人の端末として扱われます。メールアドレスは伏せて表示されるので読めませんが、お知らせの宛先は書き換えられます。 ページの「指定メンバーだけに見せる」は、鍵ではなく目隠しです。 人事や評価のように、見られて困る情報は置かないでください。
設置した人は、スプレッドシートと Google ドライブから、下書きを含むすべてのページを読めます。
メンバーを消しても、同じ URL を開けばまた参加できます。 締め出すときは、デプロイのアクセス範囲を狭めるか、URL を作り直します。
添付ファイルの中身まで探せるようにするため、Google ドライブを検索する権限を使います。承認画面にもそう出ます。
アクセス範囲の設定を間違えると、意図しない人に読まれます。ここは設置する側で確かめてください。
保存される場所
データは設置した人の Google アカウントの中だけに置かれます。ページの中身も添付ファイルも、外部のサーバーには送りません。
| 中身 | 置き場所 |
|---|---|
| ページの情報、コメント、メンバー | スプレッドシート「wiki のデータ」 |
| 本文と履歴 | 同じスプレッドシート(長い文章はセルを分けて入れます) |
| 添付ファイルと画像 | Google ドライブのフォルダ「wiki のファイル」 |
速さの目安
Google Apps Script は、画面とサーバーの往復だけで1秒ほどかかります。
- 開く: 1〜2秒
- ページを表示する: 1〜2秒
- 保存する: 4〜6秒
保存の間は画面に「保存しています…」と出て、ボタンは二重に押せません。
上限
- 1つの添付ファイル: 10MB まで
- 本文: 1ページ 500KB まで
- 履歴: 1ページ 30件まで(古いものから消えます)
- タグ: 1ページ 8個まで
- メール: 個人の Gmail では1日100通まで(Google Workspace は1500通まで)
- 1日の残りが20通を切ると、更新とコメントのお知らせは送りません。見直しのお知らせのために残します
- 更新とコメントのお知らせ: 同じページの同じ種類のものは、1人に1時間に1通まで
- 更新とコメントのお知らせ: メンバー1人がきっかけで送るのは1日30件まで、管理者以外の全員あわせて1日40件まで(管理者は数えません)
- 更新とコメントのお知らせ: 1つのアドレスに届くのは1日10通まで(
+を付けた別名も同じアドレスとして数えます) - 見直しのお知らせ: 1人に1日1通にまとめます。全体で1日20通まで
- @ で知らせる人: 1回のコメントで5人まで
- アップロード: 全員で1日300MBまで。設置した人の Google ドライブの空きが1GBを切ると止まります
- 端末: 1人10台まで(11台目で入ると、いちばん古い端末が外れます)
- 新しい人の参加: 全員で10分に20人まで
- 「管理者」という名前は、設置した人しか使えません
まだできないこと
- 全ページの書き出し(バックアップと、ほかのツールへの持ち出し)
- 言い換えの登録(「会議室」と「ミーティングルーム」を同じ扱いにする)
- 表のセルの結合
- 同じページを2人で同時に編集すること(片方が保存したときに、上書きするか読み込み直すかを選びます)
更新履歴
Google側の仕様変更や、届いた報告にあわせて直しています。古い版で動いている場合は、コード.gs を差し替えてください。