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


Obsidianコミュニティプラグイン:Dataview が有効化されている。
Obsidianコミュニティプラグイン:Dataviewの設定でEnable JavaScript queries をオンにする

本記事は**PDCA(Plan→Do→Check→Action)**の流れで整理しています。
この記事の通りに進めれば、あなたの環境でも同じ仕組みが動きます。
Plan:設計(アカウント管理の仕組み)
全体像
システムでは、操作とデータを分けて管理します。
- view(入力・更新・閲覧):ユーザーが画面で行う操作
- data(保存データ):操作の結果として蓄積される実際の情報
これにより、見た目をスッキリさせつつ、データを安全に蓄積・活用 できるようになります。


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

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


分離のメリット
- 表示ノートとデータノートを役割分担できる
→ 入力画面がごちゃつかず、管理しやすい - 拡張しやすい
→ 将来的に別のフォームやデータ処理にも流用可能
Do:実装(実際に動かす)


- 新規フォルダーでaccountフォルダーを作成する。
- 新規ノートでタイトルを【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();
```
Check:確認(使いながら気づいたこと)
- サービス名、ユーザー名、パスワードの入力文字数が長くなると折り返しが発生して見にくくなる
- 更新日の新しい順に配列するので一覧が見にくい
- 同じユーザー名が繰り返される際に入力が手間

これらの課題を解決するために、見た目の調整(文字が折り返されないようにするなど)や入力の工夫を取り入れました。
次の「Action」で改善策を紹介します。
Action:改善(より使いやすくする工夫)
ここでは、Checkで挙げた課題を解決するために行った改善策を紹介します。
1️⃣文字折り返しの解消(テーブルを横スクロールに)

背景(なぜ必要か)
サービス名やユーザー名、パスワードなどの入力内容が長くなると、表のセル内で折り返されてしまい、一気に見づらくなっていました。
特にメールアドレスや長いパスワードは1行に収まらず、縦に伸びてしまうため一覧性が失われるのが問題でした。
改善方法(コード or 設定)
Obsidianの「スニペット機能」を使って、テーブル全体の表示方法を調整しました。
ここでは、テーブルの折り返しを防いで横スクロールできるようにするための手順を紹介します。
Obsidianの設定(⚙️) → 外観 → CSSスニペット → 📂マークをクリック

snippetsフォルダが開く

「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 設定)
サービス名のアルファベット順(または任意の項目) で並べ替えるようにしました。
これにより、サービスごとに整列された見やすい一覧表が表示されます。
必要に応じて ユーザー名
や 更新日
など、任意の項目に切り替えることも可能です。
並び替えが必要な場合は、次の手順を実施してください。
Obsidianで対象のノート【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 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】のコードを丸ごと置き換えるだけで、次の改善が一度に反映されます。
- 並び替え:サービス名/ユーザー名/更新日(昇順・降順)をメニューから切替
- サジェスト:過去に入力した サービス名・ユーザー名 を候補として自動表示(入力の手間を削減)
Obsidianで対象のノート【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 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の魅力です。ぜひ試してみてください。」
注意:本記事は個人運用を想定。
共有環境での取り扱い/暗号化は各自のポリシーに従ってください。