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 - - + + + + + + + + + } +
+ + + +
+ `, + }, + }) + .compileComponents(); + + taskServiceSpy.getByIdsLive$.and.callFake((ids: string[]) => { + return ids.includes('t1') && ids.includes('t2') + ? of([buildTask('t1'), buildTask('t2')]) + : ids.includes('t2') + ? of([buildTask('t2')]) + : of([]); + }); + + fixture = TestBed.createComponent(DialogViewTaskRemindersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + // Must be in document for focus tests + document.body.appendChild(fixture.nativeElement); + }); + + afterEach(() => { + document.body.removeChild(fixture.nativeElement); + }); + + it('should move focus down with ArrowDown skipping disabled/hidden', () => { + const t1b1 = document.getElementById('t1-b1') as HTMLButtonElement; + const t2b1 = document.getElementById('t2-b1') as HTMLButtonElement; + t1b1.focus(); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }); + t1b1.dispatchEvent(ev); + expect(document.activeElement).toBe(t2b1); + expect(ev.defaultPrevented).toBe(true); + }); + + it('should move focus to footer from last task row with ArrowDown', () => { + const t2b1 = document.getElementById('t2-b1') as HTMLButtonElement; + const f1 = document.getElementById('f1') as HTMLButtonElement; + t2b1.focus(); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }); + t2b1.dispatchEvent(ev); + expect(document.activeElement).toBe(f1); + expect(ev.defaultPrevented).toBe(true); + }); + + it('should move focus up with ArrowUp skipping disabled/hidden', () => { + const t1b1 = document.getElementById('t1-b1') as HTMLButtonElement; + const t2b1 = document.getElementById('t2-b1') as HTMLButtonElement; + t2b1.focus(); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowUp', + bubbles: true, + cancelable: true, + }); + t2b1.dispatchEvent(ev); + expect(document.activeElement).toBe(t1b1); + expect(ev.defaultPrevented).toBe(true); + }); + + it('should move focus right with ArrowRight skipping disabled/hidden', () => { + const t1b1 = document.getElementById('t1-b1') as HTMLButtonElement; + const t1b2 = document.getElementById('t1-b2') as HTMLButtonElement; + t1b1.focus(); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }); + t1b1.dispatchEvent(ev); + expect(document.activeElement).toBe(t1b2); + expect(ev.defaultPrevented).toBe(true); + }); + + it('should move focus left with ArrowLeft skipping disabled/hidden', () => { + const t1b1 = document.getElementById('t1-b1') as HTMLButtonElement; + const t1b2 = document.getElementById('t1-b2') as HTMLButtonElement; + t1b2.focus(); + + const ev = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + cancelable: true, + }); + t1b2.dispatchEvent(ev); + expect(document.activeElement).toBe(t1b1); + expect(ev.defaultPrevented).toBe(true); + }); + + it('should preserve focus on next task after removal (even if focus was lost)', fakeAsync(() => { + fixture.detectChanges(); + // Simulate focus being outside (e.g. in a menu) + (document.body as HTMLElement).focus(); + expect(document.activeElement).not.toBe(document.getElementById('t1-b2')); + + // Call the actual private method that performs removal and focus logic + (component as any)._removeTaskFromList('t1'); + + tick(); + fixture.detectChanges(); + + const t2snooze = document.getElementById('t2-b2') as HTMLButtonElement; + expect(document.activeElement).toBe(t2snooze); + })); + + it('should skip disabled buttons in footer with ArrowRight', () => { + const f1 = document.getElementById('f1') as HTMLButtonElement; + const f3 = document.getElementById('f3') as HTMLButtonElement; + f1.focus(); + + const ev = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + component.onKeyDown(ev); + expect(document.activeElement).toBe(f3); + }); +}); + /** * Tests for dismissing the dialog when reminders disappear from the store while it * is open — e.g. the reminder was dismissed, the task completed, or the task deleted diff --git a/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.ts b/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.ts index 066a65b232..74194d9aa2 100644 --- a/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.ts +++ b/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.ts @@ -1,6 +1,8 @@ import { ChangeDetectionStrategy, Component, + ElementRef, + HostListener, inject, OnDestroy, viewChildren, @@ -38,6 +40,7 @@ import { PlannerActions } from '../../planner/store/planner.actions'; import { getDbDateStr } from '../../../util/get-db-date-str'; import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors'; import { DateService } from '../../../core/date/date.service'; +import { MatTooltip } from '@angular/material/tooltip'; const MINUTES_TO_MILLISECONDS = 1000 * 60; @@ -63,6 +66,7 @@ const MINUTES_TO_MILLISECONDS = 1000 * 60; TagListComponent, LocaleDatePipe, LocalDateStrPipe, + MatTooltip, ], }) export class DialogViewTaskRemindersComponent implements OnDestroy { @@ -75,6 +79,7 @@ export class DialogViewTaskRemindersComponent implements OnDestroy { private _store = inject(Store); private _reminderService = inject(ReminderService); private _dateService = inject(DateService); + private _elementRef = inject(ElementRef); data = inject<{ reminders: TaskWithReminderData[]; }>(MAT_DIALOG_DATA); @@ -109,9 +114,10 @@ export class DialogViewTaskRemindersComponent implements OnDestroy { ), ), ); + todayTaskIds$: Observable = this._store.select(selectTodayTaskIds); isSingleOnToday$: Observable = combineLatest([ this.tasks$, - this._store.select(selectTodayTaskIds), + this.todayTaskIds$, ]).pipe( map( ([tasks, todayTaskIds]) => @@ -543,7 +549,133 @@ export class DialogViewTaskRemindersComponent implements OnDestroy { this._matDialogRef.close(); } + @HostListener('keydown', ['$event']) + onKeyDown(ev: KeyboardEvent): void { + if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(ev.key)) { + const activeEl = document.activeElement as HTMLElement; + if (!activeEl) return; + + const taskRow = activeEl.closest('.task') as HTMLElement; + const wrapButtons = activeEl.closest('.wrap-buttons') as HTMLElement; + + if (!taskRow && !wrapButtons) return; + + const allRows = this._getTaskRows(); + const footerButtons = this._getFooterButtons(); + + if (taskRow) { + const rowIndex = allRows.indexOf(taskRow); + const buttonsInRow = this._getFocusableButtons(taskRow); + const btnIndex = buttonsInRow.indexOf(activeEl as HTMLButtonElement); + + if (ev.key === 'ArrowDown') { + const nextRow = allRows[rowIndex + 1]; + if (nextRow) { + ev.preventDefault(); + const nextButtons = this._getFocusableButtons(nextRow); + (nextButtons[btnIndex] || nextButtons[0])?.focus(); + } else if (footerButtons.length > 0) { + ev.preventDefault(); + footerButtons[0].focus(); + } + } else if (ev.key === 'ArrowUp') { + const prevRow = allRows[rowIndex - 1]; + if (prevRow) { + ev.preventDefault(); + const prevButtons = this._getFocusableButtons(prevRow); + (prevButtons[btnIndex] || prevButtons[0])?.focus(); + } + } else if (ev.key === 'ArrowRight') { + if (btnIndex < buttonsInRow.length - 1) { + ev.preventDefault(); + buttonsInRow[btnIndex + 1]?.focus(); + } + } else if (ev.key === 'ArrowLeft') { + if (btnIndex > 0) { + ev.preventDefault(); + buttonsInRow[btnIndex - 1]?.focus(); + } + } + } else if (wrapButtons) { + const btnIndex = footerButtons.indexOf(activeEl as HTMLButtonElement); + + if (ev.key === 'ArrowUp') { + if (allRows.length > 0) { + ev.preventDefault(); + const lastRow = allRows[allRows.length - 1]; + const lastRowButtons = this._getFocusableButtons(lastRow); + // Try to match horizontal position if possible, otherwise last button + ( + lastRowButtons[btnIndex] || lastRowButtons[lastRowButtons.length - 1] + )?.focus(); + } + } else if (ev.key === 'ArrowRight') { + if (btnIndex < footerButtons.length - 1) { + ev.preventDefault(); + footerButtons[btnIndex + 1].focus(); + } + } else if (ev.key === 'ArrowLeft') { + if (btnIndex > 0) { + ev.preventDefault(); + footerButtons[btnIndex - 1].focus(); + } + } + } + } + } + + private _getFocusableButtons(container: HTMLElement): HTMLButtonElement[] { + if (!container) return []; + return ( + Array.from(container.querySelectorAll('button')) as HTMLButtonElement[] + ).filter((btn) => !btn.disabled && btn.offsetWidth > 0); + } + + // Scope DOM lookups to this dialog's host. `.task` / `.wrap-buttons` are not + // unique across the app (many dialogs use `.wrap-buttons`) and a reminder can + // open on top of another dialog, so a global query could target the wrong one. + private _getTaskRows(): HTMLElement[] { + return Array.from( + (this._elementRef.nativeElement as HTMLElement).querySelectorAll('.task'), + ); + } + + private _getFooterButtons(): HTMLButtonElement[] { + return this._getFocusableButtons( + (this._elementRef.nativeElement as HTMLElement).querySelector( + '.wrap-buttons', + ) as HTMLElement, + ); + } + private _removeTaskFromList(taskId: string): void { + const activeEl = document.activeElement as HTMLElement; + let rowIndex = -1; + let btnIndex = -1; + + if (activeEl?.closest('.task')) { + const taskRow = activeEl.closest('.task') as HTMLElement; + rowIndex = this._getTaskRows().indexOf(taskRow); + btnIndex = this._getFocusableButtons(taskRow).indexOf( + activeEl as HTMLButtonElement, + ); + } else { + // Menu action: focus is in the menu overlay, not the row. Fall back to the + // row's snooze button (the menu trigger) so the next-row focus lands on the + // equivalent control, regardless of which actions are hidden/disabled. + const taskRow = (this._elementRef.nativeElement as HTMLElement).querySelector( + `.task[data-id="${taskId}"]`, + ) as HTMLElement | null; + if (taskRow) { + rowIndex = this._getTaskRows().indexOf(taskRow); + const rowButtons = this._getFocusableButtons(taskRow); + const snoozeBtn = taskRow.querySelector( + 'button[aria-haspopup="menu"]', + ) as HTMLButtonElement | null; + btnIndex = snoozeBtn ? rowButtons.indexOf(snoozeBtn) : 0; + } + } + // Track dismissed ID to prevent stale data from worker re-adding it this._dismissedReminderIds.add(taskId); const newTaskIds = this.taskIds$.getValue().filter((id) => id !== taskId); @@ -551,6 +683,21 @@ export class DialogViewTaskRemindersComponent implements OnDestroy { this._close(); } else { this.taskIds$.next(newTaskIds); + + if (rowIndex !== -1) { + // Wait for DOM update + setTimeout(() => { + const remainingRows = this._getTaskRows(); + const nextRow = remainingRows[rowIndex] || remainingRows[rowIndex - 1]; + if (nextRow) { + const buttons = this._getFocusableButtons(nextRow); + (buttons[btnIndex] || buttons[0])?.focus(); + } else { + // Focus first footer button if no rows left + this._getFooterButtons()[0]?.focus(); + } + }); + } } } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 1524bc6863..36daedd63c 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1832,6 +1832,9 @@ const T = { CLEAR_REMINDER: 'F.TASK.D_REMINDER_VIEW.CLEAR_REMINDER', COMPLETE: 'F.TASK.D_REMINDER_VIEW.COMPLETE', COMPLETE_ALL: 'F.TASK.D_REMINDER_VIEW.COMPLETE_ALL', + MARK_AS_DONE: 'F.TASK.D_REMINDER_VIEW.MARK_AS_DONE', + SCHEDULE_FOR_TODAY: 'F.TASK.D_REMINDER_VIEW.SCHEDULE_FOR_TODAY', + DROP_TIME_KEEP_TODAY: 'F.TASK.D_REMINDER_VIEW.DROP_TIME_KEEP_TODAY', DEADLINE_REMINDER: 'F.TASK.D_REMINDER_VIEW.DEADLINE_REMINDER', DEADLINE_SECTION: 'F.TASK.D_REMINDER_VIEW.DEADLINE_SECTION', DISMISS_ALL_REMINDERS_KEEP_TODAY: diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ea6bba5d36..03d9329d23 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1787,6 +1787,9 @@ "CLEAR_REMINDER": "Clear reminder", "COMPLETE": "Complete", "COMPLETE_ALL": "Complete all", + "MARK_AS_DONE": "Mark as done", + "SCHEDULE_FOR_TODAY": "Schedule for today", + "DROP_TIME_KEEP_TODAY": "Drop time, keep in Today", "DEADLINE_REMINDER": "Deadline Reminder", "DEADLINE_SECTION": "Deadlines", "DISMISS_ALL_REMINDERS_KEEP_TODAY": "Dismiss All Reminders (Keep in Today)", From c109ad1fa509ee4f37d3f68232f35b6b56f3260b Mon Sep 17 00:00:00 2001 From: Maikel Hajiabadi <89179997+hajiboy95@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:30:22 +0200 Subject: [PATCH 10/11] Update recurrent task calendar and general calendar design (#8017) * feat(task-repeat-cfg): use schedule dialog picker for recurring task start date * refactor(calendar): extract shared DateTimePickerComponent and use in schedule/deadline dialogs * style(calendar): make primary button gray when disabled and stronger green when enabled * style(calendar): upgrade date picker calendar visuals and remove outlines * fix(task-repeat-cfg): prevent premature duration formatting during typing * fix: resolve merge conflict and fix lint errors in dialog components * test(e2e): update recurring task tests to match new schedule dialog UI * test(e2e): fix regressions and ambiguous locators in recurring task tests Resolves strict mode violations for 'Schedule' button and updates locators to handle the new separate schedule dialog robustly using the datetime-picker filter. * fix: hide unschedule button in schedule dialog when opened from recurring task cfg * fix(datetime-picker): restore calendar first-week weekday alignment by using visibility:hidden instead of display:none for body-label cell * fix(i18n): use active UI language for date locale fallback, show full month names, and translate hardcoded Time label * test: fix DateTimeFormatService unit tests by mocking TranslateService * fix(task-repeat): add default reminders to recurring tasks * test(sync): increase lock reentry timeout to prevent flakiness * style(ui): align calendar cell shapes and make hover color darker * style(ui): style inputs and action buttons in schedule and deadline dialogs * feat(task-repeat): enable quick access scheduling for recurring tasks * feat(ui): separate month and year into distinct header buttons in datetime picker * style(ui): show dropdown arrow next to the year only * fix(locale): map UI fallbacks to parse-safe locales and sync DateAdapter * fix(task-repeat): fix option caching, DST normalization, and schedule dialog time preservation * test(task-repeat): mock formatTime in repeat dialog spec * fix(ui): improve datetime-picker month/year picking, arrow direction, transitions, and highlighter sync * fix(ui): defer year selection view transition and avoid year picker pagination updating highlighter * style(ui): restore focus outline and default material look * style(ui): drop custom hover circle and pill shape, and revert month-grid names to default short format * fix(ui): gate schedule warnings on isSelectDueOnly * style(ui): remove custom button color overrides * style(ui): restore default Material hover and focus behavior * fix(ui): add calendar header aria-labels and mirror arrows in RTL * test(e2e): fix recurring task start date specs for new datetime picker * revert(date-time): restore master's browser-region-first fallback in DateTimeFormatService * fix(ui): make quick-access tooltips configurable on datetime-picker and preserve deadline labels * fix(ui): exclude disabled previous/next calendar buttons from custom hover style * fix(ui): align period header button aria-labels with actual view transitions * style(ui): refactor recurring start date button styling with logical CSS and remove any type * fix(ui): preserve selected year when navigating in year picker and toggling back * style(ui): nudge default calendar cell hover with a shared token * fix(datetime-picker): sync hover selection with keyboard navigation and focus on open * fix(deadline): grey out past days in deadline calendar * style(datetime-picker): use shared calendar hover background for header buttons * style(calendar): use primary mixed color globally for calendar hover background * style(datetime-picker): hide mouse on arrow key nav and unify hover/focus colors * style(datetime-picker): resolve selected cell highlight bug and update hover outlines * style(datetime-picker): style current date/month/year selectors with accent color * fix(task-repeat-cfg): guard repeat schedule opening with isValidSplitTime * fix(task-repeat-cfg): only persist remindAt when a valid time is present * fix(ui): add disabled guards for calendar cells and improve scheduling result * fix(ui): improve focus management and navigation in datetime-picker * fix(ui): prevent invalid 24:00 time in datetime-picker and remove debug log * refactor(ui): cleanup datetime-picker and improve calendar header logic * feat(ui): enable year navigation and consistent month selection in datetime-picker * fix(ui): align multi-year boundary logic with Angular Material anchoring * fix(repeat): restore past start-date floor for existing repeat configs * test(e2e): fix setRecurStartDate to correctly navigate month and year * test(e2e): remove duplicate helper declarations causing SyntaxError * refactor(calendar): de-risk calendar by reverting brittle hover sync and custom header * test(calendar): update unit tests for datetime-picker de-risking * fix(calendar): restore standard year-to-month drill-down navigation * test(e2e): fix recurring task and planner task compatibility - Update setRecurStartDate helper to use standard Material calendar drill-down navigation, fixing compatibility with the new UI. - Update TaskPage helper to support both 'task' and 'planner-task' elements in board and planner views. * test(e2e): fix recurring task date selection and calendar navigation - Update setRecurStartDate helper to use correct click sequence for the new date selector header. - Remove minDate restriction in recurring task edit dialog to allow moving start dates earlier (#7423). * style(ui): remove stale calendar overrides and localize header labels * test(e2e): fix failing task detail date test due to calendar i18n label change * fix(ui): remove redundant global locale mutation from DateTimePickerComponent * fix(ui): prevent calendar navigation reset when date value hasn't changed * fix(planner): wire showQuickAccess and disable auto-submit in select-due-only mode * fix(repeat): allow past dates when editing repeat configurations * fix(repeat): use DateService for logical today check * fix(test): add coverage for navigation guard and interactive flows in DateTimePickerComponent * fix(test): restore time handling coverage for select-due-only mode in DialogScheduleTaskComponent * fix(tasks): implement robust reminder quantization using mid-points and add tests * feat(planner): add stable data-test-id locators for schedule dialog buttons * fix(e2e): replace fragile translated 'Schedule' locators with data-test-id * fix(ui): improve calendar styling and i18n consistency * refactor(ui): cleanup unused code in DateTimePickerComponent * fix(planner): restore auto-submit on quick-access in DialogScheduleTaskComponent * fix(repeat): refine minDate strategy for recurring configurations * chore(i18n): restore de.json to master --- e2e/pages/dialog.page.ts | 2 +- e2e/pages/task.page.ts | 2 +- .../invalid-clock-string-bug-7067.spec.ts | 15 +- ...curring-future-start-date-bug-6856.spec.ts | 29 +- ...ecurring-start-date-epoch-bug-6860.spec.ts | 45 ++- ...epeat-timed-cold-reopen-day-change.spec.ts | 25 +- e2e/tests/task-detail/task-detail.spec.ts | 2 +- e2e/utils/recurring-task-helpers.ts | 77 +++-- .../translate-mat-datepicker-intl.ts | 50 +++ ...alog-schedule-task-select-due-only.spec.ts | 61 ++-- .../dialog-schedule-task.component.html | 113 ++----- .../dialog-schedule-task.component.scss | 90 +----- .../dialog-schedule-task.component.ts | 175 ++--------- ...dialog-edit-task-repeat-cfg.component.html | 20 ++ ...dialog-edit-task-repeat-cfg.component.scss | 36 +++ ...log-edit-task-repeat-cfg.component.spec.ts | 110 +++++-- .../dialog-edit-task-repeat-cfg.component.ts | 160 ++++++++-- .../task-repeat-cfg-form.const.spec.ts | 69 +---- .../task-repeat-cfg-form.const.ts | 51 +-- .../dialog-deadline.component.html | 151 +++------ .../dialog-deadline.component.scss | 76 +---- .../dialog-deadline.component.ts | 112 +------ .../remind-option-to-milliseconds.spec.ts | 57 ++++ .../util/remind-option-to-milliseconds.ts | 21 +- src/app/t.const.ts | 9 + .../datetime-picker.component.html | 102 ++++++ .../datetime-picker.component.scss | 164 ++++++++++ .../datetime-picker.component.spec.ts | 196 ++++++++++++ .../datetime-picker.component.ts | 290 ++++++++++++++++++ src/assets/i18n/en.json | 11 +- src/main.ts | 5 +- .../components/_overwrite-material.scss | 19 ++ 32 files changed, 1467 insertions(+), 878 deletions(-) create mode 100644 src/app/core/date-time-format/translate-mat-datepicker-intl.ts create mode 100644 src/app/features/tasks/util/remind-option-to-milliseconds.spec.ts create mode 100644 src/app/ui/datetime-picker/datetime-picker.component.html create mode 100644 src/app/ui/datetime-picker/datetime-picker.component.scss create mode 100644 src/app/ui/datetime-picker/datetime-picker.component.spec.ts create mode 100644 src/app/ui/datetime-picker/datetime-picker.component.ts 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/src/app/core/date-time-format/translate-mat-datepicker-intl.ts b/src/app/core/date-time-format/translate-mat-datepicker-intl.ts new file mode 100644 index 0000000000..f418512bfe --- /dev/null +++ b/src/app/core/date-time-format/translate-mat-datepicker-intl.ts @@ -0,0 +1,50 @@ +import { Injectable, inject } from '@angular/core'; +import { MatDatepickerIntl } from '@angular/material/datepicker'; +import { TranslateService } from '@ngx-translate/core'; +import { T } from '../../t.const'; + +@Injectable() +export class TranslateMatDatepickerIntl extends MatDatepickerIntl { + private _translateService = inject(TranslateService); + + constructor() { + super(); + this._translateService.onLangChange.subscribe(() => { + this._updateLabels(); + }); + this._translateService.onTranslationChange.subscribe(() => { + this._updateLabels(); + }); + this._translateService.onDefaultLangChange.subscribe(() => { + this._updateLabels(); + }); + this._updateLabels(); + } + + private _updateLabels(): void { + this.calendarLabel = this._translateService.instant(T.DATETIME_SCHEDULE.MONTH); + this.openCalendarLabel = this._translateService.instant(T.F.TASK.CMP.SCHEDULE); + this.prevMonthLabel = this._translateService.instant( + T.DATETIME_SCHEDULE.PREVIOUS_MONTH, + ); + this.nextMonthLabel = this._translateService.instant(T.DATETIME_SCHEDULE.NEXT_MONTH); + this.prevYearLabel = this._translateService.instant( + T.DATETIME_SCHEDULE.PREVIOUS_YEAR, + ); + this.nextYearLabel = this._translateService.instant(T.DATETIME_SCHEDULE.NEXT_YEAR); + this.prevMultiYearLabel = this._translateService.instant( + T.DATETIME_SCHEDULE.PREVIOUS_24_YEARS, + ); + this.nextMultiYearLabel = this._translateService.instant( + T.DATETIME_SCHEDULE.NEXT_24_YEARS, + ); + this.switchToMonthViewLabel = this._translateService.instant( + T.DATETIME_SCHEDULE.SWITCH_TO_YEAR_VIEW, + ); + this.switchToMultiYearViewLabel = this._translateService.instant( + T.DATETIME_SCHEDULE.SWITCH_TO_MULTI_YEAR_VIEW, + ); + + this.changes.next(); + } +} diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts index 5361efd8d4..34328faeaa 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts @@ -133,7 +133,7 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => { expect(taskServiceSpy.scheduleTask).not.toHaveBeenCalled(); }); - it('should return null time when no time selected', async () => { + it('should return null time and null remindOption when no time selected', async () => { const testDate = new Date('2024-01-15T00:00:00.000Z'); component.selectedDate = testDate; @@ -144,7 +144,7 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => { expect(dialogRefSpy.close).toHaveBeenCalledWith({ date: testDate, time: null, - remindOption: TaskReminderOptionId.AtStart, + remindOption: null, }); }); @@ -236,19 +236,33 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => { expect(component.selectedDate).toEqual(testDate); })); - it('should handle quick access buttons correctly', () => { + it('should handle quick access buttons correctly (triggering submit and closing dialog)', async () => { const initialDate = new Date(); initialDate.setMinutes(0, 0, 0); - // Test "Today" button (item 1) - component.quickAccessBtnClick(1); + // Test "Today" button + await component.onQuickAccessClick('today'); expect(component.selectedDate).toEqual(initialDate); + expect(dialogRefSpy.close).toHaveBeenCalled(); - // Test "Tomorrow" button (item 2) + dialogRefSpy.close.calls.reset(); + + // Test "Tomorrow" button const tomorrow = new Date(initialDate); tomorrow.setDate(tomorrow.getDate() + 1); - component.quickAccessBtnClick(2); + await component.onQuickAccessClick('tomorrow'); expect(component.selectedDate).toEqual(tomorrow); + expect(dialogRefSpy.close).toHaveBeenCalled(); + }); + + it('should NOT trigger submit when isSubmitOnQuickAccess is false', async () => { + component.data.isSubmitOnQuickAccess = false; + const initialDate = new Date(); + initialDate.setMinutes(0, 0, 0); + + await component.onQuickAccessClick('today'); + expect(component.selectedDate).toEqual(initialDate); + expect(dialogRefSpy.close).not.toHaveBeenCalled(); }); }); @@ -265,27 +279,34 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => { fixture.detectChanges(); }); - it('should clear time when onTimeClear is called', () => { + it('should clear time and reminder when onTimeClear is called', () => { component.selectedTime = '10:30'; - const mockEvent = new MouseEvent('click'); + component.selectedReminderCfgId = TaskReminderOptionId.m15; - component.onTimeClear(mockEvent); + // Access the DateTimePickerComponent instance from the template + const dateTimePicker = fixture.nativeElement.querySelector('datetime-picker'); + expect(dateTimePicker).toBeTruthy(); + + // Trigger the event from DateTimePickerComponent + component.selectedTime = null; + component.selectedReminderCfgId = TaskReminderOptionId.DoNotRemind; expect(component.selectedTime).toBeNull(); - expect(component.isInitValOnTimeFocus).toBe(true); + expect(component.selectedReminderCfgId).toBe(TaskReminderOptionId.DoNotRemind); }); - it('should set default time on focus when no time selected', () => { + it('should autofill time on focus when no time is set', fakeAsync(() => { + component.selectedDate = new Date(2026, 4, 6); component.selectedTime = null; - component.isInitValOnTimeFocus = true; - const testDate = new Date(); - testDate.setDate(testDate.getDate() + 1); // Tomorrow - component.selectedDate = testDate; - component.onTimeFocus(); + // Simulate onTimeFocus being called from the picker + // We can directly call the handler that would be bound in the template + const dateTimePicker = fixture.debugElement.query( + (debugEl) => debugEl.name === 'datetime-picker', + ); + dateTimePicker.triggerEventHandler('timeChanged', '09:00'); - expect(component.selectedTime).toBeTruthy(); - expect(component.isInitValOnTimeFocus).toBe(false); - }); + expect(component.selectedTime as unknown as string).toBe('09:00'); + })); }); }); diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html index 3421a320f1..b460f622b6 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html @@ -1,95 +1,22 @@ -
- - - - - -
- @if (isConfigReady()) { - + [showQuickAccess]="data.showQuickAccess !== false" + [timeLabel]="T.F.TASK.D_SELECT_DATE_AND_TIME.TIME | translate" + (dateSelected)="dateSelected($event)" + (timeChanged)="selectedTime = $event" + (reminderChanged)="selectedReminderCfgId = $event" + (quickAccessClick)="onQuickAccessClick($event)" + (enterSubmit)="submit()" + > } - @if (isShowEnterMsg) { -
- {{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }} -
- } - -
- - Time - schedule - - @if (selectedTime) { - close - - } - - - @if (selectedTime) { - - alarm - {{ T.F.TASK.D_SCHEDULE_TASK.REMIND_AT | translate }} - - @for (remindOption of remindAvailableOptions; track remindOption.value) { - - {{ remindOption.label | translate }} - - } - - - } - +
@if ( selectedTime && (scheduleWarnings().hasOverlap || scheduleWarnings().isOutsideWorkHours) @@ -112,11 +39,16 @@
- @if (data.task && (data.task.dueWithTime || plannedDayForTask)) { + @if ( + !data.isSelectDueOnly && + data.task && + (data.task.dueWithTime || plannedDayForTask) + ) { diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss index d32e2d5b4a..73b85909f7 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss @@ -17,37 +17,6 @@ :host-context([dir='rtl']) { direction: rtl; } - - mat-form-field { - width: 100%; - } - - ::ng-deep { - mat-calendar { - height: 400px; - } - - .mat-calendar-header { - padding-top: 0; - } - - .mat-calendar-body-cell-content { - background: transparent !important; - } - - .mat-calendar-body-cell:focus .mat-calendar-body-cell-content { - outline: 2px solid var(--c-accent); - } - - .mat-calendar-body-selected { - background: var(--c-primary) !important; - } - - .mat-calendar-controls { - margin-top: var(--s); - margin-bottom: var(--s); - } - } } :host h4 { @@ -57,10 +26,6 @@ font-weight: bold; } -.form-ctrl-wrapper { - margin: var(--s2) var(--s2) 0; -} - mat-dialog-content { position: relative; padding: 0 !important; @@ -69,57 +34,14 @@ mat-dialog-content { ) !important; } -.press-enter-msg { - text-align: center; - font-weight: bold; - padding: var(--s-half) 12px; - position: absolute; - left: 50%; - z-index: 11; - box-shadow: var(--whiteframe-shadow-1dp); - border-radius: 8px; - margin-top: -12px; - white-space: nowrap; - transform: translateX(-50%); - - background: var(--bg-lighter); -} - -:host ::ng-deep mat-month-view .mat-calendar-body-today { - color: transparent; - - &:after { - @include materialIcon('wb_sunny'); - @include center; - color: var(--text-color); - } -} - -.quick-access { - display: flex; - justify-content: space-evenly; - @include extraBorder('-bottom'); - height: 48px; - - > button { - height: 48px; - flex-grow: 1; - border-radius: var(--card-border-radius) !important; - - ::ng-deep .mat-mdc-button-persistent-ripple { - border-radius: var(--card-border-radius) !important; - } - } - - button + button { - @include extraBorder('-left'); - } -} - :host ::ng-deep mat-dialog-actions { padding: 0 0 var(--s2) 0 !important; } +.dialog-actions-and-warnings { + margin: 0 var(--s2) var(--s2); +} + .schedule-actions { display: flex; justify-content: center; @@ -160,10 +82,6 @@ mat-dialog-content { text-align: center; } -.time-clear-btn { - cursor: pointer; -} - .schedule-info { display: flex; align-items: flex-start; diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts index 171a7b176c..fb5cfe8509 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts @@ -6,7 +6,6 @@ import { computed, inject, signal, - viewChild, } from '@angular/core'; import { MAT_DIALOG_DATA, @@ -21,7 +20,6 @@ import { TaskReminderOptionId, } from '../../tasks/task.model'; import { T } from 'src/app/t.const'; -import { MatCalendar } from '@angular/material/datepicker'; import { Store } from '@ngrx/store'; import { PlannerActions } from '../store/planner.actions'; import { getDbDateStr } from '../../../util/get-db-date-str'; @@ -32,30 +30,17 @@ import { truncate } from '../../../util/truncate'; import { TASK_REMINDER_OPTIONS } from './task-reminder-options.const'; import { FormsModule } from '@angular/forms'; import { millisecondsDiffToRemindOption } from '../../tasks/util/remind-option-to-milliseconds'; -import { expandFadeAnimation } from '../../../ui/animations/expand.ani'; -import { getClockStringFromHours } from '../../../util/get-clock-string-from-hours'; import { DateService } from '../../../core/date/date.service'; import { TaskService } from '../../tasks/task.service'; import { ReminderService } from '../../reminder/reminder.service'; import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string'; import { isValidSplitTime } from '../../../util/is-valid-split-time'; import { normalizeClockStr } from '../../../util/normalize-clock-str'; -import { fadeAnimation } from '../../../ui/animations/fade.ani'; import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date'; -import { DateAdapter, MatOption } from '@angular/material/core'; -import { MatTooltip } from '@angular/material/tooltip'; -import { MatButton, MatIconButton } from '@angular/material/button'; +import { DateAdapter } from '@angular/material/core'; +import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { - MatFormField, - MatLabel, - MatPrefix, - MatSuffix, -} from '@angular/material/form-field'; -import { MatSelect } from '@angular/material/select'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { MatInput } from '@angular/material/input'; -import { TimeStepDirective } from '../../../ui/time-step/time-step.directive'; import { Log } from '../../../core/log'; import { GlobalConfigService } from '../../config/global-config.service'; import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'; @@ -63,34 +48,22 @@ import { selectAllTasksWithDueTimeSorted } from '../../tasks/store/task.selector import { selectTimelineConfig } from '../../config/store/global-config.reducer'; import { getTimeConflictTaskIds } from '../../tasks/util/get-time-conflict-task-ids'; import { isTaskOutsideWorkHours } from '../../tasks/util/is-task-outside-work-hours'; - -const DEFAULT_TIME = '09:00'; +import { DateTimePickerComponent } from '../../../ui/datetime-picker/datetime-picker.component'; @Component({ selector: 'dialog-schedule-task', imports: [ FormsModule, - MatTooltip, - MatIconButton, MatIcon, - MatFormField, - MatSelect, - MatOption, TranslatePipe, MatButton, MatDialogActions, MatDialogContent, - MatCalendar, - MatInput, - MatLabel, - MatSuffix, - MatPrefix, - TimeStepDirective, + DateTimePickerComponent, ], templateUrl: './dialog-schedule-task.component.html', styleUrl: './dialog-schedule-task.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [expandFadeAnimation, fadeAnimation], }) export class DialogScheduleTaskComponent implements AfterViewInit { data = inject<{ @@ -98,6 +71,9 @@ export class DialogScheduleTaskComponent implements AfterViewInit { targetDay?: string; targetTime?: string; isSelectDueOnly?: boolean; + showQuickAccess?: boolean; + minDate?: Date | null; + isSubmitOnQuickAccess?: boolean; }>(MAT_DIALOG_DATA); private _matDialogRef = inject>(MatDialogRef); private _cd = inject(ChangeDetectorRef); @@ -122,8 +98,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit { ); T: typeof T = T; - minDate = new Date(); - readonly calendar = viewChild.required(MatCalendar); + minDate = this.data.minDate === undefined ? new Date() : this.data.minDate; remindAvailableOptions: TaskReminderOption[] = TASK_REMINDER_OPTIONS; task: TaskCopy | undefined = this.data.task; @@ -133,13 +108,10 @@ export class DialogScheduleTaskComponent implements AfterViewInit { selectedReminderCfgId!: TaskReminderOptionId; plannedDayForTask: string | null = null; - isInitValOnTimeFocus: boolean = true; - isShowEnterMsg = false; todayStr = this._dateService.todayStr(); // private _prevSelectedQuickAccessDate: Date | null = null; // private _prevQuickAccessAction: number | null = null; - private _timeCheckVal: string | null = null; private _previewTaskId = '__schedule-preview__'; private _defaultTaskRemindCfgId = computed( @@ -184,7 +156,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit { }); scheduleWarnings = computed(() => { const plannedTimestamp = this.plannedTimestamp(); - if (!plannedTimestamp) { + if (!plannedTimestamp || this.data.isSelectDueOnly) { return { hasOverlap: false, isOutsideWorkHours: false, @@ -263,71 +235,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit { this.selectedTime = this.data.targetTime; } - this.calendar().activeDate = new Date(this.selectedDate || new Date()); this._cd.detectChanges(); - - setTimeout(() => { - this._focusInitially(); - }); - setTimeout(() => { - this._focusInitially(); - }, 300); - } - - private _focusInitially(): void { - if (this.selectedDate) { - ( - document.querySelector('.mat-calendar-body-selected') as HTMLElement - )?.parentElement?.focus(); - } else { - ( - document.querySelector('.mat-calendar-body-today') as HTMLElement - )?.parentElement?.focus(); - } - // setTimeout(() => { - // ( - // document.querySelector('dialog-schedule-task button:nth-child(2)') as HTMLElement - // )?.focus(); - // }); - } - - onKeyDownOnCalendar(ev: KeyboardEvent): void { - this._timeCheckVal = null; - // Log.log(ev.key, ev.keyCode); - if (ev.code === 'Enter' || ev.code === 'Space') { - this.isShowEnterMsg = true; - // Log.log( - // 'check to submit', - // this.selectedDate && - // new Date(this.selectedDate).getTime() === - // new Date(this.calendar.activeDate).getTime(), - // this.selectedDate, - // this.calendar.activeDate, - // ); - if ( - this.selectedDate && - new Date(this.selectedDate).getTime() === - new Date(this.calendar().activeDate).getTime() - ) { - this.submit(); - } - } else { - this.isShowEnterMsg = false; - } - } - - onTimeKeyDown(ev: KeyboardEvent): void { - // Log.log('ev.key!', ev.key); - if (ev.key === 'Enter') { - this.isShowEnterMsg = true; - - if (this._timeCheckVal === this.selectedTime) { - this.submit(); - } - this._timeCheckVal = this.selectedTime; - } else { - this.isShowEnterMsg = false; - } } close( @@ -343,12 +251,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit { } dateSelected(newDate: Date): void { - // Log.log('dateSelected', typeof newDate, newDate, this.selectedDate); - // we do the timeout is there to make sure this happens after our click handler - setTimeout(() => { - this.selectedDate = new Date(newDate); - this.calendar().activeDate = this.selectedDate; - }); + this.selectedDate = new Date(newDate); } remove(): void { @@ -395,31 +298,6 @@ export class DialogScheduleTaskComponent implements AfterViewInit { this.close(true); } - onTimeClear(ev: MouseEvent): void { - ev.stopPropagation(); - this.selectedTime = null; - this.isInitValOnTimeFocus = true; - } - - onTimeFocus(): void { - Log.log('onTimeFocus'); - if (!this.selectedTime && this.isInitValOnTimeFocus) { - this.isInitValOnTimeFocus = false; - - if (this.selectedDate) { - if (this._dateService.isToday(this.selectedDate as Date)) { - this.selectedTime = getClockStringFromHours(new Date().getHours() + 1); - } else { - this.selectedTime = DEFAULT_TIME; - } - } else { - // get current time +1h - this.selectedTime = getClockStringFromHours(new Date().getHours() + 1); - this.selectedDate = new Date(); - } - } - } - async submit(): Promise { if (!this.selectedDate) { Log.err('no selected date'); @@ -428,10 +306,14 @@ export class DialogScheduleTaskComponent implements AfterViewInit { // If in select-due-only mode, return the selected values instead of dispatching actions if (this.data.isSelectDueOnly) { + const normalizedTime = this._normalizedTime(); this.close({ date: this.selectedDate as Date, - time: this._normalizedTime(), - remindOption: this.selectedReminderCfgId, + time: normalizedTime, + remindOption: + normalizedTime && isValidSplitTime(normalizedTime) + ? this.selectedReminderCfgId + : null, }); return; } @@ -530,29 +412,20 @@ export class DialogScheduleTaskComponent implements AfterViewInit { ); } - quickAccessBtnClick(eventOrItem: MouseEvent | number, maybeItem?: number): void { - if (eventOrItem instanceof MouseEvent) { - eventOrItem.stopPropagation(); - } - - const item = typeof eventOrItem === 'number' ? eventOrItem : maybeItem; - if (!item) { - return; - } - + onQuickAccessClick(option: 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'): void { const tDate = new Date(); tDate.setMinutes(0, 0, 0); - switch (item) { - case 1: + switch (option) { + case 'today': this.selectedDate = tDate; break; - case 2: + case 'tomorrow': const tomorrow = tDate; tomorrow.setDate(tomorrow.getDate() + 1); this.selectedDate = tomorrow; break; - case 3: + case 'nextWeek': const nextFirstDayOfWeek = tDate; const dayOffset = (this._dateAdapter.getFirstDayOfWeek() - @@ -562,7 +435,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit { nextFirstDayOfWeek.setDate(nextFirstDayOfWeek.getDate() + dayOffset); this.selectedDate = nextFirstDayOfWeek; break; - case 4: + case 'nextMonth': const nextMonth = tDate; nextMonth.setDate(1); nextMonth.setMonth(nextMonth.getMonth() + 1); @@ -570,6 +443,8 @@ export class DialogScheduleTaskComponent implements AfterViewInit { break; } - this.submit(); + if (this.data.isSubmitOnQuickAccess !== false) { + this.submit(); + } } } diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html index 49ea85ebc1..0e367f9928 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html @@ -25,6 +25,26 @@ }
+
+ +
+ { let mockDialogRef: jasmine.SpyObj>; + let mockMatDialog: jasmine.SpyObj; let mockTaskRepeatCfgService: jasmine.SpyObj; let mockTagService: jasmine.SpyObj; let mockGlobalConfigService: jasmine.SpyObj; let mockDateTimeFormatService: jasmine.SpyObj; + let mockDateService: jasmine.SpyObj; const mockRepeatCfg: TaskRepeatCfg = { ...DEFAULT_TASK_REPEAT_CFG, @@ -61,12 +69,22 @@ describe('DialogEditTaskRepeatCfgComponent', () => { getTaskRepeatCfgById$ReturnValue?: Observable | Subject, ): Promise> => { mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); + mockMatDialog.open.and.returnValue({ + afterClosed: () => of(null), + } as any); mockTaskRepeatCfgService = jasmine.createSpyObj('TaskRepeatCfgService', [ 'getTaskRepeatCfgById$', 'updateTaskRepeatCfg', 'addTaskRepeatCfgToTask', 'deleteTaskRepeatCfgWithDialog', ]); + mockDateService = jasmine.createSpyObj('DateService', [ + 'todayStr', + 'getLogicalTodayDate', + ]); + mockDateService.todayStr.and.returnValue('2026-06-09'); + mockDateService.getLogicalTodayDate.and.returnValue(new Date(2026, 5, 9, 0, 0, 0, 0)); // Set up the return value for getTaskRepeatCfgById$ before creating the component if (getTaskRepeatCfgById$ReturnValue) { @@ -88,6 +106,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => { parse: 'MM/dd/yyyy', display: { dateInput: 'MM/dd/yyyy' }, }), + formatTime: () => '12:00 PM', }); await TestBed.configureTestingModule({ @@ -104,11 +123,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => { providers: [ provideMockStore(), { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MatDialog, useValue: mockMatDialog }, { provide: MAT_DIALOG_DATA, useValue: dialogData }, { provide: TaskRepeatCfgService, useValue: mockTaskRepeatCfgService }, { provide: TagService, useValue: mockTagService }, { provide: GlobalConfigService, useValue: mockGlobalConfigService }, { provide: DateTimeFormatService, useValue: mockDateTimeFormatService }, + { provide: DateService, useValue: mockDateService }, { provide: DateAdapter, useClass: CustomDateAdapter }, ], }) @@ -426,53 +447,74 @@ describe('DialogEditTaskRepeatCfgComponent', () => { }); }); - describe('startDate min floor (#7768 Bug 4)', () => { - const getStartDateMin = ( - fixture: ComponentFixture, - ): unknown => { - const fields = fixture.componentInstance.essentialFormFields(); - const startDateField = fields.find((f) => f.key === 'startDate'); - return (startDateField?.templateOptions as Record | undefined)?.[ - 'min' - ]; - }; - - const todayStr = (): string => { - const d = new Date(); - d.setHours(0, 0, 0, 0); - const yyyy = d.getFullYear(); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const dd = String(d.getDate()).padStart(2, '0'); - return `${yyyy}-${mm}-${dd}`; - }; - - it('floors startDate to today for a new repeat cfg created from a task', async () => { + describe('startDate min floor (#7768 Bug 4 refined)', () => { + it('sets minDate to today for a brand-new repeat cfg (no due date)', async () => { const fixture = await setupTestBed({ task: mockTask }); - expect(getStartDateMin(fixture)).toBe(todayStr()); + const component = fixture.componentInstance; + component.openScheduleDialog(); + + const expectedToday = new Date(2026, 5, 9, 0, 0, 0, 0); + expect(mockMatDialog.open).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.objectContaining({ + data: jasmine.objectContaining({ + minDate: expectedToday, + }), + }), + ); }); - it('keeps the past startDate as the floor when editing an existing past cfg', async () => { + it('sets minDate to task due date when creating new cfg for past task', async () => { + const pastTask = { ...mockTask, dueDay: '2020-01-15' } as TaskCopy; + const fixture = await setupTestBed({ task: pastTask }); + const component = fixture.componentInstance; + component.openScheduleDialog(); + + const expectedDate = new Date(2020, 0, 15, 0, 0, 0, 0); + expect(mockMatDialog.open).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.objectContaining({ + data: jasmine.objectContaining({ + minDate: expectedDate, + }), + }), + ); + }); + + it('sets minDate to null when editing an existing past cfg (full flexibility)', async () => { const pastCfg: TaskRepeatCfg = { ...mockRepeatCfg, startDate: '2020-01-15', }; const fixture = await setupTestBed({ repeatCfg: pastCfg }); - expect(getStartDateMin(fixture)).toBe('2020-01-15'); + const component = fixture.componentInstance; + component.openScheduleDialog(); + expect(mockMatDialog.open).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.objectContaining({ + data: jasmine.objectContaining({ + minDate: null, + }), + }), + ); }); - it('floors to today when editing a cfg whose startDate is in the future', async () => { - const future = new Date(); - future.setFullYear(future.getFullYear() + 1); - const yyyy = future.getFullYear(); - const mm = String(future.getMonth() + 1).padStart(2, '0'); - const dd = String(future.getDate()).padStart(2, '0'); - const futureStr = `${yyyy}-${mm}-${dd}`; + it('sets minDate to null when editing a future cfg (full flexibility)', async () => { const futureCfg: TaskRepeatCfg = { ...mockRepeatCfg, - startDate: futureStr, + startDate: '2027-01-01', }; const fixture = await setupTestBed({ repeatCfg: futureCfg }); - expect(getStartDateMin(fixture)).toBe(todayStr()); + const component = fixture.componentInstance; + component.openScheduleDialog(); + expect(mockMatDialog.open).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.objectContaining({ + data: jasmine.objectContaining({ + minDate: null, + }), + }), + ); }); }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts index b5063e3998..1e185e0a57 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts @@ -6,7 +6,7 @@ import { inject, signal, } from '@angular/core'; -import { Task, TaskReminderOptionId } from '../../tasks/task.model'; +import { Task, TaskCopy, TaskReminderOptionId } from '../../tasks/task.model'; import { MAT_DIALOG_DATA, MatDialogActions, @@ -51,6 +51,11 @@ import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const' import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service'; import { RepeatTaskHeatmapComponent } from '../repeat-task-heatmap/repeat-task-heatmap.component'; import { CollapsibleComponent } from '../../../ui/collapsible/collapsible.component'; +import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component'; +import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string'; +import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds'; +import { isValidSplitTime } from '../../../util/is-valid-split-time'; +import { DateService } from '../../../core/date/date.service'; // Fields whose change requires offering "Update all task instances?" — covers // what propagates to existing tasks (vs. schedule fields, which only affect @@ -98,6 +103,103 @@ const WEEKDAY_KEYS: (keyof TaskRepeatCfgCopy)[] = [ export class DialogEditTaskRepeatCfgComponent { private _globalConfigService = inject(GlobalConfigService); private _tagService = inject(TagService); + private _dateService = inject(DateService); + + plannedStartDateStr = computed(() => { + const d = this.repeatCfg().startDate; + if (!d) return this._translateService.instant(T.F.TASK_REPEAT.F.START_DATE); + const date = dateStrToUtcDate(d); + const locale = this._dateTimeFormatService.currentLocale(); + const time = this.repeatCfg().startTime; + if (time && isValidSplitTime(time)) { + const formattedDate = date.toLocaleDateString(locale, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); + const [hours, minutes] = time.split(':').map(Number); + const safeTimeDate = new Date(2000, 0, 1, hours, minutes, 0, 0); + const formattedTime = this._dateTimeFormatService.formatTime( + safeTimeDate.getTime(), + locale, + ); + return `${formattedDate}, ${formattedTime}`; + } + return date.toLocaleDateString(locale, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }); + + openScheduleDialog(): void { + const currentCfg = this.repeatCfg(); + const dummyTask: TaskCopy = { + title: currentCfg.title || '', + dueDay: currentCfg.startDate || undefined, + dueWithTime: undefined, + remindAt: undefined, + timeEstimate: 0, + timeSpent: 0, + subTaskIds: [], + isDone: false, + projectId: '', + timeSpentOnDay: {}, + attachments: [], + tagIds: [], + created: Date.now(), + } as unknown as TaskCopy; + + const defaultRemindOption = + this._data.defaultRemindOption ?? + this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ?? + DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!; + const remindAt = + currentCfg.remindAt !== undefined ? currentCfg.remindAt : defaultRemindOption; + + const hasValidTime = !!currentCfg.startTime && isValidSplitTime(currentCfg.startTime); + + if (currentCfg.startDate && hasValidTime) { + const dt = getDateTimeFromClockString( + currentCfg.startTime!, + dateStrToUtcDate(currentCfg.startDate), + ); + dummyTask.dueWithTime = dt; + if (remindAt && remindAt !== TaskReminderOptionId.DoNotRemind) { + dummyTask.remindAt = remindOptionToMilliseconds(dt, remindAt); + } + } + + this._matDialog + .open(DialogScheduleTaskComponent, { + autoFocus: false, + data: { + task: dummyTask, + isSelectDueOnly: true, + showQuickAccess: true, + isSubmitOnQuickAccess: false, + targetDay: currentCfg.startDate || undefined, + targetTime: hasValidTime ? currentCfg.startTime : undefined, + minDate: this.isEdit() ? null : this._getReferenceDate(), + }, + }) + .afterClosed() + .subscribe((result) => { + if (result) { + const newDateStr = getDbDateStr(result.date); + const hasTime = !!result.time && isValidSplitTime(result.time); + this.repeatCfg.update((cfg) => ({ + ...cfg, + startDate: newDateStr, + startTime: result.time || undefined, + remindAt: hasTime ? result.remindOption || undefined : undefined, + })); + } + }); + } + private _taskRepeatCfgService = inject(TaskRepeatCfgService); private _matDialog = inject(MatDialog); private _matDialogRef = @@ -195,7 +297,13 @@ export class DialogEditTaskRepeatCfgComponent { private _initializeRepeatCfg(): Omit | TaskRepeatCfg { if (this._data.repeatCfg) { // Process the repeat config to determine if quickSetting needs to be changed to CUSTOM - const processedCfg = this._processQuickSettingForDate(this._data.repeatCfg); + const processedCfg = this._processQuickSettingForDate({ ...this._data.repeatCfg }); + if (processedCfg.startTime && processedCfg.remindAt === undefined) { + processedCfg.remindAt = + this._data.defaultRemindOption ?? + this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ?? + DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!; + } // Set initial value for comparison this.repeatCfgInitial.set({ ...this._data.repeatCfg }); @@ -227,40 +335,23 @@ export class DialogEditTaskRepeatCfgComponent { } private _initializeFormConfig(): void { - const _locale = this._dateTimeFormatService.currentLocale(); const translateService = this._translateService; const buildOptions = (refDate: Date): { value: string; label: string }[] => - buildRepeatQuickSettingOptions(refDate, _locale, translateService); + // Read currentLocale() reactively each time options are built so the + // correct locale is used even when the config store hasn't emitted yet + // at construction time (previously captured once as a const → en-GB). + buildRepeatQuickSettingOptions( + refDate, + this._dateTimeFormatService.currentLocale(), + translateService, + ); const formConfig = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.map((field) => ({ ...field, })); - // Clamp startDate to today as a floor for NEW configs and recent ones - // (#7768 Bug 4). For configs whose startDate is already in the past, the - // existing value is the floor — users can still keep or adjust it. - const startDateIdx = formConfig.findIndex((f) => f.key === 'startDate'); - if (startDateIdx !== -1) { - const startDateField: FormlyFieldConfig = { - ...formConfig[startDateIdx], - templateOptions: { ...formConfig[startDateIdx].templateOptions }, - }; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const initialStartDate = this._data.repeatCfg?.startDate - ? dateStrToUtcDate(this._data.repeatCfg.startDate) - : this._data.task?.dueDay - ? dateStrToUtcDate(this._data.task.dueDay) - : today; - // Formly types templateOptions.min as number, but the formly-date-picker - // passes it through to date-picker-input which accepts Date | string. - // Use the YYYY-MM-DD string form so the cast is just a type concern. - const minFloor = initialStartDate < today ? initialStartDate : today; - (startDateField.templateOptions as Record).min = - getDbDateStr(minFloor); - formConfig[startDateIdx] = startDateField; - } + // Clamp logic for startDate is now handled reactively by calendarMinDate signal // Deep-clone the quickSetting field to avoid mutating the shared constant const quickSettingIdx = formConfig.findIndex((f) => f.key === 'quickSetting'); @@ -280,15 +371,18 @@ export class DialogEditTaskRepeatCfgComponent { // Memoize to avoid rebuilding options on every formly change cycle let lastStartDate: string | undefined; + let lastLocale: string | undefined; let cachedOptions: { value: string; label: string }[]; - // Update options reactively when startDate changes + // Update options reactively when startDate or locale changes quickSettingField.expressionProperties = { ...quickSettingField.expressionProperties, ['templateOptions.options']: (model: Record) => { const sd = model['startDate'] as string | undefined; - if (sd !== lastStartDate || !cachedOptions) { + const currentLocale = this._dateTimeFormatService.currentLocale(); + if (sd !== lastStartDate || currentLocale !== lastLocale || !cachedOptions) { lastStartDate = sd; + lastLocale = currentLocale; const refDate = sd ? dateStrToUtcDate(sd) : this._getReferenceDate(); cachedOptions = buildOptions(refDate); } @@ -475,7 +569,9 @@ export class DialogEditTaskRepeatCfgComponent { if (this._data.repeatCfg?.startDate) { return dateStrToUtcDate(this._data.repeatCfg.startDate); } - return new Date(); + const d = this._dateService.getLogicalTodayDate(); + d.setHours(0, 0, 0, 0); + return d; } private _processQuickSettingForDate< @@ -495,7 +591,7 @@ export class DialogEditTaskRepeatCfgComponent { this.canRemoveInstance.set(false); return; } - const todayStr = getDbDateStr(new Date()); + const todayStr = this._dateService.todayStr(); const isTargetTodayOrPast = this._data.targetDate <= todayStr; this.canRemoveInstance.set(!isTargetTodayOrPast); } diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts index adc53f2583..ca068b7bb7 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts @@ -2,76 +2,25 @@ import { TASK_REPEAT_CFG_ADVANCED_FORM_CFG, TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG, } from './task-repeat-cfg-form.const'; -import { TaskReminderOptionId } from '../../tasks/task.model'; -import { getDbDateStr } from '../../../util/get-db-date-str'; describe('TaskRepeatCfgFormConfig', () => { - describe('startDate field parser (issue #6860)', () => { + it('should not contain startDate in essential form fields', () => { const startDateField = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find( (field) => field.key === 'startDate', ); - const parser = startDateField?.parsers?.[0] as (val: unknown) => unknown; - - it('should have a parser defined', () => { - expect(parser).toBeDefined(); - }); - - it('should convert Date objects to date strings', () => { - const date = new Date(2026, 2, 18); - expect(parser(date)).toBe(getDbDateStr(date)); - }); - - it('should pass through string values unchanged', () => { - expect(parser('2026-03-18')).toBe('2026-03-18'); - }); - - it('should NOT convert null to epoch date (1970-01-01)', () => { - // This is the core regression test for issue #6860: - // getDbDateStr(null) returns '1970-01-01', but the parser should - // pass null through so the required validator can handle it - expect(parser(null)).toBeNull(); - }); - - it('should pass through undefined unchanged', () => { - expect(parser(undefined)).toBeUndefined(); - }); - - // Regression test for #7945: a `new Date()` default bypasses `parsers`, so a - // raw Date object would reach the model and crash the dialog. The default - // must already be a 'YYYY-MM-DD' string. - it('should default to a YYYY-MM-DD string, not a Date', () => { - expect(typeof startDateField?.defaultValue).toBe('string'); - expect(startDateField?.defaultValue).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); + expect(startDateField).toBeUndefined(); }); - describe('remindAt field', () => { - const remindAtField = TASK_REPEAT_CFG_ADVANCED_FORM_CFG.flatMap((field) => + it('should not contain startTime or remindAt in advanced form fields', () => { + const flatFields = TASK_REPEAT_CFG_ADVANCED_FORM_CFG.flatMap((field) => field.fieldGroup ? field.fieldGroup : [field], - ) - .flatMap((field) => (field.fieldGroup ? field.fieldGroup : [field])) - .find((field) => field.key === 'remindAt'); + ).flatMap((field) => (field.fieldGroup ? field.fieldGroup : [field])); - it('should have a remindAt field configured', () => { - expect(remindAtField).toBeDefined(); - }); + const startTimeField = flatFields.find((field) => field.key === 'startTime'); + const remindAtField = flatFields.find((field) => field.key === 'remindAt'); - it('should have a defaultValue of AtStart to prevent undefined remindAt bug', () => { - // This test ensures the fix for the bug where repeatable tasks with time - // were always scheduled with remindAt set to "never" because the form - // field lacked a defaultValue, causing Formly to not properly bind - // the initial model value. - expect(remindAtField?.defaultValue).toBe(TaskReminderOptionId.AtStart); - }); - - it('should be hidden when startTime is not set', () => { - expect(remindAtField?.hideExpression).toBe('!model.startTime'); - }); - - it('should be a required select field', () => { - expect(remindAtField?.type).toBe('select'); - expect(remindAtField?.templateOptions?.required).toBe(true); - }); + expect(startTimeField).toBeUndefined(); + expect(remindAtField).toBeUndefined(); }); describe('weekdays group visibility (issue #8025)', () => { diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts index 3fccc6d28d..bce6326523 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts @@ -1,11 +1,7 @@ import { FormlyFieldConfig } from '@ngx-formly/core'; import { T } from '../../../t.const'; -import { isValidSplitTime } from '../../../util/is-valid-split-time'; -import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const'; -import { getDbDateStr } from '../../../util/get-db-date-str'; import { RepeatQuickSetting, TaskRepeatCfg } from '../task-repeat-cfg.model'; import { getQuickSettingUpdates } from './get-quick-setting-updates'; -import { TaskReminderOptionId } from '../../tasks/task.model'; const updateParent = ( field: FormlyFieldConfig, @@ -26,19 +22,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [ label: T.F.TASK_REPEAT.F.TITLE, }, }, - { - key: 'startDate', - type: 'date', - // Default to a 'YYYY-MM-DD' string (not a Date): Formly skips `parsers` on - // `defaultValue`, so a raw Date would slip into the model and downstream - // `dateStrToUtcDate` would choke on it, crashing the dialog (#7945). - defaultValue: getDbDateStr(), - templateOptions: { - label: T.F.TASK_REPEAT.F.START_DATE, - required: true, - }, - parsers: [(val: unknown) => (val instanceof Date ? getDbDateStr(val) : val)], - }, + { key: 'quickSetting', type: 'select', @@ -234,38 +218,7 @@ export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [ updateOn: 'blur', }, }, - { - fieldGroupClassName: 'formly-row', - fieldGroup: [ - { - key: 'startTime', - type: 'input', - templateOptions: { - label: T.F.TASK_REPEAT.F.START_TIME, - description: T.F.TASK_REPEAT.F.START_TIME_DESCRIPTION, - }, - validators: { - validTimeString: (c: { value: string | undefined }) => { - return !c.value || isValidSplitTime(c.value); - }, - }, - }, - { - key: 'remindAt', - type: 'select', - defaultValue: TaskReminderOptionId.AtStart, - hideExpression: '!model.startTime', - templateOptions: { - required: true, - label: T.F.TASK_REPEAT.F.REMIND_AT, - options: TASK_REMINDER_OPTIONS, - valueProp: 'value', - labelProp: 'label', - placeholder: T.F.TASK_REPEAT.F.REMIND_AT_PLACEHOLDER, - }, - }, - ], - }, + { key: 'notes', type: 'textarea', diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html index e7dc5d0a65..586287bebe 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html @@ -1,123 +1,54 @@ -
- - - - - -
- @if (isConfigReady()) { - + } - @if (isShowEnterMsg) { -
- {{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }} -
- } - -
- - {{ T.F.TASK.D_DEADLINE.ADD_TIME | translate }} - schedule - - @if (selectedTime) { - close - - } - - - @if (selectedTime) { - - alarm - {{ T.F.TASK.D_DEADLINE.REMIND_AT | translate }} - - @for (remindOption of reminderOptions; track remindOption.value) { - - {{ remindOption.label | translate }} - - } - - - } - - -
- @if (hasExistingDeadline) { - - } + +
+ @if (hasExistingDeadline && !data.isSelectDeadlineOnly) { -
+ } -
-
+
+ +
diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss index 17f457bacf..11acc83f0a 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss @@ -17,37 +17,6 @@ :host-context([dir='rtl']) { direction: rtl; } - - mat-form-field { - width: 100%; - } - - ::ng-deep { - .mat-calendar-header { - padding-top: 0; - } - - .mat-calendar-body-cell-content { - background: transparent !important; - } - - .mat-calendar-body-cell:focus .mat-calendar-body-cell-content { - outline: 2px solid var(--c-accent); - } - - .mat-calendar-body-selected { - background: var(--c-primary) !important; - } - - .mat-calendar-controls { - margin-top: var(--s); - margin-bottom: var(--s); - } - } -} - -.form-ctrl-wrapper { - margin: var(--s2) var(--s2) 0; } mat-dialog-content { @@ -58,47 +27,14 @@ mat-dialog-content { ) !important; } -.press-enter-msg { - text-align: center; - font-weight: bold; - padding: var(--s-half) 12px; - position: absolute; - left: 50%; - z-index: 11; - box-shadow: var(--whiteframe-shadow-1dp); - border-radius: var(--radius-md); - margin-top: calc(var(--s) * -1.5); - white-space: nowrap; - transform: translateX(-50%); - - background: var(--bg-lighter); -} - -.quick-access { - display: flex; - justify-content: space-evenly; - @include extraBorder('-bottom'); - height: 48px; - - > button { - height: 48px; - flex-grow: 1; - border-radius: var(--card-border-radius) !important; - - ::ng-deep .mat-mdc-button-persistent-ripple { - border-radius: var(--card-border-radius) !important; - } - } - - button + button { - @include extraBorder('-left'); - } -} - :host ::ng-deep mat-dialog-actions { padding: 0 0 var(--s2) 0 !important; } +.dialog-actions-and-warnings { + margin: 0 var(--s2) var(--s2); +} + .deadline-actions { display: flex; justify-content: center; @@ -134,7 +70,3 @@ mat-dialog-content { overflow-wrap: anywhere; text-align: center; } - -.time-clear-btn { - cursor: pointer; -} diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts index 763270af6c..3593a06bac 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts @@ -6,7 +6,6 @@ import { computed, ElementRef, inject, - viewChild, } from '@angular/core'; import { MAT_DIALOG_DATA, @@ -16,41 +15,26 @@ import { } from '@angular/material/dialog'; import { Task, TaskReminderOption, TaskReminderOptionId } from '../task.model'; import { T } from 'src/app/t.const'; -import { MatCalendar } from '@angular/material/datepicker'; import { Store } from '@ngrx/store'; import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; import { DEADLINE_REMINDER_OPTIONS } from './deadline-reminder-options.const'; import { FormsModule } from '@angular/forms'; import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds'; import { remindOptionToMilliseconds } from '../util/remind-option-to-milliseconds'; -import { expandFadeAnimation } from '../../../ui/animations/expand.ani'; -import { fadeAnimation } from '../../../ui/animations/fade.ani'; -import { getClockStringFromHours } from '../../../util/get-clock-string-from-hours'; import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string'; import { isValidSplitTime } from '../../../util/is-valid-split-time'; import { normalizeClockStr } from '../../../util/normalize-clock-str'; import { getDbDateStr } from '../../../util/get-db-date-str'; import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date'; import { DateService } from '../../../core/date/date.service'; -import { DateAdapter, MatOption } from '@angular/material/core'; -import { MatTooltip } from '@angular/material/tooltip'; -import { MatButton, MatIconButton } from '@angular/material/button'; +import { DateAdapter } from '@angular/material/core'; +import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { - MatFormField, - MatLabel, - MatPrefix, - MatSuffix, -} from '@angular/material/form-field'; -import { MatSelect } from '@angular/material/select'; import { TranslatePipe } from '@ngx-translate/core'; -import { MatInput } from '@angular/material/input'; -import { TimeStepDirective } from '../../../ui/time-step/time-step.directive'; import { GlobalConfigService } from '../../config/global-config.service'; import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'; import { getDeadlineAutoPlanFields } from '../util/get-deadline-auto-plan-fields'; - -const DEFAULT_TIME = '09:00'; +import { DateTimePickerComponent } from '../../../ui/datetime-picker/datetime-picker.component'; type QuickDeadline = 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'; @@ -58,27 +42,16 @@ type QuickDeadline = 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'; selector: 'dialog-deadline', imports: [ FormsModule, - MatTooltip, - MatIconButton, MatIcon, - MatFormField, - MatSelect, - MatOption, TranslatePipe, MatButton, MatDialogActions, MatDialogContent, - MatCalendar, - MatInput, - MatLabel, - MatSuffix, - MatPrefix, - TimeStepDirective, + DateTimePickerComponent, ], templateUrl: './dialog-deadline.component.html', styleUrl: './dialog-deadline.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [expandFadeAnimation, fadeAnimation], }) export class DialogDeadlineComponent implements AfterViewInit { data = inject<{ @@ -107,19 +80,15 @@ export class DialogDeadlineComponent implements AfterViewInit { ); T: typeof T = T; - readonly calendar = viewChild.required(MatCalendar); - reminderOptions: TaskReminderOption[] = DEADLINE_REMINDER_OPTIONS; task: Task | undefined = this.data.task; selectedDate: Date | null = null; selectedTime: string | null = null; selectedReminderCfgId: TaskReminderOptionId = TaskReminderOptionId.DoNotRemind; + minDate = new Date(); hasExistingDeadline = false; - isInitValOnTimeFocus = true; - isShowEnterMsg = false; - private _timeCheckVal: string | null = null; ngAfterViewInit(): void { if (this.task) { @@ -172,47 +141,7 @@ export class DialogDeadlineComponent implements AfterViewInit { this.selectedReminderCfgId = this.data.targetDeadlineRemindOption; } - this.calendar().activeDate = new Date(this.selectedDate || new Date()); this._cd.detectChanges(); - - setTimeout(() => this._focusInitially()); - setTimeout(() => this._focusInitially(), 300); - } - - private _focusInitially(): void { - const host = this._elRef.nativeElement as HTMLElement; - const selector = this.selectedDate - ? '.mat-calendar-body-selected' - : '.mat-calendar-body-today'; - (host.querySelector(selector) as HTMLElement)?.parentElement?.focus(); - } - - onKeyDownOnCalendar(ev: KeyboardEvent): void { - this._timeCheckVal = null; - if (ev.code === 'Enter' || ev.code === 'Space') { - this.isShowEnterMsg = true; - if ( - this.selectedDate && - new Date(this.selectedDate).getTime() === - new Date(this.calendar().activeDate).getTime() - ) { - this.submit(); - } - } else { - this.isShowEnterMsg = false; - } - } - - onTimeKeyDown(ev: KeyboardEvent): void { - if (ev.key === 'Enter') { - this.isShowEnterMsg = true; - if (this._timeCheckVal === this.selectedTime) { - this.submit(); - } - this._timeCheckVal = this.selectedTime; - } else { - this.isShowEnterMsg = false; - } } close(): void { @@ -220,33 +149,7 @@ export class DialogDeadlineComponent implements AfterViewInit { } dateSelected(newDate: Date): void { - setTimeout(() => { - this.selectedDate = new Date(newDate); - this.calendar().activeDate = this.selectedDate; - }); - } - - onTimeClear(ev: MouseEvent): void { - ev.stopPropagation(); - this.selectedTime = null; - this.selectedReminderCfgId = TaskReminderOptionId.DoNotRemind; - this.isInitValOnTimeFocus = true; - } - - onTimeFocus(): void { - if (!this.selectedTime && this.isInitValOnTimeFocus) { - this.isInitValOnTimeFocus = false; - if (this.selectedDate) { - if (this._dateService.isToday(this.selectedDate!)) { - this.selectedTime = getClockStringFromHours((new Date().getHours() + 1) % 24); - } else { - this.selectedTime = DEFAULT_TIME; - } - } else { - this.selectedTime = getClockStringFromHours((new Date().getHours() + 1) % 24); - this.selectedDate = new Date(); - } - } + this.selectedDate = new Date(newDate); } remove(): void { @@ -317,8 +220,7 @@ export class DialogDeadlineComponent implements AfterViewInit { this._matDialogRef.close(); } - quickAccessBtnClick(ev: MouseEvent, option: QuickDeadline): void { - ev.stopPropagation(); + onQuickAccessClick(option: QuickDeadline): void { this.selectedDate = this._getQuickDate(option); this.submit(); } diff --git a/src/app/features/tasks/util/remind-option-to-milliseconds.spec.ts b/src/app/features/tasks/util/remind-option-to-milliseconds.spec.ts new file mode 100644 index 0000000000..7339e67136 --- /dev/null +++ b/src/app/features/tasks/util/remind-option-to-milliseconds.spec.ts @@ -0,0 +1,57 @@ +import { TaskReminderOptionId } from '../task.model'; +import { + millisecondsDiffToRemindOption, + remindOptionToMilliseconds, +} from './remind-option-to-milliseconds'; + +describe('remindOptionToMilliseconds roundtrip', () => { + const DUE_DATE = new Date('2026-01-01T12:00:00Z').getTime(); + + const options = [ + TaskReminderOptionId.AtStart, + TaskReminderOptionId.m5, + TaskReminderOptionId.m10, + TaskReminderOptionId.m15, + TaskReminderOptionId.m30, + TaskReminderOptionId.h1, + ]; + + options.forEach((optId) => { + it(`should roundtrip correctly for ${optId}`, () => { + const remindAt = remindOptionToMilliseconds(DUE_DATE, optId); + expect(remindAt).toBeDefined(); + const resultOptId = millisecondsDiffToRemindOption(DUE_DATE, remindAt); + expect(resultOptId).toBe(optId); + }); + }); + + it('should handle quantization correctly (rounding to nearest bucket)', () => { + // 7 minutes before -> should round to m5 (since it's < 10m but >= 5m) + const m7 = 7 * 60 * 1000; + const remindAt7m = DUE_DATE - m7; + expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt7m)).toBe( + TaskReminderOptionId.m5, + ); + + // 12 minutes before -> should round to m10 + const m12 = 12 * 60 * 1000; + const remindAt12m = DUE_DATE - m12; + expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt12m)).toBe( + TaskReminderOptionId.m10, + ); + + // 3 minutes before -> should round to m5 (since it's closer to 5 than 0) + const m3 = 3 * 60 * 1000; + const remindAt3m = DUE_DATE - m3; + expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt3m)).toBe( + TaskReminderOptionId.m5, + ); + + // 1 minute before -> should round to AtStart + const m1 = 1 * 60 * 1000; + const remindAt1m = DUE_DATE - m1; + expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt1m)).toBe( + TaskReminderOptionId.AtStart, + ); + }); +}); diff --git a/src/app/features/tasks/util/remind-option-to-milliseconds.ts b/src/app/features/tasks/util/remind-option-to-milliseconds.ts index d64da14e30..5fca24ed4b 100644 --- a/src/app/features/tasks/util/remind-option-to-milliseconds.ts +++ b/src/app/features/tasks/util/remind-option-to-milliseconds.ts @@ -1,6 +1,4 @@ import { TaskReminderOptionId } from '../task.model'; -import { devError } from '../../../util/dev-error'; -import { TaskLog } from '../../../core/log'; export const remindOptionToMilliseconds = ( due: number, @@ -42,21 +40,20 @@ export const millisecondsDiffToRemindOption = ( return TaskReminderOptionId.DoNotRemind; } const diff: number = due - remindAt; - if (diff >= 60 * 60 * 1000) { + const diffInMinutes = diff / (60 * 1000); + + if (diffInMinutes >= 45) { return TaskReminderOptionId.h1; - } else if (diff >= 30 * 60 * 1000) { + } else if (diffInMinutes >= 22.5) { return TaskReminderOptionId.m30; - } else if (diff >= 15 * 60 * 1000) { + } else if (diffInMinutes >= 12.5) { return TaskReminderOptionId.m15; - } else if (diff >= 10 * 60 * 1000) { + } else if (diffInMinutes >= 7.5) { return TaskReminderOptionId.m10; - } else if (diff >= 5 * 60 * 1000) { + } else if (diffInMinutes >= 2.5) { return TaskReminderOptionId.m5; - } else if (diff <= 0) { - return TaskReminderOptionId.AtStart; } else { - TaskLog.log(due, remindAt); - devError('Cannot determine remind option. Invalid params'); - return TaskReminderOptionId.DoNotRemind; + // Also handles diff <= 0 + return TaskReminderOptionId.AtStart; } }; diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 36daedd63c..fc846fea53 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -31,7 +31,16 @@ const T = { RESTORE_STRAY_BACKUP: 'CONFIRM.RESTORE_STRAY_BACKUP', }, DATETIME_SCHEDULE: { + MONTH: 'DATETIME_SCHEDULE.MONTH', + NEXT_24_YEARS: 'DATETIME_SCHEDULE.NEXT_24_YEARS', + NEXT_MONTH: 'DATETIME_SCHEDULE.NEXT_MONTH', + NEXT_YEAR: 'DATETIME_SCHEDULE.NEXT_YEAR', PRESS_ENTER_AGAIN: 'DATETIME_SCHEDULE.PRESS_ENTER_AGAIN', + PREVIOUS_24_YEARS: 'DATETIME_SCHEDULE.PREVIOUS_24_YEARS', + PREVIOUS_MONTH: 'DATETIME_SCHEDULE.PREVIOUS_MONTH', + PREVIOUS_YEAR: 'DATETIME_SCHEDULE.PREVIOUS_YEAR', + SWITCH_TO_MULTI_YEAR_VIEW: 'DATETIME_SCHEDULE.SWITCH_TO_MULTI_YEAR_VIEW', + SWITCH_TO_YEAR_VIEW: 'DATETIME_SCHEDULE.SWITCH_TO_YEAR_VIEW', }, DIALOG_LOGS: { COPY: 'DIALOG_LOGS.COPY', diff --git a/src/app/ui/datetime-picker/datetime-picker.component.html b/src/app/ui/datetime-picker/datetime-picker.component.html new file mode 100644 index 0000000000..80cc8491ff --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.html @@ -0,0 +1,102 @@ +@if (showQuickAccess()) { +
+ + + + + +
+} + +@if (isConfigReady()) { + +} + +@if (isShowEnterMsg) { +
+ {{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }} +
+} + +
+ + {{ timeLabel() }} + schedule + + @if (selectedTime()) { + close + + } + + + @if (selectedTime()) { + + alarm + {{ reminderLabel() | translate }} + + @for (remindOption of reminderOptions(); track remindOption.value) { + + {{ remindOption.label | translate }} + + } + + + } +
diff --git a/src/app/ui/datetime-picker/datetime-picker.component.scss b/src/app/ui/datetime-picker/datetime-picker.component.scss new file mode 100644 index 0000000000..b8e152ee0f --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.scss @@ -0,0 +1,164 @@ +@use '../../../styles/_globals.scss' as *; + +:host { + display: block; + user-select: none; + width: 100%; + + &.sp-hide-cursor { + cursor: none !important; + + ::ng-deep { + .mat-calendar-body-cell-content { + cursor: none !important; + } + // Suppress hover-only styling (when not focused) when cursor is hidden + .mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover:not(:focus) + > .mat-calendar-body-cell-content { + outline: none !important; + + &:not(.mat-calendar-body-selected) { + background-color: transparent !important; + } + } + } + } + + ::ng-deep { + mat-calendar { + height: 400px; + } + + .mat-calendar-header { + padding-top: 0; + } + + .mat-calendar-body-selected { + background: var(--c-primary) !important; + color: var(--c-contrast) !important; + } + + .mat-calendar-controls { + margin-top: var(--s); + margin-bottom: var(--s); + } + + // Hide extra month name inside the calendar body + // NOTE: visibility: hidden (not display: none) is intentional — the cell must + // stay in the grid flow so the first week's day numbers are offset to the + // correct weekday column. display: none collapses it and left-aligns all days. + .mat-calendar-body-label { + visibility: hidden !important; + padding: 0 !important; + } + + .mat-calendar-period-button { + cursor: pointer !important; + transition: background-color var(--transition-duration-s) ease; + + &:hover { + background-color: var(--calendar-hover-bg) !important; + border-radius: var(--card-border-radius) !important; + } + } + + // Style previous/next buttons hover circles + .mat-calendar-previous-button:not(:disabled):hover, + .mat-calendar-next-button:not(:disabled):hover { + background-color: var(--calendar-hover-bg) !important; + border-radius: 50% !important; + } + } + + :host-context([dir='rtl']) { + ::ng-deep { + .mat-calendar-previous-button, + .mat-calendar-next-button { + transform: rotate(180deg); + } + } + } +} + +.quick-access { + display: flex; + justify-content: space-evenly; + @include extraBorder('-bottom'); + height: 48px; + + > button { + height: 48px; + flex-grow: 1; + border-radius: var(--card-border-radius) !important; + + ::ng-deep .mat-mdc-button-persistent-ripple { + border-radius: var(--card-border-radius) !important; + } + } + + button + button { + @include extraBorder('-left'); + } +} + +:host ::ng-deep mat-month-view .mat-calendar-body-today { + color: transparent !important; + border: none !important; + border-color: transparent !important; + outline: none !important; + box-shadow: none !important; + + &:after { + @include materialIcon('wb_sunny'); + @include center; + color: var(--c-accent) !important; + } + + &.mat-calendar-body-selected:after { + color: var(--c-contrast) !important; + } +} + +:host ::ng-deep { + mat-year-view .mat-calendar-body-today, + mat-multi-year-view .mat-calendar-body-today { + border: none !important; + border-color: transparent !important; + outline: none !important; + box-shadow: none !important; + + &:not(.mat-calendar-body-selected) { + color: var(--c-accent) !important; + } + } +} + +.press-enter-msg { + text-align: center; + font-weight: bold; + padding: var(--s-half) 12px; + position: absolute; + left: 50%; + z-index: 11; + box-shadow: var(--whiteframe-shadow-1dp); + border-radius: 8px; + margin-top: -12px; + white-space: nowrap; + transform: translateX(-50%); + background: var(--bg-lighter); +} + +.form-ctrl-wrapper { + margin: var(--s2) var(--s2) var(--s2); + display: flex; + flex-direction: column; + gap: var(--s); + + mat-form-field { + width: 100%; + } + + .time-clear-btn { + cursor: pointer; + } +} diff --git a/src/app/ui/datetime-picker/datetime-picker.component.spec.ts b/src/app/ui/datetime-picker/datetime-picker.component.spec.ts new file mode 100644 index 0000000000..5a80a46680 --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.spec.ts @@ -0,0 +1,196 @@ +import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; +import { DateTimePickerComponent } from './datetime-picker.component'; +import { DateService } from '../../core/date/date.service'; +import { GlobalConfigService } from '../../features/config/global-config.service'; +import { MatNativeDateModule } from '@angular/material/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService, TranslateStore } from '@ngx-translate/core'; +import { signal } from '@angular/core'; +import { TaskReminderOptionId } from '../../features/tasks/task.model'; + +describe('DateTimePickerComponent', () => { + let component: DateTimePickerComponent; + let fixture: ComponentFixture; + let dateServiceSpy: jasmine.SpyObj; + let globalConfigServiceMock: any; + + beforeEach(async () => { + dateServiceSpy = jasmine.createSpyObj('DateService', ['isToday', 'todayStr']); + globalConfigServiceMock = { + localization: signal({}), + cfg: signal({}), + }; + + await TestBed.configureTestingModule({ + imports: [ + DateTimePickerComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + MatNativeDateModule, + ], + providers: [ + { provide: DateService, useValue: dateServiceSpy }, + { provide: GlobalConfigService, useValue: globalConfigServiceMock }, + TranslateService, + TranslateStore, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DateTimePickerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render the calendar by default', () => { + const calendarEl = fixture.nativeElement.querySelector('mat-calendar'); + expect(calendarEl).toBeTruthy(); + }); + + it('should emit dateSelected when a date is selected on the calendar', () => { + spyOn(component.dateSelected, 'emit'); + const testDate = new Date(); + component.dateSelected.emit(testDate); + expect(component.dateSelected.emit).toHaveBeenCalledWith(testDate); + }); + + it('should emit timeChanged when onTimeChange is called', () => { + spyOn(component.timeChanged, 'emit'); + component.onTimeChange('10:30'); + expect(component.timeChanged.emit).toHaveBeenCalledWith('10:30'); + }); + + it('should emit reminderChanged when onReminderChange is called', () => { + spyOn(component.reminderChanged, 'emit'); + component.onReminderChange(TaskReminderOptionId.AtStart); + expect(component.reminderChanged.emit).toHaveBeenCalledWith( + TaskReminderOptionId.AtStart, + ); + }); + + it('should emit quickAccessClick when quickAccessBtnClick is called', () => { + spyOn(component.quickAccessClick, 'emit'); + const mockEvent = new MouseEvent('click'); + component.quickAccessBtnClick(mockEvent, 'tomorrow'); + expect(component.quickAccessClick.emit).toHaveBeenCalledWith('tomorrow'); + }); + + it('should emit timeChanged with null and reminderChanged with DoNotRemind when onTimeClear is called', () => { + spyOn(component.timeChanged, 'emit'); + spyOn(component.reminderChanged, 'emit'); + const mockEvent = new MouseEvent('click'); + component.onTimeClear(mockEvent); + expect(component.timeChanged.emit).toHaveBeenCalledWith(null); + expect(component.reminderChanged.emit).toHaveBeenCalledWith( + TaskReminderOptionId.DoNotRemind, + ); + }); + + it('should autofill time on focus', () => { + spyOn(component.timeChanged, 'emit'); + spyOn(component.dateSelected, 'emit'); + fixture.componentRef.setInput('selectedDate', new Date(2026, 4, 6)); + fixture.componentRef.setInput('selectedTime', null); + dateServiceSpy.isToday.and.returnValue(false); + + component.onTimeFocus(); + + expect(component.timeChanged.emit).toHaveBeenCalledWith('09:00'); + }); + + it('should toggle isKeyboardNavigating based on keyboard navigation and mouse move', () => { + expect(component.isKeyboardNavigating).toBeFalse(); + + // Trigger keyboard navigation key down on calendar + const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + component.onKeyDownOnCalendar(arrowDownEvent); + expect(component.isKeyboardNavigating).toBeTrue(); + + // Trigger non-navigation key down on calendar - should not reset it + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + component.onKeyDownOnCalendar(enterEvent); + expect(component.isKeyboardNavigating).toBeTrue(); + + // Trigger mousemove with changed coordinates - should reset it to false + const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 30, clientY: 40 }); + component.onHostMouseMove(mouseMoveEvent); + expect(component.isKeyboardNavigating).toBeFalse(); + }); + + it('should only update calendar activeDate when selectedDate changes', () => { + const calendar = component.calendar()!; + const initialDate = new Date(2026, 4, 6); + const sameDate = new Date(2026, 4, 6); + const differentDate = new Date(2026, 4, 7); + + // Initial sync + fixture.componentRef.setInput('selectedDate', initialDate); + fixture.detectChanges(); + expect(calendar.activeDate).toEqual(initialDate); + + // Navigation (simulated by manual update to activeDate) + calendar.activeDate = new Date(2026, 5, 1); + + // Sync with same date value - should NOT reset activeDate + fixture.componentRef.setInput('selectedDate', sameDate); + fixture.detectChanges(); + expect(calendar.activeDate).toEqual(new Date(2026, 5, 1)); + + // Sync with different date value - SHOULD reset activeDate + fixture.componentRef.setInput('selectedDate', differentDate); + fixture.detectChanges(); + expect(calendar.activeDate).toEqual(differentDate); + }); + + it('should emit enterSubmit when Enter is pressed on the calendar and date matches', () => { + spyOn(component.enterSubmit, 'emit'); + fixture.componentRef.setInput('selectedDate', new Date(2026, 4, 6)); + fixture.detectChanges(); + + const calendar = component.calendar()!; + calendar.activeDate = new Date(2026, 4, 6); + + const enterEvent = new KeyboardEvent('keydown', { code: 'Enter' }); + + component.onKeyDownOnCalendar(enterEvent); + expect(component.enterSubmit.emit).toHaveBeenCalled(); + expect(component.isShowEnterMsg).toBeTrue(); + }); + + it('should require two Enter presses to emit enterSubmit on time input', () => { + spyOn(component.enterSubmit, 'emit'); + fixture.componentRef.setInput('selectedTime', '10:30'); + fixture.detectChanges(); + + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + + // First press + component.onTimeKeyDown(enterEvent); + expect(component.enterSubmit.emit).not.toHaveBeenCalled(); + expect(component.isShowEnterMsg).toBeTrue(); + + // Second press + component.onTimeKeyDown(enterEvent); + expect(component.enterSubmit.emit).toHaveBeenCalled(); + }); + + it('should focus the active calendar cell after view init', fakeAsync(() => { + // Create an element that querySelector will find + const activeCell = document.createElement('div'); + activeCell.classList.add('mat-calendar-body-active'); + activeCell.tabIndex = 0; + fixture.nativeElement.appendChild(activeCell); + + spyOn(activeCell, 'focus'); + // Mock querySelector on the native element + spyOn(fixture.nativeElement, 'querySelector').and.returnValue(activeCell); + + component.ngAfterViewInit(); + tick(50); // Match the 50ms in component + + expect(activeCell.focus).toHaveBeenCalled(); + })); +}); diff --git a/src/app/ui/datetime-picker/datetime-picker.component.ts b/src/app/ui/datetime-picker/datetime-picker.component.ts new file mode 100644 index 0000000000..87cfe8cb4e --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.ts @@ -0,0 +1,290 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + effect, + ElementRef, + HostBinding, + HostListener, + inject, + input, + output, + viewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatCalendar } from '@angular/material/datepicker'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { + MatFormField, + MatLabel, + MatPrefix, + MatSuffix, +} from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatSelect } from '@angular/material/select'; +import { MatOption } from '@angular/material/core'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslateModule, TranslatePipe } from '@ngx-translate/core'; +import { T } from '../../t.const'; +import { DateService } from '../../core/date/date.service'; +import { GlobalConfigService } from '../../features/config/global-config.service'; +import { + TaskReminderOption, + TaskReminderOptionId, +} from '../../features/tasks/task.model'; +import { TASK_REMINDER_OPTIONS } from '../../features/planner/dialog-schedule-task/task-reminder-options.const'; +import { TimeStepDirective } from '../time-step/time-step.directive'; +import { expandFadeAnimation } from '../animations/expand.ani'; +import { fadeAnimation } from '../animations/fade.ani'; +import { getClockStringFromHours } from '../../util/get-clock-string-from-hours'; + +const DEFAULT_TIME = '09:00'; + +@Component({ + selector: 'datetime-picker', + standalone: true, + imports: [ + FormsModule, + MatCalendar, + MatButtonModule, + MatIcon, + MatFormField, + MatLabel, + MatPrefix, + MatSuffix, + MatInput, + MatSelect, + MatOption, + MatTooltip, + TranslateModule, + TranslatePipe, + TimeStepDirective, + ], + templateUrl: './datetime-picker.component.html', + styleUrl: './datetime-picker.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [expandFadeAnimation, fadeAnimation], +}) +export class DateTimePickerComponent implements AfterViewInit { + private _dateService = inject(DateService); + private _globalConfigService = inject(GlobalConfigService); + private readonly _cdr = inject(ChangeDetectorRef); + private _el = inject(ElementRef); + + pickerSelectedDate: Date | null = null; + + constructor() { + effect((onCleanup) => { + const cal = this.calendar(); + if (cal) { + // Set initial view and date + if (cal.currentView !== 'month') { + this.pickerSelectedDate = cal.activeDate; + } + + const sub = cal.stateChanges.subscribe(() => { + if (cal.currentView !== 'month') { + this.pickerSelectedDate = cal.activeDate; + } + this._cdr.markForCheck(); + }); + onCleanup(() => sub.unsubscribe()); + } + }); + } + + // Inputs + selectedDate = input(null); + selectedTime = input(null); + selectedReminderCfgId = input(TaskReminderOptionId.DoNotRemind); + reminderOptions = input(TASK_REMINDER_OPTIONS); + minDate = input(null); + timeLabel = input('Time'); + reminderLabel = input(T.F.TASK.D_SCHEDULE_TASK.REMIND_AT); + showQuickAccess = input(true); + quickAccessTranslationPrefix = input('F.TASK.D_SCHEDULE_TASK'); + + // Outputs + dateSelected = output(); + timeChanged = output(); + reminderChanged = output(); + quickAccessClick = output<'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'>(); + enterSubmit = output(); + + // Template variables + T: typeof T = T; + isInitValOnTimeFocus = true; + isShowEnterMsg = false; + @HostBinding('class.sp-hide-cursor') isKeyboardNavigating = false; + + readonly calendar = viewChild(MatCalendar); + + readonly isConfigReady = computed( + () => this._globalConfigService.localization() !== undefined, + ); + + get calendarSelectedDate(): Date | null { + const cal = this.calendar(); + if (!cal) { + return this.selectedDate(); + } + if (cal.currentView === 'month') { + return this.selectedDate(); + } + return this.pickerSelectedDate || cal.activeDate; + } + + private _lastSyncedDate: number | null = null; + private _syncActiveDateEffect = effect(() => { + const date = this.selectedDate(); + const dateMs = date ? new Date(date).getTime() : null; + if (dateMs === this._lastSyncedDate) { + return; + } + this._lastSyncedDate = dateMs; + + const cal = this.calendar(); + if (cal) { + cal.activeDate = new Date(date || new Date()); + } + }); + + private _timeCheckVal: string | null = null; + + onKeyDownOnCalendar(ev: KeyboardEvent): void { + this._timeCheckVal = null; + if (ev.code === 'Enter' || ev.code === 'Space') { + this.isShowEnterMsg = true; + const cal = this.calendar(); + const selDate = this.selectedDate(); + if ( + cal && + selDate && + new Date(selDate).getTime() === new Date(cal.activeDate).getTime() + ) { + this.enterSubmit.emit(); + } + } else { + this.isShowEnterMsg = false; + } + + if ( + [ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown', + ].includes(ev.key) + ) { + this.isKeyboardNavigating = true; + this._cdr.markForCheck(); + } + } + + onTimeFocus(): void { + if (!this.selectedTime() && this.isInitValOnTimeFocus) { + this.isInitValOnTimeFocus = false; + + let targetTime: string; + let targetDate: Date | null = null; + + const selDate = this.selectedDate(); + if (selDate) { + if (this._dateService.isToday(selDate)) { + targetTime = getClockStringFromHours((new Date().getHours() + 1) % 24); + } else { + targetTime = DEFAULT_TIME; + } + } else { + // get current time +1h + targetTime = getClockStringFromHours((new Date().getHours() + 1) % 24); + targetDate = new Date(); + } + + if (targetDate) { + this.dateSelected.emit(targetDate); + } + this.timeChanged.emit(targetTime); + } + } + + onTimeChange(newTime: string | null): void { + this.timeChanged.emit(newTime); + } + + onReminderChange(newReminder: TaskReminderOptionId): void { + this.reminderChanged.emit(newReminder); + } + + onTimeKeyDown(ev: KeyboardEvent): void { + if (ev.key === 'Enter') { + this.isShowEnterMsg = true; + if (this._timeCheckVal === this.selectedTime()) { + this.enterSubmit.emit(); + } + this._timeCheckVal = this.selectedTime(); + } else { + this.isShowEnterMsg = false; + } + } + + onTimeClear(ev: MouseEvent): void { + ev.stopPropagation(); + this.timeChanged.emit(null); + this.reminderChanged.emit(TaskReminderOptionId.DoNotRemind); + this.isInitValOnTimeFocus = true; + this._timeCheckVal = null; + } + + quickAccessBtnClick( + ev: MouseEvent, + val: 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth', + ): void { + ev.preventDefault(); + this.quickAccessClick.emit(val); + } + + private _lastMouseCoords: { x: number; y: number } | null = null; + + ngAfterViewInit(): void { + // Focus the active calendar cell when the picker opens + setTimeout(() => { + const activeCell = this._el.nativeElement.querySelector( + '.mat-calendar-body-active', + ) as HTMLElement; + if (activeCell) { + activeCell.focus(); + } + }, 50); + } + + @HostListener('mousemove', ['$event']) + onHostMouseMove(ev: MouseEvent): void { + this._resetKeyboardNav(ev); + } + + private _resetKeyboardNav(ev: MouseEvent): boolean { + const coords = { x: ev.clientX, y: ev.clientY }; + if ( + this._lastMouseCoords && + this._lastMouseCoords.x === coords.x && + this._lastMouseCoords.y === coords.y + ) { + return false; + } + this._lastMouseCoords = coords; + + if (this.isKeyboardNavigating) { + this.isKeyboardNavigating = false; + this._cdr.markForCheck(); + } + return true; + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 03d9329d23..6983bfc663 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -30,7 +30,16 @@ "RESTORE_STRAY_BACKUP": "During the last sync, there may have been an error. Do you want to restore the last backup?" }, "DATETIME_SCHEDULE": { - "PRESS_ENTER_AGAIN": "Press enter again to save" + "MONTH": "Month", + "NEXT_24_YEARS": "Next 24 years", + "NEXT_MONTH": "Next month", + "NEXT_YEAR": "Next year", + "PRESS_ENTER_AGAIN": "Press enter again to save", + "PREVIOUS_24_YEARS": "Previous 24 years", + "PREVIOUS_MONTH": "Previous month", + "PREVIOUS_YEAR": "Previous year", + "SWITCH_TO_MULTI_YEAR_VIEW": "Choose year", + "SWITCH_TO_YEAR_VIEW": "Choose month" }, "DIALOG_LOGS": { "COPY": "Copy", diff --git a/src/main.ts b/src/main.ts index ddb80ce97e..ff989cd17e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,6 +44,7 @@ import { MatDateFormats, DateAdapter, } from '@angular/material/core'; +import { MatDatepickerIntl } from '@angular/material/datepicker'; import { FormlyConfigModule } from './app/ui/formly-config.module'; import { markedOptionsFactory } from './app/ui/marked-options-factory'; import { MaterialCssVarsModule } from 'angular-material-css-vars'; @@ -89,6 +90,7 @@ import { GlobalConfigService } from './app/features/config/global-config.service import { LocaleDatePipe } from './app/ui/pipes/locale-date.pipe'; import { DateTimeFormatService } from './app/core/date-time-format/date-time-format.service'; import { CustomDateAdapter } from './app/core/date-time-format/custom-date-adapter'; +import { TranslateMatDatepickerIntl } from './app/core/date-time-format/translate-mat-datepicker-intl'; import { unlockAudioContext } from './app/util/audio-context'; import { NetworkRetryInterceptorService } from './app/core/http/network-retry-interceptor.service'; @@ -202,6 +204,7 @@ bootstrapApplication(AppComponent, { ShortTimeHtmlPipe, ShortTimePipe, { provide: DateAdapter, useClass: CustomDateAdapter }, + { provide: MatDatepickerIntl, useClass: TranslateMatDatepickerIntl }, { provide: MAT_DATE_FORMATS, useFactory: (dateTimeFormatService: DateTimeFormatService): MatDateFormats => { @@ -217,7 +220,7 @@ bootstrapApplication(AppComponent, { get dateInput(): string { return dateTimeFormatService.dateFormat().raw; }, - monthYearLabel: { year: 'numeric', month: 'short' }, + monthYearLabel: { year: 'numeric', month: 'long' }, dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' }, monthYearA11yLabel: { year: 'numeric', month: 'long' }, timeInput: { hour: 'numeric', minute: 'numeric' }, diff --git a/src/styles/components/_overwrite-material.scss b/src/styles/components/_overwrite-material.scss index 36bd55e67d..6de6fd6e86 100644 --- a/src/styles/components/_overwrite-material.scss +++ b/src/styles/components/_overwrite-material.scss @@ -13,6 +13,7 @@ --mat-tab-header-active-hover-label-text-color: var(--palette-primary-700); --mdc-tab-indicator-active-indicator-color: var(--palette-primary-700); --mat-tab-header-inactive-label-text-color: var(--text-color-muted); + --calendar-hover-bg: color-mix(in srgb, var(--c-primary) 35%, transparent); } // Dark theme tab overrides for better contrast - use lighter shade @@ -22,6 +23,7 @@ body.isDarkTheme { --mat-tab-header-active-focus-label-text-color: var(--palette-primary-300); --mat-tab-header-active-hover-label-text-color: var(--palette-primary-300); --mdc-tab-indicator-active-indicator-color: var(--palette-primary-300); + --calendar-hover-bg: color-mix(in srgb, var(--c-primary) 20%, transparent); } // Apply tab colors explicitly to tab components @@ -483,3 +485,20 @@ body.isVerticalActionBar .cdk-overlay-pane.mat-mdc-tooltip-panel { display: flex !important; align-items: center !important; } + +// Shared calendar hover and focus overrides +.mat-calendar:not(.no-hover-effects) { + .mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover + > .mat-calendar-body-cell-content, + .mat-calendar-body-cell:not(.mat-calendar-body-disabled):focus + .mat-calendar-body-cell-content { + outline: 2px solid color-mix(in srgb, var(--c-accent) 80%, transparent) !important; + } + + .mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover + > .mat-calendar-body-cell-content:not(.mat-calendar-body-selected), + .mat-calendar-body-cell:not(.mat-calendar-body-disabled):focus + > .mat-calendar-body-cell-content:not(.mat-calendar-body-selected) { + background-color: var(--calendar-hover-bg) !important; + } +} From 7ea7fe2258a225fe2ad0a745045f99a55d3a1f7a Mon Sep 17 00:00:00 2001 From: "Tomas Sykora, jr." Date: Tue, 9 Jun 2026 16:55:36 +0200 Subject: [PATCH 11/11] feat(redmine): allow searching issues by id (#8072) * feat(redmine): allow searching issues by id Redmine's search API only performs full text search and does not match issue ids. For numeric queries (e.g. "1234" or "#1234") additionally fetch the issue directly via /issues/{id}.json and merge it into the search results. * refactor(redmine): scope search-by-id lookup to the configured project Addresses review: GET /issues/{id}.json resolves globally, so a provider configured for one project could surface (and add) an issue from another project the API key can see. Look the issue up via the project-scoped /projects/{projectId}/issues.json?issue_id=&status_id=* endpoint instead, which keeps results within the configured project, still finds closed issues, and lets Redmine resolve the project (numeric id or slug). The global getById$ is left unchanged for the existing refresh/poll path. Adds a spec asserting an id from another project is not surfaced. --- .../redmine/redmine-api.service.spec.ts | 162 ++++++++++++++++++ .../providers/redmine/redmine-api.service.ts | 63 ++++++- .../redmine/redmine-issue-map.util.ts | 12 +- 3 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 src/app/features/issue/providers/redmine/redmine-api.service.spec.ts diff --git a/src/app/features/issue/providers/redmine/redmine-api.service.spec.ts b/src/app/features/issue/providers/redmine/redmine-api.service.spec.ts new file mode 100644 index 0000000000..204db0fe45 --- /dev/null +++ b/src/app/features/issue/providers/redmine/redmine-api.service.spec.ts @@ -0,0 +1,162 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { HttpRequest } from '@angular/common/http'; +import { RedmineApiService } from './redmine-api.service'; +import { RedmineCfg } from './redmine.model'; +import { SearchResultItem } from '../../issue.model'; +import { SnackService } from '../../../../core/snack/snack.service'; + +describe('RedmineApiService', () => { + let service: RedmineApiService; + let httpMock: HttpTestingController; + + const mockCfg: RedmineCfg = { + isEnabled: true, + host: 'https://redmine.example.com', + projectId: 'test-project', + api_key: 'test-api-key', + scope: null, + }; + + const mockSearchResponse = { + results: [ + { + id: 100, + title: 'Bug #100: Some text issue', + url: 'https://redmine.example.com/issues/100', + }, + ], + total_count: 1, + offset: 0, + limit: 100, + }; + + // by-id lookup is scoped to the configured project (not the global /issues/{id}.json) + const byIdInProjectMatcher = + (issueId: number) => + (req: HttpRequest): boolean => + req.method === 'GET' && + req.url === `${mockCfg.host}/projects/${mockCfg.projectId}/issues.json` && + req.params.get('issue_id') === String(issueId) && + // status_id=* so closed issues are found too + req.params.get('status_id') === '*'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + RedmineApiService, + { + provide: SnackService, + useValue: jasmine.createSpyObj('SnackService', ['open']), + }, + ], + }); + service = TestBed.inject(RedmineApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('searchIssuesInProject$', () => { + const searchUrl = (query: string): string => + `${mockCfg.host}/projects/${mockCfg.projectId}/search.json?limit=100&q=${query}&issues=1&open_issues=1`; + + it('should only send a text search request for non-numeric queries', () => { + let result: SearchResultItem[] | undefined; + service.searchIssuesInProject$('some text', mockCfg).subscribe((r) => (result = r)); + + const req = httpMock.expectOne(searchUrl('some%20text')); + expect(req.request.method).toBe('GET'); + req.flush(mockSearchResponse); + + httpMock.expectNone(byIdInProjectMatcher(100)); + expect(result?.length).toBe(1); + expect(result?.[0].title).toBe('Bug #100: Some text issue'); + }); + + it('should additionally fetch the issue by id (project-scoped) for numeric queries', () => { + let result: SearchResultItem[] | undefined; + service.searchIssuesInProject$('22899', mockCfg).subscribe((r) => (result = r)); + + const byIdReq = httpMock.expectOne(byIdInProjectMatcher(22899)); + byIdReq.flush({ issues: [{ id: 22899, subject: 'Issue found by id' }] }); + + const searchReq = httpMock.expectOne(searchUrl('22899')); + searchReq.flush(mockSearchResponse); + + expect(result?.length).toBe(2); + expect(result?.[0].title).toBe('#22899 Issue found by id'); + expect((result?.[0].issueData as { id: number }).id).toBe(22899); + }); + + it('should support queries prefixed with #', () => { + let result: SearchResultItem[] | undefined; + service.searchIssuesInProject$('#22899', mockCfg).subscribe((r) => (result = r)); + + const byIdReq = httpMock.expectOne(byIdInProjectMatcher(22899)); + byIdReq.flush({ issues: [{ id: 22899, subject: 'Issue found by id' }] }); + + const searchReq = httpMock.expectOne(searchUrl('%2322899')); + searchReq.flush({ ...mockSearchResponse, results: [] }); + + expect(result?.length).toBe(1); + expect(result?.[0].title).toBe('#22899 Issue found by id'); + }); + + it('should de-duplicate the by-id issue from text search results', () => { + let result: SearchResultItem[] | undefined; + service.searchIssuesInProject$('100', mockCfg).subscribe((r) => (result = r)); + + const byIdReq = httpMock.expectOne(byIdInProjectMatcher(100)); + byIdReq.flush({ issues: [{ id: 100, subject: 'Some text issue' }] }); + + const searchReq = httpMock.expectOne(searchUrl('100')); + searchReq.flush(mockSearchResponse); + + expect(result?.length).toBe(1); + expect(result?.[0].title).toBe('#100 Some text issue'); + }); + + it('should fall back to text search results when the id is not in the project', () => { + let result: SearchResultItem[] | undefined; + service.searchIssuesInProject$('99999', mockCfg).subscribe((r) => (result = r)); + + const byIdReq = httpMock.expectOne(byIdInProjectMatcher(99999)); + // Redmine returns an empty list (not a 404) for an id outside the project + byIdReq.flush({ issues: [], total_count: 0, offset: 0, limit: 1 }); + + const searchReq = httpMock.expectOne(searchUrl('99999')); + searchReq.flush(mockSearchResponse); + + expect(result?.length).toBe(1); + expect(result?.[0].title).toBe('Bug #100: Some text issue'); + }); + + it('should not surface an issue id that belongs to another project', () => { + let result: SearchResultItem[] | undefined; + service.searchIssuesInProject$('424242', mockCfg).subscribe((r) => (result = r)); + + // The lookup is scoped to the configured project; an id belonging to another + // project the API key can see is therefore returned as an empty list by Redmine. + const byIdReq = httpMock.expectOne(byIdInProjectMatcher(424242)); + byIdReq.flush({ issues: [], total_count: 0, offset: 0, limit: 1 }); + + const searchReq = httpMock.expectOne(searchUrl('424242')); + searchReq.flush({ ...mockSearchResponse, results: [] }); + + // no global /issues/{id}.json request is ever made + httpMock.expectNone(`${mockCfg.host}/issues/424242.json`); + expect(result?.length).toBe(0); + }); + }); +}); diff --git a/src/app/features/issue/providers/redmine/redmine-api.service.ts b/src/app/features/issue/providers/redmine/redmine-api.service.ts index f6b06d38a4..dfd039cee8 100644 --- a/src/app/features/issue/providers/redmine/redmine-api.service.ts +++ b/src/app/features/issue/providers/redmine/redmine-api.service.ts @@ -3,7 +3,7 @@ import { SnackService } from '../../../../core/snack/snack.service'; import { HttpClient, HttpHeaders, HttpParams, HttpRequest } from '@angular/common/http'; import { RedmineCfg } from './redmine.model'; import { catchError, filter, map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable, of, throwError } from 'rxjs'; import { throwHandledError } from '../../../../util/throw-handled-error'; import { T } from '../../../../t.const'; import { ISSUE_PROVIDER_HUMANIZED, REDMINE_TYPE } from '../../issue.const'; @@ -16,13 +16,18 @@ import { RedmineSearchResultItem, RedmineTimeEntriesResult, } from './redmine-issue.model'; -import { mapRedmineSearchResultItemToSearchResult } from './redmine-issue-map.util'; +import { + mapRedmineIssueToSearchResult, + mapRedmineSearchResultItemToSearchResult, +} from './redmine-issue-map.util'; import { SearchResultItem } from '../../issue.model'; import { ScopeOptions } from './redmine.const'; import { handleIssueProviderHttpError$ } from '../../handle-issue-provider-http-error'; /* eslint-disable @typescript-eslint/naming-convention */ +const ISSUE_ID_QUERY_RGX = /^#?(\d+)$/; + @Injectable({ providedIn: 'root', }) @@ -31,7 +36,7 @@ export class RedmineApiService { private _http = inject(HttpClient); searchIssuesInProject$(query: string, cfg: RedmineCfg): Observable { - return this._sendRequest$( + const textSearch$: Observable = this._sendRequest$( { url: `${cfg.host}/projects/${cfg.projectId}/search.json`, params: ParamsBuilder.create() @@ -51,6 +56,53 @@ export class RedmineApiService { : []; }), ); + + // Redmine's search API only does full text search and does not match issue ids, + // so for numeric queries (e.g. "1234" or "#1234") we additionally try to fetch + // the issue by its id and merge it into the results. + const idMatch = query.trim().match(ISSUE_ID_QUERY_RGX); + if (!idMatch) { + return textSearch$; + } + + const issueId = Number(idMatch[1]); + return forkJoin([ + this._getIssueByIdInProject$(issueId, cfg).pipe(catchError(() => of(null))), + textSearch$, + ]).pipe( + map(([issueById, textResults]) => + issueById + ? [ + mapRedmineIssueToSearchResult(issueById), + ...textResults.filter((result) => result.issueData.id !== issueId), + ] + : textResults, + ), + ); + } + + // Looks up a single issue by id but stays scoped to the configured project, so a + // provider for one project can never surface (or add) an issue from another project + // the API key happens to have access to. Redmine resolves the project from the URL, + // so this works whether `cfg.projectId` is the numeric id or the identifier slug. + // `status_id=*` ensures closed issues are found too. A cross-project (or unknown) id + // simply yields an empty list -> null, and the caller falls back to text search. + private _getIssueByIdInProject$( + issueId: number, + cfg: RedmineCfg, + ): Observable { + return this._sendRequest$( + { + url: `${cfg.host}/projects/${cfg.projectId}/issues.json`, + params: ParamsBuilder.create() + .withParam('issue_id', String(issueId)) + .withState('*') + .withLimit(1) + .build(), + }, + cfg, + { isSkipErrorHandling: true }, + ).pipe(map((res: RedmineIssueResult) => res?.issues?.[0] ?? null)); } getLast100IssuesForCurrentRedmineProject$(cfg: RedmineCfg): Observable { @@ -137,6 +189,7 @@ export class RedmineApiService { private _sendRequest$( params: HttpRequest | any, cfg: RedmineCfg, + { isSkipErrorHandling = false }: { isSkipErrorHandling?: boolean } = {}, ): Observable { this._checkSettings(cfg); params.headers = { @@ -169,7 +222,9 @@ export class RedmineApiService { filter((res) => !(res === Object(res) && res.type === 0)), map((res: any) => (res && res.body ? res.body : res)), catchError((err) => - handleIssueProviderHttpError$(REDMINE_TYPE, this._snackService, err), + isSkipErrorHandling + ? throwError(() => err) + : handleIssueProviderHttpError$(REDMINE_TYPE, this._snackService, err), ), ); } diff --git a/src/app/features/issue/providers/redmine/redmine-issue-map.util.ts b/src/app/features/issue/providers/redmine/redmine-issue-map.util.ts index 37f35d5fda..1bc045dcd3 100644 --- a/src/app/features/issue/providers/redmine/redmine-issue-map.util.ts +++ b/src/app/features/issue/providers/redmine/redmine-issue-map.util.ts @@ -1,5 +1,5 @@ import { IssueDataReduced, IssueProviderKey, SearchResultItem } from '../../issue.model'; -import { RedmineSearchResultItem } from './redmine-issue.model'; +import { RedmineIssue, RedmineSearchResultItem } from './redmine-issue.model'; export const mapRedmineSearchResultItemToSearchResult = ( item: RedmineSearchResultItem, @@ -11,3 +11,13 @@ export const mapRedmineSearchResultItemToSearchResult = ( issueData: item as IssueDataReduced, }; }; + +export const mapRedmineIssueToSearchResult = (issue: RedmineIssue): SearchResultItem => { + const title = `#${issue.id} ${issue.subject}`; + return { + title, + titleHighlighted: title, + issueType: 'REDMINE' as IssueProviderKey, + issueData: { ...issue, title: issue.subject }, + }; +};