「selfmark」のGoogle Chrome拡張機能を強化

シンプルなブックマーク管理ツール。けっこう実用出来るので、より使いやすく改良してみました。
具体的にはGoogle Chromeの拡張機能を強化しています。
「selfmark」ロゴをクリックしたらサーバの管理ページを開けるようにしたほか、カテゴリー内の並び替えと名前編集に対応させました。これで、普段はこの拡張機能上だけで対応出来るように。より詳細に編集したい時はサーバの管理ページを開いて編集、という形です。

Google Chrome拡張機能の仕様変更に伴い、サーバ側のスクリプトも更新しています。

サーバ側、LXDコンテナにインストール

#!/usr/bin/env bash
# =============================================================================
#  selfmark — シンプルなブックマーク管理のインストールスクリプト
#  - ブックマーク追加・編集・削除・並び替え
#  - お気に入り(スター)機能
#  - カテゴリー分け(プルダウン選択 / 新規入力 / 編集)
#  - カテゴリープルダウンは保存済みの並び順で表示
#  - ファビコン表示
#  - エクスポート/インポート
#  - ポート 3325 で公開
# =============================================================================
set -euo pipefail

INSTALL_DIR="/opt/selfmark"
SERVICE_NAME="selfmark"
PORT=3325
VENV_DIR="/opt/selfmark/venv"

info()  { echo -e "\033[1;34m[INFO]\033[0m  $*"; }
ok()    { echo -e "\033[1;32m[ OK ]\033[0m  $*"; }
warn()  { echo -e "\033[1;33m[WARN]\033[0m  $*"; }
die()   { echo -e "\033[1;31m[ERR ]\033[0m  $*" >&2; exit 1; }

info "前提確認..."
command -v python3 >/dev/null 2>&1 || die "python3 が見つかりません"

NEED_VENV_SETUP=false
if [[ ! -f "${VENV_DIR}/bin/python" ]]; then
  NEED_VENV_SETUP=true
elif ! "${VENV_DIR}/bin/python" -c "import flask" >/dev/null 2>&1; then
  warn "既存の venv に flask が見つかりません。再セットアップします..."
  NEED_VENV_SETUP=true
fi

if [[ "${NEED_VENV_SETUP}" == "true" ]]; then
  if [[ ! -f "${VENV_DIR}/bin/python" ]]; then
    warn "venv が見つかりません。新規作成します..."
    mkdir -p "$(dirname "${VENV_DIR}")"
    TMPDIR_VENV=$(mktemp -d)
    VENV_OK=false
    if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
      VENV_OK=true
    fi
    rm -rf "${TMPDIR_VENV}"
    if [[ "${VENV_OK}" == "false" ]]; then
      warn "python3-venv が見つかりません。インストールします..."
      PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null)
      apt-get update -qq 2>/dev/null
      apt-get install -y -qq "python3.${PYVER}-venv" 2>/dev/null || \
      apt-get install -y -qq python3-venv 2>/dev/null || \
      die "python3-venv のインストールに失敗しました"
    fi
    python3 -m venv "${VENV_DIR}" || die "venv の作成に失敗しました"
  fi
  "${VENV_DIR}/bin/pip" install --quiet --upgrade flask
fi

if ! "${VENV_DIR}/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
  die "venv が正しく作成されませんでした"
fi
if ! "${VENV_DIR}/bin/python" -c "import flask" >/dev/null 2>&1; then
  die "venv に flask が正しくインストールされませんでした"
fi
ok "Python 環境 OK ($("${VENV_DIR}/bin/python" -c "import sys; print(sys.version)"))"

info "ディレクトリ作成..."
mkdir -p "${INSTALL_DIR}"

info "アプリケーションを生成..."
cat > "${INSTALL_DIR}/app.py" << 'PYEOF'
#!/usr/bin/env python3
"""selfmark — シンプルなブックマーク管理"""

import json
import os
from flask import Flask, Response, request, jsonify

DATA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bookmarks.json")
app = Flask(__name__)


def load_data():
    if os.path.exists(DATA_FILE):
        with open(DATA_FILE, "r") as f:
            data = json.load(f)
            data.setdefault("favicon_enabled", False)
            data.setdefault("item_order", {})
            return data
    return {"bookmarks": [], "cat_order": [], "starred_order": [], "categories": [], "favicon_enabled": False, "item_order": {}}


def save_data(data):
    with open(DATA_FILE, "w") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)


HTML = r"""<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'><stop offset='0%25' stop-color='%231a73e8'/><stop offset='100%25' stop-color='%234285f4'/></linearGradient></defs><polygon points='50,5 63,35 95,38 72,60 78,92 50,77 22,92 28,60, 5,38 37,35' fill='url(%23g)'/><text x='50' y='62' font-size='32' font-weight='bold' fill='white' text-anchor='middle' font-family='Arial'>S</text></svg>">
<title>selfmark</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
  .header { background: #1a73e8; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
  .header h1 { font-size: 20px; }
  .container { max-width: 800px; margin: 24px auto; padding: 0 16px; }
  .toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
  .btn { padding: 8px 16px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
  .btn-primary { background: #1a73e8; color: white; }
  .btn-primary:hover { background: #1557b0; }
  .btn-secondary { background: #e0e0e0; color: #333; }
  .btn-secondary:hover { background: #d0d0d0; }
  .btn-success { background: #34a853; color: white; }
  .btn-success:hover { background: #2d8f47; }
  .btn-danger { background: #e0e0e0; color: #333; }
  .btn-danger:hover { background: #d0d0d0; }
  .btn-danger .trash-icon { color: #ea4335; }
  .favicon-toggle-wrap { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); font-size: 13px; color: #555; }
  .switch { position: relative; display: inline-block; width: 38px; height: 22px; flex-shrink: 0; }
  .switch input { opacity: 0; width: 0; height: 0; }
  .switch-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .2s; border-radius: 22px; }
  .switch-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .2s; border-radius: 50%; }
  .switch input:checked + .switch-slider { background-color: #1a73e8; }
  .switch input:checked + .switch-slider:before { transform: translateX(16px); }
  .card { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 16px; margin-bottom: 12px; display: flex; align-items: center; gap: 16px; cursor: grab; transition: transform 0.15s, box-shadow 0.15s; }
  .card:active { cursor: grabbing; }
  .card.dragging { opacity: 0.5; transform: scale(0.98); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
  .card.drag-over { border-top: 3px solid #1a73e8; }
  .card .drag-handle { color: #ccc; font-size: 18px; cursor: grab; user-select: none; flex-shrink: 0; }
  .card .drag-handle:hover { color: #999; }
  .card .icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; flex-shrink: 0; }
  .card .info { flex: 1; min-width: 0; }
  .card .name { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .card .url { font-size: 13px; color: #666; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .card .url a { color: #1a73e8; text-decoration: none; }
  .card .url a:hover { text-decoration: underline; }
  .card .actions { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
  .card .cat-badge { background: #e6f4ea; color: #1e8e3e; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; margin-left: 4px; }
  .category-group { margin-bottom: 24px; }
  .category-title { font-size: 14px; font-weight: 700; color: #555; margin-bottom: 8px; padding: 8px 12px; display: flex; align-items: center; gap: 8px; cursor: pointer; background: white; border-radius: 8px; user-select: none; }
  .category-title:hover { background: #e8f0fe; }
  .category-title .cat-icon { font-size: 16px; }
  .category-title .arrow { font-size: 10px; transition: transform 0.2s; }
  .category-title.collapsed .arrow { transform: rotate(-90deg); }
  .category-body { display: block; }
  .category-body.collapsed { display: none; }
  .category-group.dragging { opacity: 0.5; transform: scale(0.98); }
  .category-group.drag-over-cat { border-top: 3px solid #1a73e8; padding-top: 8px; }
  .drag-handle-cat { color: #ccc; font-size: 14px; cursor: grab; user-select: none; flex-shrink: 0; }
  .drag-handle-cat:hover { color: #999; }
  .cat-move-btn { background: none; border: none; color: #999; font-size: 12px; cursor: pointer; padding: 2px 4px; border-radius: 4px; line-height: 1; }
  .cat-move-btn:hover { background: #e0e0e0; color: #333; }
  .cat-input { border: 1px solid #ddd; border-radius: 6px; padding: 4px 8px; font-size: 13px; width: 120px; }
  .cat-input:focus { outline: none; border-color: #1a73e8; }
  .cat-suggestions { position: absolute; background: white; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 10; max-height: 150px; overflow-y: auto; display: none; }
  .cat-suggestions.show { display: block; }
  .cat-suggestions div { padding: 6px 12px; cursor: pointer; font-size: 13px; }
  .cat-suggestions div:hover { background: #f0f0f0; }
  .edit-input { border: 1px solid #ddd; border-radius: 6px; padding: 4px 8px; font-size: 14px; width: 200px; }
  .empty { text-align: center; padding: 40px; color: #999; }
  .icon-bg { background: #e8f0fe; color: #1a73e8; }
  .toast { position: fixed; bottom: 24px; right: 24px; background: #333; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; display: none; z-index: 100; }
  .add-form { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 16px; margin-bottom: 16px; display: none; }
  .add-form.show { display: block; }
  .add-form h3 { font-size: 14px; margin-bottom: 12px; color: #555; }
  .form-row { display: flex; gap: 8px; margin-bottom: 8px; }
  .form-row input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
  .form-row input:focus { outline: none; border-color: #1a73e8; }
  .cat-edit-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
  .cat-edit-item:last-child { border-bottom: none; }
  .cat-edit-item input { flex: 1; padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
  .cat-edit-item input:focus { outline: none; border-color: #1a73e8; }
  .cat-edit-item .cat-count { font-size: 12px; color: #999; min-width: 40px; }
  .cat-select { border: 1px solid #ddd; border-radius: 6px; padding: 4px 6px; font-size: 13px; max-width: 120px; background: white; cursor: pointer; }
  .cat-select:focus { outline: none; border-color: #1a73e8; }
  .star-btn { background: none; border: none; font-size: 18px; cursor: pointer; padding: 2px 4px; border-radius: 4px; line-height: 1; color: #ccc; }
  .star-btn:hover { color: #f9ab00; }
  .star-btn.on { color: #f9ab00; }
  .star-section { margin-bottom: 24px; }
  .star-title { font-size: 14px; font-weight: 700; color: #f9ab00; margin-bottom: 8px; padding: 8px 12px; display: flex; align-items: center; gap: 8px; background: #fff8e1; border-radius: 8px; }
  .star-title .cat-icon { font-size: 16px; }
  .card.starred-view .actions { gap: 4px; }
  .card.starred-view .info { min-width: 0; }
  .card.starred-view .name { font-size: 14px; }
</style>
</head>
<body>
<div class="header">
  <h1>selfmark</h1>
</div>
<div class="container">
  <div class="toolbar">
    <button class="btn btn-success" onclick="toggleAddForm()">+ ブックマーク追加</button>
    <button class="btn btn-primary" onclick="showCatEditor()">📂 カテゴリー編集</button>
    <label class="favicon-toggle-wrap">
      <span class="switch">
        <input type="checkbox" id="faviconToggle" onchange="onFaviconToggle(this.checked)">
        <span class="switch-slider"></span>
      </span>
      ファビコン取得
    </label>
    <button class="btn btn-success" onclick="saveAll()" id="saveBtn" style="display:none">💾 保存</button>
  </div>

  <div class="add-form" id="addForm">
    <h3>ブックマークを追加</h3>
    <div class="form-row">
      <input type="text" id="addUrl" placeholder="URL (例: https://example.com)">
      <input type="text" id="addName" placeholder="名前 (例: Nextcloud)">
    </div>
    <div class="form-row">
      <select class="cat-select" id="addCategorySelect" style="max-width:200px" onchange="document.getElementById('addCategory').value=''">
        <option value="">-- カテゴリー選択 --</option>
      </select>
      <input type="text" id="addCategory" placeholder="または新規入力">
    </div>
    <button class="btn btn-primary" onclick="addBookmark()">追加</button>
    <button class="btn btn-secondary" onclick="toggleAddForm()">キャンセル</button>
  </div>

  <div class="add-form" id="catEditor">
    <h3>カテゴリー編集</h3>
    <div id="catEditorList"></div>
    <div class="form-row" style="margin-top:12px">
      <input type="text" id="newCatName" placeholder="新しいカテゴリー名">
      <button class="btn btn-primary" onclick="addCategory()">追加</button>
    </div>
    <button class="btn btn-secondary" onclick="closeCatEditor()" style="margin-top:8px">閉じる</button>
  </div>

  <div id="bookmarkList"></div>

  <div style="margin-top:32px; padding-top:16px; border-top:1px solid #ddd; display:flex; gap:8px; justify-content:center;">
    <button class="btn btn-secondary" onclick="exportData()">📥 エクスポート</button>
    <button class="btn btn-secondary" onclick="document.getElementById('importFile').click()">📤 インポート</button>
    <input type="file" id="importFile" accept=".json" style="display:none" onchange="importData(event)">
  </div>
</div>
<div class="toast" id="toast"></div>

<script>
let bookmarks = [];
let editedCats = {};
let dragSrc = null;
let allCategories = [];
let catOrder = [];
let starredOrder = [];
let itemOrder = {};
let catCollapsed = {};
let dragCatSrc = null;
let dirty = false;
let faviconEnabled = false;

function escapeHtml(s) {
  const d = document.createElement('div');
  d.appendChild(document.createTextNode(s));
  return d.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function showToast(msg) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.style.display = 'block';
  setTimeout(() => t.style.display = 'none', 2000);
}

function markDirty() {
  dirty = true;
  document.getElementById('saveBtn').style.display = 'inline-block';
}

function toggleAddForm() {
  const form = document.getElementById('addForm');
  form.classList.toggle('show');
  if (form.classList.contains('show')) {
    const sel = document.getElementById('addCategorySelect');
    const cur = document.getElementById('addCategory').value.trim();
    sel.innerHTML = '<option value="">-- カテゴリー選択 --</option>' +
      getSortedCategories().map(c => `<option value="${escapeHtml(c)}" ${c === cur ? 'selected' : ''}>${escapeHtml(c)}</option>`).join('');
  }
}

function onFaviconToggle(checked) {
  faviconEnabled = checked;
  persistFaviconSetting();
  render();
}

function persistFaviconSetting() {
  fetch('/api/favicon-setting', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({favicon_enabled: faviconEnabled}),
  }).catch(() => {});
}

function refresh() {
  fetch('/api/bookmarks').then(r => r.json()).then(data => {
    bookmarks = data.bookmarks || [];
    editedCats = {};
    catOrder = data.cat_order || [];
    starredOrder = data.starred_order || [];
    itemOrder = data.item_order || {};
    allCategories = data.categories || [];
    faviconEnabled = !!data.favicon_enabled;
    document.getElementById('faviconToggle').checked = faviconEnabled;
    collectCategories();
    allCategories.forEach(c => catCollapsed[c] = true);
    catCollapsed['_none'] = true;
    document.getElementById('saveBtn').style.display = 'none';
    dirty = false;
    render();
  });
}

function collectCategories() {
  const cats = new Set(allCategories);
  bookmarks.forEach((s, i) => {
    const cat = editedCats[i] !== undefined ? editedCats[i] : (s.category || '');
    if (cat) cats.add(cat);
  });
  allCategories = [...cats];
}

function getCat(i) {
  return editedCats[i] !== undefined ? editedCats[i] : (bookmarks[i].category || '');
}

function getSortedCategories() {
  const all = [...new Set([...allCategories])];
  if (catOrder.length > 0) {
    return catOrder.filter(c => all.includes(c)).concat(all.filter(c => !catOrder.includes(c)));
  }
  return all.sort();
}

function getItemOrderKey(cat) {
  return cat || '__none__';
}

function applyItemOrderToGroup(items, cat) {
  const key = getItemOrderKey(cat);
  const order = itemOrder[key];
  if (!order || order.length === 0) return items;
  const found = order.map(url => items.find(({s}) => s.url === url)).filter(Boolean);
  const rest = items.filter(({s}) => !order.includes(s.url));
  return found.concat(rest);
}

function renderCard(s, i, compact) {
  const cat = getCat(i);
  const isEditing = s._editing;
  const isUrlEditing = s._urlEditing;
  const catBadge = cat ? `<span class="cat-badge">${escapeHtml(cat)}</span>` : '';
  const isStarred = s.starred;
  const starClass = isStarred ? 'on' : '';
  const starIcon = isStarred ? '\u2605' : '\u2606';
  const starAction = isStarred ? `unstarBookmark(${i})` : `starBookmark(${i})`;
  let faviconHtml = '\u{1F4CC}';
  if (faviconEnabled) {
    try {
      const origin = new URL(s.url).origin;
      faviconHtml = '<img loading="lazy" src="' + origin + '/favicon.ico" width="24" height="24" style="border-radius:4px;" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'block\'"><span style="display:none">\u{1F4CC}</span>';
    } catch(e) {}
  }

  if (compact) {
    return `<div class="card starred-view" draggable="true" data-star-index="${i}"
      ondragstart="onStarDragStart(event)" ondragover="onStarDragOver(event)" ondragleave="onStarDragLeave(event)" ondrop="onStarDrop(event)" ondragend="onStarDragEnd(event)">
      <span class="drag-handle">\u2807</span>
      <div class="icon icon-bg">${faviconHtml}</div>
      <div class="info">
        <div class="name">${escapeHtml(s.name)}</div>
        <div class="url"><a href="${escapeHtml(s.url)}" target="_blank">${escapeHtml(s.url)}</a></div>
      </div>
      <div class="actions">
        <button class="star-btn on" onclick="unstarBookmark(${i})" title="\u30b9\u30bf\u30fc\u5916\u308b">\u2605</button>
      </div>
    </div>`;
  }

  return `<div class="card" draggable="true" data-index="${i}"
    ondragstart="onDragStart(event)" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)" ondragend="onDragEnd(event)">
    <span class="drag-handle">\u2807</span>
    <div class="icon icon-bg">${faviconHtml}</div>
    <div class="info">
      ${isEditing
        ? `<input class="edit-input" value="${escapeHtml(s.name)}" onchange="updateName(${i}, this.value)" onblur="stopEdit(${i})" id="edit_${i}">`
        : `<div class="name">${escapeHtml(s.name)}${catBadge}</div>`
      }
      ${isUrlEditing
        ? `<input class="edit-input" style="margin-top:4px;font-size:12px;width:100%;" value="${escapeHtml(s.url)}" onchange="updateUrl(${i}, this.value)" onblur="stopUrlEdit(${i})" id="url_edit_${i}">`
        : `<div class="url"><a href="${escapeHtml(s.url)}" target="_blank">${escapeHtml(s.url)}</a></div>`
      }
    </div>
    <div class="actions">
      <button class="star-btn ${starClass}" onclick="${starAction}" title="\u304a\u6c17\u306b\u5165\u308a">${starIcon}</button>
      <select class="cat-select" onchange="updateCategory(${i}, this.value)">
        <option value="">\u672a\u5206\u985e</option>
        ${getSortedCategories().map(c => `<option value="${escapeHtml(c)}" ${c === cat ? 'selected' : ''}>${escapeHtml(c)}</option>`).join('')}
      </select>
      <button class="btn btn-secondary" onclick="startEdit(${i})">\u270f\uFE0F</button>
      <button class="btn btn-secondary" onclick="startUrlEdit(${i})" title="URL\u7de8\u96c6">\u{1F517}</button>
      <button class="btn btn-danger" onclick="removeBookmark(${i})"><span class="trash-icon">\u{1F5D1}\uFE0F</span></button>
    </div>
  </div>`;
}

function render() {
  const visible = bookmarks.map((s, i) => ({s, i, cat: getCat(i)}));
  const groups = {};
  const noCategory = [];
  visible.forEach(({s, i, cat}) => {
    if (cat) {
      if (!groups[cat]) groups[cat] = [];
      groups[cat].push({s, i});
    } else {
      noCategory.push({s, i});
    }
  });

  // カテゴリー内のアイテム順を itemOrder で調整
  Object.keys(groups).forEach(cat => {
    groups[cat] = applyItemOrderToGroup(groups[cat], cat);
  });
  const noCategorySorted = applyItemOrderToGroup(noCategory, '');

  const containerEl = document.getElementById('bookmarkList');
  let html = '';

  const starredItems = bookmarks.map((s, i) => ({s, i})).filter(({s}) => s.starred);
  const sortedStarred = starredOrder.length > 0
    ? starredOrder.map(url => starredItems.find(({s}) => s.url === url)).filter(Boolean)
    : starredItems;
  const starredInOrder = sortedStarred.concat(starredItems.filter(({s}) => !starredOrder.includes(s.url)));

  if (starredInOrder.length > 0) {
    html += `<div class="star-section">
      <div class="star-title"><span class="cat-icon">\u2b50</span> \u304a\u6c17\u306b\u5165\u308a</div>
      ${starredInOrder.map(({s, i}) => renderCard(s, i, true)).join('')}
    </div>`;
  }

  const defaultCats = [...new Set([...Object.keys(groups), ...allCategories])].sort();
  const sortedCats = catOrder.length > 0
    ? catOrder.filter(c => defaultCats.includes(c)).concat(defaultCats.filter(c => !catOrder.includes(c)))
    : defaultCats;

  sortedCats.forEach((cat, catIdx) => {
    const catId = 'cat-' + catIdx;
    const isCollapsed = catCollapsed[cat] !== false;
    const collapsedClass = isCollapsed ? 'collapsed' : '';
    const catItems = groups[cat] || [];
    html += `<div class="category-group" draggable="true" data-cat="${escapeHtml(cat)}"
      ondragstart="onCatDragStart(event)" ondragover="onCatDragOver(event)" ondragleave="onCatDragLeave(event)" ondrop="onCatDrop(event)" ondragend="onCatDragEnd(event)">
      <div class="category-title ${collapsedClass}" onclick="toggleCategory('${catId}', '${escapeHtml(cat).replace(/'/g, "\\'")}')">
        <span class="arrow">\u25BC</span>
        <span class="drag-handle-cat" onclick="event.stopPropagation()">\u2807</span>
        <span class="cat-icon">\u{1F4C2}</span> ${escapeHtml(cat)}
        <span style="margin-left:auto;display:flex;align-items:center;gap:2px;">
          <button class="cat-move-btn" onclick="event.stopPropagation();moveCategory('${escapeHtml(cat).replace(/'/g, "\\'")}', -1)" title="\u4e0a\u306b\u79fb\u52d5">\u25B2</button>
          <button class="cat-move-btn" onclick="event.stopPropagation();moveCategory('${escapeHtml(cat).replace(/'/g, "\\'")}', 1)" title="\u4e0b\u306b\u79fb\u52d5">\u25BC</button>
          <span style="font-size:12px;color:#999;margin-left:4px;">${catItems.length}</span>
        </span>
      </div>
      <div class="category-body ${collapsedClass}" id="${catId}">
        ${catItems.length > 0 ? catItems.map(({s, i}) => renderCard(s, i)).join('') : '<div class="empty" style="padding:16px;font-size:13px;">\u3053\u306e\u30ab\u30c6\u30b4\u30ea\u30fc\u306b\u306f\u307e\u3060\u30d6\u30c3\u30af\u30de\u30fc\u30af\u304c\u3042\u308a\u307e\u305b\u3093</div>'}
      </div>
    </div>`;
  });

  if (noCategory.length > 0) {
    const noCatId = 'cat-none';
    const isCollapsedNone = catCollapsed['_none'] !== false;
    const collapsedClassNone = isCollapsedNone ? 'collapsed' : '';
    if (sortedCats.length > 0) {
      html += `<div class="category-group" draggable="true" data-cat="_none"
        ondragstart="onCatDragStart(event)" ondragover="onCatDragOver(event)" ondragleave="onCatDragLeave(event)" ondrop="onCatDrop(event)" ondragend="onCatDragEnd(event)">
        <div class="category-title ${collapsedClassNone}" onclick="toggleCategory('${noCatId}', '_none')">
          <span class="arrow">\u25BC</span>
          <span class="drag-handle-cat" onclick="event.stopPropagation()">\u2807</span>
          <span class="cat-icon">\u{1F4CB}</span> \u672a\u5206\u985e
          <span style="margin-left:auto;display:flex;align-items:center;gap:2px;">
            <button class="cat-move-btn" onclick="event.stopPropagation();moveCategory('_none', -1)" title="\u4e0a\u306b\u79fb\u52d5">\u25B2</button>
            <button class="cat-move-btn" onclick="event.stopPropagation();moveCategory('_none', 1)" title="\u4e0b\u306b\u79fb\u52d5">\u25BC</button>
            <span style="font-size:12px;color:#999;margin-left:4px;">${noCategorySorted.length}</span>
          </span>
        </div>
        <div class="category-body ${collapsedClassNone}" id="${noCatId}">
          ${noCategorySorted.map(({s, i}) => renderCard(s, i)).join('')}
        </div>
      </div>`;
    } else {
      html = noCategorySorted.map(({s, i}) => renderCard(s, i)).join('');
    }
  }

  if (bookmarks.length === 0) {
    html = '<div class="empty">\u30d6\u30c3\u30af\u30de\u30fc\u30af\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u201c\uff0b \u30d6\u30c3\u30af\u30de\u30fc\u30af\u8ffd\u52a0\u201d\u304b\u3089\u8ffd\u52a0\u3057\u3066\u304f\u3060\u3055\u3044\u3002</div>';
  }

  containerEl.innerHTML = html;
}

function showCatEditor() {
  document.getElementById('catEditor').classList.add('show');
  renderCatEditorList();
}

function closeCatEditor() {
  document.getElementById('catEditor').classList.remove('show');
}

function renderCatEditorList() {
  const counts = {};
  bookmarks.forEach(s => {
    const c = s.category || '';
    if (c) counts[c] = (counts[c] || 0) + 1;
  });
  const cats = [...new Set([...allCategories, ...Object.keys(counts)])].sort();
  const el = document.getElementById('catEditorList');
  if (cats.length === 0) {
    el.innerHTML = '<div style="color:#999;font-size:13px;padding:8px 0;">\u30ab\u30c6\u30b4\u30ea\u30fc\u304c\u3042\u308a\u307e\u305b\u3093</div>';
    return;
  }
  el.innerHTML = cats.map(c => {
    const n = counts[c] || 0;
    return `<div class="cat-edit-item">
      <input type="text" value="${escapeHtml(c)}" data-old="${escapeHtml(c)}" onchange="renameCategory(this)">
      <span class="cat-count">${n}\u4ef6</span>
      <button class="btn btn-danger" onclick="deleteCategory('${escapeHtml(c).replace(/'/g, "\\'")}')" title="\u524a\u9664"><span class="trash-icon">\u{1F5D1}\uFE0F</span></button>
    </div>`;
  }).join('');
}

function addCategory() {
  const input = document.getElementById('newCatName');
  const name = input.value.trim();
  if (!name) return;
  if (allCategories.includes(name)) { showToast('\u540c\u3058\u540d\u524d\u306e\u30ab\u30c6\u30b4\u30ea\u30fc\u304c\u65e2\u306b\u3042\u308a\u307e\u3059'); return; }
  allCategories.push(name);
  input.value = '';
  renderCatEditorList();
  markDirty();
  render();
  showToast('\u30ab\u30c6\u30b4\u30ea\u30fc\u3092\u8ffd\u52a0\u3057\u307e\u3057\u305f');
}

function renameCategory(inputEl) {
  const oldName = inputEl.dataset.old;
  const newName = inputEl.value.trim();
  if (!newName || newName === oldName) { renderCatEditorList(); return; }
  if (allCategories.includes(newName)) { showToast('\u540c\u3058\u540d\u524d\u306e\u30ab\u30c6\u30b4\u30ea\u30fc\u304c\u65e2\u306b\u3042\u308a\u307e\u3059'); inputEl.value = oldName; return; }
  const idx = allCategories.indexOf(oldName);
  if (idx >= 0) allCategories[idx] = newName;
  bookmarks.forEach(s => { if (s.category === oldName) s.category = newName; });
  Object.keys(catCollapsed).forEach(k => {
    if (k === oldName) { catCollapsed[newName] = catCollapsed[k]; delete catCollapsed[k]; }
  });
  catOrder = catOrder.map(c => c === oldName ? newName : c);
  if (itemOrder[oldName]) {
    itemOrder[newName] = itemOrder[oldName];
    delete itemOrder[oldName];
  }
  collectCategories();
  markDirty();
  renderCatEditorList();
  render();
  showToast('\u30ab\u30c6\u30b4\u30ea\u30fc\u540d\u3092\u5909\u66F4\u3057\u307e\u3057\u305F');
}

function deleteCategory(catName) {
  const n = bookmarks.filter(s => (s.category || '') === catName).length;
  if (n > 0 && !confirm(`\u300C${catName}\u300D\u306B\u5c5e\u3059\u308b\u30d6\u30c3\u30af\u30de\u30fc\u30af\u304C${n}\u4ef6\u3042\u308A\u307e\u3059\u3002\u672a\u5206\u985E\u306B\u5909\u66F4\u3057\u307e\u3059\u304B\uFF1F`)) return;
  if (n === 0 && !confirm(`\u300C${catName}\u300D\u3092\u524a\u9664\u3057\u307E\u3059\u304B\uFF1F`)) return;
  bookmarks.forEach(s => { if (s.category === catName) s.category = ''; });
  allCategories = allCategories.filter(c => c !== catName);
  delete catCollapsed[catName];
  catOrder = catOrder.filter(c => c !== catName);
  delete itemOrder[catName];
  collectCategories();
  markDirty();
  renderCatEditorList();
  render();
  showToast('\u30ab\u30c6\u30b4\u30ea\u30fc\u3092\u524a\u9664\u3057\u307e\u3057\u305F');
}

function showCatSuggestions(id) {
  const el = document.getElementById('sug_' + id);
  if (el && el.children.length > 0) el.classList.add('show');
}

function hideCatSuggestions(id) {
  const el = document.getElementById('sug_' + id);
  if (el) el.classList.remove('show');
}

function toggleCategory(catId, catName) {
  catCollapsed[catName] = !catCollapsed[catName];
  const bodyEl = document.getElementById(catId);
  if (bodyEl) {
    bodyEl.previousElementSibling.classList.toggle('collapsed');
    bodyEl.classList.toggle('collapsed');
  }
}

function moveCategory(catName, direction) {
  const currentOrder = getDisplayCatOrder();
  const idx = currentOrder.indexOf(catName);
  if (idx === -1) return;
  const newIdx = idx + direction;
  if (newIdx < 0 || newIdx >= currentOrder.length) return;
  currentOrder.splice(idx, 1);
  currentOrder.splice(newIdx, 0, catName);
  catOrder = currentOrder;
  markDirty();
  render();
}

function onCatDragStart(e) {
  if (!e.target.classList.contains('drag-handle-cat') && e.target.closest('.card')) return;
  dragCatSrc = e.target.closest('.category-group').dataset.cat;
  e.target.closest('.category-group').classList.add('dragging');
  e.dataTransfer.effectAllowed = 'move';
  e.dataTransfer.setData('text/plain', 'category');
}

function onCatDragOver(e) {
  e.preventDefault();
  if (!dragCatSrc) return;
  const group = e.target.closest('.category-group');
  if (group && group.dataset.cat !== dragCatSrc) group.classList.add('drag-over-cat');
}

function onCatDragLeave(e) {
  const group = e.target.closest('.category-group');
  if (group) group.classList.remove('drag-over-cat');
}

function onCatDrop(e) {
  e.preventDefault();
  const group = e.target.closest('.category-group');
  if (group) group.classList.remove('drag-over-cat');
  if (!dragCatSrc) return;
  const dstCat = group.dataset.cat;
  if (dragCatSrc === dstCat) return;
  const currentOrder = getDisplayCatOrder();
  const srcIdx = currentOrder.indexOf(dragCatSrc);
  const dstIdx = currentOrder.indexOf(dstCat);
  if (srcIdx === -1 || dstIdx === -1) return;
  currentOrder.splice(srcIdx, 1);
  currentOrder.splice(dstIdx, 0, dragCatSrc);
  catOrder = currentOrder;
  markDirty();
  render();
}

function onCatDragEnd(e) {
  document.querySelectorAll('.category-group').forEach(el => {
    el.classList.remove('dragging');
    el.classList.remove('drag-over-cat');
  });
  dragCatSrc = null;
}

function getDisplayCatOrder() {
  const groups = {};
  bookmarks.forEach((s, i) => {
    const cat = getCat(i);
    if (cat) groups[cat] = true;
  });
  const allCats = Object.keys(groups);
  if (catOrder.length > 0) {
    return catOrder.filter(c => allCats.includes(c)).concat(allCats.filter(c => !catOrder.includes(c)));
  }
  return allCats.sort();
}

function setCategory(i, cat) {
  editedCats[i] = cat;
  markDirty();
  render();
}

function updateCategory(i, val) {
  const cat = val.trim();
  editedCats[i] = cat;
  if (cat && !allCategories.includes(cat)) allCategories.push(cat);
  markDirty();
  render();
}

function onDragStart(e) {
  dragSrc = parseInt(e.target.dataset.index);
  e.target.classList.add('dragging');
  e.dataTransfer.effectAllowed = 'move';
}

function onDragOver(e) {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'move';
  const card = e.target.closest('.card');
  if (card && !card.classList.contains('dragging')) card.classList.add('drag-over');
}

function onDragLeave(e) {
  const card = e.target.closest('.card');
  if (card) card.classList.remove('drag-over');
}

function onDrop(e) {
  e.preventDefault();
  const card = e.target.closest('.card');
  if (card) card.classList.remove('drag-over');
  if (dragSrc === null || !card) return;
  const dstIndex = parseInt(card.dataset.index);
  if (dragSrc === dstIndex) return;

  const srcUrl = bookmarks[dragSrc].url;
  const dstUrl = bookmarks[dstIndex].url;
  const cat = getCat(dragSrc);

  const item = bookmarks.splice(dragSrc, 1)[0];
  const adjustedIndex = dstIndex > dragSrc ? dstIndex - 1 : dstIndex;
  bookmarks.splice(adjustedIndex, 0, item);
  editedCats = {};
  collectCategories();

  // 同一カテゴリー内でのドラッグ並び替えを itemOrder にも反映
  updateItemOrderFromDrag(cat, srcUrl, dstUrl);

  markDirty();
  render();
}

function updateItemOrderFromDrag(cat, srcUrl, dstUrl) {
  const key = getItemOrderKey(cat);
  // 現在のそのカテゴリー内表示順を基準に、srcUrlをdstUrlの位置へ移動
  const visibleInCat = bookmarks
    .map((s, i) => ({s, i}))
    .filter(({s, i}) => getCat(i) === cat)
    .map(({s}) => s.url);
  let order = itemOrder[key] && itemOrder[key].length > 0
    ? itemOrder[key].filter(u => visibleInCat.includes(u)).concat(visibleInCat.filter(u => !itemOrder[key].includes(u)))
    : visibleInCat.slice();
  const filtered = order.filter(u => u !== srcUrl);
  const dstIdx = filtered.indexOf(dstUrl);
  if (dstIdx === -1) {
    filtered.push(srcUrl);
  } else {
    filtered.splice(dstIdx, 0, srcUrl);
  }
  itemOrder[key] = filtered;
}

function onDragEnd(e) {
  e.target.classList.remove('dragging');
  document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
  dragSrc = null;
}

function startEdit(i) {
  bookmarks[i]._editing = true;
  render();
  const inp = document.getElementById('edit_' + i);
  if (inp) { inp.focus(); inp.select(); }
}

function stopEdit(i) {
  bookmarks[i]._editing = false;
  render();
}

function updateName(i, val) {
  bookmarks[i].name = val.trim();
  bookmarks[i]._editing = false;
  markDirty();
  render();
}

function startUrlEdit(i) {
  bookmarks[i]._urlEditing = true;
  render();
  const inp = document.getElementById('url_edit_' + i);
  if (inp) { inp.focus(); inp.select(); }
}

function stopUrlEdit(i) {
  bookmarks[i]._urlEditing = false;
  render();
}

function updateUrl(i, val) {
  const newUrl = val.trim();
  if (!newUrl) { stopUrlEdit(i); return; }
  const oldUrl = bookmarks[i].url;
  bookmarks[i].url = newUrl;
  bookmarks[i]._urlEditing = false;
  // itemOrder / starredOrder 内のURL参照も更新
  Object.keys(itemOrder).forEach(key => {
    itemOrder[key] = itemOrder[key].map(u => u === oldUrl ? newUrl : u);
  });
  starredOrder = starredOrder.map(u => u === oldUrl ? newUrl : u);
  markDirty();
  render();
}

function addBookmark() {
  const name = document.getElementById('addName').value.trim();
  const url = document.getElementById('addUrl').value.trim();
  const categoryInput = document.getElementById('addCategory').value.trim();
  const categorySelect = document.getElementById('addCategorySelect').value;
  const category = categoryInput || categorySelect;
  if (!name || !url) { showToast('\u540d\u524d\u3068URL\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044'); return; }
  bookmarks.push({name, url, category, starred: false});
  if (category && !allCategories.includes(category)) allCategories.push(category);
  const key = getItemOrderKey(category);
  if (!itemOrder[key]) itemOrder[key] = [];
  itemOrder[key].push(url);
  document.getElementById('addName').value = '';
  document.getElementById('addUrl').value = '';
  document.getElementById('addCategory').value = '';
  document.getElementById('addCategorySelect').value = '';
  document.getElementById('addForm').classList.remove('show');
  markDirty();
  render();
  showToast('\u8ffd\u52a0\u3057\u307e\u3057\u305F');
}

function removeBookmark(i) {
  if (!confirm('\u3053\u306E\u30d6\u30c3\u30AF\u30DE\u30FC\u30AF\u3092\u524a\u9664\u3057\u307E\u3059\u304B\uFF1F')) return;
  const url = bookmarks[i].url;
  bookmarks.splice(i, 1);
  editedCats = {};
  collectCategories();
  Object.keys(itemOrder).forEach(key => {
    itemOrder[key] = itemOrder[key].filter(u => u !== url);
  });
  starredOrder = starredOrder.filter(u => u !== url);
  markDirty();
  render();
}

function starBookmark(i) {
  bookmarks[i].starred = true;
  if (!starredOrder.includes(bookmarks[i].url)) starredOrder.push(bookmarks[i].url);
  markDirty();
  render();
}

function unstarBookmark(i) {
  bookmarks[i].starred = false;
  starredOrder = starredOrder.filter(u => u !== bookmarks[i].url);
  markDirty();
  render();
}

let starDragSrc = null;

function onStarDragStart(e) {
  starDragSrc = parseInt(e.target.closest('.card').dataset.starIndex);
  e.target.closest('.card').classList.add('dragging');
  e.dataTransfer.effectAllowed = 'move';
  e.dataTransfer.setData('text/plain', 'star');
}

function onStarDragOver(e) {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'move';
  const card = e.target.closest('.card');
  if (card && !card.classList.contains('dragging')) card.classList.add('drag-over');
}

function onStarDragLeave(e) {
  const card = e.target.closest('.card');
  if (card) card.classList.remove('drag-over');
}

function onStarDrop(e) {
  e.preventDefault();
  const card = e.target.closest('.card');
  if (card) card.classList.remove('drag-over');
  if (starDragSrc === null || !card) return;
  const dstIndex = parseInt(card.dataset.starIndex);
  if (starDragSrc === dstIndex) return;
  const srcUrl = bookmarks[starDragSrc].url;
  const dstUrl = bookmarks[dstIndex].url;
  const srcIdx = starredOrder.indexOf(srcUrl);
  const dstIdx = starredOrder.indexOf(dstUrl);
  if (srcIdx === -1 || dstIdx === -1) return;
  starredOrder.splice(srcIdx, 1);
  starredOrder.splice(dstIdx, 0, srcUrl);
  markDirty();
  render();
}

function onStarDragEnd(e) {
  e.target.closest('.card').classList.remove('dragging');
  document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
  starDragSrc = null;
}

function saveAll() {
  bookmarks.forEach((s, i) => {
    delete s._editing;
    delete s._urlEditing;
    if (editedCats[i] !== undefined) s.category = editedCats[i];
  });
  editedCats = {};
  collectCategories();
  fetch('/api/bookmarks', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({bookmarks, cat_order: catOrder, starred_order: starredOrder, categories: allCategories, favicon_enabled: faviconEnabled, item_order: itemOrder}),
  }).then(r => r.json()).then(() => {
    document.getElementById('saveBtn').style.display = 'none';
    dirty = false;
    showToast('\u4fdd\u5b58\u3057\u307e\u3057\u305F');
  });
}

function exportData() {
  const data = { bookmarks, exportedAt: new Date().toISOString() };
  const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `selfmark-${new Date().toISOString().slice(0,10)}.json`;
  a.click();
  URL.revokeObjectURL(url);
  showToast('\u30a8\u30af\u30b9\u30dd\u30fc\u30c8\u3057\u307e\u3057\u305F');
}

function importData(e) {
  const file = e.target.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = function(ev) {
    try {
      const data = JSON.parse(ev.target.result);
      const items = data.bookmarks || data.manualServices || [];
      const existingUrls = new Set(bookmarks.map(s => s.url));
      let added = 0;
      items.forEach(s => {
        if (!existingUrls.has(s.url)) {
          bookmarks.push({name: s.name || '', url: s.url || '', category: s.category || '', starred: s.starred || false});
          added++;
        }
      });
      collectCategories();
      markDirty();
      render();
      showToast(added > 0 ? `${added} \u4ef6\u30a4\u30f3\u30dd\u30fc\u30c8\u3057\u307e\u3057\u305F\u3002\u4fdd\u5b58\u3057\u3066\u304f\u3060\u3055\u3044\u3002` : '\u65b0\u3057\u3044\u30d6\u30c3\u30af\u30de\u30fc\u30af\u306f\u3042\u308a\u307e\u305b\u3093\u3067\u3057\u305F');
    } catch(err) {
      showToast('\u30d5\u30a1\u30a4\u30eb\u306E\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305F');
    }
  };
  reader.readAsText(file);
  e.target.value = '';
}

window.addEventListener('beforeunload', e => {
  if (dirty) { e.preventDefault(); e.returnValue = ''; }
});

refresh();
</script>
</body>
</html>"""


@app.route("/")
def index():
    return Response(HTML, content_type="text/html")


@app.route("/api/bookmarks", methods=["GET", "POST"])
def api_bookmarks():
    if request.method == "POST":
        data = request.json
        save_data({
            "bookmarks": data.get("bookmarks", []),
            "cat_order": data.get("cat_order", []),
            "starred_order": data.get("starred_order", []),
            "categories": data.get("categories", []),
            "favicon_enabled": bool(data.get("favicon_enabled", False)),
            "item_order": data.get("item_order", {}),
        })
        return jsonify({"ok": True})

    return jsonify(load_data())


@app.route("/api/favicon-setting", methods=["POST"])
def api_favicon_setting():
    data = request.json
    current = load_data()
    current["favicon_enabled"] = bool(data.get("favicon_enabled", False))
    save_data(current)
    return jsonify({"ok": True})


if __name__ == "__main__":
    print("[INFO] selfmark: http://127.0.0.1:3325", flush=True)
    app.run(host="0.0.0.0", port=3325, debug=False)
PYEOF
ok "アプリケーション生成完了"

info "依存パッケージを確認..."
if [[ -f "${VENV_DIR}/bin/pip" ]]; then
  "${VENV_DIR}/bin/pip" install --quiet --upgrade flask 2>/dev/null
else
  die "venv の pip が見つかりません"
fi
ok "依存パッケージ OK"

info "systemd ユニットファイルを生成..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=selfmark Bookmark Manager
After=network.target

[Service]
Type=simple
ExecStart=${VENV_DIR}/bin/python ${INSTALL_DIR}/app.py
WorkingDirectory=${INSTALL_DIR}
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
ok "systemd ユニットファイル生成完了"

if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then
  warn "ポート ${PORT} は既に使用中です。停止します..."
  fuser -k "${PORT}/tcp" 2>/dev/null || true
  sleep 1
fi

info "サービスを起動..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" 2>/dev/null
systemctl restart "${SERVICE_NAME}"

STARTED=false
for i in $(seq 1 10); do
  sleep 1
  if curl -s --max-time 2 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
    STARTED=true
    break
  fi
done

if [[ "${STARTED}" == "true" ]]; then
  ok "サービス起動完了"
else
  warn "サービスの起動を確認できません。ログを確認します..."
  journalctl -u ${SERVICE_NAME} --no-pager -n 10 2>/dev/null || true
  sleep 3
  if curl -s --max-time 2 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
    ok "サービス起動完了(遅延あり)"
  else
    die "サービスの起動に失敗しました"
  fi
fi

echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "セットアップ完了!"
echo ""
echo "  URL: http://127.0.0.1:${PORT}"
echo ""
echo "  管理コマンド:"
echo "    systemctl status ${SERVICE_NAME}"
echo "    systemctl restart ${SERVICE_NAME}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

Google Chrome拡張機能

今回のバージョンに対応したGoogle Chrome拡張機能です。デベロッパーモードにして追加してください。

タイトルとURLをコピーしました