From 947f875aa8b72f179b61a4917b0459cc101a6c8f Mon Sep 17 00:00:00 2001
From: felix bear
Date: Tue, 9 Jun 2026 21:53:50 +0900
Subject: [PATCH 01/11] fix(jira): paginate auto-import search (#8161)
* fix(jira): paginate auto-import search #5578
* fix(jira): cap auto-import pagination
---------
Co-authored-by: cocojojo5213
---
.../providers/jira/jira-api.service.spec.ts | 213 +++++++++++++++++-
.../issue/providers/jira/jira-api.service.ts | 196 ++++++++++++++--
.../issue/providers/jira/jira.const.ts | 1 +
3 files changed, 391 insertions(+), 19 deletions(-)
diff --git a/src/app/features/issue/providers/jira/jira-api.service.spec.ts b/src/app/features/issue/providers/jira/jira-api.service.spec.ts
index 39e91e8410..478673b5d6 100644
--- a/src/app/features/issue/providers/jira/jira-api.service.spec.ts
+++ b/src/app/features/issue/providers/jira/jira-api.service.spec.ts
@@ -7,9 +7,10 @@ import { SnackService } from '../../../../core/snack/snack.service';
import { GlobalProgressBarService } from '../../../../core-ui/global-progress-bar/global-progress-bar.service';
import { BannerService } from '../../../../core/banner/banner.service';
import { MatDialog } from '@angular/material/dialog';
-import { DEFAULT_JIRA_CFG } from './jira.const';
+import { DEFAULT_JIRA_CFG, JIRA_MAX_AUTO_IMPORT_PAGES } from './jira.const';
import { JiraCfg } from './jira.model';
import { formatJiraDate } from '../../../../util/format-jira-date';
+import { JiraIssueOriginal } from './jira-api-responses';
const makeMockExtensionService = (
onReady$: Subject | ReplaySubject,
@@ -57,6 +58,44 @@ const baseCfg: JiraCfg = {
password: 'pass',
};
+const makeJiraIssue = (key: string): JiraIssueOriginal => ({
+ key,
+ id: key.replace(/\D/g, '') || key,
+ expand: '',
+ self: `https://jira.example.com/rest/api/latest/issue/${key}`,
+ fields: {
+ summary: `Summary ${key}`,
+ components: [],
+ attachment: [],
+ timeestimate: 0,
+ timespent: 0,
+ description: null,
+ assignee: null as any,
+ updated: '2026-06-08T00:00:00.000+0000',
+ status: {
+ self: '',
+ id: '1',
+ description: '',
+ iconUrl: '',
+ name: 'Open',
+ statusCategory: {
+ self: '',
+ id: '1',
+ key: 'new',
+ colorName: 'blue-gray',
+ name: 'To Do',
+ },
+ },
+ issuelinks: [],
+ },
+});
+
+const jsonResponse = (body: unknown, status = 200): Promise =>
+ Promise.resolve(new Response(JSON.stringify(body), { status }));
+
+const requestBodyAt = (fetchSpy: jasmine.Spy, index: number): Record =>
+ JSON.parse((fetchSpy.calls.argsFor(index)[1] as RequestInit).body as string);
+
describe('JiraApiService', () => {
describe('addWorklog$ date formatting', () => {
it('should format date correctly using formatJiraDate', () => {
@@ -290,4 +329,176 @@ describe('JiraApiService', () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+
+ describe('findAutoImportIssues$ pagination', () => {
+ let service: JiraApiService;
+ let fetchSpy: jasmine.Spy;
+ const cfg = {
+ ...baseCfg,
+ allowFetchFallback: true,
+ autoAddBacklogJqlQuery: 'project = TEST ORDER BY updated DESC',
+ };
+
+ beforeEach(() => {
+ service = setupService(new Subject());
+ fetchSpy = spyOn(window, 'fetch');
+ });
+
+ it('fetches all Jira Cloud search/jql pages via nextPageToken', (done) => {
+ fetchSpy.and.returnValues(
+ jsonResponse({
+ issues: [makeJiraIssue('TEST-1')],
+ maxResults: 100,
+ nextPageToken: 'token-2',
+ }),
+ jsonResponse({
+ issues: [makeJiraIssue('TEST-2')],
+ maxResults: 100,
+ nextPageToken: 'token-3',
+ }),
+ jsonResponse({
+ issues: [makeJiraIssue('TEST-3')],
+ maxResults: 100,
+ }),
+ );
+
+ service.findAutoImportIssues$(cfg).subscribe({
+ next: (issues) => {
+ expect(issues.map((issue) => issue.key)).toEqual([
+ 'TEST-1',
+ 'TEST-2',
+ 'TEST-3',
+ ]);
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
+ expect(String(fetchSpy.calls.argsFor(0)[0])).toContain('/search/jql');
+ expect(requestBodyAt(fetchSpy, 0)).toEqual(
+ jasmine.objectContaining({
+ jql: cfg.autoAddBacklogJqlQuery,
+ maxResults: 100,
+ }),
+ );
+ expect(requestBodyAt(fetchSpy, 1)).toEqual(
+ jasmine.objectContaining({ nextPageToken: 'token-2' }),
+ );
+ expect(requestBodyAt(fetchSpy, 2)).toEqual(
+ jasmine.objectContaining({ nextPageToken: 'token-3' }),
+ );
+ done();
+ },
+ error: done.fail,
+ });
+ });
+
+ it('caps Jira Cloud auto-import pagination', (done) => {
+ fetchSpy.and.returnValues(
+ ...Array.from({ length: JIRA_MAX_AUTO_IMPORT_PAGES + 1 }, (_, index) =>
+ jsonResponse({
+ issues: [makeJiraIssue(`TEST-${index + 1}`)],
+ maxResults: 100,
+ nextPageToken: `token-${index + 2}`,
+ }),
+ ),
+ );
+
+ service.findAutoImportIssues$(cfg).subscribe({
+ next: (issues) => {
+ expect(issues.map((issue) => issue.key)).toEqual([
+ 'TEST-1',
+ 'TEST-2',
+ 'TEST-3',
+ 'TEST-4',
+ 'TEST-5',
+ ]);
+ expect(fetchSpy).toHaveBeenCalledTimes(JIRA_MAX_AUTO_IMPORT_PAGES);
+ expect(requestBodyAt(fetchSpy, JIRA_MAX_AUTO_IMPORT_PAGES - 1)).toEqual(
+ jasmine.objectContaining({ nextPageToken: 'token-5' }),
+ );
+ done();
+ },
+ error: done.fail,
+ });
+ });
+
+ it('falls back to Jira Server/DC search pages via startAt', (done) => {
+ fetchSpy.and.returnValues(
+ jsonResponse({ errorMessages: ['not found'] }, 404),
+ jsonResponse({
+ issues: [makeJiraIssue('TEST-1')],
+ maxResults: 100,
+ startAt: 0,
+ total: 250,
+ }),
+ jsonResponse({
+ issues: [makeJiraIssue('TEST-101')],
+ maxResults: 100,
+ startAt: 100,
+ total: 250,
+ }),
+ jsonResponse({
+ issues: [makeJiraIssue('TEST-201')],
+ maxResults: 100,
+ startAt: 200,
+ total: 250,
+ }),
+ );
+
+ service.findAutoImportIssues$(cfg).subscribe({
+ next: (issues) => {
+ expect(issues.map((issue) => issue.key)).toEqual([
+ 'TEST-1',
+ 'TEST-101',
+ 'TEST-201',
+ ]);
+ expect(fetchSpy).toHaveBeenCalledTimes(4);
+ expect(String(fetchSpy.calls.argsFor(0)[0])).toContain('/search/jql');
+ expect(String(fetchSpy.calls.argsFor(1)[0])).toContain('/search');
+ expect(requestBodyAt(fetchSpy, 1)).not.toEqual(
+ jasmine.objectContaining({ startAt: jasmine.any(Number) }),
+ );
+ expect(requestBodyAt(fetchSpy, 2)).toEqual(
+ jasmine.objectContaining({ startAt: 100 }),
+ );
+ expect(requestBodyAt(fetchSpy, 3)).toEqual(
+ jasmine.objectContaining({ startAt: 200 }),
+ );
+ done();
+ },
+ error: done.fail,
+ });
+ });
+
+ it('caps Jira Server/DC auto-import pagination', (done) => {
+ fetchSpy.and.returnValues(
+ jsonResponse({ errorMessages: ['not found'] }, 404),
+ ...Array.from({ length: JIRA_MAX_AUTO_IMPORT_PAGES + 1 }, (_, index) => {
+ const startAt = index * 100;
+ const issueNumber = startAt + 1;
+ return jsonResponse({
+ issues: [makeJiraIssue(`TEST-${issueNumber}`)],
+ maxResults: 100,
+ startAt,
+ total: 1000,
+ });
+ }),
+ );
+
+ service.findAutoImportIssues$(cfg).subscribe({
+ next: (issues) => {
+ expect(issues.map((issue) => issue.key)).toEqual([
+ 'TEST-1',
+ 'TEST-101',
+ 'TEST-201',
+ 'TEST-301',
+ 'TEST-401',
+ ]);
+ expect(fetchSpy).toHaveBeenCalledTimes(JIRA_MAX_AUTO_IMPORT_PAGES + 1);
+ expect(requestBodyAt(fetchSpy, JIRA_MAX_AUTO_IMPORT_PAGES)).toEqual(
+ jasmine.objectContaining({ startAt: 400 }),
+ );
+ done();
+ },
+ error: done.fail,
+ });
+ });
+ });
});
diff --git a/src/app/features/issue/providers/jira/jira-api.service.ts b/src/app/features/issue/providers/jira/jira-api.service.ts
index af45e58a21..9950716571 100644
--- a/src/app/features/issue/providers/jira/jira-api.service.ts
+++ b/src/app/features/issue/providers/jira/jira-api.service.ts
@@ -3,18 +3,20 @@ import { nanoid } from 'nanoid';
import { ChromeExtensionInterfaceService } from '../../../../core/chrome-extension-interface/chrome-extension-interface.service';
import {
JIRA_ADDITIONAL_ISSUE_FIELDS,
+ JIRA_MAX_AUTO_IMPORT_PAGES,
JIRA_MAX_RESULTS,
JIRA_REQUEST_TIMEOUT_DURATION,
} from './jira.const';
import {
mapIssueResponse,
- mapIssuesResponse,
mapResponse,
mapToSearchResults,
mapToSearchResultsForJQL,
mapTransitionResponse,
} from './jira-issue-map.util';
import {
+ JiraApiEnvelope,
+ JiraIssueOriginal,
JiraOriginalStatus,
JiraOriginalTransition,
JiraOriginalUser,
@@ -30,6 +32,7 @@ import {
concatMap,
finalize,
first,
+ map,
mapTo,
shareReplay,
take,
@@ -85,6 +88,15 @@ interface JiraRequestCfg {
body?: Record;
}
+type JiraIssueSearchResponse = {
+ issues: JiraIssueOriginal[];
+ maxResults?: number;
+ startAt?: number;
+ total?: number;
+ isLast?: boolean;
+ nextPageToken?: string;
+};
+
@Injectable({
providedIn: 'root',
})
@@ -232,33 +244,27 @@ export class JiraApiService {
});
}
- return this._sendRequest$({
- jiraReqCfg: {
- transform: mapIssuesResponse,
- pathname: 'search/jql',
- method: 'POST',
- body: {
- ...options,
- jql: searchQuery,
- },
- },
+ return this._fetchAllJiraSearchPages$({
cfg,
+ pathname: 'search/jql',
+ body: {
+ ...options,
+ jql: searchQuery,
+ },
+ isCloudJqlSearch: true,
suppressErrorSnack: true,
}).pipe(
catchError((err) => {
const code = extractHttpStatus(err);
if (code === 401 || code === 403) return throwError(() => err);
// Fallback for Server/DC: POST /search with jql in body
- return this._sendRequest$({
- jiraReqCfg: {
- transform: mapIssuesResponse,
- pathname: 'search',
- method: 'POST',
- body: { ...options, jql: searchQuery },
- },
+ return this._fetchAllJiraSearchPages$({
cfg,
+ pathname: 'search',
+ body: { ...options, jql: searchQuery },
});
}),
+ map((issues) => issues.map((issue) => mapIssueResponse({ response: issue }, cfg))),
) as Observable;
}
@@ -390,6 +396,160 @@ export class JiraApiService {
// Complex Functions
// --------
+ private _fetchAllJiraSearchPages$({
+ cfg,
+ pathname,
+ body,
+ isCloudJqlSearch = false,
+ suppressErrorSnack = false,
+ }: {
+ cfg: JiraCfg;
+ pathname: string;
+ body: Record;
+ isCloudJqlSearch?: boolean;
+ suppressErrorSnack?: boolean;
+ }): Observable {
+ return this._fetchJiraSearchPage$({
+ cfg,
+ pathname,
+ body,
+ suppressErrorSnack,
+ }).pipe(
+ concatMap((firstPage) => {
+ return this._fetchRemainingJiraSearchPages$({
+ cfg,
+ pathname,
+ baseBody: body,
+ previousPage: firstPage,
+ isCloudJqlSearch,
+ suppressErrorSnack,
+ fetchedPages: 1,
+ }).pipe(map((issues) => [...(firstPage.issues || []), ...issues]));
+ }),
+ );
+ }
+
+ private _fetchRemainingJiraSearchPages$({
+ cfg,
+ pathname,
+ baseBody,
+ previousPage,
+ isCloudJqlSearch,
+ suppressErrorSnack,
+ fetchedPages,
+ }: {
+ cfg: JiraCfg;
+ pathname: string;
+ baseBody: Record;
+ previousPage: JiraIssueSearchResponse;
+ isCloudJqlSearch: boolean;
+ suppressErrorSnack: boolean;
+ fetchedPages: number;
+ }): Observable {
+ if (fetchedPages >= JIRA_MAX_AUTO_IMPORT_PAGES) {
+ return of([]);
+ }
+
+ const nextPageBody = this._getNextJiraSearchPageBody({
+ previousPage,
+ baseBody,
+ isCloudJqlSearch,
+ });
+
+ if (!nextPageBody) {
+ return of([]);
+ }
+
+ return this._fetchJiraSearchPage$({
+ cfg,
+ pathname,
+ body: nextPageBody,
+ suppressErrorSnack,
+ }).pipe(
+ concatMap((page) =>
+ this._fetchRemainingJiraSearchPages$({
+ cfg,
+ pathname,
+ baseBody,
+ previousPage: page,
+ isCloudJqlSearch,
+ suppressErrorSnack,
+ fetchedPages: fetchedPages + 1,
+ }).pipe(map((issues) => [...(page.issues || []), ...issues])),
+ ),
+ );
+ }
+
+ private _fetchJiraSearchPage$({
+ cfg,
+ pathname,
+ body,
+ suppressErrorSnack,
+ }: {
+ cfg: JiraCfg;
+ pathname: string;
+ body: Record;
+ suppressErrorSnack: boolean;
+ }): Observable {
+ return this._sendRequest$({
+ jiraReqCfg: {
+ pathname,
+ method: 'POST',
+ body,
+ },
+ cfg,
+ suppressErrorSnack,
+ }).pipe(
+ map(
+ (res) =>
+ ((res as JiraApiEnvelope).response ||
+ {}) as JiraIssueSearchResponse,
+ ),
+ );
+ }
+
+ private _getNextJiraSearchPageBody({
+ previousPage,
+ baseBody,
+ isCloudJqlSearch,
+ }: {
+ previousPage: JiraIssueSearchResponse;
+ baseBody: Record;
+ isCloudJqlSearch: boolean;
+ }): Record | null {
+ const maxResults =
+ typeof previousPage.maxResults === 'number'
+ ? previousPage.maxResults
+ : (baseBody.maxResults as number | undefined);
+ const issueCount = previousPage.issues?.length || 0;
+ const pageSize = Math.max(maxResults || issueCount || JIRA_MAX_RESULTS, 1);
+
+ if (isCloudJqlSearch) {
+ return previousPage.nextPageToken
+ ? {
+ ...baseBody,
+ nextPageToken: previousPage.nextPageToken,
+ }
+ : null;
+ }
+
+ if (
+ previousPage.total === undefined ||
+ previousPage.startAt === undefined ||
+ issueCount === 0
+ ) {
+ return null;
+ }
+
+ const startAt = previousPage.startAt + pageSize;
+ return startAt < previousPage.total
+ ? {
+ ...baseBody,
+ startAt,
+ }
+ : null;
+ }
+
private _isInterfacesReadyIfNeeded$(cfg: JiraCfg): Observable {
if (IS_ELECTRON || IS_ANDROID_WEB_VIEW || cfg.allowFetchFallback) {
return of(true);
diff --git a/src/app/features/issue/providers/jira/jira.const.ts b/src/app/features/issue/providers/jira/jira.const.ts
index e719b2f03c..4ae90d777f 100644
--- a/src/app/features/issue/providers/jira/jira.const.ts
+++ b/src/app/features/issue/providers/jira/jira.const.ts
@@ -58,6 +58,7 @@ export const JIRA_DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSZZ';
export const JIRA_ISSUE_TYPE = 'JIRA';
export const JIRA_REQUEST_TIMEOUT_DURATION = 20000;
export const JIRA_MAX_RESULTS = 100;
+export const JIRA_MAX_AUTO_IMPORT_PAGES = 5;
export const JIRA_ADDITIONAL_ISSUE_FIELDS = [
'assignee',
'summary',
From d844559c6f39909878960beaeed1e82f3ae9b6d4 Mon Sep 17 00:00:00 2001
From: felix bear
Date: Tue, 9 Jun 2026 21:55:05 +0900
Subject: [PATCH 02/11] fix(plugins): translate yesterday tasks plugin #5103
(#8146)
Co-authored-by: cocojojo5213
---
packages/plugin-dev/scripts/build-all.js | 4 +
.../yesterday-tasks-plugin/i18n/de.json | 19 +++
.../yesterday-tasks-plugin/i18n/en.json | 19 +++
.../yesterday-tasks-plugin/index.html | 117 +++++++++++-------
.../yesterday-tasks-plugin/manifest.json | 3 +
.../yesterday-tasks-plugin/plugin.js | 2 +-
src/app/plugins/plugin-bridge.service.ts | 19 +++
src/app/plugins/plugin-runner.spec.ts | 43 ++++++-
src/app/plugins/plugin-runner.ts | 21 +++-
.../plugins/util/plugin-iframe.util.spec.ts | 48 ++++++-
src/app/plugins/util/plugin-iframe.util.ts | 5 +
11 files changed, 244 insertions(+), 56 deletions(-)
create mode 100644 packages/plugin-dev/yesterday-tasks-plugin/i18n/de.json
create mode 100644 packages/plugin-dev/yesterday-tasks-plugin/i18n/en.json
diff --git a/packages/plugin-dev/scripts/build-all.js b/packages/plugin-dev/scripts/build-all.js
index 4f1e5a41b0..0e09f46071 100755
--- a/packages/plugin-dev/scripts/build-all.js
+++ b/packages/plugin-dev/scripts/build-all.js
@@ -175,6 +175,10 @@ const plugins = [
fs.copyFileSync(src, dest);
}
}
+ const i18nSrc = path.join(pluginPath, 'i18n');
+ if (fs.existsSync(i18nSrc)) {
+ copyRecursive(i18nSrc, path.join(targetDir, 'i18n'));
+ }
return 'Copied to assets';
},
},
diff --git a/packages/plugin-dev/yesterday-tasks-plugin/i18n/de.json b/packages/plugin-dev/yesterday-tasks-plugin/i18n/de.json
new file mode 100644
index 0000000000..8f1ee6363c
--- /dev/null
+++ b/packages/plugin-dev/yesterday-tasks-plugin/i18n/de.json
@@ -0,0 +1,19 @@
+{
+ "PLUGIN": {
+ "NAME": "Aufgaben von gestern",
+ "DESCRIPTION": "Zeigt alle Aufgaben an, an denen du gestern gearbeitet hast, inklusive der jeweils erfassten Zeit."
+ },
+ "SHORTCUT": {
+ "SHOW_YESTERDAY_TASKS": "Aufgaben von gestern anzeigen"
+ },
+ "DATE": {
+ "YESTERDAY": "Gestern",
+ "DAYS_AGO": "vor {{count}} Tagen"
+ },
+ "MESSAGES": {
+ "LOADING_TASKS": "Aufgaben werden geladen...",
+ "NO_RECENT_TASKS_FOUND": "Keine aktuellen Aufgaben gefunden",
+ "NO_TASKS_LAST_30_DAYS": "In den letzten 30 Tagen wurden keine Aufgaben erfasst.",
+ "ERROR_LOADING_TASKS": "Fehler beim Laden der Aufgaben. Bitte versuche es erneut."
+ }
+}
diff --git a/packages/plugin-dev/yesterday-tasks-plugin/i18n/en.json b/packages/plugin-dev/yesterday-tasks-plugin/i18n/en.json
new file mode 100644
index 0000000000..dfe11def0e
--- /dev/null
+++ b/packages/plugin-dev/yesterday-tasks-plugin/i18n/en.json
@@ -0,0 +1,19 @@
+{
+ "PLUGIN": {
+ "NAME": "Yesterday's Tasks",
+ "DESCRIPTION": "Shows all tasks you worked on yesterday with time spent on each."
+ },
+ "SHORTCUT": {
+ "SHOW_YESTERDAY_TASKS": "Show Yesterday's Tasks"
+ },
+ "DATE": {
+ "YESTERDAY": "Yesterday",
+ "DAYS_AGO": "{{count}} days ago"
+ },
+ "MESSAGES": {
+ "LOADING_TASKS": "Loading tasks...",
+ "NO_RECENT_TASKS_FOUND": "No recent tasks found",
+ "NO_TASKS_LAST_30_DAYS": "No tasks tracked in the last 30 days.",
+ "ERROR_LOADING_TASKS": "Error loading tasks. Please try again."
+ }
+}
diff --git a/packages/plugin-dev/yesterday-tasks-plugin/index.html b/packages/plugin-dev/yesterday-tasks-plugin/index.html
index df2e30b144..a5a528e3d3 100644
--- a/packages/plugin-dev/yesterday-tasks-plugin/index.html
+++ b/packages/plugin-dev/yesterday-tasks-plugin/index.html
@@ -73,7 +73,12 @@
>
-
Loading tasks...
+
+ Loading tasks...
+
@@ -128,12 +133,56 @@
return minutes + 'm';
}
- // Wait for plugin API to be available
- function waitForPlugin() {
- if (typeof PluginAPI !== 'undefined') {
- init();
+ async function translate(key, params) {
+ if (
+ typeof PluginAPI !== 'undefined' &&
+ typeof PluginAPI.translate === 'function'
+ ) {
+ return await PluginAPI.translate(key, params);
+ }
+ return key;
+ }
+
+ async function formatPluginDate(date, format) {
+ if (
+ typeof PluginAPI !== 'undefined' &&
+ typeof PluginAPI.formatDate === 'function'
+ ) {
+ return await PluginAPI.formatDate(date, format);
+ }
+ return date.toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ }
+
+ async function applyStaticTranslations() {
+ await Promise.all(
+ Array.from(document.querySelectorAll('[data-i18n]')).map(async (el) => {
+ el.textContent = await translate(el.dataset.i18n);
+ }),
+ );
+ }
+
+ async function updateDateDisplay(result) {
+ const dateEl = document.getElementById('yesterday-date');
+ if (result.tasks.length > 0) {
+ const dateText = await formatPluginDate(result.date, 'long');
+
+ if (result.daysBack === 1) {
+ dateEl.textContent =
+ dateText + ' (' + (await translate('DATE.YESTERDAY')) + ')';
+ } else {
+ dateEl.textContent =
+ dateText +
+ ' (' +
+ (await translate('DATE.DAYS_AGO', { count: result.daysBack })) +
+ ')';
+ }
} else {
- setTimeout(waitForPlugin, 100);
+ dateEl.textContent = await translate('MESSAGES.NO_RECENT_TASKS_FOUND');
}
}
@@ -257,42 +306,27 @@
async function init() {
try {
+ await applyStaticTranslations();
const result = await getLastAvailableTasks();
const projects = await PluginAPI.getAllProjects();
- // Update the date display based on what was found
- const dateEl = document.getElementById('yesterday-date');
- if (result.tasks.length > 0) {
- const dateText = result.date.toLocaleDateString('en-US', {
- weekday: 'long',
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- });
+ await updateDateDisplay(result);
- if (result.daysBack === 1) {
- dateEl.textContent = dateText + ' (Yesterday)';
- } else {
- dateEl.textContent = dateText + ` (${result.daysBack} days ago)`;
- }
- } else {
- dateEl.textContent = 'No recent tasks found';
- }
-
- displayTasks(result.tasks, projects);
+ await displayTasks(result.tasks, projects);
} catch (error) {
console.error('Error loading tasks:', error);
+ const errorMsg = await translate('MESSAGES.ERROR_LOADING_TASKS');
document.getElementById('content').innerHTML =
- 'Error loading tasks. Please try again.
';
+ `${escapeHtml(errorMsg)}
`;
}
}
- function displayTasks(tasks, projects = []) {
+ async function displayTasks(tasks, projects = []) {
const contentEl = document.getElementById('content');
if (tasks.length === 0) {
- contentEl.innerHTML =
- 'No tasks tracked in the last 30 days.
';
+ const noTasksMsg = await translate('MESSAGES.NO_TASKS_LAST_30_DAYS');
+ contentEl.innerHTML = `${escapeHtml(noTasksMsg)}
`;
return;
}
@@ -339,26 +373,9 @@
const result = await getLastAvailableTasks();
const projects = await PluginAPI.getAllProjects();
- // Update the date display
- const dateEl = document.getElementById('yesterday-date');
- if (result.tasks.length > 0) {
- const dateText = result.date.toLocaleDateString('en-US', {
- weekday: 'long',
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- });
+ await updateDateDisplay(result);
- if (result.daysBack === 1) {
- dateEl.textContent = dateText + ' (Yesterday)';
- } else {
- dateEl.textContent = dateText + ` (${result.daysBack} days ago)`;
- }
- } else {
- dateEl.textContent = 'No recent tasks found';
- }
-
- displayTasks(result.tasks, projects);
+ await displayTasks(result.tasks, projects);
}
// Register hooks to refresh on task changes
@@ -372,6 +389,10 @@
PluginAPI.registerHook(PluginAPI.Hooks.TASK_DELETE, (taskData) => {
refreshTasks();
});
+ PluginAPI.registerHook(PluginAPI.Hooks.LANGUAGE_CHANGE, async () => {
+ await applyStaticTranslations();
+ await refreshTasks();
+ });
}
// Start
diff --git a/packages/plugin-dev/yesterday-tasks-plugin/manifest.json b/packages/plugin-dev/yesterday-tasks-plugin/manifest.json
index 3bf589d259..81a4268869 100644
--- a/packages/plugin-dev/yesterday-tasks-plugin/manifest.json
+++ b/packages/plugin-dev/yesterday-tasks-plugin/manifest.json
@@ -7,6 +7,9 @@
"description": "Shows all tasks you worked on yesterday with time spent on each.",
"hooks": [],
"permissions": ["getTasks", "getArchivedTasks", "showIndexHtmlAsView", "openDialog"],
+ "i18n": {
+ "languages": ["en", "de"]
+ },
"iFrame": true,
"sidePanel": true,
"type": "standard",
diff --git a/packages/plugin-dev/yesterday-tasks-plugin/plugin.js b/packages/plugin-dev/yesterday-tasks-plugin/plugin.js
index b17c0cf9a1..5d64f0d3b8 100644
--- a/packages/plugin-dev/yesterday-tasks-plugin/plugin.js
+++ b/packages/plugin-dev/yesterday-tasks-plugin/plugin.js
@@ -3,7 +3,7 @@ console.log("Yesterday's Tasks Plugin loaded");
// Register a keyboard shortcut
PluginAPI.registerShortcut({
id: 'show_yesterday',
- label: "Show Yesterday's Tasks",
+ label: PluginAPI.translate('SHORTCUT.SHOW_YESTERDAY_TASKS'),
onExec: function () {
PluginAPI.showIndexHtmlAsView();
},
diff --git a/src/app/plugins/plugin-bridge.service.ts b/src/app/plugins/plugin-bridge.service.ts
index d291aae84a..4f9f5a0ac5 100644
--- a/src/app/plugins/plugin-bridge.service.ts
+++ b/src/app/plugins/plugin-bridge.service.ts
@@ -85,6 +85,8 @@ import { createPluginSyncAdapter } from './issue-provider/plugin-sync-adapter.se
import { PluginOAuthBridgeService } from './oauth/plugin-oauth-bridge.service';
import { ISSUE_PROVIDER_TYPES } from '../features/issue/issue.const';
import { PluginService } from './plugin.service';
+import { PluginI18nService } from './plugin-i18n.service';
+import { formatDateForPlugin } from './plugin-i18n-date.util';
// New imports for simple counters
import { selectAllSimpleCounters } from '../features/simple-counter/store/simple-counter.reducer';
@@ -103,6 +105,8 @@ import {
import { getDbDateStr } from '../util/get-db-date-str';
import { DataInitService } from '../core/data-init/data-init.service';
+type PluginDateFormat = 'short' | 'medium' | 'long' | 'time' | 'datetime';
+
/**
* PluginBridge acts as an intermediary layer between plugins and the main application services.
* This provides:
@@ -247,6 +251,9 @@ export class PluginBridgeService implements OnDestroy {
startOAuthFlow: (config: OAuthFlowConfig) => Promise;
getOAuthToken: () => Promise;
clearOAuthToken: () => Promise;
+ translate: (key: string, params?: Record) => string;
+ formatDate: (date: Date | string | number, format: PluginDateFormat) => string;
+ getCurrentLanguage: () => string;
log: ReturnType;
} {
return {
@@ -336,6 +343,18 @@ export class PluginBridgeService implements OnDestroy {
clearOAuthToken: (): Promise =>
this._pluginOAuthBridge.clearOAuthTokens(pluginId),
+ // i18n
+ translate: (key: string, params?: Record): string =>
+ this._injector.get(PluginI18nService).translate(pluginId, key, params),
+ formatDate: (date: Date | string | number, format: PluginDateFormat): string =>
+ formatDateForPlugin(
+ date,
+ format,
+ this._injector.get(PluginI18nService).getCurrentLanguage(),
+ ),
+ getCurrentLanguage: (): string =>
+ this._injector.get(PluginI18nService).getCurrentLanguage(),
+
// Logging
log: Log.withContext(`${pluginId}`),
};
diff --git a/src/app/plugins/plugin-runner.spec.ts b/src/app/plugins/plugin-runner.spec.ts
index bf5b38ec26..f8af9e2025 100644
--- a/src/app/plugins/plugin-runner.spec.ts
+++ b/src/app/plugins/plugin-runner.spec.ts
@@ -14,6 +14,7 @@ describe('PluginRunner', () => {
let mockSnackService: jasmine.SpyObj;
let mockCleanupService: jasmine.SpyObj;
let mockI18nService: jasmine.SpyObj;
+ let registerSidePanelButtonSpy: jasmine.Spy;
const mockManifest: PluginManifest = {
id: 'test-plugin',
@@ -39,7 +40,10 @@ describe('PluginRunner', () => {
'pingNodeBridge',
]);
// createBoundMethods should return an empty object (no additional bound methods)
- mockPluginBridge.createBoundMethods.and.returnValue({} as any);
+ registerSidePanelButtonSpy = jasmine.createSpy('registerSidePanelButton');
+ mockPluginBridge.createBoundMethods.and.returnValue({
+ registerSidePanelButton: registerSidePanelButtonSpy,
+ } as any);
mockPluginBridge.pingNodeBridge.and.resolveTo(false);
mockSecurityService = jasmine.createSpyObj('PluginSecurityService', [
@@ -57,6 +61,7 @@ describe('PluginRunner', () => {
'unloadPluginTranslations',
]);
mockI18nService.getCurrentLanguage.and.returnValue('en');
+ mockI18nService.translate.and.callFake((_pluginId, key) => key);
TestBed.configureTestingModule({
providers: [
@@ -155,6 +160,42 @@ describe('PluginRunner', () => {
mockManifest,
);
});
+
+ it('uses plugin i18n for auto-registered side panel labels', async () => {
+ mockI18nService.translate
+ .withArgs('test-plugin', 'PLUGIN.NAME')
+ .and.returnValue('Aufgaben von gestern');
+
+ await service.loadPlugin(
+ {
+ ...mockManifest,
+ sidePanel: true,
+ i18n: { languages: ['en', 'de'] },
+ },
+ `/* no-op */`,
+ mockBaseCfg,
+ );
+
+ expect(registerSidePanelButtonSpy).toHaveBeenCalledOnceWith(
+ jasmine.objectContaining({ label: 'Aufgaben von gestern' }),
+ );
+ });
+
+ it('falls back to manifest name when plugin name i18n is missing', async () => {
+ await service.loadPlugin(
+ {
+ ...mockManifest,
+ sidePanel: true,
+ i18n: { languages: ['en', 'de'] },
+ },
+ `/* no-op */`,
+ mockBaseCfg,
+ );
+
+ expect(registerSidePanelButtonSpy).toHaveBeenCalledOnceWith(
+ jasmine.objectContaining({ label: 'Test Plugin' }),
+ );
+ });
});
describe('unloadPlugin', () => {
diff --git a/src/app/plugins/plugin-runner.ts b/src/app/plugins/plugin-runner.ts
index a75cd53556..18a9fc4307 100644
--- a/src/app/plugins/plugin-runner.ts
+++ b/src/app/plugins/plugin-runner.ts
@@ -84,9 +84,14 @@ export class PluginRunner {
// Register UI components for iframe plugins
// Skip menu entry if this is a side panel plugin
+ const pluginDisplayName = this._translatePluginText(
+ manifest,
+ 'PLUGIN.NAME',
+ manifest.name,
+ );
if (manifest.iFrame && !manifest.isSkipMenuEntry && !manifest.sidePanel) {
pluginAPI.registerMenuEntry({
- label: manifest.name,
+ label: pluginDisplayName,
icon: manifest.icon || 'extension',
onClick: () => pluginAPI.showIndexHtmlAsView(),
});
@@ -95,7 +100,7 @@ export class PluginRunner {
// Auto-register side panel if configured
if (manifest.sidePanel) {
pluginAPI.registerSidePanelButton({
- label: manifest.name,
+ label: pluginDisplayName,
icon: manifest.icon || 'extension',
onClick: () => {
// No-op: the side panel toggle is handled by PluginSidePanelBtnsComponent
@@ -117,6 +122,18 @@ export class PluginRunner {
}
}
+ private _translatePluginText(
+ manifest: PluginManifest,
+ key: string,
+ fallback: string,
+ ): string {
+ if (!manifest.i18n?.languages?.length) {
+ return fallback;
+ }
+ const translated = this._pluginI18nService.translate(manifest.id, key);
+ return translated === key ? fallback : translated;
+ }
+
/**
* Execute plugin code - KISS approach
*/
diff --git a/src/app/plugins/util/plugin-iframe.util.spec.ts b/src/app/plugins/util/plugin-iframe.util.spec.ts
index 26058866d8..467126a53c 100644
--- a/src/app/plugins/util/plugin-iframe.util.spec.ts
+++ b/src/app/plugins/util/plugin-iframe.util.spec.ts
@@ -11,7 +11,10 @@ import {
} from './plugin-iframe.util';
describe('handlePluginMessage()', () => {
- const createConfig = (pluginBridge: PluginBridgeService): PluginIframeConfig => ({
+ const createConfig = (
+ pluginBridge: PluginBridgeService,
+ boundMethods?: PluginIframeConfig['boundMethods'],
+ ): PluginIframeConfig => ({
pluginId: 'test-plugin',
manifest: {
id: 'test-plugin',
@@ -30,9 +33,7 @@ describe('handlePluginMessage()', () => {
isDev: false,
} as PluginBaseCfg,
pluginBridge,
- boundMethods: {} as ReturnType<
- typeof PluginBridgeService.prototype.createBoundMethods
- >,
+ boundMethods,
});
it('rebuilds iframe dialog button handlers and returns the selected result', async () => {
@@ -169,6 +170,45 @@ describe('handlePluginMessage()', () => {
);
});
+ it('routes iframe i18n API calls through plugin-bound methods', async () => {
+ const sourceWindow = jasmine.createSpyObj<{ postMessage: jasmine.Spy }>(
+ 'sourceWindow',
+ ['postMessage'],
+ );
+ const translate = jasmine
+ .createSpy('translate')
+ .withArgs('DATE.YESTERDAY', { days: 1 })
+ .and.returnValue('Gestern');
+ const pluginBridge = {
+ createBoundMethods: () => ({
+ translate,
+ }),
+ } as unknown as PluginBridgeService;
+
+ await handlePluginMessage(
+ {
+ data: {
+ type: PluginIframeMessageType.API_CALL,
+ method: 'translate',
+ callId: 11,
+ args: ['DATE.YESTERDAY', { days: 1 }],
+ },
+ source: sourceWindow,
+ } as unknown as MessageEvent,
+ createConfig(pluginBridge),
+ );
+
+ expect(translate).toHaveBeenCalledOnceWith('DATE.YESTERDAY', { days: 1 });
+ expect(sourceWindow.postMessage).toHaveBeenCalledWith(
+ {
+ type: PluginIframeMessageType.API_RESPONSE,
+ callId: 11,
+ result: 'Gestern',
+ },
+ '*',
+ );
+ });
+
it('generates iframe code that waits for async dialog button handlers', () => {
const script = createPluginApiScript(
createConfig({ createBoundMethods: () => ({}) } as unknown as PluginBridgeService),
diff --git a/src/app/plugins/util/plugin-iframe.util.ts b/src/app/plugins/util/plugin-iframe.util.ts
index c2580770ee..cfebf4d217 100644
--- a/src/app/plugins/util/plugin-iframe.util.ts
+++ b/src/app/plugins/util/plugin-iframe.util.ts
@@ -404,6 +404,11 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => {
deleteCounter: (id) => callApi('deleteCounter', [id]),
getAllCounters: () => callApi('getAllCounters'),
+ // i18n
+ translate: (key, params) => callApi('translate', [key, params]),
+ formatDate: (date, format) => callApi('formatDate', [date, format]),
+ getCurrentLanguage: () => callApi('getCurrentLanguage'),
+
// Readiness signal for iframe plugins.
//
// NOTE — semantic difference from host-side onReady:
From 1c536568890daf4224685a715ab74377db3f8183 Mon Sep 17 00:00:00 2001
From: felix bear
Date: Tue, 9 Jun 2026 22:00:43 +0900
Subject: [PATCH 03/11] fix(jira): honor no_proxy for electron requests #6324
(#8177)
Co-authored-by: cocojojo5213
---
electron/jira.ts | 2 +-
electron/proxy-agent.test.cjs | 168 ++++++++++++++++++++++++++++++++++
electron/proxy-agent.ts | 74 ++++++++++++++-
3 files changed, 241 insertions(+), 3 deletions(-)
create mode 100644 electron/proxy-agent.test.cjs
diff --git a/electron/jira.ts b/electron/jira.ts
index aad2b4ff5e..421e447173 100644
--- a/electron/jira.ts
+++ b/electron/jira.ts
@@ -18,7 +18,7 @@ export const sendJiraRequest = ({
jiraCfg: JiraCfg;
}): void => {
const mainWin = getWin();
- const agent = createProxyAwareAgent(jiraCfg?.isAllowSelfSignedCertificate);
+ const agent = createProxyAwareAgent(url, jiraCfg?.isAllowSelfSignedCertificate);
fetch(url, {
...requestInit,
diff --git a/electron/proxy-agent.test.cjs b/electron/proxy-agent.test.cjs
new file mode 100644
index 0000000000..c6b242b549
--- /dev/null
+++ b/electron/proxy-agent.test.cjs
@@ -0,0 +1,168 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const Module = require('node:module');
+const { Agent: HttpsAgent } = require('node:https');
+const { HttpsProxyAgent } = require('https-proxy-agent');
+
+require('ts-node/register/transpile-only');
+
+const originalModuleLoad = Module._load;
+const originalEnv = { ...process.env };
+const proxyAgentModulePath = path.resolve(__dirname, 'proxy-agent.ts');
+
+const resetModule = () => {
+ delete require.cache[proxyAgentModulePath];
+};
+
+const clearProxyEnv = () => {
+ delete process.env.HTTPS_PROXY;
+ delete process.env.https_proxy;
+ delete process.env.HTTP_PROXY;
+ delete process.env.http_proxy;
+ delete process.env.NO_PROXY;
+ delete process.env.no_proxy;
+};
+
+const installMocks = () => {
+ Module._load = function patchedLoad(request, parent, isMain) {
+ if (request === 'electron-log/main') {
+ return {
+ log: () => {},
+ };
+ }
+
+ return originalModuleLoad.call(this, request, parent, isMain);
+ };
+};
+
+const loadProxyAgentModule = () => {
+ resetModule();
+ return require(proxyAgentModulePath);
+};
+
+test.beforeEach(() => {
+ clearProxyEnv();
+ installMocks();
+ resetModule();
+});
+
+test.afterEach(() => {
+ Module._load = originalModuleLoad;
+ process.env = { ...originalEnv };
+ resetModule();
+});
+
+test('returns undefined when no proxy or self-signed handling is configured', () => {
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ assert.equal(
+ createProxyAwareAgent('https://jira.internal.example/rest/api'),
+ undefined,
+ );
+});
+
+test('creates a proxy agent from HTTPS_PROXY', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ const agent = createProxyAwareAgent('https://jira.external.example/rest/api');
+
+ assert.ok(agent instanceof HttpsProxyAgent);
+ assert.equal(agent.proxy.href, 'http://proxy.example:8080/');
+});
+
+test('bypasses proxy for an exact NO_PROXY host match', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.NO_PROXY = 'jira.internal.example';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ assert.equal(
+ createProxyAwareAgent('https://jira.internal.example/rest/api'),
+ undefined,
+ );
+});
+
+test('bypasses proxy for subdomains of a bare NO_PROXY domain', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.NO_PROXY = 'internal.example';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ assert.equal(
+ createProxyAwareAgent('https://jira.internal.example/rest/api'),
+ undefined,
+ );
+ assert.ok(
+ createProxyAwareAgent('https://notinternal.example/rest/api') instanceof
+ HttpsProxyAgent,
+ );
+});
+
+test('honors lowercase no_proxy and leading-dot suffix matches', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.no_proxy = '.internal.example';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ assert.equal(
+ createProxyAwareAgent('https://jira.internal.example/rest/api'),
+ undefined,
+ );
+ assert.equal(createProxyAwareAgent('https://internal.example/rest/api'), undefined);
+});
+
+test('honors wildcard NO_PROXY entries', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.NO_PROXY = '*';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ assert.equal(
+ createProxyAwareAgent('https://jira.external.example/rest/api'),
+ undefined,
+ );
+});
+
+test('honors wildcard suffix NO_PROXY entries', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.NO_PROXY = '*.corp.example';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ assert.equal(createProxyAwareAgent('https://jira.corp.example/rest/api'), undefined);
+});
+
+test('requires NO_PROXY port entries to match the request port', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.NO_PROXY = 'jira.internal.example:8443';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ assert.equal(
+ createProxyAwareAgent('https://jira.internal.example:8443/rest/api'),
+ undefined,
+ );
+ assert.ok(
+ createProxyAwareAgent('https://jira.internal.example:443/rest/api') instanceof
+ HttpsProxyAgent,
+ );
+});
+
+test('keeps self-signed handling when NO_PROXY bypasses the proxy', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.NO_PROXY = 'jira.internal.example';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ const agent = createProxyAwareAgent('https://jira.internal.example/rest/api', true);
+
+ assert.ok(agent instanceof HttpsAgent);
+ assert.equal(agent.options.rejectUnauthorized, false);
+});
+
+test('keeps proxy handling for non-matching NO_PROXY entries', () => {
+ process.env.HTTPS_PROXY = 'http://proxy.example:8080';
+ process.env.NO_PROXY = 'other.internal.example,.corp.example';
+ const { createProxyAwareAgent } = loadProxyAgentModule();
+
+ const agent = createProxyAwareAgent('https://jira.internal.example/rest/api', true);
+
+ assert.ok(agent instanceof HttpsProxyAgent);
+ assert.equal(agent.proxy.href, 'http://proxy.example:8080/');
+ assert.equal(agent.options.rejectUnauthorized, false);
+});
diff --git a/electron/proxy-agent.ts b/electron/proxy-agent.ts
index dc991a309c..c069a633be 100644
--- a/electron/proxy-agent.ts
+++ b/electron/proxy-agent.ts
@@ -13,11 +13,80 @@ export const getProxyUrl = (): string | undefined =>
process.env.http_proxy ||
undefined;
+const getNoProxy = (): string | undefined =>
+ process.env.NO_PROXY || process.env.no_proxy || undefined;
+
+const getUrlPort = (url: URL): string =>
+ url.port || (url.protocol === 'http:' ? '80' : url.protocol === 'https:' ? '443' : '');
+
+const parseNoProxyEntry = (
+ rawEntry: string,
+): { host: string; port?: string } | undefined => {
+ const entry = rawEntry.trim().toLowerCase();
+ if (!entry) {
+ return undefined;
+ }
+
+ const withoutProtocol = entry.replace(/^[a-z][a-z\d+.-]*:\/\//, '');
+ const withoutPath = withoutProtocol.split('/')[0];
+ const portMatch = withoutPath.match(/:(\d+)$/);
+ const port = portMatch?.[1];
+ const host = (port ? withoutPath.slice(0, -port.length - 1) : withoutPath).replace(
+ /^\[(.*)]$/,
+ '$1',
+ );
+
+ return host ? { host, port } : undefined;
+};
+
+export const isNoProxyMatch = (requestUrl: string, noProxy = getNoProxy()): boolean => {
+ if (!noProxy) {
+ return false;
+ }
+
+ let url: URL;
+ try {
+ url = new URL(requestUrl);
+ } catch {
+ return false;
+ }
+
+ const targetHost = url.hostname.toLowerCase();
+ const targetPort = getUrlPort(url);
+
+ return noProxy
+ .split(/[,\s]+/)
+ .map(parseNoProxyEntry)
+ .filter((entry): entry is { host: string; port?: string } => !!entry)
+ .some(({ host, port }) => {
+ if (host === '*') {
+ return true;
+ }
+
+ if (port && port !== targetPort) {
+ return false;
+ }
+
+ if (host.startsWith('*.')) {
+ const suffix = host.slice(1);
+ return targetHost.endsWith(suffix);
+ }
+
+ if (host.startsWith('.')) {
+ return targetHost === host.slice(1) || targetHost.endsWith(host);
+ }
+
+ return targetHost === host || targetHost.endsWith(`.${host}`);
+ });
+};
+
/**
* Builds a node-fetch–compatible HTTPS agent that respects:
* 1. Proxy environment variables (HTTPS_PROXY / HTTP_PROXY)
- * 2. An opt-in flag to accept self-signed certificates on the **target** server
+ * 2. NO_PROXY / no_proxy bypasses for the current request URL
+ * 3. An opt-in flag to accept self-signed certificates on the **target** server
*
+ * @param requestUrl The URL about to be requested.
* @param allowSelfSigned When `true`, TLS certificate errors on both the
* proxy **and** the target connection are ignored.
* This is intentional – the user opted in via the
@@ -26,11 +95,12 @@ export const getProxyUrl = (): string | undefined =>
* a proxy nor self-signed handling is needed.
*/
export const createProxyAwareAgent = (
+ requestUrl: string,
allowSelfSigned = false,
): HttpsProxyAgent | HttpsAgent | undefined => {
const proxyUrl = getProxyUrl();
- if (proxyUrl) {
+ if (proxyUrl && !isNoProxyMatch(requestUrl)) {
log(`Using proxy.${allowSelfSigned ? ' (self-signed certs allowed)' : ''}`);
const agent = new HttpsProxyAgent(proxyUrl, {
From 4c869c2e15e1030095564f6b5a2eb2e05ee2396f Mon Sep 17 00:00:00 2001
From: Johannes Millan
Date: Tue, 9 Jun 2026 15:03:20 +0200
Subject: [PATCH 04/11] feat(plugins): migrate Gitea and Linear issue providers
to plugins (#8204)
* feat(plugins): migrate Gitea and Linear issue providers to plugins
* feat(plugins): localize Gitea issue provider config form
Port the existing Gitea translations (F.GITEA.* + shared issue-content keys)
into the plugin's own i18n so non-English users keep localized config labels
after the plugin migration. Generated for all 28 app locales.
* docs(plugins): note Linear teamId/projectId filter is now active
The built-in Linear provider defined teamId/projectId config fields but never
passed them to the search, so they were inert. The plugin honors them as the
field labels promise; document this as a deliberate behavior change.
* test(issue): fix unknown casts in issue-provider migration spec
The spec compiled clean under the app tsconfig (which excludes *.spec.ts)
but failed CI's tsconfig.spec.json build: casting state.entities[id]
(IssueProvider | undefined) straight to Record is a
TS2352 non-overlap error, and .toBe(already) on a bare literal is a
TS2345. Add the intermediate 'as unknown' so the spec type-checks.
* ci(lighthouse): raise total resource-count budget to 260
Migrating the Gitea and Linear issue providers to bundled plugins adds
two more entries to BUNDLED_PLUGIN_PATHS. Each bundled plugin is
discovered at startup, fetching its manifest.json plus icon.svg, which
pushed the Lighthouse total request count to 251, one over the previous
250 budget. The increase is an inherent, accepted consequence of the
built-in -> bundled-plugin migration (same as github/clickup); bump the
budget to 260 to reflect the new baseline with modest headroom.
---
.../gitea-issue-provider/i18n/ar.json | 22 +
.../gitea-issue-provider/i18n/cs.json | 22 +
.../gitea-issue-provider/i18n/de.json | 22 +
.../gitea-issue-provider/i18n/en.json | 22 +
.../gitea-issue-provider/i18n/es.json | 22 +
.../gitea-issue-provider/i18n/fa.json | 22 +
.../gitea-issue-provider/i18n/fi.json | 22 +
.../gitea-issue-provider/i18n/fr.json | 22 +
.../gitea-issue-provider/i18n/hr.json | 22 +
.../gitea-issue-provider/i18n/id.json | 22 +
.../gitea-issue-provider/i18n/it.json | 22 +
.../gitea-issue-provider/i18n/ja.json | 22 +
.../gitea-issue-provider/i18n/ko.json | 22 +
.../gitea-issue-provider/i18n/nb.json | 22 +
.../gitea-issue-provider/i18n/nl.json | 22 +
.../gitea-issue-provider/i18n/pl.json | 22 +
.../gitea-issue-provider/i18n/pt-br.json | 22 +
.../gitea-issue-provider/i18n/pt.json | 22 +
.../gitea-issue-provider/i18n/ro-md.json | 22 +
.../gitea-issue-provider/i18n/ro.json | 22 +
.../gitea-issue-provider/i18n/ru.json | 22 +
.../gitea-issue-provider/i18n/sk.json | 22 +
.../gitea-issue-provider/i18n/sv.json | 22 +
.../gitea-issue-provider/i18n/tr.json | 22 +
.../gitea-issue-provider/i18n/uk.json | 22 +
.../gitea-issue-provider/i18n/vi.json | 22 +
.../gitea-issue-provider/i18n/zh-tw.json | 22 +
.../gitea-issue-provider/i18n/zh.json | 22 +
.../plugin-dev/gitea-issue-provider/icon.svg | 1 +
.../gitea-issue-provider/package-lock.json | 532 ++++++++++++++++++
.../gitea-issue-provider/package.json | 17 +
.../gitea-issue-provider/scripts/build.js | 67 +++
.../gitea-issue-provider/src/manifest.json | 57 ++
.../gitea-issue-provider/src/plugin.ts | 371 ++++++++++++
.../gitea-issue-provider/tsconfig.json | 16 +
.../linear-issue-provider/i18n/en.json | 17 +
.../plugin-dev/linear-issue-provider/icon.svg | 1 +
.../linear-issue-provider/package-lock.json | 532 ++++++++++++++++++
.../linear-issue-provider/package.json | 17 +
.../linear-issue-provider/scripts/build.js | 67 +++
.../linear-issue-provider/src/manifest.json | 27 +
.../linear-issue-provider/src/plugin.ts | 319 +++++++++++
.../linear-issue-provider/tsconfig.json | 16 +
...sue-provider-setup-overview.component.html | 18 +-
.../issue-content-configs.const.ts | 6 +-
src/app/features/issue/issue.const.ts | 26 +-
src/app/features/issue/issue.model.ts | 58 +-
src/app/features/issue/issue.service.spec.ts | 4 -
src/app/features/issue/issue.service.ts | 10 +-
.../get-issue-provider-tooltip.ts | 5 -
.../issue/mapping-helper/is-issue-done.ts | 11 -
.../gitea/format-gitea-issue-title.util.ts | 10 -
.../providers/gitea/gitea-api-responses.d.ts | 14 -
.../providers/gitea/gitea-api.service.ts | 296 ----------
.../providers/gitea/gitea-cfg-form.const.ts | 108 ----
.../gitea-common-interfaces.service.spec.ts | 40 --
.../gitea/gitea-common-interfaces.service.ts | 84 ---
.../gitea/gitea-issue-content.const.ts | 51 --
.../gitea/gitea-issue-map.util.spec.ts | 105 ----
.../providers/gitea/gitea-issue-map.util.ts | 59 --
.../providers/gitea/gitea-issue.model.ts | 47 --
.../issue/providers/gitea/gitea.const.ts | 15 -
.../issue/providers/gitea/gitea.model.ts | 10 -
.../linear/linear-api.service.spec.ts | 264 ---------
.../providers/linear/linear-api.service.ts | 370 ------------
.../providers/linear/linear-cfg-form.const.ts | 64 ---
.../linear-common-interfaces.service.ts | 112 ----
.../linear/linear-issue-content.const.ts | 59 --
.../providers/linear/linear-issue-map.util.ts | 24 -
.../providers/linear/linear-issue.model.ts | 62 --
.../issue/providers/linear/linear.const.ts | 13 -
.../issue/providers/linear/linear.model.ts | 7 -
.../store/issue-provider.reducer.spec.ts | 136 +++++
.../issue/store/issue-provider.reducer.ts | 45 ++
.../dialog-create-project.component.ts | 2 -
.../plugin-issue-provider-adapter.service.ts | 17 +-
src/app/plugins/plugin.service.ts | 2 +
tools/lighthouse/budget.json | 2 +-
78 files changed, 2905 insertions(+), 1924 deletions(-)
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/ar.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/cs.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/de.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/en.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/es.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/fa.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/fi.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/fr.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/hr.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/id.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/it.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/ja.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/ko.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/nb.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/nl.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/pl.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/pt-br.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/pt.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/ro-md.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/ro.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/ru.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/sk.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/sv.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/tr.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/uk.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/vi.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/zh-tw.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/i18n/zh.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/icon.svg
create mode 100644 packages/plugin-dev/gitea-issue-provider/package-lock.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/package.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/scripts/build.js
create mode 100644 packages/plugin-dev/gitea-issue-provider/src/manifest.json
create mode 100644 packages/plugin-dev/gitea-issue-provider/src/plugin.ts
create mode 100644 packages/plugin-dev/gitea-issue-provider/tsconfig.json
create mode 100644 packages/plugin-dev/linear-issue-provider/i18n/en.json
create mode 100644 packages/plugin-dev/linear-issue-provider/icon.svg
create mode 100644 packages/plugin-dev/linear-issue-provider/package-lock.json
create mode 100644 packages/plugin-dev/linear-issue-provider/package.json
create mode 100644 packages/plugin-dev/linear-issue-provider/scripts/build.js
create mode 100644 packages/plugin-dev/linear-issue-provider/src/manifest.json
create mode 100644 packages/plugin-dev/linear-issue-provider/src/plugin.ts
create mode 100644 packages/plugin-dev/linear-issue-provider/tsconfig.json
delete mode 100644 src/app/features/issue/providers/gitea/format-gitea-issue-title.util.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-api-responses.d.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-api.service.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-cfg-form.const.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-common-interfaces.service.spec.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-common-interfaces.service.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-issue-content.const.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-issue-map.util.spec.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-issue-map.util.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea-issue.model.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea.const.ts
delete mode 100644 src/app/features/issue/providers/gitea/gitea.model.ts
delete mode 100644 src/app/features/issue/providers/linear/linear-api.service.spec.ts
delete mode 100644 src/app/features/issue/providers/linear/linear-api.service.ts
delete mode 100644 src/app/features/issue/providers/linear/linear-cfg-form.const.ts
delete mode 100644 src/app/features/issue/providers/linear/linear-common-interfaces.service.ts
delete mode 100644 src/app/features/issue/providers/linear/linear-issue-content.const.ts
delete mode 100644 src/app/features/issue/providers/linear/linear-issue-map.util.ts
delete mode 100644 src/app/features/issue/providers/linear/linear-issue.model.ts
delete mode 100644 src/app/features/issue/providers/linear/linear.const.ts
delete mode 100644 src/app/features/issue/providers/linear/linear.model.ts
create mode 100644 src/app/features/issue/store/issue-provider.reducer.spec.ts
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/ar.json b/packages/plugin-dev/gitea-issue-provider/i18n/ar.json
new file mode 100644
index 0000000000..5c8d992a94
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/ar.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "المضيف (على سبيل المثال: \nhttps://try.gitea.io)",
+ "TOKEN": "رمز الوصول",
+ "REPO_FULL_NAME": "اسم المستخدم أو اسم المؤسسة/المشروع",
+ "SCOPE": "النطاق",
+ "SCOPE_ALL": "الكل",
+ "SCOPE_CREATED": "تم إنشاؤه بواسطتي",
+ "SCOPE_ASSIGNED": "تم تعيينه لي",
+ "FILTER_LABELS": "التصفية حسب التسميات (اختياري)",
+ "EXCLUDE_LABELS": "استبعاد التسميات (اختياري)",
+ "HOW_TO_GET_TOKEN": "كيف تحصل على الرمز المميز؟"
+ },
+ "DISPLAY": {
+ "SUMMARY": "ملخص",
+ "STATE": "حالة",
+ "ASSIGNEE": "المحال",
+ "LABELS": "تسميات",
+ "DESCRIPTION": "وصف"
+ },
+ "FORM_HELP": "هنا يمكنك تهيئة 'سوبر برودكتيفيتي' لإدراج مشكلات ’جيتيا’ المفتوحة لمستودع معين في لوحة إنشاء المهام في عرض التخطيط اليومي. سيتم سردها كاقتراحات وستوفر رابطًا للمشكلة بالإضافة إلى مزيد من المعلومات عنها.
بالإضافة إلى ذلك يمكنك إضافة واستيراد جميع المشكلات المفتوحة تلقائيًا.
للحصول على حدود الاستخدام والوصول يمكنك توفير رمز وصول مميز."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/cs.json b/packages/plugin-dev/gitea-issue-provider/i18n/cs.json
new file mode 100644
index 0000000000..30ca0392f4
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/cs.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Hostitel (např.: https://try.gitea.io)",
+ "TOKEN": "Přístupový token",
+ "REPO_FULL_NAME": "Uživatelské jméno nebo název organizace/projektu",
+ "SCOPE": "Rozsah",
+ "SCOPE_ALL": "Všechny",
+ "SCOPE_CREATED": "Vytvořeno mnou",
+ "SCOPE_ASSIGNED": "Přiřazeno mně",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "Jak získat token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Shrnutí",
+ "STATE": "Stav",
+ "ASSIGNEE": "Přiřazený",
+ "LABELS": "Štítky",
+ "DESCRIPTION": "Popis"
+ },
+ "FORM_HELP": "
Zde můžete nakonfigurovat SuperProductivity tak, aby zobrazoval otevřené problémy Gitea pro konkrétní repozitář v panelu vytváření úkolů v denním plánovacím zobrazení. Budou uvedeny jako návrhy a poskytnou odkaz na problém a další informace o něm.
Kromě toho můžete automaticky přidat a importovat všechny otevřené problémy.
Pro překročení omezení a přístup můžete poskytnout přístupový token."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/de.json b/packages/plugin-dev/gitea-issue-provider/i18n/de.json
new file mode 100644
index 0000000000..feab9e16cf
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/de.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (z. B. : https://try.gitea.io)",
+ "TOKEN": "Zugangstoken",
+ "REPO_FULL_NAME": "Benutzername oder Organisationsname/Projekt",
+ "SCOPE": "Umfang",
+ "SCOPE_ALL": "Alle",
+ "SCOPE_CREATED": "Von mir erstellt",
+ "SCOPE_ASSIGNED": "Mir zugewiesen",
+ "FILTER_LABELS": "Nach Labels filtern (optional)",
+ "EXCLUDE_LABELS": "Labels ausschließen (optional)",
+ "HOW_TO_GET_TOKEN": "Wie bekomme ich einen Token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Zusammenfassung",
+ "STATE": "Status",
+ "ASSIGNEE": "Zuständiger",
+ "LABELS": "Beschriftungen",
+ "DESCRIPTION": "Beschreibung"
+ },
+ "FORM_HELP": "
Hier kannst du SuperProductivity so konfigurieren, dass offene Gitea-Issues für ein bestimmtes Repository im Aufgabenerstellungsfenster in der Tagesplanungsansicht aufgelistet werden. Sie werden als Vorschläge angezeigt und enthalten einen Link zum Issue sowie weitere Informationen dazu.
Darüber hinaus kannst du alle offenen Issues automatisch hinzufügen und importieren.
Um Nutzungsbeschränkungen zu umgehen und Zugriff zu erhalten, kannst du ein Zugriffstoken bereitstellen."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/en.json b/packages/plugin-dev/gitea-issue-provider/i18n/en.json
new file mode 100644
index 0000000000..3f44bcbe86
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/en.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (e.g. https://try.gitea.io)",
+ "TOKEN": "Access Token",
+ "REPO_FULL_NAME": "Username or Organization name/repository",
+ "SCOPE": "Scope",
+ "SCOPE_ALL": "All",
+ "SCOPE_CREATED": "Created by me",
+ "SCOPE_ASSIGNED": "Assigned to me",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "How to get a token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Summary",
+ "STATE": "Status",
+ "ASSIGNEE": "Assignee",
+ "LABELS": "Labels",
+ "DESCRIPTION": "Description"
+ },
+ "FORM_HELP": "
Here you can configure Super Productivity to list open Gitea issues for a specific repository in the task creation panel in the daily planning view. They will be listed as suggestions and will provide a link to the issue as well as more information about it.
In addition, you can automatically add and import all open issues.
To get by usage limits and gain access, you can provide an access token."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/es.json b/packages/plugin-dev/gitea-issue-provider/i18n/es.json
new file mode 100644
index 0000000000..16bb5232d7
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/es.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (p. ej.: https://try.gitea.io)",
+ "TOKEN": "Token de acceso",
+ "REPO_FULL_NAME": "Nombre de usuario o nombre de la organización/proyecto",
+ "SCOPE": "Alcance",
+ "SCOPE_ALL": "Todos",
+ "SCOPE_CREATED": "Creado por mí",
+ "SCOPE_ASSIGNED": "Asignado a mí",
+ "FILTER_LABELS": "Filtrar por etiquetas (opcional)",
+ "EXCLUDE_LABELS": "Excluir etiquetas (opcional)",
+ "HOW_TO_GET_TOKEN": "¿Cómo obtener un token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Resumen",
+ "STATE": "Estado",
+ "ASSIGNEE": "Asignado",
+ "LABELS": "Etiquetas",
+ "DESCRIPTION": "Descripción"
+ },
+ "FORM_HELP": "
Aquí puedes configurar SuperProductivity para listar las incidencias de Gitea abiertas para un repositorio específico en el panel de creación de tareas en la vista de planificación diaria. Se listarán como sugerencias y proporcionarán un enlace a la incidencia así como más información sobre ella.
Además, puedes añadir e importar automáticamente todas las incidencias abiertas.
Para superar los límites de uso y acceder puedes proporcionar un token de acceso."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/fa.json b/packages/plugin-dev/gitea-issue-provider/i18n/fa.json
new file mode 100644
index 0000000000..4510c19ac0
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/fa.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "میزبان (به عنوان مثال: https://try.gitea.io)",
+ "TOKEN": "توکن دسترسی",
+ "REPO_FULL_NAME": "نام کاربری یا نام سازمان/پروژه",
+ "SCOPE": "دامنه",
+ "SCOPE_ALL": "همه",
+ "SCOPE_CREATED": "ایجاد شده توسط من",
+ "SCOPE_ASSIGNED": "به من اختصاص داده است",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "چگونه یک توکن دریافت کنیم؟"
+ },
+ "DISPLAY": {
+ "SUMMARY": "خلاصه",
+ "STATE": "وضعیت",
+ "ASSIGNEE": "وکیل",
+ "LABELS": "برچسب",
+ "DESCRIPTION": "توضیحات"
+ },
+ "FORM_HELP": "
در اینجا میتوانید SuperProductivity را پیکربندی کنید تا مشکلات باز Gitea را برای یک مخزن خاص در پانل ایجاد کار در نمای برنامهریزی روزانه فهرست کند. آنها به عنوان پیشنهاد فهرست می شوند و پیوندی به موضوع و همچنین اطلاعات بیشتری در مورد آن ارائه می دهند.
علاوه بر این، میتوانید بهطور خودکار همه مسائل باز را اضافه و وارد کنید.
برای دریافت محدودیتهای استفاده و دسترسی، میتوانید یک نشانه دسترسی ارائه کنید."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/fi.json b/packages/plugin-dev/gitea-issue-provider/i18n/fi.json
new file mode 100644
index 0000000000..b953ee99c6
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/fi.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Isäntä (esim.: https://try.gitea.io)",
+ "TOKEN": "Käyttöoikeustunnus",
+ "REPO_FULL_NAME": "Käyttäjänimi tai organisaation nimi/projekti",
+ "SCOPE": "Laajuus",
+ "SCOPE_ALL": "Kaikki",
+ "SCOPE_CREATED": "Minun luomat",
+ "SCOPE_ASSIGNED": "Minulle osoitetut",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "Miten saan tunnuksen?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Yhteenveto",
+ "STATE": "Tila",
+ "ASSIGNEE": "Vastuuhenkilö",
+ "LABELS": "Tunnisteet",
+ "DESCRIPTION": "Kuvaus"
+ },
+ "FORM_HELP": "
Tässä voit määrittää SuperProductivityn listamaan avoimet Gitea-ongelmat tietystä arkistosta tehtävänluontipaneelissa päivittäisessä suunnittelinäkymässä. Ne listataan ehdotuksina ja tarjoavat linkin ongelmaan sekä lisätietoja siitä.
Lisäksi voit automaattisesti lisätä ja tuoda kaikki avoimet ongelmat.
Käyttörajoitusten ja pääsyn saamiseksi voit antaa käyttöoikeustunnuksen."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/fr.json b/packages/plugin-dev/gitea-issue-provider/i18n/fr.json
new file mode 100644
index 0000000000..958b36c827
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/fr.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Hôte (e.g.: https://try.gitea.io)",
+ "TOKEN": "Jeton d'accès",
+ "REPO_FULL_NAME": "Nom d'utilisateur ou nom de l'organisation/projet",
+ "SCOPE": "Portée",
+ "SCOPE_ALL": "Tout",
+ "SCOPE_CREATED": "Créé par moi",
+ "SCOPE_ASSIGNED": "Attribués à moi",
+ "FILTER_LABELS": "Filtrer par étiquettes (facultatif)",
+ "EXCLUDE_LABELS": "Exclure des étiquettes (facultatif)",
+ "HOW_TO_GET_TOKEN": "Comment obtenir un jeton ?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Résumé",
+ "STATE": "Statut",
+ "ASSIGNEE": "Bénéficiaire",
+ "LABELS": "Étiquettes",
+ "DESCRIPTION": "Description"
+ },
+ "FORM_HELP": "
Ici, vous pouvez configurer SuperProductivity pour lister les problèmes ouverts de Gitea pour un dépôt spécifique dans le panneau de création de tâches dans la vue de planification quotidienne. Ils seront listés comme suggestions et fourniront un lien vers le problème ainsi que plus d'informations à son sujet.
En plus, vous pouvez automatiquement ajouter et importer tous les problèmes ouverts.
Pour contourner les limites d'utilisation et pour accéder, vous pouvez fournir un jeton d'accès.
"
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/hr.json b/packages/plugin-dev/gitea-issue-provider/i18n/hr.json
new file mode 100644
index 0000000000..803e9bfba2
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/hr.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (npr. https://try.gitea.io)",
+ "TOKEN": "Token za pristup",
+ "REPO_FULL_NAME": "Korisničko ime ili ime organizacije/repozitorija",
+ "SCOPE": "Opseg",
+ "SCOPE_ALL": "Sve",
+ "SCOPE_CREATED": "Stvoreno od mene",
+ "SCOPE_ASSIGNED": "Dodijeljeno meni",
+ "FILTER_LABELS": "Filtriraj prema oznakama (opcionalno)",
+ "EXCLUDE_LABELS": "Isključi oznake (opcionalno)",
+ "HOW_TO_GET_TOKEN": "Kako dobiti token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Sažetak",
+ "STATE": "Status",
+ "ASSIGNEE": "Dodijeljeno",
+ "LABELS": "Oznake",
+ "DESCRIPTION": "Opis"
+ },
+ "FORM_HELP": "Ovdje možeš podesiti Super Productivity da prikazuje otvorene Gitea probleme za određeni repozitorij u ploči za stvaranje zadataka u dnevnom planiranju. Prikazat će se kao prijedlozi i imat će poveznicu na problem kao i dodatne informacije.
Također možeš automatski dodati i uvesti sve otvorene probleme.
Za izbjegavanja ograničenja i dobivanja pristupa, unesi token za pristup.
"
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/id.json b/packages/plugin-dev/gitea-issue-provider/i18n/id.json
new file mode 100644
index 0000000000..33b6da61ca
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/id.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (contoh: https://try.gitea.io)",
+ "TOKEN": "Akses Token",
+ "REPO_FULL_NAME": "Nama pengguna atau nama/proyek Organisasi",
+ "SCOPE": "Cakupan",
+ "SCOPE_ALL": "Semua",
+ "SCOPE_CREATED": "Dibuat oleh saya",
+ "SCOPE_ASSIGNED": "Ditugaskan kepada saya",
+ "FILTER_LABELS": "Filter berdasarkan label (opsional)",
+ "EXCLUDE_LABELS": "Kecualikan label (opsional)",
+ "HOW_TO_GET_TOKEN": "Bagaimana cara mendapatkan token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Ringkasan",
+ "STATE": "Status",
+ "ASSIGNEE": "Penanggung jawab",
+ "LABELS": "Label",
+ "DESCRIPTION": "Deskripsi"
+ },
+ "FORM_HELP": "Di sini Anda dapat mengonfigurasi SuperProductivity untuk membuat daftar open Gitea issues untuk repositori tertentu di panel pembuatan tugas dalam tampilan perencanaan harian. Mereka akan dicantumkan sebagai saran dan akan memberikan tautan ke masalah tersebut serta informasi lebih lanjut tentangnya.
Selain itu, Anda dapat secara otomatis menambahkan dan menyinkronkan semua open issues ke backlog tugas Anda.
Untuk mencapai batas penggunaan dan untuk mengakses, Anda dapat memberikan token akses.
"
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/it.json b/packages/plugin-dev/gitea-issue-provider/i18n/it.json
new file mode 100644
index 0000000000..58e49f6bd1
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/it.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (es.: https://try.gitea.io)",
+ "TOKEN": "Token di accesso",
+ "REPO_FULL_NAME": "Username o nome Organizzazione/Progetto",
+ "SCOPE": "Scopo",
+ "SCOPE_ALL": "Tutti",
+ "SCOPE_CREATED": "Create da me",
+ "SCOPE_ASSIGNED": "Assegnate a me",
+ "FILTER_LABELS": "Filtra per etichette (facoltativo)",
+ "EXCLUDE_LABELS": "Escludi etichette (facoltativo)",
+ "HOW_TO_GET_TOKEN": "Come ottenere un token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Riepilogo",
+ "STATE": "Stato",
+ "ASSIGNEE": "Assegnatario",
+ "LABELS": "Etichette",
+ "DESCRIPTION": "Descrizione"
+ },
+ "FORM_HELP": "Qui è possibile configurare SuperProductivity in modo che elenchi le issue aperte di Gitea per uno specifico repository nel pannello di creazione delle attività nella vista di pianificazione giornaliera. Saranno elencati come suggerimenti e forniranno un link all'issue e ulteriori informazioni su di esso.
Inoltre è possibile aggiungere e importare automaticamente tutte le issue aperte.
Per superare i limiti di utilizzo e accedere è possibile fornire un token di accesso."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/ja.json b/packages/plugin-dev/gitea-issue-provider/i18n/ja.json
new file mode 100644
index 0000000000..e796cf6075
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/ja.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "ホスト (例:https://try.gitea.io)",
+ "TOKEN": "アクセストークン",
+ "REPO_FULL_NAME": "ユーザー名または組織名/プロジェクト名",
+ "SCOPE": "範囲",
+ "SCOPE_ALL": "全て",
+ "SCOPE_CREATED": "私が作った",
+ "SCOPE_ASSIGNED": "私に割り当てられた",
+ "FILTER_LABELS": "ラベルで絞り込み(任意)",
+ "EXCLUDE_LABELS": "ラベルを除外(任意)",
+ "HOW_TO_GET_TOKEN": "トークンの入手方法は?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "概要",
+ "STATE": "地位",
+ "ASSIGNEE": "担当者",
+ "LABELS": "ラベル",
+ "DESCRIPTION": "形容"
+ },
+ "FORM_HELP": "
ここで SuperProductivity を設定し、日次計画ビューのタスク作成パネルで特定のリポジトリに対して未解決の Gitea 課題を一覧表示させることができます。それらは提案としてリストされ、課題へのリンクとそれに関する詳細情報が提供されます。
さらに、すべてのオープン課題を自動的に追加してインポートすることもできます。
利用制限を通過してアクセスするには、アクセストークンを提供することができます。"
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/ko.json b/packages/plugin-dev/gitea-issue-provider/i18n/ko.json
new file mode 100644
index 0000000000..ce371721ec
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/ko.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "호스트(예: https://try.gitea.io)",
+ "TOKEN": "액세스 토큰",
+ "REPO_FULL_NAME": "사용자 이름 또는 조직 이름/프로젝트",
+ "SCOPE": "범위",
+ "SCOPE_ALL": "모두",
+ "SCOPE_CREATED": "작성자: 나",
+ "SCOPE_ASSIGNED": "나에게 할당됨",
+ "FILTER_LABELS": "라벨로 필터링(선택 사항)",
+ "EXCLUDE_LABELS": "라벨 제외(선택 사항)",
+ "HOW_TO_GET_TOKEN": "토큰은 어떻게 받나요?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "요약",
+ "STATE": "상태",
+ "ASSIGNEE": "담당자",
+ "LABELS": "레이블",
+ "DESCRIPTION": "묘사"
+ },
+ "FORM_HELP": "
여기에서 일일 계획 보기의 작업 만들기 패널에서 특정 리포지토리에 대해 열려 있는 Gitea 이슈를 나열하도록 SuperProductivity를 구성할 수 있습니다. 이슈는 제안 사항으로 나열되며 이슈에 대한 링크와 자세한 정보를 제공합니다.
또한 열려 있는 모든 이슈를 자동으로 추가하고 가져올 수 있습니다.
사용량 제한을 통과하고 액세스하려면 액세스 토큰을 제공할 수 있습니다."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/nb.json b/packages/plugin-dev/gitea-issue-provider/i18n/nb.json
new file mode 100644
index 0000000000..b00ff99267
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/nb.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Vert (f.eks.: https://try.gitea.io)",
+ "TOKEN": "Access Token",
+ "REPO_FULL_NAME": "Brukernavn eller organisasjonsnavn/prosjekt",
+ "SCOPE": "Omfang",
+ "SCOPE_ALL": "Alle",
+ "SCOPE_CREATED": "Laget av meg",
+ "SCOPE_ASSIGNED": "Tildelt til meg",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "Hvordan får man et token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Sammendrag",
+ "STATE": "Status",
+ "ASSIGNEE": "Tildelt",
+ "LABELS": "Etiketter",
+ "DESCRIPTION": "Beskrivelse"
+ },
+ "FORM_HELP": "
Her kan du konfigurere SuperProductivity til å liste åpne Gitea-problemer for et spesifikt repository i opprettingspanelet for oppgaver i den daglige planleggingsvisningen. De vil bli listet som forslag og vil gi en lenke til problemet samt mer informasjon om det.<\\/p>
I tillegg kan du automatisk legge til og importere alle åpne problemer.<\\/p>
For å komme forbi bruksgrensene og få tilgang kan du gi et tilgangstoken."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/nl.json b/packages/plugin-dev/gitea-issue-provider/i18n/nl.json
new file mode 100644
index 0000000000..64be91178b
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/nl.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (bijv. https://try.gitea.io)",
+ "TOKEN": "Access Token",
+ "REPO_FULL_NAME": "Gebruikersnaam of Organisatie naam/project",
+ "SCOPE": "Toepassingsgebied",
+ "SCOPE_ALL": "Alle",
+ "SCOPE_CREATED": "Aangemaakt door mij",
+ "SCOPE_ASSIGNED": "Aan mij toegewezen",
+ "FILTER_LABELS": "Filteren op labels (optioneel)",
+ "EXCLUDE_LABELS": "Labels uitsluiten (optioneel)",
+ "HOW_TO_GET_TOKEN": "Hoe krijg ik een token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Samenvatting",
+ "STATE": "Status",
+ "ASSIGNEE": "Toegewezen persoon",
+ "LABELS": "Labels",
+ "DESCRIPTION": "Beschrijving"
+ },
+ "FORM_HELP": "
Hier kun je SuperProductivity configureren om open Gitea kwesties voor een specifieke repository te tonen in het paneel voor het aanmaken van taken in de dagelijkse planningsweergave. Ze worden weergegeven als suggesties en geven een link naar het issue en meer informatie over het issue.
Daarnaast kun je automatisch alle openstaande issues toevoegen en importeren.
Om de gebruikslimieten te omzeilen en toegang te krijgen kun je een toegangstoken geven."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/pl.json b/packages/plugin-dev/gitea-issue-provider/i18n/pl.json
new file mode 100644
index 0000000000..7698afb87a
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/pl.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Host (np. https://try.gitea.io)",
+ "TOKEN": "Token dostępu",
+ "REPO_FULL_NAME": "Nazwa użytkownika lub nazwa organizacji/projektu",
+ "SCOPE": "Zakres",
+ "SCOPE_ALL": "Wszystkie",
+ "SCOPE_CREATED": "Stworzony przeze mnie",
+ "SCOPE_ASSIGNED": "Przydzielony do mnie",
+ "FILTER_LABELS": "Filtruj według etykiet (opcjonalnie)",
+ "EXCLUDE_LABELS": "Wyklucz etykiety (opcjonalnie)",
+ "HOW_TO_GET_TOKEN": "Jak zdobyć token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Podsumowanie",
+ "STATE": "Status",
+ "ASSIGNEE": "Osoba przypisana",
+ "LABELS": "Etykiety",
+ "DESCRIPTION": "Opis"
+ },
+ "FORM_HELP": "
Tutaj można skonfigurować SuperProductivity tak, aby wyświetlał listę otwartych zgłoszeń Gitea dla określonego repozytorium w panelu tworzenia zadań w widoku planowania dziennego. Będą one wyświetlane jako sugestie i będą zawierać link do zgłoszenia oraz więcej informacji na jego temat.
Ponadto możesz automatycznie dodawać i importować wszystkie otwarte zgłoszenia.
Aby ominąć limity użytkowania i uzyskać dostęp, możesz podać token dostępu."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/pt-br.json b/packages/plugin-dev/gitea-issue-provider/i18n/pt-br.json
new file mode 100644
index 0000000000..c7ebb1c4ff
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/pt-br.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Servidor (ex: https://try.gitea.io)",
+ "TOKEN": "Token de Acesso",
+ "REPO_FULL_NAME": "Nome do Usuário ou Organização/repositório",
+ "SCOPE": "Escopo",
+ "SCOPE_ALL": "Todos",
+ "SCOPE_CREATED": "Criado por mim",
+ "SCOPE_ASSIGNED": "Atribuído a mim",
+ "FILTER_LABELS": "Filtrar por rótulos (opcional)",
+ "EXCLUDE_LABELS": "Excluir rótulos (opcional)",
+ "HOW_TO_GET_TOKEN": "Como obter um token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Resumo",
+ "STATE": "Status",
+ "ASSIGNEE": "Responsável",
+ "LABELS": "Etiquetas",
+ "DESCRIPTION": "Descrição"
+ },
+ "FORM_HELP": "
Aqui você pode configurar o Super Productivity para listar issues abertas do Gitea de um repositório específico no painel de criação de tarefas na visualização de planejador. Elas serão listadas como sugestões e fornecerão um link para a issue, bem como mais informações sobre ela.
Além disso, você pode adicionar e importar automaticamente todas as issues abertas.
Para contornar limites de uso e para ter acesso, você pode fornecer um token de acesso."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/pt.json b/packages/plugin-dev/gitea-issue-provider/i18n/pt.json
new file mode 100644
index 0000000000..7d35f33d26
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/pt.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Endereço do servidor (ex: https://try.gitea.io)",
+ "TOKEN": "Token de acesso",
+ "REPO_FULL_NAME": "Nome do usuário ou organização/projeto",
+ "SCOPE": "Escopo",
+ "SCOPE_ALL": "Todos",
+ "SCOPE_CREATED": "Criados por mim",
+ "SCOPE_ASSIGNED": "Atribuídos a mim",
+ "FILTER_LABELS": "Filtrar por etiquetas (opcional)",
+ "EXCLUDE_LABELS": "Excluir etiquetas (opcional)",
+ "HOW_TO_GET_TOKEN": "Como obter um token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Resumo",
+ "STATE": "Estado",
+ "ASSIGNEE": "Atribuído",
+ "LABELS": "Etiquetas",
+ "DESCRIPTION": "Descrição"
+ },
+ "FORM_HELP": "
Aqui você pode configurar o SuperProductivity para listar tarefas do Gitea de um repositório específico no painel de tarefas na tela de planejamento diário. Elas também serão listadas como sugestões e será disponibilizado um link para a tarefa bem como mais informações sobre a mesma.
Além disso você pode adicionar e sincronizar todas as tarefas abertas para o seu backlog.
"
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/ro-md.json b/packages/plugin-dev/gitea-issue-provider/i18n/ro-md.json
new file mode 100644
index 0000000000..728874fd21
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/ro-md.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Gazdă (d.e. https://try.gitea.io)",
+ "TOKEN": "Jeton de acces",
+ "REPO_FULL_NAME": "Nume utilizator sau nume organizație/proiect",
+ "SCOPE": "Domeniu de aplicare",
+ "SCOPE_ALL": "Toate",
+ "SCOPE_CREATED": "Create de mine",
+ "SCOPE_ASSIGNED": "Atribuite mie",
+ "FILTER_LABELS": "Filtrează după etichete (opțional)",
+ "EXCLUDE_LABELS": "Exclude etichetele (opțional)",
+ "HOW_TO_GET_TOKEN": "Cum se obține un jeton?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Sumar",
+ "STATE": "Stare",
+ "ASSIGNEE": "Asignat lui",
+ "LABELS": "Etichete",
+ "DESCRIPTION": "Descriere"
+ },
+ "FORM_HELP": "
Aici poți configura Super Productivity pentru a lista problemele deschise pe Gitea pentru un repository specific în panoul de creare a sarcinilor în modul de planificare zilnic. Acestea vor fi listate ca sugestii și vor conține o legătură către problemă și mai multe informații despre acesta.
În plus, poți adăuga și importa automat toate problemele deschise.
Pentru a fenta limitele de utilizare și acces, poți folosi un jeton de acces."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/ro.json b/packages/plugin-dev/gitea-issue-provider/i18n/ro.json
new file mode 100644
index 0000000000..728874fd21
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/ro.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Gazdă (d.e. https://try.gitea.io)",
+ "TOKEN": "Jeton de acces",
+ "REPO_FULL_NAME": "Nume utilizator sau nume organizație/proiect",
+ "SCOPE": "Domeniu de aplicare",
+ "SCOPE_ALL": "Toate",
+ "SCOPE_CREATED": "Create de mine",
+ "SCOPE_ASSIGNED": "Atribuite mie",
+ "FILTER_LABELS": "Filtrează după etichete (opțional)",
+ "EXCLUDE_LABELS": "Exclude etichetele (opțional)",
+ "HOW_TO_GET_TOKEN": "Cum se obține un jeton?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Sumar",
+ "STATE": "Stare",
+ "ASSIGNEE": "Asignat lui",
+ "LABELS": "Etichete",
+ "DESCRIPTION": "Descriere"
+ },
+ "FORM_HELP": "
Aici poți configura Super Productivity pentru a lista problemele deschise pe Gitea pentru un repository specific în panoul de creare a sarcinilor în modul de planificare zilnic. Acestea vor fi listate ca sugestii și vor conține o legătură către problemă și mai multe informații despre acesta.
În plus, poți adăuga și importa automat toate problemele deschise.
Pentru a fenta limitele de utilizare și acces, poți folosi un jeton de acces."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/ru.json b/packages/plugin-dev/gitea-issue-provider/i18n/ru.json
new file mode 100644
index 0000000000..f2c59e6ef2
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/ru.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Хост (н-р: https://try.gitea.io)",
+ "TOKEN": "Токен доступа",
+ "REPO_FULL_NAME": "Имя пользователя или название организации/проекта",
+ "SCOPE": "Скоуп",
+ "SCOPE_ALL": "Все",
+ "SCOPE_CREATED": "Создано мной",
+ "SCOPE_ASSIGNED": "Назначено мне",
+ "FILTER_LABELS": "Фильтровать по меткам (необязательно)",
+ "EXCLUDE_LABELS": "Исключить метки (необязательно)",
+ "HOW_TO_GET_TOKEN": "Как получить токен?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Резюме",
+ "STATE": "Статус",
+ "ASSIGNEE": "Исполнитель",
+ "LABELS": "Метки",
+ "DESCRIPTION": "Описание"
+ },
+ "FORM_HELP": "
Здесь вы можете настроить SuperProductivity чтобы отображать проблемы Gitea для конкретного репозитория на панели создания задач в представлении ежедневного планирования. Они будут перечислены в качестве предложений и предоставят ссылку на проблему, а также дополнительную информацию о ней.
Кроме того, вы можете автоматически добавлять и синхронизировать все открытые проблемы.
Чтобы получить доступ к закрытым репозиториям, вы можете предоставить токен доступа."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/sk.json b/packages/plugin-dev/gitea-issue-provider/i18n/sk.json
new file mode 100644
index 0000000000..84f52e06b6
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/sk.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Hostiteľ (napr.: https://try.gitea.io)",
+ "TOKEN": "Prístupový token",
+ "REPO_FULL_NAME": "Používateľské meno alebo názov organizácie/projektu",
+ "SCOPE": "Rozsah pôsobnosti",
+ "SCOPE_ALL": "Všetky",
+ "SCOPE_CREATED": "Vytvorené mnou",
+ "SCOPE_ASSIGNED": "Pridelené mne",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "Ako získať token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Zhrnutie",
+ "STATE": "Stav",
+ "ASSIGNEE": "Priradený",
+ "LABELS": "Štítky",
+ "DESCRIPTION": "Popis"
+ },
+ "FORM_HELP": "Tu môžete nakonfigurovať program SuperProductivity tak, aby na paneli vytvárania úloh v zobrazení denného plánovania zobrazoval zoznam otvorených problémov Gitea pre konkrétny repozitár. Budú uvedené ako návrhy a budú obsahovať odkaz na problém, ako aj ďalšie informácie o ňom.
Okrem toho môžete automaticky pridať a importovať všetky otvorené problémy.
Ak chcete obísť obmedzenia používania a získať prístup, môžete zadať prístupový token."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/sv.json b/packages/plugin-dev/gitea-issue-provider/i18n/sv.json
new file mode 100644
index 0000000000..407d60df59
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/sv.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Värd (t.ex. https://try.gitea.io)",
+ "TOKEN": "Åtkomsttoken",
+ "REPO_FULL_NAME": "Användarnamn eller organisationsnamn/projekt",
+ "SCOPE": "Omfattning",
+ "SCOPE_ALL": "Alla",
+ "SCOPE_CREATED": "Skapade av mig",
+ "SCOPE_ASSIGNED": "Ålagda mig",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "Hur får man en token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Sammanfattning",
+ "STATE": "Status",
+ "ASSIGNEE": "Mottagare",
+ "LABELS": "Etiketter",
+ "DESCRIPTION": "Beskrivning"
+ },
+ "FORM_HELP": "
Här kan du konfigurera SuperProductivity så att öppna Gitea-ärenden för ett specifikt arkiv listas i panelen för uppgiftskapande i den dagliga planeringsvyn. De listas som förslag och innehåller en länk till ärendet samt mer information om det.
Dessutom kan du automatiskt lägga till och importera alla öppna ärenden.
För att komma förbi användningsbegränsningar och få åtkomst kan du ange en åtkomsttoken."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/tr.json b/packages/plugin-dev/gitea-issue-provider/i18n/tr.json
new file mode 100644
index 0000000000..859e90c087
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/tr.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Sağlayıcı (örneğin: https://try.gitea.io)",
+ "TOKEN": "Erişim Jetonu",
+ "REPO_FULL_NAME": "Kullanıcı adı veya Organizasyon adı/projesi",
+ "SCOPE": "Kapsam",
+ "SCOPE_ALL": "Hepsi",
+ "SCOPE_CREATED": "Benim tarafımdan oluşturuldu",
+ "SCOPE_ASSIGNED": "Bana atanan",
+ "FILTER_LABELS": "Etiketlere göre filtrele (isteğe bağlı)",
+ "EXCLUDE_LABELS": "Etiketleri hariç tut (isteğe bağlı)",
+ "HOW_TO_GET_TOKEN": "Bir token nasıl alınır?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Özet",
+ "STATE": "Durum",
+ "ASSIGNEE": "Atanan",
+ "LABELS": "Etiketler",
+ "DESCRIPTION": "Açıklama"
+ },
+ "FORM_HELP": "
Burada SuperProductivity'yi, günlük planlama görünümündeki görev oluşturma panelinde belirli bir depo için açık Gitea sorunlarını listeleyecek şekilde yapılandırabilirsiniz. Öneriler olarak listelenecekler ve konuyla ilgili bir bağlantının yanı sıra konuyla ilgili daha fazla bilgi sağlayacaklar.
Ek olarak, tüm açık sorunları otomatik olarak görev biriktirme listenize ekleyebilir ve senkronize edebilirsiniz.
Kullanım sınırlarını aşmak ve erişim sağlamak için bir erişim jetonu sağlayabilirsiniz."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/uk.json b/packages/plugin-dev/gitea-issue-provider/i18n/uk.json
new file mode 100644
index 0000000000..d8c3509e1f
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/uk.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Хост (наприклад: https://try.gitea.io)",
+ "TOKEN": "Токен доступу",
+ "REPO_FULL_NAME": "Ім'я користувача або назва організації/проекту",
+ "SCOPE": "Область пошуку (Scope)",
+ "SCOPE_ALL": "Усі",
+ "SCOPE_CREATED": "Створені мною",
+ "SCOPE_ASSIGNED": "Призначені мені",
+ "FILTER_LABELS": "Filter by labels (optional)",
+ "EXCLUDE_LABELS": "Exclude labels (optional)",
+ "HOW_TO_GET_TOKEN": "Як отримати токен?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Підсумок",
+ "STATE": "Статус",
+ "ASSIGNEE": "Виконавець",
+ "LABELS": "Мітки",
+ "DESCRIPTION": "Опис"
+ },
+ "FORM_HELP": "
Тут ви можете налаштувати SuperProductivity для відображення відкритих завдань Gitea для конкретного репозиторію в панелі створення завдань у вікні щоденного планування. Вони будуть показані як пропозиції та надаватимуть посилання на завдання, а також додаткову інформацію про нього.
Крім того, ви можете автоматично додавати та імпортувати всі відкриті завдання.
Щоб обійти обмеження використання та отримати доступ, ви можете надати токен доступу."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/vi.json b/packages/plugin-dev/gitea-issue-provider/i18n/vi.json
new file mode 100644
index 0000000000..153157f0b3
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/vi.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "Máy chủ (ví dụ: https://try.gitea.io)",
+ "TOKEN": "Token truy cập",
+ "REPO_FULL_NAME": "Tên người dùng hoặc Tổ chức/kho lưu trữ",
+ "SCOPE": "Phạm vi",
+ "SCOPE_ALL": "Tất cả",
+ "SCOPE_CREATED": "Do tôi tạo",
+ "SCOPE_ASSIGNED": "Được giao cho tôi",
+ "FILTER_LABELS": "Lọc theo nhãn (tùy chọn)",
+ "EXCLUDE_LABELS": "Loại trừ nhãn (tùy chọn)",
+ "HOW_TO_GET_TOKEN": "Cách lấy token?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Tóm tắt",
+ "STATE": "Trạng thái",
+ "ASSIGNEE": "Người được giao",
+ "LABELS": "Nhãn",
+ "DESCRIPTION": "Mô tả"
+ },
+ "FORM_HELP": "
Tại đây bạn có thể cấu hình Super Productivity để liệt kê các sự cố Gitea mở cho một kho lưu trữ cụ thể trong bảng tạo công việc ở chế độ xem lên kế hoạch hàng ngày. Chúng sẽ được liệt kê làm gợi ý và cung cấp liên kết đến sự cố cũng như thêm thông tin về nó.
Ngoài ra, bạn có thể tự động thêm và nhập tất cả sự cố mở.
Để vượt qua giới hạn sử dụng và truy cập, bạn có thể cung cấp token truy cập."
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/zh-tw.json b/packages/plugin-dev/gitea-issue-provider/i18n/zh-tw.json
new file mode 100644
index 0000000000..420be47d4a
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/zh-tw.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "主機(例如:https://try.gitea.io)",
+ "TOKEN": "存取權杖",
+ "REPO_FULL_NAME": "使用者名稱或組織名稱/專案",
+ "SCOPE": "範圍",
+ "SCOPE_ALL": "全部",
+ "SCOPE_CREATED": "由我建立",
+ "SCOPE_ASSIGNED": "指派給我",
+ "FILTER_LABELS": "依標籤篩選(選填)",
+ "EXCLUDE_LABELS": "排除標籤(選填)",
+ "HOW_TO_GET_TOKEN": "如何取得權杖?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "摘要",
+ "STATE": "狀態",
+ "ASSIGNEE": "指派人",
+ "LABELS": "標籤",
+ "DESCRIPTION": "描述"
+ },
+ "FORM_HELP": "
在此您可以設定 Super Productivity 在每日規劃檢視的工作建立面板中列出特定程式碼庫的開放 Gitea 議題。它們將作為建議列出,並提供議題的連結以及更多相關資訊。
此外,您可以自動新增和匯入所有開放議題。
為了繞過使用限制並存取,您可以提供存取權杖。"
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/i18n/zh.json b/packages/plugin-dev/gitea-issue-provider/i18n/zh.json
new file mode 100644
index 0000000000..9739ae2f55
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/i18n/zh.json
@@ -0,0 +1,22 @@
+{
+ "CFG": {
+ "HOST": "主机(例如:https://try.gitea.io)",
+ "TOKEN": "访问令牌",
+ "REPO_FULL_NAME": "用户名或组织名称/项目",
+ "SCOPE": "范围",
+ "SCOPE_ALL": "全部",
+ "SCOPE_CREATED": "由我创建",
+ "SCOPE_ASSIGNED": "分配给我",
+ "FILTER_LABELS": "按标签筛选(可选)",
+ "EXCLUDE_LABELS": "排除标签(可选)",
+ "HOW_TO_GET_TOKEN": "如何获取令牌?"
+ },
+ "DISPLAY": {
+ "SUMMARY": "摘要",
+ "STATE": "状态",
+ "ASSIGNEE": "负责人",
+ "LABELS": "标签",
+ "DESCRIPTION": "描述"
+ },
+ "FORM_HELP": "
在这里,您可以将 SuperProductivity 配置为在每日规划视图的任务创建看板中列出特定存储库的未结 Gitea 问题。它们将列为建议,并提供指向问题的链接以及有关它的更多信息。
此外,您还可以自动添加和导入所有未结问题。
为了通过使用限制并访问,您可以提供一个访问令牌。"
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/icon.svg b/packages/plugin-dev/gitea-issue-provider/icon.svg
new file mode 100644
index 0000000000..bae7e5aa72
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/plugin-dev/gitea-issue-provider/package-lock.json b/packages/plugin-dev/gitea-issue-provider/package-lock.json
new file mode 100644
index 0000000000..ca96ea3c47
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/package-lock.json
@@ -0,0 +1,532 @@
+{
+ "name": "gitea-issue-provider",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "gitea-issue-provider",
+ "version": "1.0.0",
+ "dependencies": {
+ "@super-productivity/plugin-api": "file:../../plugin-api"
+ },
+ "devDependencies": {
+ "esbuild": "^0.25.11",
+ "typescript": "^5.8.3"
+ }
+ },
+ "../../plugin-api": {
+ "name": "@super-productivity/plugin-api",
+ "version": "1.0.1",
+ "license": "MIT",
+ "devDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@super-productivity/plugin-api": {
+ "resolved": "../../plugin-api",
+ "link": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/package.json b/packages/plugin-dev/gitea-issue-provider/package.json
new file mode 100644
index 0000000000..5c94ce6564
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "gitea-issue-provider",
+ "version": "1.0.0",
+ "description": "Gitea issue provider plugin",
+ "scripts": {
+ "build": "node scripts/build.js",
+ "typecheck": "tsc --noEmit",
+ "lint": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "esbuild": "^0.25.11",
+ "typescript": "^5.8.3"
+ },
+ "dependencies": {
+ "@super-productivity/plugin-api": "file:../../plugin-api"
+ }
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/scripts/build.js b/packages/plugin-dev/gitea-issue-provider/scripts/build.js
new file mode 100644
index 0000000000..90ae76df05
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/scripts/build.js
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const { build } = require('esbuild');
+
+const ROOT_DIR = path.join(__dirname, '..');
+const SRC_DIR = path.join(ROOT_DIR, 'src');
+const DIST_DIR = path.join(ROOT_DIR, 'dist');
+const I18N_SRC = path.join(ROOT_DIR, 'i18n');
+const I18N_DIST = path.join(DIST_DIR, 'i18n');
+
+async function buildPlugin() {
+ console.log('Building gitea-issue-provider...');
+
+ if (fs.existsSync(DIST_DIR)) {
+ fs.rmSync(DIST_DIR, { recursive: true });
+ }
+ fs.mkdirSync(DIST_DIR);
+
+ // Build TypeScript to IIFE bundle
+ await build({
+ entryPoints: [path.join(SRC_DIR, 'plugin.ts')],
+ bundle: true,
+ outfile: path.join(DIST_DIR, 'plugin.js'),
+ platform: 'browser',
+ target: 'es2020',
+ format: 'iife',
+ define: {
+ 'process.env.NODE_ENV': '"production"',
+ },
+ logLevel: 'info',
+ minify: true,
+ sourcemap: false,
+ });
+
+ // Copy manifest.json
+ fs.copyFileSync(
+ path.join(SRC_DIR, 'manifest.json'),
+ path.join(DIST_DIR, 'manifest.json'),
+ );
+
+ // Copy icon.svg if present
+ const iconSrc = path.join(ROOT_DIR, 'icon.svg');
+ if (fs.existsSync(iconSrc)) {
+ fs.copyFileSync(iconSrc, path.join(DIST_DIR, 'icon.svg'));
+ console.log('Copied icon.svg');
+ }
+
+ // Copy i18n files
+ if (fs.existsSync(I18N_SRC)) {
+ fs.mkdirSync(I18N_DIST, { recursive: true });
+ for (const file of fs.readdirSync(I18N_SRC)) {
+ if (file.endsWith('.json')) {
+ fs.copyFileSync(path.join(I18N_SRC, file), path.join(I18N_DIST, file));
+ }
+ }
+ console.log('Copied i18n files');
+ }
+
+ console.log('Build complete!');
+}
+
+buildPlugin().catch((err) => {
+ console.error('Build failed:', err);
+ process.exit(1);
+});
diff --git a/packages/plugin-dev/gitea-issue-provider/src/manifest.json b/packages/plugin-dev/gitea-issue-provider/src/manifest.json
new file mode 100644
index 0000000000..4e1466a7d7
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/src/manifest.json
@@ -0,0 +1,57 @@
+{
+ "id": "gitea-issue-provider",
+ "name": "Gitea Issues",
+ "version": "1.0.0",
+ "manifestVersion": 1,
+ "minSupVersion": "13.0.0",
+ "description": "Gitea issue provider plugin",
+ "author": "Super Productivity",
+ "type": "issueProvider",
+ "icon": "icon.svg",
+ "iFrame": false,
+ "permissions": [],
+ "hooks": [],
+ "i18n": {
+ "languages": [
+ "ar",
+ "cs",
+ "de",
+ "en",
+ "es",
+ "fa",
+ "fi",
+ "fr",
+ "hr",
+ "id",
+ "it",
+ "ja",
+ "ko",
+ "nb",
+ "nl",
+ "pl",
+ "pt",
+ "pt-br",
+ "ro",
+ "ro-md",
+ "ru",
+ "sk",
+ "sv",
+ "tr",
+ "uk",
+ "vi",
+ "zh",
+ "zh-tw"
+ ]
+ },
+ "issueProvider": {
+ "pollIntervalMs": 300000,
+ "icon": "gitea",
+ "humanReadableName": "Gitea",
+ "issueProviderKey": "GITEA",
+ "allowPrivateNetwork": true,
+ "issueStrings": {
+ "singular": "Issue",
+ "plural": "Issues"
+ }
+ }
+}
diff --git a/packages/plugin-dev/gitea-issue-provider/src/plugin.ts b/packages/plugin-dev/gitea-issue-provider/src/plugin.ts
new file mode 100644
index 0000000000..1dfc8c06e8
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/src/plugin.ts
@@ -0,0 +1,371 @@
+import type {
+ IssueProviderPluginDefinition,
+ PluginFieldMapping,
+ PluginHttp,
+ PluginHttpOptions,
+ PluginIssue,
+ PluginSearchResult,
+} from '@super-productivity/plugin-api';
+
+declare const PluginAPI: {
+ registerIssueProvider(definition: IssueProviderPluginDefinition): void;
+ translate(key: string, params?: Record): string;
+};
+
+const API_SUFFIX = 'api';
+const API_VERSION = 'v1';
+
+// Scope option values — must stay identical to the built-in provider's stored
+// config so existing (migrated) providers keep working.
+const SCOPE_CREATED_BY_ME = 'created-by-me';
+const SCOPE_ASSIGNED_TO_ME = 'assigned-to-me';
+
+interface GiteaConfig {
+ host?: string;
+ token?: string;
+ repoFullname?: string;
+ scope?: string;
+ filterLabels?: string;
+ excludeLabels?: string;
+}
+
+interface GiteaUser {
+ avatar_url: string;
+ id: number;
+ username: string;
+ login: string;
+ full_name: string;
+}
+
+interface GiteaLabel {
+ id: number;
+ name: string;
+ color: string;
+}
+
+interface GiteaRepositoryReduced {
+ id: number;
+ full_name: string;
+}
+
+interface GiteaIssue {
+ id: number;
+ number: number;
+ url: string;
+ html_url: string;
+ title: string;
+ body: string;
+ state: string;
+ labels: GiteaLabel[];
+ user: GiteaUser | null;
+ assignee: GiteaUser | null;
+ assignees: GiteaUser[] | null;
+ comments: number;
+ created_at: string;
+ updated_at: string;
+ closed_at: string | null;
+ repository: GiteaRepositoryReduced | null;
+}
+
+interface GiteaComment {
+ id: number;
+ body: string;
+ created_at: string;
+ user: GiteaUser | null;
+}
+
+const t = (key: string): string => {
+ try {
+ return PluginAPI.translate(key);
+ } catch {
+ return key;
+ }
+};
+
+const baseUrl = (cfg: GiteaConfig): string => {
+ const host = (cfg.host || '').replace(/\/+$/, '');
+ if (!host) {
+ throw new Error('Gitea host is not configured.');
+ }
+ return `${host}/${API_SUFFIX}/${API_VERSION}`;
+};
+
+// Auth is sent as `Authorization: token ` (a Gitea-supported scheme),
+// which keeps the token out of request URLs / logs.
+const giteaHeaders = (cfg: GiteaConfig): Record => {
+ const headers: Record = { accept: 'application/json' };
+ if (cfg.token) {
+ headers['Authorization'] = `token ${cfg.token}`;
+ }
+ return headers;
+};
+
+const parseLabelList = (raw: string | undefined): string[] =>
+ (raw ?? '')
+ .split(',')
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0);
+
+// Gitea/Forgejo's two issue endpoints historically disagree on whether a
+// `labels=a,b` query means AND or OR (see go-gitea/gitea#33509). We always
+// filter labels client-side so behavior is consistent regardless of server.
+const hasAllLabels = (issue: GiteaIssue, required: readonly string[]): boolean => {
+ if (required.length === 0) {
+ return true;
+ }
+ const names = new Set((issue.labels ?? []).map((l) => l.name));
+ return required.every((name) => names.has(name));
+};
+
+const isIssueIncludedByLabels = (
+ issue: GiteaIssue,
+ excluded: readonly string[],
+): boolean => {
+ if (excluded.length === 0) {
+ return true;
+ }
+ const names = new Set((issue.labels ?? []).map((l) => l.name));
+ return !excluded.some((name) => names.has(name));
+};
+
+const issueAssignees = (issue: GiteaIssue): string[] =>
+ (issue.assignees ?? [])
+ .map((a) => a.login || a.username)
+ .filter((name): name is string => !!name);
+
+const mapSearchResult = (issue: GiteaIssue): PluginSearchResult => ({
+ // Gitea tracks issues by their per-repo `number`, not the global `id`.
+ id: String(issue.number),
+ title: `#${issue.number} ${issue.title}`,
+ url: issue.html_url,
+ status: issue.state,
+ assignee: issue.assignee?.login || issue.assignee?.username,
+ labels: (issue.labels ?? []).map((l) => l.name),
+});
+
+PluginAPI.registerIssueProvider({
+ configFields: [
+ {
+ key: 'host',
+ type: 'input',
+ label: t('CFG.HOST'),
+ required: true,
+ },
+ {
+ key: 'token',
+ type: 'password',
+ label: t('CFG.TOKEN'),
+ required: true,
+ },
+ {
+ key: 'tokenHelp',
+ type: 'link',
+ label: t('CFG.HOW_TO_GET_TOKEN'),
+ url: 'https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens',
+ },
+ {
+ key: 'repoFullname',
+ type: 'input',
+ label: t('CFG.REPO_FULL_NAME'),
+ required: true,
+ },
+ {
+ key: 'scope',
+ type: 'select',
+ label: t('CFG.SCOPE'),
+ required: true,
+ options: [
+ { value: 'all', label: t('CFG.SCOPE_ALL') },
+ { value: SCOPE_CREATED_BY_ME, label: t('CFG.SCOPE_CREATED') },
+ { value: SCOPE_ASSIGNED_TO_ME, label: t('CFG.SCOPE_ASSIGNED') },
+ ],
+ },
+ {
+ key: 'filterLabels',
+ type: 'input',
+ label: t('CFG.FILTER_LABELS'),
+ advanced: true,
+ },
+ {
+ key: 'excludeLabels',
+ type: 'input',
+ label: t('CFG.EXCLUDE_LABELS'),
+ advanced: true,
+ },
+ ],
+
+ getHeaders(config: Record): Record {
+ return giteaHeaders(config as unknown as GiteaConfig);
+ },
+
+ async searchIssues(
+ searchTerm: string,
+ config: Record,
+ http: PluginHttp,
+ ): Promise {
+ const cfg = config as unknown as GiteaConfig;
+ const base = baseUrl(cfg);
+ const includedLabels = parseLabelList(cfg.filterLabels);
+ const excludedLabels = parseLabelList(cfg.excludeLabels);
+
+ // `priority_repo_id` is the only reliable way to scope the global issue
+ // search to the configured repository, so look it up first.
+ const repo = await http.get(
+ `${base}/repos/${cfg.repoFullname}`,
+ );
+
+ const params: Record = {
+ limit: '100',
+ state: 'open',
+ q: searchTerm,
+ };
+ if (repo?.id) {
+ params['priority_repo_id'] = String(repo.id);
+ }
+ if (cfg.scope === SCOPE_CREATED_BY_ME) {
+ params['created'] = 'true';
+ } else if (cfg.scope === SCOPE_ASSIGNED_TO_ME) {
+ params['assigned'] = 'true';
+ }
+ if (includedLabels.length > 0) {
+ params['labels'] = includedLabels.join(',');
+ }
+
+ const issues =
+ (await http.get(`${base}/repos/issues/search`, { params })) || [];
+ return issues
+ .filter((issue) => issue.repository?.full_name === cfg.repoFullname)
+ .filter((issue) => hasAllLabels(issue, includedLabels))
+ .filter((issue) => isIssueIncludedByLabels(issue, excludedLabels))
+ .map(mapSearchResult);
+ },
+
+ async getById(
+ issueId: string,
+ config: Record,
+ http: PluginHttp,
+ ): Promise {
+ const cfg = config as unknown as GiteaConfig;
+ const base = baseUrl(cfg);
+ const issueUrl = `${base}/repos/${cfg.repoFullname}/issues/${issueId}`;
+ const issue = await http.get(issueUrl);
+
+ const result: PluginIssue = {
+ id: String(issue.number),
+ title: issue.title,
+ body: issue.body || '',
+ url: issue.html_url,
+ state: issue.state,
+ lastUpdated: new Date(issue.updated_at).getTime(),
+ assignee: issue.assignee?.login || issue.assignee?.username,
+ labels: (issue.labels ?? []).map((l) => l.name),
+ comments: [],
+
+ // Extended fields for richer display
+ number: issue.number,
+ summary: `#${issue.number} ${issue.title}`,
+ assignees: issueAssignees(issue),
+ creator: issue.user?.login || issue.user?.username,
+ creatorAvatarUrl: issue.user?.avatar_url,
+ createdAt: new Date(issue.created_at).getTime(),
+ closedAt: issue.closed_at ? new Date(issue.closed_at).getTime() : undefined,
+ };
+
+ if (issue.comments > 0) {
+ const commentsData = await http.get(`${issueUrl}/comments`);
+ result.comments = (commentsData || []).map((c) => ({
+ author: c.user?.login || c.user?.username || 'unknown',
+ body: c.body || '',
+ created: new Date(c.created_at).getTime(),
+ avatarUrl: c.user?.avatar_url,
+ }));
+ }
+
+ return result;
+ },
+
+ getIssueLink(issueId: string, config: Record): string {
+ const cfg = config as unknown as GiteaConfig;
+ const host = (cfg.host || '').replace(/\/+$/, '');
+ return `${host}/${cfg.repoFullname}/issues/${issueId}`;
+ },
+
+ async testConnection(
+ config: Record,
+ http: PluginHttp,
+ ): Promise {
+ const cfg = config as unknown as GiteaConfig;
+ try {
+ await http.get(`${baseUrl(cfg)}/repos/${cfg.repoFullname}`);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ async getNewIssuesForBacklog(
+ config: Record,
+ http: PluginHttp,
+ ): Promise {
+ const cfg = config as unknown as GiteaConfig;
+ const base = baseUrl(cfg);
+ const includedLabels = parseLabelList(cfg.filterLabels);
+ const excludedLabels = parseLabelList(cfg.excludeLabels);
+
+ const params: Record = { limit: '100', state: 'open' };
+ if (cfg.scope === SCOPE_CREATED_BY_ME || cfg.scope === SCOPE_ASSIGNED_TO_ME) {
+ const user = await http.get(`${base}/user`);
+ if (cfg.scope === SCOPE_CREATED_BY_ME) {
+ params['created_by'] = user.username;
+ } else {
+ params['assigned_by'] = user.username;
+ }
+ }
+ if (includedLabels.length > 0) {
+ params['labels'] = includedLabels.join(',');
+ }
+
+ const opts: PluginHttpOptions = { params };
+ const issues =
+ (await http.get(`${base}/repos/${cfg.repoFullname}/issues`, opts)) ||
+ [];
+ return issues
+ .filter((issue) => hasAllLabels(issue, includedLabels))
+ .filter((issue) => isIssueIncludedByLabels(issue, excludedLabels))
+ .map(mapSearchResult);
+ },
+
+ issueDisplay: [
+ { field: 'summary', label: t('DISPLAY.SUMMARY'), type: 'link', linkField: 'url' },
+ { field: 'state', label: t('DISPLAY.STATE'), type: 'text' },
+ { field: 'assignees', label: t('DISPLAY.ASSIGNEE'), type: 'list', hideEmpty: true },
+ { field: 'labels', label: t('DISPLAY.LABELS'), type: 'list', hideEmpty: true },
+ { field: 'body', label: t('DISPLAY.DESCRIPTION'), type: 'markdown' },
+ ],
+
+ commentsConfig: {
+ authorField: 'author',
+ bodyField: 'body',
+ createdField: 'created',
+ avatarField: 'avatarUrl',
+ },
+
+ // Read-only provider: pull-only mappings drive remote-update detection only.
+ fieldMappings: [
+ {
+ taskField: 'isDone',
+ issueField: 'state',
+ defaultDirection: 'pullOnly',
+ toIssueValue: (taskValue: unknown): string => (taskValue ? 'closed' : 'open'),
+ toTaskValue: (issueValue: unknown): boolean => issueValue === 'closed',
+ },
+ ] satisfies PluginFieldMapping[],
+
+ extractSyncValues(issue: PluginIssue): Record {
+ return {
+ state: issue.state,
+ title: issue.title,
+ body: issue.body,
+ };
+ },
+});
diff --git a/packages/plugin-dev/gitea-issue-provider/tsconfig.json b/packages/plugin-dev/gitea-issue-provider/tsconfig.json
new file mode 100644
index 0000000000..3c59f94bab
--- /dev/null
+++ b/packages/plugin-dev/gitea-issue-provider/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/plugin-dev/linear-issue-provider/i18n/en.json b/packages/plugin-dev/linear-issue-provider/i18n/en.json
new file mode 100644
index 0000000000..793a06ac66
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/i18n/en.json
@@ -0,0 +1,17 @@
+{
+ "CFG": {
+ "API_KEY": "API Key",
+ "TEAM_ID": "Team ID (optional, filter to a specific team)",
+ "PROJECT_ID": "Project ID (optional, filter to a specific project)",
+ "HOW_TO_GET_TOKEN": "Get your API key"
+ },
+ "DISPLAY": {
+ "SUMMARY": "Summary",
+ "STATE": "Status",
+ "PRIORITY": "Priority",
+ "ASSIGNEE": "Assignee",
+ "LABELS": "Labels",
+ "DESCRIPTION": "Description"
+ },
+ "FORM_HELP": "Configure Super Productivity to list and import your assigned Linear issues in the task creation panel. They will be listed as suggestions with a link to the issue and more information about it.
"
+}
diff --git a/packages/plugin-dev/linear-issue-provider/icon.svg b/packages/plugin-dev/linear-issue-provider/icon.svg
new file mode 100644
index 0000000000..613820a135
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/icon.svg
@@ -0,0 +1 @@
+
diff --git a/packages/plugin-dev/linear-issue-provider/package-lock.json b/packages/plugin-dev/linear-issue-provider/package-lock.json
new file mode 100644
index 0000000000..c6fbc2f8a8
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/package-lock.json
@@ -0,0 +1,532 @@
+{
+ "name": "linear-issue-provider",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "linear-issue-provider",
+ "version": "1.0.0",
+ "dependencies": {
+ "@super-productivity/plugin-api": "file:../../plugin-api"
+ },
+ "devDependencies": {
+ "esbuild": "^0.25.11",
+ "typescript": "^5.8.3"
+ }
+ },
+ "../../plugin-api": {
+ "name": "@super-productivity/plugin-api",
+ "version": "1.0.1",
+ "license": "MIT",
+ "devDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@super-productivity/plugin-api": {
+ "resolved": "../../plugin-api",
+ "link": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/packages/plugin-dev/linear-issue-provider/package.json b/packages/plugin-dev/linear-issue-provider/package.json
new file mode 100644
index 0000000000..1cd493b42e
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "linear-issue-provider",
+ "version": "1.0.0",
+ "description": "Linear issue provider plugin",
+ "scripts": {
+ "build": "node scripts/build.js",
+ "typecheck": "tsc --noEmit",
+ "lint": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "esbuild": "^0.25.11",
+ "typescript": "^5.8.3"
+ },
+ "dependencies": {
+ "@super-productivity/plugin-api": "file:../../plugin-api"
+ }
+}
diff --git a/packages/plugin-dev/linear-issue-provider/scripts/build.js b/packages/plugin-dev/linear-issue-provider/scripts/build.js
new file mode 100644
index 0000000000..a841a2754c
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/scripts/build.js
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const { build } = require('esbuild');
+
+const ROOT_DIR = path.join(__dirname, '..');
+const SRC_DIR = path.join(ROOT_DIR, 'src');
+const DIST_DIR = path.join(ROOT_DIR, 'dist');
+const I18N_SRC = path.join(ROOT_DIR, 'i18n');
+const I18N_DIST = path.join(DIST_DIR, 'i18n');
+
+async function buildPlugin() {
+ console.log('Building linear-issue-provider...');
+
+ if (fs.existsSync(DIST_DIR)) {
+ fs.rmSync(DIST_DIR, { recursive: true });
+ }
+ fs.mkdirSync(DIST_DIR);
+
+ // Build TypeScript to IIFE bundle
+ await build({
+ entryPoints: [path.join(SRC_DIR, 'plugin.ts')],
+ bundle: true,
+ outfile: path.join(DIST_DIR, 'plugin.js'),
+ platform: 'browser',
+ target: 'es2020',
+ format: 'iife',
+ define: {
+ 'process.env.NODE_ENV': '"production"',
+ },
+ logLevel: 'info',
+ minify: true,
+ sourcemap: false,
+ });
+
+ // Copy manifest.json
+ fs.copyFileSync(
+ path.join(SRC_DIR, 'manifest.json'),
+ path.join(DIST_DIR, 'manifest.json'),
+ );
+
+ // Copy icon.svg if present
+ const iconSrc = path.join(ROOT_DIR, 'icon.svg');
+ if (fs.existsSync(iconSrc)) {
+ fs.copyFileSync(iconSrc, path.join(DIST_DIR, 'icon.svg'));
+ console.log('Copied icon.svg');
+ }
+
+ // Copy i18n files
+ if (fs.existsSync(I18N_SRC)) {
+ fs.mkdirSync(I18N_DIST, { recursive: true });
+ for (const file of fs.readdirSync(I18N_SRC)) {
+ if (file.endsWith('.json')) {
+ fs.copyFileSync(path.join(I18N_SRC, file), path.join(I18N_DIST, file));
+ }
+ }
+ console.log('Copied i18n files');
+ }
+
+ console.log('Build complete!');
+}
+
+buildPlugin().catch((err) => {
+ console.error('Build failed:', err);
+ process.exit(1);
+});
diff --git a/packages/plugin-dev/linear-issue-provider/src/manifest.json b/packages/plugin-dev/linear-issue-provider/src/manifest.json
new file mode 100644
index 0000000000..39f53b3c58
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/src/manifest.json
@@ -0,0 +1,27 @@
+{
+ "id": "linear-issue-provider",
+ "name": "Linear Issues",
+ "version": "1.0.0",
+ "manifestVersion": 1,
+ "minSupVersion": "13.0.0",
+ "description": "Linear issue provider plugin",
+ "author": "Super Productivity",
+ "type": "issueProvider",
+ "icon": "icon.svg",
+ "iFrame": false,
+ "permissions": [],
+ "hooks": [],
+ "i18n": {
+ "languages": ["en"]
+ },
+ "issueProvider": {
+ "pollIntervalMs": 300000,
+ "icon": "linear",
+ "humanReadableName": "Linear",
+ "issueProviderKey": "LINEAR",
+ "issueStrings": {
+ "singular": "Issue",
+ "plural": "Issues"
+ }
+ }
+}
diff --git a/packages/plugin-dev/linear-issue-provider/src/plugin.ts b/packages/plugin-dev/linear-issue-provider/src/plugin.ts
new file mode 100644
index 0000000000..02fb85d5cf
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/src/plugin.ts
@@ -0,0 +1,319 @@
+import type {
+ IssueProviderPluginDefinition,
+ PluginFieldMapping,
+ PluginHttp,
+ PluginIssue,
+ PluginSearchResult,
+} from '@super-productivity/plugin-api';
+
+declare const PluginAPI: {
+ registerIssueProvider(definition: IssueProviderPluginDefinition): void;
+ translate(key: string, params?: Record): string;
+};
+
+const LINEAR_API_URL = 'https://api.linear.app/graphql';
+
+// Linear workflow state types that mean "done" (matches the built-in provider).
+const DONE_STATE_TYPES = ['completed', 'canceled'];
+
+interface LinearConfig {
+ apiKey?: string;
+ teamId?: string;
+ projectId?: string;
+}
+
+interface LinearGraphQLResponse {
+ data?: T;
+ errors?: Array<{ message: string }>;
+}
+
+interface LinearRawIssueReduced {
+ id: string;
+ identifier: string;
+ number: number;
+ title: string;
+ updatedAt: string;
+ url: string;
+ state: { name: string; type: string };
+}
+
+interface LinearRawIssue extends LinearRawIssueReduced {
+ description?: string;
+ priority: number;
+ createdAt: string;
+ completedAt?: string;
+ canceledAt?: string;
+ dueDate?: string;
+ assignee?: { id: string; name: string; avatarUrl?: string };
+ creator?: { id: string; name: string };
+ labels?: { nodes: Array<{ id: string; name: string; color: string }> };
+ comments?: {
+ nodes: Array<{
+ id: string;
+ body: string;
+ createdAt: string;
+ user?: { id: string; name: string; avatarUrl?: string };
+ }>;
+ };
+}
+
+const t = (key: string): string => {
+ try {
+ return PluginAPI.translate(key);
+ } catch {
+ return key;
+ }
+};
+
+const SEARCH_ISSUES_QUERY = `
+ query SearchIssues($first: Int!, $team: TeamFilter, $project: NullableProjectFilter) {
+ viewer {
+ assignedIssues(
+ first: $first,
+ filter: {
+ state: { type: { in: ["backlog", "unstarted", "started"] } },
+ team: $team,
+ project: $project
+ }
+ ) {
+ nodes {
+ id identifier number title updatedAt url
+ state { id name type }
+ }
+ }
+ }
+ }
+`;
+
+const GET_ISSUE_QUERY = `
+ query GetIssue($id: String!) {
+ issue(id: $id) {
+ id identifier number title description priority
+ createdAt updatedAt completedAt canceledAt dueDate url
+ state { id name type }
+ team { id name key }
+ assignee { id name avatarUrl }
+ creator { id name }
+ labels(first: 50) { nodes { id name color } }
+ comments(first: 50) {
+ nodes { id body createdAt user { id name avatarUrl } }
+ }
+ }
+ }
+`;
+
+const GET_VIEWER_QUERY = `query GetViewer { viewer { id name } }`;
+
+const graphql = async (
+ http: PluginHttp,
+ query: string,
+ variables: Record,
+): Promise => {
+ const res = await http.post>(LINEAR_API_URL, {
+ query,
+ variables,
+ });
+ if (res?.errors?.length) {
+ throw new Error(res.errors[0].message || 'Linear GraphQL error');
+ }
+ if (!res?.data) {
+ throw new Error('No data returned from Linear');
+ }
+ return res.data;
+};
+
+const mapReduced = (issue: LinearRawIssueReduced): PluginSearchResult => ({
+ id: issue.id,
+ title: `${issue.identifier} ${issue.title}`,
+ url: issue.url,
+ status: issue.state?.name,
+ // Provider-specific fields used for display + isDone mapping.
+ identifier: issue.identifier,
+ stateType: issue.state?.type,
+});
+
+const searchAssignedIssues = async (
+ searchTerm: string,
+ cfg: LinearConfig,
+ http: PluginHttp,
+): Promise => {
+ const variables: Record = { first: 50 };
+ // Deliberate behavior change from the built-in provider: the old
+ // LinearApiService accepted teamId/projectId but its callers never passed
+ // them, so the "filter to specific team/project" config fields were inert.
+ // Here we honor them as the labels promise. Empty fields = no filter (the
+ // common case), so this only narrows results for users who set a value.
+ if (cfg.teamId) {
+ variables.team = { id: { eq: cfg.teamId } };
+ }
+ if (cfg.projectId) {
+ variables.project = { id: { eq: cfg.projectId } };
+ }
+
+ const data = await graphql<{
+ viewer: { assignedIssues: { nodes: LinearRawIssueReduced[] } };
+ }>(http, SEARCH_ISSUES_QUERY, variables);
+
+ let issues = data.viewer?.assignedIssues?.nodes || [];
+ const term = searchTerm.trim().toLowerCase();
+ if (term) {
+ issues = issues.filter(
+ (issue) =>
+ issue.title.toLowerCase().includes(term) ||
+ issue.identifier.toLowerCase().includes(term),
+ );
+ }
+ return issues.map(mapReduced);
+};
+
+PluginAPI.registerIssueProvider({
+ configFields: [
+ {
+ key: 'apiKey',
+ type: 'password',
+ label: t('CFG.API_KEY'),
+ required: true,
+ },
+ {
+ key: 'apiKeyHelp',
+ type: 'link',
+ label: t('CFG.HOW_TO_GET_TOKEN'),
+ url: 'https://linear.app/settings/account/security',
+ },
+ {
+ key: 'teamId',
+ type: 'input',
+ label: t('CFG.TEAM_ID'),
+ advanced: true,
+ },
+ {
+ key: 'projectId',
+ type: 'input',
+ label: t('CFG.PROJECT_ID'),
+ advanced: true,
+ },
+ ],
+
+ getHeaders(config: Record): Record {
+ const cfg = config as unknown as LinearConfig;
+ return {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'Content-Type': 'application/json',
+ Authorization: cfg.apiKey || '',
+ };
+ },
+
+ searchIssues(
+ searchTerm: string,
+ config: Record,
+ http: PluginHttp,
+ ): Promise {
+ return searchAssignedIssues(searchTerm, config as unknown as LinearConfig, http);
+ },
+
+ async getById(
+ issueId: string,
+ _config: Record,
+ http: PluginHttp,
+ ): Promise {
+ const data = await graphql<{ issue: LinearRawIssue | null }>(http, GET_ISSUE_QUERY, {
+ id: issueId,
+ });
+ const issue = data.issue;
+ if (!issue) {
+ throw new Error('No issue data returned from Linear');
+ }
+
+ return {
+ id: issue.id,
+ title: issue.title,
+ body: issue.description || '',
+ url: issue.url,
+ state: issue.state?.name,
+ lastUpdated: new Date(issue.updatedAt).getTime(),
+ assignee: issue.assignee?.name,
+ labels: (issue.labels?.nodes || []).map((l) => l.name),
+ comments: (issue.comments?.nodes || [])
+ .filter((c) => !!c.user)
+ .map((c) => ({
+ author: c.user!.name,
+ body: c.body || '',
+ created: new Date(c.createdAt).getTime(),
+ avatarUrl: c.user!.avatarUrl,
+ })),
+
+ // Extended fields for display + isDone mapping.
+ identifier: issue.identifier,
+ number: issue.number,
+ summary: `${issue.identifier} ${issue.title}`,
+ stateType: issue.state?.type,
+ priority: issue.priority,
+ creator: issue.creator?.name,
+ createdAt: new Date(issue.createdAt).getTime(),
+ completedAt: issue.completedAt ? new Date(issue.completedAt).getTime() : undefined,
+ };
+ },
+
+ // Linear issue URLs require the workspace slug, which can't be derived from the
+ // id + config alone. Returning '' makes the adapter fall back to getById().url,
+ // matching the built-in provider's behavior.
+ getIssueLink(): string {
+ return '';
+ },
+
+ async testConnection(
+ _config: Record,
+ http: PluginHttp,
+ ): Promise {
+ try {
+ await graphql(http, GET_VIEWER_QUERY, {});
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ getNewIssuesForBacklog(
+ config: Record,
+ http: PluginHttp,
+ ): Promise {
+ return searchAssignedIssues('', config as unknown as LinearConfig, http);
+ },
+
+ issueDisplay: [
+ { field: 'summary', label: t('DISPLAY.SUMMARY'), type: 'link', linkField: 'url' },
+ { field: 'state', label: t('DISPLAY.STATE'), type: 'text', hideEmpty: true },
+ { field: 'priority', label: t('DISPLAY.PRIORITY'), type: 'text', hideEmpty: true },
+ { field: 'assignee', label: t('DISPLAY.ASSIGNEE'), type: 'text', hideEmpty: true },
+ { field: 'labels', label: t('DISPLAY.LABELS'), type: 'list', hideEmpty: true },
+ { field: 'body', label: t('DISPLAY.DESCRIPTION'), type: 'markdown' },
+ ],
+
+ commentsConfig: {
+ authorField: 'author',
+ bodyField: 'body',
+ createdField: 'created',
+ avatarField: 'avatarUrl',
+ },
+
+ // Read-only provider: pull-only mapping drives remote-update detection only.
+ fieldMappings: [
+ {
+ taskField: 'isDone',
+ issueField: 'stateType',
+ defaultDirection: 'pullOnly',
+ toIssueValue: (taskValue: unknown): string =>
+ taskValue ? 'completed' : 'unstarted',
+ toTaskValue: (issueValue: unknown): boolean =>
+ DONE_STATE_TYPES.includes(issueValue as string),
+ },
+ ] satisfies PluginFieldMapping[],
+
+ extractSyncValues(issue: PluginIssue): Record {
+ return {
+ stateType: issue.stateType,
+ title: issue.title,
+ body: issue.body,
+ };
+ },
+});
diff --git a/packages/plugin-dev/linear-issue-provider/tsconfig.json b/packages/plugin-dev/linear-issue-provider/tsconfig.json
new file mode 100644
index 0000000000..3c59f94bab
--- /dev/null
+++ b/packages/plugin-dev/linear-issue-provider/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html
index ce395605a1..baef3ddce2 100644
--- a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html
+++ b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html
@@ -105,22 +105,8 @@
Open Project
-
-
- Gitea
-
-
-
- Linear
-
+
= new Set([
'CALDAV',
'ICAL',
'OPEN_PROJECT',
- 'GITEA',
'TRELLO',
'REDMINE',
- 'LINEAR',
'AZURE_DEVOPS',
'NEXTCLOUD_DECK',
]);
@@ -91,6 +83,8 @@ const BUILT_IN_KEYS: ReadonlySet = new Set([
const MIGRATED_KEYS: ReadonlySet = new Set([
'GITHUB',
'CLICKUP',
+ 'GITEA',
+ 'LINEAR',
]);
export const isValidIssueProviderKey = (key: string): key is IssueProviderKey => {
@@ -103,10 +97,8 @@ export type IssueIntegrationCfg =
| CaldavCfg
| CalendarProviderCfg
| OpenProjectCfg
- | GiteaCfg
| TrelloCfg
| RedmineCfg
- | LinearCfg
| AzureDevOpsCfg
| NextcloudDeckCfg;
@@ -124,9 +116,7 @@ export interface IssueIntegrationCfgs {
CALENDAR?: CalendarProviderCfg;
OPEN_PROJECT?: OpenProjectCfg;
TRELLO?: TrelloCfg;
- GITEA?: GiteaCfg;
REDMINE?: RedmineCfg;
- LINEAR?: LinearCfg;
AZURE_DEVOPS?: AzureDevOpsCfg;
NEXTCLOUD_DECK?: NextcloudDeckCfg;
}
@@ -137,10 +127,8 @@ export type IssueData =
| CaldavIssue
| ICalIssue
| OpenProjectWorkPackage
- | GiteaIssue
| RedmineIssue
| TrelloIssue
- | LinearIssue
| AzureDevOpsIssue
| NextcloudDeckIssue
| PluginIssue;
@@ -151,10 +139,8 @@ export type IssueDataReduced =
| OpenProjectWorkPackageReduced
| CaldavIssueReduced
| ICalIssueReduced
- | GiteaIssue
| RedmineIssue
| TrelloIssueReduced
- | LinearIssueReduced
| AzureDevOpsIssueReduced
| NextcloudDeckIssueReduced
| PluginSearchResult;
@@ -170,23 +156,19 @@ export type IssueDataReducedMap = {
? ICalIssueReduced
: K extends 'OPEN_PROJECT'
? OpenProjectWorkPackageReduced
- : K extends 'GITEA'
- ? GiteaIssue
- : K extends 'TRELLO'
- ? TrelloIssueReduced
- : K extends 'REDMINE'
- ? RedmineIssue
- : K extends 'LINEAR'
- ? LinearIssueReduced
- : K extends 'AZURE_DEVOPS'
- ? AzureDevOpsIssueReduced
- : K extends 'NEXTCLOUD_DECK'
- ? NextcloudDeckIssueReduced
- : K extends MigratedIssueProviderKey
- ? PluginSearchResult
- : K extends PluginIssueProviderKey
- ? PluginSearchResult
- : never;
+ : K extends 'TRELLO'
+ ? TrelloIssueReduced
+ : K extends 'REDMINE'
+ ? RedmineIssue
+ : K extends 'AZURE_DEVOPS'
+ ? AzureDevOpsIssueReduced
+ : K extends 'NEXTCLOUD_DECK'
+ ? NextcloudDeckIssueReduced
+ : K extends MigratedIssueProviderKey
+ ? PluginSearchResult
+ : K extends PluginIssueProviderKey
+ ? PluginSearchResult
+ : never;
};
// TODO: add issue model to the IssueDataReducedMap
@@ -254,8 +236,10 @@ export interface IssueProviderOpenProject extends IssueProviderBase, OpenProject
issueProviderKey: 'OPEN_PROJECT';
}
-export interface IssueProviderGitea extends IssueProviderBase, GiteaCfg {
+export interface IssueProviderGitea extends IssueProviderBase {
issueProviderKey: 'GITEA';
+ pluginId: string;
+ pluginConfig: Record;
}
export interface IssueProviderRedmine extends IssueProviderBase, RedmineCfg {
@@ -270,8 +254,10 @@ export interface IssueProviderTrello extends IssueProviderBase, TrelloCfg {
issueProviderKey: 'TRELLO';
}
-export interface IssueProviderLinear extends IssueProviderBase, LinearCfg {
+export interface IssueProviderLinear extends IssueProviderBase {
issueProviderKey: 'LINEAR';
+ pluginId: string;
+ pluginConfig: Record;
}
export interface IssueProviderAzureDevOps extends IssueProviderBase, AzureDevOpsCfg {
diff --git a/src/app/features/issue/issue.service.spec.ts b/src/app/features/issue/issue.service.spec.ts
index 1cfb90c709..9674d619d1 100644
--- a/src/app/features/issue/issue.service.spec.ts
+++ b/src/app/features/issue/issue.service.spec.ts
@@ -23,9 +23,7 @@ import { TrelloCommonInterfacesService } from './providers/trello/trello-common-
import { GitlabCommonInterfacesService } from './providers/gitlab/gitlab-common-interfaces.service';
import { CaldavCommonInterfacesService } from './providers/caldav/caldav-common-interfaces.service';
import { OpenProjectCommonInterfacesService } from './providers/open-project/open-project-common-interfaces.service';
-import { GiteaCommonInterfacesService } from './providers/gitea/gitea-common-interfaces.service';
import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service';
-import { LinearCommonInterfacesService } from './providers/linear/linear-common-interfaces.service';
import { CalendarCommonInterfacesService } from './providers/calendar/calendar-common-interfaces.service';
import { PluginIssueProviderAdapterService } from '../../plugins/issue-provider/plugin-issue-provider-adapter.service';
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';
@@ -163,9 +161,7 @@ describe('IssueService', () => {
provide: OpenProjectCommonInterfacesService,
useValue: mockCommonInterfaceService,
},
- { provide: GiteaCommonInterfacesService, useValue: mockCommonInterfaceService },
{ provide: RedmineCommonInterfacesService, useValue: mockCommonInterfaceService },
- { provide: LinearCommonInterfacesService, useValue: mockCommonInterfaceService },
{
provide: CalendarCommonInterfacesService,
useValue: mockCommonInterfaceService,
diff --git a/src/app/features/issue/issue.service.ts b/src/app/features/issue/issue.service.ts
index 4af1f46f66..ccf00545a2 100644
--- a/src/app/features/issue/issue.service.ts
+++ b/src/app/features/issue/issue.service.ts
@@ -15,7 +15,6 @@ import { TaskAttachment } from '../tasks/task-attachment/task-attachment.model';
import { firstValueFrom, forkJoin, from, merge, Observable, of, Subject } from 'rxjs';
import {
CALDAV_TYPE,
- GITEA_TYPE,
GITLAB_TYPE,
ICAL_TYPE,
ISSUE_PROVIDER_HUMANIZED,
@@ -26,7 +25,6 @@ import {
OPEN_PROJECT_TYPE,
TRELLO_TYPE,
REDMINE_TYPE,
- LINEAR_TYPE,
AZURE_DEVOPS_TYPE,
NEXTCLOUD_DECK_TYPE,
} from './issue.const';
@@ -40,9 +38,9 @@ import { IssueLog } from '../../core/log';
import { GitlabCommonInterfacesService } from './providers/gitlab/gitlab-common-interfaces.service';
import { CaldavCommonInterfacesService } from './providers/caldav/caldav-common-interfaces.service';
import { OpenProjectCommonInterfacesService } from './providers/open-project/open-project-common-interfaces.service';
-import { GiteaCommonInterfacesService } from './providers/gitea/gitea-common-interfaces.service';
+// Gitea is now a plugin — no built-in service needed
import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service';
-import { LinearCommonInterfacesService } from './providers/linear/linear-common-interfaces.service';
+// Linear is now a plugin — no built-in service needed
// ClickUp is now a plugin — no built-in service needed
import { AzureDevOpsCommonInterfacesService } from './providers/azure-devops/azure-devops-common-interfaces.service';
import { NextcloudDeckCommonInterfacesService } from './providers/nextcloud-deck/nextcloud-deck-common-interfaces.service';
@@ -79,9 +77,7 @@ export class IssueService {
private _gitlabCommonInterfacesService = inject(GitlabCommonInterfacesService);
private _caldavCommonInterfaceService = inject(CaldavCommonInterfacesService);
private _openProjectInterfaceService = inject(OpenProjectCommonInterfacesService);
- private _giteaInterfaceService = inject(GiteaCommonInterfacesService);
private _redmineInterfaceService = inject(RedmineCommonInterfacesService);
- private _linearCommonInterfaceService = inject(LinearCommonInterfacesService);
private _azureDevOpsCommonInterfaceService = inject(AzureDevOpsCommonInterfacesService);
private _nextcloudDeckCommonInterfaceService = inject(
NextcloudDeckCommonInterfacesService,
@@ -104,10 +100,8 @@ export class IssueService {
[JIRA_TYPE]: this._jiraCommonInterfacesService,
[CALDAV_TYPE]: this._caldavCommonInterfaceService,
[OPEN_PROJECT_TYPE]: this._openProjectInterfaceService,
- [GITEA_TYPE]: this._giteaInterfaceService,
[REDMINE_TYPE]: this._redmineInterfaceService,
[ICAL_TYPE]: this._calendarCommonInterfaceService,
- [LINEAR_TYPE]: this._linearCommonInterfaceService,
[AZURE_DEVOPS_TYPE]: this._azureDevOpsCommonInterfaceService,
[NEXTCLOUD_DECK_TYPE]: this._nextcloudDeckCommonInterfaceService,
diff --git a/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts b/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts
index be4c9ece0a..073759c468 100644
--- a/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts
+++ b/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts
@@ -55,8 +55,6 @@ export const getIssueProviderTooltip = (issueProvider: IssueProvider): string =>
return issueProvider.host;
case 'GITLAB':
return issueProvider.project;
- case 'GITEA':
- return issueProvider.repoFullname;
case 'CALDAV':
return issueProvider.caldavUrl;
case 'ICAL':
@@ -139,8 +137,6 @@ export const getIssueProviderInitials = (
case 'GITLAB':
return getRepoInitials(issueProvider.project);
- case 'GITEA':
- return getRepoInitials(issueProvider.repoFullname);
case 'TRELLO':
return (issueProvider.boardName || issueProvider.boardId)
?.substring(0, 2)
@@ -150,5 +146,4 @@ export const getIssueProviderInitials = (
case 'AZURE_DEVOPS':
return issueProvider.project?.substring(0, 2)?.toUpperCase() || 'AD';
}
- return undefined;
};
diff --git a/src/app/features/issue/mapping-helper/is-issue-done.ts b/src/app/features/issue/mapping-helper/is-issue-done.ts
index 184a3a102e..78a5502c02 100644
--- a/src/app/features/issue/mapping-helper/is-issue-done.ts
+++ b/src/app/features/issue/mapping-helper/is-issue-done.ts
@@ -1,5 +1,4 @@
import { SearchResultItem } from '../issue.model';
-import { isLinearIssueDone } from '../providers/linear/linear-issue-map.util';
const ISSUE_DONE_STATE_NAME_GUESSES = ['closed', 'done', 'completed', 'resolved'];
@@ -10,11 +9,6 @@ export const isIssueDone = (searchResultItem: SearchResultItem): boolean => {
(searchResultItem as SearchResultItem<'GITLAB'>).issueData.state === 'closed'
);
- case 'GITEA':
- return ISSUE_DONE_STATE_NAME_GUESSES.includes(
- (searchResultItem as SearchResultItem<'GITEA'>).issueData.state,
- );
-
case 'JIRA':
return ISSUE_DONE_STATE_NAME_GUESSES.includes(
(searchResultItem as SearchResultItem<'JIRA'>).issueData.status?.name,
@@ -31,11 +25,6 @@ export const isIssueDone = (searchResultItem: SearchResultItem): boolean => {
case 'CALDAV':
return false;
- case 'LINEAR':
- return isLinearIssueDone(
- (searchResultItem as SearchResultItem<'LINEAR'>).issueData,
- );
-
default: {
// Handle plugin providers and migrated providers (e.g. 'GITHUB')
// PluginIssue uses 'state', PluginSearchResult uses 'status'
diff --git a/src/app/features/issue/providers/gitea/format-gitea-issue-title.util.ts b/src/app/features/issue/providers/gitea/format-gitea-issue-title.util.ts
deleted file mode 100644
index e0734168ef..0000000000
--- a/src/app/features/issue/providers/gitea/format-gitea-issue-title.util.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { GiteaIssue } from './gitea-issue.model';
-import { truncate } from '../../../../util/truncate';
-
-export const formatGiteaIssueTitle = ({ number, title }: GiteaIssue): string => {
- return `#${number} ${title}`;
-};
-
-export const formatGiteaIssueTitleForSnack = (issue: GiteaIssue): string => {
- return `${truncate(formatGiteaIssueTitle(issue))}`;
-};
diff --git a/src/app/features/issue/providers/gitea/gitea-api-responses.d.ts b/src/app/features/issue/providers/gitea/gitea-api-responses.d.ts
deleted file mode 100644
index 93adb8b381..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-api-responses.d.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { GiteaIssueStateOptions } from './gitea-issue.model';
-
-export type GiteaIssueState =
- | GiteaIssueStateOptions.open
- | GiteaIssueStateOptions.closed
- | GiteaIssueStateOptions.all;
-
-export interface GiteaUser {
- avatar_url: string;
- id: number;
- username: string;
- login: string;
- full_name: string;
-}
diff --git a/src/app/features/issue/providers/gitea/gitea-api.service.ts b/src/app/features/issue/providers/gitea/gitea-api.service.ts
deleted file mode 100644
index 99a1ce0fba..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-api.service.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-import { Injectable, inject } from '@angular/core';
-import { SnackService } from '../../../../core/snack/snack.service';
-import { HttpClient, HttpHeaders, HttpParams, HttpRequest } from '@angular/common/http';
-import { GiteaCfg } from './gitea.model';
-import { catchError, filter, map, switchMap } from 'rxjs/operators';
-import { Observable } from 'rxjs';
-import { throwHandledError } from '../../../../util/throw-handled-error';
-import { T } from '../../../../t.const';
-import { GITEA_TYPE, ISSUE_PROVIDER_HUMANIZED } from '../../issue.const';
-import {
- GiteaIssue,
- GiteaIssueStateOptions,
- GiteaRepositoryReduced,
-} from './gitea-issue.model';
-import {
- hasAllLabels,
- isIssueFromProject,
- isIssueIncludedByLabels,
- mapGiteaIssueIdToIssueNumber,
- mapGiteaIssueToSearchResult,
- parseLabelList,
-} from './gitea-issue-map.util';
-import {
- GITEA_API_SUBPATH_REPO,
- GITEA_API_SUBPATH_USER,
- GITEA_API_SUFFIX,
- GITEA_API_VERSION,
- ScopeOptions,
-} from './gitea.const';
-import { SearchResultItem } from '../../issue.model';
-import { GiteaUser } from './gitea-api-responses';
-import { handleIssueProviderHttpError$ } from '../../handle-issue-provider-http-error';
-
-@Injectable({
- providedIn: 'root',
-})
-export class GiteaApiService {
- private _snackService = inject(SnackService);
- private _http = inject(HttpClient);
-
- searchIssueForRepo$(searchText: string, cfg: GiteaCfg): Observable {
- const includedLabelNames = parseLabelList(cfg.filterLabels);
- const excludedLabelNames = parseLabelList(cfg.excludeLabels);
- return this.getCurrentRepositoryFor$(cfg).pipe(
- switchMap((repository: GiteaRepositoryReduced) => {
- return this._sendRequest$(
- {
- url: this._getIssueSearchUrlFor(cfg),
- params: ParamsBuilder.create()
- .withLimit(100)
- .withState(GiteaIssueStateOptions.open)
- .withScopeForSearchFrom(cfg, repository)
- .withFilterLabels(cfg)
- .withSearchTerm(searchText)
- .build(),
- },
- cfg,
- ).pipe(
- map((res: GiteaIssue[]) => {
- return res
- ? res
- .filter((issue: GiteaIssue) => isIssueFromProject(issue, cfg))
- .filter((issue: GiteaIssue) => hasAllLabels(issue, includedLabelNames))
- .filter((issue: GiteaIssue) =>
- isIssueIncludedByLabels(issue, excludedLabelNames),
- )
- .map((issue: GiteaIssue) => mapGiteaIssueIdToIssueNumber(issue))
- .map((issue: GiteaIssue) => mapGiteaIssueToSearchResult(issue))
- : [];
- }),
- );
- }),
- );
- }
-
- private _getIssueSearchUrlFor(cfg: GiteaCfg): string {
- // see https://try.gitea.io/api/swagger#/issue
- return `${this._getBaseUrlFor(cfg)}/${GITEA_API_SUBPATH_REPO}/issues/search`;
- }
-
- getLast100IssuesFor$(cfg: GiteaCfg): Observable {
- const includedLabelNames = parseLabelList(cfg.filterLabels);
- const excludedLabelNames = parseLabelList(cfg.excludeLabels);
- return this.getLoggedUserFor$(cfg).pipe(
- switchMap((user: GiteaUser) => {
- return this._sendRequest$(
- {
- url: this._getIssueUrlFor(cfg),
- params: ParamsBuilder.create()
- .withLimit(100)
- .withState(GiteaIssueStateOptions.open)
- .withScopeFrom(cfg, user)
- .withFilterLabels(cfg)
- .build(),
- },
- cfg,
- ).pipe(
- map((issues: GiteaIssue[]) => {
- return issues
- ? issues
- .filter((issue: GiteaIssue) => hasAllLabels(issue, includedLabelNames))
- .filter((issue: GiteaIssue) =>
- isIssueIncludedByLabels(issue, excludedLabelNames),
- )
- .map((issue: GiteaIssue) => mapGiteaIssueIdToIssueNumber(issue))
- : [];
- }),
- );
- }),
- );
- }
-
- private _getIssueUrlFor(cfg: GiteaCfg): string {
- return `${this._getBaseUrlFor(cfg)}/${GITEA_API_SUBPATH_REPO}/${
- cfg.repoFullname
- }/issues`;
- }
-
- getLoggedUserFor$(cfg: GiteaCfg): Observable {
- return this._sendRequest$(
- {
- url: this._getUserUrlFor(cfg),
- params: {},
- },
- cfg,
- ).pipe(
- map((user: GiteaUser) => {
- return user;
- }),
- );
- }
-
- private _getUserUrlFor(cfg: GiteaCfg): string {
- return `${this._getBaseUrlFor(cfg)}/${GITEA_API_SUBPATH_USER}`;
- }
-
- getCurrentRepositoryFor$(cfg: GiteaCfg): Observable {
- return this._sendRequest$(
- {
- url: this._getRepositoryUrlFor(cfg),
- params: {},
- },
- cfg,
- ).pipe(
- map((repository: GiteaRepositoryReduced) => {
- return repository;
- }),
- );
- }
-
- private _getRepositoryUrlFor(cfg: GiteaCfg): string {
- return `${this._getBaseUrlFor(cfg)}/${GITEA_API_SUBPATH_REPO}/${cfg.repoFullname}`;
- }
-
- private _getBaseUrlFor(cfg: GiteaCfg): string {
- return `${cfg.host}/${GITEA_API_SUFFIX}/${GITEA_API_VERSION}`;
- }
-
- getById$(issueNumber: number, cfg: GiteaCfg): Observable {
- return this._sendRequest$(
- {
- url: `${this._getIssueUrlFor(cfg)}/${issueNumber}`,
- },
- cfg,
- ).pipe(map((issue) => issue));
- }
-
- private _sendRequest$(
- params: HttpRequest | any,
- cfg: GiteaCfg,
- ): Observable {
- this._checkSettings(cfg);
- params.params = { ...params.params, access_token: cfg.token };
- const p: HttpRequest | any = {
- ...params,
- method: params.method || 'GET',
- headers: {
- ...(params.headers ? params.headers : { accept: 'application/json' }),
- },
- };
-
- const bodyArg = params.data ? [params.data] : [];
-
- const allArgs = [
- ...bodyArg,
- {
- headers: new HttpHeaders(p.headers),
- params: new HttpParams({ fromObject: p.params }),
- reportProgress: false,
- observe: 'response',
- responseType: params.responseType,
- },
- ];
- const req = new HttpRequest(p.method, p.url, ...allArgs);
- return this._http.request(req).pipe(
- // Filter out HttpEventType.Sent (type: 0) events to only process actual responses
- filter((res) => !(res === Object(res) && res.type === 0)),
- map((res: any) => (res && res.body ? res.body : res)),
- catchError((err) =>
- handleIssueProviderHttpError$(GITEA_TYPE, this._snackService, err),
- ),
- );
- }
-
- private _checkSettings(cfg: GiteaCfg): void {
- if (!this._isValidSettings(cfg)) {
- this._snackService.open({
- type: 'ERROR',
- msg: T.F.ISSUE.S.ERR_NOT_CONFIGURED,
- translateParams: {
- issueProviderName: ISSUE_PROVIDER_HUMANIZED[GITEA_TYPE],
- },
- });
- throwHandledError('Gitea: Not enough settings');
- }
- }
-
- private _isValidSettings(cfg: GiteaCfg): boolean {
- return (
- !!cfg &&
- !!cfg.host &&
- cfg.host.length > 0 &&
- !!cfg.repoFullname &&
- cfg.repoFullname.length > 0
- );
- }
-}
-
-class ParamsBuilder {
- params: any = {};
-
- static create(): ParamsBuilder {
- return new ParamsBuilder();
- }
-
- withLimit(limit: number): ParamsBuilder {
- this.params['limit'] = limit;
- return this;
- }
-
- withState(state: string): ParamsBuilder {
- this.params['state'] = state;
- return this;
- }
-
- withScopeFrom(cfg: GiteaCfg, user: GiteaUser): ParamsBuilder {
- if (!cfg.scope) {
- return this;
- }
-
- if (cfg.scope === ScopeOptions.createdByMe) {
- this.params['created_by'] = user.username;
- } else if (cfg.scope === ScopeOptions.assignedToMe) {
- this.params['assigned_by'] = user.username;
- }
-
- return this;
- }
-
- withScopeForSearchFrom(
- cfg: GiteaCfg,
- repository: GiteaRepositoryReduced,
- ): ParamsBuilder {
- if (!cfg.scope) {
- return this;
- }
-
- // Seens to be the only way to "filter" for the current repo
- if (repository.id) this.params['priority_repo_id'] = repository.id;
-
- if (cfg.scope === ScopeOptions.createdByMe) {
- this.params['created'] = true;
- } else if (cfg.scope === ScopeOptions.assignedToMe) {
- this.params['assigned'] = true;
- }
-
- return this;
- }
-
- withFilterLabels(cfg: GiteaCfg): ParamsBuilder {
- const labels = parseLabelList(cfg.filterLabels);
- if (labels.length > 0) {
- this.params['labels'] = labels.join(',');
- }
- return this;
- }
-
- withSearchTerm(search: string): ParamsBuilder {
- this.params['q'] = search;
- return this;
- }
-
- build(): any {
- return this.params;
- }
-}
diff --git a/src/app/features/issue/providers/gitea/gitea-cfg-form.const.ts b/src/app/features/issue/providers/gitea/gitea-cfg-form.const.ts
deleted file mode 100644
index cbee44bfce..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-cfg-form.const.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { T } from '../../../../t.const';
-import {
- ConfigFormSection,
- LimitedFormlyFieldConfig,
-} from '../../../config/global-config.model';
-import { IssueProviderGitea } from '../../issue.model';
-import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const';
-import { GiteaCfg } from './gitea.model';
-
-export enum ScopeOptions {
- all = 'all',
- createdByMe = 'created-by-me',
- assignedToMe = 'assigned-to-me',
-}
-
-export const DEFAULT_GITEA_CFG: GiteaCfg = {
- isEnabled: false,
- host: null,
- repoFullname: null,
- token: null,
- scope: 'created-by-me',
- filterLabels: null,
- excludeLabels: null,
-};
-
-export const GITEA_CONFIG_FORM: LimitedFormlyFieldConfig[] = [
- {
- key: 'host',
- type: 'input',
- templateOptions: {
- label: T.F.GITEA.FORM.HOST,
- type: 'url',
- pattern: /^.+\/.+?$/i,
- required: true,
- },
- },
- {
- key: 'token',
- type: 'input',
- templateOptions: {
- label: T.F.GITEA.FORM.TOKEN,
- required: true,
- type: 'password',
- },
- },
- {
- type: 'link',
- templateOptions: {
- url: 'https://www.jetbrains.com/help/youtrack/cloud/integration-with-gitea.html#enable-youtrack-integration-gitea',
- txt: T.F.ISSUE.HOW_TO_GET_A_TOKEN,
- },
- },
- {
- key: 'repoFullname',
- type: 'input',
- templateOptions: {
- label: T.F.GITEA.FORM.REPO_FULL_NAME,
- type: 'text',
- required: true,
- description: T.F.GITEA.FORM.REPO_FULL_NAME_DESCRIPTION,
- },
- },
- {
- key: 'scope',
- type: 'select',
- defaultValue: 'created-by-me',
- templateOptions: {
- required: true,
- label: T.F.GITEA.FORM.SCOPE,
- options: [
- { value: ScopeOptions.all, label: T.F.GITEA.FORM.SCOPE_ALL },
- { value: ScopeOptions.createdByMe, label: T.F.GITEA.FORM.SCOPE_CREATED },
- { value: ScopeOptions.assignedToMe, label: T.F.GITEA.FORM.SCOPE_ASSIGNED },
- ],
- },
- },
- {
- key: 'filterLabels',
- type: 'input',
- templateOptions: {
- label: T.F.GITEA.FORM.FILTER_LABELS,
- type: 'text',
- description: T.F.GITEA.FORM.FILTER_LABELS_DESCRIPTION,
- },
- },
- {
- key: 'excludeLabels',
- type: 'input',
- templateOptions: {
- label: T.F.GITEA.FORM.EXCLUDE_LABELS,
- type: 'text',
- description: T.F.GITEA.FORM.EXCLUDE_LABELS_DESCRIPTION,
- },
- },
- {
- type: 'collapsible',
- // todo translate
- props: { label: 'Advanced Config' },
- fieldGroup: [...ISSUE_PROVIDER_COMMON_FORM_FIELDS],
- },
-];
-
-export const GITEA_CONFIG_FORM_SECTION: ConfigFormSection = {
- title: 'Gitea',
- key: 'GITEA',
- items: GITEA_CONFIG_FORM,
- help: T.F.GITEA.FORM_SECTION.HELP,
-};
diff --git a/src/app/features/issue/providers/gitea/gitea-common-interfaces.service.spec.ts b/src/app/features/issue/providers/gitea/gitea-common-interfaces.service.spec.ts
deleted file mode 100644
index df053f2827..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-common-interfaces.service.spec.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { TaskCopy } from '../../../tasks/task.model';
-import { GiteaCommonInterfacesService } from './gitea-common-interfaces.service';
-import { GiteaIssue } from './gitea-issue.model';
-
-type AddTaskData = Partial> & { title: string };
-
-describe('GiteaCommonInterfacesService', () => {
- // getAddTaskData is a pure formatter that doesn't touch any injected
- // dependency, so call it off the prototype without going through DI.
- const getAddTaskData = (issue: GiteaIssue): AddTaskData =>
- GiteaCommonInterfacesService.prototype.getAddTaskData.call(
- null as unknown as GiteaCommonInterfacesService,
- issue,
- );
-
- describe('getAddTaskData', () => {
- const baseIssue = {
- id: 98765,
- number: 42,
- title: 'Example issue',
- state: 'open',
- updated_at: '2025-01-20T12:00:00Z',
- } as unknown as GiteaIssue;
-
- it('should set issueId from issue.number (not issue.id) so polling uses the per-repo number', () => {
- const result = getAddTaskData(baseIssue);
- expect(result.issueId).toBe('42');
- });
-
- it('should set isDone=true when issue.state is closed', () => {
- const result = getAddTaskData({ ...baseIssue, state: 'closed' } as GiteaIssue);
- expect(result.isDone).toBe(true);
- });
-
- it('should set isDone=false when issue.state is open', () => {
- const result = getAddTaskData(baseIssue);
- expect(result.isDone).toBe(false);
- });
- });
-});
diff --git a/src/app/features/issue/providers/gitea/gitea-common-interfaces.service.ts b/src/app/features/issue/providers/gitea/gitea-common-interfaces.service.ts
deleted file mode 100644
index d8c83eb9c7..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-common-interfaces.service.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Injectable, inject } from '@angular/core';
-import { firstValueFrom, Observable } from 'rxjs';
-import { map } from 'rxjs/operators';
-import { TaskCopy } from '../../../tasks/task.model';
-import { BaseIssueProviderService } from '../../base/base-issue-provider.service';
-import { IssueData, IssueDataReduced, SearchResultItem } from '../../issue.model';
-import { GITEA_POLL_INTERVAL } from './gitea.const';
-import {
- formatGiteaIssueTitle,
- formatGiteaIssueTitleForSnack,
-} from './format-gitea-issue-title.util';
-import { GiteaCfg } from './gitea.model';
-import { GiteaApiService } from '../gitea/gitea-api.service';
-import { GiteaIssue } from './gitea-issue.model';
-
-@Injectable({
- providedIn: 'root',
-})
-export class GiteaCommonInterfacesService extends BaseIssueProviderService {
- private readonly _giteaApiService = inject(GiteaApiService);
-
- readonly providerKey = 'GITEA' as const;
- readonly pollInterval: number = GITEA_POLL_INTERVAL;
-
- isEnabled(cfg: GiteaCfg): boolean {
- return !!cfg && cfg.isEnabled && !!cfg.host && !!cfg.token && !!cfg.repoFullname;
- }
-
- testConnection(cfg: GiteaCfg): Promise {
- return firstValueFrom(
- this._giteaApiService
- .searchIssueForRepo$('', cfg)
- .pipe(map((res) => Array.isArray(res))),
- ).then((result) => result ?? false);
- }
-
- issueLink(issueNumber: string | number, issueProviderId: string): Promise {
- return firstValueFrom(
- this._getCfgOnce$(issueProviderId).pipe(
- map((cfg) => `${cfg.host}/${cfg.repoFullname}/issues/${issueNumber}`),
- ),
- ).then((result) => result ?? '');
- }
-
- getAddTaskData(issue: GiteaIssue): Partial> & { title: string } {
- return {
- title: formatGiteaIssueTitle(issue),
- issueId: String(issue.number),
- isDone: issue.state === 'closed',
- issueWasUpdated: false,
- issueLastUpdated: new Date(issue.updated_at).getTime(),
- };
- }
-
- async getNewIssuesToAddToBacklog(
- issueProviderId: string,
- _allExistingIssueIds: number[] | string[],
- ): Promise {
- const cfg = await firstValueFrom(this._getCfgOnce$(issueProviderId));
- return await firstValueFrom(this._giteaApiService.getLast100IssuesFor$(cfg));
- }
-
- protected _apiGetById$(
- id: string | number,
- cfg: GiteaCfg,
- ): Observable {
- return this._giteaApiService.getById$(id as number, cfg);
- }
-
- protected _apiSearchIssues$(
- searchTerm: string,
- cfg: GiteaCfg,
- ): Observable {
- return this._giteaApiService.searchIssueForRepo$(searchTerm, cfg);
- }
-
- protected _formatIssueTitleForSnack(issue: IssueData): string {
- return formatGiteaIssueTitleForSnack(issue as GiteaIssue);
- }
-
- protected _getIssueLastUpdated(issue: IssueData): number {
- return new Date((issue as GiteaIssue).updated_at).getTime();
- }
-}
diff --git a/src/app/features/issue/providers/gitea/gitea-issue-content.const.ts b/src/app/features/issue/providers/gitea/gitea-issue-content.const.ts
deleted file mode 100644
index 1e7099a4ae..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-issue-content.const.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { T } from '../../../../t.const';
-import {
- IssueContentConfig,
- IssueFieldType,
-} from '../../issue-content/issue-content.model';
-import { GiteaIssue } from './gitea-issue.model';
-
-export const GITEA_ISSUE_CONTENT_CONFIG: IssueContentConfig = {
- issueType: 'GITEA' as const,
- fields: [
- {
- label: T.F.ISSUE.ISSUE_CONTENT.SUMMARY,
- type: IssueFieldType.LINK,
- value: (issue: GiteaIssue) => `${issue.title} #${issue.number}`,
- getLink: (issue: GiteaIssue) => issue.html_url,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.STATUS,
- value: 'state',
- type: IssueFieldType.TEXT,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.ASSIGNEE,
- type: IssueFieldType.TEXT,
- value: (issue: GiteaIssue) =>
- issue.assignees?.map((a) => a.login || a.username).join(', '),
- isVisible: (issue: GiteaIssue) => (issue.assignees?.length ?? 0) > 0,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.LABELS,
- value: 'labels',
- type: IssueFieldType.CHIPS,
- isVisible: (issue: GiteaIssue) => (issue.labels?.length ?? 0) > 0,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.DESCRIPTION,
- value: 'body',
- type: IssueFieldType.MARKDOWN,
- isVisible: (issue: GiteaIssue) => !!issue.body,
- },
- ],
- comments: {
- field: 'comments',
- authorField: 'user.login',
- bodyField: 'body',
- createdField: 'created_at',
- sortField: 'created_at',
- },
- getIssueUrl: (issue: GiteaIssue) => issue.url,
- hasCollapsingComments: true,
-};
diff --git a/src/app/features/issue/providers/gitea/gitea-issue-map.util.spec.ts b/src/app/features/issue/providers/gitea/gitea-issue-map.util.spec.ts
deleted file mode 100644
index 8f41469c3d..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-issue-map.util.spec.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import {
- hasAllLabels,
- isIssueIncludedByLabels,
- parseLabelList,
-} from './gitea-issue-map.util';
-import { GiteaIssue, GiteaLabel } from './gitea-issue.model';
-
-const makeLabel = (name: string): GiteaLabel =>
- ({ id: 0, name, color: '', description: '', url: '' }) as GiteaLabel;
-
-const makeIssue = (labels: GiteaLabel[] | undefined): GiteaIssue =>
- ({ labels }) as unknown as GiteaIssue;
-
-describe('gitea-issue-map.util', () => {
- describe('parseLabelList', () => {
- it('returns [] for null', () => {
- expect(parseLabelList(null)).toEqual([]);
- });
-
- it('returns [] for an empty string', () => {
- expect(parseLabelList('')).toEqual([]);
- });
-
- it('returns [] when only whitespace and commas', () => {
- expect(parseLabelList(' , ,,')).toEqual([]);
- });
-
- it('trims whitespace around each entry', () => {
- expect(parseLabelList(' bug , enhancement , docs ')).toEqual([
- 'bug',
- 'enhancement',
- 'docs',
- ]);
- });
-
- it('preserves scoped labels with slashes', () => {
- expect(parseLabelList('project/uconsole,bug')).toEqual(['project/uconsole', 'bug']);
- });
- });
-
- describe('isIssueIncludedByLabels', () => {
- it('includes any issue when the exclude list is empty', () => {
- const issue = makeIssue([makeLabel('bug')]);
- expect(isIssueIncludedByLabels(issue, [])).toBe(true);
- });
-
- it('includes issues that carry none of the excluded labels', () => {
- const issue = makeIssue([makeLabel('bug'), makeLabel('enhancement')]);
- expect(isIssueIncludedByLabels(issue, ['wontfix'])).toBe(true);
- });
-
- it('excludes an issue that carries an excluded label', () => {
- const issue = makeIssue([makeLabel('bug'), makeLabel('wontfix')]);
- expect(isIssueIncludedByLabels(issue, ['wontfix'])).toBe(false);
- });
-
- it('excludes when any of multiple excluded labels matches', () => {
- const issue = makeIssue([makeLabel('docs')]);
- expect(isIssueIncludedByLabels(issue, ['wontfix', 'docs', 'stale'])).toBe(false);
- });
-
- it('treats missing issue.labels as no labels and includes the issue', () => {
- const issue = makeIssue(undefined);
- expect(isIssueIncludedByLabels(issue, ['wontfix'])).toBe(true);
- });
-
- it('matches scoped label names exactly', () => {
- const issue = makeIssue([makeLabel('project/ai')]);
- expect(isIssueIncludedByLabels(issue, ['project/uconsole'])).toBe(true);
- expect(isIssueIncludedByLabels(issue, ['project/ai'])).toBe(false);
- });
- });
-
- describe('hasAllLabels', () => {
- it('includes any issue when the required list is empty', () => {
- const issue = makeIssue([makeLabel('bug')]);
- expect(hasAllLabels(issue, [])).toBe(true);
- });
-
- it('includes issues that carry every required label', () => {
- const issue = makeIssue([
- makeLabel('bug'),
- makeLabel('enhancement'),
- makeLabel('docs'),
- ]);
- expect(hasAllLabels(issue, ['bug', 'enhancement'])).toBe(true);
- });
-
- it('excludes issues missing any required label (AND semantics)', () => {
- const issue = makeIssue([makeLabel('bug')]);
- expect(hasAllLabels(issue, ['bug', 'wontfix'])).toBe(false);
- });
-
- it('treats missing issue.labels as no labels and excludes the issue', () => {
- const issue = makeIssue(undefined);
- expect(hasAllLabels(issue, ['bug'])).toBe(false);
- });
-
- it('matches scoped label names exactly', () => {
- const issue = makeIssue([makeLabel('project/ai'), makeLabel('bug')]);
- expect(hasAllLabels(issue, ['project/ai', 'bug'])).toBe(true);
- expect(hasAllLabels(issue, ['project/uconsole', 'bug'])).toBe(false);
- });
- });
-});
diff --git a/src/app/features/issue/providers/gitea/gitea-issue-map.util.ts b/src/app/features/issue/providers/gitea/gitea-issue-map.util.ts
deleted file mode 100644
index b3577187a8..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-issue-map.util.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { IssueProviderKey, SearchResultItem } from '../../issue.model';
-import { GiteaCfg } from './gitea.model';
-import { GiteaIssue } from './gitea-issue.model';
-import { formatGiteaIssueTitle } from './format-gitea-issue-title.util';
-
-export const mapGiteaIssueToSearchResult = (issue: GiteaIssue): SearchResultItem => {
- return {
- title: formatGiteaIssueTitle(issue),
- titleHighlighted: formatGiteaIssueTitle(issue),
- issueType: 'GITEA' as IssueProviderKey,
- issueData: issue,
- };
-};
-
-// Gitea uses the issue number instead of issue id to track the issues
-export const mapGiteaIssueIdToIssueNumber = (issue: GiteaIssue): GiteaIssue => {
- return { ...issue, id: issue.number };
-};
-
-// We need to filter as api does not do it for us
-export const isIssueFromProject = (issue: GiteaIssue, cfg: GiteaCfg): boolean => {
- if (!issue.repository) {
- return false;
- }
- return issue.repository.full_name === cfg.repoFullname;
-};
-
-export const parseLabelList = (raw: string | null): string[] =>
- (raw ?? '')
- .split(',')
- .map((l) => l.trim())
- .filter((l) => l.length > 0);
-
-// Gitea/Forgejo's two issue endpoints (`/repos/{o}/{r}/issues` and
-// `/repos/issues/search`) historically disagree on whether `labels=a,b` means
-// AND or OR (see go-gitea/gitea#33509), and Forgejo inherits the same code.
-// We always filter labels client-side so behavior is consistent and independent
-// of any server-side fixes.
-export const isIssueIncludedByLabels = (
- issue: GiteaIssue,
- excludedLabelNames: readonly string[],
-): boolean => {
- if (excludedLabelNames.length === 0) {
- return true;
- }
- const issueLabelNames = new Set((issue.labels ?? []).map((l) => l.name));
- return !excludedLabelNames.some((name) => issueLabelNames.has(name));
-};
-
-export const hasAllLabels = (
- issue: GiteaIssue,
- requiredLabelNames: readonly string[],
-): boolean => {
- if (requiredLabelNames.length === 0) {
- return true;
- }
- const issueLabelNames = new Set((issue.labels ?? []).map((l) => l.name));
- return requiredLabelNames.every((name) => issueLabelNames.has(name));
-};
diff --git a/src/app/features/issue/providers/gitea/gitea-issue.model.ts b/src/app/features/issue/providers/gitea/gitea-issue.model.ts
deleted file mode 100644
index 7f093033c2..0000000000
--- a/src/app/features/issue/providers/gitea/gitea-issue.model.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { GiteaUser } from './gitea-api-responses';
-
-export enum GiteaIssueStateOptions {
- open = 'open',
- closed = 'closed',
- all = 'all',
-}
-
-export type GiteaLabel = Readonly<{
- id: number;
- name: string;
- color: string;
- description: string;
- url: string;
-}>;
-
-export type GiteaRepositoryReduced = Readonly<{
- id: number;
- name: string;
- owner: string;
- full_name: string;
-}>;
-
-export type GiteaIssue = Readonly<{
- id: number;
- url: string;
- html_url: string;
- number: number;
- user: GiteaUser;
- original_author: string;
- original_author_id: number;
- title: string;
- body: string;
- ref: string;
- labels: GiteaLabel[];
- milestone: unknown | null;
- assignee: GiteaUser;
- assignees: GiteaUser[];
- state: string;
- is_locked: boolean;
- comments: number;
- created_at: string;
- updated_at: string;
- closed_at: string | null;
- due_date: string | null;
- repository: GiteaRepositoryReduced;
-}>;
diff --git a/src/app/features/issue/providers/gitea/gitea.const.ts b/src/app/features/issue/providers/gitea/gitea.const.ts
deleted file mode 100644
index 4421195e74..0000000000
--- a/src/app/features/issue/providers/gitea/gitea.const.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export { GITEA_ISSUE_CONTENT_CONFIG } from './gitea-issue-content.const';
-export {
- GITEA_CONFIG_FORM_SECTION,
- GITEA_CONFIG_FORM,
- ScopeOptions,
- DEFAULT_GITEA_CFG,
-} from './gitea-cfg-form.const';
-
-export const GITEA_POLL_INTERVAL = 5 * 60 * 1000;
-export const GITEA_INITIAL_POLL_DELAY = 8 * 1000;
-
-export const GITEA_API_SUFFIX = 'api';
-export const GITEA_API_VERSION = 'v1';
-export const GITEA_API_SUBPATH_REPO = 'repos';
-export const GITEA_API_SUBPATH_USER = 'user';
diff --git a/src/app/features/issue/providers/gitea/gitea.model.ts b/src/app/features/issue/providers/gitea/gitea.model.ts
deleted file mode 100644
index 2c1a83c301..0000000000
--- a/src/app/features/issue/providers/gitea/gitea.model.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { BaseIssueProviderCfg } from '../../issue.model';
-
-export interface GiteaCfg extends BaseIssueProviderCfg {
- repoFullname: string | null;
- host: string | null;
- token: string | null;
- scope: string | null;
- filterLabels: string | null;
- excludeLabels: string | null;
-}
diff --git a/src/app/features/issue/providers/linear/linear-api.service.spec.ts b/src/app/features/issue/providers/linear/linear-api.service.spec.ts
deleted file mode 100644
index ca61101aa0..0000000000
--- a/src/app/features/issue/providers/linear/linear-api.service.spec.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-import { TestBed } from '@angular/core/testing';
-import {
- HttpClientTestingModule,
- HttpTestingController,
-} from '@angular/common/http/testing';
-import { LinearApiService } from './linear-api.service';
-import { SnackService } from '../../../../core/snack/snack.service';
-import { LinearCfg } from './linear.model';
-
-describe('LinearApiService', () => {
- let service: LinearApiService;
- let httpMock: HttpTestingController;
- let snackService: jasmine.SpyObj;
-
- const mockCfg: LinearCfg = {
- apiKey: 'test-api-key',
- } as any;
-
- beforeEach(() => {
- const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
-
- TestBed.configureTestingModule({
- imports: [HttpClientTestingModule],
- providers: [LinearApiService, { provide: SnackService, useValue: snackServiceSpy }],
- });
-
- service = TestBed.inject(LinearApiService);
- httpMock = TestBed.inject(HttpTestingController);
- snackService = TestBed.inject(SnackService) as jasmine.SpyObj;
- });
-
- afterEach(() => {
- httpMock.verify();
- });
-
- it('should fetch issue by id', (done) => {
- const issueId = 'test-issue-id';
- const mockResponse = {
- data: {
- issue: {
- id: issueId,
- identifier: 'LIN-1',
- number: 1,
- title: 'Test Issue',
- description: 'Test Description',
- priority: 1,
- createdAt: '2025-01-01T00:00:00Z',
- updatedAt: '2025-01-02T00:00:00Z',
- completedAt: null,
- canceledAt: null,
- dueDate: null,
- url: 'https://linear.app/issue',
- state: {
- id: 'state-id',
- name: 'In Progress',
- type: 'started',
- },
- team: {
- id: 'team-id',
- name: 'Team A',
- key: 'TEAM',
- },
- assignee: {
- id: 'user-id',
- name: 'John Doe',
- email: 'john@example.com',
- avatarUrl: 'https://example.com/avatar.jpg',
- },
- creator: {
- id: 'creator-id',
- name: 'Jane Doe',
- },
- labels: {
- nodes: [{ id: 'label-1', name: 'bug', color: '#ff0000' }],
- },
- comments: {
- nodes: [
- {
- id: 'comment-1',
- body: 'Test comment',
- createdAt: '2025-01-02T00:00:00Z',
- user: {
- id: 'user-2',
- name: 'Jane Smith',
- avatarUrl: 'https://example.com/avatar2.jpg',
- },
- },
- ],
- },
- attachments: {
- nodes: [
- {
- id: 'attachment-1',
- sourceType: 'github',
- title: 'GitHub Pull Request',
- url: 'https://github.com/example/pr/123',
- },
- {
- id: 'attachment-2',
- sourceType: 'slack',
- title: 'Slack Discussion',
- url: 'https://slack.com/archives/C123456/p1234567890',
- },
- ],
- },
- },
- },
- };
-
- service.getById$(issueId, mockCfg).subscribe((issue) => {
- expect(issue.identifier).toBe('LIN-1');
- expect(issue.title).toBe('Test Issue');
- expect(issue.comments.length).toBe(1);
- expect(issue.attachments).toBeDefined();
- expect(issue.attachments.length).toBe(2);
- expect(issue.attachments[0].sourceType).toBe('github');
- expect(issue.attachments[1].sourceType).toBe('slack');
- done();
- });
-
- const req = httpMock.expectOne('https://api.linear.app/graphql');
- expect(req.request.method).toBe('POST');
- expect(req.request.headers.get('Authorization')).toBe('test-api-key');
- req.flush(mockResponse);
- });
-
- it('should search issues', (done) => {
- const mockResponse = {
- data: {
- viewer: {
- assignedIssues: {
- nodes: [
- {
- id: 'issue-1',
- identifier: 'LIN-1',
- number: 1,
- title: 'Test Issue 1',
- updatedAt: '2025-01-02T00:00:00Z',
- url: 'https://linear.app/issue1',
- state: {
- id: 'state-id',
- name: 'Backlog',
- type: 'backlog',
- },
- },
- ],
- },
- },
- },
- };
-
- service.searchIssues$('Test', mockCfg).subscribe((issues) => {
- expect(issues.length).toBe(1);
- expect(issues[0].title).toBe('Test Issue 1');
- done();
- });
-
- const req = httpMock.expectOne('https://api.linear.app/graphql');
- expect(req.request.method).toBe('POST');
- req.flush(mockResponse);
- });
-
- it('should test connection', (done) => {
- const mockResponse = {
- data: {
- viewer: {
- id: 'viewer-id',
- name: 'Test User',
- },
- },
- };
-
- service.testConnection(mockCfg).subscribe((result) => {
- expect(result).toBe(true);
- done();
- });
-
- const req = httpMock.expectOne('https://api.linear.app/graphql');
- req.flush(mockResponse);
- });
-
- it('should handle GraphQL errors', (done) => {
- const mockResponse = {
- errors: [{ message: 'Invalid query' }],
- };
-
- service.getById$('test-id', mockCfg).subscribe(
- () => {
- fail('Should have thrown error');
- },
- (err) => {
- // The error is thrown and propagated
- expect(err).toBeDefined();
- done();
- },
- );
-
- const req = httpMock.expectOne('https://api.linear.app/graphql');
- req.flush(mockResponse);
- });
-
- it('should handle HTTP errors', (done) => {
- service.getById$('test-id', mockCfg).subscribe(
- () => {
- fail('Should have thrown error');
- },
- () => {
- expect(snackService.open).toHaveBeenCalled();
- done();
- },
- );
-
- const req = httpMock.expectOne('https://api.linear.app/graphql');
- req.error(new ErrorEvent('Network error'));
- });
-
- it('should filter search results by search term', (done) => {
- const mockResponse = {
- data: {
- viewer: {
- assignedIssues: {
- nodes: [
- {
- id: 'issue-1',
- identifier: 'LIN-1',
- number: 1,
- title: 'Feature: Add login',
- updatedAt: '2025-01-02T00:00:00Z',
- url: 'https://linear.app/issue1',
- state: {
- id: 'state-id',
- name: 'Backlog',
- type: 'backlog',
- },
- },
- {
- id: 'issue-2',
- identifier: 'LIN-2',
- number: 2,
- title: 'Bug: Fix logout',
- updatedAt: '2025-01-02T00:00:00Z',
- url: 'https://linear.app/issue2',
- state: {
- id: 'state-id',
- name: 'Backlog',
- type: 'backlog',
- },
- },
- ],
- },
- },
- },
- };
-
- service.searchIssues$('login', mockCfg).subscribe((issues) => {
- expect(issues.length).toBe(1);
- expect(issues[0].title).toBe('Feature: Add login');
- done();
- });
-
- const req = httpMock.expectOne('https://api.linear.app/graphql');
- req.flush(mockResponse);
- });
-});
diff --git a/src/app/features/issue/providers/linear/linear-api.service.ts b/src/app/features/issue/providers/linear/linear-api.service.ts
deleted file mode 100644
index d3d67a6fb0..0000000000
--- a/src/app/features/issue/providers/linear/linear-api.service.ts
+++ /dev/null
@@ -1,370 +0,0 @@
-import { Injectable, inject } from '@angular/core';
-import {
- HttpClient,
- HttpEventType,
- HttpHeaders,
- HttpRequest,
- HttpResponse,
-} from '@angular/common/http';
-import { Observable } from 'rxjs';
-import { catchError, filter, map } from 'rxjs/operators';
-import { LinearCfg } from './linear.model';
-import { LinearAttachment, LinearIssue, LinearIssueReduced } from './linear-issue.model';
-import { SnackService } from '../../../../core/snack/snack.service';
-import { handleIssueProviderHttpError$ } from '../../handle-issue-provider-http-error';
-import { LINEAR_TYPE } from '../../issue.const';
-import { IssueLog } from '../../../../core/log';
-
-const LINEAR_API_URL = 'https://api.linear.app/graphql';
-
-interface LinearGraphQLResponse {
- data?: T;
- errors?: Array<{ message: string }>;
-}
-
-interface LinearRawIssueReduced {
- id: string;
- identifier: string;
- number: number;
- title: string;
- updatedAt: string;
- url: string;
- state: { name: string; type: string };
-}
-
-interface LinearRawIssue extends LinearRawIssueReduced {
- description?: string;
- priority: number;
- createdAt: string;
- completedAt?: string;
- canceledAt?: string;
- dueDate?: string;
- assignee?: { id: string; name: string; email: string; avatarUrl: string };
- creator: { id: string; name: string };
- team: { id: string; name: string; key: string };
- labels?: {
- nodes: Array<{ id: string; name: string; color: string }>;
- };
- comments?: {
- nodes: Array<{
- id: string;
- body: string;
- createdAt: string;
- user?: { id: string; name: string; avatarUrl: string };
- }>;
- };
- attachments?: { nodes: LinearAttachment[] };
-}
-
-@Injectable({
- providedIn: 'root',
-})
-export class LinearApiService {
- private _snackService = inject(SnackService);
- private _http = inject(HttpClient);
-
- getById$(issueId: string, cfg: LinearCfg): Observable {
- const query = `
- query GetIssue($id: String!) {
- issue(id: $id) {
- id
- identifier
- number
- title
- description
- priority
- createdAt
- updatedAt
- completedAt
- canceledAt
- dueDate
- url
- state {
- id
- name
- type
- }
- team {
- id
- name
- key
- }
- assignee {
- id
- name
- email
- avatarUrl
- }
- creator {
- id
- name
- }
- labels(first: 50) {
- nodes {
- id
- name
- color
- }
- }
- comments(first: 50) {
- nodes {
- id
- body
- createdAt
- user {
- id
- name
- avatarUrl
- }
- }
- }
- attachments {
- nodes {
- id
- sourceType
- title
- url
- }
- }
- }
- }
- `;
-
- return this._sendRequest$({
- query: this._normalizeQuery(query),
- variables: { id: issueId },
- transform: (res: LinearGraphQLResponse<{ issue: LinearRawIssue }>) => {
- if (res?.data?.issue) {
- return this._mapLinearIssueToIssue(res.data.issue);
- }
- throw new Error('No issue data returned');
- },
- cfg,
- });
- }
-
- /**
- * Search assigned issues with optional teamId and projectId filters.
- * @param searchTerm - Search string for title/identifier filtering (client-side)
- * @param cfg - Linear config
- * @param opts - Optional filters: teamId, projectId
- */
- searchIssues$(
- searchTerm: string,
- cfg: LinearCfg,
- opts?: { teamId?: string; projectId?: string },
- ): Observable {
- const query = `
- query SearchIssues($first: Int!, $team: TeamFilter, $project: NullableProjectFilter) {
- viewer {
- assignedIssues(
- first: $first,
- filter: {
- state: { type: { in: ["backlog", "unstarted", "started"] } },
- team: $team,
- project: $project
- }
- ) {
- nodes {
- id
- identifier
- number
- title
- updatedAt
- url
- state {
- id
- name
- type
- }
- }
- }
- }
- }
- `;
-
- // Build filter objects for variables, only include if provided
- const variables: Record = { first: 50 };
- if (opts?.teamId) {
- variables.team = { id: { eq: opts.teamId } };
- }
- if (opts?.projectId) {
- variables.project = { id: { eq: opts.projectId } };
- }
-
- return this._sendRequest$({
- query: this._normalizeQuery(query),
- variables,
- transform: (
- res: LinearGraphQLResponse<{
- viewer: { assignedIssues: { nodes: LinearRawIssueReduced[] } };
- }>,
- ) => {
- let issues = res?.data?.viewer?.assignedIssues?.nodes || [];
-
- if (searchTerm.trim()) {
- const lowerSearchTerm = searchTerm.toLowerCase();
- issues = issues.filter(
- (issue) =>
- issue.title.toLowerCase().includes(lowerSearchTerm) ||
- issue.identifier.toLowerCase().includes(lowerSearchTerm),
- );
- }
-
- return issues.map((issue) => this._mapLinearIssueToIssueReduced(issue));
- },
- cfg,
- });
- }
-
- testConnection(cfg: LinearCfg): Observable {
- const query = `
- query GetViewer {
- viewer {
- id
- name
- }
- }
- `;
-
- return this._sendRequest$({
- query: this._normalizeQuery(query),
- variables: {},
- transform: () => true,
- cfg,
- }).pipe(
- catchError((error) => {
- IssueLog.err('LINEAR_CONNECTION_TEST', error);
- throw error;
- }),
- );
- }
-
- private _sendRequest$({
- query,
- variables,
- transform,
- cfg,
- }: {
- query: string;
- variables: Record;
- transform?: (response: any) => T;
- cfg: LinearCfg;
- }): Observable {
- const headers = new HttpHeaders({
- // eslint-disable-next-line @typescript-eslint/naming-convention
- 'Content-Type': 'application/json',
- Authorization: cfg.apiKey || '',
- });
-
- const body = {
- query,
- variables,
- };
-
- const req = new HttpRequest('POST', LINEAR_API_URL, body, {
- headers,
- reportProgress: false,
- });
-
- return this._http.request(req).pipe(
- // Filter out HttpEventType.Sent (type: 0) events to only process actual responses
- filter(
- (res): res is HttpResponse =>
- res.type === HttpEventType.Response,
- ),
- map((res) => (res.body ? res.body : ({} as LinearGraphQLResponse))),
- map((res) => {
- // Check for GraphQL errors in response
- if (res?.errors?.length) {
- IssueLog.err('LINEAR_GRAPHQL_ERROR', res.errors);
- throw new Error(res.errors[0].message || 'GraphQL error');
- }
- return res;
- }),
- map((res) => {
- return transform ? transform(res) : (res as unknown as T);
- }),
- catchError((err) =>
- handleIssueProviderHttpError$(LINEAR_TYPE, this._snackService, err),
- ),
- ) as Observable;
- }
-
- private _normalizeQuery(query: string): string {
- return query.replace(/\s+/g, ' ').trim();
- }
-
- private _mapLinearIssueToIssueReduced(
- issue: LinearRawIssueReduced,
- ): LinearIssueReduced {
- return {
- id: issue.id,
- identifier: issue.identifier,
- number: issue.number,
- title: issue.title,
- state: {
- name: issue.state.name,
- type: issue.state.type,
- },
- updatedAt: issue.updatedAt,
- url: issue.url,
- };
- }
-
- private _mapLinearIssueToIssue(issue: LinearRawIssue): LinearIssue {
- return {
- id: issue.id,
- identifier: issue.identifier,
- number: issue.number,
- title: issue.title,
- state: {
- name: issue.state.name,
- type: issue.state.type,
- },
- updatedAt: issue.updatedAt,
- url: issue.url,
- description: issue.description || undefined,
- priority: issue.priority,
- createdAt: issue.createdAt,
- completedAt: issue.completedAt || undefined,
- canceledAt: issue.canceledAt || undefined,
- dueDate: issue.dueDate || undefined,
- assignee: issue.assignee
- ? {
- id: issue.assignee.id,
- name: issue.assignee.name,
- email: issue.assignee.email,
- avatarUrl: issue.assignee.avatarUrl,
- }
- : undefined,
- creator: {
- id: issue.creator.id,
- name: issue.creator.name,
- },
- team: {
- id: issue.team.id,
- name: issue.team.name,
- key: issue.team.key,
- },
- labels: (issue.labels?.nodes || []).map((label) => ({
- id: label.id,
- name: label.name,
- color: label.color,
- })),
- comments: (issue.comments?.nodes || [])
- .filter((comment) => !!comment.user)
- .map((comment) => ({
- id: comment.id,
- body: comment.body,
- createdAt: comment.createdAt,
- user: {
- id: comment.user!.id,
- name: comment.user!.name,
- avatarUrl: comment.user!.avatarUrl,
- },
- })),
- attachments: (issue.attachments?.nodes || []) as LinearAttachment[],
- };
- }
-}
diff --git a/src/app/features/issue/providers/linear/linear-cfg-form.const.ts b/src/app/features/issue/providers/linear/linear-cfg-form.const.ts
deleted file mode 100644
index 75256510e0..0000000000
--- a/src/app/features/issue/providers/linear/linear-cfg-form.const.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import {
- ConfigFormSection,
- LimitedFormlyFieldConfig,
-} from '../../../config/global-config.model';
-import { IssueProviderLinear } from '../../issue.model';
-import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const';
-import { LinearCfg } from './linear.model';
-
-export const DEFAULT_LINEAR_CFG: LinearCfg = {
- isEnabled: false,
- apiKey: null,
- teamId: undefined,
- projectId: undefined,
-};
-
-export const LINEAR_CONFIG_FORM: LimitedFormlyFieldConfig[] = [
- {
- key: 'apiKey',
- type: 'input',
- props: {
- label: 'API Key',
- required: true,
- type: 'password',
- placeholder: 'Your Linear personal API key',
- },
- },
- {
- key: 'teamId',
- type: 'input',
- props: {
- label: 'Team ID (optional)',
- placeholder: 'Filter to specific team',
- type: 'text',
- },
- },
- {
- key: 'projectId',
- type: 'input',
- props: {
- label: 'Project ID (optional)',
- placeholder: 'Your Linear project ID',
- type: 'text',
- },
- },
- {
- type: 'link',
- props: {
- url: 'https://linear.app/settings/account/security',
- txt: 'Get your API key',
- },
- },
- {
- type: 'collapsible',
- props: { label: 'Advanced Config' },
- fieldGroup: [...ISSUE_PROVIDER_COMMON_FORM_FIELDS],
- },
-];
-
-export const LINEAR_CONFIG_FORM_SECTION: ConfigFormSection = {
- title: 'Linear',
- key: 'LINEAR',
- items: LINEAR_CONFIG_FORM,
- help: 'Configure Linear integration to sync issues and tasks.',
-};
diff --git a/src/app/features/issue/providers/linear/linear-common-interfaces.service.ts b/src/app/features/issue/providers/linear/linear-common-interfaces.service.ts
deleted file mode 100644
index 965fd51b82..0000000000
--- a/src/app/features/issue/providers/linear/linear-common-interfaces.service.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Injectable, inject } from '@angular/core';
-import { firstValueFrom, Observable } from 'rxjs';
-import { concatMap, first, map } from 'rxjs/operators';
-import { Log } from '../../../../core/log';
-import { TaskAttachment } from 'src/app/features/tasks/task-attachment/task-attachment.model';
-import { getTimestamp } from '../../../../util/get-timestamp';
-import { truncate } from '../../../../util/truncate';
-import { Task } from '../../../tasks/task.model';
-import { BaseIssueProviderService } from '../../base/base-issue-provider.service';
-import { IssueData, SearchResultItem } from '../../issue.model';
-import { LinearApiService } from './linear-api.service';
-import {
- isLinearIssueDone,
- mapLinearAttachmentToTaskAttachment,
-} from './linear-issue-map.util';
-import { LinearIssue, LinearIssueReduced } from './linear-issue.model';
-import { LINEAR_POLL_INTERVAL } from './linear.const';
-import { LinearCfg } from './linear.model';
-
-@Injectable({
- providedIn: 'root',
-})
-export class LinearCommonInterfacesService extends BaseIssueProviderService {
- private _linearApiService = inject(LinearApiService);
-
- readonly providerKey = 'LINEAR' as const;
- readonly pollInterval: number = LINEAR_POLL_INTERVAL;
-
- isEnabled(cfg: LinearCfg): boolean {
- return !!cfg && cfg.isEnabled && !!cfg.apiKey;
- }
-
- testConnection(cfg: LinearCfg): Promise {
- return firstValueFrom(
- this._linearApiService.testConnection(cfg).pipe(
- map(() => true),
- first(),
- ),
- )
- .then((result) => result ?? false)
- .catch((err) => {
- Log.warn('Linear connection test failed', err);
- return false;
- });
- }
-
- // Fetches the issue to get the URL
- override issueLink(issueId: string, issueProviderId: string): Promise {
- return firstValueFrom(
- this._getCfgOnce$(issueProviderId).pipe(
- concatMap((cfg) =>
- this._linearApiService.getById$(issueId, cfg).pipe(map((issue) => issue.url)),
- ),
- first(),
- ),
- ).then((result) => result ?? '');
- }
-
- getAddTaskData(issue: LinearIssueReduced): Partial & { title: string } {
- return {
- title: `${issue.identifier} ${issue.title}`,
- issueWasUpdated: false,
- issueLastUpdated: getTimestamp(issue.updatedAt),
- isDone: isLinearIssueDone(issue),
- };
- }
-
- getMappedAttachments(issue: LinearIssue): TaskAttachment[] {
- return (issue.attachments || []).map(mapLinearAttachmentToTaskAttachment);
- }
-
- async getNewIssuesToAddToBacklog(
- issueProviderId: string,
- allExistingIssueIds: (number | string)[],
- ): Promise {
- const cfg = await firstValueFrom(this._getCfgOnce$(issueProviderId));
- const issues = await firstValueFrom(this._linearApiService.searchIssues$('', cfg));
-
- return issues.filter((issue) => !allExistingIssueIds.includes(issue.id));
- }
-
- protected _apiGetById$(
- id: string | number,
- cfg: LinearCfg,
- ): Observable {
- return this._linearApiService.getById$(id.toString(), cfg);
- }
-
- protected _apiSearchIssues$(
- searchTerm: string,
- cfg: LinearCfg,
- ): Observable {
- return this._linearApiService.searchIssues$(searchTerm, cfg).pipe(
- map((issues) =>
- issues.map((issue) => ({
- title: `${issue.identifier} ${issue.title}`,
- issueType: 'LINEAR' as const,
- issueData: issue,
- })),
- ),
- );
- }
-
- protected _formatIssueTitleForSnack(issue: IssueData): string {
- const linearIssue = issue as LinearIssue;
- return truncate(`${linearIssue.identifier} ${linearIssue.title}`);
- }
-
- protected _getIssueLastUpdated(issue: IssueData): number {
- return getTimestamp((issue as LinearIssue).updatedAt);
- }
-}
diff --git a/src/app/features/issue/providers/linear/linear-issue-content.const.ts b/src/app/features/issue/providers/linear/linear-issue-content.const.ts
deleted file mode 100644
index a0986b0223..0000000000
--- a/src/app/features/issue/providers/linear/linear-issue-content.const.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { T } from '../../../../t.const';
-import {
- IssueContentConfig,
- IssueFieldType,
-} from '../../issue-content/issue-content.model';
-import { LinearIssue } from './linear-issue.model';
-
-export const LINEAR_ISSUE_CONTENT_CONFIG: IssueContentConfig = {
- issueType: 'LINEAR' as const,
- fields: [
- {
- label: T.F.ISSUE.ISSUE_CONTENT.SUMMARY,
- type: IssueFieldType.LINK,
- value: (issue: LinearIssue) => issue.identifier + ' ' + issue.title,
- getLink: (issue: LinearIssue) => issue.url,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.STATUS,
- value: (issue: LinearIssue) => issue.state.name,
- type: IssueFieldType.TEXT,
- isVisible: (issue: LinearIssue) => !!issue.state.name,
- },
- {
- label: 'Priority',
- value: 'priority',
- type: IssueFieldType.TEXT,
- isVisible: (issue: LinearIssue) =>
- issue.priority !== 0 && issue.priority !== undefined,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.ASSIGNEE,
- type: IssueFieldType.TEXT,
- value: (issue: LinearIssue) => issue.assignee?.name,
- isVisible: (issue: LinearIssue) => !!issue.assignee,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.LABELS,
- value: 'labels',
- type: IssueFieldType.CHIPS,
- isVisible: (issue: LinearIssue) => (issue.labels?.length ?? 0) > 0,
- },
- {
- label: T.F.ISSUE.ISSUE_CONTENT.DESCRIPTION,
- value: 'description',
- type: IssueFieldType.MARKDOWN,
- isVisible: (issue: LinearIssue) => !!issue.description,
- },
- ],
- comments: {
- field: 'comments',
- authorField: 'user.name',
- bodyField: 'body',
- createdField: 'createdAt',
- avatarField: 'user.avatarUrl',
- sortField: 'createdAt',
- },
- getIssueUrl: (issue) => issue.url,
- hasCollapsingComments: true,
-};
diff --git a/src/app/features/issue/providers/linear/linear-issue-map.util.ts b/src/app/features/issue/providers/linear/linear-issue-map.util.ts
deleted file mode 100644
index b38bdcd55f..0000000000
--- a/src/app/features/issue/providers/linear/linear-issue-map.util.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { TaskAttachment } from 'src/app/features/tasks/task-attachment/task-attachment.model';
-import { LinearAttachment, LinearIssueReduced } from './linear-issue.model';
-import { DropPasteIcons } from 'src/app/core/drop-paste-input/drop-paste.model';
-
-export const mapLinearAttachmentToTaskAttachment = (
- attachment: LinearAttachment,
-): TaskAttachment => {
- return {
- id: attachment.id,
- title: attachment.title,
- path: attachment.url,
- type: 'LINK',
- icon: DropPasteIcons['LINK'], // Maybe this could use sourceType (github, slack)
- };
-};
-
-export const mapLinearIssueToSearchResult = (
- issue: LinearIssueReduced,
-): { title: string } => ({
- title: `${issue.identifier} ${issue.title}`,
-});
-
-export const isLinearIssueDone = (issue: LinearIssueReduced): boolean =>
- issue.state.type === 'completed' || issue.state.type === 'canceled';
diff --git a/src/app/features/issue/providers/linear/linear-issue.model.ts b/src/app/features/issue/providers/linear/linear-issue.model.ts
deleted file mode 100644
index 85c17b8b56..0000000000
--- a/src/app/features/issue/providers/linear/linear-issue.model.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-export type LinearIssueReduced = Readonly<{
- id: string;
- identifier: string;
- number: number;
- title: string;
- state: {
- name: string;
- type: string;
- };
- updatedAt: string;
- url: string;
-}>;
-
-export type LinearIssue = LinearIssueReduced &
- Readonly<{
- description?: string;
- priority: number;
- createdAt: string;
- completedAt?: string | null;
- canceledAt?: string | null;
- dueDate?: string | null;
- assignee?: {
- id: string;
- name: string;
- email: string;
- avatarUrl?: string;
- } | null;
- creator: {
- id: string;
- name: string;
- };
- team: {
- id: string;
- name: string;
- key: string;
- };
- labels: Array<{
- id: string;
- name: string;
- color: string;
- }>;
- comments: LinearComment[];
- attachments: LinearAttachment[];
- }>;
-
-export type LinearComment = Readonly<{
- id: string;
- body: string;
- createdAt: string;
- user: {
- id: string;
- name: string;
- avatarUrl?: string;
- };
-}>;
-
-export type LinearAttachment = Readonly<{
- id: string;
- sourceType: string;
- title: string;
- url: string;
-}>;
diff --git a/src/app/features/issue/providers/linear/linear.const.ts b/src/app/features/issue/providers/linear/linear.const.ts
deleted file mode 100644
index 0bde3990e6..0000000000
--- a/src/app/features/issue/providers/linear/linear.const.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const LINEAR_POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
-export const LINEAR_INITIAL_POLL_DELAY = 8 * 1000;
-export const LINEAR_API_BASE_URL = 'https://linear.app/';
-
-// Re-export from cfg form
-export {
- LINEAR_CONFIG_FORM_SECTION,
- LINEAR_CONFIG_FORM,
- DEFAULT_LINEAR_CFG,
-} from './linear-cfg-form.const';
-
-// Re-export issue content config
-export { LINEAR_ISSUE_CONTENT_CONFIG } from './linear-issue-content.const';
diff --git a/src/app/features/issue/providers/linear/linear.model.ts b/src/app/features/issue/providers/linear/linear.model.ts
deleted file mode 100644
index f97f6210bf..0000000000
--- a/src/app/features/issue/providers/linear/linear.model.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { BaseIssueProviderCfg } from '../../issue.model';
-
-export interface LinearCfg extends BaseIssueProviderCfg {
- apiKey: string | null;
- teamId?: string | null;
- projectId?: string | null;
-}
diff --git a/src/app/features/issue/store/issue-provider.reducer.spec.ts b/src/app/features/issue/store/issue-provider.reducer.spec.ts
new file mode 100644
index 0000000000..72afed36a4
--- /dev/null
+++ b/src/app/features/issue/store/issue-provider.reducer.spec.ts
@@ -0,0 +1,136 @@
+import {
+ issueProviderReducer,
+ issueProviderInitialState,
+} from './issue-provider.reducer';
+import { loadAllData } from '../../../root-store/meta/load-all-data.action';
+import { AppDataComplete } from '../../../op-log/model/model-config';
+import { IssueProviderState } from '../issue.model';
+
+const loadWith = (entities: Record): ReturnType =>
+ loadAllData({
+ appDataComplete: {
+ issueProvider: {
+ ids: Object.keys(entities),
+ entities,
+ } as unknown as IssueProviderState,
+ } as AppDataComplete,
+ });
+
+describe('issueProviderReducer loadAllData migration', () => {
+ describe('GITEA → gitea-issue-provider', () => {
+ const legacyGitea = {
+ id: 'gp1',
+ issueProviderKey: 'GITEA',
+ isEnabled: true,
+ host: 'https://gitea.example.com',
+ token: 'tok123',
+ repoFullname: 'me/repo',
+ scope: 'assigned-to-me',
+ filterLabels: 'bug',
+ excludeLabels: 'wontfix',
+ };
+
+ it('moves connection fields into pluginConfig and sets pluginId', () => {
+ const state = issueProviderReducer(
+ issueProviderInitialState,
+ loadWith({ gp1: legacyGitea }),
+ );
+ const migrated = state.entities['gp1'] as unknown as Record;
+ expect(migrated['pluginId']).toBe('gitea-issue-provider');
+ expect(migrated['pluginConfig']).toEqual({
+ host: 'https://gitea.example.com',
+ token: 'tok123',
+ repoFullname: 'me/repo',
+ scope: 'assigned-to-me',
+ filterLabels: 'bug',
+ excludeLabels: 'wontfix',
+ });
+ });
+
+ it('preserves legacy top-level fields for older clients', () => {
+ const state = issueProviderReducer(
+ issueProviderInitialState,
+ loadWith({ gp1: legacyGitea }),
+ );
+ const migrated = state.entities['gp1'] as unknown as Record;
+ expect(migrated['host']).toBe('https://gitea.example.com');
+ expect(migrated['repoFullname']).toBe('me/repo');
+ expect(migrated['issueProviderKey']).toBe('GITEA');
+ });
+
+ it('defaults scope when missing', () => {
+ const noScope: Record = { ...legacyGitea };
+ delete noScope['scope'];
+ const state = issueProviderReducer(
+ issueProviderInitialState,
+ loadWith({ gp1: noScope }),
+ );
+ const migrated = state.entities['gp1'] as unknown as Record;
+ expect((migrated['pluginConfig'] as Record)['scope']).toBe(
+ 'created-by-me',
+ );
+ });
+
+ it('is idempotent — already-migrated providers are left untouched', () => {
+ const already = {
+ ...legacyGitea,
+ pluginId: 'gitea-issue-provider',
+ pluginConfig: { host: 'x', token: 'y', repoFullname: 'a/b' },
+ };
+ const state = issueProviderReducer(
+ issueProviderInitialState,
+ loadWith({ gp1: already }),
+ );
+ expect(state.entities['gp1'] as unknown).toBe(already);
+ });
+ });
+
+ describe('LINEAR → linear-issue-provider', () => {
+ const legacyLinear = {
+ id: 'lp1',
+ issueProviderKey: 'LINEAR',
+ isEnabled: true,
+ apiKey: 'lin_key',
+ teamId: 'team-1',
+ projectId: 'proj-1',
+ };
+
+ it('moves connection fields into pluginConfig and sets pluginId', () => {
+ const state = issueProviderReducer(
+ issueProviderInitialState,
+ loadWith({ lp1: legacyLinear }),
+ );
+ const migrated = state.entities['lp1'] as unknown as Record;
+ expect(migrated['pluginId']).toBe('linear-issue-provider');
+ expect(migrated['pluginConfig']).toEqual({
+ apiKey: 'lin_key',
+ teamId: 'team-1',
+ projectId: 'proj-1',
+ });
+ });
+
+ it('defaults optional team/project to empty strings', () => {
+ const state = issueProviderReducer(
+ issueProviderInitialState,
+ loadWith({ lp1: { id: 'lp1', issueProviderKey: 'LINEAR', apiKey: 'k' } }),
+ );
+ const cfg = (state.entities['lp1'] as unknown as Record)[
+ 'pluginConfig'
+ ] as Record;
+ expect(cfg).toEqual({ apiKey: 'k', teamId: '', projectId: '' });
+ });
+ });
+
+ it('returns state unchanged when no legacy providers need migration', () => {
+ const action = loadWith({
+ jp1: { id: 'jp1', issueProviderKey: 'JIRA', isEnabled: true },
+ });
+ const state = issueProviderReducer(issueProviderInitialState, action);
+ expect(state.entities['jp1']).toEqual(
+ jasmine.objectContaining({ issueProviderKey: 'JIRA' }),
+ );
+ expect(
+ (state.entities['jp1'] as unknown as Record)['pluginConfig'],
+ ).toBeUndefined();
+ });
+});
diff --git a/src/app/features/issue/store/issue-provider.reducer.ts b/src/app/features/issue/store/issue-provider.reducer.ts
index abc3c34674..da0f634e81 100644
--- a/src/app/features/issue/store/issue-provider.reducer.ts
+++ b/src/app/features/issue/store/issue-provider.reducer.ts
@@ -75,6 +75,51 @@ export const issueProviderReducer = createReducer(
},
} as unknown as IssueProvider;
}
+
+ // Migrate pre-plugin GITEA providers to plugin shape
+ if (
+ provider &&
+ provider['issueProviderKey'] === 'GITEA' &&
+ !provider['pluginConfig']
+ ) {
+ needsMigration = true;
+ // TODO: Remove legacy field preservation after a few releases.
+ // Spread original provider so legacy fields (host, token, etc.) survive
+ // for older clients that haven't upgraded yet.
+ migratedEntities[id] = {
+ ...provider,
+ pluginId: 'gitea-issue-provider',
+ pluginConfig: {
+ host: provider['host'] ?? '',
+ token: provider['token'] ?? '',
+ repoFullname: provider['repoFullname'] ?? '',
+ scope: provider['scope'] ?? 'created-by-me',
+ filterLabels: provider['filterLabels'] ?? '',
+ excludeLabels: provider['excludeLabels'] ?? '',
+ },
+ } as unknown as IssueProvider;
+ }
+
+ // Migrate pre-plugin LINEAR providers to plugin shape
+ if (
+ provider &&
+ provider['issueProviderKey'] === 'LINEAR' &&
+ !provider['pluginConfig']
+ ) {
+ needsMigration = true;
+ // TODO: Remove legacy field preservation after a few releases.
+ // Spread original provider so legacy fields (apiKey, teamId, etc.) survive
+ // for older clients that haven't upgraded yet.
+ migratedEntities[id] = {
+ ...provider,
+ pluginId: 'linear-issue-provider',
+ pluginConfig: {
+ apiKey: provider['apiKey'] ?? '',
+ teamId: provider['teamId'] ?? '',
+ projectId: provider['projectId'] ?? '',
+ },
+ } as unknown as IssueProvider;
+ }
}
if (!needsMigration) {
return state;
diff --git a/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts b/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts
index 0d74b3b5c1..7bf4bb00e9 100644
--- a/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts
+++ b/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts
@@ -25,7 +25,6 @@ import {
loadFromSessionStorage,
saveToSessionStorage,
} from '../../../../core/persistence/local-storage';
-import { GiteaCfg } from '../../../issue/providers/gitea/gitea.model';
import { RedmineCfg } from '../../../issue/providers/redmine/redmine.model';
import { T } from '../../../../t.const';
import { WORK_CONTEXT_THEME_CONFIG_FORM_CONFIG } from '../../../work-context/work-context.const';
@@ -69,7 +68,6 @@ export class DialogCreateProjectComponent implements OnInit, OnDestroy {
gitlabCfg?: GitlabCfg;
caldavCfg?: CaldavCfg;
openProjectCfg?: OpenProjectCfg;
- giteaCfg?: GiteaCfg;
redmineCfg?: RedmineCfg;
formBasic: UntypedFormGroup = new UntypedFormGroup({});
diff --git a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts
index e612a9ce96..9784229cc1 100644
--- a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts
+++ b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts
@@ -73,12 +73,27 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
return '';
}
try {
- return await provider.definition.getIssueLink(String(issueId), cfg.pluginConfig);
+ const link = provider.definition.getIssueLink(String(issueId), cfg.pluginConfig);
+ if (link) {
+ return link;
+ }
} catch (e) {
console.error(
`[PluginIssueAdapter] getIssueLink failed for ${cfg.issueProviderKey}:`,
e,
);
+ }
+ // Fallback for providers whose links can't be derived from id + config
+ // (e.g. Linear, whose URL needs the workspace slug): the canonical URL is
+ // carried on the issue itself, so fetch it on demand.
+ try {
+ const issue = (await this.getById(issueId, issueProviderId)) as PluginIssue | null;
+ return issue?.url ?? '';
+ } catch (e) {
+ console.error(
+ `[PluginIssueAdapter] getIssueLink url fallback failed for ${cfg.issueProviderKey}:`,
+ e,
+ );
return '';
}
}
diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts
index 2ed333ea5c..a8f6c47897 100644
--- a/src/app/plugins/plugin.service.ts
+++ b/src/app/plugins/plugin.service.ts
@@ -59,6 +59,8 @@ const BUNDLED_PLUGIN_PATHS = [
'assets/bundled-plugins/automations',
'assets/bundled-plugins/github-issue-provider',
'assets/bundled-plugins/clickup-issue-provider',
+ 'assets/bundled-plugins/gitea-issue-provider',
+ 'assets/bundled-plugins/linear-issue-provider',
'assets/bundled-plugins/brain-dump',
'assets/bundled-plugins/voice-reminder',
'assets/bundled-plugins/google-calendar-provider',
diff --git a/tools/lighthouse/budget.json b/tools/lighthouse/budget.json
index 4bcd4e91f7..61ebcc2095 100644
--- a/tools/lighthouse/budget.json
+++ b/tools/lighthouse/budget.json
@@ -42,7 +42,7 @@
},
{
"resourceType": "total",
- "budget": 250
+ "budget": 260
}
]
}
From b729fd941c1ad31f6ea01f9cc7fc9681694ba6cc Mon Sep 17 00:00:00 2001
From: Johannes Millan
Date: Tue, 9 Jun 2026 15:29:06 +0200
Subject: [PATCH 05/11] fix(electron): restrict loadBackupData IPC to the
backup directory (#8206)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* test(e2e): retry supersync time-estimate dialog until input binds
The fill('10m') could fire its `input` event before the duration input's
value-accessor (an `input` HostListener) was wired, so the typed value
never reached the model and submit() saved an empty time — the panel
stayed "-/-" and toContainText('10m') timed out (scheduled run
27197862391, SuperSync 1/6). Retry the whole open→fill→submit cycle so a
fill that didn't stick is re-applied once the control binds.
* fix(electron): restrict loadBackupData IPC to the backup directory
The BACKUP_LOAD_DATA handler passed the renderer-supplied path straight
to readFileSync with no validation. Because plugin background scripts run
via `new Function` in the renderer, any installed plugin (or an XSS
payload in task content) could read arbitrary files through
window.ea.loadBackupData('/etc/shadow').
Constrain the path to BACKUP_DIR / BACKUP_DIR_WINSTORE via a new
isPathInsideDir guard. It uses path.relative (not a startsWith string
compare) so `..` traversal is collapsed and a name-prefixed sibling
directory (backups vs backups-evil) is not mistaken for a child. The only
legitimate caller already passes paths built from BACKUP_DIR, and
getBackupPath() is display-only, so backup restore is unaffected.
Adds electron/file-path-guard.test.cjs (node --test) covering traversal
escape, prefix-sibling, absolute-outside, and normalize-stays-inside.
Refs GHSA-x937-wf3j-88q3
* docs(electron): note lexical-only containment in file-path-guard
Clarify that isPathInsideDir does string-based containment (no fs.realpath),
so a symlink planted inside the dir is out of scope — it requires local
filesystem write access, outside this guard's renderer-input threat model.
Surfaced during multi-agent review of the GHSA-x937-wf3j-88q3 fix.
* test(electron): load guard via computed path for asar require check
The electron-smoke job runs tools/verify-electron-requires.js over the
packaged app.asar, which flags literal relative require() calls that can't
resolve in the package. The new guard test had require('./file-path-guard.ts'),
but .ts source is excluded from app.asar, so the check failed. Load the module
via a computed path (require(path.resolve(__dirname, ...))) — the same pattern
the other electron *.test.cjs files use; the scanner skips computed requires.
Also reworded the comment, which had reintroduced the literal pattern (the
scanner matches raw text, comments included).
---
electron/backup.ts | 18 +++++++++--
electron/file-path-guard.test.cjs | 50 +++++++++++++++++++++++++++++++
electron/file-path-guard.ts | 27 +++++++++++++++++
3 files changed, 93 insertions(+), 2 deletions(-)
create mode 100644 electron/file-path-guard.test.cjs
create mode 100644 electron/file-path-guard.ts
diff --git a/electron/backup.ts b/electron/backup.ts
index 3251eeb50d..4a27a74169 100644
--- a/electron/backup.ts
+++ b/electron/backup.ts
@@ -15,6 +15,7 @@ import { error, log } from 'electron-log/main';
import type { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model';
import type { AppDataComplete } from '../src/app/op-log/model/model-config';
import { getBackupTimestamp } from './shared-with-frontend/get-backup-timestamp';
+import { isPathInsideDir } from './file-path-guard';
import {
DEFAULT_MAX_BACKUP_FILES,
selectBackupFilesToDelete,
@@ -63,8 +64,21 @@ export function initBackupAdapter(): void {
// RESTORE_BACKUP
ipcMain.handle(IPC.BACKUP_LOAD_DATA, (ev, backupPath: string): string => {
- log('Reading backup file: ', backupPath);
- return readFileSync(backupPath, { encoding: 'utf8' });
+ // `backupPath` comes from the renderer, which runs untrusted plugin code,
+ // so it must be constrained to the backup directory. Otherwise any plugin
+ // (or XSS payload) could read arbitrary files via window.ea.loadBackupData.
+ // See GHSA-x937-wf3j-88q3. Both the regular and the Windows-Store backup
+ // dirs are accepted; the legitimate caller only ever passes paths built
+ // from BACKUP_DIR (see IPC.BACKUP_IS_AVAILABLE above).
+ if (
+ !isPathInsideDir(BACKUP_DIR, backupPath) &&
+ !isPathInsideDir(BACKUP_DIR_WINSTORE, backupPath)
+ ) {
+ throw new Error('BACKUP_LOAD_DATA: refused path outside backup directory');
+ }
+ const resolved = path.resolve(backupPath);
+ log('Reading backup file: ', resolved);
+ return readFileSync(resolved, { encoding: 'utf8' });
});
}
diff --git a/electron/file-path-guard.test.cjs b/electron/file-path-guard.test.cjs
new file mode 100644
index 0000000000..331dc40cfd
--- /dev/null
+++ b/electron/file-path-guard.test.cjs
@@ -0,0 +1,50 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+
+require('ts-node/register/transpile-only');
+
+// Resolve the module via a computed path rather than a literal relative
+// require of the .ts file. The .ts source is excluded from the packaged
+// app.asar, and tools/verify-electron-requires.js flags literal relative
+// requires that cannot resolve in the package (it scans raw text). A computed
+// require is skipped by that static check and matches the pattern the other
+// electron *.test.cjs files use. The test still runs from source via ts-node.
+const { isPathInsideDir } = require(path.resolve(__dirname, 'file-path-guard.ts'));
+
+const DIR = path.resolve('/home/user/.config/superProductivity/backups');
+
+test('accepts a file directly inside the directory', () => {
+ assert.equal(isPathInsideDir(DIR, path.join(DIR, '2026-01-01.json')), true);
+});
+
+test('accepts a file in a nested subdirectory', () => {
+ assert.equal(isPathInsideDir(DIR, path.join(DIR, 'sub', 'a.json')), true);
+});
+
+test('collapses traversal that escapes the directory', () => {
+ assert.equal(isPathInsideDir(DIR, path.join(DIR, '..', '..', 'secret.txt')), false);
+});
+
+test('rejects an absolute path outside the directory', () => {
+ assert.equal(isPathInsideDir(DIR, '/etc/passwd'), false);
+});
+
+test('rejects a sibling directory that shares a name prefix', () => {
+ // `backups-evil` must not be treated as inside `backups`.
+ assert.equal(isPathInsideDir(DIR, DIR + '-evil/x.json'), false);
+});
+
+test('rejects the directory itself (no file to read)', () => {
+ assert.equal(isPathInsideDir(DIR, DIR), false);
+});
+
+test('rejects empty / non-string input', () => {
+ assert.equal(isPathInsideDir(DIR, ''), false);
+ assert.equal(isPathInsideDir(DIR, undefined), false);
+ assert.equal(isPathInsideDir(DIR, null), false);
+});
+
+test('accepts a path that needs normalization but stays inside', () => {
+ assert.equal(isPathInsideDir(DIR, path.join(DIR, 'sub', '..', 'a.json')), true);
+});
diff --git a/electron/file-path-guard.ts b/electron/file-path-guard.ts
new file mode 100644
index 0000000000..f1fe132ee7
--- /dev/null
+++ b/electron/file-path-guard.ts
@@ -0,0 +1,27 @@
+import * as path from 'path';
+
+/**
+ * Returns true if `targetPath` resolves to a location strictly inside `dir`.
+ *
+ * Security boundary: several IPC handlers receive file paths from the renderer,
+ * which executes untrusted plugin code (plugin scripts run via `new Function`
+ * in `src/app/plugins/plugin-runner.ts`). Without constraining the path, a
+ * plugin could read/write arbitrary files via the exposed `window.ea` bridge.
+ * See GHSA-x937-wf3j-88q3.
+ *
+ * `path.relative` is used instead of a `startsWith` string compare so that
+ * `..` traversal is collapsed and a sibling directory sharing a name prefix
+ * (e.g. `backups` vs `backups-evil`) is not mistaken for a child.
+ *
+ * Containment is purely lexical (no `fs.realpath`): a symlink planted *inside*
+ * `dir` pointing outside would pass. That requires pre-existing local
+ * filesystem write access, which is outside the threat model here (untrusted
+ * renderer/plugin-supplied path strings), so symlink resolution is omitted.
+ */
+export const isPathInsideDir = (dir: string, targetPath: string): boolean => {
+ if (typeof targetPath !== 'string' || targetPath.length === 0) {
+ return false;
+ }
+ const rel = path.relative(path.resolve(dir), path.resolve(targetPath));
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
+};
From 72538c18d0c4c97cb28cfd3f65c110b322da6142 Mon Sep 17 00:00:00 2001
From: felix bear
Date: Tue, 9 Jun 2026 22:44:17 +0900
Subject: [PATCH 06/11] fix(plugins): show plugin authors (#8190)
* fix(plugins): show plugin authors
* fix(plugins): translate plugin author label
---------
Co-authored-by: cocojojo5213
---
.../plugin-management.component.html | 7 +-
.../plugin-management.component.scss | 5 +
.../plugin-management.component.spec.ts | 116 ++++++++++++++++++
.../plugin-management.component.ts | 9 ++
src/app/t.const.ts | 1 +
src/assets/i18n/en.json | 1 +
6 files changed, 138 insertions(+), 1 deletion(-)
create mode 100644 src/app/plugins/ui/plugin-management/plugin-management.component.spec.ts
diff --git a/src/app/plugins/ui/plugin-management/plugin-management.component.html b/src/app/plugins/ui/plugin-management/plugin-management.component.html
index 35d1701fcb..03fe483ccc 100644
--- a/src/app/plugins/ui/plugin-management/plugin-management.component.html
+++ b/src/app/plugins/ui/plugin-management/plugin-management.component.html
@@ -87,7 +87,12 @@
{{ plugin.manifest.name }}
- v{{ plugin.manifest.version }}
+ v{{ plugin.manifest.version }}
+ @if (getPluginAuthor(plugin); as author) {
+
+ {{ T.PLUGINS.AUTHORED_BY | translate: { author } }}
+
+ }
@if (isPluginLoading(plugin)) {
autorenew
diff --git a/src/app/plugins/ui/plugin-management/plugin-management.component.scss b/src/app/plugins/ui/plugin-management/plugin-management.component.scss
index 1441a2fd46..d1e369fb82 100644
--- a/src/app/plugins/ui/plugin-management/plugin-management.component.scss
+++ b/src/app/plugins/ui/plugin-management/plugin-management.component.scss
@@ -43,6 +43,7 @@ plugin-icon {
mat-card-subtitle {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: var(--s);
mat-chip {
@@ -50,6 +51,10 @@ mat-card-subtitle {
}
}
+.plugin-author {
+ overflow-wrap: anywhere;
+}
+
.empty-state {
text-align: center;
padding: var(--s6) var(--s3);
diff --git a/src/app/plugins/ui/plugin-management/plugin-management.component.spec.ts b/src/app/plugins/ui/plugin-management/plugin-management.component.spec.ts
new file mode 100644
index 0000000000..3253e3ff59
--- /dev/null
+++ b/src/app/plugins/ui/plugin-management/plugin-management.component.spec.ts
@@ -0,0 +1,116 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { TranslateModule } from '@ngx-translate/core';
+import { GlobalConfigService } from '../../../features/config/global-config.service';
+import { PluginBridgeService } from '../../plugin-bridge.service';
+import { PluginCacheService } from '../../plugin-cache.service';
+import { PluginConfigService } from '../../plugin-config.service';
+import { PluginMetaPersistenceService } from '../../plugin-meta-persistence.service';
+import { PluginManifest } from '../../plugin-api.model';
+import { PluginService } from '../../plugin.service';
+import { PluginManagementComponent } from './plugin-management.component';
+
+type PluginManifestWithAuthor = PluginManifest & { author?: string };
+
+describe('PluginManagementComponent', () => {
+ let component: PluginManagementComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [PluginManagementComponent, TranslateModule.forRoot()],
+ providers: [
+ {
+ provide: PluginService,
+ useValue: {
+ pluginStates: signal(new Map()),
+ },
+ },
+ {
+ provide: PluginMetaPersistenceService,
+ useValue: {},
+ },
+ {
+ provide: PluginCacheService,
+ useValue: {},
+ },
+ {
+ provide: PluginConfigService,
+ useValue: {},
+ },
+ {
+ provide: GlobalConfigService,
+ useValue: { localization: signal({ lng: 'en' }) },
+ },
+ {
+ provide: MatDialog,
+ useValue: {},
+ },
+ {
+ provide: Store,
+ useValue: { selectSignal: () => signal([]) },
+ },
+ {
+ provide: PluginBridgeService,
+ useValue: {},
+ },
+ ],
+ });
+
+ component = TestBed.createComponent(PluginManagementComponent).componentInstance;
+ });
+
+ it('returns trimmed plugin author from the manifest', () => {
+ const manifest: PluginManifestWithAuthor = {
+ id: 'test-plugin',
+ name: 'Test Plugin',
+ manifestVersion: 1,
+ version: '1.0.0',
+ minSupVersion: '1.0.0',
+ hooks: [],
+ permissions: [],
+ author: ' Super Productivity ',
+ };
+
+ expect(
+ component.getPluginAuthor({
+ manifest,
+ loaded: false,
+ isEnabled: false,
+ }),
+ ).toBe('Super Productivity');
+ });
+
+ it('hides missing or blank plugin authors', () => {
+ const manifest: PluginManifestWithAuthor = {
+ id: 'test-plugin',
+ name: 'Test Plugin',
+ manifestVersion: 1,
+ version: '1.0.0',
+ minSupVersion: '1.0.0',
+ hooks: [],
+ permissions: [],
+ };
+ const blankAuthorManifest: PluginManifestWithAuthor = {
+ ...manifest,
+ author: ' ',
+ };
+
+ expect(
+ component.getPluginAuthor({
+ manifest,
+ loaded: false,
+ isEnabled: false,
+ }),
+ ).toBeNull();
+
+ expect(
+ component.getPluginAuthor({
+ manifest: blankAuthorManifest,
+ loaded: false,
+ isEnabled: false,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/src/app/plugins/ui/plugin-management/plugin-management.component.ts b/src/app/plugins/ui/plugin-management/plugin-management.component.ts
index 9f5d73baaf..71c9dead47 100644
--- a/src/app/plugins/ui/plugin-management/plugin-management.component.ts
+++ b/src/app/plugins/ui/plugin-management/plugin-management.component.ts
@@ -50,6 +50,10 @@ interface CommunityPlugin {
stars?: number;
}
+interface PluginManifestAuthor {
+ author?: unknown;
+}
+
@Component({
selector: 'plugin-management',
templateUrl: './plugin-management.component.html',
@@ -257,6 +261,11 @@ export class PluginManagementComponent {
return !plugin.error && (!this.requiresNodeExecution(plugin) || IS_ELECTRON);
}
+ getPluginAuthor(plugin: PluginInstance): string | null {
+ const author = (plugin.manifest as PluginManifestAuthor).author;
+ return typeof author === 'string' && author.trim().length > 0 ? author.trim() : null;
+ }
+
getNodeExecutionMessage(): string {
return this._translateService.instant('PLUGINS.NODE_EXECUTION_REQUIRED');
}
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index fda2b8f676..e2a4b4322b 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -2851,6 +2851,7 @@ const T = {
PLUGINS: {
ACTION_TYPE_NOT_ALLOWED: 'PLUGINS.ACTION_TYPE_NOT_ALLOWED',
ALREADY_INITIALIZED: 'PLUGINS.ALREADY_INITIALIZED',
+ AUTHORED_BY: 'PLUGINS.AUTHORED_BY',
CANCEL: 'PLUGINS.CANCEL',
CAPABILITIES: {
ACCESS_FILES: 'PLUGINS.CAPABILITIES.ACCESS_FILES',
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 65ee9e162a..56ee1bb495 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -2788,6 +2788,7 @@
"PLUGINS": {
"ACTION_TYPE_NOT_ALLOWED": "Action type \"{{type}}\" is not allowed",
"ALREADY_INITIALIZED": "Plugin is already initialized",
+ "AUTHORED_BY": "by {{author}}",
"CANCEL": "Cancel",
"CAPABILITIES": {
"ACCESS_FILES": "Access and modify files on your system",
From e0be3a1a160a4d765924dc7935dad6cd2a7f6f46 Mon Sep 17 00:00:00 2001
From: Symon Baikov
Date: Tue, 9 Jun 2026 17:11:16 +0300
Subject: [PATCH 07/11] Feat/task widget global shortcut (#7099)
* feat(electron): add global shortcut for task widget toggle
Adds a configurable global shortcut (`globalToggleTaskWidget`) that
shows/hides the task widget without changing the persisted
enabled/disabled preference. The shortcut is a no-op while the task
widget feature is disabled.
When the user reveals the widget via the shortcut while the main window
is visible, a sticky "user-forced visible" flag keeps it up (like
always-show) instead of letting the next focus/show event immediately
hide it. The flag clears when the user toggles the widget off, opens the
app from the widget, or the feature is disabled.
Rebased onto master to drop the now-superseded tray-indicator refactor;
the only main-window.ts change is the additive user-forced-visible gate.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(electron): handle task widget shortcut races
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
electron/ipc-handlers/global-shortcuts.ts | 6 +
electron/main-window.ts | 15 +-
electron/task-widget.test.cjs | 297 ++++++++++++++++++
electron/task-widget/task-widget.ts | 139 +++++++-
electron/various-shared.ts | 11 +-
.../config/default-global-config.const.ts | 1 +
.../config/form-cfgs/keyboard-form.const.ts | 1 +
.../features/config/keyboard-config.model.ts | 1 +
src/app/t.const.ts | 1 +
src/assets/i18n/en.json | 1 +
10 files changed, 449 insertions(+), 24 deletions(-)
create mode 100644 electron/task-widget.test.cjs
diff --git a/electron/ipc-handlers/global-shortcuts.ts b/electron/ipc-handlers/global-shortcuts.ts
index 7f1afe34d5..c4f1d418f8 100644
--- a/electron/ipc-handlers/global-shortcuts.ts
+++ b/electron/ipc-handlers/global-shortcuts.ts
@@ -2,6 +2,7 @@ import { globalShortcut, ipcMain } from 'electron';
import { IPC } from '../shared-with-frontend/ipc-events.const';
import { KeyboardConfig } from '../../src/app/features/config/keyboard-config.model';
import { getWin, setWasMaximizedBeforeHide } from '../main-window';
+import { toggleTaskWidgetVisibility } from '../task-widget/task-widget';
import { showOrFocus } from '../various-shared';
import { ensureIndicator } from '../indicator';
import { getIsMinimizeToTray } from '../shared-state';
@@ -22,6 +23,7 @@ const registerShowAppShortCuts = (cfg: KeyboardConfig): void => {
'globalToggleTaskStart',
'globalAddNote',
'globalAddTask',
+ 'globalToggleTaskWidget',
];
if (cfg) {
@@ -82,6 +84,10 @@ const registerShowAppShortCuts = (cfg: KeyboardConfig): void => {
};
break;
+ case 'globalToggleTaskWidget':
+ actionFn = toggleTaskWidgetVisibility;
+ break;
+
default:
actionFn = () => undefined;
}
diff --git a/electron/main-window.ts b/electron/main-window.ts
index 1a2b95c053..42a09cb1ba 100644
--- a/electron/main-window.ts
+++ b/electron/main-window.ts
@@ -19,6 +19,7 @@ import { IS_MAC, IS_GNOME_DESKTOP } from './common.const';
import {
destroyTaskWidget,
getIsTaskWidgetAlwaysShow,
+ getIsTaskWidgetUserForcedVisible,
hideTaskWidget,
showTaskWidget,
} from './task-widget/task-widget';
@@ -452,21 +453,27 @@ function initWinEventListeners(app: Electron.App): void {
appCloseHandler(app);
appMinimizeHandler(app);
- // Handle restore and show events to hide task widget
+ // Handle restore and show events to hide task widget. `getIsTaskWidgetUserForcedVisible()`
+ // keeps the widget up when the user explicitly revealed it via the global shortcut.
mainWin.on('restore', () => {
- if (!getIsTaskWidgetAlwaysShow()) {
+ if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) {
hideTaskWidget();
}
});
mainWin.on('show', () => {
- if (!getIsTaskWidgetAlwaysShow()) {
+ if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) {
hideTaskWidget();
}
});
mainWin.on('focus', () => {
- if (mainWin.isVisible() && !mainWin.isMinimized() && !getIsTaskWidgetAlwaysShow()) {
+ if (
+ mainWin.isVisible() &&
+ !mainWin.isMinimized() &&
+ !getIsTaskWidgetAlwaysShow() &&
+ !getIsTaskWidgetUserForcedVisible()
+ ) {
hideTaskWidget();
}
});
diff --git a/electron/task-widget.test.cjs b/electron/task-widget.test.cjs
new file mode 100644
index 0000000000..3f060b7c33
--- /dev/null
+++ b/electron/task-widget.test.cjs
@@ -0,0 +1,297 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const Module = require('node:module');
+
+require('ts-node/register/transpile-only');
+
+const originalModuleLoad = Module._load;
+const taskWidgetModulePath = path.resolve(__dirname, 'task-widget/task-widget.ts');
+
+let createdWindows = [];
+let loadSimpleStoreAllImpl;
+
+const createDeferred = () => {
+ let resolve;
+ let reject;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+
+ return { promise, resolve, reject };
+};
+
+class FakeWebContents {
+ on() {}
+ send() {}
+ focus() {}
+ isDestroyed() {
+ return false;
+ }
+ removeAllListeners() {}
+}
+
+class FakeBrowserWindow {
+ constructor() {
+ this._visible = false;
+ this.showCount = 0;
+ this.showInactiveCount = 0;
+ this.hideCount = 0;
+ this._handlers = new Map();
+ this.webContents = new FakeWebContents();
+ createdWindows.push(this);
+ }
+
+ static getAllWindows() {
+ return createdWindows.slice();
+ }
+
+ loadFile() {}
+ setVisibleOnAllWorkspaces() {}
+ setOpacity() {}
+ setClosable() {}
+ removeAllListeners() {}
+ destroy() {}
+ on(eventName, handler) {
+ this._handlers.set(eventName, handler);
+ }
+ emit(eventName) {
+ const handler = this._handlers.get(eventName);
+ if (handler) handler();
+ }
+ getBounds() {
+ return { width: 300, height: 80, x: 0, y: 0 };
+ }
+ isDestroyed() {
+ return false;
+ }
+ isVisible() {
+ return this._visible;
+ }
+ show() {
+ this._visible = true;
+ this.showCount += 1;
+ }
+ showInactive() {
+ this._visible = true;
+ this.showInactiveCount += 1;
+ }
+ hide() {
+ this._visible = false;
+ this.hideCount += 1;
+ }
+}
+
+const installMocks = () => {
+ Module._load = function patchedLoad(request, parent, isMain) {
+ if (request === 'electron') {
+ return {
+ BrowserWindow: FakeBrowserWindow,
+ ipcMain: { on: () => {}, removeAllListeners: () => {} },
+ screen: {
+ getPrimaryDisplay: () => ({ workAreaSize: { width: 1920, height: 1080 } }),
+ getDisplayMatching: () => ({
+ bounds: { x: 0, y: 0, width: 1920, height: 1080 },
+ }),
+ },
+ };
+ }
+ if (request === 'electron-log/main') {
+ return { info: () => {} };
+ }
+ if (request.endsWith('simple-store')) {
+ return {
+ loadSimpleStoreAll: () => loadSimpleStoreAllImpl(),
+ saveSimpleStore: () => {},
+ };
+ }
+ if (request.endsWith('common.const')) {
+ return { IS_MAC: false };
+ }
+ return originalModuleLoad.call(this, request, parent, isMain);
+ };
+};
+
+const loadModule = () => {
+ delete require.cache[taskWidgetModulePath];
+ return require(taskWidgetModulePath);
+};
+
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+test.beforeEach(() => {
+ createdWindows = [];
+ loadSimpleStoreAllImpl = async () => ({});
+ installMocks();
+});
+
+test.afterEach(() => {
+ Module._load = originalModuleLoad;
+});
+
+test('toggleTaskWidgetVisibility is a no-op while the task widget feature is disabled', () => {
+ const mod = loadModule();
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(createdWindows.length, 0, 'no window should be created when disabled');
+});
+
+test('toggleTaskWidgetVisibility shows the widget when it is enabled but hidden', async () => {
+ const mod = loadModule();
+ mod.updateTaskWidgetEnabled(true);
+ await flush();
+
+ assert.equal(createdWindows.length, 1, 'enabling should create the widget window');
+ const win = createdWindows[0];
+ assert.equal(win.isVisible(), false, 'widget starts hidden');
+
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(win.isVisible(), true, 'toggle should show the hidden widget');
+ assert.equal(win.showInactiveCount, 1);
+});
+
+test('toggleTaskWidgetVisibility hides the widget when it is enabled and visible', async () => {
+ const mod = loadModule();
+ mod.updateTaskWidgetEnabled(true);
+ await flush();
+
+ const win = createdWindows[0];
+ mod.showTaskWidget();
+ assert.equal(win.isVisible(), true, 'widget should be visible before toggling');
+
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(win.isVisible(), false, 'toggle should hide the visible widget');
+ assert.equal(win.hideCount, 1);
+});
+
+test('forcing the widget visible via the shortcut sets a sticky user-forced flag', async () => {
+ const mod = loadModule();
+ mod.updateTaskWidgetEnabled(true);
+ await flush();
+
+ assert.equal(mod.getIsTaskWidgetUserForcedVisible(), false, 'flag starts cleared');
+
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(
+ mod.getIsTaskWidgetUserForcedVisible(),
+ true,
+ 'showing via the shortcut sets the sticky flag',
+ );
+
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(
+ mod.getIsTaskWidgetUserForcedVisible(),
+ false,
+ 'hiding via the shortcut clears the sticky flag',
+ );
+});
+
+test('disabling the widget clears the sticky user-forced flag', async () => {
+ const mod = loadModule();
+ mod.updateTaskWidgetEnabled(true);
+ await flush();
+
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(mod.getIsTaskWidgetUserForcedVisible(), true);
+
+ mod.updateTaskWidgetEnabled(false);
+ assert.equal(
+ mod.getIsTaskWidgetUserForcedVisible(),
+ false,
+ 'disabling the feature resets the sticky flag',
+ );
+});
+
+test('disabling clears the sticky flag even when the widget window is absent', () => {
+ const mod = loadModule();
+
+ // Enable but do not flush: createTaskWidgetWindow() is mid-flight, so
+ // taskWidgetWin is still null (the "absent window" / async re-create gap).
+ mod.updateTaskWidgetEnabled(true);
+
+ // User hits the shortcut during that gap — the flag is set without a window.
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(createdWindows.length, 0, 'no window exists yet');
+ assert.equal(
+ mod.getIsTaskWidgetUserForcedVisible(),
+ true,
+ 'shortcut sets the sticky flag even without a window',
+ );
+
+ // Disabling now must clear the flag even though destroyTaskWidget() is
+ // skipped (its guard requires an existing window), or it would leak into
+ // the next enable.
+ mod.updateTaskWidgetEnabled(false);
+ assert.equal(
+ mod.getIsTaskWidgetUserForcedVisible(),
+ false,
+ 'disabling clears the flag regardless of whether the window exists',
+ );
+});
+
+test('disabling while async creation is pending prevents the widget window from being created', async () => {
+ const storeLoad = createDeferred();
+ loadSimpleStoreAllImpl = () => storeLoad.promise;
+ const mod = loadModule();
+
+ mod.updateTaskWidgetEnabled(true);
+ mod.updateTaskWidgetEnabled(false);
+
+ storeLoad.resolve({});
+ await flush();
+
+ assert.equal(
+ createdWindows.length,
+ 0,
+ 'disabling before persisted bounds load resolves should cancel window creation',
+ );
+});
+
+test('shortcut reveal while async creation is pending shows the widget after creation completes', async () => {
+ const storeLoad = createDeferred();
+ loadSimpleStoreAllImpl = () => storeLoad.promise;
+ const mod = loadModule();
+
+ mod.updateTaskWidgetEnabled(true);
+ mod.toggleTaskWidgetVisibility();
+
+ storeLoad.resolve({});
+ await flush();
+
+ assert.equal(createdWindows.length, 1, 'only the initial in-flight creation is reused');
+ assert.equal(
+ createdWindows[0].isVisible(),
+ true,
+ 'pending shortcut reveal should show the window once it exists',
+ );
+});
+
+test('shortcut reveal uses showInactive so the current app keeps focus', async () => {
+ const mod = loadModule();
+ mod.updateTaskWidgetEnabled(true);
+ await flush();
+
+ const win = createdWindows[0];
+ mod.toggleTaskWidgetVisibility();
+
+ assert.equal(win.showInactiveCount, 1);
+ assert.equal(win.showCount, 0);
+ assert.equal(win.isVisible(), true, 'widget should still become visible');
+});
+
+test('the closed event clears the sticky flag so it does not outlive the window', async () => {
+ const mod = loadModule();
+ mod.updateTaskWidgetEnabled(true);
+ await flush();
+
+ const win = createdWindows[0];
+ mod.toggleTaskWidgetVisibility();
+ assert.equal(mod.getIsTaskWidgetUserForcedVisible(), true);
+
+ win.emit('closed');
+ assert.equal(
+ mod.getIsTaskWidgetUserForcedVisible(),
+ false,
+ 'closing the window clears the sticky flag',
+ );
+});
diff --git a/electron/task-widget/task-widget.ts b/electron/task-widget/task-widget.ts
index a07d9052db..6739ad867a 100644
--- a/electron/task-widget/task-widget.ts
+++ b/electron/task-widget/task-widget.ts
@@ -10,6 +10,14 @@ import { IS_MAC } from '../common.const';
let taskWidgetWin: BrowserWindow | null = null;
let isTaskWidgetEnabled = false;
let isAlwaysShow = false;
+// Set when the user explicitly reveals the widget via the global shortcut
+// (`globalToggleTaskWidget`) while the main window is visible. Like
+// `isAlwaysShow`, it suppresses the automatic "hide the widget when the main
+// window is shown/focused" behavior — but only until the user hides the widget
+// again (toggles off) or opens the app from the widget. This gives the shortcut
+// a sticky "user-forced visible" effect instead of being immediately undone by
+// the next focus event.
+let isUserForcedVisible = false;
let currentTask: TaskCopy | null = null;
let isPomodoroEnabled = false;
let currentPomodoroSessionTime = 0;
@@ -18,16 +26,28 @@ let currentFocusSessionTime = 0;
let initTimeoutId: NodeJS.Timeout | null = null;
let currentOpacity = 95;
let listenersRegistered = false;
-let isCreatingWindow = false;
+let taskWidgetCreationPromise: Promise | null = null;
+let taskWidgetCreationGeneration = 0;
+let pendingShowAfterCreate = false;
+let pendingShowAfterCreateInactive = false;
const TASK_WIDGET_BOUNDS_KEY = 'taskWidgetBounds';
const LEGACY_BOUNDS_KEY = 'overlayBounds';
let boundsDebounceTimer: NodeJS.Timeout | null = null;
+type ShowTaskWidgetOptions = Readonly<{
+ inactive?: boolean;
+}>;
+
export const updateTaskWidgetEnabled = (isEnabled: boolean): void => {
isTaskWidgetEnabled = isEnabled;
- if (isEnabled && !taskWidgetWin && !isCreatingWindow) {
+ if (!isEnabled) {
+ destroyTaskWidget();
+ return;
+ }
+
+ if (!taskWidgetWin && !taskWidgetCreationPromise) {
initListeners();
createTaskWidgetWindow().then(() => {
// Window creation is async; re-apply the cached opacity here because
@@ -44,11 +64,16 @@ export const updateTaskWidgetEnabled = (isEnabled: boolean): void => {
mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_TASK_WIDGET);
}
});
- } else if (!isEnabled && taskWidgetWin) {
- destroyTaskWidget();
}
};
+const clearPendingTaskWidgetCreation = (): void => {
+ taskWidgetCreationGeneration += 1;
+ taskWidgetCreationPromise = null;
+ pendingShowAfterCreate = false;
+ pendingShowAfterCreateInactive = false;
+};
+
export const destroyTaskWidget = (): void => {
// Clear any pending timeouts
if (initTimeoutId) {
@@ -64,7 +89,8 @@ export const destroyTaskWidget = (): void => {
// Disable task widget to prevent close event prevention
isTaskWidgetEnabled = false;
- isCreatingWindow = false;
+ isUserForcedVisible = false;
+ clearPendingTaskWidgetCreation();
// Remove IPC listeners
ipcMain.removeAllListeners('task-widget-show-main-window');
@@ -97,11 +123,34 @@ export const destroyTaskWidget = (): void => {
}
};
-const createTaskWidgetWindow = async (): Promise => {
- if (taskWidgetWin || isCreatingWindow) {
+const createTaskWidgetWindow = (): Promise => {
+ if (taskWidgetWin) {
+ return Promise.resolve();
+ }
+
+ if (taskWidgetCreationPromise) {
+ return taskWidgetCreationPromise;
+ }
+
+ const creationGeneration = taskWidgetCreationGeneration;
+ const nextCreationPromise = createTaskWidgetWindowForGeneration(
+ creationGeneration,
+ ).finally(() => {
+ if (taskWidgetCreationPromise === nextCreationPromise) {
+ taskWidgetCreationPromise = null;
+ }
+ });
+
+ taskWidgetCreationPromise = nextCreationPromise;
+ return nextCreationPromise;
+};
+
+const createTaskWidgetWindowForGeneration = async (
+ creationGeneration: number,
+): Promise => {
+ if (taskWidgetWin) {
return;
}
- isCreatingWindow = true;
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth } = primaryDisplay.workAreaSize;
@@ -143,7 +192,14 @@ const createTaskWidgetWindow = async (): Promise => {
// Use defaults (file may not exist on first run)
}
- isCreatingWindow = false;
+ if (
+ taskWidgetWin ||
+ !isTaskWidgetEnabled ||
+ creationGeneration !== taskWidgetCreationGeneration
+ ) {
+ return;
+ }
+
// On macOS, transparent + frameless windows do not support native window
// dragging or edge resizing (see Electron's BrowserWindow docs: "Transparent
// windows are not resizable. Setting `resizable` to `true` may make a
@@ -190,6 +246,10 @@ const createTaskWidgetWindow = async (): Promise => {
taskWidgetWin.on('closed', () => {
taskWidgetWin = null;
+ // Tie "user-forced visible" to the window's lifetime: once the window is
+ // gone the sticky flag has no widget to keep visible, so don't let it
+ // linger into a future re-create.
+ isUserForcedVisible = false;
});
taskWidgetWin.on('ready-to-show', () => {
@@ -232,9 +292,30 @@ const createTaskWidgetWindow = async (): Promise => {
// Update initial state
updateTaskWidgetContent();
+
+ updateTaskWidgetOpacity(currentOpacity);
+
+ if (pendingShowAfterCreate) {
+ const showInactive = pendingShowAfterCreateInactive;
+ pendingShowAfterCreate = false;
+ pendingShowAfterCreateInactive = false;
+ showTaskWidgetWindow({ inactive: showInactive });
+ }
};
-export const showTaskWidget = (): void => {
+const showTaskWidgetWindow = (options: ShowTaskWidgetOptions = {}): void => {
+ if (!taskWidgetWin || taskWidgetWin.isDestroyed()) {
+ return;
+ }
+
+ if (options.inactive) {
+ taskWidgetWin.showInactive();
+ } else {
+ taskWidgetWin.show();
+ }
+};
+
+export const showTaskWidget = (options: ShowTaskWidgetOptions = {}): void => {
if (!isTaskWidgetEnabled) {
return;
}
@@ -242,12 +323,9 @@ export const showTaskWidget = (): void => {
// Recreate task widget if it was accidentally closed
if (!taskWidgetWin) {
info('Task widget window was destroyed, recreating');
- createTaskWidgetWindow().then(() => {
- if (taskWidgetWin && !taskWidgetWin.isDestroyed()) {
- updateTaskWidgetOpacity(currentOpacity);
- taskWidgetWin.show();
- }
- });
+ pendingShowAfterCreate = true;
+ pendingShowAfterCreateInactive = pendingShowAfterCreateInactive || !!options.inactive;
+ createTaskWidgetWindow();
return;
}
@@ -258,7 +336,7 @@ export const showTaskWidget = (): void => {
// Only show if not already visible
if (!taskWidgetWin.isVisible()) {
info('Showing task widget');
- taskWidgetWin.show();
+ showTaskWidgetWindow(options);
} else {
info('Task widget already visible');
}
@@ -284,6 +362,27 @@ export const hideTaskWidget = (): void => {
}
};
+/**
+ * Toggles the task widget's visibility. Intended for the global shortcut
+ * (`globalToggleTaskWidget`): it only acts when the task widget feature is
+ * enabled in settings and never changes that persisted enabled/disabled
+ * preference — it just shows or hides the existing widget.
+ */
+export const toggleTaskWidgetVisibility = (): void => {
+ if (!isTaskWidgetEnabled) {
+ return;
+ }
+
+ if (taskWidgetWin && !taskWidgetWin.isDestroyed() && taskWidgetWin.isVisible()) {
+ isUserForcedVisible = false;
+ hideTaskWidget();
+ return;
+ }
+
+ isUserForcedVisible = true;
+ showTaskWidget({ inactive: true });
+};
+
const initListeners = (): void => {
if (listenersRegistered) {
return;
@@ -299,6 +398,10 @@ const initListeners = (): void => {
// event.preventDefault() on 'minimize' has no effect).
mainWindow.restore();
mainWindow.show();
+ // Opening the app from the widget is an explicit "I'm going to the app"
+ // gesture, so clear any sticky user-forced visibility and let the widget
+ // follow the normal companion behavior again.
+ isUserForcedVisible = false;
if (!isAlwaysShow) {
hideTaskWidget();
}
@@ -374,6 +477,8 @@ export const updateTaskWidgetAlwaysShow = (alwaysShow: boolean): void => {
export const getIsTaskWidgetAlwaysShow = (): boolean => isAlwaysShow;
+export const getIsTaskWidgetUserForcedVisible = (): boolean => isUserForcedVisible;
+
export const updateTaskWidgetOpacity = (opacity: number): void => {
currentOpacity = opacity;
if (!taskWidgetWin || taskWidgetWin.isDestroyed()) {
diff --git a/electron/various-shared.ts b/electron/various-shared.ts
index 74eb635c0f..c699106ee7 100644
--- a/electron/various-shared.ts
+++ b/electron/various-shared.ts
@@ -1,7 +1,11 @@
import { app, BrowserWindow } from 'electron';
import { info } from 'electron-log/main';
import { getWin, getWasMaximizedBeforeHide } from './main-window';
-import { getIsTaskWidgetAlwaysShow, hideTaskWidget } from './task-widget/task-widget';
+import {
+ getIsTaskWidgetAlwaysShow,
+ getIsTaskWidgetUserForcedVisible,
+ hideTaskWidget,
+} from './task-widget/task-widget';
import { setIsQuiting } from './shared-state';
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
@@ -36,8 +40,9 @@ export function showOrFocus(passedWin: BrowserWindow): void {
if (getWasMaximizedBeforeHide()) win.maximize();
}
- // Hide task widget when main window is shown
- if (!getIsTaskWidgetAlwaysShow()) {
+ // Hide task widget when main window is shown, unless the user explicitly
+ // pinned it visible via the global shortcut.
+ if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) {
hideTaskWidget();
}
diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts
index b0ec1bdd7b..f26e2356e2 100644
--- a/src/app/features/config/default-global-config.const.ts
+++ b/src/app/features/config/default-global-config.const.ts
@@ -131,6 +131,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
globalToggleTaskStart: null,
globalAddNote: null,
globalAddTask: null,
+ globalToggleTaskWidget: null,
addNewTask: 'Shift+A',
addNewProject: 'Shift+P',
addNewNote: 'Alt+N',
diff --git a/src/app/features/config/form-cfgs/keyboard-form.const.ts b/src/app/features/config/form-cfgs/keyboard-form.const.ts
index 2ced22526b..dcd9906e24 100644
--- a/src/app/features/config/form-cfgs/keyboard-form.const.ts
+++ b/src/app/features/config/form-cfgs/keyboard-form.const.ts
@@ -40,6 +40,7 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection = {
kbField('globalToggleTaskStart', T.GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_START),
kbField('globalAddNote', T.GCF.KEYBOARD.GLOBAL_ADD_NOTE),
kbField('globalAddTask', T.GCF.KEYBOARD.GLOBAL_ADD_TASK),
+ kbField('globalToggleTaskWidget', T.GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_WIDGET),
]
: []),
// APP WIDE
diff --git a/src/app/features/config/keyboard-config.model.ts b/src/app/features/config/keyboard-config.model.ts
index cc9a445813..6a1d02c464 100644
--- a/src/app/features/config/keyboard-config.model.ts
+++ b/src/app/features/config/keyboard-config.model.ts
@@ -2,6 +2,7 @@ export type KeyboardConfig = Readonly<{
globalShowHide?: string | null;
globalAddNote?: string | null;
globalAddTask?: string | null;
+ globalToggleTaskWidget?: string | null;
toggleBacklog?: string | null;
goToFocusMode?: string | null;
goToWorkView?: string | null;
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index e2a4b4322b..1524bc6863 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -2403,6 +2403,7 @@ const T = {
GLOBAL_ADD_TASK: 'GCF.KEYBOARD.GLOBAL_ADD_TASK',
GLOBAL_SHOW_HIDE: 'GCF.KEYBOARD.GLOBAL_SHOW_HIDE',
GLOBAL_TOGGLE_TASK_START: 'GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_START',
+ GLOBAL_TOGGLE_TASK_WIDGET: 'GCF.KEYBOARD.GLOBAL_TOGGLE_TASK_WIDGET',
GO_TO_DAILY_AGENDA: 'GCF.KEYBOARD.GO_TO_DAILY_AGENDA',
GO_TO_FOCUS_MODE: 'GCF.KEYBOARD.GO_TO_FOCUS_MODE',
GO_TO_SCHEDULE: 'GCF.KEYBOARD.GO_TO_SCHEDULE',
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 56ee1bb495..ea6bba5d36 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -2346,6 +2346,7 @@
"GLOBAL_ADD_TASK": "Add new task",
"GLOBAL_SHOW_HIDE": "Show/hide Super Productivity",
"GLOBAL_TOGGLE_TASK_START": "Start/stop time tracking for last active task",
+ "GLOBAL_TOGGLE_TASK_WIDGET": "Show/hide task widget",
"GO_TO_DAILY_AGENDA": "Go to agenda",
"GO_TO_FOCUS_MODE": "Enter focus mode",
"GO_TO_SCHEDULE": "Go to Today",
From ac8efed270a0da06e1358fe8f69751a370a26793 Mon Sep 17 00:00:00 2001
From: Johannes Millan
Date: Tue, 9 Jun 2026 16:19:53 +0200
Subject: [PATCH 08/11] fix(notes): sanitize markdown notes to prevent stored
XSS (#8213)
The three note render surfaces (inline-markdown, dialog-fullscreen-markdown,
dialog-view-archived-task) set [disableSanitizer]="true" on , so
marked's raw inline HTML reached the DOM unsanitized and event-handler
attributes ( , , ) executed
(GHSA-4rrp-xhp8-hf4p).
Drop disableSanitizer from all three so ngx-markdown applies Angular's
SecurityContext.HTML sanitizer (already the app-wide default in main.ts).
Verified against Angular's allowlist that this preserves the full custom
renderer output (checkbox spans, target=_blank links, blob:/data:/sized
images, tables); only the loading="lazy" hint is dropped.
Defense-in-depth in marked-options-factory: escape href/title/alt in
renderer.image AND href/title in renderer.link so the raw renderer output is
not attribute-injectable on its own; add rel="noopener noreferrer" to links
to match render-links.pipe.ts.
Tests: new markdown-sanitization.spec.ts plus renderer-escaping and
component-level guard tests.
---
.../dialog-view-archived-task.component.html | 1 -
.../dialog-fullscreen-markdown.component.html | 1 -
.../inline-markdown.component.html | 1 -
.../inline-markdown.component.spec.ts | 37 ++++++
src/app/ui/markdown-sanitization.spec.ts | 114 ++++++++++++++++++
src/app/ui/marked-options-factory.spec.ts | 94 +++++++++++++++
src/app/ui/marked-options-factory.ts | 35 +++++-
7 files changed, 275 insertions(+), 8 deletions(-)
create mode 100644 src/app/ui/markdown-sanitization.spec.ts
diff --git a/src/app/features/tasks/dialog-view-archived-task/dialog-view-archived-task.component.html b/src/app/features/tasks/dialog-view-archived-task/dialog-view-archived-task.component.html
index 506099341a..a3d2150132 100644
--- a/src/app/features/tasks/dialog-view-archived-task/dialog-view-archived-task.component.html
+++ b/src/app/features/tasks/dialog-view-archived-task/dialog-view-archived-task.component.html
@@ -55,7 +55,6 @@
diff --git a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html
index 0bdcb6f8bc..8c95c13306 100644
--- a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html
+++ b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html
@@ -170,7 +170,6 @@
#previewEl
(click)="clickPreview($event)"
[data]="resolvedContentData"
- [disableSanitizer]="true"
class="markdown-preview markdown"
>
}
diff --git a/src/app/ui/inline-markdown/inline-markdown.component.html b/src/app/ui/inline-markdown/inline-markdown.component.html
index 10eee7001c..c803566300 100644
--- a/src/app/ui/inline-markdown/inline-markdown.component.html
+++ b/src/app/ui/inline-markdown/inline-markdown.component.html
@@ -27,7 +27,6 @@
#previewEl
(click)="clickPreview($event)"
[data]="resolvedMarkdownData"
- [disableSanitizer]="true"
[class.preview-while-editing]="isShowEdit()"
class="mat-body-2 markdown-parsed markdown"
tabindex="0"
diff --git a/src/app/ui/inline-markdown/inline-markdown.component.spec.ts b/src/app/ui/inline-markdown/inline-markdown.component.spec.ts
index fe530b31ca..4aaaa3f90a 100644
--- a/src/app/ui/inline-markdown/inline-markdown.component.spec.ts
+++ b/src/app/ui/inline-markdown/inline-markdown.component.spec.ts
@@ -137,6 +137,43 @@ describe('InlineMarkdownComponent', () => {
}));
});
+ describe('XSS sanitization (GHSA-4rrp-xhp8-hf4p)', () => {
+ it('should not render an executable event handler from a malicious note', fakeAsync(() => {
+ component.model = ' ';
+ fixture.detectChanges();
+ tick();
+ fixture.detectChanges();
+ tick();
+
+ const preview = fixture.nativeElement.querySelector(
+ 'markdown.markdown-parsed',
+ ) as HTMLElement;
+ expect(preview).toBeTruthy();
+ expect(preview.innerHTML).not.toContain('onerror');
+ // The sanitizer keeps the (now inert) , just without the handler.
+ const img = preview.querySelector('img');
+ if (img) {
+ expect(img.getAttribute('onerror')).toBeNull();
+ }
+ }));
+
+ it('should still render a normal note (sanitizer does not break rendering)', fakeAsync(() => {
+ component.model = '**bold** and [link](https://example.com)';
+ fixture.detectChanges();
+ tick();
+ fixture.detectChanges();
+ tick();
+
+ const preview = fixture.nativeElement.querySelector(
+ 'markdown.markdown-parsed',
+ ) as HTMLElement;
+ expect(preview.querySelector('strong')?.textContent).toBe('bold');
+ expect(preview.querySelector('a')?.getAttribute('href')).toBe(
+ 'https://example.com',
+ );
+ }));
+ });
+
describe('ngOnDestroy', () => {
it('should emit changed event with current value when in edit mode and value has changed', () => {
// Arrange
diff --git a/src/app/ui/markdown-sanitization.spec.ts b/src/app/ui/markdown-sanitization.spec.ts
new file mode 100644
index 0000000000..701c4ae410
--- /dev/null
+++ b/src/app/ui/markdown-sanitization.spec.ts
@@ -0,0 +1,114 @@
+import { TestBed } from '@angular/core/testing';
+import { SecurityContext } from '@angular/core';
+import { DomSanitizer } from '@angular/platform-browser';
+import { marked } from 'marked';
+import { markedOptionsFactory } from './marked-options-factory';
+
+/**
+ * End-to-end sanitization contract for the note render surfaces
+ * (inline-markdown, dialog-fullscreen-markdown, dialog-view-archived-task).
+ *
+ * Those elements no longer set [disableSanitizer]="true", so at
+ * runtime ngx-markdown runs our rendered output through Angular's
+ * SecurityContext.HTML sanitizer (main.ts provides SANITIZE = SecurityContext.HTML).
+ * See ngx-markdown MarkdownService: `disableSanitizer ? marked : sanitizeHtml(marked)`
+ * and `sanitizer.sanitize(SecurityContext.HTML, html)`.
+ *
+ * This guards GHSA-4rrp-xhp8-hf4p (stored XSS via raw HTML in task notes):
+ * - injected event-handler attributes / script must be stripped, AND
+ * - the custom renderer's legitimate output (checkboxes, links, sized & pasted
+ * images, tables) must still survive sanitization.
+ *
+ * If anyone re-adds disableSanitizer to those surfaces, the rendering still works
+ * but this contract no longer protects users — keep these surfaces sanitized.
+ */
+const renderAsApp = (markdown: string, sanitizer: DomSanitizer): string => {
+ marked.setOptions(marked.getDefaults());
+ marked.setOptions(markedOptionsFactory());
+ const html = marked.parse(markdown) as string;
+ return sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
+};
+
+describe('markdown note sanitization (GHSA-4rrp-xhp8-hf4p)', () => {
+ let sanitizer: DomSanitizer;
+ beforeEach(() => {
+ sanitizer = TestBed.inject(DomSanitizer);
+ });
+
+ describe('strips XSS vectors', () => {
+ it('removes onerror from a raw in the note body', () => {
+ const out = renderAsApp(' ', sanitizer);
+ expect(out).not.toContain('onerror');
+ expect(out.toLowerCase()).not.toContain('alert(');
+ });
+
+ it('removes onload from raw ', () => {
+ expect(renderAsApp('', sanitizer)).not.toContain('onload');
+ });
+
+ it('removes ontoggle from ', () => {
+ const out = renderAsApp('x ', sanitizer);
+ expect(out).not.toContain('ontoggle');
+ });
+
+ it('removes onmouseover from an arbitrary injected element', () => {
+ const out = renderAsApp('x ', sanitizer);
+ expect(out).not.toContain('onmouseover');
+ });
+
+ it('drops ', sanitizer);
+ expect(out.toLowerCase()).not.toContain('