From 1daab9e4c58ffbf6173dd7cd0667760f9c0ddf00 Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 30 Oct 2026 05:16:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(emoji-usage):=20=E4=B8=8A=E4=BD=8D=205?= =?UTF-8?q?=20=E4=BB=B6=E3=82=92=E3=80=8C1=E4=BD=8D:=20:x:=20(n=E5=9B=9E)?= =?UTF-8?q?=E3=80=8D=E5=BD=A2=E5=BC=8F=E3=81=A7=E3=83=8E=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B=E6=8A=95=E7=A8=BF=E3=83=9C=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Misskey Play の「よく使うリアクション」と同じ形式 (@name の #よく使うリアクション / 1位: :x: (n回) … / 配布ページ URL) で投稿フォームを開けるようにした。1.1.0。 - postFormButton は .update できないので container で包み、集計のたびに children を差し替える。空・失敗時はボタンを消す - ウィジェットでは USER_USERNAME が空なので users/show で一度だけ取る。 "i" は account.read 権限が新たに要るため使わない (permission 変更なし)。 取れなければ「@name の」を省く - 画面の 15 件とは別に POST_TOP = 5 件だけ載せる Co-Authored-By: Claude Fable 5.1 --- public/registry/widgets.json | 13 ++++--- public/registry/widgets/emoji-usage/api.json | 2 +- .../widgets/emoji-usage/locales/en.json | 4 +- public/registry/widgets/emoji-usage/meta.json | 4 +- public/registry/widgets/emoji-usage/widget.is | 37 +++++++++++++++++++ 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/public/registry/widgets.json b/public/registry/widgets.json index 00cb9b6..8de6b29 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T23:08:30.161Z", + "updatedAt": "2026-10-29T20:15:50.606Z", "widgets": [ { "id": "aiscript-health", @@ -2464,9 +2464,9 @@ { "id": "emoji-usage", "name": "よく使うリアクション", - "version": "1.0.0", + "version": "1.1.0", "author": "@hitalin", - "description": "自分が最近付けたリアクション (users/reactions、最大 200 件) を絵文字ごとに集計し、上位 15 件を横棒グラフで表示。合計件数・種類数・期間と、このサーバーのカスタム絵文字率も併記。カスタム絵文字 (:name:) もそのまま描画。1時間ごとに自動更新。実行アカウントの userId だけを使う。トークン設定不要", + "description": "自分が最近付けたリアクション (users/reactions、最大 200 件) を絵文字ごとに集計し、上位 15 件を横棒グラフで表示。合計件数・種類数・期間と、このサーバーのカスタム絵文字率も併記。カスタム絵文字 (:name:) もそのまま描画。上位 5 件を「1位: :x: (n回)」形式でノートにする投稿ボタン付き。1時間ごとに自動更新。実行アカウントの userId だけを使う。トークン設定不要", "icon": "ti-thumb-up", "autoRun": true, "category": "stats", @@ -2483,7 +2483,7 @@ ], "sourceUrl": "https://store.notedeck.io/registry/widgets/emoji-usage/widget.is", "apiUrl": "https://store.notedeck.io/registry/widgets/emoji-usage/api.json", - "sha512": "da4e5a6947ef4e6da4467b2980176b772f5d8e63c144bbb84d9302ccc4fdf4318145b1ba9d5d066bccabaea0618cbc7be44f16b52ff04749e90fcb5ae25628ba", + "sha512": "52738f7f08da90f459a9ccc039731f297a083e4444cccd02bf34429751a5e57a7bca5d560dd4e7d46878c11842a253eeeef79f5d14f3df4d850cb57ff1f2636b", "createdAt": "2026-10-12T19:09:59+09:00", "updatedAt": "2026-10-12T19:09:59+09:00", "authorUrl": "https://github.com/hitalin", @@ -2491,7 +2491,7 @@ "locales": { "en": { "name": "Top Reactions", - "description": "Tallies the reactions you recently gave (users/reactions, up to 200) per emoji and shows the top 15 as horizontal bars, with the total count, number of distinct emojis, the covered period and the share of this server's custom emojis. Custom emojis (:name:) are rendered as-is. Refreshes hourly. Only uses the running account's userId. No token setup required." + "description": "Tallies the reactions you recently gave (users/reactions, up to 200) per emoji and shows the top 15 as horizontal bars, with the total count, number of distinct emojis, the covered period and the share of this server's custom emojis. Custom emojis (:name:) are rendered as-is. Includes a post button that drafts a note with the top 5 as \"1st: :x: (n times)\" lines. Refreshes hourly. Only uses the running account's userId. No token setup required." } }, "iconUrl": "https://store.notedeck.io/registry/widgets/emoji-usage/icon.svg", @@ -2503,7 +2503,8 @@ "permissions": [], "ndCalls": [], "mkApi": [ - "users/reactions" + "users/reactions", + "users/show" ], "hooks": [], "external": false, diff --git a/public/registry/widgets/emoji-usage/api.json b/public/registry/widgets/emoji-usage/api.json index 93a8f5c..2c3f30d 100644 --- a/public/registry/widgets/emoji-usage/api.json +++ b/public/registry/widgets/emoji-usage/api.json @@ -1 +1 @@ -{"type":"widget","data":"/// @ 1.2.1\n/// よく使うリアクションウィジェット\n/// 自分が最近付けたリアクションを users/reactions から最大 2 ページ (200 件) 取り、絵文字ごとに\n/// 集計して上位 15 件を横棒 (████▌) で並べる。users/reactions は相手が公開していないと空になるが、\n/// 自分自身 (USER_ID) なら公開設定に関係なく取れる。\n/// - カスタム絵文字の type は \":name@.:\" (ローカル) / \":name@host:\" (リモート)。ローカルは \":name:\" に\n/// 正規化して MFM に書けば描画される。リモートはそのまま書く\n/// - MFM インジェクション対策: 絵文字名は [a-zA-Z0-9_+-] と \"@\" \".\" だけを許可し、他の文字を含む type は\n/// 集計から除外する。Unicode 絵文字も ASCII (キーキャップ用の数字と # 以外) を含むものは除外する\n/// - 等幅表示に絵文字を混ぜると幅が崩れるので、バーと件数だけを $[font.monospace] に入れ、絵文字は\n/// その後ろ (等幅の外) に置く。バーは必ず 1 文字以上なので等幅部分が空白で始まることはない\n/// - 1 時間ごとに自動再取得、「更新」ボタンでいつでも\n/// 骨組みは起動時に一度だけ組み、更新は捕捉済み container / mfm の .update() に絞る。\n/// 注意: NoteDeck の Mk:api は失敗時に例外で止まる (ホストが枠にエラーを出す)。本家互換の\n/// error 値が返る環境のために Core:type(res) も見る。\n\nlet PAGE_LIMIT = 100 // users/reactions の 1 回あたりの最大件数\nlet PAGES = 2 // untilId で辿るページ数 (= 最大 200 件)\nlet TOP = 15 // 表示する絵文字の数\nlet BAR_W = 12 // バーの最大幅 (全角ブロック換算)\nlet MAX_UNICODE_LEN = 16 // Unicode 絵文字として認めるコードポイント数の上限 (家族絵文字などの結合列を許す)\nlet REFRESH_MS = 3600000 // 自動再取得の間隔 (1 時間)\n\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"86b300\"\nlet GOLD = \"ffb300\"\nlet ALERT = \"f43f5e\"\n\n// カスタム絵文字名 (host 部を含む) に許す文字\nlet NAME_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+-@.\"\n// Unicode 絵文字に混ざってよい ASCII (キーキャップ 0️⃣〜9️⃣ · #️⃣ のため)\nlet KEYCAP_CHARS = \"0123456789#\"\n\nvar fetchedAt = 0\n\n// 捕捉済みコンポーネント (起動時に一度だけ作る)\nvar heroC = null\nvar subC = null\nvar rateC = null\nvar listC = null\nvar footC = null\n\n@pad(n) { if (n < 10) `0{n}` else `{n}` }\n\n/// エポック (Date:now) → \"HH:MM\"\n@timeLabel(t) {\n `{pad(Date:hour(t))}:{pad(Date:minute(t))}`\n}\n\n/// エポック → \"MM/DD\"\n@dayLabel(t) {\n `{pad(Date:month(t))}/{pad(Date:day(t))}`\n}\n\n@divider() {\n Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]
` })\n}\n\n@emptyRow(text) {\n Ui:C:mfm({ text: `$[fg.color={MUTED} {text}]` })\n}\n\n// ─────────────────────────────\n// 絵文字 type の分類\n// ─────────────────────────────\n/// s のすべての文字が allowed に含まれるか\n@onlyChars(s, allowed) {\n let chars = s.to_unicode_arr()\n for (let i, chars.len) {\n if (!allowed.incl(chars[i])) { return false }\n }\n true\n}\n\n/// users/reactions の type → { key, kind }。表示できない (不正な文字を含む) ものは null\n/// key: MFM に書く文字列 (\":name:\" / \":name@host:\" / Unicode 絵文字)\n/// kind: \"local\" / \"remote\" / \"unicode\"\n@classify(t) {\n if (Core:type(t) != \"str\" || t.len == 0) { return null }\n if (t.starts_with(\":\") && t.ends_with(\":\") && t.len >= 3) {\n var inner = t.slice(1, t.len - 1)\n // \":name@.:\" はローカルのカスタム絵文字。\"@.\" を落として \":name:\" にする\n if (inner.ends_with(\"@.\")) { inner = inner.slice(0, inner.len - 2) }\n if (inner.len == 0 || !onlyChars(inner, NAME_CHARS)) { return null }\n let parts = inner.split(\"@\")\n if (parts.len > 2) { return null }\n if (parts.len == 2 && (parts[0].len == 0 || parts[1].len == 0)) { return null }\n let kind = if (parts.len == 2) \"remote\" else \"local\"\n return { key: `:{inner}:`, kind: kind }\n }\n // Unicode 絵文字。空白や MFM 記号 ($ [ ] < > ` @ * など) を含むものは出さない\n let chars = t.to_unicode_arr()\n if (chars.len > MAX_UNICODE_LEN) { return null }\n for (let i, chars.len) {\n let c = chars[i]\n if (c.codepoint_at(0) < 128 && !KEYCAP_CHARS.incl(c)) { return null }\n }\n { key: t, kind: \"unicode\" }\n}\n\n// ─────────────────────────────\n// 集計\n// ─────────────────────────────\n@summarize(items) {\n var counts = {}\n var total = 0\n var skipped = 0\n var local = 0\n var remote = 0\n var unicode = 0\n var newest = 0\n var oldest = 0\n for (let i, items.len) {\n let it = items[i]\n if (Core:type(it) != \"obj\" || !Obj:has(it, \"type\")) { continue }\n let c = classify(it.type)\n if (Core:type(c) == \"null\") {\n skipped += 1\n continue\n }\n total += 1\n if (c.kind == \"local\") { local += 1 } elif (c.kind == \"remote\") { remote += 1 } else { unicode += 1 }\n if (Obj:has(counts, c.key)) { counts[c.key] += 1 } else { counts[c.key] = 1 }\n if (Obj:has(it, \"createdAt\") && Core:type(it.createdAt) == \"str\") {\n // Date:parse は失敗時に error 型を返すので型でガードする\n let ts = Date:parse(it.createdAt)\n if (Core:type(ts) == \"num\") {\n if (newest == 0 || ts > newest) { newest = ts }\n if (oldest == 0 || ts < oldest) { oldest = ts }\n }\n }\n }\n var ranking = []\n let kvs = Obj:kvs(counts)\n for (let i, kvs.len) {\n ranking.push({ key: kvs[i][0], n: kvs[i][1] })\n }\n ranking.sort(@(a, b) { b.n - a.n })\n if (ranking.len > TOP) { ranking = ranking.slice(0, TOP) }\n {\n total: total\n skipped: skipped\n unique: kvs.len\n local: local\n remote: remote\n unicode: unicode\n newest: newest\n oldest: oldest\n ranking: ranking\n }\n}\n\n/// n / mx の比率を半角ブロック単位の横棒にする (最低 ▌、最大 BAR_W 文字)\n@bar(n, mx) {\n var halves = Math:round(n / mx * BAR_W * 2)\n if (halves < 1) { halves = 1 }\n var s = \"\".pad_end(Math:floor(halves / 2), \"█\")\n if (halves % 2 == 1) { s = `{s}▌` }\n s\n}\n\n// ─────────────────────────────\n// 描画 (捕捉済みコンポーネントの .update() のみ)\n// ─────────────────────────────\n@showEmpty(total, message) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} **{total}**]]
` })\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(message) ] })\n}\n\n@showRanking(s) {\n heroC.update({ text: `
$[x2 $[fg.color={GOLD} **{s.total}**]]
` })\n var period = \"期間不明\"\n if (s.oldest > 0 && s.newest > 0) { period = `{dayLabel(s.oldest)}〜{dayLabel(s.newest)}` }\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション · {s.unique} 種類 · {period}]
` })\n\n let pct = Math:round(s.local / s.total * 100)\n rateC.update({ text: `
$[fg.color={MUTED} 🎨 このサーバーのカスタム絵文字 $[fg.color={ACCENT} **{pct}%**] · リモート {s.remote} · Unicode {s.unicode}]
` })\n\n let mx = s.ranking[0].n\n let nw = `{mx}`.len\n var rows = []\n for (let i, s.ranking.len) {\n let r = s.ranking[i]\n let col = if (i == 0) GOLD else ACCENT\n let b = bar(r.n, mx)\n let gap = \"\".pad_end(BAR_W - b.len, \" \")\n let cnt = `{r.n}`.pad_start(nw, \" \")\n rows.push(Ui:C:mfm({ text: `$[font.monospace $[fg.color={col} {b}]{gap} $[fg.color={MUTED} ×]{cnt}] {r.key}` }))\n }\n listC.update({ children: rows })\n}\n\n@showFoot(fetched, skipped) {\n var note = \"\"\n if (skipped > 0) { note = ` · 表示できない {skipped} 件を除外` }\n footC.update({ text: `
$[fg.color={MUTED} 最終更新 {timeLabel(fetchedAt)} · 直近 {fetched} 件を集計{note}]
` })\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchData() {\n if (USER_ID == \"\") {\n showEmpty(0, \"実行アカウントが設定されていません\")\n return null\n }\n var items = []\n var untilId = \"\"\n var failed = false\n for (let p, PAGES) {\n var params = { userId: USER_ID, limit: PAGE_LIMIT }\n if (untilId != \"\") { params.untilId = untilId }\n let res = Mk:api(\"users/reactions\", params)\n if (Core:type(res) != \"arr\") {\n if (p == 0) { failed = true }\n break\n }\n items = items.concat(res)\n if (res.len < PAGE_LIMIT) { break }\n let last = res[res.len - 1]\n if (Core:type(last) != \"obj\" || !Obj:has(last, \"id\") || Core:type(last.id) != \"str\") { break }\n untilId = last.id\n }\n fetchedAt = Date:now()\n if (failed) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\n subC.update({ text: `
$[fg.color={ALERT} ⚠️ リアクションを取得できませんでした]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(\"カラムにアカウントが紐づいているか確認してください\") ] })\n showFoot(0, 0)\n return null\n }\n if (items.len == 0) {\n showEmpty(0, \"リアクションの取得結果が空です\")\n showFoot(0, 0)\n return null\n }\n let s = summarize(items)\n if (s.total == 0) {\n showEmpty(0, \"表示できるリアクションがありません\")\n } else {\n showRanking(s)\n }\n showFoot(items.len, s.skipped)\n}\n\n// ─────────────────────────────\n// 起動\n// ─────────────────────────────\nheroC = Ui:C:mfm({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\nsubC = Ui:C:mfm({ text: `
$[fg.color={MUTED} リアクションを読み込み中…]
` })\nrateC = Ui:C:mfm({ text: \"\" })\nlistC = Ui:C:container({ align: \"left\", children: [ Ui:C:mfm({ text: \"\" }) ] })\nfootC = Ui:C:mfm({ text: \"\" })\n\nUi:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `
$[fg.color={MUTED} 😀 よく使うリアクション · 直近 {PAGE_LIMIT * PAGES} 件]
` })\n heroC\n subC\n rateC\n divider()\n listC\n footC\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n })\n])\n\nAsync:interval(REFRESH_MS, @() { fetchData() })\n\nfetchData()\n"} +{"type":"widget","data":"/// @ 1.2.1\n/// よく使うリアクションウィジェット\n/// 自分が最近付けたリアクションを users/reactions から最大 2 ページ (200 件) 取り、絵文字ごとに\n/// 集計して上位 15 件を横棒 (████▌) で並べる。users/reactions は相手が公開していないと空になるが、\n/// 自分自身 (USER_ID) なら公開設定に関係なく取れる。\n/// - カスタム絵文字の type は \":name@.:\" (ローカル) / \":name@host:\" (リモート)。ローカルは \":name:\" に\n/// 正規化して MFM に書けば描画される。リモートはそのまま書く\n/// - MFM インジェクション対策: 絵文字名は [a-zA-Z0-9_+-] と \"@\" \".\" だけを許可し、他の文字を含む type は\n/// 集計から除外する。Unicode 絵文字も ASCII (キーキャップ用の数字と # 以外) を含むものは除外する\n/// - 等幅表示に絵文字を混ぜると幅が崩れるので、バーと件数だけを $[font.monospace] に入れ、絵文字は\n/// その後ろ (等幅の外) に置く。バーは必ず 1 文字以上なので等幅部分が空白で始まることはない\n/// - 1 時間ごとに自動再取得、「更新」ボタンでいつでも\n/// - 「ノートにする」ボタンで上位 5 件を「@me の #よく使うリアクション / 1位: :x: (n回) …」の形で投稿フォームに流す。\n/// ユーザー名は users/show (permission 不要。\"i\" は account.read が要る) で一度だけ取り、取れなければ「@me の」を省く\n/// 骨組みは起動時に一度だけ組み、更新は捕捉済み container / mfm の .update() に絞る。\n/// 注意: NoteDeck の Mk:api は失敗時に例外で止まる (ホストが枠にエラーを出す)。本家互換の\n/// error 値が返る環境のために Core:type(res) も見る。\n\nlet PAGE_LIMIT = 100 // users/reactions の 1 回あたりの最大件数\nlet PAGES = 2 // untilId で辿るページ数 (= 最大 200 件)\nlet TOP = 15 // 表示する絵文字の数\nlet BAR_W = 12 // バーの最大幅 (全角ブロック換算)\nlet MAX_UNICODE_LEN = 16 // Unicode 絵文字として認めるコードポイント数の上限 (家族絵文字などの結合列を許す)\nlet REFRESH_MS = 3600000 // 自動再取得の間隔 (1 時間)\nlet POST_TOP = 5 // ノートに載せる絵文字の数\nlet STORE_URL = \"https://store.notedeck.io/widgets/emoji-usage\" // 配布ページ (投稿に含める)\n\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"86b300\"\nlet GOLD = \"ffb300\"\nlet ALERT = \"f43f5e\"\n\n// カスタム絵文字名 (host 部を含む) に許す文字\nlet NAME_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+-@.\"\n// Unicode 絵文字に混ざってよい ASCII (キーキャップ 0️⃣〜9️⃣ · #️⃣ のため)\nlet KEYCAP_CHARS = \"0123456789#\"\n\nvar fetchedAt = 0\nvar username = \"\" // 投稿文の \"@name の\" に使う。空なら省く\n\n// 捕捉済みコンポーネント (起動時に一度だけ作る)\nvar heroC = null\nvar subC = null\nvar rateC = null\nvar listC = null\nvar footC = null\nvar postC = null // postFormButton は .update できないので container の children を差し替える\n\n@pad(n) { if (n < 10) `0{n}` else `{n}` }\n\n/// エポック (Date:now) → \"HH:MM\"\n@timeLabel(t) {\n `{pad(Date:hour(t))}:{pad(Date:minute(t))}`\n}\n\n/// エポック → \"MM/DD\"\n@dayLabel(t) {\n `{pad(Date:month(t))}/{pad(Date:day(t))}`\n}\n\n@divider() {\n Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]
` })\n}\n\n@emptyRow(text) {\n Ui:C:mfm({ text: `$[fg.color={MUTED} {text}]` })\n}\n\n// ─────────────────────────────\n// 絵文字 type の分類\n// ─────────────────────────────\n/// s のすべての文字が allowed に含まれるか\n@onlyChars(s, allowed) {\n let chars = s.to_unicode_arr()\n for (let i, chars.len) {\n if (!allowed.incl(chars[i])) { return false }\n }\n true\n}\n\n/// users/reactions の type → { key, kind }。表示できない (不正な文字を含む) ものは null\n/// key: MFM に書く文字列 (\":name:\" / \":name@host:\" / Unicode 絵文字)\n/// kind: \"local\" / \"remote\" / \"unicode\"\n@classify(t) {\n if (Core:type(t) != \"str\" || t.len == 0) { return null }\n if (t.starts_with(\":\") && t.ends_with(\":\") && t.len >= 3) {\n var inner = t.slice(1, t.len - 1)\n // \":name@.:\" はローカルのカスタム絵文字。\"@.\" を落として \":name:\" にする\n if (inner.ends_with(\"@.\")) { inner = inner.slice(0, inner.len - 2) }\n if (inner.len == 0 || !onlyChars(inner, NAME_CHARS)) { return null }\n let parts = inner.split(\"@\")\n if (parts.len > 2) { return null }\n if (parts.len == 2 && (parts[0].len == 0 || parts[1].len == 0)) { return null }\n let kind = if (parts.len == 2) \"remote\" else \"local\"\n return { key: `:{inner}:`, kind: kind }\n }\n // Unicode 絵文字。空白や MFM 記号 ($ [ ] < > ` @ * など) を含むものは出さない\n let chars = t.to_unicode_arr()\n if (chars.len > MAX_UNICODE_LEN) { return null }\n for (let i, chars.len) {\n let c = chars[i]\n if (c.codepoint_at(0) < 128 && !KEYCAP_CHARS.incl(c)) { return null }\n }\n { key: t, kind: \"unicode\" }\n}\n\n// ─────────────────────────────\n// 集計\n// ─────────────────────────────\n@summarize(items) {\n var counts = {}\n var total = 0\n var skipped = 0\n var local = 0\n var remote = 0\n var unicode = 0\n var newest = 0\n var oldest = 0\n for (let i, items.len) {\n let it = items[i]\n if (Core:type(it) != \"obj\" || !Obj:has(it, \"type\")) { continue }\n let c = classify(it.type)\n if (Core:type(c) == \"null\") {\n skipped += 1\n continue\n }\n total += 1\n if (c.kind == \"local\") { local += 1 } elif (c.kind == \"remote\") { remote += 1 } else { unicode += 1 }\n if (Obj:has(counts, c.key)) { counts[c.key] += 1 } else { counts[c.key] = 1 }\n if (Obj:has(it, \"createdAt\") && Core:type(it.createdAt) == \"str\") {\n // Date:parse は失敗時に error 型を返すので型でガードする\n let ts = Date:parse(it.createdAt)\n if (Core:type(ts) == \"num\") {\n if (newest == 0 || ts > newest) { newest = ts }\n if (oldest == 0 || ts < oldest) { oldest = ts }\n }\n }\n }\n var ranking = []\n let kvs = Obj:kvs(counts)\n for (let i, kvs.len) {\n ranking.push({ key: kvs[i][0], n: kvs[i][1] })\n }\n ranking.sort(@(a, b) { b.n - a.n })\n if (ranking.len > TOP) { ranking = ranking.slice(0, TOP) }\n {\n total: total\n skipped: skipped\n unique: kvs.len\n local: local\n remote: remote\n unicode: unicode\n newest: newest\n oldest: oldest\n ranking: ranking\n }\n}\n\n/// n / mx の比率を半角ブロック単位の横棒にする (最低 ▌、最大 BAR_W 文字)\n@bar(n, mx) {\n var halves = Math:round(n / mx * BAR_W * 2)\n if (halves < 1) { halves = 1 }\n var s = \"\".pad_end(Math:floor(halves / 2), \"█\")\n if (halves % 2 == 1) { s = `{s}▌` }\n s\n}\n\n// ─────────────────────────────\n// 描画 (捕捉済みコンポーネントの .update() のみ)\n// ─────────────────────────────\n@showEmpty(total, message) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} **{total}**]]
` })\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(message) ] })\n postC.update({ children: [ Ui:C:mfm({ text: \"\" }) ] })\n}\n\n/// 投稿文。上位 POST_TOP 件を「1位: :x: (n回)」で並べ、末尾に配布ページを付ける\n@noteText(ranking) {\n var lines = []\n lines.push(if (username != \"\") `@{username} の #よく使うリアクション` else \"#よく使うリアクション\")\n lines.push(\"\")\n let n = if (ranking.len < POST_TOP) ranking.len else POST_TOP\n for (let i, n) {\n lines.push(`{i + 1}位: {ranking[i].key} ({ranking[i].n}回)`)\n }\n lines.push(\"\")\n lines.push(STORE_URL)\n lines.join(Str:lf)\n}\n\n@showRanking(s) {\n heroC.update({ text: `
$[x2 $[fg.color={GOLD} **{s.total}**]]
` })\n var period = \"期間不明\"\n if (s.oldest > 0 && s.newest > 0) { period = `{dayLabel(s.oldest)}〜{dayLabel(s.newest)}` }\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション · {s.unique} 種類 · {period}]
` })\n\n let pct = Math:round(s.local / s.total * 100)\n rateC.update({ text: `
$[fg.color={MUTED} 🎨 このサーバーのカスタム絵文字 $[fg.color={ACCENT} **{pct}%**] · リモート {s.remote} · Unicode {s.unicode}]
` })\n\n let mx = s.ranking[0].n\n let nw = `{mx}`.len\n var rows = []\n for (let i, s.ranking.len) {\n let r = s.ranking[i]\n let col = if (i == 0) GOLD else ACCENT\n let b = bar(r.n, mx)\n let gap = \"\".pad_end(BAR_W - b.len, \" \")\n let cnt = `{r.n}`.pad_start(nw, \" \")\n rows.push(Ui:C:mfm({ text: `$[font.monospace $[fg.color={col} {b}]{gap} $[fg.color={MUTED} ×]{cnt}] {r.key}` }))\n }\n listC.update({ children: rows })\n postC.update({ children: [\n Ui:C:postFormButton({ text: \"📝 ノートにする\", rounded: true, form: { text: noteText(s.ranking) } })\n ] })\n}\n\n@showFoot(fetched, skipped) {\n var note = \"\"\n if (skipped > 0) { note = ` · 表示できない {skipped} 件を除外` }\n footC.update({ text: `
$[fg.color={MUTED} 最終更新 {timeLabel(fetchedAt)} · 直近 {fetched} 件を集計{note}]
` })\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 自分の username を一度だけ取る (ウィジェットでは USER_USERNAME が空)。失敗しても集計は続ける\n@loadUsername() {\n if (username != \"\") { return null }\n let me = Mk:api(\"users/show\", { userId: USER_ID })\n if (Core:type(me) == \"obj\" && Obj:has(me, \"username\") && Core:type(me.username) == \"str\") {\n username = me.username\n }\n}\n\n@fetchData() {\n if (USER_ID == \"\") {\n showEmpty(0, \"実行アカウントが設定されていません\")\n return null\n }\n loadUsername()\n var items = []\n var untilId = \"\"\n var failed = false\n for (let p, PAGES) {\n var params = { userId: USER_ID, limit: PAGE_LIMIT }\n if (untilId != \"\") { params.untilId = untilId }\n let res = Mk:api(\"users/reactions\", params)\n if (Core:type(res) != \"arr\") {\n if (p == 0) { failed = true }\n break\n }\n items = items.concat(res)\n if (res.len < PAGE_LIMIT) { break }\n let last = res[res.len - 1]\n if (Core:type(last) != \"obj\" || !Obj:has(last, \"id\") || Core:type(last.id) != \"str\") { break }\n untilId = last.id\n }\n fetchedAt = Date:now()\n if (failed) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\n subC.update({ text: `
$[fg.color={ALERT} ⚠️ リアクションを取得できませんでした]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(\"カラムにアカウントが紐づいているか確認してください\") ] })\n postC.update({ children: [ Ui:C:mfm({ text: \"\" }) ] })\n showFoot(0, 0)\n return null\n }\n if (items.len == 0) {\n showEmpty(0, \"リアクションの取得結果が空です\")\n showFoot(0, 0)\n return null\n }\n let s = summarize(items)\n if (s.total == 0) {\n showEmpty(0, \"表示できるリアクションがありません\")\n } else {\n showRanking(s)\n }\n showFoot(items.len, s.skipped)\n}\n\n// ─────────────────────────────\n// 起動\n// ─────────────────────────────\nheroC = Ui:C:mfm({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\nsubC = Ui:C:mfm({ text: `
$[fg.color={MUTED} リアクションを読み込み中…]
` })\nrateC = Ui:C:mfm({ text: \"\" })\nlistC = Ui:C:container({ align: \"left\", children: [ Ui:C:mfm({ text: \"\" }) ] })\nfootC = Ui:C:mfm({ text: \"\" })\npostC = Ui:C:container({ align: \"center\", children: [ Ui:C:mfm({ text: \"\" }) ] })\n\nUi:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `
$[fg.color={MUTED} 😀 よく使うリアクション · 直近 {PAGE_LIMIT * PAGES} 件]
` })\n heroC\n subC\n rateC\n divider()\n listC\n footC\n postC\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n })\n])\n\nAsync:interval(REFRESH_MS, @() { fetchData() })\n\nfetchData()\n"} diff --git a/public/registry/widgets/emoji-usage/locales/en.json b/public/registry/widgets/emoji-usage/locales/en.json index 77432bd..d1b11c9 100644 --- a/public/registry/widgets/emoji-usage/locales/en.json +++ b/public/registry/widgets/emoji-usage/locales/en.json @@ -1,5 +1,5 @@ { - "sourceHash": "05f5d7a474cd", + "sourceHash": "f7dc7b394a3d", "name": "Top Reactions", - "description": "Tallies the reactions you recently gave (users/reactions, up to 200) per emoji and shows the top 15 as horizontal bars, with the total count, number of distinct emojis, the covered period and the share of this server's custom emojis. Custom emojis (:name:) are rendered as-is. Refreshes hourly. Only uses the running account's userId. No token setup required." + "description": "Tallies the reactions you recently gave (users/reactions, up to 200) per emoji and shows the top 15 as horizontal bars, with the total count, number of distinct emojis, the covered period and the share of this server's custom emojis. Custom emojis (:name:) are rendered as-is. Includes a post button that drafts a note with the top 5 as \"1st: :x: (n times)\" lines. Refreshes hourly. Only uses the running account's userId. No token setup required." } diff --git a/public/registry/widgets/emoji-usage/meta.json b/public/registry/widgets/emoji-usage/meta.json index f4ab51e..c6f218c 100644 --- a/public/registry/widgets/emoji-usage/meta.json +++ b/public/registry/widgets/emoji-usage/meta.json @@ -1,10 +1,10 @@ { "id": "emoji-usage", "name": "よく使うリアクション", - "version": "1.0.0", + "version": "1.1.0", "author": "@hitalin", "authorUrl": "https://github.com/hitalin", - "description": "自分が最近付けたリアクション (users/reactions、最大 200 件) を絵文字ごとに集計し、上位 15 件を横棒グラフで表示。合計件数・種類数・期間と、このサーバーのカスタム絵文字率も併記。カスタム絵文字 (:name:) もそのまま描画。1時間ごとに自動更新。実行アカウントの userId だけを使う。トークン設定不要", + "description": "自分が最近付けたリアクション (users/reactions、最大 200 件) を絵文字ごとに集計し、上位 15 件を横棒グラフで表示。合計件数・種類数・期間と、このサーバーのカスタム絵文字率も併記。カスタム絵文字 (:name:) もそのまま描画。上位 5 件を「1位: :x: (n回)」形式でノートにする投稿ボタン付き。1時間ごとに自動更新。実行アカウントの userId だけを使う。トークン設定不要", "icon": "ti-thumb-up", "autoRun": true, "category": "stats", diff --git a/public/registry/widgets/emoji-usage/widget.is b/public/registry/widgets/emoji-usage/widget.is index f4deef8..d6c7dc6 100644 --- a/public/registry/widgets/emoji-usage/widget.is +++ b/public/registry/widgets/emoji-usage/widget.is @@ -10,6 +10,8 @@ /// - 等幅表示に絵文字を混ぜると幅が崩れるので、バーと件数だけを $[font.monospace] に入れ、絵文字は /// その後ろ (等幅の外) に置く。バーは必ず 1 文字以上なので等幅部分が空白で始まることはない /// - 1 時間ごとに自動再取得、「更新」ボタンでいつでも +/// - 「ノートにする」ボタンで上位 5 件を「@me の #よく使うリアクション / 1位: :x: (n回) …」の形で投稿フォームに流す。 +/// ユーザー名は users/show (permission 不要。"i" は account.read が要る) で一度だけ取り、取れなければ「@me の」を省く /// 骨組みは起動時に一度だけ組み、更新は捕捉済み container / mfm の .update() に絞る。 /// 注意: NoteDeck の Mk:api は失敗時に例外で止まる (ホストが枠にエラーを出す)。本家互換の /// error 値が返る環境のために Core:type(res) も見る。 @@ -20,6 +22,8 @@ let TOP = 15 // 表示する絵文字の数 let BAR_W = 12 // バーの最大幅 (全角ブロック換算) let MAX_UNICODE_LEN = 16 // Unicode 絵文字として認めるコードポイント数の上限 (家族絵文字などの結合列を許す) let REFRESH_MS = 3600000 // 自動再取得の間隔 (1 時間) +let POST_TOP = 5 // ノートに載せる絵文字の数 +let STORE_URL = "https://store.notedeck.io/widgets/emoji-usage" // 配布ページ (投稿に含める) let MUTED = "8a96a3" let LINE = "5a6573" @@ -33,6 +37,7 @@ let NAME_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 let KEYCAP_CHARS = "0123456789#" var fetchedAt = 0 +var username = "" // 投稿文の "@name の" に使う。空なら省く // 捕捉済みコンポーネント (起動時に一度だけ作る) var heroC = null @@ -40,6 +45,7 @@ var subC = null var rateC = null var listC = null var footC = null +var postC = null // postFormButton は .update できないので container の children を差し替える @pad(n) { if (n < 10) `0{n}` else `{n}` } @@ -168,6 +174,21 @@ var footC = null subC.update({ text: `
$[fg.color={MUTED} 件のリアクション]
` }) rateC.update({ text: "" }) listC.update({ children: [ emptyRow(message) ] }) + postC.update({ children: [ Ui:C:mfm({ text: "" }) ] }) +} + +/// 投稿文。上位 POST_TOP 件を「1位: :x: (n回)」で並べ、末尾に配布ページを付ける +@noteText(ranking) { + var lines = [] + lines.push(if (username != "") `@{username} の #よく使うリアクション` else "#よく使うリアクション") + lines.push("") + let n = if (ranking.len < POST_TOP) ranking.len else POST_TOP + for (let i, n) { + lines.push(`{i + 1}位: {ranking[i].key} ({ranking[i].n}回)`) + } + lines.push("") + lines.push(STORE_URL) + lines.join(Str:lf) } @showRanking(s) { @@ -191,6 +212,9 @@ var footC = null rows.push(Ui:C:mfm({ text: `$[font.monospace $[fg.color={col} {b}]{gap} $[fg.color={MUTED} ×]{cnt}] {r.key}` })) } listC.update({ children: rows }) + postC.update({ children: [ + Ui:C:postFormButton({ text: "📝 ノートにする", rounded: true, form: { text: noteText(s.ranking) } }) + ] }) } @showFoot(fetched, skipped) { @@ -202,11 +226,21 @@ var footC = null // ───────────────────────────── // データ取得 // ───────────────────────────── +/// 自分の username を一度だけ取る (ウィジェットでは USER_USERNAME が空)。失敗しても集計は続ける +@loadUsername() { + if (username != "") { return null } + let me = Mk:api("users/show", { userId: USER_ID }) + if (Core:type(me) == "obj" && Obj:has(me, "username") && Core:type(me.username) == "str") { + username = me.username + } +} + @fetchData() { if (USER_ID == "") { showEmpty(0, "実行アカウントが設定されていません") return null } + loadUsername() var items = [] var untilId = "" var failed = false @@ -230,6 +264,7 @@ var footC = null subC.update({ text: `
$[fg.color={ALERT} ⚠️ リアクションを取得できませんでした]
` }) rateC.update({ text: "" }) listC.update({ children: [ emptyRow("カラムにアカウントが紐づいているか確認してください") ] }) + postC.update({ children: [ Ui:C:mfm({ text: "" }) ] }) showFoot(0, 0) return null } @@ -255,6 +290,7 @@ subC = Ui:C:mfm({ text: `
$[fg.color={MUTED} リアクションを rateC = Ui:C:mfm({ text: "" }) listC = Ui:C:container({ align: "left", children: [ Ui:C:mfm({ text: "" }) ] }) footC = Ui:C:mfm({ text: "" }) +postC = Ui:C:container({ align: "center", children: [ Ui:C:mfm({ text: "" }) ] }) Ui:render([ Ui:C:container({ @@ -268,6 +304,7 @@ Ui:render([ divider() listC footC + postC Ui:C:container({ align: "center", children: [ Ui:C:button({ text: "🔄 更新", rounded: true, onClick: @() { fetchData() } }) ] }) From 7a149971feeb1162c6d0df620b8357494bbbe82b Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 30 Oct 2026 05:42:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(emoji-usage):=20username=20=E5=8F=96?= =?UTF-8?q?=E5=BE=97=E3=82=92=E3=83=A9=E3=83=B3=E3=82=AD=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E6=8F=8F=E7=94=BB=E3=81=AE=E5=BE=8C=E3=81=AB=E5=9B=9E=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mk:api は失敗時に throw し AiScript に try/catch が無いので、users/show を users/reactions の前に置くとその tick の描画が丸ごと止まっていた。 リアクション取得 → ランキング・footer 描画 → users/show → 投稿ボタンの順に 変え、users/show が失敗しても欠けるのは投稿ボタンだけにする。ウィジェットは abortOnError: false (notedeck#1294) なので次回 tick で再試行される。 Co-Authored-By: Claude Fable 5.1 --- public/registry/widgets.json | 48 +++++++++---------- public/registry/widgets/emoji-usage/api.json | 2 +- public/registry/widgets/emoji-usage/widget.is | 12 +++-- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/public/registry/widgets.json b/public/registry/widgets.json index 03ef410..fdfc12e 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-29T20:41:55.111Z", + "updatedAt": "2026-10-29T20:42:21.909Z", "widgets": [ { "id": "aiscript-health", @@ -80,7 +80,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/atcoder/api.json", "sha512": "5e61fedc9b2670e8f080692244fb7347948bac5f51fa59f9bcf9a871d1e1469c36ff5f6cbfca6b326582a6a5579edb51448d93e422a23ad8625e275219c630bf", "createdAt": "2026-07-01T08:50:22+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -280,7 +280,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/ctftime/api.json", "sha512": "bea143bec262967466a900d4938eb17188a11d6cd6a0d57d5584cbd6cc91c10b4d12709edf85f921848101180cbe8742afe17f0b47cd895a8174aa13cd374c05", "createdAt": "2026-07-01T09:04:06+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -327,7 +327,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/cve/api.json", "sha512": "64653b7063f6399c47bdbc958fcdeb62a123b2299c276652ed701739b89df58340464eb621258e974713cc3981f8baf4492a6542d89f070f080a802f738d2dac", "createdAt": "2026-06-25T14:23:09+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -375,7 +375,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/discord-online/api.json", "sha512": "1a54f8e3874e961a8cf5d4f5a5bc70fa13ada77680c7efe7755d82d5c5601064ab5367d1555ec2a5e83dd3cbb117b50e177dea79d3cd2a8c5cd1c779d09fa655", "createdAt": "2026-06-30T19:04:56+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -424,7 +424,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/discord-presence/api.json", "sha512": "584fd06986b023c3075dd8fba0d6cc71739971fe230e6ce615f6eec919935e583fbe8211e9bea278a6c713d6db437ebc32e5729bdb6f439abc98d390ab03b576", "createdAt": "2026-10-29T04:15:03+09:00", - "updatedAt": "2026-10-29T04:25:33+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -570,7 +570,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/listenbrainz/api.json", "sha512": "771079ee3b41ecfbf66241985c52f89fd82a6d51d405f9b0267732c43674f3bf233a3a2b334fd5cec6165396b983852c678b3b54ea4114123010a3d8791407a9", "createdAt": "2026-05-14T15:16:09+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -667,7 +667,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/minecraft/api.json", "sha512": "e83e672e36ce3cf4b1de3e6a0d436d90311794739f9ca28826cab73ed77440f8f1539b360828d36e665aeed5014c7f790a4561f98aa0d0b542ed577630a113ef", "createdAt": "2026-06-30T18:46:47+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -715,7 +715,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/misskey-online/api.json", "sha512": "429a7a8c5db21a0aa1a089127bf0ac175f027dc04554cd259126a2df9371fa0fc10facc862a1add35683da20d58f1e4f6f2544ae2d763a99fb2b8720ce705e80", "createdAt": "2026-06-30T19:07:19+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -764,7 +764,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/api.json", "sha512": "c8a34a3667987b74cd5f805897fb48c61f7ed32837c911bfd84e251651e215918504e5d0034cc312764c4f4db39867669e58cd80c298c3c874b4962b55729f73", "createdAt": "2026-08-15T03:32:33+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -813,7 +813,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/okj-prices/api.json", "sha512": "8ecc733d17084b9118fda2087ed7bc5756fac175ae5b4ba471520ad00b511c2825a382ad66bc24754150595431e40cceedeaeb9f5dfb37ffe1e52a3f8e0d7af4", "createdAt": "2026-07-20T08:37:27+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -913,7 +913,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/api.json", "sha512": "eb449f4ba3db1394758e1e1044e36c9c733700ab74875728fa6dad9213d752dcbcd46839e3faaf217565a08254e43c9c9faa7b7142a92b85ef56ddb2a74543df", "createdAt": "2026-08-15T03:33:09+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -1061,7 +1061,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/soundcloud-feed/api.json", "sha512": "cb8d4df7b017fdd6eed369171d906a84c5556727654bde796db8376b0a3a2e9d7c1a638490d288e41afe113f3cc9db7e789cad839ae254aca4fcd654641e444d", "createdAt": "2026-10-28T21:24:46+09:00", - "updatedAt": "2026-10-28T21:24:46+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -1304,7 +1304,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/zenn/api.json", "sha512": "e5b48dfb90905efa539ed20ddd45aa7191064092c44c402742300cfabefb783d60c601fd09162ee9065b9d78f041916aa82259663341b776bfc1e0fc4a52aec5", "createdAt": "2026-07-01T08:40:14+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -1997,7 +1997,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/nicolive-watch/api.json", "sha512": "4eb2b4c799a159db05a08a8f410d19d677f51c214262ab75e01971744e97d789e4c37106018bf937e97d2f72d949881d075718b50bfd520819a8ea68a67a491a", "createdAt": "2026-10-28T21:24:46+09:00", - "updatedAt": "2026-10-28T21:24:46+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -2151,7 +2151,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/vocaloid-trending/api.json", "sha512": "54ff05ac29d70c56b54342dbfdd915e9ed31093460dfc3ce7e11beb1d31b2b6e9e3500ba8425d6961061e2e5b784db2077b2a928903bfeefa1425eaf8adcf4c5", "createdAt": "2026-10-28T21:24:46+09:00", - "updatedAt": "2026-10-28T21:24:46+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -2483,7 +2483,7 @@ ], "sourceUrl": "https://store.notedeck.io/registry/widgets/emoji-usage/widget.is", "apiUrl": "https://store.notedeck.io/registry/widgets/emoji-usage/api.json", - "sha512": "52738f7f08da90f459a9ccc039731f297a083e4444cccd02bf34429751a5e57a7bca5d560dd4e7d46878c11842a253eeeef79f5d14f3df4d850cb57ff1f2636b", + "sha512": "af81b93456a39950440310815fe23d8b05d97b2399f4474033d0a112e1d5ef1f6d4eb63aa2123fb5ff767ae1a71cfea8005699248dfe0c401f929203c82fd692", "createdAt": "2026-10-12T19:09:59+09:00", "updatedAt": "2026-10-30T05:16:25+09:00", "authorUrl": "https://github.com/hitalin", @@ -2631,7 +2631,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/github-releases/api.json", "sha512": "195685a3cba897dc4ad1ef1e8a25303b6011365b702e4f71a54646819be866c4177480ec75d462f24f366bd277c6b61e73c8cc43989803cc669a158e18dbd796", "createdAt": "2026-06-19T16:01:13+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -2823,7 +2823,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/earthquake/api.json", "sha512": "26d80040f0f2ff2e63c36c5f34c6e257881173d1fbefb826a8536e4dd33daa54c2a4b9db37cd75ddb6e151c69ffe8c1a436c1da73864a751a18f8b9b75b52e95", "createdAt": "2026-06-18T15:05:26+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -2869,7 +2869,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/weather/api.json", "sha512": "220fcb9abfc7c867c4587b7406d854c367767aaf4b5709ed08ee92a5a5808ab081277a416df48868add508e92e40a19465be34e2d2c1cacb1f22a006dd028466", "createdAt": "2026-06-18T14:16:28+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -3075,7 +3075,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/ink-new-servers/api.json", "sha512": "e9b08b989ba0512086627881bc509052c39bad98c9f851aceb4ad15937e292c3e493e7c9a71936eb4788ca158047fe5ebb7b21197e422ecc0f72bba341473e1f", "createdAt": "2026-07-07T17:34:41+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -3122,7 +3122,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/crypto/api.json", "sha512": "0a3363e7dedcdf0ec2b2fa8409e6b73a628280021714fb360555b2484f7143afa67c5e025ed133530e7f2198dd0f1be9406214c73afbc0043add4581d0eacdd7", "createdAt": "2026-06-23T12:10:19+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -3170,7 +3170,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/stocks/api.json", "sha512": "763a6d0f31d58491d9966c161b5b763d60cb710884d4d5e4852f47a5d76c2dfe7847489b274a353004635cf6f4cee476b8d04633ed90b9e21e6b9f19d0da2035", "createdAt": "2026-06-23T13:02:37+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -3429,7 +3429,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/joinmisskey-stats/api.json", "sha512": "0b39151ebfcee2fe8233ec057250d753f35bf6c3164fe36182e5e4a402aa91a19b8021fb72a3f7431c3b325e96d9a2ad70935c905ace276374301822edf90812", "createdAt": "2026-07-07T17:13:35+09:00", - "updatedAt": "2026-09-25T18:20:04+09:00", + "updatedAt": "2026-10-30T04:41:03+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { diff --git a/public/registry/widgets/emoji-usage/api.json b/public/registry/widgets/emoji-usage/api.json index 2c3f30d..d980df4 100644 --- a/public/registry/widgets/emoji-usage/api.json +++ b/public/registry/widgets/emoji-usage/api.json @@ -1 +1 @@ -{"type":"widget","data":"/// @ 1.2.1\n/// よく使うリアクションウィジェット\n/// 自分が最近付けたリアクションを users/reactions から最大 2 ページ (200 件) 取り、絵文字ごとに\n/// 集計して上位 15 件を横棒 (████▌) で並べる。users/reactions は相手が公開していないと空になるが、\n/// 自分自身 (USER_ID) なら公開設定に関係なく取れる。\n/// - カスタム絵文字の type は \":name@.:\" (ローカル) / \":name@host:\" (リモート)。ローカルは \":name:\" に\n/// 正規化して MFM に書けば描画される。リモートはそのまま書く\n/// - MFM インジェクション対策: 絵文字名は [a-zA-Z0-9_+-] と \"@\" \".\" だけを許可し、他の文字を含む type は\n/// 集計から除外する。Unicode 絵文字も ASCII (キーキャップ用の数字と # 以外) を含むものは除外する\n/// - 等幅表示に絵文字を混ぜると幅が崩れるので、バーと件数だけを $[font.monospace] に入れ、絵文字は\n/// その後ろ (等幅の外) に置く。バーは必ず 1 文字以上なので等幅部分が空白で始まることはない\n/// - 1 時間ごとに自動再取得、「更新」ボタンでいつでも\n/// - 「ノートにする」ボタンで上位 5 件を「@me の #よく使うリアクション / 1位: :x: (n回) …」の形で投稿フォームに流す。\n/// ユーザー名は users/show (permission 不要。\"i\" は account.read が要る) で一度だけ取り、取れなければ「@me の」を省く\n/// 骨組みは起動時に一度だけ組み、更新は捕捉済み container / mfm の .update() に絞る。\n/// 注意: NoteDeck の Mk:api は失敗時に例外で止まる (ホストが枠にエラーを出す)。本家互換の\n/// error 値が返る環境のために Core:type(res) も見る。\n\nlet PAGE_LIMIT = 100 // users/reactions の 1 回あたりの最大件数\nlet PAGES = 2 // untilId で辿るページ数 (= 最大 200 件)\nlet TOP = 15 // 表示する絵文字の数\nlet BAR_W = 12 // バーの最大幅 (全角ブロック換算)\nlet MAX_UNICODE_LEN = 16 // Unicode 絵文字として認めるコードポイント数の上限 (家族絵文字などの結合列を許す)\nlet REFRESH_MS = 3600000 // 自動再取得の間隔 (1 時間)\nlet POST_TOP = 5 // ノートに載せる絵文字の数\nlet STORE_URL = \"https://store.notedeck.io/widgets/emoji-usage\" // 配布ページ (投稿に含める)\n\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"86b300\"\nlet GOLD = \"ffb300\"\nlet ALERT = \"f43f5e\"\n\n// カスタム絵文字名 (host 部を含む) に許す文字\nlet NAME_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+-@.\"\n// Unicode 絵文字に混ざってよい ASCII (キーキャップ 0️⃣〜9️⃣ · #️⃣ のため)\nlet KEYCAP_CHARS = \"0123456789#\"\n\nvar fetchedAt = 0\nvar username = \"\" // 投稿文の \"@name の\" に使う。空なら省く\n\n// 捕捉済みコンポーネント (起動時に一度だけ作る)\nvar heroC = null\nvar subC = null\nvar rateC = null\nvar listC = null\nvar footC = null\nvar postC = null // postFormButton は .update できないので container の children を差し替える\n\n@pad(n) { if (n < 10) `0{n}` else `{n}` }\n\n/// エポック (Date:now) → \"HH:MM\"\n@timeLabel(t) {\n `{pad(Date:hour(t))}:{pad(Date:minute(t))}`\n}\n\n/// エポック → \"MM/DD\"\n@dayLabel(t) {\n `{pad(Date:month(t))}/{pad(Date:day(t))}`\n}\n\n@divider() {\n Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]
` })\n}\n\n@emptyRow(text) {\n Ui:C:mfm({ text: `$[fg.color={MUTED} {text}]` })\n}\n\n// ─────────────────────────────\n// 絵文字 type の分類\n// ─────────────────────────────\n/// s のすべての文字が allowed に含まれるか\n@onlyChars(s, allowed) {\n let chars = s.to_unicode_arr()\n for (let i, chars.len) {\n if (!allowed.incl(chars[i])) { return false }\n }\n true\n}\n\n/// users/reactions の type → { key, kind }。表示できない (不正な文字を含む) ものは null\n/// key: MFM に書く文字列 (\":name:\" / \":name@host:\" / Unicode 絵文字)\n/// kind: \"local\" / \"remote\" / \"unicode\"\n@classify(t) {\n if (Core:type(t) != \"str\" || t.len == 0) { return null }\n if (t.starts_with(\":\") && t.ends_with(\":\") && t.len >= 3) {\n var inner = t.slice(1, t.len - 1)\n // \":name@.:\" はローカルのカスタム絵文字。\"@.\" を落として \":name:\" にする\n if (inner.ends_with(\"@.\")) { inner = inner.slice(0, inner.len - 2) }\n if (inner.len == 0 || !onlyChars(inner, NAME_CHARS)) { return null }\n let parts = inner.split(\"@\")\n if (parts.len > 2) { return null }\n if (parts.len == 2 && (parts[0].len == 0 || parts[1].len == 0)) { return null }\n let kind = if (parts.len == 2) \"remote\" else \"local\"\n return { key: `:{inner}:`, kind: kind }\n }\n // Unicode 絵文字。空白や MFM 記号 ($ [ ] < > ` @ * など) を含むものは出さない\n let chars = t.to_unicode_arr()\n if (chars.len > MAX_UNICODE_LEN) { return null }\n for (let i, chars.len) {\n let c = chars[i]\n if (c.codepoint_at(0) < 128 && !KEYCAP_CHARS.incl(c)) { return null }\n }\n { key: t, kind: \"unicode\" }\n}\n\n// ─────────────────────────────\n// 集計\n// ─────────────────────────────\n@summarize(items) {\n var counts = {}\n var total = 0\n var skipped = 0\n var local = 0\n var remote = 0\n var unicode = 0\n var newest = 0\n var oldest = 0\n for (let i, items.len) {\n let it = items[i]\n if (Core:type(it) != \"obj\" || !Obj:has(it, \"type\")) { continue }\n let c = classify(it.type)\n if (Core:type(c) == \"null\") {\n skipped += 1\n continue\n }\n total += 1\n if (c.kind == \"local\") { local += 1 } elif (c.kind == \"remote\") { remote += 1 } else { unicode += 1 }\n if (Obj:has(counts, c.key)) { counts[c.key] += 1 } else { counts[c.key] = 1 }\n if (Obj:has(it, \"createdAt\") && Core:type(it.createdAt) == \"str\") {\n // Date:parse は失敗時に error 型を返すので型でガードする\n let ts = Date:parse(it.createdAt)\n if (Core:type(ts) == \"num\") {\n if (newest == 0 || ts > newest) { newest = ts }\n if (oldest == 0 || ts < oldest) { oldest = ts }\n }\n }\n }\n var ranking = []\n let kvs = Obj:kvs(counts)\n for (let i, kvs.len) {\n ranking.push({ key: kvs[i][0], n: kvs[i][1] })\n }\n ranking.sort(@(a, b) { b.n - a.n })\n if (ranking.len > TOP) { ranking = ranking.slice(0, TOP) }\n {\n total: total\n skipped: skipped\n unique: kvs.len\n local: local\n remote: remote\n unicode: unicode\n newest: newest\n oldest: oldest\n ranking: ranking\n }\n}\n\n/// n / mx の比率を半角ブロック単位の横棒にする (最低 ▌、最大 BAR_W 文字)\n@bar(n, mx) {\n var halves = Math:round(n / mx * BAR_W * 2)\n if (halves < 1) { halves = 1 }\n var s = \"\".pad_end(Math:floor(halves / 2), \"█\")\n if (halves % 2 == 1) { s = `{s}▌` }\n s\n}\n\n// ─────────────────────────────\n// 描画 (捕捉済みコンポーネントの .update() のみ)\n// ─────────────────────────────\n@showEmpty(total, message) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} **{total}**]]
` })\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(message) ] })\n postC.update({ children: [ Ui:C:mfm({ text: \"\" }) ] })\n}\n\n/// 投稿文。上位 POST_TOP 件を「1位: :x: (n回)」で並べ、末尾に配布ページを付ける\n@noteText(ranking) {\n var lines = []\n lines.push(if (username != \"\") `@{username} の #よく使うリアクション` else \"#よく使うリアクション\")\n lines.push(\"\")\n let n = if (ranking.len < POST_TOP) ranking.len else POST_TOP\n for (let i, n) {\n lines.push(`{i + 1}位: {ranking[i].key} ({ranking[i].n}回)`)\n }\n lines.push(\"\")\n lines.push(STORE_URL)\n lines.join(Str:lf)\n}\n\n@showRanking(s) {\n heroC.update({ text: `
$[x2 $[fg.color={GOLD} **{s.total}**]]
` })\n var period = \"期間不明\"\n if (s.oldest > 0 && s.newest > 0) { period = `{dayLabel(s.oldest)}〜{dayLabel(s.newest)}` }\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション · {s.unique} 種類 · {period}]
` })\n\n let pct = Math:round(s.local / s.total * 100)\n rateC.update({ text: `
$[fg.color={MUTED} 🎨 このサーバーのカスタム絵文字 $[fg.color={ACCENT} **{pct}%**] · リモート {s.remote} · Unicode {s.unicode}]
` })\n\n let mx = s.ranking[0].n\n let nw = `{mx}`.len\n var rows = []\n for (let i, s.ranking.len) {\n let r = s.ranking[i]\n let col = if (i == 0) GOLD else ACCENT\n let b = bar(r.n, mx)\n let gap = \"\".pad_end(BAR_W - b.len, \" \")\n let cnt = `{r.n}`.pad_start(nw, \" \")\n rows.push(Ui:C:mfm({ text: `$[font.monospace $[fg.color={col} {b}]{gap} $[fg.color={MUTED} ×]{cnt}] {r.key}` }))\n }\n listC.update({ children: rows })\n postC.update({ children: [\n Ui:C:postFormButton({ text: \"📝 ノートにする\", rounded: true, form: { text: noteText(s.ranking) } })\n ] })\n}\n\n@showFoot(fetched, skipped) {\n var note = \"\"\n if (skipped > 0) { note = ` · 表示できない {skipped} 件を除外` }\n footC.update({ text: `
$[fg.color={MUTED} 最終更新 {timeLabel(fetchedAt)} · 直近 {fetched} 件を集計{note}]
` })\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 自分の username を一度だけ取る (ウィジェットでは USER_USERNAME が空)。失敗しても集計は続ける\n@loadUsername() {\n if (username != \"\") { return null }\n let me = Mk:api(\"users/show\", { userId: USER_ID })\n if (Core:type(me) == \"obj\" && Obj:has(me, \"username\") && Core:type(me.username) == \"str\") {\n username = me.username\n }\n}\n\n@fetchData() {\n if (USER_ID == \"\") {\n showEmpty(0, \"実行アカウントが設定されていません\")\n return null\n }\n loadUsername()\n var items = []\n var untilId = \"\"\n var failed = false\n for (let p, PAGES) {\n var params = { userId: USER_ID, limit: PAGE_LIMIT }\n if (untilId != \"\") { params.untilId = untilId }\n let res = Mk:api(\"users/reactions\", params)\n if (Core:type(res) != \"arr\") {\n if (p == 0) { failed = true }\n break\n }\n items = items.concat(res)\n if (res.len < PAGE_LIMIT) { break }\n let last = res[res.len - 1]\n if (Core:type(last) != \"obj\" || !Obj:has(last, \"id\") || Core:type(last.id) != \"str\") { break }\n untilId = last.id\n }\n fetchedAt = Date:now()\n if (failed) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\n subC.update({ text: `
$[fg.color={ALERT} ⚠️ リアクションを取得できませんでした]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(\"カラムにアカウントが紐づいているか確認してください\") ] })\n postC.update({ children: [ Ui:C:mfm({ text: \"\" }) ] })\n showFoot(0, 0)\n return null\n }\n if (items.len == 0) {\n showEmpty(0, \"リアクションの取得結果が空です\")\n showFoot(0, 0)\n return null\n }\n let s = summarize(items)\n if (s.total == 0) {\n showEmpty(0, \"表示できるリアクションがありません\")\n } else {\n showRanking(s)\n }\n showFoot(items.len, s.skipped)\n}\n\n// ─────────────────────────────\n// 起動\n// ─────────────────────────────\nheroC = Ui:C:mfm({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\nsubC = Ui:C:mfm({ text: `
$[fg.color={MUTED} リアクションを読み込み中…]
` })\nrateC = Ui:C:mfm({ text: \"\" })\nlistC = Ui:C:container({ align: \"left\", children: [ Ui:C:mfm({ text: \"\" }) ] })\nfootC = Ui:C:mfm({ text: \"\" })\npostC = Ui:C:container({ align: \"center\", children: [ Ui:C:mfm({ text: \"\" }) ] })\n\nUi:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `
$[fg.color={MUTED} 😀 よく使うリアクション · 直近 {PAGE_LIMIT * PAGES} 件]
` })\n heroC\n subC\n rateC\n divider()\n listC\n footC\n postC\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n })\n])\n\nAsync:interval(REFRESH_MS, @() { fetchData() })\n\nfetchData()\n"} +{"type":"widget","data":"/// @ 1.2.1\n/// よく使うリアクションウィジェット\n/// 自分が最近付けたリアクションを users/reactions から最大 2 ページ (200 件) 取り、絵文字ごとに\n/// 集計して上位 15 件を横棒 (████▌) で並べる。users/reactions は相手が公開していないと空になるが、\n/// 自分自身 (USER_ID) なら公開設定に関係なく取れる。\n/// - カスタム絵文字の type は \":name@.:\" (ローカル) / \":name@host:\" (リモート)。ローカルは \":name:\" に\n/// 正規化して MFM に書けば描画される。リモートはそのまま書く\n/// - MFM インジェクション対策: 絵文字名は [a-zA-Z0-9_+-] と \"@\" \".\" だけを許可し、他の文字を含む type は\n/// 集計から除外する。Unicode 絵文字も ASCII (キーキャップ用の数字と # 以外) を含むものは除外する\n/// - 等幅表示に絵文字を混ぜると幅が崩れるので、バーと件数だけを $[font.monospace] に入れ、絵文字は\n/// その後ろ (等幅の外) に置く。バーは必ず 1 文字以上なので等幅部分が空白で始まることはない\n/// - 1 時間ごとに自動再取得、「更新」ボタンでいつでも\n/// - 「ノートにする」ボタンで上位 5 件を「@me の #よく使うリアクション / 1位: :x: (n回) …」の形で投稿フォームに流す。\n/// ユーザー名は users/show (permission 不要。\"i\" は account.read が要る) で一度だけ取り、取れなければ「@me の」を省く。\n/// Mk:api は失敗時に throw するので、ランキングを描いた後に取りに行く (失敗しても欠けるのは投稿ボタンだけ)\n/// 骨組みは起動時に一度だけ組み、更新は捕捉済み container / mfm の .update() に絞る。\n/// 注意: NoteDeck の Mk:api は失敗時に例外で止まる (ホストが枠にエラーを出す)。本家互換の\n/// error 値が返る環境のために Core:type(res) も見る。\n\nlet PAGE_LIMIT = 100 // users/reactions の 1 回あたりの最大件数\nlet PAGES = 2 // untilId で辿るページ数 (= 最大 200 件)\nlet TOP = 15 // 表示する絵文字の数\nlet BAR_W = 12 // バーの最大幅 (全角ブロック換算)\nlet MAX_UNICODE_LEN = 16 // Unicode 絵文字として認めるコードポイント数の上限 (家族絵文字などの結合列を許す)\nlet REFRESH_MS = 3600000 // 自動再取得の間隔 (1 時間)\nlet POST_TOP = 5 // ノートに載せる絵文字の数\nlet STORE_URL = \"https://store.notedeck.io/widgets/emoji-usage\" // 配布ページ (投稿に含める)\n\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"86b300\"\nlet GOLD = \"ffb300\"\nlet ALERT = \"f43f5e\"\n\n// カスタム絵文字名 (host 部を含む) に許す文字\nlet NAME_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+-@.\"\n// Unicode 絵文字に混ざってよい ASCII (キーキャップ 0️⃣〜9️⃣ · #️⃣ のため)\nlet KEYCAP_CHARS = \"0123456789#\"\n\nvar fetchedAt = 0\nvar username = \"\" // 投稿文の \"@name の\" に使う。空なら省く\n\n// 捕捉済みコンポーネント (起動時に一度だけ作る)\nvar heroC = null\nvar subC = null\nvar rateC = null\nvar listC = null\nvar footC = null\nvar postC = null // postFormButton は .update できないので container の children を差し替える\n\n@pad(n) { if (n < 10) `0{n}` else `{n}` }\n\n/// エポック (Date:now) → \"HH:MM\"\n@timeLabel(t) {\n `{pad(Date:hour(t))}:{pad(Date:minute(t))}`\n}\n\n/// エポック → \"MM/DD\"\n@dayLabel(t) {\n `{pad(Date:month(t))}/{pad(Date:day(t))}`\n}\n\n@divider() {\n Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]
` })\n}\n\n@emptyRow(text) {\n Ui:C:mfm({ text: `$[fg.color={MUTED} {text}]` })\n}\n\n// ─────────────────────────────\n// 絵文字 type の分類\n// ─────────────────────────────\n/// s のすべての文字が allowed に含まれるか\n@onlyChars(s, allowed) {\n let chars = s.to_unicode_arr()\n for (let i, chars.len) {\n if (!allowed.incl(chars[i])) { return false }\n }\n true\n}\n\n/// users/reactions の type → { key, kind }。表示できない (不正な文字を含む) ものは null\n/// key: MFM に書く文字列 (\":name:\" / \":name@host:\" / Unicode 絵文字)\n/// kind: \"local\" / \"remote\" / \"unicode\"\n@classify(t) {\n if (Core:type(t) != \"str\" || t.len == 0) { return null }\n if (t.starts_with(\":\") && t.ends_with(\":\") && t.len >= 3) {\n var inner = t.slice(1, t.len - 1)\n // \":name@.:\" はローカルのカスタム絵文字。\"@.\" を落として \":name:\" にする\n if (inner.ends_with(\"@.\")) { inner = inner.slice(0, inner.len - 2) }\n if (inner.len == 0 || !onlyChars(inner, NAME_CHARS)) { return null }\n let parts = inner.split(\"@\")\n if (parts.len > 2) { return null }\n if (parts.len == 2 && (parts[0].len == 0 || parts[1].len == 0)) { return null }\n let kind = if (parts.len == 2) \"remote\" else \"local\"\n return { key: `:{inner}:`, kind: kind }\n }\n // Unicode 絵文字。空白や MFM 記号 ($ [ ] < > ` @ * など) を含むものは出さない\n let chars = t.to_unicode_arr()\n if (chars.len > MAX_UNICODE_LEN) { return null }\n for (let i, chars.len) {\n let c = chars[i]\n if (c.codepoint_at(0) < 128 && !KEYCAP_CHARS.incl(c)) { return null }\n }\n { key: t, kind: \"unicode\" }\n}\n\n// ─────────────────────────────\n// 集計\n// ─────────────────────────────\n@summarize(items) {\n var counts = {}\n var total = 0\n var skipped = 0\n var local = 0\n var remote = 0\n var unicode = 0\n var newest = 0\n var oldest = 0\n for (let i, items.len) {\n let it = items[i]\n if (Core:type(it) != \"obj\" || !Obj:has(it, \"type\")) { continue }\n let c = classify(it.type)\n if (Core:type(c) == \"null\") {\n skipped += 1\n continue\n }\n total += 1\n if (c.kind == \"local\") { local += 1 } elif (c.kind == \"remote\") { remote += 1 } else { unicode += 1 }\n if (Obj:has(counts, c.key)) { counts[c.key] += 1 } else { counts[c.key] = 1 }\n if (Obj:has(it, \"createdAt\") && Core:type(it.createdAt) == \"str\") {\n // Date:parse は失敗時に error 型を返すので型でガードする\n let ts = Date:parse(it.createdAt)\n if (Core:type(ts) == \"num\") {\n if (newest == 0 || ts > newest) { newest = ts }\n if (oldest == 0 || ts < oldest) { oldest = ts }\n }\n }\n }\n var ranking = []\n let kvs = Obj:kvs(counts)\n for (let i, kvs.len) {\n ranking.push({ key: kvs[i][0], n: kvs[i][1] })\n }\n ranking.sort(@(a, b) { b.n - a.n })\n if (ranking.len > TOP) { ranking = ranking.slice(0, TOP) }\n {\n total: total\n skipped: skipped\n unique: kvs.len\n local: local\n remote: remote\n unicode: unicode\n newest: newest\n oldest: oldest\n ranking: ranking\n }\n}\n\n/// n / mx の比率を半角ブロック単位の横棒にする (最低 ▌、最大 BAR_W 文字)\n@bar(n, mx) {\n var halves = Math:round(n / mx * BAR_W * 2)\n if (halves < 1) { halves = 1 }\n var s = \"\".pad_end(Math:floor(halves / 2), \"█\")\n if (halves % 2 == 1) { s = `{s}▌` }\n s\n}\n\n// ─────────────────────────────\n// 描画 (捕捉済みコンポーネントの .update() のみ)\n// ─────────────────────────────\n@showEmpty(total, message) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} **{total}**]]
` })\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(message) ] })\n postC.update({ children: [ Ui:C:mfm({ text: \"\" }) ] })\n}\n\n/// 投稿文。上位 POST_TOP 件を「1位: :x: (n回)」で並べ、末尾に配布ページを付ける\n@noteText(ranking) {\n var lines = []\n lines.push(if (username != \"\") `@{username} の #よく使うリアクション` else \"#よく使うリアクション\")\n lines.push(\"\")\n let n = if (ranking.len < POST_TOP) ranking.len else POST_TOP\n for (let i, n) {\n lines.push(`{i + 1}位: {ranking[i].key} ({ranking[i].n}回)`)\n }\n lines.push(\"\")\n lines.push(STORE_URL)\n lines.join(Str:lf)\n}\n\n@showRanking(s) {\n heroC.update({ text: `
$[x2 $[fg.color={GOLD} **{s.total}**]]
` })\n var period = \"期間不明\"\n if (s.oldest > 0 && s.newest > 0) { period = `{dayLabel(s.oldest)}〜{dayLabel(s.newest)}` }\n subC.update({ text: `
$[fg.color={MUTED} 件のリアクション · {s.unique} 種類 · {period}]
` })\n\n let pct = Math:round(s.local / s.total * 100)\n rateC.update({ text: `
$[fg.color={MUTED} 🎨 このサーバーのカスタム絵文字 $[fg.color={ACCENT} **{pct}%**] · リモート {s.remote} · Unicode {s.unicode}]
` })\n\n let mx = s.ranking[0].n\n let nw = `{mx}`.len\n var rows = []\n for (let i, s.ranking.len) {\n let r = s.ranking[i]\n let col = if (i == 0) GOLD else ACCENT\n let b = bar(r.n, mx)\n let gap = \"\".pad_end(BAR_W - b.len, \" \")\n let cnt = `{r.n}`.pad_start(nw, \" \")\n rows.push(Ui:C:mfm({ text: `$[font.monospace $[fg.color={col} {b}]{gap} $[fg.color={MUTED} ×]{cnt}] {r.key}` }))\n }\n listC.update({ children: rows })\n}\n\n/// 投稿ボタンを出す。ランキングと footer を描いた後に呼ぶ (users/show が throw してもそこまでは残る)\n@showPost(ranking) {\n loadUsername()\n postC.update({ children: [\n Ui:C:postFormButton({ text: \"📝 ノートにする\", rounded: true, form: { text: noteText(ranking) } })\n ] })\n}\n\n@showFoot(fetched, skipped) {\n var note = \"\"\n if (skipped > 0) { note = ` · 表示できない {skipped} 件を除外` }\n footC.update({ text: `
$[fg.color={MUTED} 最終更新 {timeLabel(fetchedAt)} · 直近 {fetched} 件を集計{note}]
` })\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 自分の username を一度だけ取る (ウィジェットでは USER_USERNAME が空)。失敗しても集計は続ける\n@loadUsername() {\n if (username != \"\") { return null }\n let me = Mk:api(\"users/show\", { userId: USER_ID })\n if (Core:type(me) == \"obj\" && Obj:has(me, \"username\") && Core:type(me.username) == \"str\") {\n username = me.username\n }\n}\n\n@fetchData() {\n if (USER_ID == \"\") {\n showEmpty(0, \"実行アカウントが設定されていません\")\n return null\n }\n var items = []\n var untilId = \"\"\n var failed = false\n for (let p, PAGES) {\n var params = { userId: USER_ID, limit: PAGE_LIMIT }\n if (untilId != \"\") { params.untilId = untilId }\n let res = Mk:api(\"users/reactions\", params)\n if (Core:type(res) != \"arr\") {\n if (p == 0) { failed = true }\n break\n }\n items = items.concat(res)\n if (res.len < PAGE_LIMIT) { break }\n let last = res[res.len - 1]\n if (Core:type(last) != \"obj\" || !Obj:has(last, \"id\") || Core:type(last.id) != \"str\") { break }\n untilId = last.id\n }\n fetchedAt = Date:now()\n if (failed) {\n heroC.update({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\n subC.update({ text: `
$[fg.color={ALERT} ⚠️ リアクションを取得できませんでした]
` })\n rateC.update({ text: \"\" })\n listC.update({ children: [ emptyRow(\"カラムにアカウントが紐づいているか確認してください\") ] })\n postC.update({ children: [ Ui:C:mfm({ text: \"\" }) ] })\n showFoot(0, 0)\n return null\n }\n if (items.len == 0) {\n showEmpty(0, \"リアクションの取得結果が空です\")\n showFoot(0, 0)\n return null\n }\n let s = summarize(items)\n if (s.total == 0) {\n showEmpty(0, \"表示できるリアクションがありません\")\n } else {\n showRanking(s)\n }\n showFoot(items.len, s.skipped)\n if (s.total > 0) { showPost(s.ranking) }\n}\n\n// ─────────────────────────────\n// 起動\n// ─────────────────────────────\nheroC = Ui:C:mfm({ text: `
$[x2 $[fg.color={MUTED} …]]
` })\nsubC = Ui:C:mfm({ text: `
$[fg.color={MUTED} リアクションを読み込み中…]
` })\nrateC = Ui:C:mfm({ text: \"\" })\nlistC = Ui:C:container({ align: \"left\", children: [ Ui:C:mfm({ text: \"\" }) ] })\nfootC = Ui:C:mfm({ text: \"\" })\npostC = Ui:C:container({ align: \"center\", children: [ Ui:C:mfm({ text: \"\" }) ] })\n\nUi:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `
$[fg.color={MUTED} 😀 よく使うリアクション · 直近 {PAGE_LIMIT * PAGES} 件]
` })\n heroC\n subC\n rateC\n divider()\n listC\n footC\n postC\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n })\n])\n\nAsync:interval(REFRESH_MS, @() { fetchData() })\n\nfetchData()\n"} diff --git a/public/registry/widgets/emoji-usage/widget.is b/public/registry/widgets/emoji-usage/widget.is index d6c7dc6..68f0301 100644 --- a/public/registry/widgets/emoji-usage/widget.is +++ b/public/registry/widgets/emoji-usage/widget.is @@ -11,7 +11,8 @@ /// その後ろ (等幅の外) に置く。バーは必ず 1 文字以上なので等幅部分が空白で始まることはない /// - 1 時間ごとに自動再取得、「更新」ボタンでいつでも /// - 「ノートにする」ボタンで上位 5 件を「@me の #よく使うリアクション / 1位: :x: (n回) …」の形で投稿フォームに流す。 -/// ユーザー名は users/show (permission 不要。"i" は account.read が要る) で一度だけ取り、取れなければ「@me の」を省く +/// ユーザー名は users/show (permission 不要。"i" は account.read が要る) で一度だけ取り、取れなければ「@me の」を省く。 +/// Mk:api は失敗時に throw するので、ランキングを描いた後に取りに行く (失敗しても欠けるのは投稿ボタンだけ) /// 骨組みは起動時に一度だけ組み、更新は捕捉済み container / mfm の .update() に絞る。 /// 注意: NoteDeck の Mk:api は失敗時に例外で止まる (ホストが枠にエラーを出す)。本家互換の /// error 値が返る環境のために Core:type(res) も見る。 @@ -212,8 +213,13 @@ var postC = null // postFormButton は .update できないので co rows.push(Ui:C:mfm({ text: `$[font.monospace $[fg.color={col} {b}]{gap} $[fg.color={MUTED} ×]{cnt}] {r.key}` })) } listC.update({ children: rows }) +} + +/// 投稿ボタンを出す。ランキングと footer を描いた後に呼ぶ (users/show が throw してもそこまでは残る) +@showPost(ranking) { + loadUsername() postC.update({ children: [ - Ui:C:postFormButton({ text: "📝 ノートにする", rounded: true, form: { text: noteText(s.ranking) } }) + Ui:C:postFormButton({ text: "📝 ノートにする", rounded: true, form: { text: noteText(ranking) } }) ] }) } @@ -240,7 +246,6 @@ var postC = null // postFormButton は .update できないので co showEmpty(0, "実行アカウントが設定されていません") return null } - loadUsername() var items = [] var untilId = "" var failed = false @@ -280,6 +285,7 @@ var postC = null // postFormButton は .update できないので co showRanking(s) } showFoot(items.len, s.skipped) + if (s.total > 0) { showPost(s.ranking) } } // ─────────────────────────────