【テキストエディタでタスクシュート】 終了予定時刻でシュミレーションが可能に
3週間ほど前からテキストエディタを使って、簡易的にタスクシュート的な運用をしてみています。
使っているのは1WriterとObsidianで、ファイルはDropboxに置いて連携しています。
この記事では、実際にどうやって運用しているかを、使っているプラグインやコードなどを含めてまとめています。
今回の主な追加点は、終了予定時刻の実装です。これで、タスクシュートメソッドの強みのひとつである1日のシュミレーションができるようになりました。
ObsidianのDynamic Timetableを使ってみる
Obsidianのプラグインで、タスクシュートみたいなことができる、Dynamic Timetableというものがあることに気づく。
タスクの終了予定時刻を出すことができる便利なプラグイン。
これで、終了予定時刻のシュミレーションまでできるようになった。
このプラグイン単体だと、実際の開始時刻や終了時刻は記録できないので、開始時刻と終了時刻のスタンプはこれまで通り自分で打刻していくことにした。
Dynamic Timetableを導入するにあたって、タスクの記法を変更した。
- [x] 18:02-18:02 \5 完了タスク
- [ ] 18:02- \10 実行中タスク
- [ ] \15 未実行タスク
タスクのステータスは✅や🔲で表すのをやめて、マークダウンのチェックリストを使うことにした。
Dynamic Timetableで使う、見積時刻の区切り文字は`\`、開始予定時刻はデフォルトの`@`のままにした。1Writerで見てもごちゃごちゃせず、他とバッティングしずらそうな記号を選んだ。
開始時刻・終了時刻は引き続き行頭に打刻するようにした。
これで、Dynamic Timetableでの時刻計算をしつつ、これまで通り後からタスクの実行タイミングを確認したり、所要時間を算出したりできるようになった。
この変更に合わせて、開始・終了時のコードなども書き換えた。
Dynamic Timetableのインストール
設定で"Community plugins"をクリック。"Community plugins"セクションの"Browse"ボタンを押す。
"Dynamic Timetable"を検索して"Install"ボタンを押す
"Installed plugins"セクションで、"Dynamic Timetable"を探し、有効化。
Dynamic Timetableの設定
Task/Estimate Delimiterは`\`にした。使用頻度が少なく、1Writerから見ても邪魔にならないので。
開始予定時刻の識別記号は、デフォルトの`@`のままにした。
Dynamic Timetableに合わせたコードの書き換え
Dynamic Timetableに合わせて記法を変えたことによって、各種コードも書き換えました。
また、セクションを入れるようになったので、その対応も入れています。セクションについては、記事の後ろの方に少し書いています。
あと、処理終了後のカーソル位置を変えています。
タスク開始時はそのまま、タスク終了時は次のタスク、タスクコピー時はコピー先のタスクにカーソルが合うようにしました。
1. Obsidian タスクの開始・終了
開始時刻は現在時刻。
<%*
// 現在時刻を取得
const now = tp.date.now("HH:mm");
const fullNow = tp.date.now("HH:mm:ss");
// 現在のエディタとカーソル行を取得
const editor = app.workspace.activeLeaf.view.editor;
const cursorLine = editor.getCursor().line;
const cursorCh = editor.getCursor().ch; // カーソルの列位置
const currentLineText = editor.getLine(cursorLine).trim();
// タスクのプロパティが存在するかチェック
const propertyRegex = /^---\n([\s\S]+?)\n---/s;
let fileContent = app.workspace.activeLeaf.view.data;
let propertiesMatch = fileContent.match(propertyRegex);
if (propertiesMatch) {
// プロパティブロックが存在する場合
let properties = propertiesMatch[1];
let updatedProperties;
if (/startTime:\s*.+/g.test(properties)) {
// "startTime" プロパティを更新
updatedProperties = properties.replace(/startTime:\s*.*/g, `startTime: ${fullNow}`);
} else {
// "startTime" プロパティがない場合、追加
updatedProperties = properties + `\nstartTime: ${fullNow}`;
}
// プロパティ部分のみを置換してファイルを更新
const updatedFileContent = fileContent.replace(properties, updatedProperties);
await app.vault.modify(app.workspace.getActiveFile(), updatedFileContent);
} else {
// プロパティブロックがない場合、新しいプロパティブロックを追加
const newProperties = `---\nstartTime: ${fullNow}\n---\n`;
const updatedFileContent = newProperties + fileContent;
await app.vault.modify(app.workspace.getActiveFile(), updatedFileContent);
}
// 1. [ ] のみの場合: 開始時刻を追加し、カーソルを元の位置 + 追加した文字数に移動
if (/^- \[ \] (?!\d{2}:\d{2}-)/.test(currentLineText)) {
const updatedLine = currentLineText.replace("- [ ] ", `- [ ] ${now}- `);
editor.setLine(cursorLine, updatedLine);
// カーソルを元の位置 + 追加した文字数に移動
const newCursorPos = cursorCh + now.length + 2; // 'hh:mm-' の長さ分移動
editor.setCursor({ line: cursorLine, ch: newCursorPos });
}
// 2. [ ] hh:mm- の場合: 終了時刻を追加して完了状態にし、カーソルを次の行の末尾に移動
else if (/^- \[ \] \d{2}:\d{2}-/.test(currentLineText)) {
const updatedLine = currentLineText.replace(/^-\s\[ \]\s(\d{2}:\d{2})-/, `- [x] $1-${now}`);
editor.setLine(cursorLine, updatedLine);
// カーソルを次の行の末尾に移動
const nextLineLength = editor.getLine(cursorLine + 1).length;
editor.setCursor({ line: cursorLine + 1, ch: nextLineLength });
}
// 3. [x] hh:mm-hh:mm の場合: 行を複製し、カーソルを新しい行の末尾に移動
else if (/^- \[x\] \d{2}:\d{2}-\d{2}:\d{2}/.test(currentLineText)) {
const timeRangeEnd = currentLineText.indexOf(" ", 16);
const taskDescription = currentLineText.slice(timeRangeEnd).trim();
const newLine = `- [ ] ${taskDescription}`;
editor.replaceRange(newLine + "\n", { line: cursorLine + 1, ch: 0 });
// カーソルを新しい行の末尾に移動
const newLineLength = newLine.length;
editor.setCursor({ line: cursorLine + 1, ch: newLineLength });
}
%>
2. 1Writer タスクの開始・終了
開始時刻は現在時刻です。
// 現在時刻を取得
const now = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
const fullNow = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
// 現在の選択範囲とカーソル位置を保存
const initialCursorRange = editor.getSelectedRange();
const [start, end] = editor.getSelectedLineRange(); // 現在の選択行の範囲を取得
const currentLineText = editor.getTextInRange(start, end).trim(); // 行のテキストを取得
// 行が空白の場合、新しいタスクを作成
if (!currentLineText) {
const newTask = `- [ ] ${now}- `;
editor.replaceTextInRange(start, end, newTask);
editor.setSelectedRange(start + newTask.length, start + newTask.length);
return;
}
// ファイル全体のテキストを取得
let fileContent = editor.getText();
// タスクのプロパティが存在するかチェック
const propertyRegex = /^---\n([\s\S]+?)\n---/s;
let propertiesMatch = fileContent.match(propertyRegex);
if (propertiesMatch) {
// プロパティブロックが存在する場合
let properties = propertiesMatch[1];
let updatedProperties;
if (/startTime:\s*.+/g.test(properties)) {
// "startTime" プロパティを更新
updatedProperties = properties.replace(/startTime:\s*.*/g, `startTime: ${fullNow}`);
} else {
// "startTime" プロパティがない場合、追加
updatedProperties = properties + `\nstartTime: ${fullNow}`;
}
// プロパティ部分のみを置換してテキストを更新
fileContent = fileContent.replace(properties, updatedProperties);
editor.setText(fileContent);
} else {
// プロパティブロックがない場合、新しいプロパティブロックを追加
const newProperties = `---\nstartTime: ${fullNow}\n---\n`;
fileContent = newProperties + fileContent;
editor.setText(fileContent);
}
// カーソルを設定するための変数
let newCursorPos = initialCursorRange[0];
// 1. [ ] のみの場合: 開始時刻を追加
if (/^- \[ \] (?!\d{2}:\d{2}-)/.test(currentLineText)) {
const updatedLine = currentLineText.replace("- [ ] ", `- [ ] ${now}- `);
editor.replaceTextInRange(start, end, updatedLine);
// カーソルをカーソル行の末尾に移動
newCursorPos = start + updatedLine.length;
}
// 2. [ ] hh:mm- の場合: 終了時刻を追加して、完了状態にする
else if (/^- \[ \] \d{2}:\d{2}-/.test(currentLineText)) {
const updatedLine = currentLineText.replace(/^-\s\[ \]\s(\d{2}:\d{2})-/, `- [x] $1-${now}`);
editor.replaceTextInRange(start, end, updatedLine);
// カーソルを次の行に設定
newCursorPos = end + updatedLine.length - currentLineText.length + 1;
}
// 3. [x] hh:mm-hh:mm の場合: 行を複製し、複製した行を未完了状態にして、hh:mm-hh:mm の部分を消す
else if (/^- \[x\] \d{2}:\d{2}-\d{2}:\d{2}/.test(currentLineText)) {
const timeRangeEnd = currentLineText.indexOf(" ", 16);
const taskDescription = currentLineText.slice(timeRangeEnd).trim();
const newLine = `- [ ] ${taskDescription}`;
editor.replaceTextInRange(start, end, currentLineText); // 現在の行を保持
editor.replaceTextInRange(end, end, "\n" + newLine); // 新しい行を追加
// カーソルを次の行の末尾に設定
newCursorPos = end + newLine.length + 1;
}
// 処理完了後、カーソル位置を設定
editor.setSelectedRange(newCursorPos, newCursorPos);
3. 1Writer リピートタスクの一括コピー
仕様
- [ ] `- [ ]`から始まるもののみ、リピートタスクと判定してコピー
- [ ] !平: 平日
- [ ] !月,金: 曜日指定
- [ ] !0904,0905: 月日指定
コード
// ノート情報の保存
var folder = editor.getFolderPath(); // 現在のノートのフォルダパスを取得
var editingfile = editor.getFileName();
var cursorPosition = editor.getSelectedRange(); // カーソル位置を保存
// ノートタイトルから日付部分を取得(例: "2023-08-20")
var datePattern = /^(\d{4})-(\d{2})-(\d{2})/;
var match = editingfile.match(datePattern);
if (!match) {
ui.alert("ノートのタイトルに有効な日付が含まれていません。");
return;
}
// 日付オブジェクトを作成
var noteDate = new Date(match[1], match[2] - 1, match[3]); // 月は0から始まるため-1
// MMDD形式で現在の日付を取得
var currentMMDD = ("0" + (noteDate.getMonth() + 1)).slice(-2) + ("0" + noteDate.getDate()).slice(-2);
// ノートの日付から曜日を取得(0: 日曜, 1: 月曜, ..., 6: 土曜)
var dayOfWeek = noteDate.getDay();
// 曜日のマッピング(0: 日曜 ~ 6: 土曜)
var dayOfWeekMapping = ['日', '月', '火', '水', '木', '金', '土'];
// リピートタスクを記述しているファイルのファイル名
var openfilename = 'リピートタスク.md';
// リピートタスクのファイルを開く(現在のフォルダ内)
editor.openFile(folder + '/' + openfilename, 'edit', call);
function call() {
// ファイルのテキストを取得
var text = editor.getText();
// "- [ ] " または "🗂️" で始まる行のみをフィルタリング
let listData = text.split('\n').filter(line => {
// タスクが "- [ ] " または "🗂️" で始まっているか確認
if (!(line.startsWith('- [ ] ') || line.startsWith('\**🗂️'))) return false;
// リピート条件が指定されているか確認(例: !木,金,MMDD,平)
let matchedCondition = line.match(/!([月火水木金土日平,\d{4}]+)/);
if (matchedCondition) {
let conditions = matchedCondition[1].split(',').map(cond => cond.trim());
// 曜日が指定されているか確認
let dayMatch = conditions.some(cond => dayOfWeekMapping.includes(cond));
if (dayMatch) return conditions.includes(dayOfWeekMapping[dayOfWeek]);
// MMDD形式のチェック
let dateMatch = conditions.some(cond => /^\d{4}$/.test(cond));
if (dateMatch) return conditions.includes(currentMMDD);
// "平" が指定された場合の処理(平日ならタスクを含める)
if (conditions.includes('平')) {
return dayOfWeek >= 1 && dayOfWeek <= 5; // 平日なら含める
}
return false; // 条件に合致しない場合は除外
}
// 条件が指定されていない場合は常にタスクを含める
return true;
});
// 条件部分の削除処理
listData = listData.map(line => {
// リピート条件部分の削除(例: !木,金,0820,平)
return line.replace(/!([月火水木金土日平,\d{4}]+)\s*/, '').trim();
});
ui.hudDismiss();
if (listData.length === 0) {
ui.alert("リピート条件に一致するタスクが見つかりませんでした。");
return;
}
const selectedText = listData.join('\n'); // すべてのタスクを1つの文字列に結合
// 元のノートに戻り、カーソル位置にすべてのタスクを挿入
editor.openFile(folder + '/' + editingfile, 'edit', function() {
editor.setSelectedRange(cursorPosition[0]); // カーソル位置に戻る
editor.replaceSelection(selectedText); // 選択されたテキストを挿入
editor.setSelectedRange(cursorPosition[0] + selectedText.length); // カーソルを挿入後の位置に移動
});
}
1Writer版の終了予定時刻を計算する
シュミレーション機能はあきらめていたのだが、いざ使ってみると、やはり便利。先がパッと見通せる安心感はすばらしい。
ということで、1Writerでも終了予定時刻を出せるようにした。
開いているノートから、未完了で所要時間が入っているタスクを抜き出して、終了予定時刻をリスト形式で表示する。
複数タスクが実行されていたり、タスクの順序が開始時刻順になっていなかったり、見積時間が規定のフォーマットで入っていなかったりするとうまく表示されないが、一応使える。
見積の超過や開始予定時刻の超過がわかって結構便利。
// ノートの全テキストを取得
let noteText = editor.getText();
// 未完了タスクおよび🗂️で始まる行を正規表現で抽出
let tasks = noteText.match(/(?:- \[ \](?: (\d{2}:\d{2})-)? \\(\d+)(.*)|🗂️.*)/g);
if (tasks) {
// 結果を格納するリスト
let endTimes = [];
let currentDateTime = new Date(); // 現在の時刻を基準にする
tasks.forEach((task, index) => {
// 🗂️で始まる行を処理
if (task.startsWith("🗂️")) {
endTimes.push(task); // タスク名をそのままリストに追加
return; // 次のループへ
}
// タスクから開始時刻、所要時間、タスク名を抽出
let match = task.match(/(?: (\d{2}:\d{2})-)? \\(\d+)(.*)/);
let startTime = match[1]; // 開始時刻 (optional)
let duration = parseInt(match[2], 10); // 所要時間
let taskName = match[3].trim(); // タスク名
let baseTime;
let actualStartTime;
let scheduleTimeWarning = '';
// 一つ目のタスクの処理
if (index === 0 || !endTimes[endTimes.length - 1].startsWith("終了予定時刻")) {
if (startTime) {
// 開始時刻が指定されている場合
let [hours, minutes] = startTime.split(':').map(Number);
baseTime = new Date();
baseTime.setHours(hours);
baseTime.setMinutes(minutes);
baseTime.setSeconds(0);
baseTime.setMilliseconds(0);
} else {
// 開始時刻がない場合、現在時刻を基準にする
baseTime = new Date(currentDateTime);
}
} else {
// 2つ目以降のタスクは、前のタスクの終了予定時刻を基準にする
baseTime = new Date(currentDateTime);
}
// 基準時刻に所要時間を追加して終了時刻を計算
actualStartTime = new Date(baseTime); // 実際の開始時刻
let endDate = new Date(baseTime.getTime() + duration * 60000);
// 計算した終了予定時刻が現在時刻より前の場合、終了時刻を現在時刻に設定
if (endDate < new Date()) {
endDate = new Date();
}
// スケジュール時間のチェックと警告の設定
let scheduleTimeMatch = taskName.match(/@(\d{2}:\d{2})/);
if (scheduleTimeMatch) {
let [schedHours, schedMinutes] = scheduleTimeMatch[1].split(':').map(Number);
let scheduleTime = new Date();
scheduleTime.setHours(schedHours);
scheduleTime.setMinutes(schedMinutes);
scheduleTime.setSeconds(0);
scheduleTime.setMilliseconds(0);
// 開始予定時刻がスケジュールされた時間より後なら警告をつける
if (actualStartTime > scheduleTime) {
scheduleTimeWarning = '⚠️';
}
}
// "HH:MM"形式にフォーマット
let startTimeStr = actualStartTime.toTimeString().slice(0, 5);
let endTimeStr = endDate.toTimeString().slice(0, 5);
// 残り時間の計算(現在時刻 - 開始時刻)
let elapsedTime = Math.ceil((new Date() - actualStartTime) / 60000);
// 超過時間のチェック
let remainingTimeInfo = '';
if (elapsedTime > duration) {
let overdueMinutes = elapsedTime - duration;
remainingTimeInfo = `⚠️超過${overdueMinutes}分`;
} else {
let remainingTime = duration - elapsedTime;
remainingTimeInfo = `➡️残り${remainingTime}分`;
}
// 結果を「タスク名: 開始時刻 - 終了予定時刻 (見積時間分)」の形式でリストに追加
let taskOutput = `${taskName}||${scheduleTimeWarning}${startTimeStr} - ${endTimeStr} (${duration}分)`;
if (startTime) {
taskOutput += ` ${remainingTimeInfo}`;
}
endTimes.push(taskOutput);
// 次のタスクの基準時刻として設定
currentDateTime = endDate;
});
// 結果をリスト形式で表示
ui.list("未完了タスクと🗂️行の終了予定時刻", endTimes);
} else {
ui.alert("未完了タスクが見つかりませんでした。");
}
セクションの追加
タスクを見やすくするために、セクションを追加した。
`🗂️セクション名`
のように書いて、任意の位置に置くだけ。
**🗂️17:00**
- [x] 18:02-18:02 \5 完了タスク
- [ ] 18:02- \10 実行中タスク
- [ ] \15 未実行タスク
**🗂️21:00**
- [ ] \15 未実行タスク
- [ ] \15 未実行タスク
- [ ] \15 未実行タスク
おまけ 1Writer カーソル行の削除
カーソル行を削除し、一つ下の行にカーソルを移動。
もともと使っていたコードは一つ上の行にカーソルが移動していたが、下に移動して欲しいことが多かったのでつくった。
// カーソルがある行の範囲を取得
let lineRange = editor.getSelectedLineRange();
let start = lineRange[0];
let end = lineRange[1];
// 改行も含めて完全に行を削除
editor.replaceTextInRange(start, end + 1, "");
// 次の行の開始位置にカーソルを移動
editor.setSelectedRange(start);