takker-scheduler-3/sync
日刊記録sheetとGoogle Calendarを同期させるmodule
task line (takker-scheduler-3)に対して有効
2021-07-16
13:11:00 bulk.js中のpathを更新してなかった
2021-07-12
18:08:42 Advanced Calendar Serviceを叩くコードまでは書いたが……それ以降書く気がしない……
何故だか知らないけど書きたくねぇ……
1日ごとにScrapboxの更新を通知するbotからfetchしているところとScript Propertiesを使っているところとをコピペして楽するか
2021-07-11
17:40:58 副作用を分離した
Clientで使うコードと、Google Apps Scriptから使うコードとを実装するための準備
2021-05-11
01:14:03
予定の名前はbracketingを外す
末尾に日付がついている名前は、予定の場合のみ日付を外す
custom-new-page-2で切り出したタスクには、日付をタイトル末尾につけるようにしている
予定にタスクの日付をつける必要はないので消す
2021-05-11 01:22:34 記録のほうも日付を削除しようかな
開始時刻と終了時刻から、いつ実施したのかは分かるので、タイトルに日付を入れる必要がない
2021-05-09
15:14:05 takker-scheduler-3/sync#609778d41280f0000052bddfを作った
14:45:12 書き込む予定が空だと何もしなかった
既存の予定の削除が働いていなかった
2021-05-05
17:14:58 変更がなければPATCHしない
14:46:31 名無しの予定はcalendarに反映させない
2021-05-04
14:50:55 処理を2段階に分けた
1. をページから{text: string; id: string;}[]を取得する
2. {text: string; id: string;}[]から予定と記録を抽出してGoogle Calendarに転記する
1.を任意のページデータの取得に差し替えれば、全ての日刊記録sheetの予定と記録を一気に書き出せる
実装したいこと
インデントでぶら下げてある説明文をcalendarに入れたい
複数ページにまたがる予定を同期させるときに使うcode
code:js
(async () => {
const {syncMultiPages} = await import('/api/code/takker/takker-scheduler-3%2Fsync/bulk.js');
const {eachDayOfInterval, lightFormat} = await import('/api/code/takker/date-fns.min.js/script.js');
await syncMultiPages(eachDayOfInterval({start: new Date(2021, 4, 9), end: new Date(2021, 4, 15)}).map(date => ({project: 'takker-memex', title: 日刊記録sheet ${lightFormat(date, 'yyyy-MM-dd')}})));
})();
code:bulk.js
import {calendarIds} from '/api/code/takker-memex/takkerのGoogle_Calendar/script.js';
import {syncTasks} from './client.js';
export async function syncMultiPages(pages) {
const params = await Promise.all(pages.map(async ({project, title}) => {
const titleLc = title.toLowerCase().replace(/ /g, '_');
const res = await fetch(/api/pages/${project}/${encodeURIComponent(titleLc)});
if (!res.ok) return;
const {lines} = await res.json();
return {lines, options: {project, title, ...calendarIds}};
}));
for (const {lines, options} of params) {
await syncTasks(lines, options);
}
}
Client (Firefox)用code
code:client.js
import {replaceEvents} from './calendar.js';
import {analyze} from './script.js';
export async function syncTasks(lines, {project, title, planId, recordId}) {
const {plans, records} = analyze(lines, {project, title});
// 書き込む
console.log('Writing events...');
await replaceEvents(planId, plans, {project, title});
await replaceEvents(recordId, records, {project, title});
console.log('Wrote.');
}
Server(GAS)用
更新されたページのうち、日刊記録sheet \d{4}-\d{2}-\d{2}に該当するページを抜き出して、更新されたタスクをcalendarに書き込む
code:server.js
import {replaceEvents} from './calendar-gas.js';
import {analyze} from './script.js';
function sync() {
const {plans, records} = analyze(lines, {project, title});
// 書き込む
console.log('Writing events...');
replaceEvents(planId, plans, {project, title});
replaceEvents(recordId, records, {project, title});
console.log('Wrote.');
}
同期対象のtask line (takker-scheduler-3)を取得する
code:script.js
import {parseFromString} from '../takker-scheduler-3%2Ftask/script.js';
import {lightFormat, add} from '../date-fns.min.js/script.js';
export function analyze(lines, {project, title}) {
console.log('Analyzing...');
const titleLc = title.toLowerCase().replace(/ /g, '_');
// 行情報に含まれるtask lineを全て抽出する
const tasks = lines.flatMap(({text, id}) => {
const task = parseFromString(text);
const result = [];
if (!task) return result;
const {title: _summary, plan, record} = task;
const source = {title: titleLc, url: https://scrapbox.io/${project}/${titleLc}#${id}};
const extendedProperties = {private: {project, title, id},};
// eventの名前から[]を外して末尾の日付を消しておく
const summary = _summary
.replace(/\[(^\]+)\]/g, '$1')
.trim()
.replace(/\s\d{4}-\d{2}-\d{2}$/, '')
// 前に日付があるタイプも消す
.replace(/^\d{4}-\d{2}-\d{2}\s/, '');
// 予め予定と記録に分けておく
// 名無しの予定は飛ばす
if (plan.start && plan.duration && summary !== '') result.push({
type: 'plan',
summary,
start: plan.start, end: add(plan.start, plan.duration),
extendedProperties, source,
});
if (record.start && record.end) result.push({
type: 'record',
summary,
start: record.start, end: record.end,
extendedProperties, source,
});
return result;
});
// 予定と記録に分ける
const plans = tasks.filter(({type}) => type === 'plan');
const records = tasks.filter(({type}) => type === 'record');
console.log('Analyzed: ', plans, records);
return {plans, records};
}
Calendar Serviceの操作
Google Apps Scriptで動かす
URLの埋め込みやprivate metadataの埋め込みを行うAPIが見つからなかったので断念。代わりにAdvanced Calendar Serviceを用いる
code:calendar-gas.js
import {formatRFC3339, set, addDays, min, max, isSameDay} from '../date-fns.min.js/script.js';
export function replaceEvents(calendarId, events, {project, title}) {
// projectとtitleが一致する予定を検索する
const existEvents = Calendar.Events.list(calendarId, {
privateExtendedProperty: [project=${project}, title=${title}],
});
// 既存の予定も新規作成する予定もなければ何もしない
if (events.length === 0 && existEvents.length === 0) return;
// 新しい予定を書き込む
for (const event of events) {
const {start, end, ...rest} = event;
const index = existEvents.findIndex(({extendedProperties}) =>
event.extendedProperties.private.project === extendedProperties.private.project &&
event.extendedProperties.private.title === extendedProperties.private.title &&
event.extendedProperties.private.id === extendedProperties.private.id
);
if (index > -1) {
const oldEvent = existEventsindex;
// 紐付けられているとわかったeventはどんどん削っていく
existEvents.splice(index, 1);
// 変更がなければ何もしない
if (oldEvent.summary === event.summary
&& oldEvent.start.dateTime === formatRFC3339(event.start)
&& oldEvent.end.dateTime === formatRFC3339(event.end)) return;
Calendar.Events.patch({
start: {dateTime: formatRFC3339(start)},
end: {dateTime: formatRFC3339(end)},
...rest,
}, calendarId, oldEvent.id);
continue;
}
Calendar.Events.insert({
start: {dateTime: formatRFC3339(start)},
end: {dateTime: formatRFC3339(end)},
...rest,
}, calendarId);
}
// 紐付けられていない予定をすべて消す
console.log('Delete ', existEvents);
for (const {id} of existEvents) {
Calendar.Events.remove(calendarId, id);
}
}
privateExtendedPropertyを指定した検索をGASからどうやってやるのかがわからない
google apps script - calendar API How to search for Default / None / Empty privateExtendedProperty in Calendar Events list - Stack Overflow
配列で渡せばよさそう
Google Apps Script - how can one filter calendar events on multiple properties with advanced calendar service - Stack Overflow
とりあえずこんなふうに置換した
deleteEvent→Calendar.Events.remove
createEvent→Calendar.Events.insert
updateEvent→Calendar.Events.patch
startとendを加工する必要がある
Google Calendar APIの操作
初期化
認証情報の読み込みはtakker.iconしかできないようになっています
他の人がこのコードを実行しようとすると、403 unauthorized errorが発生します
もしこのコードを使いたい人がいたら、認証情報の部分だけを差し替えてください
dependencies
Scrapbox-Google-API
code:calendar.js
import {init, exec} from '../Scrapbox-Google-API/script.js';
import {formatRFC3339, set, addDays, min, max, isSameDay} from '../date-fns.min.js/script.js';
import {CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN} from '/api/code/takker-memex/Google_API_for_Scrapbox/script.js';
const clientId = CLIENT_ID;
const clientSecret = CLIENT_SECRET;
const scopes = ['https://www.googleapis.com/auth/calendar.events'];
init({clientId, clientSecret, scopes});
特定の期間の予定をresetする
code:calendar.js
export async function replaceEvents(calendarId, events, {project, title}) {
// projectとtitleが一致する予定を検索する
const existEvents= await searchEvents(calendarId, {private: {project, title}});
// 既存の予定も新規作成する予定もなければ何もしない
if (events.length === 0 && existEvents.length === 0) return;
// 新しい予定を書き込む
await Promise.all(events.map(event => {
const index = existEvents.findIndex(({extendedProperties}) =>
event.extendedProperties.private.project === extendedProperties.private.project &&
event.extendedProperties.private.title === extendedProperties.private.title &&
event.extendedProperties.private.id === extendedProperties.private.id
);
if (index > -1) {
const oldEvent = existEventsindex;
// 紐付けられているとわかったeventはどんどん削っていく
existEvents.splice(index, 1);
// 変更がなければ何もしない
if (oldEvent.summary === event.summary
&& oldEvent.start.dateTime === formatRFC3339(event.start)
&& oldEvent.end.dateTime === formatRFC3339(event.end)) return;
return updateEvent(calendarId, oldEvent.id, event);
}
return createEvent(calendarId, event);
}));
// 紐付けられていない予定をすべて消す
console.log('Delete ', existEvents);
for (const {id} of existEvents) {
await deleteEvent(calendarId, id);
}
}
APIのwrapper
code:calendar.js
async function createEvent(calendarId, {summary, start, end, ...rest}) {
const res = await exec(/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
summary,
start: {dateTime: formatRFC3339(start)},
end: {dateTime: formatRFC3339(end)},
...rest,
}),
});
return await res.json();
}
async function updateEvent(calendarId, eventId, {summary, start, end, ...rest}) {
const res = await exec(/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
summary,
start: {dateTime: formatRFC3339(start)},
end: {dateTime: formatRFC3339(end)},
...rest
}),
});
return await res.json();
}
async function deleteEvent(calendarId, eventId) {
await exec(/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}, {
method: 'DELETE',
});
}
async function searchEvents(calendarId, extendedProperties) {
const params = new URLSearchParams();
Object.keys(extendedProperties.private).forEach(key =>
params.append('privateExtendedProperty', ${key}=${extendedProperties.private[key]})
);
const res = await exec(/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params.toString()});
return (await res.json()).items;
}
async function getEvents(calendarId, start, end) {
const params = new URLSearchParams();
params.append('timeMax', formatRFC3339(end));
params.append('timeMin', formatRFC3339(start));
const res = await exec(/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params.toString()});
return (await res.json()).items;
}
#2021-09-28 09:06:48
#2021-07-16 13:10:54
#2021-07-12 11:58:23
#2021-07-11
#2021-06-06 13:28:17
#2021-05-11 01:14:11
#2021-05-05 17:15:14
#2021-05-05 14:46:55
#2021-05-03 17:35:07