tritask-scrapbox
本家との違い
一部の機能と属性を外しました
Logicの実装はできている
構文を少し変えました
code:original
/^(\d| ) (\d{4}-\d{2}-\d{2}) (\w{3}) ( {5}|\d{2}:\d{2}) ( {5}|\d{2}:\d{2}) (^\n*)$/ code:this
/^(\d| ) (\d{4}-\d{2}-\d{2}) (\w{3}) ( {5}|\d{2}:\d{2}) ( {5}|\d{2}:\d{2})(^\n*)$/ 数字の位置を揃えるために、インラインコード記法で等幅フォントにしています
タスク名の部分は通常のテキストです
リンクや文字装飾記法を含めることができます
ソート時に開始日を今日にする機能がありません
単純に実装し忘れてました……takker.icon
Screenshots
タスクの追加
https://gyazo.com/c6cfd2c7a732769ace923d5971e0716a
ルーチンタスク
https://gyazo.com/da46b60e91d3ddb60cba8df83c7e976c
複数行操作とソート
https://gyazo.com/9ceb63cab6ada06bab57ba605f2d1490
行の順番がむちゃくちゃだと、ソートに少し時間がかかります
テロメアをむやみに更新しないよう、1行ずつアウトライン編集を用いて移動させているので
使い方
お試し方法
このUserScriptはTritaskの操作コマンドを提供する機能しかありません
実際に使うには、別途Page Menuやkeyboard shortcutの設定を行う必要があります
keyboard shortcutを使った設定例
code:js
import {
addTask, addInbox, copyTask,
startTask, endTask, posterioriEndTask, closeTask,
selectContent, walkDay, moveToday, changeInbox, sort, report,
} from '/api/code/takker/tritask-scrapbox/script.js';
import {scrapBindings} from '/api/code/takker/ScrapBindings/script.js';
const config = [
{key: 'alt+a alt+a', command: () => {addTask();return false;},},
{key: 'alt+a alt+x', command: () => {addInbox();return false;},},
{key: 'alt+a alt+c', command: () => {copyTask();return false;},},
{key: 'alt+a alt+s', command: () => {startTask();return false;},},
{key: 'alt+a alt+e', command: () => {endTask();return false;},},
{key: 'alt+a alt+0', command: () => {posterioriEndTask();return false;},},
{key: 'alt+a alt+q', command: () => {closeTask();return false;},},
{key: 'alt+a alt+/', command: () => {selectContent();return false;},},
{key: 'alt+a alt+1', command: () => {walkDay();return false;},},
{key: 'alt+a alt+t', command: () => {moveToday();return false;},},
{key: 'alt+a alt+i', command: () => {changeInbox();return false;},},
{key: 'alt+a alt+shift+s', command: () => {sort();return false;},},
{key: 'alt+a alt+.', command: () => {report();return false;},},
//{key: 'alt+a alt+b', command: () => {selectPlanningTime(); return false;},},
//{key: 'alt+a alt+m', command: () => {selectEstimatedTime(); return false;},},
];
scrapBindings.install()
.then(() => scrapBindings.push(...config));
キーバインドは本家の設定を踏襲しました
押しやすくするために、全てのキーにaltをつけています
alt+spaceがうごかなかったので、alt+shift+sに変えています
Page menuをつかった設定例
mobile版scrapbox向けの設定
code:js
import {
addTask, startTask, endTask, posterioriEndTask,
walkDay, moveToday, changeInbox, sort, report,
} from '/api/code/takker/tritask-scrapbox/script.js';
import {isMobile} from '/api/code/takker/mobile版scrapboxの判定/script.js';
if (isMobile()) {
const id = 'Tritask';
scrapbox.PageMenu.addMenu({
title: id,
});
scrapbox.PageMenu(id).addItem({
title: 'Add task',
onClick: addTask,
});
scrapbox.PageMenu(id).addItem({
title: 'Start task',
onClick: startTask,
});
scrapbox.PageMenu(id).addItem({
title: 'End task',
onClick: endTask,
});
scrapbox.PageMenu(id).addItem({
title: 'Posteriori end task',
onClick: posterioriEndTask,
});
scrapbox.PageMenu(id).addItem({
title: 'walk +1 day',
onClick: () => walkDay(),
});
scrapbox.PageMenu(id).addItem({
title: 'Change today',
onClick: moveToday,
});
// 以下2つはmobileで動くか確認していない
scrapbox.PageMenu(id).addItem({
title: 'Sort',
onClick: sort,
});
scrapbox.PageMenu(id).addItem({
title: 'Report',
onClick: report,
});
}
継続的に使う場合は、依存コードを全て自分の個人projectにコピーすることをおすすめします
一覧
うっげ依存コード多すぎだなtakker.icon
これじゃコピペするのも一苦労だ
なんとかしなきゃ
作っといてアレですが、takker.iconはこのScriptを使用していません
バグ報告があれば直しますが、積極的なメンテナンスはしないつもりです
Scrapboxに合うような形にカスタムしたいという理由もあります
例えば日付ごとにページを分けるとか
タスクの下にインデントを下げてメモを書くとか
んでそのメモを別のページに送る
code:script.js
import {
goLine, goHead, enterEdit, upLines
} 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';
code:script.js
// タスクの書式
const taskReg = /^(\d| ) (\d{4}-\d{2}-\d{2}) (\w{3}) ( {5}|\d{2}:\d{2}) ( {5}|\d{2}:\d{2})(^\n*)$/; const inboxReg = /^( {28})(^\n*)$/; const timeReg = /\d{2}:\d{2}/;
const dateReg = /\d{4}-\d{2}-\d{2}/;
const dummyTime = ' '.repeat(5);
const interval = 5; // 5 minutes
機能一覧
追加
https://gyazo.com/c6cfd2c7a732769ace923d5971e0716a
code:script.js
export function addTask() {
const text = l(cursor().line).text;
// 空白のみの行ならそのまま上書きする
// 何か書き込まれている行だったら、改行して新しい行を作る
if (!/^\s+$/.test(text) && text !== '') {
press('End');
press('Enter');
}
const task = parse({line: text});
write({type: 'task',
// 現在行がタスクなら、それと同じ日付にする
// 違ったら今日にする
date: (task.type === 'task' ? task.date : new Date()),})
}
code:script.js
export function copyTask() {
const text = l(cursor().line).text;
// taskのみが対象
if (!taskReg.test(text)) return;
// コピペする
press('End');
press('Enter');
insertText({text});
}
code:script.js
export function addInbox() {
const text = l(cursor().line).text;
// 空白のみの行ならそのまま上書きする
// 何か書き込まれている行だったら、改行して新しい行を作る
if (!/^\s+$/.test(text) && text !== '') {
press('End');
press('Enter');
}
write({type: 'inbox'});
}
code:script.js
export function startTask() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
const {start: orgStart, end, ...rest} = task;
if (end) return;// すでに終了していたら何もしない
// 開始時刻をtoggleする
const now = new Date();
const start = !orgStart ? {
hours: now.getHours(),
minutes: now.getMinutes(),} : undefined;
// 全選択して上書きする
write({start, ...rest});
}
タスク終了系コマンドは、対象タスクの属性に応じて新しいタスクを生成する
https://gyazo.com/da46b60e91d3ddb60cba8df83c7e976c
code:script.js
export function endTask() {
const task = parse({line: l(cursor().line).text});
//console.log(task);
if (task.type !== 'task') return; // タスクでなければ何もしない
const {end: orgEnd, start, ...rest} = task;
if (!start) return;// まだ開始していなかったら何もしない
// 終了時刻をtoggleする
const now = new Date();
const end = !orgEnd ? {
hours: now.getHours(),
minutes: now.getMinutes(),} : undefined;
// repが指定されていたら、次のタスクを作成する
const newTask = createNextTask(task);
const text = create({start, end, ...rest})
+ (newTask === '' || orgEnd ? '' : \n${newTask});
// 全選択して上書きする
selectLine();
insertText({text});
}
code:script.js
export function posterioriEndTask() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
const {start: orgStart, end: orgEnd, ...rest} = task;
//console.log(task);
if (orgStart || orgEnd) return; // 開始していないタスクのみが対象
const now = new Date();
// 直近のタスクを検索する
const lineNo = l(cursor().line).index;
.slice(0, lineNo + 1)
.reverse()
.find(line => {
const {type, end} = parse({line: l(line).text});
return type === 'task' && end; // 終了しているタスクのみ検索する
});
const end = {
hours: now.getHours(),
minutes: now.getMinutes(),
};
const start = targetline ? parse({line: l(targetline).text}).end : end;
// repが指定されていたら、次のタスクを作成する
const newTask = createNextTask(task);
const text = create({start, end, ...rest})
+ (newTask === '' ? '' : \n${newTask});
// 全選択して上書きする
selectLine();
insertText({text});
}
これいるのか?
一応作った
code:script.js
export function closeTask() {
const task = parse({line: l(cursor().line).text});
//console.log(task);
if (task.type !== 'task') return; // タスクでなければ何もしない
const {start: orgStart, end: orgEnd, ...rest} = task;
if ((!orgStart && orgEnd) || (orgStart && !orgEnd)) return;
// 開始時刻と終了時刻をtoggleする
const now = new Date();
const start = !orgStart ? {
hours: now.getHours(),
minutes: now.getMinutes(),} : undefined;
// repが指定されていたら、次のタスクを作成する
const newTask = createNextTask(task);
const text = create({start, end: start, ...rest})
+ (newTask === '' || orgStart ? '' : \n${newTask});
// 全選択して上書きする
selectLine();
insertText({text});
}
タスク名の全選択
code:script.js
export function selectContent() {
const {type, content} = parse({line: l(cursor().line).text});
if (!type) return;
press('End');
// contentの長さだけ選択する
for (let i = 0; i < content.length; i++) {
press('ArrowLeft', {shiftKey: true});
}
}
日付操作
数値指定が面倒なので実装しない
code:script.js
export 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);
write({date: newDate, ...rest});
}
code:script.js
export 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;
write({date: new Date(), ...rest});
}
code:script.js
export function changeInbox() {
const task = parse({line: l(cursor().line).text});
if (task.type !== 'task') return; // タスクでなければ何もしない
const {content} = task;
write({type: 'inbox', content});
}
いるのかこれ?
タイトル以外の全ての行を一気にソートする
code:script.js
export async function sort() {
if (scrapbox.Page.lines.length < 3) return;
const startNo = 1;
const endNo = scrapbox.Page.lines.length - 1;
const sortedLineDOMs = scrapbox.Page.lines
.slice(startNo, 1 + endNo)
.map((line, i) => {return {lineDOM: scrapboxDOM.lines.childreni + startNo, text: line.text}}) .sort((a,b)=>new Intl.Collator().compare(a.text,b.text))
.map(({lineDOM}) => lineDOM);
// 一番上から順に入れ替え作業をする
let insertPosition = startNo;
for (const lineDOM of sortedLineDOMs) {
const presentPosition = l(lineDOM).index; // 現在の行の位置
if (presentPosition !== insertPosition) {
goLine({index: presentPosition});
await sleep(10);
upLines(presentPosition - insertPosition);
}
insertPosition++;
}
}
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
これはいらない気もする
そんなに長くなる前にページ分割して
code:script.js
export function report() {
if (!selection.exist) {
let now = new Date();
const tasks = scrapbox.Page.lines
.filter(line => taskReg.test(line.text))
.map(line => parse({line: line.text}))
.filter(({date}) =>
//今日のタスクのみ抽出する
date.getFullYear() === now.getFullYear()
&& date.getMonth() === now.getMonth()
&& date.getDate() === now.getDate());
const taskNum = tasks.length;
const doneTaskNum = tasks.filter(({end})=>end).length;
const restTime = tasks
.filter(({end, estimate}) => !end && estimate !== undefined)
.map(({estimate}) => estimate)
.reduce((result, estimate) => result + estimate, 0);
now.setMinutes(now.getMinutes() + restTime);
window.alert(`You've done ${doneTaskNum}/${taskNum} tasks. (rest: ${taskNum - doneTaskNum})
Your goal time is ${toHHMM(now)}. (rest: ${(restTime / 60.0).toPrecision(3)}H)`);
} else {
const {start: {lineNo: startNo}, end: {lineNo: endNo}} = selection.range;
const tasks = scrapbox.Page.lines.slice(startNo, endNo + 1)
.filter(line => taskReg.test(line.text))
.map(line => parse({line: line.text}));
const restTime = tasks.filter(({end, estimate}) => !end && estimate !== undefined)
.map(({estimate}) => estimate)
.reduce((result, estimate) => result + estimate, 0);
window.alert(There are ${tasks.length} tasks, ${(restTime / 60.0).toPrecision(3)}H);
}
}
属性
これはページ分けろでいいかなあtakker.icon
taskを解析する
code:script.js
function parse({line}) {
if (inboxReg.test(line)) {
return {type: 'inbox', content};
}
if (!taskReg.test(line)) return {};
const startH, startM = start.split(':').map(number => parseInt(number)); const endH, endM = end.split(':').map(number => parseInt(number)); return {
type: 'task',
header,
date: new Date(year, month - 1, date),
start: !/^\s*$/.test(start) ? {
hours: startH,
minutes: startM,
} : undefined,
end: !/^\s*$/.test(end) ? {
hours: endH,
minutes: endM,
} : undefined,
content,
...parseAttributes({content}),
};
}
function parseAttributes({content}) {
const repeat = content.split(/\s/)
.find(fragment => /^rep:\d+$/.test(fragment))
?.replace(/^rep:(\d+)$/, '$1');
const estimate = content.split(/\s/)
.find(fragment => /^m:\d+$/.test(fragment))
?.replace(/^m:(\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 '日':
case '月':
case '火':
case '水':
case '木':
case '金':
case '土':
case '休':
case '平':
}
}) ?? [],
estimate: estimate !== undefined ? parseInt(estimate) : undefined,
};
}
taskもしくはinboxの文字列を作成する
code:script.js
const initalInbox = \`${' '.repeat(28)}\`;
function create({type,
date,
header,
start, end,
content,}) {
switch(type) {
case 'inbox':
return ${initalInbox}${content ?? ''};
case 'task':
let time = new Date(date);
const dateString = ${toYYYYMMDD(time)} ${getDayString(time.getDay())};
if (start) {
time.setHours(start.hours);
time.setMinutes(start.minutes);
}
const startStr = start ? toHHMM(time) : dummyTime;
if (end) {
time.setHours(end.hours);
time.setMinutes(end.minutes);
}
const endStr = end ? toHHMM(time) : dummyTime;
return \`${header ?? ' '} ${dateString} ${startStr} ${endStr}\`${content ?? ''};
default:
throw Error(${type} is an invalid type.);
}
}
// 現在行に上書きする
function write(taskOrInboxData) {
// 全選択して上書きする
selectLine();
insertText({text: create(taskOrInboxData)});
}
繰り返し属性を解析して、次のタスクを作る
code:script.js
function createNextTask({date, content, skip, repeat, header}) {
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) {
// 全ての曜日がスキップに設定されていた場合は空文字を返す
while (skip.includes(nextDate.getDay())) {
nextDate.setDate(nextDate.getDate() + 1);
//console.log({nextDate});
}
}
return create({type: 'task',header, date: nextDate, content})
}
Utilities
code: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())};
}
Reference
作者自らによるScrapboxへの移植の考察
書き込み用APIが提供されていないことから、移植を断念した模様