Obsidian End Time 1.1.8
2025年8月13日
デスクトップのみ対応
ペイン上でタスククリックすることで、タスクの開始・終了処理が走る(SimpleTC.mdをTemplaterに登録しておく必要あり)
見積時間未入力の場合、5分で計算
フォルダ構成
code:txt
vault
└ .obsidian
└ plugins
└ endtime-obsidian
└ main.js
└ manifest.json
└ styles.css
code:manifest.json
{
"id": "endtime-obsidian",
"name": "End Time",
"version": "1.1.5",
"minAppVersion": "0.12.0",
"author": "Shino",
"description": "Displays task information with estimated end time in the sidebar.",
"isDesktopOnly": false
}
code:styles.css
.task-item {
margin-bottom: 1em; /* Add some space between tasks */
}
.task-name {
margin-bottom: -0.5em; /* Reduce space between task name and time */
line-height: 1; /* Slightly reduce line-height */
}
.task-time {
color: #888; /* Gray out the text */ font-size: 0.9em; /* Slightly smaller font size */
line-height: 1; /* Slightly reduce line-height */
}
/* Section (🗂️) specific styling */
.section {
font-weight: bold; /* Make section titles bold */
margin-bottom: 0.5em; /* Add some space after section titles */
line-height: 1.2; /* Adjust line-height for sections */
}
code:main.js
const { Plugin, ItemView, Notice } = require("obsidian");
const VIEW_TYPE_TASK_SIDEBAR = "task-sidebar-view";
const DEFAULT_DURATION_MIN = 5; // 所要分数が無い場合の既定値
// YYYY-MM-DD.md 判定
function isDailyNoteName(name) {
return /^\d{4}-\d{2}-\d{2}\.md$/.test(name || "");
}
// スロットル
function throttle(fn, wait = 200) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn(...args);
}
};
}
// 文字列ハッシュ
function hashString(str) {
let hash = 0;
if (!str) return hash;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
function stripMdLinks(s) {
return s.replace(/\[(^\]+)\]\(^)+\)/g, "$1"); }
// ; 以降コメントを除去(半角; のみ)
function stripComment(s) {
const i = s.indexOf(";");
return i >= 0 ? s.slice(0, i).trim() : s.trim();
}
// タスク抽出(仕様どおり): 行配列 -> タスク配列
function extractTasksFromLines(lines) {
const tasks = [];
let chainBase = new Date(); // 連鎖開始の基準(最初は「今」)
for (let idx = 0; idx < lines.length; idx++) {
if (!raw || !raw.trim()) continue;
const norm = raw.trim().normalize("NFKC");
// チェックボックス行抽出
const mCb = norm.match(/^\s*-\s*\[( xX)\]\s*(.*)$/); if (!mCb) continue;
const mark = (mCb1 || "").toLowerCase(); if (mark === "x") continue; // 完了は除外
const rest = (mCb2 || "").trim(); // すでに hh:mm - hh:mm を含む確定済みレンジは除外
if (/\b\d{2}:\d{2}\s*-–—−\s*\d{2}:\d{2}\b/.test(rest)) continue; // 開始時刻・所要分数・本文
const m = rest.match(/^(?:(\d{2}:\d{2}))?-?\s*(?:\((\d+)\))?\s*(.*)$/);
if (!m) continue;
const startStr = (m1 || "").trim(); // "HH:MM" | "" const durStr = (m2 || "").trim(); // "nn" | "" let body = (m3 || "").trim(); // 残り // 抽出時に一元整形(描画時には触らない)
body = stripComment(stripMdLinks(body));
// 時間決定
let baseTime;
if (startStr) {
const h, mm = startStr.split(":").map(Number); baseTime = new Date();
baseTime.setHours(h, mm, 0, 0);
} else {
baseTime = new Date(chainBase); // 連鎖
}
const durationMin = durStr !== "" ? (parseInt(durStr, 10) || 0) : DEFAULT_DURATION_MIN;
const startTime = new Date(baseTime);
let endTime = new Date(baseTime.getTime() + durationMin * 60000);
// 現在時刻より過去なら now に引き上げ(所要分数が有る場合のみ有効)
if (durationMin > 0 && endTime < new Date()) {
endTime = new Date();
}
// 進捗ノート
let tailNote = "";
if (startStr && durationMin > 0) {
const elapsedMin = Math.ceil((Date.now() - startTime.getTime()) / 60000);
if (elapsedMin > durationMin) {
tailNote = 🚨超過${elapsedMin - durationMin}分;
} else {
tailNote = ➡️残り${Math.max(0, durationMin - elapsedMin)}分;
}
}
const fmt = (d) => d.toTimeString().slice(0, 5);
tasks.push({
title: body,
timeText: ${fmt(startTime)} - ${fmt(endTime)} (${durationMin}分)${tailNote},
lineIndex: idx, // クリック時に選択するため
startTimeMs: startTime.getTime(),
endTimeMs: endTime.getTime(),
durationMin
});
// 連鎖更新(duration=0 の場合でも 5分既定を入れているので進む)
chainBase = endTime;
}
return tasks;
}
// Markdownエディタを取得してアクティブ化&フォーカス
async function focusMarkdownEditor(app) {
const ws = app.workspace;
let leaf = ws.getMostRecentLeaf?.() || ws.activeLeaf;
if (leaf?.view?.getViewType?.() !== "markdown") {
const mdLeaves = ws.getLeavesOfType("markdown");
if (mdLeaves?.length) leaf = mdLeaves0; }
if (!leaf || leaf.view?.getViewType?.() !== "markdown") return null;
ws.revealLeaf(leaf);
ws.setActiveLeaf(leaf, { focus: true });
// フォーカス反映を次ティックで待つ
await new Promise((r) => setTimeout(r, 0));
return leaf.view.editor;
}
// 指定行へカーソル → 次ティック後に Templater 実行
async function goToLineAndRunTemplater(app, lineIndex) {
const editor = await focusMarkdownEditor(app);
if (!editor) {
new Notice("アクティブなMarkdownエディタが見つかりません。");
return false;
}
const line = Math.max(0, Math.min(lineIndex, editor.lineCount() - 1));
const ch = 0; // 行頭にカーソル
editor.setCursor({ line, ch });
editor.scrollIntoView({ from: { line, ch }, to: { line, ch } }, true);
// カーソル移動がUIに反映されるまで待つ
await new Promise((r) => setTimeout(r, 0));
// Templaterコマンド実行(名前検索/見つからなければ通知)
runTemplaterSimpleTC(app);
return true;
}
// Templater の「SimpleTC.md」を実行
function runTemplaterSimpleTC(app) {
const commandId = "templater-obsidian:template/SimpleTC.md"; // 確認したIDに置き換え
if (app.commands.executeCommandById(commandId)) {
return true;
} else {
new Notice(Templaterコマンドが見つかりません: ${commandId});
return false;
}
}
// エディタで行を選択
function selectEditorLine(app, lineIndex) {
const leaf = app.workspace.getMostRecentLeaf?.() || app.workspace.activeLeaf;
const view = leaf?.view;
const editor = view?.editor;
if (!editor) return false;
const line = Math.max(0, Math.min(lineIndex, editor.lineCount() - 1));
const lineText = editor.getLine(line) ?? "";
editor.setSelection({ line, ch: 0 }, { line, ch: lineText.length });
editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: lineText.length } }, true);
return true;
}
class TaskSidebarView extends ItemView {
constructor(leaf, plugin) {
super(leaf);
this.plugin = plugin; // クリック時に呼び戻すため
this._prevTasksJson = ""; // 差分検知
}
getViewType() {
return VIEW_TYPE_TASK_SIDEBAR;
}
getDisplayText() {
return "Task Sidebar";
}
async onOpen() {
const el = this.contentEl;
el.empty();
const h = el.createEl("h4", { text: "Task Schedule" });
h.style.margin = "8px 8px 4px 8px";
}
// tasks: {title, timeText, lineIndex, ...}[]
async updateTasks(tasks) {
const el = this.contentEl;
if (!el) return;
// 差分検知
const nextJson = JSON.stringify(
);
if (nextJson === this._prevTasksJson) return;
this._prevTasksJson = nextJson;
el.empty();
const list = tasks && tasks.length ? tasks : title: "(本日のタスクは見つかりませんでした)", timeText: "", lineIndex: -1 };
for (const t of list) {
const item = el.createDiv({ cls: "task-item" });
item.style.padding = "6px 8px";
item.style.borderBottom = "1px solid var(--background-modifier-border)";
item.style.cursor = t.lineIndex >= 0 ? "pointer" : "default";
const nameEl = item.createEl("p", { text: t.title || "" });
nameEl.classList.add(t.title?.startsWith?.("🗂️") ? "section" : "task-name");
nameEl.style.margin = "0 0 2px 0";
const timeEl = item.createEl("p", { text: t.timeText || "" });
timeEl.classList.add("task-time");
timeEl.style.color = "var(--text-muted)";
timeEl.style.fontSize = "0.9em";
timeEl.style.margin = "0";
if (t.lineIndex >= 0) {
item.addEventListener(
"click",
async () => {
if (t.lineIndex < 0) return;
const ok = await goToLineAndRunTemplater(this.app, t.lineIndex);
if (!ok) {
// 任意:ログや追加通知
}
},
{ passive: true }
);
}
}
}
}
module.exports = class EndtimeObsidianPlugin extends Plugin {
async onload() {
console.log("Endtime Obsidian (mobile-first) loaded.");
// ビュー登録(plugin を渡す)
this.registerView(VIEW_TYPE_TASK_SIDEBAR, (leaf) => new TaskSidebarView(leaf, this));
this.addCommand({
id: "show-tasks-sidebar",
name: "Show Tasks in Sidebar",
callback: () => this.showTasksSidebar(true),
});
this._prevTaskHash = "";
this._throttledRT = throttle(() => this.showTasksSidebar(true), 200);
// エディタ変更(対象は日付ノートのみ)
this.registerEvent(this.app.workspace.on("editor-change", () => {
const f = this.app.workspace.getActiveFile?.();
if (!f || !isDailyNoteName(f.name)) return;
this._throttledRT();
}));
// ファイル切替(対象は日付ノートのみ)
this.registerEvent(this.app.workspace.on("file-open", (file) => {
if (!file || !isDailyNoteName(file.name)) return;
this.showTasksSidebar(true);
}));
await this.activateView();
}
onunload() {
console.log("Endtime Obsidian unloaded.");
this.app.workspace.detachLeavesOfType(VIEW_TYPE_TASK_SIDEBAR);
}
async activateView() {
// 既存 leaf を探す or 生成(右サイドバーがあれば優先)
let leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_TASK_SIDEBAR)0; if (!leaf) {
const getRightLeaf = this.app.workspace.getRightLeaf;
if (typeof getRightLeaf === "function") leaf = getRightLeaf.call(this.app.workspace, true);
if (!leaf) {
const getLeaf = this.app.workspace.getLeaf;
leaf = typeof getLeaf === "function" ? getLeaf.call(this.app.workspace, true) : null;
}
}
if (!leaf) {
console.warn("Failed to acquire a leaf for TaskSidebarView.");
return;
}
await leaf.setViewState({ type: VIEW_TYPE_TASK_SIDEBAR, active: true });
this.app.workspace.revealLeaf(leaf);
}
// サイドバー更新(force: 差分検知を越えて描画)
showTasksSidebar(force = false) {
const getActiveFile = this.app.workspace.getActiveFile;
const activeFile = typeof getActiveFile === "function" ? getActiveFile.call(this.app.workspace) : null;
if (!activeFile || !isDailyNoteName(activeFile.name)) return;
this.app.vault.cachedRead(activeFile).then((noteText) => {
const lines = noteText.split(/\r?\n/);
const tasks = extractTasksFromLines(lines);
// 差分検知(ハッシュ)
const newHash = hashString(
tasks.map(t => ${t.title}|${t.timeText}|${t.lineIndex}).join("\n")
);
if (!force && this._prevTaskHash === newHash) return;
this._prevTaskHash = newHash;
const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_TASK_SIDEBAR)0; const view = leaf?.view;
if (view && typeof view.updateTasks === "function") {
view.updateTasks(tasks);
}
}).catch((err) => {
console.error("cachedRead failed:", err);
});
}
};