-
Notifications
You must be signed in to change notification settings - Fork 11
feat: add plugin to copy issues as Cacoo cards #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6baefe5
077e79a
d3d29b3
5667c62
c33712a
b454db2
f6d9c2d
7d09f3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| const EVENT_PREFIX = "backlog-power-ups:main-world"; | ||
|
|
||
| export default defineContentScript({ | ||
| matches: [ | ||
| "https://*.backlog.jp/*", | ||
| "https://*.backlog.com/*", | ||
| "https://*.backlogtool.com/*", | ||
| ], | ||
| world: "MAIN", | ||
| main() { | ||
| window.addEventListener(`${EVENT_PREFIX}:show-status-message`, (( | ||
| e: CustomEvent<string>, | ||
| ) => { | ||
| // @ts-expect-error — Backlog global | ||
| window.Backlog?.StatusBar?.init(); | ||
| // @ts-expect-error — Backlog global | ||
| window.Backlog?.StatusBar?.showTextAndHide(e.detail); | ||
| }) as EventListener); | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import type { IssueData } from "./extract-issue-data"; | ||
|
|
||
| const COLOR_RED = "#E65050"; | ||
| const COLOR_BLUE = "#4B91FA"; | ||
| const COLOR_GREEN = "#69C955"; | ||
|
|
||
| const TITLE_CONTAINER_WIDTH = 236; | ||
| const TITLE_LINE_HEIGHT = 20; | ||
| const TITLE_FONT = 'bold 14px "Open Sans", sans-serif'; | ||
| const BASE_CARD_HEIGHT = 91; | ||
|
|
||
| export function resolveColor(type: string, priority: string): string { | ||
| if (type === "バグ" || priority === "高") return COLOR_RED; | ||
| if (priority === "低") return COLOR_GREEN; | ||
| return COLOR_BLUE; | ||
| } | ||
|
|
||
| export function measureTitleHeight(text: string): number { | ||
| const canvas = document.createElement("canvas"); | ||
| const ctx = canvas.getContext("2d"); | ||
| if (!ctx) return TITLE_LINE_HEIGHT; | ||
|
|
||
| ctx.font = TITLE_FONT; | ||
|
|
||
| let lines = 1; | ||
| let currentWidth = 0; | ||
|
|
||
| for (const segment of text.split(/(?<=\s)/)) { | ||
| const segmentWidth = ctx.measureText(segment).width; | ||
|
|
||
| if (segmentWidth > TITLE_CONTAINER_WIDTH) { | ||
| // Handle words wider than container (overflow-wrap: break-word) | ||
| for (const char of segment) { | ||
| const charWidth = ctx.measureText(char).width; | ||
| if ( | ||
| currentWidth + charWidth > TITLE_CONTAINER_WIDTH && | ||
| currentWidth > 0 | ||
| ) { | ||
| lines++; | ||
| currentWidth = 0; | ||
| } | ||
| currentWidth += charWidth; | ||
| } | ||
| } else if ( | ||
| currentWidth + segmentWidth > TITLE_CONTAINER_WIDTH && | ||
| currentWidth > 0 | ||
| ) { | ||
| lines++; | ||
| currentWidth = segmentWidth; | ||
| } else { | ||
| currentWidth += segmentWidth; | ||
| } | ||
| } | ||
|
|
||
| return Math.min(lines, 2) * TITLE_LINE_HEIGHT; | ||
| } | ||
|
|
||
| export function buildCacooJson(issue: IssueData): string { | ||
| const titleText = `${issue.key} ${issue.summary}`; | ||
| const color = resolveColor(issue.type, issue.priority); | ||
| const titleHeight = measureTitleHeight(titleText); | ||
| const cardHeight = BASE_CARD_HEIGHT + titleHeight - TITLE_LINE_HEIGHT; | ||
|
|
||
| const payload = { | ||
| target: "shapes", | ||
| sheetId: "generated", | ||
| shapes: [ | ||
| { | ||
| uid: crypto.randomUUID(), | ||
| type: 12, | ||
| keepAspectRatio: true, | ||
| locked: false, | ||
| bounds: { | ||
| top: 3000, | ||
| bottom: 3000 + cardHeight, | ||
| left: 1100, | ||
| right: 1360, | ||
| }, | ||
| cardType: 0, | ||
| cacoo: { | ||
| title: { | ||
| text: titleText, | ||
| leading: 6, | ||
| styles: [ | ||
| { | ||
| index: 0, | ||
| font: "Open Sans", | ||
| size: 14, | ||
| color: "2488fd", | ||
| bold: true, | ||
| underline: true, | ||
| }, | ||
| { | ||
| index: issue.key.length, | ||
| font: "Open Sans", | ||
| size: 14, | ||
| color: "333333", | ||
| bold: true, | ||
| }, | ||
| ], | ||
| links: [ | ||
| { | ||
| type: 1, | ||
| to: issue.url, | ||
| startIndex: 0, | ||
| endIndex: issue.key.length - 1, | ||
| }, | ||
|
Comment on lines
+103
to
+107
|
||
| ], | ||
| height: titleHeight, | ||
| }, | ||
| description: { | ||
| text: "", | ||
| leading: 6, | ||
| styles: [ | ||
| { | ||
| index: 0, | ||
| font: "Open Sans", | ||
| size: 12, | ||
| color: "333333", | ||
| }, | ||
| ], | ||
| links: [], | ||
| height: 18, | ||
| }, | ||
| expanded: false, | ||
| primaryColor: color, | ||
| secondaryColor: "#DCEBFF", | ||
| dueDate: issue.dueDate ?? "", | ||
| externalAccountId: "", | ||
| }, | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| return JSON.stringify(payload); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,19 @@ | ||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * copy イベントを利用してカスタム MIME タイプでクリップボードにデータを書き込む。 | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * navigator.clipboard.write() は標準 MIME タイプ(text/plain, text/html, image/png) | ||||||||||||||||||||||
| * のみ許可しており、cacoo/shape のような非標準 MIME タイプは書き込めない。 | ||||||||||||||||||||||
| * 確実に動作する document.execCommand('copy') + copy イベントハンドラ方式を採用する。 | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function copyToClipboard(cacooJson: string, plainText: string): void { | ||||||||||||||||||||||
| const handler = (e: Event) => { | ||||||||||||||||||||||
| if (!(e instanceof ClipboardEvent)) return; | ||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||
| e.clipboardData?.setData("cacoo/shape", cacooJson); | ||||||||||||||||||||||
| e.clipboardData?.setData("text/plain", plainText); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| document.addEventListener("copy", handler); | ||||||||||||||||||||||
| document.execCommand("copy"); | ||||||||||||||||||||||
| document.removeEventListener("copy", handler); | ||||||||||||||||||||||
|
Comment on lines
+17
to
+18
|
||||||||||||||||||||||
| document.execCommand("copy"); | |
| document.removeEventListener("copy", handler); | |
| try { | |
| const success = document.execCommand("copy"); | |
| if (!success) { | |
| console.warn("Copy command was blocked or did not succeed."); | |
| } | |
| } finally { | |
| document.removeEventListener("copy", handler); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| export interface IssueData { | ||
| key: string; | ||
| summary: string; | ||
| assignee: string; | ||
| dueDate: string | null; | ||
| type: string; | ||
| priority: string; | ||
| url: string; | ||
| } | ||
|
|
||
| /** | ||
| * Backlog の課題ページから情報を取得する。 | ||
| * | ||
| * セレクタは Backlog の現行 UI の data-testid 属性を使用している。 | ||
| * Classic UI には対応していない。 | ||
| */ | ||
| export function extractIssueData(): IssueData { | ||
| const key = | ||
| document | ||
| .querySelector<HTMLElement>("[data-testid='issueKey']") | ||
| ?.textContent?.trim() ?? ""; | ||
|
|
||
| const summary = | ||
| document | ||
| .querySelector<HTMLElement>("[data-testid='issueSummary']") | ||
| ?.textContent?.trim() ?? ""; | ||
|
|
||
| const assignee = | ||
| document | ||
| .querySelector<HTMLElement>("[data-testid='issueAssignee']") | ||
| ?.textContent?.trim() ?? ""; | ||
|
|
||
| const type = | ||
| document | ||
| .querySelector<HTMLElement>("[data-testid='issueType']") | ||
| ?.textContent?.trim() ?? ""; | ||
|
|
||
| const priority = | ||
| document | ||
| .querySelector<HTMLElement>("[data-testid='issuePriority']") | ||
| ?.textContent?.trim() ?? ""; | ||
|
|
||
| const dueDateEl = document.querySelector<HTMLElement>( | ||
| "[data-testid='dueDate']", | ||
| ); | ||
| const dueDate = extractDateValue(dueDateEl); | ||
|
|
||
| const url = window.location.href; | ||
|
|
||
| return { key, summary, assignee, dueDate, type, priority, url }; | ||
| } | ||
|
|
||
| /** | ||
| * Backlog の課題一覧テーブルの行から課題情報を取得する。 | ||
| * | ||
| * 行内のリンク (`a[href*="/view/"]`) からキーと URL を、 | ||
| * リンクテキストが課題キーパターンに一致しないものをサマリとして取得する。 | ||
| */ | ||
| export function extractIssueDataFromRow(row: HTMLTableRowElement): IssueData { | ||
| const links = row.querySelectorAll<HTMLAnchorElement>('a[href*="/view/"]'); | ||
|
|
||
| let key = ""; | ||
| let summary = ""; | ||
| let url = window.location.href; | ||
|
|
||
| for (const link of links) { | ||
| const href = link.getAttribute("href") ?? ""; | ||
| const text = link.textContent?.trim() ?? ""; | ||
|
|
||
| if (/^[A-Z][A-Z_0-9]+-\d+$/.test(text)) { | ||
| key = text; | ||
| url = new URL(href, window.location.origin).href; | ||
| } else if (!summary) { | ||
| summary = text; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| key, | ||
| summary, | ||
| assignee: "", | ||
| dueDate: null, | ||
| type: "", | ||
| priority: "", | ||
| url, | ||
|
Comment on lines
+78
to
+85
|
||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * 日付要素から日付値を取得する。 | ||
| * | ||
| * Backlog の日付要素は `<span data-testid="dueDate"><span>期限日</span>2025/12/16</span>` の | ||
| * 構造になっており、ラベル部分を除いた日付テキストを取得する。 | ||
| */ | ||
| function extractDateValue(el: HTMLElement | null): string | null { | ||
| if (!el) return null; | ||
| const label = el.querySelector("span")?.textContent ?? ""; | ||
| const fullText = el.textContent?.trim() ?? ""; | ||
| const value = fullText.replace(label, "").trim(); | ||
| return value && value !== "-" ? value : null; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| /* 課題詳細ページ: #copyKey-help 横のボタン配置 */ | ||
| :global(.ticket__key) { | ||
| & > :global(.ticket__key-copy:not(:first-of-type)) { | ||
| transform: translateX(calc((100% + 2px) * var(--nth))); | ||
| } | ||
|
|
||
| & :nth-child(2 of :global(.ticket__key-copy)) { --nth: 1; } | ||
| & :nth-child(3 of :global(.ticket__key-copy)) { --nth: 2; } | ||
| & :nth-child(4 of :global(.ticket__key-copy)) { --nth: 3; } | ||
| & :nth-child(5 of :global(.ticket__key-copy)) { --nth: 4; } | ||
| & :nth-child(6 of :global(.ticket__key-copy)) { --nth: 5; } | ||
| } | ||
|
|
||
| /* 課題一覧ページ: cell-hover-actions 内のボタン */ | ||
| .button { | ||
| background: transparent; | ||
| color: var(--iconColorDefault); | ||
| border: 2px solid currentColor; | ||
| padding: 0; | ||
| cursor: pointer; | ||
| width: 20px; | ||
| height: 20px; | ||
| border-radius: 4px; | ||
| display: inline-flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| } | ||
|
|
||
| .button:hover { | ||
| color: var(--defaultColorAccent); | ||
| } | ||
|
|
||
| .button > svg { | ||
| width: 100%; | ||
| height: 100%; | ||
| object-fit: contain; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveColorcomparestype/priorityagainst Japanese UI labels only ("バグ", "高", "低"). On English Backlog UIs these values will differ, so the color will silently fall back to blue and the feature won't behave as intended. Prefer comparing against language-independent identifiers (if available) or handle both JA/EN label variants.