diff --git a/e2e/pages/dialog.page.ts b/e2e/pages/dialog.page.ts
index 247ca5a914..169f9dc859 100644
--- a/e2e/pages/dialog.page.ts
+++ b/e2e/pages/dialog.page.ts
@@ -173,7 +173,7 @@ export class DialogPage extends BasePage {
* Open calendar picker
*/
async openCalendarPicker(): Promise {
- const openCalendarBtn = this.page.getByRole('button', { name: 'Open calendar' });
+ const openCalendarBtn = this.page.locator('mat-datepicker-toggle button').first();
await openCalendarBtn.waitFor({ state: 'visible', timeout: 3000 });
await openCalendarBtn.click();
await this.page.waitForTimeout(300);
diff --git a/e2e/pages/task.page.ts b/e2e/pages/task.page.ts
index a868ea74b4..aabe7b690c 100644
--- a/e2e/pages/task.page.ts
+++ b/e2e/pages/task.page.ts
@@ -204,7 +204,7 @@ export class TaskPage extends BasePage {
async waitForTaskCount(expectedCount: number, timeout: number = 10000): Promise {
await this.page.waitForFunction(
(args) => {
- const currentCount = document.querySelectorAll('task').length;
+ const currentCount = document.querySelectorAll('task, planner-task').length;
return currentCount === args.expectedCount;
},
{ expectedCount },
diff --git a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts
index b8bca45480..062bd5df40 100644
--- a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts
+++ b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts
@@ -36,15 +36,24 @@ test('should not crash when a repeat config has an invalid startTime in the stor
.filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) })
.click();
- const repeatDialog = page.locator('mat-dialog-container');
+ const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
// Set a valid startTime so the config has startTime + remindAt in the store
- await repeatDialog.locator('collapsible .collapsible-header').last().click();
- const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
+ await repeatDialog.locator('.planned-start-date-btn').click();
+ const scheduleDialog = page
+ .locator('mat-dialog-container')
+ .filter({ has: page.locator('datetime-picker') });
+ await expect(scheduleDialog).toBeVisible();
+
+ const startTimeField = scheduleDialog.getByLabel('Time');
await expect(startTimeField).toBeVisible({ timeout: 5000 });
await startTimeField.fill('10:30');
await startTimeField.blur();
+
+ const scheduleBtn = scheduleDialog.locator('[data-test-id="schedule-submit-btn"]');
+ await scheduleBtn.click();
+ await scheduleDialog.waitFor({ state: 'hidden' });
await page.waitForTimeout(300);
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
diff --git a/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts b/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts
index 3b9d0a966e..a0149f00b5 100644
--- a/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts
+++ b/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts
@@ -41,29 +41,40 @@ test.describe('Recurring Task - Future Start Date (#6856)', () => {
await recurItem.click();
// 3. Wait for the repeat dialog and set a future start date via calendar
- const repeatDialog = page.locator('mat-dialog-container');
+ const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
- // Open the calendar popup
- const calendarToggle = repeatDialog.locator('mat-datepicker-toggle button');
- await calendarToggle.click();
+ // Open the schedule dialog
+ const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
+ await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
+ await scheduleBtn.click();
- const calendar = page.locator('.mat-calendar');
+ // Wait for the schedule dialog to appear
+ const scheduleDialog = page
+ .locator('mat-dialog-container')
+ .filter({ has: page.locator('datetime-picker') });
+ await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
+
+ const calendar = scheduleDialog.locator('mat-calendar');
await expect(calendar).toBeVisible({ timeout: 5000 });
// Navigate to next month to ensure the date is in the future
- const nextMonthBtn = page.getByRole('button', { name: /next month/i });
+ const nextMonthBtn = scheduleDialog.getByRole('button', { name: /next month/i });
await nextMonthBtn.click();
// Select the first available day in next month
- const firstDay = page
+ const firstDay = scheduleDialog
.locator('.mat-calendar-body-cell:not(.mat-calendar-body-disabled)')
.first();
await expect(firstDay).toBeVisible({ timeout: 5000 });
await firstDay.click();
- // Wait for calendar to close
- await expect(calendar).not.toBeVisible({ timeout: 5000 });
+ // Click Schedule button
+ const scheduleSubmitBtn = scheduleDialog.locator(
+ '[data-test-id="schedule-submit-btn"]',
+ );
+ await scheduleSubmitBtn.click();
+ await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
// Save the repeat config — wait for the button to be enabled first
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
diff --git a/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts
index 4641acf029..8de0345410 100644
--- a/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts
+++ b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts
@@ -41,32 +41,46 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
await recurItem.click();
// 3. Wait for the repeat dialog to appear
- const repeatDialog = page.locator('mat-dialog-container');
+ const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
- // 4. Open the calendar popup and select first day of next month
- const calendarToggle = repeatDialog.locator('mat-datepicker-toggle button');
- await calendarToggle.click();
+ // 4. Open the schedule dialog
+ const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
+ await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
+ await scheduleBtn.click();
- const calendar = page.locator('.mat-calendar');
+ // Wait for the schedule dialog to appear
+ const scheduleDialog = page
+ .locator('mat-dialog-container')
+ .filter({ has: page.locator('datetime-picker') });
+ await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
+
+ const calendar = scheduleDialog.locator('mat-calendar');
await expect(calendar).toBeVisible({ timeout: 5000 });
// Navigate to next month and select the first available day
- const nextMonthBtn = page.getByRole('button', { name: /next month/i });
+ const nextMonthBtn = scheduleDialog.getByRole('button', { name: /next month/i });
await nextMonthBtn.click();
- const firstDay = page
+ const firstDay = scheduleDialog
.locator('.mat-calendar-body-cell:not(.mat-calendar-body-disabled)')
.first();
await expect(firstDay).toBeVisible({ timeout: 5000 });
await firstDay.click();
- // 5. Verify the date input does not show epoch
- const dateInput = repeatDialog.getByRole('textbox', { name: /start date/i });
- await expect(dateInput).toBeVisible();
- const inputValue = await dateInput.inputValue();
- expect(inputValue).not.toBe('');
- expect(inputValue).not.toContain('1970');
+ // Click Schedule button
+ const scheduleSubmitBtn = scheduleDialog.locator(
+ '[data-test-id="schedule-submit-btn"]',
+ );
+ await scheduleSubmitBtn.click();
+ await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
+
+ // 5. Verify the date input/val does not show epoch
+ const dateVal = repeatDialog.locator('.planned-date-val');
+ await expect(dateVal).toBeVisible();
+ const valText = await dateVal.innerText();
+ expect(valText).not.toBe('');
+ expect(valText).not.toContain('1970');
// 6. Save and verify the date survives persistence
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
@@ -74,8 +88,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
await saveBtn.click();
await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 });
});
-
- test('should preserve start date when typing date manually into input', async ({
+ test('should preserve start date when configuring recurring task via helper', async ({
page,
workViewPage,
taskPage,
@@ -105,7 +118,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
await recurItem.click();
// 3. Wait for the repeat dialog to appear
- const repeatDialog = page.locator('mat-dialog-container');
+ const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
await setRecurStartDate(page, '15/06/2026');
diff --git a/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts b/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts
index 08dc49e90b..bb3363d239 100644
--- a/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts
+++ b/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts
@@ -46,17 +46,34 @@ test.describe('Repeat Task - Timed + Cold Reopen Day Change', () => {
await expect(recurItem).toBeVisible({ timeout: 5000 });
await recurItem.click();
- const repeatDialog = page.locator('mat-dialog-container');
+ const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
- // 4. Make it a TIMED daily repeat: expand Advanced, set a start time (13:00).
+ // 4. Make it a TIMED daily repeat: Open schedule dialog and set a start time (13:00).
// remindAt defaults to AtStart once startTime is set, so the config is timed.
- await repeatDialog.locator('collapsible .collapsible-header').last().click();
- const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
+ const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
+ await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
+ await scheduleBtn.click();
+
+ // Wait for the schedule dialog to appear
+ const scheduleDialog = page
+ .locator('mat-dialog-container')
+ .filter({ has: page.locator('datetime-picker') });
+ await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
+
+ // Set a valid startTime
+ const startTimeField = scheduleDialog.getByLabel('Time');
await expect(startTimeField).toBeVisible({ timeout: 5000 });
await startTimeField.fill('13:00');
await startTimeField.blur();
+ // Click Schedule button
+ const scheduleSubmitBtn = scheduleDialog.locator(
+ '[data-test-id="schedule-submit-btn"]',
+ );
+ await scheduleSubmitBtn.click();
+ await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
+
// 5. Save the (default DAILY) repeat config.
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
await expect(saveBtn).toBeEnabled({ timeout: 5000 });
diff --git a/e2e/tests/task-detail/task-detail.spec.ts b/e2e/tests/task-detail/task-detail.spec.ts
index c6f9f7acf4..2b76a825e6 100644
--- a/e2e/tests/task-detail/task-detail.spec.ts
+++ b/e2e/tests/task-detail/task-detail.spec.ts
@@ -54,7 +54,7 @@ test.describe('Task detail', () => {
const completedInfoText = await completedInfo.textContent();
await completedInfo.click();
- await page.getByRole('button', { name: 'Open calendar' }).click();
+ await page.locator('mat-datepicker-toggle button').first().click();
await page.getByRole('button', { name: 'Next month' }).click();
// Picking the first day of the next month should guarantee a change
await page.locator('mat-month-view button').first().click();
diff --git a/e2e/utils/recurring-task-helpers.ts b/e2e/utils/recurring-task-helpers.ts
index 392cd68e79..bbb97b04c9 100644
--- a/e2e/utils/recurring-task-helpers.ts
+++ b/e2e/utils/recurring-task-helpers.ts
@@ -45,32 +45,63 @@ export const openRecurDialogFromProjection = async (
};
/**
- * Set the recurring "Start date" by typing into the matInput. The input parses
- * the locale's display format (en-GB → "DD/MM/YYYY") on blur, which is more
- * robust than driving the calendar overlay across Material versions.
- *
- * Flake guard: the Material datepicker input intermittently drops the typed
- * value while the dialog is still binding/animating. On blur the (dateChange)
- * handler clears `innerValue` whenever the field hasn't yet parsed to a valid
- * date, and the one-way `[ngModel]="innerValue()"` binding then re-renders the
- * input as empty (`toHaveValue("")`). The previous guard wrapped only the
- * fill — so the value still vanished on the Tab-triggered blur. Retry the WHOLE
- * type-and-commit cycle (fill + Tab) until the committed value sticks.
+ * Set the recurring "Start date" by using the calendar datepicker.
*/
export const setRecurStartDate = async (page: Page, ddmmyyyy: string): Promise => {
- const dialog = page.locator(DIALOG_CONTAINER);
- const startDateInput = dialog
- .locator('mat-form-field')
- .filter({ hasText: /Start date/i })
- .locator('input')
+ const dialog = page.locator(DIALOG_CONTAINER).first();
+ const scheduleBtn = dialog.locator('.planned-start-date-btn');
+ await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
+ await scheduleBtn.click();
+
+ const scheduleDialog = page
+ .locator(DIALOG_CONTAINER)
+ .filter({ has: page.locator('datetime-picker') });
+ await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
+
+ const calendar = scheduleDialog.locator('mat-calendar');
+ await expect(calendar).toBeVisible({ timeout: 5000 });
+
+ const [dayStr, monthStr, yearStr] = ddmmyyyy.split('/');
+ const day = parseInt(dayStr, 10);
+ const month = parseInt(monthStr, 10) - 1; // 0-indexed
+ const year = parseInt(yearStr, 10);
+
+ // Navigate to correct year
+ await scheduleDialog.locator('.mat-calendar-period-button').click();
+ const yearCell = scheduleDialog
+ .locator('.mat-calendar-body-cell')
+ .filter({ hasText: new RegExp(`^\\s*${year}\\s*$`) })
.first();
- await expect(startDateInput).toBeVisible({ timeout: 5000 });
- await expect(async () => {
- await startDateInput.fill('');
- await startDateInput.fill(ddmmyyyy);
- await startDateInput.press('Tab');
- await expect(startDateInput).toHaveValue(ddmmyyyy, { timeout: 1000 });
- }).toPass({ timeout: 10000 });
+ await expect(yearCell).toBeVisible({ timeout: 5000 });
+ await yearCell.click();
+
+ // Navigate to correct month
+ const monthCell = scheduleDialog
+ .locator('.mat-calendar-body-cell')
+ .filter({
+ hasText: new RegExp(
+ `^\\s*${new Intl.DateTimeFormat('en-US', { month: 'short' }).format(new Date(year, month, 1))}\\s*$`,
+ 'i',
+ ),
+ })
+ .first();
+ await expect(monthCell).toBeVisible({ timeout: 5000 });
+ await monthCell.click();
+
+ // Select day
+ const dayCell = scheduleDialog
+ .locator('.mat-calendar-body-cell')
+ .filter({ hasText: new RegExp(`^\\s*${day}\\s*$`) })
+ .first();
+ await expect(dayCell).toBeVisible({ timeout: 5000 });
+ await dayCell.click();
+
+ // Click Schedule button
+ const scheduleSubmitBtn = scheduleDialog.locator(
+ '[data-test-id="schedule-submit-btn"]',
+ );
+ await scheduleSubmitBtn.click();
+ await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
};
/** Switch the recurring-config quick-setting select (e.g. Daily → Mon-Fri). */
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);
+};
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/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/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/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, {
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/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.
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.
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": "
여기에서 일일 계획 보기의 작업 만들기 패널에서 특정 리포지토리에 대해 열려 있는 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 議題。它們將作為建議列出,並提供議題的連結以及更多相關資訊。
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.