Obsidianに入力フォームを作ってアカウント管理してみた

[PR]本ページはプロモーションが含まれています

パスワード管理アプリは便利ですが、使い勝手や自由度が合わないことも。
「毎日触るObsidianの中に“自分仕様の管理ツール”を作れないか?」と考え、入力フォーム+一覧+更新・削除まで動かす仕組みを作りました。
本記事は**PDCA(Plan→Do→Check→Act)**の流れで整理しています。この記事の通りに進めれば、あなたの環境でも同じ仕組みが動きます。

目次

前提(今回の検証環境)

 Obsidian がインストールされている。

 Obsidianコミュニティプラグイン:Dataview が有効化されている。

Obsidianコミュニティプラグイン:Dataviewの設定でEnable JavaScript queries をオンにする

本記事は**PDCA(Plan→Do→Check→Action)**の流れで整理しています。
この記事の通りに進めれば、あなたの環境でも同じ仕組みが動きます。

この記事のコードはコピペで動くように整理済みです。

Plan:設計(アカウント管理の仕組み)

全体像

システムでは、操作とデータを分けて管理します。

  • view(入力・更新・閲覧):ユーザーが画面で行う操作
  • data(保存データ):操作の結果として蓄積される実際の情報

これにより、見た目をスッキリさせつつ、データを安全に蓄積・活用 できるようになります。

ノート【view】
ノート【data】

フォルダ/ノート構成

account        (アカウント管理用フォルダ)
├─ view.md      (入力フォームを設置するノート):ノート【view】
└─ data.md      (入力された内容を保存するノート):ノート【data】

.mdはファイルの拡張子なのでObsidian上では表示されずviewやdataと表示されます。


動作イメージ(フロー)

[ view.md ]   ← 入力フォーム:ノート【view】
     │ 入力送信
     ▼
[ data.md ]   ← データが追記されて保存:ノート【data】
  1. ノート【view】の入力フォームに記入
  2. ボタンを押すと内容がノート【data】へ
  3. ノート【data】には時系列でどんどん追記されていく

ノート【data】は保存のみで内容は確認しません。

入力フォーム:ノート【view】
データ保存:ノート【data】

分離のメリット

  • 表示ノートとデータノートを役割分担できる
    → 入力画面がごちゃつかず、管理しやすい
  • 拡張しやすい
    → 将来的に別のフォームやデータ処理にも流用可能

Do:実装(実際に動かす)

STEP
accountフォルダーとノート【view】を作成
  1. 新規フォルダーでaccountフォルダーを作成する。
  2. 新規ノートでタイトルを【view】としてノートを作成する。
STEP
ノート【view】にコードを貼る

下記のコード全文をコピーしてノート【view】の本文に貼り付けてください。

コードは長いので折りたたんであります。

表示されている『貼り付けコード』の部分をクリックすると展開され、内容を確認できます。

貼り付け用コード
```dataviewjs
/***** 設定 *****/
const DATA_PATH = "account/data.md";

/***** スタイル(見やすさ調整) *****/
const STYLE_ID = "acct-crud-split";
if (!document.getElementById(STYLE_ID)) {
  const st = document.createElement("style");
  st.id = STYLE_ID;
  st.textContent = `
  .acct-form { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; align-items:center; }
  .acct-form input[type="text"], .acct-form input[type="password"] {
    padding:6px 8px; border:1px solid var(--background-modifier-border);
    border-radius:6px; min-width:180px;
  }
  .acct-form .btn {
    padding:6px 12px; border:1px solid var(--background-modifier-border);
    border-radius:6px; background:var(--interactive-accent-hover);
    color:var(--text-on-accent); cursor:pointer;
  }
  .acct-form .btn.secondary { background:transparent; color:var(--text-normal); }
  .acct-form .chk { display:flex; align-items:center; gap:6px; }
  .acct-table { width:100%; border-collapse:collapse; margin-top:8px; }
  .acct-table th, .acct-table td { padding:6px 8px; border-bottom:1px solid var(--background-modifier-border); }
  .acct-table th { text-align:left; }
  .acct-actions { display:flex; gap:8px; }
  `;
  document.head.appendChild(st);
}

/***** ユーティリティ:data.md の取得/作成/読み書き *****/
async function ensureDataFile() {
  let af = app.vault.getAbstractFileByPath(DATA_PATH);
  if (!af) {
    const parts = DATA_PATH.split("/");
    const folder = parts.slice(0, -1).join("/");
    if (folder && !app.vault.getAbstractFileByPath(folder)) {
      await app.vault.createFolder(folder);
    }
    af = await app.vault.create(DATA_PATH, "# アカウントデータ\n");
  }
  return af;
}
async function readText()  { return await app.vault.read(await ensureDataFile()); }
async function writeText(t){ return await app.vault.modify(await ensureDataFile(), t); }

/***** 入力フォーム *****/
const root = dv.el("div","",{cls:"acct-form"});
const svc = root.createEl("input",{type:"text",placeholder:"サービス名"});
const usr = root.createEl("input",{type:"text",placeholder:"ユーザー名"});
const pwd = root.createEl("input",{type:"text",placeholder:"パスワード"});
const wrap = root.createEl("div",{cls:"chk"});
const hide = wrap.createEl("input",{type:"checkbox"}); wrap.createEl("span",{text:"隠す"});
hide.addEventListener("change",()=>{ pwd.type = hide.checked ? "password" : "text"; });
const addBtn    = root.createEl("button",{text:"追加",cls:"btn"});
const updateBtn = root.createEl("button",{text:"更新",cls:"btn secondary"}); updateBtn.disabled = true;

const tableHost = dv.el("div","");
let editTarget = null; // {start,end}

/***** ブロック検出(data.mdの中をブロック単位で把握) *****/
function findBlocksWithPositions(text) {
  const lines = text.split(/\r?\n/);
  const starts = [];
  let offset = 0;
  const hdrRe = /^\s*#{2,6}\s*.+\s*$/;
  const svcRe = /^\s*(?:-?\s*)?service\s*(?::|:){1,2}/i;
  for (const line of lines) {
    if (hdrRe.test(line) || svcRe.test(line)) starts.push(offset);
    offset += line.length + 1;
  }
  if (starts.length === 0) return [];
  starts.push(text.length);
  const blocks = [];
  for (let i=0;i<starts.length-1;i++) {
    const start = starts[i], end = starts[i+1];
    const chunk = text.slice(start, end);
    const get = (k) => {
      const r = new RegExp("^\\s*(?:-\\s*)?" + k + "\\s*(?::|:){1,2}\\s*(.*)$","mi").exec(chunk);
      return r ? r[1].trim() : "";
    };
    const service  = get("service");
    const username = get("username");
    const password = get("password");
    const updated  = get("updated");
    if (service) blocks.push({ start, end, text:chunk, service, username, password, updated });
  }
  return blocks;
}

/***** 表描画 *****/
function renderTable(blocks) {
  const rows = [...blocks].sort((a,b)=>(b.updated||"").localeCompare(a.updated||""));
  let html = `<table class="acct-table"><thead><tr>
    <th>サービス名</th><th>ユーザー名</th><th>パスワード</th><th>更新日</th><th></th>
  </tr></thead><tbody>`;
  if (rows.length === 0) {
    html += `<tr><td colspan="5">DataviewJS: レコードがありません</td></tr>`;
  } else {
    for (const b of rows) {
      html += `<tr data-start="${b.start}" data-end="${b.end}">
        <td>${b.service||""}</td>
        <td>${b.username||""}</td>
        <td>${b.password||""}</td>
        <td>${b.updated||""}</td>
        <td><div class="acct-actions">
          <button class="btn secondary acct-edit">編集</button>
          <button class="btn secondary acct-del">削除</button>
        </div></td>
      </tr>`;
    }
  }
  html += `</tbody></table>`;
  tableHost.innerHTML = html;

  // イベント
  tableHost.querySelectorAll(".acct-edit").forEach(btn=>{
    btn.onclick = async () => {
      const tr = btn.closest("tr");
      const start = Number(tr.dataset.start), end = Number(tr.dataset.end);
      const text = await readText();
      const blocks = findBlocksWithPositions(text);
      const b = blocks.find(x=>x.start===start && x.end===end);
      if (!b) return;
      svc.value = b.service; usr.value = b.username; pwd.value = b.password;
      editTarget = { start, end };
      updateBtn.disabled = false; addBtn.disabled = true;
      new Notice("編集モードになりました(更新で保存)");
    };
  });
  tableHost.querySelectorAll(".acct-del").forEach(btn=>{
    btn.onclick = async () => {
      const tr = btn.closest("tr");
      const start = Number(tr.dataset.start), end = Number(tr.dataset.end);
      let text = await readText();
      let s = start; while (s>0 && text[s-1]==="\n") s--;
      const newText = text.slice(0, s) + text.slice(end);
      await writeText(newText);
      await refresh();
      new Notice("削除しました");
      if (editTarget && editTarget.start===start && editTarget.end===end) {
        editTarget = null; updateBtn.disabled = true; addBtn.disabled = false;
        svc.value = ""; usr.value = ""; pwd.value = "";
      }
    };
  });
}

/***** 再描画 *****/
async function refresh() {
  const text = await readText();
  const blocks = findBlocksWithPositions(text);
  renderTable(blocks);
}

/***** 追加(data.md へ追記) *****/
addBtn.onclick = async () => {
  const service  = (svc.value||"").trim();
  const username = (usr.value||"").trim();
  const password = (pwd.value||"").trim();
  if (!service) { new Notice("サービス名は必須です"); return; }
  const today = window.moment().format("YYYY-MM-DD");
  let entry = "\n\n## " + service + "\n";
  entry    += "service:: "  + service  + "\n";
  entry    += "username:: " + username + "\n";
  entry    += "password:: " + password + "\n";
  entry    += "updated:: "  + today    + "\n";
  const text = await readText();
  await writeText(text + entry);
  svc.value = ""; usr.value = ""; pwd.value = ""; hide.checked = false; pwd.type = "text";
  new Notice("追加しました → " + DATA_PATH);
  await refresh();
};

/***** 更新(data.md の対象ブロック置換) *****/
updateBtn.onclick = async () => {
  if (!editTarget) return;
  const service  = (svc.value||"").trim();
  const username = (usr.value||"").trim();
  const password = (pwd.value||"").trim();
  if (!service) { new Notice("サービス名は必須です"); return; }
  const today = window.moment().format("YYYY-MM-DD");
  let replacement = "\n\n## " + service + "\n";
  replacement    += "service:: "  + service  + "\n";
  replacement    += "username:: " + username + "\n";
  replacement    += "password:: " + password + "\n";
  replacement    += "updated:: "  + today    + "\n";
  let text = await readText();
  let s = editTarget.start; while (s>0 && text[s-1]==="\n") s--;
  const newText = text.slice(0, s) + replacement + text.slice(editTarget.end);
  await writeText(newText);
  editTarget = null; updateBtn.disabled = true; addBtn.disabled = false;
  svc.value = ""; usr.value = ""; pwd.value = ""; hide.checked = false; pwd.type = "text";
  new Notice("更新しました");
  await refresh();
};

/***** 初期化 *****/
await ensureDataFile();
await refresh();
```

データは自動でノート【data】に保存されます。
ノート【data】が無ければ自動で作成されます。

Check:確認(使いながら気づいたこと)

  1. サービス名、ユーザー名、パスワードの入力文字数が長くなると折り返しが発生して見にくくなる
  2. 更新日の新しい順に配列するので一覧が見にくい
  3. 同じユーザー名が繰り返される際に入力が手間

これらの課題を解決するために、見た目の調整(文字が折り返されないようにするなど)や入力の工夫を取り入れました。
次の「Action」で改善策を紹介します。


Action:改善(より使いやすくする工夫)

ここでは、Checkで挙げた課題を解決するために行った改善策を紹介します。

1️⃣文字折り返しの解消(テーブルを横スクロールに)

背景(なぜ必要か)

サービス名やユーザー名、パスワードなどの入力内容が長くなると、表のセル内で折り返されてしまい、一気に見づらくなっていました。
特にメールアドレスや長いパスワードは1行に収まらず、縦に伸びてしまうため一覧性が失われるのが問題でした。

改善方法(コード or 設定)

Obsidianの「スニペット機能」を使って、テーブル全体の表示方法を調整しました。
ここでは、テーブルの折り返しを防いで横スクロールできるようにするための手順を紹介します。

STEP
.obsidian/snippetsフォルダを開く

Obsidianの設定(⚙️) → 外観 → CSSスニペット → 📂マークをクリック

snippetsフォルダが開く

STEP
新しいCSSファイルを作成する

「snippets」フォルダの中に wide-table.css という名前のファイルを作成します。

メモ帳などのエディタでファイルを開き、下記のCSSコードを貼り付けます。

CSSコード
/* テーブルを横に広げる */
.markdown-preview-view table {
  display: block;
  overflow-x: auto;     /* 横スクロールを許可 */
  white-space: nowrap;  /* 折り返し禁止 */
}

/* 各セルの最小幅を広げる */
.markdown-preview-view table td,
.markdown-preview-view table th {
  min-width: 220px;   /* 数値はお好みで調整 */
}
補足:拡張子の確認と変更方法

1. エクスプローラーで拡張子を表示する

Windowsでは、初期設定だとファイルの拡張子(.txt.css など)が非表示になっています。
そのままだと 「wide-table.css」 を作ったつもりが実際には 「wide-table.css.txt」 になってしまうことがあります。

✅ 表示方法

1.エクスプローラーを開く

2.上部メニューの 「表示」→「表示」→「ファイル名拡張子」 にチェックを入れる

これで、全ファイルの拡張子が表示されるようになります。


2. txt から拡張子を変更する場合の注意点

メモ帳で保存するとデフォルトで「.txt」が付いてしまうことがあります。
その場合は、ファイル名をリネームして 「.txt」を削除し、.css をつける 必要があります。

⚠ 注意:

  • 単純にファイル名を「wide-table.css.txt」から「wide-table.css」に直してください。
  • 拡張子を変更すると「ファイルが使えなくなる可能性があります」という警告が出ますが、そのまま「はい」でOK です。

2️⃣一覧表示の並び替え

背景(なぜ必要か)

デフォルトでは、データが更新日の新しい順に並びます。
しかしパスワードやアカウント情報を一覧で確認する際、サービスごとに並んでいないと探すのが大変になります。
特に登録件数が増えてくると「どのサービスがどこにあるか」が分かりにくくなってしまうのが問題でした。

改善方法(コード or 設定)

サービス名のアルファベット順(または任意の項目) で並べ替えるようにしました。

これにより、サービスごとに整列された見やすい一覧表が表示されます。
必要に応じて ユーザー名更新日 など、任意の項目に切り替えることも可能です。

並び替えが必要な場合は、次の手順を実施してください。

STEP
ノート【view】をソースモードに切り替える

Obsidianで対象のノート【view】を開き、右上の「ソースモード」ボタンを押して編集できる状態にします。

STEP
既存のコードをすべて削除し、改善版のコードを貼り付ける

下記の「改善版コード」の全文をコピーして貼り付けます。
貼り替えたら保存して、表示が正しく更新されたことを確認してください。

改善版コード
```dataviewjs  
/***** 設定 *****/
const DATA_PATH = "account/data.md";

/***** スタイル(見やすさ調整) *****/
const STYLE_ID = "acct-crud-split";
if (!document.getElementById(STYLE_ID)) {
  const st = document.createElement("style");
  st.id = STYLE_ID;
  st.textContent = `
  .acct-form { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; align-items:center; }
  .acct-form input[type="text"], .acct-form input[type="password"] {
    padding:6px 8px; border:1px solid var(--background-modifier-border);
    border-radius:6px; min-width:180px;
  }
  .acct-form select {
    padding:6px 8px; border:1px solid var(--background-modifier-border);
    border-radius:6px; background:var(--background-primary);
    min-width:180px;
  }
  .acct-form .btn {
    padding:6px 12px; border:1px solid var(--background-modifier-border);
    border-radius:6px; background:var(--interactive-accent-hover);
    color:var(--text-on-accent); cursor:pointer;
  }
  .acct-form .btn.secondary { background:transparent; color:var(--text-normal); }
  .acct-form .chk { display:flex; align-items:center; gap:6px; }
  .acct-table { width:100%; border-collapse:collapse; margin-top:8px; }
  .acct-table th, .acct-table td { padding:6px 8px; border-bottom:1px solid var(--background-modifier-border); white-space:nowrap; }
  .acct-table th { text-align:left; position:sticky; top:0; background:var(--background-primary); }
  .acct-actions { display:flex; gap:8px; }
  `;
  document.head.appendChild(st);
}

/***** ユーティリティ:data.md の取得/作成/読み書き *****/
async function ensureDataFile() {
  let af = app.vault.getAbstractFileByPath(DATA_PATH);
  if (!af) {
    const parts = DATA_PATH.split("/");
    const folder = parts.slice(0, -1).join("/");
    if (folder && !app.vault.getAbstractFileByPath(folder)) {
      await app.vault.createFolder(folder);
    }
    af = await app.vault.create(DATA_PATH, "# アカウントデータ\n");
  }
  return af;
}
async function readText()  { return await app.vault.read(await ensureDataFile()); }
async function writeText(t){ return await app.vault.modify(await ensureDataFile(), t); }

/***** 入力フォーム *****/
const root = dv.el("div","",{cls:"acct-form"});
const svc = root.createEl("input",{type:"text",placeholder:"サービス名"});
const usr = root.createEl("input",{type:"text",placeholder:"ユーザー名"});
const pwd = root.createEl("input",{type:"text",placeholder:"パスワード"});

const wrap = root.createEl("div",{cls:"chk"});
const hide = wrap.createEl("input",{type:"checkbox"}); wrap.createEl("span",{text:"隠す"});
hide.addEventListener("change",()=>{ pwd.type = hide.checked ? "password" : "text"; });

const addBtn    = root.createEl("button",{text:"追加",cls:"btn"});
const updateBtn = root.createEl("button",{text:"更新",cls:"btn secondary"}); updateBtn.disabled = true;

/* ▼ 並び順セレクトを追加 */
const SORT_KEY = "acct-sort-mode";
const sortWrap = root.createEl("div",{cls:"chk"});
sortWrap.createEl("span",{text:"並び順"});
const sortSel = sortWrap.createEl("select");
[
  ["service-asc","サービス名(A→Z)"],
  ["service-desc","サービス名(Z→A)"],
  ["user-asc","ユーザー名(A→Z)"],
  ["updated-desc","更新日(新→旧)"],
  ["updated-asc","更新日(旧→新)"],
].forEach(([v,label])=>{
  const opt = sortSel.createEl("option",{text:label});
  opt.value = v;
});
sortSel.value = localStorage.getItem(SORT_KEY) || "service-asc";
sortSel.onchange = ()=>{ localStorage.setItem(SORT_KEY, sortSel.value); refresh(); };

const tableHost = dv.el("div","");
let editTarget = null; // {start,end}

/***** ブロック検出(data.mdの中をブロック単位で把握) *****/
function findBlocksWithPositions(text) {
  const lines = text.split(/\r?\n/);
  const starts = [];
  let offset = 0;
  const hdrRe = /^\s*#{2,6}\s*.+\s*$/;
  const svcRe = /^\s*(?:-?\s*)?service\s*(?::|:){1,2}/i;
  for (const line of lines) {
    if (hdrRe.test(line) || svcRe.test(line)) starts.push(offset);
    offset += line.length + 1;
  }
  if (starts.length === 0) return [];
  starts.push(text.length);
  const blocks = [];
  for (let i=0;i<starts.length-1;i++) {
    const start = starts[i], end = starts[i+1];
    const chunk = text.slice(start, end);
    const get = (k) => {
      const r = new RegExp("^\\s*(?:-\\s*)?" + k + "\\s*(?::|:){1,2}\\s*(.*)$","mi").exec(chunk);
      return r ? r[1].trim() : "";
    };
    const service  = get("service");
    const username = get("username");
    const password = get("password");
    const updated  = get("updated");
    if (service) blocks.push({ start, end, text:chunk, service, username, password, updated });
  }
  return blocks;
}

/***** 並び替え *****/
function sortRows(rows, mode) {
  const byStr = (a,b,ka,kb)=> (ka||"").localeCompare(kb||"", undefined, {numeric:true, sensitivity:"base"});
  switch(mode){
    case "service-desc": return [...rows].sort((a,b)=> byStr(b,a,b.service,a.service));
    case "user-asc":     return [...rows].sort((a,b)=> byStr(a,b,a.username,b.username));
    case "updated-asc":  return [...rows].sort((a,b)=> byStr(a,b,a.updated,b.updated));
    case "updated-desc": return [...rows].sort((a,b)=> byStr(b,a,b.updated,a.updated));
    case "service-asc":
    default:             return [...rows].sort((a,b)=> byStr(a,b,a.service,b.service));
  }
}

/***** 表描画 *****/
function renderTable(blocks) {
  const mode = sortSel.value || "service-asc";
  const rows = sortRows(blocks, mode);

  let html = `<table class="acct-table"><thead><tr>
    <th>サービス名</th><th>ユーザー名</th><th>パスワード</th><th>更新日</th><th></th>
  </tr></thead><tbody>`;
  if (rows.length === 0) {
    html += `<tr><td colspan="5">DataviewJS: レコードがありません</td></tr>`;
  } else {
    for (const b of rows) {
      html += `<tr data-start="${b.start}" data-end="${b.end}">
        <td>${b.service||""}</td>
        <td>${b.username||""}</td>
        <td>${b.password||""}</td>
        <td>${b.updated||""}</td>
        <td><div class="acct-actions">
          <button class="btn secondary acct-edit">編集</button>
          <button class="btn secondary acct-del">削除</button>
        </div></td>
      </tr>`;
    }
  }
  html += `</tbody></table>`;
  tableHost.innerHTML = html;

  // イベント
  tableHost.querySelectorAll(".acct-edit").forEach(btn=>{
    btn.onclick = async () => {
      const tr = btn.closest("tr");
      const start = Number(tr.dataset.start), end = Number(tr.dataset.end);
      const text = await readText();
      const blocks = findBlocksWithPositions(text);
      const b = blocks.find(x=>x.start===start && x.end===end);
      if (!b) return;
      svc.value = b.service; usr.value = b.username; pwd.value = b.password;
      editTarget = { start, end };
      updateBtn.disabled = false; addBtn.disabled = true;
      new Notice("編集モードになりました(更新で保存)");
    };
  });
  tableHost.querySelectorAll(".acct-del").forEach(btn=>{
    btn.onclick = async () => {
      const tr = btn.closest("tr");
      const start = Number(tr.dataset.start), end = Number(tr.dataset.end);
      let text = await readText();
      let s = start; while (s>0 && text[s-1]==="\n") s--;
      const newText = text.slice(0, s) + text.slice(end);
      await writeText(newText);
      await refresh();
      new Notice("削除しました");
      if (editTarget && editTarget.start===start && editTarget.end===end) {
        editTarget = null; updateBtn.disabled = true; addBtn.disabled = false;
        svc.value = ""; usr.value = ""; pwd.value = "";
      }
    };
  });
}

/***** 再描画 *****/
async function refresh() {
  const text = await readText();
  const blocks = findBlocksWithPositions(text);
  renderTable(blocks);
}

/***** 追加(data.md へ追記) *****/
addBtn.onclick = async () => {
  const service  = (svc.value||"").trim();
  const username = (usr.value||"").trim();
  const password = (pwd.value||"").trim();
  if (!service) { new Notice("サービス名は必須です"); return; }
  const today = window.moment().format("YYYY-MM-DD");
  let entry = "\n\n## " + service + "\n";
  entry    += "service:: "  + service  + "\n";
  entry    += "username:: " + username + "\n";
  entry    += "password:: " + password + "\n";
  entry    += "updated:: "  + today    + "\n";
  const text = await readText();
  await writeText(text + entry);
  svc.value = ""; usr.value = ""; pwd.value = ""; hide.checked = false; pwd.type = "text";
  new Notice("追加しました → " + DATA_PATH);
  await refresh();
};

/***** 更新(data.md の対象ブロック置換) *****/
updateBtn.onclick = async () => {
  if (!editTarget) return;
  const service  = (svc.value||"").trim();
  const username = (usr.value||"").trim();
  const password = (pwd.value||"").trim();
  if (!service) { new Notice("サービス名は必須です"); return; }
  const today = window.moment().format("YYYY-MM-DD");
  let replacement = "\n\n## " + service + "\n";
  replacement    += "service:: "  + service  + "\n";
  replacement    += "username:: " + username + "\n";
  replacement    += "password:: " + password + "\n";
  replacement    += "updated:: "  + today    + "\n";
  let text = await readText();
  let s = editTarget.start; while (s>0 && text[s-1]==="\n") s--;
  const newText = text.slice(0, s) + replacement + text.slice(editTarget.end);
  await writeText(newText);
  editTarget = null; updateBtn.disabled = true; addBtn.disabled = false;
  svc.value = ""; usr.value = ""; pwd.value = ""; hide.checked = false; pwd.type = "text";
  new Notice("更新しました");
  await refresh();
};

/***** 初期化 *****/
await ensureDataFile();
await refresh();

```

3️⃣ 同じユーザー名の入力をラクにする(候補の自動サジェスト)

背景(なぜ必要か)

同じサービスやユーザー名を何度も登録するケースでは、毎回フルタイプするのが手間です。
入力ミスも起こりやすく、一覧に重複表記が混ざる原因にもなります。

改善方法(コード or 設定)

ノート【view】のコードを丸ごと置き換えるだけで、次の改善が一度に反映されます。

  • 並び替え:サービス名/ユーザー名/更新日(昇順・降順)をメニューから切替
  • サジェスト:過去に入力した サービス名・ユーザー名 を候補として自動表示(入力の手間を削減)
STEP
ノート【view】をソースモードに切り替える

Obsidianで対象のノート【view】を開き、右上の「ソースモード」ボタンを押して編集できる状態にします。

STEP
既存のコードをすべて削除し、改善版のコードを貼り付ける

下記の「改善版コード」の全文をコピーして貼り付けます。
貼り替えたら保存して、表示が正しく更新されたことを確認してください。

改善版コード
```dataviewjs  
/***** 設定 *****/
const DATA_PATH = "account/data.md";

/***** スタイル(見やすさ調整) *****/
const STYLE_ID = "acct-crud-split";
if (!document.getElementById(STYLE_ID)) {
  const st = document.createElement("style");
  st.id = STYLE_ID;
  st.textContent = `
  .acct-form { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; align-items:center; }
  .acct-form input[type="text"], .acct-form input[type="password"] {
    padding:6px 8px; border:1px solid var(--background-modifier-border);
    border-radius:6px; min-width:180px;
  }
  .acct-form select {
    padding:6px 8px; border:1px solid var(--background-modifier-border);
    border-radius:6px; background:var(--background-primary);
    min-width:180px;
  }
  .acct-form .btn {
    padding:6px 12px; border:1px solid var(--background-modifier-border);
    border-radius:6px; background:var(--interactive-accent-hover);
    color:var(--text-on-accent); cursor:pointer;
  }
  .acct-form .btn.secondary { background:transparent; color:var(--text-normal); }
  .acct-form .chk { display:flex; align-items:center; gap:6px; }
  .acct-table { width:100%; border-collapse:collapse; margin-top:8px; }
  .acct-table th, .acct-table td { padding:6px 8px; border-bottom:1px solid var(--background-modifier-border); white-space:nowrap; }
  .acct-table th { text-align:left; position:sticky; top:0; background:var(--background-primary); }
  .acct-actions { display:flex; gap:8px; }
  `;
  document.head.appendChild(st);
}

/***** ユーティリティ:data.md の取得/作成/読み書き *****/
async function ensureDataFile() {
  let af = app.vault.getAbstractFileByPath(DATA_PATH);
  if (!af) {
    const parts = DATA_PATH.split("/");
    const folder = parts.slice(0, -1).join("/");
    if (folder && !app.vault.getAbstractFileByPath(folder)) {
      await app.vault.createFolder(folder);
    }
    af = await app.vault.create(DATA_PATH, "# アカウントデータ\n");
  }
  return af;
}
async function readText()  { return await app.vault.read(await ensureDataFile()); }
async function writeText(t){ return await app.vault.modify(await ensureDataFile(), t); }

/***** 入力フォーム *****/
const root = dv.el("div","",{cls:"acct-form"});
const svc = root.createEl("input",{type:"text",placeholder:"サービス名"});
const usr = root.createEl("input",{type:"text",placeholder:"ユーザー名"});
const pwd = root.createEl("input",{type:"text",placeholder:"パスワード"});

// ▼ サジェスト(datalist)を準備
const SVC_LIST_ID = "acct-svc-list";
const USR_LIST_ID = "acct-usr-list";
const svcList = root.createEl("datalist"); svcList.id = SVC_LIST_ID;
const usrList = root.createEl("datalist"); usrList.id = USR_LIST_ID;
svc.setAttr("list", SVC_LIST_ID);
usr.setAttr("list", USR_LIST_ID);

const wrap = root.createEl("div",{cls:"chk"});
const hide = wrap.createEl("input",{type:"checkbox"}); wrap.createEl("span",{text:"隠す"});
hide.addEventListener("change",()=>{ pwd.type = hide.checked ? "password" : "text"; });

const addBtn    = root.createEl("button",{text:"追加",cls:"btn"});
const updateBtn = root.createEl("button",{text:"更新",cls:"btn secondary"}); updateBtn.disabled = true;

/* ▼ 並び順セレクト */
const SORT_KEY = "acct-sort-mode";
const sortWrap = root.createEl("div",{cls:"chk"});
sortWrap.createEl("span",{text:"並び順"});
const sortSel = sortWrap.createEl("select");
[
  ["service-asc","サービス名(A→Z)"],
  ["service-desc","サービス名(Z→A)"],
  ["user-asc","ユーザー名(A→Z)"],
  ["updated-desc","更新日(新→旧)"],
  ["updated-asc","更新日(旧→新)"],
].forEach(([v,label])=>{
  const opt = sortSel.createEl("option",{text:label});
  opt.value = v;
});
sortSel.value = localStorage.getItem(SORT_KEY) || "service-asc";
sortSel.onchange = ()=>{ localStorage.setItem(SORT_KEY, sortSel.value); refresh(); };

const tableHost = dv.el("div","");
let editTarget = null; // {start,end}

/***** ブロック検出(data.mdの中をブロック単位で把握) *****/
function findBlocksWithPositions(text) {
  const lines = text.split(/\r?\n/);
  const starts = [];
  let offset = 0;
  const hdrRe = /^\s*#{2,6}\s*.+\s*$/;
  const svcRe = /^\s*(?:-?\s*)?service\s*(?::|:){1,2}/i;
  for (const line of lines) {
    if (hdrRe.test(line) || svcRe.test(line)) starts.push(offset);
    offset += line.length + 1;
  }
  if (starts.length === 0) return [];
  starts.push(text.length);
  const blocks = [];
  for (let i=0;i<starts.length-1;i++) {
    const start = starts[i], end = starts[i+1];
    const chunk = text.slice(start, end);
    const get = (k) => {
      const r = new RegExp("^\\s*(?:-\\s*)?" + k + "\\s*(?::|:){1,2}\\s*(.*)$","mi").exec(chunk);
      return r ? r[1].trim() : "";
    };
    const service  = get("service");
    const username = get("username");
    const password = get("password");
    const updated  = get("updated");
    if (service) blocks.push({ start, end, text:chunk, service, username, password, updated });
  }
  return blocks;
}

/***** 並び替え *****/
function sortRows(rows, mode) {
  const byStr = (a,b,ka,kb)=> (ka||"").localeCompare(kb||"", undefined, {numeric:true, sensitivity:"base"});
  switch(mode){
    case "service-desc": return [...rows].sort((a,b)=> byStr(b,a,b.service,a.service));
    case "user-asc":     return [...rows].sort((a,b)=> byStr(a,b,a.username,b.username));
    case "updated-asc":  return [...rows].sort((a,b)=> byStr(a,b,a.updated,b.updated));
    case "updated-desc": return [...rows].sort((a,b)=> byStr(b,a,b.updated,a.updated));
    case "service-asc":
    default:             return [...rows].sort((a,b)=> byStr(a,b,a.service,b.service));
  }
}

/***** サジェスト(datalist)更新 *****/
function updateDatalists(blocks){
  // ユニークなサービス / ユーザー名を抽出
  const svcSet = new Set();
  const usrSet = new Set();
  for (const b of blocks) {
    if (b.service)  svcSet.add(b.service);
    if (b.username) usrSet.add(b.username);
  }
  // 一旦クリアして再生成
  svcList.innerHTML = "";
  usrList.innerHTML = "";
  [...svcSet].sort((a,b)=>a.localeCompare(b,'ja')).forEach(v=>{
    const o = document.createElement("option"); o.value = v; svcList.appendChild(o);
  });
  [...usrSet].sort((a,b)=>a.localeCompare(b,'ja')).forEach(v=>{
    const o = document.createElement("option"); o.value = v; usrList.appendChild(o);
  });
}

/***** 表描画 *****/
function renderTable(blocks) {
  const mode = sortSel.value || "service-asc";
  const rows = sortRows(blocks, mode);

  let html = `<table class="acct-table"><thead><tr>
    <th>サービス名</th><th>ユーザー名</th><th>パスワード</th><th>更新日</th><th></th>
  </tr></thead><tbody>`;
  if (rows.length === 0) {
    html += `<tr><td colspan="5">DataviewJS: レコードがありません</td></tr>`;
  } else {
    for (const b of rows) {
      html += `<tr data-start="${b.start}" data-end="${b.end}">
        <td>${b.service||""}</td>
        <td>${b.username||""}</td>
        <td>${b.password||""}</td>
        <td>${b.updated||""}</td>
        <td><div class="acct-actions">
          <button class="btn secondary acct-edit">編集</button>
          <button class="btn secondary acct-del">削除</button>
        </div></td>
      </tr>`;
    }
  }
  html += `</tbody></table>`;
  tableHost.innerHTML = html;

  // イベント
  tableHost.querySelectorAll(".acct-edit").forEach(btn=>{
    btn.onclick = async () => {
      const tr = btn.closest("tr");
      const start = Number(tr.dataset.start), end = Number(tr.dataset.end);
      const text = await readText();
      const blocks = findBlocksWithPositions(text);
      const b = blocks.find(x=>x.start===start && x.end===end);
      if (!b) return;
      svc.value = b.service; usr.value = b.username; pwd.value = b.password;
      editTarget = { start, end };
      updateBtn.disabled = false; addBtn.disabled = true;
      new Notice("編集モードになりました(更新で保存)");
    };
  });
  tableHost.querySelectorAll(".acct-del").forEach(btn=>{
    btn.onclick = async () => {
      const tr = btn.closest("tr");
      const start = Number(tr.dataset.start), end = Number(tr.dataset.end);
      let text = await readText();
      let s = start; while (s>0 && text[s-1]==="\n") s--;
      const newText = text.slice(0, s) + text.slice(end);
      await writeText(newText);
      await refresh();
      new Notice("削除しました");
      if (editTarget && editTarget.start===start && editTarget.end===end) {
        editTarget = null; updateBtn.disabled = true; addBtn.disabled = false;
        svc.value = ""; usr.value = ""; pwd.value = "";
      }
    };
  });
}

/***** 再描画 *****/
async function refresh() {
  const text = await readText();
  const blocks = findBlocksWithPositions(text);
  // ← サジェストの候補を更新
  updateDatalists(blocks);
  renderTable(blocks);
}

/***** 追加(data.md へ追記) *****/
addBtn.onclick = async () => {
  const service  = (svc.value||"").trim();
  const username = (usr.value||"").trim();
  const password = (pwd.value||"").trim();
  if (!service) { new Notice("サービス名は必須です"); return; }
  const today = window.moment().format("YYYY-MM-DD");
  let entry = "\n\n## " + service + "\n";
  entry    += "service:: "  + service  + "\n";
  entry    += "username:: " + username + "\n";
  entry    += "password:: " + password + "\n";
  entry    += "updated:: "  + today    + "\n";
  const text = await readText();
  await writeText(text + entry);
  svc.value = ""; usr.value = ""; pwd.value = ""; hide.checked = false; pwd.type = "text";
  new Notice("追加しました → " + DATA_PATH);
  await refresh();
};

/***** 更新(data.md の対象ブロック置換) *****/
updateBtn.onclick = async () => {
  if (!editTarget) return;
  const service  = (svc.value||"").trim();
  const username = (usr.value||"").trim();
  const password = (pwd.value||"").trim();
  if (!service) { new Notice("サービス名は必須です"); return; }
  const today = window.moment().format("YYYY-MM-DD");
  let replacement = "\n\n## " + service + "\n";
  replacement    += "service:: "  + service  + "\n";
  replacement    += "username:: " + username + "\n";
  replacement    += "password:: " + password + "\n";
  replacement    += "updated:: "  + today    + "\n";
  let text = await readText();
  let s = editTarget.start; while (s>0 && text[s-1]==="\n") s--;
  const newText = text.slice(0, s) + replacement + text.slice(editTarget.end);
  await writeText(newText);
  editTarget = null; updateBtn.disabled = true; addBtn.disabled = false;
  svc.value = ""; usr.value = ""; pwd.value = ""; hide.checked = false; pwd.type = "text";
  new Notice("更新しました");
  await refresh();
};

/***** 初期化 *****/
await ensureDataFile();
await refresh();

```

まとめ

  • 専用アプリに頼らず、自分の使い方に合わせて仕組みを育てられるのが最大の魅力
  • 本記事のコードをコピペすれば、誰でも同じ環境を再現できます

「小さな工夫を積み重ねることで、自分だけの便利な“デジタル作業環境”を作れるのがObsidianの魅力です。ぜひ試してみてください。」

注意:本記事は個人運用を想定。
共有環境での取り扱い/暗号化は各自のポリシーに従ってください。


同じように迷ってる誰かに届くように、よかったらシェアをお願いします。
  • URLをコピーしました!
  • URLをコピーしました!
目次