generated at
pin-diary-4
pin-diaryの実装その4
pin-diary-3 -> pin-diary-4 -> pin-diary-5

使い方
お試し
以下を自分のページの script.js に貼り付ける
js
await import("/api/code/villagepump/pin-diary-4/script.js");
自分以外が編集できるprojectからscriptをimportするのは危険なので、常用する際はこのコードをコピーして使ってください

実行内容
以下を井戸端にアクセスした直後に実行し、以後井戸端にいる間1日1回の周期で同様に実行する
今日の日記ページがPinされていなければPinする
今日の日記ページ自体が見つからなかったら新しく作ってそのページに移動する
今日以外の日記ページがPinされていればPinを外す

今までとの相違点
このUserScriptを使っていない人にもPinが反映されます
実装方法を完全に変えた
pin-diary-3までの方法
見た目がPin留めページになるようにPageListのDOMを操作する
今回の方法
scrapbox.ioにPin留めの指示を出す
ScrapboxのWebSocketを模倣することで無理やり指示を出している
ちなみにこれを応用すれば、任意のページを削除することもできるようになる
これは便利そう!yosiderblu3moerniogitkgshn

2022-02-02
09:44:38 日記ページのtemplateのinterface変更に追随
2022-01-19
07:06:42 相対パス→絶対パス
2021-11-11
2021-11-10
20:26:24 日付ページが見つからなければ新規作成するようにした

known issues
これめちゃくちゃ便利なんですが、1つだけ自分ではわからないことができたので質問させてくださいtkgshn
Option+Tで入力できる日付の記法が 2022/2/19 なのに対し、これで生成されるのは 2022/02/19 になってしまいます
defaultのscrapboxのタイムスタンプの書式とは確かに違いますねtakker
一番最初に日記を始めたときのformatをそのまま使い続けている
このままだと、ぱぱっと書いてる他のメモと日程表記が合わないので、できれば 2022/2/19 の方に統一したいです
変更する部分を教えてほしいです
日付処理があちこちに分散しているのよくないですね……
ここは直したい
日付のformatと日記ページのtemplateを決めている部分をひとまとめにする
根本的には、両方とも関数の外部から注入できるようにすべき

code (before bundle)
全てのコードがこのページ内で完結しています
外部コードに一切依存していません(型定義以外)
diary.ts のみ日記ページのtemplateを使っています
このtemplate内でdate-fnsを使用しています
script.ts
/// <reference no-default-lib="true"/> /// <reference lib="esnext"/> /// <reference lib="dom"/> import {listDiaries} from "./list.ts"; import {togglePin} from "./pin.ts"; import {getTemplate} from "../日記ページのtemplate/diary.ts"; import type { Scrapbox, } from "https://pax.deno.dev/scrapbox-jp/types@0.0.5"; declare const scrapbox: Scrapbox; const targetProject = 'villagepump'; const handleChange = () => scrapbox.Project.name === targetProject ? startObserve() : endObserve(); // initialize handleChange(); scrapbox.addListener("project:changed", handleChange); let updateTimer: number | undefined; async function startObserve() { endObserve(); const res = await fetch("https://scrapbox.io/api/users/me"); const {id: userId} = await res.json(); const res2 = await fetch(`https://scrapbox.io/api/projects/${targetProject}`); const {id: projectId} = await res2.json(); pinDiary(userId, projectId); updateTimer = setInterval(() => pinDiary(userId, projectId), 24 * 3600 * 1000); } function endObserve() { clearInterval(updateTimer); } async function pinDiary(userId: string, projectId: string): Promise<void> { // 今pinされている日付ページを調査する const diaryPages = await listDiaries(targetProject); const pinnedDiaryPages = diaryPages.filter(({pin}) => pin > 0); // 今日以外の日付ページを外す for (const page of pinnedDiaryPages) { if (page.title === toYYYYMMDD(new Date())) continue; await togglePin({userId, projectId, ...page}); } const todayDiaryPage = diaryPages.find(({title}) => title === toYYYYMMDD(new Date()));

日記ページがなければ、日記ページのtemplateを使ってから処理をやり直す
このときStreamにいるとURLが壊れるバグがあったので直したtakker
script.ts
if (!todayDiaryPage) { const [title, header, footer] = getTemplate(new Date()); const a = document.createElement("a"); a.href = `/${targetProject}/${ encodeURIComponent(title) }?body=${ encodeURIComponent([header, "", "", "", footer].join("\n")) }`; document.body.append(a); a.click(); a.remove();
新しい日記ページが生成されるまで待つ
これを挟まないとpin-diary-4の無限追記ループバグが発生する
script.ts
await new Promise<void>( (resolve) => scrapbox.once("page:changed", resolve), );
script.ts
return await pinDiary(userId, projectId); }

script.ts
if (todayDiaryPage.pin > 0) return; // すでにPinされていれば何もしない await togglePin({userId, projectId, ...todayDiaryPage}); } function toYYYYMMDD(date: Date) { return `${date.getFullYear()}/${zero(date.getMonth() + 1)}/${zero(date.getDate())}`; } function zero(n: number) { return String(n).padStart(2, '0'); }

Pinの付け外しをする
pin.ts
import type {PageMetaData} from "./list.ts"; import {createWS} from "./socket.ts"; export interface PinProps extends PageMetaData { userId: string; projectId: string; } const MakeTogglePinRequest = ({pin, commitId, pageId, userId, projectId}: PinProps) => `422${JSON.stringify([ "socket.io-request", { method: "commit", data: { kind: "page", parentId: commitId, changes:[{ pin: pin > 0 ? 0 : Number.MAX_SAFE_INTEGER - Math.floor(Date.now() / 1000), }], cursor: null, pageId, userId, projectId, freeze:true, }, }, ])}`; export async function togglePin(data: PinProps) { const {send, receive, close} = await createWS( "wss://scrapbox.io/socket.io/?EIO=4&transport=websocket" ); const stream = receive(); await stream.next(); // 最初の通信に返答する await send("40"); await stream.next(); // Pinを付け外しするよう命令する await send(MakeTogglePinRequest(data)); await stream.next(); // 全部の応答が返ってきたら閉じる await close(); }

日付ページの一覧を取得する
list.ts
export interface PageMetaData { title: string; pin: number; pageId: string; commitId: string; }; interface Page { title: string; pin: number; id: string; commitId: string; } export async function listDiaries(project: string) { const pages = await fetchPages(project); return pages.flatMap(page => /\d{4}\/\d{2}\/\d{2}/.test(page.title) ? [{pageId: page.id, title: page.title, commitId: page.commitId, pin: page.pin}] : [] ); }

2021-09-18 04:38:04 本当にちゃんと全部のページを読まないとだめだ
2021/09/18が1000ページ前に埋もれていたみたいで、Pinし損ねた
04:49:24 全ページを読み込むように変更した
list.ts
async function fetchPages(project: string) { const res = await fetch( `https://scrapbox.io/api/pages/${project}?limit=1` ); const { count: pageNum } = await res.json(); const limitParam = Math.min(pageNum, 1000); // APIで一度に取得するページ数 const maxIndex = Math.floor(pageNum / 1000) + 1; // APIを叩く回数 // 一気にAPIを叩いてページ情報を取得する const results = await Promise.all( [...Array(maxIndex).keys()] .map(async (index) => { const response = await fetch( `/api/pages/${ project }/?limit=${ limitParam }&skip=${index * 1000}` ); const { pages }: {pages: Page[];} = await response.json(); return pages; }) ); return results.flat(); }

WebSocketのwrapper
socket.ts
export type Return = { close: () => Promise<Event | undefined>; send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void; receive: () => AsyncGenerator<MessageEvent>; }; export function createWS(url: string, protcols?: string | string[]) { return new Promise<Return>((resolve, reject) => { const socket = new WebSocket(url, protcols); const once = ( type: keyof WebSocketEventMap, callback: (event?: Event | MessageEvent | CloseEvent) => void, ) => { const wrapper = (e: Event | MessageEvent | CloseEvent) => { callback(e); socket.removeEventListener(type, wrapper); }; socket.addEventListener(type, wrapper); }; once("error", reject); once("open", () => resolve({ close: () => new Promise((res) => { socket.close(); once("close", (e) => res(e)); }), send: (data) => { socket.send(data); }, receive: async function* () { while (true) { const response = await new Promise((res) => once("message", (e) => res(e)) ); yield response as MessageEvent; } }, })); }); }

source code
type checkには deno bundle を使った
sh
deno bundle -r https://scrapbox.io/api/code/villagepump/pin-diary-4/script.ts
2021-11-11
12:54:22 ページが重くなるので止めた
08:32:20 やっぱsource map貼ることにしてみた
2021-11-10
20:25:23 source mapは削りました
むちゃくちゃ長くなってしまったので、そのまま貼り付けるとStreamを壊してしまう
script.js
async function F(e){return(await B(e)).flatMap(r=>/\d{4}\/\d{2}\/\d{2}/.test(r.title)?[{pageId:r.id,title:r.title,commitId:r.commitId,pin:r.pin}]:[])}async function B(e){let t=await fetch(`https://scrapbox.io/api/pages/${e}?limit=1`),{count:r}=await t.json(),o=Math.min(r,1e3),s=Math.floor(r/1e3)+1;return(await Promise.all([...Array(s).keys()].map(async c=>{let u=await fetch(`/api/pages/${e}/?limit=${o}&skip=${c*1e3}`),{pages:g}=await u.json();return g}))).flat()}function L(e,t){return new Promise((r,o)=>{let s=new WebSocket(e,t),i=(c,u)=>{let g=y=>{u(y),s.removeEventListener(c,g)};s.addEventListener(c,g)};i("error",o),i("open",()=>r({close:()=>new Promise(c=>{s.close(),i("close",u=>c(u))}),send:c=>{s.send(c)},receive:async function*(){for(;;)yield await new Promise(u=>i("message",g=>u(g)))}}))})}var G=({pin:e,commitId:t,pageId:r,userId:o,projectId:s})=>`422${JSON.stringify(["socket.io-request",{method:"commit",data:{kind:"page",parentId:t,changes:[{pin:e>0?0:Number.MAX_SAFE_INTEGER-Math.floor(Date.now()/1e3)}],cursor:null,pageId:r,userId:o,projectId:s,freeze:!0}}])}`;async function A(e){let{send:t,receive:r,close:o}=await L("wss://scrapbox.io/socket.io/?EIO=4&transport=websocket"),s=r();await s.next(),await t("40"),await s.next(),await t(G(e)),await s.next(),await o()}function n(e,t){if(t.length<e)throw new TypeError(e+" argument"+(e>1?"s":"")+" required, but only "+t.length+" present")}function a(e){n(1,arguments);let t=Object.prototype.toString.call(e);return e instanceof Date||typeof e=="object"&&t==="[object Date]"?new Date(e.getTime()):typeof e=="number"||t==="[object Number]"?new Date(e):((typeof e=="string"||t==="[object String]")&&typeof console!="undefined"&&(console.warn("Starting with v2.0.0-beta.1 date-fns doesn't accept strings as date arguments. Please use `parseISO` to parse strings. See: https://git.io/fjule"),console.warn(new Error().stack)),new Date(NaN))}function _(e){n(1,arguments);let r=a(e).getFullYear();return r%400==0||r%4==0&&r%100!=0}function v(e){n(1,arguments);let t=a(e);return String(new Date(t))==="Invalid Date"?NaN:_(t)?366:365}function q(e){n(1,arguments);let t=a(e),r=new Date(0);return r.setFullYear(t.getFullYear(),0,1),r.setHours(0,0,0,0),r}function D(e){let t=new Date(Date.UTC(e.getFullYear(),e.getMonth(),e.getDate(),e.getHours(),e.getMinutes(),e.getSeconds(),e.getMilliseconds()));return t.setUTCFullYear(e.getFullYear()),e.getTime()-t.getTime()}function w(e){n(1,arguments);let t=a(e);return t.setHours(0,0,0,0),t}var J=864e5;function Y(e,t){n(2,arguments);let r=w(e),o=w(t),s=r.getTime()-D(r),i=o.getTime()-D(o);return Math.round((s-i)/J)}function T(e){n(1,arguments);let t=a(e);return Y(t,q(t))+1}function d(e){if(e===null||e===!0||e===!1)return NaN;let t=Number(e);return isNaN(t)?t:t<0?Math.ceil(t):Math.floor(t)}function l(e,t){n(2,arguments);let r=a(e),o=d(t);return isNaN(o)?new Date(NaN):(o&&r.setDate(r.getDate()+o),r)}function M(e,t){n(2,arguments);let r=d(t);return l(e,-r)}function $(e,t){n(2,arguments);let o=d(t)*7;return l(e,o)}function x(e,t){n(2,arguments);let r=d(t);return $(e,-r)}function b(e,t){n(2,arguments);let r=a(e),o=d(t);if(isNaN(o))return new Date(NaN);if(!o)return r;let s=r.getDate(),i=new Date(r.getTime());i.setMonth(r.getMonth()+o+1,0);let c=i.getDate();return s>=c?i:(r.setFullYear(i.getFullYear(),i.getMonth(),s),r)}function h(e,t){n(2,arguments);let r=d(t);return b(e,-r)}function P(e,t){n(2,arguments);let r=d(t);return b(e,r*12)}function O(e,t){n(2,arguments);let r=d(t);return P(e,-r)}function E(e){return n(1,arguments),a(e).getDay()}function k(e){return n(1,arguments),a(e).getFullYear()}function p(e,t){for(var r=e<0?"-":"",o=Math.abs(e).toString();o.length<t;)o="0"+o;return r+o}var Q={y(e,t){let r=e.getUTCFullYear(),o=r>0?r:1-r;return p(t==="yy"?o%100:o,t.length)},M(e,t){let r=e.getUTCMonth();return t==="M"?String(r+1):p(r+1,2)},d(e,t){return p(e.getUTCDate(),t.length)},a(e,t){let r=e.getUTCHours()/12>=1?"pm":"am";switch(t){case"a":case"aa":return r.toUpperCase();case"aaa":return r;case"aaaaa":return r[0];case"aaaa":default:return r==="am"?"a.m.":"p.m."}},h(e,t){return p(e.getUTCHours()%12||12,t.length)},H(e,t){return p(e.getUTCHours(),t.length)},m(e,t){return p(e.getUTCMinutes(),t.length)},s(e,t){return p(e.getUTCSeconds(),t.length)},S(e,t){let r=t.length,o=e.getUTCMilliseconds(),s=Math.floor(o*Math.pow(10,r-3));return p(s,t.length)}},R=Q;function S(e){n(1,arguments);var t=a(e);return!isNaN(t)}function j(e,t){n(2,arguments);let r=a(e).getTime(),o=d(t);return new Date(r+o)}function C(e,t){n(2,arguments);let r=d(t);return j(e,-r)}var X=/(\w)\1*|''|'(''|[^'])+('|$)|./g,K=/^'([^]*?)'?$/,ee=/''/g,te=/[a-zA-Z]/;function m(e,t){n(2,arguments);let r=a(e);if(!S(r))throw new RangeError("Invalid time value");let o=D(r),s=C(r,o),i=t.match(X);return i?i.map(u=>{if(u==="''")return"'";let g=u[0];if(g==="'")return re(u);let y=R[g];if(y)return y(s,u);if(g.match(te))throw new RangeError("Format string contains an unescaped latin alphabet character `"+g+"`");return u}).join(""):""}function re(e){let t=e.match(K);return t?t[1].replace(ee,"'"):e}var f="yyyy/MM/dd",U=e=>[m(e,f),`[${m(M(e,1),f)}.icon]←${m(e,f)}→[${m(l(e,1),f)}.icon]`,`[[${ne(e)}曜日]]`,`${k(e)}年 ${(T(e)*100/v(e)).toFixed(2)}%経過`,`[🌏 https://ja.wikipedia.org/wiki/${m(e,"M月d日")}]`,[`[${m(x(e,1),f)}.icon]`,`[${m(x(e,2),f)}.icon]`,`[${m(x(e,3),f)}.icon]`,`[${m(h(e,1),f)}.icon]`,`[${m(h(e,2),f)}.icon]`,`[${m(h(e,3),f)}.icon]`].join(" "),"今日のn年前",` [${m(O(e,1),f)}]`,"","","",`[${m(M(e,1),f)}]←${m(e,f)}→[${m(l(e,1),f)}]`].join(` `);function ne(e){switch(E(e)){case 0:return"日";case 1:return"月";case 2:return"火";case 3:return"水";case 4:return"木";case 5:return"金";case 6:return"土"}}var I="villagepump",W=()=>scrapbox.Project.name===I?oe():H();W();scrapbox.addListener("project:changed",W);var z;async function oe(){H();let e=await fetch("https://scrapbox.io/api/users/me"),{id:t}=await e.json(),r=await fetch(`https://scrapbox.io/api/projects/${I}`),{id:o}=await r.json();N(t,o),z=setInterval(()=>N(t,o),24*3600*1e3)}function H(){clearInterval(z)}async function N(e,t){let r=await F(I),o=r.filter(({pin:i})=>i>0);for(let i of o)i.title!==V(new Date)&&await A({userId:e,projectId:t,...i});let s=r.find(({title:i})=>i===V(new Date));if(!s){let[i,...c]=U(new Date).split(` `),u=document.createElement("a");return u.href=`../${I}/${encodeURIComponent(i)}?body=${encodeURIComponent(c.join(` `))}`,document.body.append(u),u.click(),u.remove(),await new Promise(g=>scrapbox.once("page:changed",g)),await N(e,t)}s.pin>0||await A({userId:e,projectId:t,...s})}function V(e){return`${e.getFullYear()}/${Z(e.getMonth()+1)}/${Z(e.getDate())}`}function Z(e){return String(e).padStart(2,"0")}