tritask-scrapbox-beta
Next version
scrapbox上で
Tritaskっぽいタスク管理を行うためのUserScript (beta version)
これ単体では動かない
2021-01-05 20:27:05 作った
2021-01-06
18:54:10
任意の場所に書いたタスクを、それぞれの日付ページに飛ばすようにした
16:47:25
refactoringした
いくつか機能を追加した
2021-01-04
09:40:13 hours === 0
のときに空白だと誤認してしまうバグを直した
!hours
ではなく hours === undefined
で比較しないとだめみたい
2021-01-02
2020-12-31 16:33:55 いろいろ修正
インデントなしで新しく行を作る
現在行がタスクなら、そのタスクと同じ日付のタスクを作る
実装したいやつ
繰り返し属性
別の日付のタスクを、それぞれのページに飛ばす
script.jsimport {
goHead, goLine, moveRight,
} from '/api/code/takker/scrapbox-edit-emulation/script.js';
import {press} from '/api/code/takker/scrapbox-keyboard-emulation-2/script.js';
import {cursor} from '/api/code/takker/scrapbox-cursor-position-3/script.js';
import {line as l} from '/api/code/takker/scrapbox-line-info-2/script.js';
import {insertText} from '/api/code/takker/scrapbox-insert-text/script.js';
import {scrapboxDOM} from '/api/code/takker/scrapbox-dom-accessor/script.js';
import {selection} from '/api/code/takker/scrapbox-selection-2/script.js';
script.jsconst interval = 5; // 5 minutes
export function addTask() {
const text = l(cursor().line).text;
// 空白以外が書き込まれていたら、新しい行に書き込む
if (!/^\s+$/.test(text) && text !== '') {
press('End');
insertText({text: '\n'});
}
const task = parse({line: text});
// 現在行がタスクなら、それと同じ日付にする
// 違ったら今日にする
const date = task.date ?? new Date();
// 予定開始時刻と見積もり時刻を計算する
// 予定開始時刻は、その前のタスクの見積もり時間にintervalを足したものだけずらしておく
const plan = {
start: task.plan?.start ? (() => {
const temp = new Date();
temp.setHours(task.plan.start.hours);
temp.setMinutes(task.plan.start.minutes
+ interval + (task.plan.duration.minutes ?? 0));
return {
hours: temp.getHours(),
minutes: temp.getMinutes(),
};
})() : undefined,
duration: task.plan?.duration,
};
// 全選択して上書きする
selectLine();
insertText({text: create({type: 'task',date, plan})});
}
export function startTask() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
const {record, ...rest} = task;
if (record.end) return;// すでに終了していたら何もしない
// 開始時刻をtoggleする
const now = new Date();
const start = !record.start ? {
hours: now.getHours(),
minutes: now.getMinutes(),
seconds: now.getSeconds(),
} : undefined;
// 全選択して上書きする
selectLine();
insertText({text: create({record: {start}, ... rest})});
}
export function endTask() {
const task = parse({line: l(cursor().line).text});
//console.log(task);
if (task.type !== 'task') return; // タスクでなければ何もしない
const {record, ...rest} = task;
if (!record.start) return;// まだ開始していなかったら何もしない
// 終了時刻をtoggleする
const now = new Date();
const end = !record.end ? {
hours: now.getHours(),
minutes: now.getMinutes(),
seconds: now.getSeconds(),
} : undefined;
const newTask = {record: {start: record.start, end}, ...rest};
// repが指定されていたら、次のタスクを作成する
const nextTask = createNextTask(newTask)
const text = create(newTask)
+ (nextTask === '' || record.end ? '' : `\n${nextTask}`);
// 全選択して上書きする
selectLine();
insertText({text});
}
script.jsexport function posterioriEndTask() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
const {record, ...rest} = task;
if (record.start || record.end) return; // 開始していないタスクのみが対象
const now = new Date();
// 直近のタスクを検索する
const lineNo = l(cursor().line).index;
const targetline = [...scrapboxDOM.lines.children]
.slice(0, lineNo + 1)
.reverse()
.find(line => {
const {type, record} = parse({line: l(line).text});
return type === 'task' && record?.end; // 終了しているタスクのみ検索する
});
// 開始時刻と終了時刻を計算する
const end = {
hours: now.getHours(),
minutes: now.getMinutes(),
seconds: now.getSeconds(),
};
const start = targetline ? parse({line: l(targetline).text}).record.end : end;
const newTask = {record: {start, end}, ...rest};
// repが指定されていたら、次のタスクを作成する
const nextTask = createNextTask(newTask)
const text = create(newTask)
+ (nextTask === '' || record.end ? '' : `\n${nextTask}`);
// 全選択して上書きする
selectLine();
insertText({text});
}
編集支援
文字の選択
script.js// 予定開始時刻を選択する
export function selectPlanningTime() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
goHead();
moveRight(12); // `YYYY-MM-DD なので12文字
// 5文字選択する
for (let i = 0; i < 5; i++) {
press('ArrowRight', {shiftKey: true});
}
}
// 見積もり時間を選択する
export function selectEstimatedTime() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
goHead();
moveRight(18);// `YYYY-MM-DD hh:mm なので18文字
// 4文字選択する
for (let i = 0; i < 4; i++) {
press('ArrowRight', {shiftKey: true});
}
}
日付を増やす
script.jsexport async function walkDay(count = 1) {
if (!selection.exist) {
_walkDay(count);
} else {
const {start: {lineNo: startNo}, end: {lineNo: endNo}} = selection.range;
for (let i = startNo; i <= endNo; i++) {
goLine({index: i});
await sleep(10);
_walkDay(count);
await sleep(1);
}
}
}
function _walkDay(count) {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
const {date, ...rest} = task;
// 日付をずらす
let newDate = new Date(date);
newDate.setDate(newDate.getDate() + count);
// 全選択して上書きする
selectLine();
insertText({text: create({date: newDate, ...rest})});
}
今日にする
script.jsexport async function moveToday() {
if (!selection.exist) {
_moveToday();
} else {
const {start: {lineNo: startNo}, end: {lineNo: endNo}} = selection.range;
for (let i = startNo; i <= endNo; i++) {
goLine({index: i});
await sleep(10);
_moveToday();
await sleep(1);
}
}
}
function _moveToday() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
const {date, ...rest} = task;
const now = new Date();
// 日付に変更がなければ何もしない
if (date.getFullYear() === now.getFullYear()
&& date.getMonth() === now.getMonth()
&& date.getDate() === now.getDate()) return;
// 全選択して上書きする
selectLine();
insertText({text: create({date: now, ...rest})});
}
Page Network関係
ぶら下がっているテキストは転送しない
parse
でぶら下がっているテキストも取得できるようにすればできそう
script.js// 転送先の日付ページの名前
const diaryPage = (date) => `日刊記録sheet ${toYYYYMMDD(date)}`;
function parseDiary() {
if (!/^日刊記録sheet \d{4}-\d{2}-\d{2}$/.test(scrapbox.Page.title)) return undefined;
const [year, month, date] = scrapbox.Page.title
.match(/^日刊記録sheet (\d{4})-(\d{2})-(\d{2})$/).slice(1);
return new Date(year, month - 1, date);
}
export async function transport({targetProject}) {
// 転送しないタスクの日付
const diaryDate = parseDiary();
// 検索する範囲
const {startNo, endNo} = selection.exist ? (() => {
const {start,end} = selection.range;
return {startNo: start.lineNo, endNo: end.lineNo};
})() : {startNo: 1, endNo: scrapbox.Page.lines.length - 1};
// 移動する行を取得する
const targetLines = scrapbox.Page.lines
.slice(startNo, endNo + 1)
.map((line, i) => {
return {
lineDOM: scrapboxDOM.lines.children[i + startNo],
text: line.text,
date: parse({line: line.text}).date,
};
})
.filter(({date}) => {
if (!date) return false;
if (!diaryDate) return true; // 日付ページでなければ、全てのタスクを転送する
return !(date.getFullYear() === diaryDate.getFullYear()
&& date.getMonth() === diaryDate.getMonth()
&& date.getDate() === diaryDate.getDate());
});
// 対象の行を削除する
for (const {lineDOM} of targetLines) {
goLine({index: l(lineDOM).index});
await sleep(10);
// 改行を含めて消す
press('End');
press('Home', {shiftKey: true});
press('Home', {shiftKey: true});
press('ArrowLeft', {shiftKey: true});
press('Delete');
}
// 日付ごとにタスクをまとめる
let bodies = {};
targetLines.forEach(({text,date}) => {
bodies[toYYYYMMDD(date)] = {
date,
texts: [...(bodies[toYYYYMMDD(date)]?.texts ?? []), text]
};
});
// 新しいタブで開く
for (const [key, {date, texts}] of Object.entries(bodies)) {
const body = encodeURIComponent(texts.join('\n'));
window.open(`https://scrapbox.io/${targetProject}/${diaryPage(date)}?body=${body}`);
}
}
設定とかもろもろ
script.js// タスクの書式
const taskReg = /^`(\d{4}-\d{2}-\d{2}) ( {5}|\d{2}:\d{2}) ( {4}|\d{4}) ( {8}|\d{2}:\d{2}:\d{2}) ( {8}|\d{2}:\d{2}:\d{2})`([^\n]*)$/;
taskを解析する
script.jsfunction parse({line}) {
if (!taskReg.test(line)) return {};
const [dateString, plan, estimate, start, end, content]
= line.match(taskReg)?.slice(1);
const [year, month, date] = dateString.split('-').map(number => parseInt(number));
const [planH, planM] = plan.split(':').map(number => parseInt(number));
return {
type: 'task',
date: new Date(year, month - 1, date),
plan: {
start: !/^\s*$/.test(plan) ? {
hours: planH,
minutes: planM,
} : undefined,
duration: !/^\s*$/.test(estimate) ? {
minutes: parseInt(estimate),
} : undefined,
},
record: {
start: parseTime(start),
end: parseTime(end),
},
content,
...parseAttributes({content}),
};
}
helper関数
hh:mm:ss
をobjectに分解してくれるやつ
script.jsfunction parseTime(timeString) {
if (!/\d{2}:\d{2}:\d{2}/.test(timeString)) return undefined;
const [hours, minutes, seconds]
= timeString.split(':').map(number => parseInt(number));
return {hours, minutes, seconds};
}
属性を解析する
script.jsfunction parseAttributes({content}) {
const repeat = content.split(/\s/)
.find(fragment => /^rep:\d+$/.test(fragment))
?.replace(/^rep:(\d+)$/, '$1');
return {
repeat: repeat !== undefined ? parseInt(repeat) : undefined,
skip: content.split(/\s/)
.find(fragment => /^skip:[月火水木金土日平休]+$/u.test(fragment))
?.replace(/^skip:([月火水木金土日平休]+)$/u, '$1')?.match(/./ug)
//Date.prototype.getDay()の書式に変換する
?.flatMap(dayString => {
switch(dayString) {
case '日':
return [0];
case '月':
return [1];
case '火':
return [2];
case '水':
return [3];
case '木':
return [4];
case '金':
return [5];
case '土':
return [6];
case '休':
return [0,6];
case '平':
return [1,2,3,4,5];
}
}) ?? [],
};
}
taskもしくはinboxの文字列を作成する
script.jsconst initalInbox = `\`${' '.repeat(28)}\``;
function create({type, date, plan, record, content,}) {
switch(type) {
case 'task':
let time = new Date(date);
const dateString = toYYYYMMDD(time);
if (plan?.start) {
time.setHours(plan.start.hours);
time.setMinutes(plan.start.minutes);
}
const planStr = plan?.start ? toHHMM(time) : ' '.repeat(5);
const durationStr = plan?.duration ?
String(plan?.duration.minutes).padStart(4, '0') : ' '.repeat(4);
if (record?.start) {
time.setHours(record.start.hours);
time.setMinutes(record.start.minutes);
time.setSeconds(record.start.seconds);
}
const startStr = record?.start ? toHHMMSS(time) : ' '.repeat(8);
if (record?.end) {
time.setHours(record.end.hours);
time.setMinutes(record.end.minutes);
time.setSeconds(record.end.seconds);
}
const endStr = record?.end ? toHHMMSS(time) : ' '.repeat(8);
return `\`${dateString} ${planStr} ${durationStr} ${startStr} ${endStr}\`${content ?? ''}`;
default:
throw Error(`${type} is an invalid type.`);
}
}
繰り返し属性を解析して、次のタスクを作る
script.jsfunction createNextTask({date, plan, record, content, skip, repeat}) {
if (repeat === undefined) return '';
//console.log({date, skip, repeat});
let nextDate = new Date(date);
//console.log({nextDate});
nextDate.setDate(nextDate.getDate() + repeat);
// skip属性があったら、指定された曜日以外になるまで日付をずらす
if (skip.length !== 0) {
// 全ての曜日がスキップに設定されていた場合は空文字を返す
if ([0,1,2,3,4,5,6].every(day => skip.includes(day))) return '';
while (skip.includes(nextDate.getDay())) {
nextDate.setDate(nextDate.getDate() + 1);
//console.log({nextDate});
}
}
// 見積もり時間を設定する
const duration = plan?.duration
// 実績から見積もり時間を計算する
?? {minutes: (() => {
const start = record.start.hours * 60 + record.start.minutes;
const end = record.end.hours * 60 + record.end.minutes;
return start < end ? end - start
// 終了時刻のほうが早かった場合は、日付をまたいでいるとみなす
: end + 24 * 60 - start;
})()};
return create({type: 'task',
date: nextDate,
plan: {start: plan.start, duration: duration},
content});
}
Utilities
script.js// 全選択
function selectLine() {
goHead();
press('End', {shiftKey: true});
}
function getDayString(day) {
switch(day) {
case 0:
return 'Sun';
case 1:
return 'Mon';
case 2:
return 'Tue';
case 3:
return 'Wed';
case 4:
return 'Thu';
case 5:
return 'Fri';
case 6:
return 'Sat';
default:
throw Error(`Invalid day number: ${day}`);
}
}
const zero = n => String(n).padStart(2, '0');
function toYYYYMMDD(d) {
return `${d.getFullYear()}-${zero(d.getMonth() + 1)}-${zero(d.getDate())}`;
}
function toHHMMSS(d) {
return `${zero(d.getHours())}:${zero(d.getMinutes())}:${zero(d.getSeconds())}`;
}
function toHHMM(d) {
return `${zero(d.getHours())}:${zero(d.getMinutes())}`;
}
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));