Skip to content
Open
20 changes: 20 additions & 0 deletions entrypoints/main-world-bridge.content/index.ts
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);
},
});
4 changes: 4 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ boardOneline:
name: Add a button to minimize cards on the board to a single line
childPage:
name: Create a new wiki page as a subpage of the current one
copyIssueCacooFormat:
name: Add a button to copy issues as Cacoo cards
tooltip: Copy as Cacoo card
success: Copied Cacoo card to clipboard
copyIssueKeysAndSubjects:
name: Add a button to copy all issue keys and subjects from the search results
tooltip: Copy all issue keys and subjects
Expand Down
4 changes: 4 additions & 0 deletions locales/ja.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ boardOneline:
name: ボードのカードを1行の最小化表示できるボタンを追加する
childPage:
name: 新規ページを現在表示しているページの下に作成する
copyIssueCacooFormat:
name: 課題をCacooカードとしてコピーできるボタンを追加する
tooltip: Cacooカードとしてコピー
success: Cacooカードをクリップボードにコピーしました
copyIssueKeysAndSubjects:
name: 検索結果で課題キーと件名を一括コピーできるボタンを追加する
tooltip: 課題キーと件名を一括コピー
Expand Down
136 changes: 136 additions & 0 deletions plugins/copyIssueCacooFormat/build-cacoo-json.ts
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;
Comment on lines +13 to +14
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveColor compares type/priority against 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.

Suggested change
if (type === "バグ" || priority === "高") return COLOR_RED;
if (priority === "低") return COLOR_GREEN;
const normalizedType = type.trim().toLowerCase();
const normalizedPriority = priority.trim().toLowerCase();
const isBug =
normalizedType === "バグ" || normalizedType === "bug";
const isHighPriority =
normalizedPriority === "高" || normalizedPriority === "high";
const isLowPriority =
normalizedPriority === "低" || normalizedPriority === "low";
if (isBug || isHighPriority) return COLOR_RED;
if (isLowPriority) return COLOR_GREEN;

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

endIndex: issue.key.length - 1 becomes -1 when issue.key is empty (which can happen because extraction falls back to "" instead of failing). This produces invalid link ranges in the generated Cacoo payload. Add validation (e.g., throw if key/url/summary are missing) or clamp indices so the payload is always valid.

Copilot uses AI. Check for mistakes.
],
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);
}
19 changes: 19 additions & 0 deletions plugins/copyIssueCacooFormat/copy-to-clipboard.ts
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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document.execCommand('copy') can throw (or be blocked) and in that case the copy event handler would remain attached because removal happens after the call. Wrap the execCommand call in a try/finally so removeEventListener always runs, and consider handling the boolean return value to surface a failure to the user.

Suggested change
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);
}

Copilot uses AI. Check for mistakes.
}
101 changes: 101 additions & 0 deletions plugins/copyIssueCacooFormat/extract-issue-data.ts
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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractIssueDataFromRow always returns empty type and priority, so cards copied from list/search pages will always use the default color (blue) regardless of the issue's actual type/priority. If dynamic coloring is expected for list pages too, extract these fields from the row (or fetch the issue JSON/HTML) before building the Cacoo payload.

Copilot uses AI. Check for mistakes.
};
}

/**
* 日付要素から日付値を取得する。
*
* 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;
}
37 changes: 37 additions & 0 deletions plugins/copyIssueCacooFormat/index.module.css
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;
}
Loading