本文へ移動
戻る

社内wiki

手順書、議事録、よくある質問を1か所にまとめる社内wikiです。 契約も月額もいらず、Google アカウントだけで動きます。 データは設置した人の Google ドライブの中に置かれ、ページの中身が外部のサービスに送られることはありません。

バージョン
1.1.2
更新日
2026-10-14
すべて
Webアプリ

プレビュー

テーマ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(/&nbsp;/g, ' ')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&amp;/g, '&')
    .replace(/\s+/g, ' ')
    .trim();
}
function escHtml_(v) {
  return String(v == null ? '' : v)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

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;
    // 属性値の中の実体参照を戻してから確かめる(&#106;avascript: のような書き方を通さないため)
    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(/&quot;/gi, '"')
    .replace(/&apos;/gi, "'")
    .replace(/&lt;/gi, '<')
    .replace(/&gt;/gi, '>')
    .replace(/&amp;/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);
  }
}
              
            
コード.gs2386 lines

このツールの解説

どんなツールか

社内の手順や決まりごとを、ページとして書きためる wiki です。 新しいサービスと契約できない、データを社外に置けない、という条件でも使えます。

Google アカウントがあれば動きます。月額はかからず、使う人数が増えても費用は変わりません。 データは設置した人の Google ドライブの中に置かれ、やめるときは自分で消せます。

書きかけは下書きのまま置けます。保存するたびに前の内容が履歴に残ります。

上のプレビューは見本のデータで動いています。ページを開く、書く、保存する、履歴から戻すまで触れます。 再読み込みすると元に戻ります。

  • ページを書く(見出し、箇条書き、チェックリスト、注意カード、表、画像、添付)
  • 下書きのまま保存する。公開するまで、ほかの人には見えません
  • 履歴から前の内容に戻す。誰がいつ何を変えたかも残ります
  • 本文と、添付ファイルの中身を探す
  • AI に書かせた文章を、マークダウンのまま取り込む
  • コメントと絵文字で反応する。@ で人を呼べます
  • 担当者と見直し期限を決めて、期限が来たらメールで知らせる
  • スマホで読む、書く、直す、消す

AI に下書きさせる

wiki が続かない理由は、たいてい書く時間がないことです。 このツールには、AI に書かせた文章をそのまま取り込む入口を入れてあります。

編集画面のツールバーの「Markdown」を押すと、読み込む画面が出ます。 「AI に頼む文をコピー」を押すと、頼み方の文がコピーされます。Gemini や ChatGPT に貼り、書きたいことを足して送ってください。 返ってきた文章を貼り付けると、見出し・箇条書き・チェックリスト・表・引用・コードが、このアプリの書式に変わります。本文に直接貼っても同じです。

使う人がマークダウンを書ける必要はありません。AI がマークダウンで返してくるから入れてあります。

頼み方の文には、使う記号を絞る指示を入れてあります。 Gemini の軽いモデルは、指示が短いと前置きを付けたり、このアプリが扱えない書き方を混ぜたりします。

画像は入りません。リンクとして残ります。字下げした入れ子の箇条書きは1段にそろいます。

見た目を決める

プレビューの上に、標準・モダン・帳票の3つと、色と明暗の切り替えがあります。 選んだ見た目のまま、下のコードが書き出されます。コードを書き換える必要はありません。

色と余白は style.html の前半に変数(--app-accent など)としてまとまっています。 あとから変えたくなったときは、この部分だけを直せば全体の印象が変わります。

入れ方

貼り付けるファイルは3つです。Google アカウントのほかに、用意するものはありません。

  1. Google Apps Script で新しいプロジェクトを作ります
  2. コード.gs の中身を、最初からある コード.gs に貼り付けます
  3. 「+」から HTML ファイルを index という名前で作り、index.html の中身を貼り付けます
  4. 同じように HTML ファイルを style という名前で作り、style.html の中身を貼り付けます
  5. 「プロジェクトの設定」でタイムゾーンを「東京」にします(日付の扱いに使います)
  6. 「デプロイ」→「新しいデプロイ」→「ウェブアプリ」を選びます
    • 実行するユーザー: 自分
    • アクセスできるユーザー: Google アカウントを持つ全員(社内だけで使うなら、組織を選びます)
  7. 出てきた URL を自分で開いて、Google の承認画面を通します。自分で作ったスクリプトなので「確認されていません」と出ます。「詳細」から進めます
  8. 開いた 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 を差し替えてください。

1.1.22026-10-14設置した人かどうかを、開くたびに Google アカウントで確かめるようにしました。設置した人が一度開いた共用の端末が、Google からログアウトしたあとも設置した人として扱われていました。設定の読み込みの回数も減らしました。
1.1.12026-10-14毎朝の見直しのお知らせを切り替えられるのを、設置した人だけにしました。デプロイの「実行するユーザー」が「自分」でないときは、お知らせを登録しません。
1.1.02026-10-14参加の合言葉を決められるようにしました。お知らせメールの送りすぎを止める上限を入れ、ほかの人の名前で入ったときにメールアドレスが見えないようにしました。設置した人の最初の名前は「管理者」になります。