mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
UX changes
This commit is contained in:
parent
85362bc35f
commit
90e3e03d8b
12 changed files with 842 additions and 523 deletions
|
|
@ -142,6 +142,16 @@ export class IssueService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push updated issue data to refresh subscribers (e.g., task detail panel)
|
||||
* Use this after updating an issue externally (e.g., syncing marker to Logseq)
|
||||
*/
|
||||
refreshIssueData(issueProviderId: string, issueId: string, issueData: IssueData): void {
|
||||
if (this.ISSUE_REFRESH_MAP[issueProviderId]?.[issueId]) {
|
||||
this.ISSUE_REFRESH_MAP[issueProviderId][issueId].next(issueData);
|
||||
}
|
||||
}
|
||||
|
||||
searchIssues(
|
||||
searchTerm: string,
|
||||
issueProviderId: string,
|
||||
|
|
|
|||
|
|
@ -21,11 +21,9 @@ describe('LogseqCommonInterfacesService', () => {
|
|||
authToken: 'test-token',
|
||||
queryFilter:
|
||||
'[:find (pull ?b [*]) :where [?b :block/marker ?m] [(contains? #{"TODO" "DOING"} ?m)]]',
|
||||
isUpdateBlockOnTaskDone: true,
|
||||
linkFormat: 'logseq-url',
|
||||
taskWorkflow: 'TODO_DOING',
|
||||
superProdReferenceMode: 'property',
|
||||
superProdReferenceProperty: 'superProductivity',
|
||||
isIncludeMarkerInUpdateDetection: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -74,7 +72,6 @@ describe('LogseqCommonInterfacesService', () => {
|
|||
expect(result.dueDay).toBeUndefined();
|
||||
expect(result.dueWithTime).toBeUndefined();
|
||||
expect(result.isDone).toBe(false);
|
||||
expect(result.issueMarker).toBe('TODO');
|
||||
});
|
||||
|
||||
it('should import task with SCHEDULED date only', () => {
|
||||
|
|
@ -371,7 +368,6 @@ describe('LogseqCommonInterfacesService', () => {
|
|||
issueId: 'block-uuid-1',
|
||||
issueProviderId: 'provider-1',
|
||||
isDone: false,
|
||||
issueMarker: 'TODO',
|
||||
issueLastUpdated: Date.now() - 10000,
|
||||
};
|
||||
|
||||
|
|
@ -382,13 +378,20 @@ describe('LogseqCommonInterfacesService', () => {
|
|||
});
|
||||
|
||||
it('should return null when no changes detected', async () => {
|
||||
// Use a fixed lastSync time that's after the block's updatedAt
|
||||
const blockUpdatedAt = Date.now() - 20000;
|
||||
const lastSyncTime = blockUpdatedAt + 5000; // Synced after last update
|
||||
const blockContent = 'TODO Task\nSCHEDULED: <2026-01-15 Wed>';
|
||||
// Calculate hash of content without drawer (same as what calculateContentHash does)
|
||||
const contentHash = -1863127520; // Pre-calculated hash of "TODO Task\nSCHEDULED: <2026-01-15 Wed>"
|
||||
|
||||
const block: LogseqBlock = {
|
||||
id: 'block-uuid-1',
|
||||
uuid: 'block-uuid-1',
|
||||
content: 'TODO Task\nSCHEDULED: <2026-01-15 Wed>',
|
||||
content: `${blockContent}\n:SP:\nsuperprod-last-sync:: ${lastSyncTime}\nsuperprod-content-hash:: ${contentHash}\n:END:`,
|
||||
marker: 'TODO',
|
||||
createdAt: Date.now() - 20000,
|
||||
updatedAt: Date.now() - 20000,
|
||||
createdAt: blockUpdatedAt - 10000,
|
||||
updatedAt: blockUpdatedAt,
|
||||
page: { id: 123 },
|
||||
parent: null,
|
||||
properties: {},
|
||||
|
|
@ -397,11 +400,11 @@ describe('LogseqCommonInterfacesService', () => {
|
|||
|
||||
const task: Partial<Task> = {
|
||||
id: 'task-1',
|
||||
title: 'Task',
|
||||
issueId: 'block-uuid-1',
|
||||
issueProviderId: 'provider-1',
|
||||
dueDay: '2026-01-15',
|
||||
isDone: false,
|
||||
issueMarker: 'TODO',
|
||||
issueLastUpdated: Date.now() - 10000,
|
||||
};
|
||||
|
||||
|
|
@ -543,40 +546,6 @@ describe('LogseqCommonInterfacesService', () => {
|
|||
// ============================================================
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('should respect isUpdateBlockOnTaskDone setting', async () => {
|
||||
const disabledCfg: IssueProviderLogseq = {
|
||||
...mockCfg,
|
||||
isUpdateBlockOnTaskDone: false,
|
||||
};
|
||||
mockIssueProviderService.getCfgOnce$.and.returnValue(of(disabledCfg));
|
||||
|
||||
const block: LogseqBlock = {
|
||||
id: 'block-uuid-1',
|
||||
uuid: 'block-uuid-1',
|
||||
content: 'TODO Test Task',
|
||||
marker: 'TODO',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
page: { id: 123 },
|
||||
parent: null,
|
||||
properties: {},
|
||||
};
|
||||
mockApiService.getBlockByUuid$.and.returnValue(of(block));
|
||||
mockApiService.updateBlock$.and.returnValue(of(void 0));
|
||||
|
||||
const task: Partial<Task> = {
|
||||
id: 'task-1',
|
||||
issueId: 'block-uuid-1',
|
||||
issueProviderId: 'provider-1',
|
||||
isDone: true,
|
||||
};
|
||||
|
||||
await service.updateIssueFromTask(task as Task);
|
||||
|
||||
// Should NOT update marker to DONE when setting is disabled
|
||||
expect(mockApiService.updateBlock$).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be enabled when all config is provided', () => {
|
||||
const result = service.isEnabled(mockCfg);
|
||||
|
||||
|
|
|
|||
|
|
@ -51,11 +51,13 @@ export const LOGSEQ_CONFIG_FORM: LimitedFormlyFieldConfig<IssueProviderLogseq>[]
|
|||
},
|
||||
},
|
||||
{
|
||||
key: 'isUpdateBlockOnTaskDone',
|
||||
key: 'isIncludeMarkerInUpdateDetection',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
defaultValue: false,
|
||||
props: {
|
||||
label: 'Update Logseq block to DONE when task is completed',
|
||||
label: 'Include marker changes (TODO/DOING/NOW/LATER/DONE) in update detection',
|
||||
description:
|
||||
'When enabled, changes to the task marker in Logseq will trigger an update notification. When disabled (default), only content changes are detected.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -84,32 +86,6 @@ export const LOGSEQ_CONFIG_FORM: LimitedFormlyFieldConfig<IssueProviderLogseq>[]
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'superProdReferenceMode',
|
||||
type: 'select',
|
||||
defaultValue: 'property',
|
||||
props: {
|
||||
label: 'Store SuperProductivity reference in Logseq as',
|
||||
description: 'How to add a reference to the SuperProductivity task in Logseq',
|
||||
options: [
|
||||
{ label: 'Block property', value: 'property' },
|
||||
{ label: 'Child block', value: 'child-block' },
|
||||
{ label: 'Do not store', value: 'none' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'superProdReferenceProperty',
|
||||
type: 'input',
|
||||
defaultValue: 'superProductivity',
|
||||
expressions: {
|
||||
hide: 'model.superProdReferenceMode !== "property"',
|
||||
},
|
||||
props: {
|
||||
label: 'Property name for SuperProductivity reference',
|
||||
placeholder: 'superProductivity',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { Task } from '../../../tasks/task.model';
|
||||
import { concatMap, first, map, switchMap } from 'rxjs/operators';
|
||||
import { IssueServiceInterface } from '../../issue-service-interface';
|
||||
|
|
@ -15,17 +15,32 @@ import {
|
|||
LOGSEQ_TYPE,
|
||||
} from './logseq.const';
|
||||
import { IssueProviderService } from '../../issue-provider.service';
|
||||
import { TaskService } from '../../../tasks/task.service';
|
||||
import {
|
||||
extractFirstLine,
|
||||
extractScheduledDate,
|
||||
extractScheduledDateTime,
|
||||
extractSpDrawerData,
|
||||
calculateContentHash,
|
||||
updateSpDrawerInContent,
|
||||
mapBlockToIssueReduced,
|
||||
mapBlockToSearchResult,
|
||||
updateScheduledInContent,
|
||||
} from './logseq-issue-map.util';
|
||||
import { TaskAttachment } from '../../../tasks/task-attachment/task-attachment.model';
|
||||
import { getDbDateStr } from '../../../../util/get-db-date-str';
|
||||
import { decodeMarker, encodeMarkerWithHash, hashCode } from './logseq-marker-hash.util';
|
||||
|
||||
export type DiscrepancyType =
|
||||
| 'LOGSEQ_DONE_SUPERPROD_NOT_DONE'
|
||||
| 'SUPERPROD_DONE_LOGSEQ_NOT_DONE'
|
||||
| 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE'
|
||||
| 'SUPERPROD_ACTIVE_LOGSEQ_NOT_ACTIVE';
|
||||
|
||||
export interface DiscrepancyItem {
|
||||
task: Task;
|
||||
block: LogseqBlock;
|
||||
discrepancyType: DiscrepancyType;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -33,9 +48,17 @@ import { decodeMarker, encodeMarkerWithHash, hashCode } from './logseq-marker-ha
|
|||
export class LogseqCommonInterfacesService implements IssueServiceInterface {
|
||||
private readonly _logseqApiService = inject(LogseqApiService);
|
||||
private readonly _issueProviderService = inject(IssueProviderService);
|
||||
private readonly _taskService = inject(TaskService);
|
||||
|
||||
pollInterval: number = LOGSEQ_POLL_INTERVAL;
|
||||
|
||||
// Write-mutex: Set of blockUuids currently being written to
|
||||
// Used to skip discrepancy detection during writes
|
||||
private _blocksBeingWritten = new Set<string>();
|
||||
|
||||
// Subject for emitting discrepancies detected during polling
|
||||
discrepancies$ = new Subject<DiscrepancyItem>();
|
||||
|
||||
isEnabled(cfg: LogseqCfg): boolean {
|
||||
return isLogseqEnabled(cfg);
|
||||
}
|
||||
|
|
@ -121,7 +144,6 @@ export class LogseqCommonInterfacesService implements IssueServiceInterface {
|
|||
title: extractFirstLine(block.content),
|
||||
issueLastUpdated: block.updatedAt,
|
||||
isDone: block.marker === 'DONE',
|
||||
issueMarker: encodeMarkerWithHash(block.marker, block.content),
|
||||
};
|
||||
|
||||
// If time is specified, use dueWithTime, otherwise use dueDay
|
||||
|
|
@ -159,6 +181,8 @@ export class LogseqCommonInterfacesService implements IssueServiceInterface {
|
|||
return {
|
||||
...baseData,
|
||||
issueWasUpdated: false,
|
||||
dueDay: undefined, // Clear dueDay when no SCHEDULED
|
||||
dueWithTime: undefined, // Clear dueWithTime when no SCHEDULED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -175,6 +199,13 @@ export class LogseqCommonInterfacesService implements IssueServiceInterface {
|
|||
throw new Error('No issueId');
|
||||
}
|
||||
|
||||
// Skip if this block is currently being written to (write-mutex)
|
||||
// This prevents race conditions where polling sees stale data during a write
|
||||
if (this._blocksBeingWritten.has(task.issueId as string)) {
|
||||
console.log('[LOGSEQ POLL] Skipping - block is being written:', task.issueId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cfg = await this._getCfgOnce$(task.issueProviderId).toPromise();
|
||||
if (!cfg) {
|
||||
throw new Error('No config found for issueProviderId');
|
||||
|
|
@ -188,65 +219,146 @@ export class LogseqCommonInterfacesService implements IssueServiceInterface {
|
|||
}
|
||||
|
||||
const blockTitle = extractFirstLine(block.content);
|
||||
const isTitleChanged = blockTitle !== task.title;
|
||||
const isDoneChanged = (block.marker === 'DONE') !== task.isDone;
|
||||
// Note: We don't compare titles directly anymore.
|
||||
// Title changes in Logseq are detected via content hash change.
|
||||
// This allows users to rename tasks in SuperProd without triggering updates.
|
||||
|
||||
// Decode stored marker and hash
|
||||
const storedData = decodeMarker(task.issueMarker);
|
||||
const currentHash = hashCode(block.content);
|
||||
const isMarkerChanged = block.marker !== storedData.marker;
|
||||
// Check for marker discrepancy (triggers discrepancy dialog, but NOT "updated" badge)
|
||||
// Get current task ID to check active status discrepancy
|
||||
const currentTaskId = this._taskService.currentTaskId();
|
||||
const isTaskActive = currentTaskId === task.id;
|
||||
const isBlockActive = block.marker === 'NOW' || block.marker === 'DOING';
|
||||
const isBlockDone = block.marker === 'DONE';
|
||||
|
||||
// Detect all marker discrepancies:
|
||||
// 1. DONE status differs
|
||||
// 2. Active status differs (block is active but task not, or vice versa)
|
||||
const isDoneDiscrepancy = isBlockDone !== task.isDone;
|
||||
const isActiveDiscrepancy = !task.isDone && isBlockActive !== isTaskActive;
|
||||
const isMarkerDiscrepancy = isDoneDiscrepancy || isActiveDiscrepancy;
|
||||
|
||||
// Read stored sync data from :SP: drawer in block content
|
||||
const spDrawerData = extractSpDrawerData(block.content);
|
||||
const currentHash = calculateContentHash(block.content);
|
||||
|
||||
// Initialize :SP: drawer if it doesn't exist yet
|
||||
// This ensures we can detect future changes
|
||||
// Do this BEFORE checking for updates so it always happens
|
||||
if (spDrawerData.contentHash === null) {
|
||||
console.log('[LOGSEQ SP DRAWER] Initializing drawer for task:', task.id);
|
||||
await this.updateSpDrawer(task.issueId as string, task.issueProviderId);
|
||||
}
|
||||
|
||||
// Check for content changes using drawer data
|
||||
// Only consider it changed if we have a stored hash AND it differs from current
|
||||
// If no stored hash exists, we can't detect content changes yet
|
||||
const isContentChanged =
|
||||
storedData.contentHash !== null && storedData.contentHash !== currentHash;
|
||||
spDrawerData.contentHash !== null && spDrawerData.contentHash !== currentHash;
|
||||
|
||||
// Check if scheduled date/time changed
|
||||
const blockScheduledDate = extractScheduledDate(block.content);
|
||||
const blockScheduledDateTime = extractScheduledDateTime(block.content);
|
||||
|
||||
// Simple comparison: trust the bidirectional sync
|
||||
// Any timing discrepancies will be corrected on next poll
|
||||
const isDueDateChanged = (blockScheduledDate ?? null) !== (task.dueDay ?? null);
|
||||
// Only detect date/time changes if block content actually changed (hash differs)
|
||||
// This prevents overwriting SuperProd changes before they're synced to Logseq
|
||||
// If hash is same or no drawer exists, SuperProd has authority
|
||||
const isDueDateChanged =
|
||||
isContentChanged && (blockScheduledDate ?? null) !== (task.dueDay ?? null);
|
||||
const isDueTimeChanged =
|
||||
(blockScheduledDateTime ?? null) !== (task.dueWithTime ?? null);
|
||||
isContentChanged && (blockScheduledDateTime ?? null) !== (task.dueWithTime ?? null);
|
||||
|
||||
// Determine if this is a content change (should mark as "updated")
|
||||
// vs just a marker discrepancy (should show dialog but not mark as "updated")
|
||||
const hasContentChange = isContentChanged || isDueDateChanged || isDueTimeChanged;
|
||||
|
||||
console.log('[LOGSEQ UPDATE CHECK]', {
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
isTitleChanged,
|
||||
isDoneChanged,
|
||||
isMarkerChanged,
|
||||
isMarkerDiscrepancy,
|
||||
isDoneDiscrepancy,
|
||||
isActiveDiscrepancy,
|
||||
isTaskActive,
|
||||
isBlockActive,
|
||||
hasContentChange,
|
||||
isContentChanged,
|
||||
isDueDateChanged,
|
||||
isDueTimeChanged,
|
||||
blockTitle,
|
||||
blockMarker: block.marker,
|
||||
storedMarker: storedData.marker,
|
||||
storedHash: storedData.contentHash,
|
||||
spDrawerData,
|
||||
currentHash,
|
||||
taskIsDone: task.isDone,
|
||||
blockScheduledDate,
|
||||
taskDueDay: task.dueDay,
|
||||
blockScheduledDateTime,
|
||||
taskDueWithTime: task.dueWithTime,
|
||||
blockContent: block.content,
|
||||
});
|
||||
|
||||
// Trigger update if there's a substantive change
|
||||
if (
|
||||
isTitleChanged ||
|
||||
isDoneChanged ||
|
||||
isMarkerChanged ||
|
||||
isContentChanged ||
|
||||
isDueDateChanged ||
|
||||
isDueTimeChanged
|
||||
) {
|
||||
// Emit marker discrepancies to Subject for dialog handling
|
||||
// This is done separately from content changes to allow the dialog to handle them
|
||||
if (isMarkerDiscrepancy) {
|
||||
let discrepancyType: DiscrepancyType;
|
||||
if (isDoneDiscrepancy) {
|
||||
discrepancyType = isBlockDone
|
||||
? 'LOGSEQ_DONE_SUPERPROD_NOT_DONE'
|
||||
: 'SUPERPROD_DONE_LOGSEQ_NOT_DONE';
|
||||
} else {
|
||||
// isActiveDiscrepancy
|
||||
discrepancyType = isBlockActive
|
||||
? 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE'
|
||||
: 'SUPERPROD_ACTIVE_LOGSEQ_NOT_ACTIVE';
|
||||
}
|
||||
|
||||
console.log('[LOGSEQ DISCREPANCY] Emitting:', {
|
||||
taskId: task.id,
|
||||
discrepancyType,
|
||||
});
|
||||
|
||||
this.discrepancies$.next({
|
||||
task,
|
||||
block,
|
||||
discrepancyType,
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger update if there's a marker discrepancy OR content change
|
||||
// - Marker discrepancy: triggers discrepancy dialog (emitted above)
|
||||
// - Content change: triggers "updated" badge
|
||||
if (isMarkerDiscrepancy || hasContentChange) {
|
||||
// Update :SP: drawer with new hash so next poll won't trigger false positive
|
||||
if (hasContentChange) {
|
||||
await this.updateSpDrawer(task.issueId as string, task.issueProviderId);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { title, isDone, ...taskDataWithoutTitleAndDone } = this.getAddTaskData(
|
||||
mapBlockToIssueReduced(block),
|
||||
);
|
||||
|
||||
// For marker discrepancy without content change, we need to force an update
|
||||
// by changing issueLastUpdated. Otherwise, if all task properties are the same,
|
||||
// no update action is dispatched and the discrepancy dialog won't appear.
|
||||
const forceUpdate = isMarkerDiscrepancy && !hasContentChange;
|
||||
|
||||
return {
|
||||
taskChanges: {
|
||||
...this.getAddTaskData(mapBlockToIssueReduced(block)),
|
||||
issueWasUpdated: true,
|
||||
// Don't update title - it's only set on task creation
|
||||
// Users can rename tasks in SuperProd without it being overwritten
|
||||
...taskDataWithoutTitleAndDone,
|
||||
// Don't update isDone for marker discrepancies - let the discrepancy dialog handle it
|
||||
// Only update isDone for content changes when there's no DONE discrepancy
|
||||
...(hasContentChange && !isDoneDiscrepancy ? { isDone } : {}),
|
||||
// Force update by setting issueLastUpdated to now if only marker changed
|
||||
...(forceUpdate ? { issueLastUpdated: Date.now() } : {}),
|
||||
// Only mark as "updated" for content changes, not marker discrepancies
|
||||
issueWasUpdated: hasContentChange,
|
||||
},
|
||||
issue: block,
|
||||
issueTitle: extractFirstLine(block.content),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -336,21 +448,29 @@ export class LogseqCommonInterfacesService implements IssueServiceInterface {
|
|||
newMarker: 'TODO' | 'DOING' | 'LATER' | 'NOW' | 'DONE',
|
||||
): Promise<void> {
|
||||
const cfg = await this._getCfgOnce$(issueProviderId).toPromise();
|
||||
if (!cfg || !cfg.isUpdateBlockOnTaskDone) {
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = await this.getById(blockUuid, issueProviderId);
|
||||
// Set write-mutex to prevent discrepancy detection during write
|
||||
this._blocksBeingWritten.add(blockUuid);
|
||||
|
||||
// Update marker in content
|
||||
const updatedContent = block.content.replace(
|
||||
/^(TODO|DONE|DOING|LATER|WAITING|NOW)\s+/i,
|
||||
`${newMarker} `,
|
||||
);
|
||||
try {
|
||||
const block = await this.getById(blockUuid, issueProviderId);
|
||||
|
||||
await this._logseqApiService
|
||||
.updateBlock$(block.uuid, updatedContent, cfg)
|
||||
.toPromise();
|
||||
// Update marker in content
|
||||
const updatedContent = block.content.replace(
|
||||
/^(TODO|DONE|DOING|LATER|WAITING|NOW)\s+/i,
|
||||
`${newMarker} `,
|
||||
);
|
||||
|
||||
await this._logseqApiService
|
||||
.updateBlock$(block.uuid, updatedContent, cfg)
|
||||
.toPromise();
|
||||
} finally {
|
||||
// Clear write-mutex after write completes
|
||||
this._blocksBeingWritten.delete(blockUuid);
|
||||
}
|
||||
}
|
||||
|
||||
async updateIssueFromTask(task: Task): Promise<void> {
|
||||
|
|
@ -363,75 +483,121 @@ export class LogseqCommonInterfacesService implements IssueServiceInterface {
|
|||
return;
|
||||
}
|
||||
|
||||
const block = await this.getById(task.issueId as string, task.issueProviderId);
|
||||
const todayStr = getDbDateStr();
|
||||
// Set write-mutex to prevent discrepancy detection during write
|
||||
this._blocksBeingWritten.add(task.issueId as string);
|
||||
|
||||
let updatedContent = block.content;
|
||||
let hasChanges = false;
|
||||
try {
|
||||
const block = await this.getById(task.issueId as string, task.issueProviderId);
|
||||
const todayStr = getDbDateStr();
|
||||
|
||||
// Update marker if task done status changed
|
||||
if (cfg.isUpdateBlockOnTaskDone && task.isDone && block.marker !== 'DONE') {
|
||||
updatedContent = updatedContent.replace(
|
||||
/^(TODO|DONE|DOING|LATER|WAITING|NOW)\s+/i,
|
||||
'DONE ',
|
||||
);
|
||||
hasChanges = true;
|
||||
let updatedContent = block.content;
|
||||
let hasChanges = false;
|
||||
|
||||
// Update marker if task done status changed
|
||||
if (task.isDone && block.marker !== 'DONE') {
|
||||
updatedContent = updatedContent.replace(
|
||||
/^(TODO|DONE|DOING|LATER|WAITING|NOW)\s+/i,
|
||||
'DONE ',
|
||||
);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update SCHEDULED - use dueWithTime if available, otherwise dueDay
|
||||
const currentScheduledDate = extractScheduledDate(block.content);
|
||||
const currentScheduledDateTime = extractScheduledDateTime(block.content);
|
||||
|
||||
// Determine what should be in Logseq
|
||||
let shouldUpdateScheduled = false;
|
||||
|
||||
if (task.dueWithTime) {
|
||||
// Task has time - check if it differs from current
|
||||
if (currentScheduledDateTime !== task.dueWithTime) {
|
||||
updatedContent = updateScheduledInContent(updatedContent, task.dueWithTime);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
} else if (task.dueDay) {
|
||||
// Smart Reschedule: If task was overdue in Logseq and is now set to today
|
||||
// Update SCHEDULED in Logseq to today as well
|
||||
const wasOverdueInLogseq =
|
||||
currentScheduledDate !== null && currentScheduledDate < todayStr;
|
||||
const isNowScheduledForToday = task.dueDay === todayStr;
|
||||
|
||||
if (wasOverdueInLogseq && isNowScheduledForToday) {
|
||||
console.log('[LOGSEQ SMART RESCHEDULE] Updating overdue task to today:', {
|
||||
taskTitle: task.title,
|
||||
oldScheduledDate: currentScheduledDate,
|
||||
newScheduledDate: todayStr,
|
||||
});
|
||||
updatedContent = updateScheduledInContent(updatedContent, todayStr);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
// Normal case: Task has only date - check if it differs from current
|
||||
else if (
|
||||
currentScheduledDate !== task.dueDay ||
|
||||
currentScheduledDateTime !== null
|
||||
) {
|
||||
updatedContent = updateScheduledInContent(updatedContent, task.dueDay);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
} else {
|
||||
// Task has no due date - remove SCHEDULED if present
|
||||
if (currentScheduledDate !== null || currentScheduledDateTime !== null) {
|
||||
updatedContent = updateScheduledInContent(updatedContent, null);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdateScheduled) {
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
await this._logseqApiService
|
||||
.updateBlock$(block.uuid, updatedContent, cfg)
|
||||
.toPromise();
|
||||
|
||||
// Update :SP: drawer with new sync data
|
||||
await this.updateSpDrawer(task.issueId as string, task.issueProviderId);
|
||||
}
|
||||
} finally {
|
||||
// Clear write-mutex after write completes
|
||||
this._blocksBeingWritten.delete(task.issueId as string);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the :SP: drawer in a block with current sync timestamp and content hash
|
||||
* This should be called after any sync operation to track the synced state
|
||||
*/
|
||||
async updateSpDrawer(blockUuid: string, issueProviderId: string): Promise<void> {
|
||||
const cfg = await this._getCfgOnce$(issueProviderId).toPromise();
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update SCHEDULED - use dueWithTime if available, otherwise dueDay
|
||||
const currentScheduledDate = extractScheduledDate(block.content);
|
||||
const currentScheduledDateTime = extractScheduledDateTime(block.content);
|
||||
|
||||
// Determine what should be in Logseq
|
||||
let shouldUpdateScheduled = false;
|
||||
|
||||
if (task.dueWithTime) {
|
||||
// Task has time - check if it differs from current
|
||||
if (currentScheduledDateTime !== task.dueWithTime) {
|
||||
updatedContent = updateScheduledInContent(updatedContent, task.dueWithTime);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
} else if (task.dueDay) {
|
||||
// Smart Reschedule: If task was overdue in Logseq and is now set to today
|
||||
// Update SCHEDULED in Logseq to today as well
|
||||
const wasOverdueInLogseq =
|
||||
currentScheduledDate !== null && currentScheduledDate < todayStr;
|
||||
const isNowScheduledForToday = task.dueDay === todayStr;
|
||||
|
||||
if (wasOverdueInLogseq && isNowScheduledForToday) {
|
||||
console.log('[LOGSEQ SMART RESCHEDULE] Updating overdue task to today:', {
|
||||
taskTitle: task.title,
|
||||
oldScheduledDate: currentScheduledDate,
|
||||
newScheduledDate: todayStr,
|
||||
});
|
||||
updatedContent = updateScheduledInContent(updatedContent, todayStr);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
// Normal case: Task has only date - check if it differs from current
|
||||
else if (
|
||||
currentScheduledDate !== task.dueDay ||
|
||||
currentScheduledDateTime !== null
|
||||
) {
|
||||
updatedContent = updateScheduledInContent(updatedContent, task.dueDay);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
} else {
|
||||
// Task has no due date - remove SCHEDULED if present
|
||||
if (currentScheduledDate !== null || currentScheduledDateTime !== null) {
|
||||
updatedContent = updateScheduledInContent(updatedContent, null);
|
||||
shouldUpdateScheduled = true;
|
||||
}
|
||||
const block = await this._logseqApiService
|
||||
.getBlockByUuid$(blockUuid, cfg)
|
||||
.toPromise();
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUpdateScheduled) {
|
||||
hasChanges = true;
|
||||
}
|
||||
// Calculate hash without the :SP: drawer to avoid self-referential hash
|
||||
const contentHash = calculateContentHash(block.content);
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (hasChanges) {
|
||||
await this._logseqApiService
|
||||
.updateBlock$(block.uuid, updatedContent, cfg)
|
||||
.toPromise();
|
||||
}
|
||||
// Update the block content with new :SP: drawer
|
||||
const updatedContent = updateSpDrawerInContent(block.content, timestamp, contentHash);
|
||||
|
||||
await this._logseqApiService
|
||||
.updateBlock$(block.uuid, updatedContent, cfg)
|
||||
.toPromise();
|
||||
|
||||
console.log('[LOGSEQ SP DRAWER] Updated:', {
|
||||
blockUuid,
|
||||
timestamp,
|
||||
contentHash,
|
||||
});
|
||||
}
|
||||
|
||||
private _getCfgOnce$(issueProviderId: string): Observable<LogseqCfg> {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { LogseqBlock } from './logseq-issue.model';
|
|||
import {
|
||||
extractFirstLine,
|
||||
extractRestOfContent,
|
||||
extractPropertiesFromContent,
|
||||
extractScheduledDate,
|
||||
extractScheduledDateTime,
|
||||
} from './logseq-issue-map.util';
|
||||
|
|
@ -79,50 +78,6 @@ export const LOGSEQ_ISSUE_CONTENT_CONFIG: IssueContentConfig<LogseqBlock> = {
|
|||
},
|
||||
type: IssueFieldType.MARKDOWN,
|
||||
},
|
||||
{
|
||||
label: 'Properties',
|
||||
value: (block: LogseqBlock) => {
|
||||
// Combine API properties and content properties
|
||||
const apiProps = block.properties || {};
|
||||
const contentProps = extractPropertiesFromContent(block.content);
|
||||
|
||||
// Merge without duplicates - API properties take precedence for inline properties
|
||||
const allProps: Record<string, any> = { ...contentProps, ...apiProps };
|
||||
|
||||
if (Object.keys(allProps).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const contentLines: string[] = [];
|
||||
|
||||
// Separate inline properties from property blocks
|
||||
const inlineProps: Record<string, any> = {};
|
||||
const blockProps: Record<string, any> = {};
|
||||
|
||||
Object.entries(allProps).forEach(([key, value]) => {
|
||||
if (typeof value === 'string' && value.includes('\n')) {
|
||||
blockProps[key] = value;
|
||||
} else {
|
||||
inlineProps[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Show inline properties first
|
||||
Object.entries(inlineProps).forEach(([key, value]) => {
|
||||
contentLines.push(`**${key}:** ${value}`);
|
||||
});
|
||||
|
||||
// Show property blocks (like LOGBOOK) as collapsible sections
|
||||
Object.entries(blockProps).forEach(([key, value]) => {
|
||||
contentLines.push(
|
||||
`\n<details>\n<summary><strong>${key}</strong></summary>\n\n\`\`\`\n${value}\n\`\`\`\n\n</details>`,
|
||||
);
|
||||
});
|
||||
|
||||
return contentLines.join(' \n');
|
||||
},
|
||||
type: IssueFieldType.MARKDOWN,
|
||||
},
|
||||
],
|
||||
getIssueUrl: (block) => `logseq://graph/logseq?block-id=${block.uuid}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import {
|
|||
extractFirstLine,
|
||||
extractBlockText,
|
||||
removeLogseqFormatting,
|
||||
extractSpDrawerData,
|
||||
getContentWithoutSpDrawer,
|
||||
updateSpDrawerInContent,
|
||||
calculateContentHash,
|
||||
} from './logseq-issue-map.util';
|
||||
|
||||
describe('logseq-issue-map.util', () => {
|
||||
|
|
@ -75,4 +79,146 @@ describe('logseq-issue-map.util', () => {
|
|||
expect(extractBlockText(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSpDrawerData', () => {
|
||||
it('should extract lastSync and contentHash from :SP: drawer', () => {
|
||||
const content = `TODO My Task
|
||||
SCHEDULED: <2026-01-20 Mon>
|
||||
:SP:
|
||||
superprod-last-sync:: 1705766400000
|
||||
superprod-content-hash:: -1234567890
|
||||
:END:`;
|
||||
|
||||
const result = extractSpDrawerData(content);
|
||||
|
||||
expect(result.lastSync).toBe(1705766400000);
|
||||
expect(result.contentHash).toBe(-1234567890);
|
||||
});
|
||||
|
||||
it('should return nulls when no :SP: drawer present', () => {
|
||||
const content = 'TODO Simple task without drawer';
|
||||
|
||||
const result = extractSpDrawerData(content);
|
||||
|
||||
expect(result.lastSync).toBeNull();
|
||||
expect(result.contentHash).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle partial drawer data', () => {
|
||||
const content = `TODO Task
|
||||
:SP:
|
||||
superprod-last-sync:: 1705766400000
|
||||
:END:`;
|
||||
|
||||
const result = extractSpDrawerData(content);
|
||||
|
||||
expect(result.lastSync).toBe(1705766400000);
|
||||
expect(result.contentHash).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentWithoutSpDrawer', () => {
|
||||
it('should remove :SP: drawer from content', () => {
|
||||
const content = `TODO My Task
|
||||
SCHEDULED: <2026-01-20 Mon>
|
||||
:SP:
|
||||
superprod-last-sync:: 1705766400000
|
||||
superprod-content-hash:: -1234567890
|
||||
:END:
|
||||
Some notes`;
|
||||
|
||||
const result = getContentWithoutSpDrawer(content);
|
||||
|
||||
expect(result).not.toContain(':SP:');
|
||||
expect(result).not.toContain('superprod-last-sync');
|
||||
expect(result).toContain('TODO My Task');
|
||||
expect(result).toContain('Some notes');
|
||||
});
|
||||
|
||||
it('should return content unchanged when no drawer present', () => {
|
||||
const content = 'TODO Simple task';
|
||||
|
||||
const result = getContentWithoutSpDrawer(content);
|
||||
|
||||
expect(result).toBe('TODO Simple task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSpDrawerInContent', () => {
|
||||
it('should add :SP: drawer after SCHEDULED line', () => {
|
||||
const content = `TODO My Task
|
||||
SCHEDULED: <2026-01-20 Mon>`;
|
||||
|
||||
const result = updateSpDrawerInContent(content, 1705766400000, -123456);
|
||||
|
||||
expect(result).toContain(':SP:');
|
||||
expect(result).toContain('superprod-last-sync:: 1705766400000');
|
||||
expect(result).toContain('superprod-content-hash:: -123456');
|
||||
expect(result).toContain(':END:');
|
||||
// Drawer should be after SCHEDULED
|
||||
const schedIndex = result.indexOf('SCHEDULED:');
|
||||
const drawerIndex = result.indexOf(':SP:');
|
||||
expect(drawerIndex).toBeGreaterThan(schedIndex);
|
||||
});
|
||||
|
||||
it('should add :SP: drawer after first line when no SCHEDULED', () => {
|
||||
const content = 'TODO Simple task';
|
||||
|
||||
const result = updateSpDrawerInContent(content, 1705766400000, -123456);
|
||||
|
||||
expect(result).toContain(':SP:');
|
||||
expect(result).toContain('superprod-last-sync:: 1705766400000');
|
||||
});
|
||||
|
||||
it('should replace existing :SP: drawer', () => {
|
||||
const content = `TODO My Task
|
||||
:SP:
|
||||
superprod-last-sync:: 1000000000000
|
||||
superprod-content-hash:: -999999
|
||||
:END:`;
|
||||
|
||||
const result = updateSpDrawerInContent(content, 1705766400000, -123456);
|
||||
|
||||
expect(result).toContain('superprod-last-sync:: 1705766400000');
|
||||
expect(result).toContain('superprod-content-hash:: -123456');
|
||||
expect(result).not.toContain('1000000000000');
|
||||
expect(result).not.toContain('-999999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateContentHash', () => {
|
||||
it('should calculate consistent hash for same content', () => {
|
||||
const content = 'TODO My Task\nSCHEDULED: <2026-01-20 Mon>';
|
||||
|
||||
const hash1 = calculateContentHash(content);
|
||||
const hash2 = calculateContentHash(content);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should ignore :SP: drawer when calculating hash', () => {
|
||||
const contentWithoutDrawer = 'TODO My Task\nSCHEDULED: <2026-01-20 Mon>';
|
||||
const contentWithDrawer = `TODO My Task
|
||||
SCHEDULED: <2026-01-20 Mon>
|
||||
:SP:
|
||||
superprod-last-sync:: 1705766400000
|
||||
superprod-content-hash:: -123456
|
||||
:END:`;
|
||||
|
||||
const hashWithout = calculateContentHash(contentWithoutDrawer);
|
||||
const hashWith = calculateContentHash(contentWithDrawer);
|
||||
|
||||
expect(hashWithout).toBe(hashWith);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different content', () => {
|
||||
const content1 = 'TODO Task A';
|
||||
const content2 = 'TODO Task B';
|
||||
|
||||
const hash1 = calculateContentHash(content1);
|
||||
const hash2 = calculateContentHash(content2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,107 @@
|
|||
import { LogseqBlock, LogseqBlockReduced } from './logseq-issue.model';
|
||||
import { SearchResultItem } from '../../issue.model';
|
||||
import { hashCode } from './logseq-marker-hash.util';
|
||||
|
||||
// SP Drawer data interface
|
||||
export interface SpDrawerData {
|
||||
lastSync: number | null;
|
||||
contentHash: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data from :SP: drawer in block content
|
||||
* Returns lastSync timestamp and contentHash if present
|
||||
*/
|
||||
export const extractSpDrawerData = (content: string): SpDrawerData => {
|
||||
const result: SpDrawerData = { lastSync: null, contentHash: null };
|
||||
|
||||
// Match the :SP: drawer block
|
||||
const drawerMatch = content.match(/:SP:\s*\n([\s\S]*?)\n:END:/);
|
||||
if (!drawerMatch) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const drawerContent = drawerMatch[1];
|
||||
|
||||
// Extract superprod-last-sync
|
||||
const lastSyncMatch = drawerContent.match(/superprod-last-sync::\s*(\d+)/);
|
||||
if (lastSyncMatch) {
|
||||
result.lastSync = parseInt(lastSyncMatch[1], 10);
|
||||
}
|
||||
|
||||
// Extract superprod-content-hash
|
||||
const hashMatch = drawerContent.match(/superprod-content-hash::\s*(-?\d+)/);
|
||||
if (hashMatch) {
|
||||
result.contentHash = parseInt(hashMatch[1], 10);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get content without drawers and marker (for hash calculation)
|
||||
* Removes :SP:, :LOGBOOK:, and other drawer blocks, plus the marker prefix
|
||||
* This ensures the hash only includes the actual task content text
|
||||
*/
|
||||
export const getContentWithoutSpDrawer = (content: string): string => {
|
||||
return (
|
||||
content
|
||||
// Remove all drawer blocks (:SP:, :LOGBOOK:, :PROPERTIES:, etc.)
|
||||
.replace(/:[A-Z_]+:\s*\n[\s\S]*?\n:END:\n?/g, '')
|
||||
// Remove marker prefix (TODO, DOING, DONE, NOW, LATER, WAITING)
|
||||
.replace(/^(TODO|DONE|DOING|LATER|WAITING|NOW)\s+/i, '')
|
||||
.trim()
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update or add :SP: drawer in block content
|
||||
* Places the drawer after SCHEDULED line (if present) or after first line
|
||||
*/
|
||||
export const updateSpDrawerInContent = (
|
||||
content: string,
|
||||
timestamp: number,
|
||||
contentHash: number,
|
||||
): string => {
|
||||
// First, remove any existing :SP: drawer
|
||||
const updatedContent = content.replace(/:SP:\s*\n[\s\S]*?\n:END:\n?/g, '');
|
||||
|
||||
// Build the new drawer content
|
||||
const drawerContent = `:SP:
|
||||
superprod-last-sync:: ${timestamp}
|
||||
superprod-content-hash:: ${contentHash}
|
||||
:END:`;
|
||||
|
||||
// Find the insertion point - after SCHEDULED line if present, otherwise after first line
|
||||
const lines = updatedContent.split('\n');
|
||||
let insertIndex = 1; // Default: after first line
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
// Insert after SCHEDULED or DEADLINE lines
|
||||
if (line.match(/^(SCHEDULED|DEADLINE):\s*<[^>]+>/)) {
|
||||
insertIndex = i + 1;
|
||||
}
|
||||
// Stop before property blocks or actual content
|
||||
if (i > 0 && (line.match(/^:[A-Z_]+:$/) || (!line.match(/^\S+::/) && line !== ''))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the drawer
|
||||
lines.splice(insertIndex, 0, drawerContent);
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate hash for block content (excluding all drawers)
|
||||
*/
|
||||
export const calculateContentHash = (content: string): number => {
|
||||
const contentWithoutDrawers = getContentWithoutSpDrawer(content);
|
||||
console.log('[LOGSEQ HASH] Content without drawers:', contentWithoutDrawers);
|
||||
return hashCode(contentWithoutDrawers);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove Logseq-specific formatting (page links and tags)
|
||||
|
|
|
|||
|
|
@ -6,38 +6,28 @@ import {
|
|||
withLatestFrom,
|
||||
mergeMap,
|
||||
switchMap,
|
||||
take,
|
||||
bufferTime,
|
||||
debounceTime,
|
||||
tap,
|
||||
buffer,
|
||||
} from 'rxjs/operators';
|
||||
import { TaskSharedActions } from '../../../../root-store/meta/task-shared.actions';
|
||||
import { setCurrentTask, unsetCurrentTask } from '../../../tasks/store/task.actions';
|
||||
import { PlannerActions } from '../../../planner/store/planner.actions';
|
||||
import { TaskService } from '../../../tasks/task.service';
|
||||
import { LogseqCommonInterfacesService } from './logseq-common-interfaces.service';
|
||||
import {
|
||||
LogseqCommonInterfacesService,
|
||||
DiscrepancyItem,
|
||||
DiscrepancyType,
|
||||
} from './logseq-common-interfaces.service';
|
||||
import { IssueProviderService } from '../../issue-provider.service';
|
||||
import { IssueService } from '../../issue.service';
|
||||
import { EMPTY, concat, of, from, Observable, Subject } from 'rxjs';
|
||||
import { EMPTY, concat, of, Observable } from 'rxjs';
|
||||
import { LogseqTaskWorkflow, LogseqCfg } from './logseq.model';
|
||||
import { LogseqBlock } from './logseq-issue.model';
|
||||
import { LOGSEQ_TYPE } from './logseq.const';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { PluginDialogComponent } from '../../../../plugins/ui/plugin-dialog/plugin-dialog.component';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Task } from '../../../tasks/task.model';
|
||||
import { encodeMarkerWithHash } from './logseq-marker-hash.util';
|
||||
|
||||
type DiscrepancyType =
|
||||
| 'LOGSEQ_DONE_SUPERPROD_NOT_DONE'
|
||||
| 'SUPERPROD_DONE_LOGSEQ_NOT_DONE'
|
||||
| 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE'
|
||||
| 'SUPERPROD_ACTIVE_LOGSEQ_NOT_ACTIVE';
|
||||
|
||||
interface DiscrepancyItem {
|
||||
task: Task;
|
||||
block: LogseqBlock;
|
||||
discrepancyType: DiscrepancyType;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LogseqIssueEffects {
|
||||
|
|
@ -49,7 +39,6 @@ export class LogseqIssueEffects {
|
|||
private _matDialog = inject(MatDialog);
|
||||
private _store = inject(Store);
|
||||
private _previousTaskId: string | null = null;
|
||||
private _discrepancies$ = new Subject<DiscrepancyItem>();
|
||||
private _isDialogOpen = false;
|
||||
|
||||
private _getMarkers(workflow: LogseqTaskWorkflow): {
|
||||
|
|
@ -169,18 +158,33 @@ export class LogseqIssueEffects {
|
|||
break;
|
||||
}
|
||||
|
||||
// Update task with new marker to prevent false positives on next poll
|
||||
// Update :SP: drawer and task state to prevent false positives on next poll
|
||||
if (updatedMarker !== null) {
|
||||
// Fetch updated block to get current content for hash
|
||||
// Update :SP: drawer with current sync state
|
||||
await this._logseqCommonService.updateSpDrawer(
|
||||
task.issueId as string,
|
||||
task.issueProviderId,
|
||||
);
|
||||
|
||||
// Fetch updated block to refresh task details display
|
||||
const updatedBlock = await this._logseqCommonService.getById(
|
||||
task.issueId as string,
|
||||
task.issueProviderId,
|
||||
);
|
||||
|
||||
// Refresh issue data in task detail panel
|
||||
if (updatedBlock) {
|
||||
this._issueService.refreshIssueData(
|
||||
task.issueProviderId,
|
||||
task.issueId as string,
|
||||
updatedBlock,
|
||||
);
|
||||
}
|
||||
|
||||
this._taskService.update(task.id, {
|
||||
issueMarker: encodeMarkerWithHash(updatedMarker, updatedBlock.content),
|
||||
isDone: updatedMarker === 'DONE',
|
||||
issueWasUpdated: true,
|
||||
// Don't mark as "updated" for marker-only changes
|
||||
issueWasUpdated: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -223,17 +227,6 @@ export class LogseqIssueEffects {
|
|||
}
|
||||
}
|
||||
|
||||
private _saveCurrentLogseqState(task: Task, block: LogseqBlock): void {
|
||||
// Save current Logseq state to prevent dialog from reappearing
|
||||
// User has acknowledged the discrepancy and chosen to ignore it
|
||||
// IMPORTANT: Set issueWasUpdated to prevent other effects from syncing to Logseq
|
||||
this._taskService.update(task.id, {
|
||||
issueMarker: encodeMarkerWithHash(block.marker, block.content),
|
||||
isDone: block.marker === 'DONE',
|
||||
issueWasUpdated: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Effect: Start a new task (and stop previous task if any)
|
||||
updateBlockOnTaskStart$ = createEffect(
|
||||
() =>
|
||||
|
|
@ -359,8 +352,8 @@ export class LogseqIssueEffects {
|
|||
this._actions$.pipe(
|
||||
ofType(TaskSharedActions.updateTask),
|
||||
filter(({ task }) => task.changes.isDone === true),
|
||||
// Only sync manual changes, not issue updates
|
||||
filter(({ task }) => task.changes.issueWasUpdated !== true),
|
||||
// Only sync manual changes, not issue/polling updates
|
||||
filter(({ task }) => task.changes.issueWasUpdated === undefined),
|
||||
concatMap(({ task }) => this._taskService.getByIdOnce$(task.id as string)),
|
||||
filter((task) => task.issueType === LOGSEQ_TYPE && !!task.issueId),
|
||||
concatMap((task) => {
|
||||
|
|
@ -393,9 +386,10 @@ export class LogseqIssueEffects {
|
|||
>;
|
||||
const hasDueDateChange = updateAction.task.changes.dueDay !== undefined;
|
||||
const hasDueTimeChange = updateAction.task.changes.dueWithTime !== undefined;
|
||||
// Skip if no due date change, or if this is an issue/polling update
|
||||
if (
|
||||
(!hasDueDateChange && !hasDueTimeChange) ||
|
||||
updateAction.task.changes.issueWasUpdated === true
|
||||
updateAction.task.changes.issueWasUpdated !== undefined
|
||||
) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
|
@ -420,8 +414,9 @@ export class LogseqIssueEffects {
|
|||
this._actions$.pipe(
|
||||
ofType(TaskSharedActions.updateTask),
|
||||
filter(({ task }) => task.changes.isDone === false),
|
||||
// Only sync manual changes, not issue updates
|
||||
filter(({ task }) => task.changes.issueWasUpdated !== true),
|
||||
// Only sync manual changes, not issue/polling updates
|
||||
// issueWasUpdated is undefined for manual actions, false for marker polling, true for content polling
|
||||
filter(({ task }) => task.changes.issueWasUpdated === undefined),
|
||||
concatMap(({ task }) => this._taskService.getByIdOnce$(task.id as string)),
|
||||
filter((task) => task.issueType === LOGSEQ_TYPE && !!task.issueId),
|
||||
withLatestFrom(this._taskService.currentTaskId$),
|
||||
|
|
@ -449,132 +444,13 @@ export class LogseqIssueEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Effect: Show dialog when there's a discrepancy between SuperProd and Logseq
|
||||
// This effect triggers when a task is updated, started, or stopped
|
||||
promptActivateTaskWhenMarkerChanges$ = createEffect(
|
||||
() =>
|
||||
this._actions$.pipe(
|
||||
ofType(TaskSharedActions.updateTask, setCurrentTask, unsetCurrentTask),
|
||||
concatMap((action) => {
|
||||
// Handle different action types
|
||||
if (action.type === TaskSharedActions.updateTask.type) {
|
||||
const updateAction = action as ReturnType<
|
||||
typeof TaskSharedActions.updateTask
|
||||
>;
|
||||
return this._taskService
|
||||
.getByIdOnce$(updateAction.task.id as string)
|
||||
.pipe(filter((task) => task.issueType === LOGSEQ_TYPE && !!task.issueId));
|
||||
} else if (action.type === setCurrentTask.type) {
|
||||
const setAction = action as ReturnType<typeof setCurrentTask>;
|
||||
if (setAction.id) {
|
||||
return this._taskService.getByIdOnce$(setAction.id);
|
||||
}
|
||||
} else if (action.type === unsetCurrentTask.type) {
|
||||
// When task is unset, check if previous task was a Logseq task
|
||||
return this._taskService.currentTaskId$.pipe(
|
||||
take(1),
|
||||
filter((id): id is string => !!id),
|
||||
concatMap((id) => this._taskService.getByIdOnce$(id)),
|
||||
);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
filter((task) => {
|
||||
const isLogseqTask = task.issueType === LOGSEQ_TYPE && !!task.issueId;
|
||||
// Validate UUID format (must be string with UUID format, not a number)
|
||||
const isValidUuid =
|
||||
typeof task.issueId === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
task.issueId,
|
||||
);
|
||||
return isLogseqTask && isValidUuid;
|
||||
}),
|
||||
withLatestFrom(this._taskService.currentTaskId$),
|
||||
mergeMap(([task, currentTaskId]) =>
|
||||
from(
|
||||
this._issueService.getById(
|
||||
LOGSEQ_TYPE,
|
||||
task.issueId as string,
|
||||
task.issueProviderId || '',
|
||||
),
|
||||
).pipe(
|
||||
mergeMap((issue) => {
|
||||
console.log('[LOGSEQ DETECT] Checking task:', task.id, task.title);
|
||||
|
||||
if (!issue) {
|
||||
console.log('[LOGSEQ DETECT] No issue found for task:', task.id);
|
||||
return EMPTY;
|
||||
}
|
||||
const block = issue as LogseqBlock;
|
||||
|
||||
const isTaskActive = currentTaskId === task.id;
|
||||
const isBlockActive = block.marker === 'NOW' || block.marker === 'DOING';
|
||||
const isTaskDone = task.isDone;
|
||||
const isBlockDone = block.marker === 'DONE';
|
||||
|
||||
console.log('[LOGSEQ DETECT] Status:', {
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
isTaskActive,
|
||||
isBlockActive,
|
||||
isTaskDone,
|
||||
isBlockDone,
|
||||
blockMarker: block.marker,
|
||||
currentTaskId,
|
||||
});
|
||||
|
||||
// Detect discrepancies
|
||||
let discrepancyType: DiscrepancyType | null = null;
|
||||
|
||||
if (isBlockDone && !isTaskDone) {
|
||||
discrepancyType = 'LOGSEQ_DONE_SUPERPROD_NOT_DONE';
|
||||
} else if (!isBlockDone && isTaskDone) {
|
||||
discrepancyType = 'SUPERPROD_DONE_LOGSEQ_NOT_DONE';
|
||||
} else if (isBlockActive && !isTaskActive) {
|
||||
discrepancyType = 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE';
|
||||
} else if (isTaskActive && !isBlockActive && !isTaskDone) {
|
||||
discrepancyType = 'SUPERPROD_ACTIVE_LOGSEQ_NOT_ACTIVE';
|
||||
}
|
||||
|
||||
console.log('[LOGSEQ DETECT] Discrepancy type:', discrepancyType);
|
||||
|
||||
if (!discrepancyType) {
|
||||
console.log(
|
||||
'[LOGSEQ DETECT] No discrepancy found, skipping task:',
|
||||
task.id,
|
||||
);
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
console.log('[LOGSEQ DETECT] Pushing to buffer:', {
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
discrepancyType,
|
||||
});
|
||||
|
||||
// Push discrepancy to subject for buffering
|
||||
// Note: Don't check _openDialogTaskIds here, as it would block multiple tasks
|
||||
// from being collected in the buffer. The dialog itself prevents duplicates.
|
||||
this._discrepancies$.next({
|
||||
task,
|
||||
block,
|
||||
discrepancyType,
|
||||
});
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Effect: Buffer discrepancies and show them in a single dialog
|
||||
// Note: Uses 2000ms buffer to catch all discrepancies from polling updates
|
||||
// Effect: Buffer discrepancies from polling and show them in a single dialog
|
||||
// Discrepancies are emitted by LogseqCommonInterfacesService.discrepancies$
|
||||
// Uses debounce to collect all discrepancies from a poll cycle before showing dialog
|
||||
showDiscrepancyDialog$ = createEffect(
|
||||
() =>
|
||||
this._discrepancies$.pipe(
|
||||
bufferTime(2000),
|
||||
this._logseqCommonService.discrepancies$.pipe(
|
||||
buffer(this._logseqCommonService.discrepancies$.pipe(debounceTime(500))),
|
||||
tap((discrepancies) => {
|
||||
console.log('[LOGSEQ BUFFER] Buffered discrepancies:', discrepancies.length);
|
||||
}),
|
||||
|
|
@ -635,6 +511,7 @@ export class LogseqIssueEffects {
|
|||
const buttons = this._buildDiscrepancyButtons(
|
||||
uniqueDiscrepancies,
|
||||
activeDiscrepancies,
|
||||
doneDiscrepancies,
|
||||
);
|
||||
|
||||
// Show dialog
|
||||
|
|
@ -665,23 +542,34 @@ export class LogseqIssueEffects {
|
|||
): string {
|
||||
let html = '<div style="margin-bottom: 16px;">';
|
||||
|
||||
// Special case: Multiple active tasks
|
||||
if (activeDiscrepancies.length > 1) {
|
||||
const logseqActiveCount = activeDiscrepancies.filter(
|
||||
(d) => d.discrepancyType === 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE',
|
||||
).length;
|
||||
// Check for active tasks in Logseq (DOING/NOW)
|
||||
const logseqActiveDiscrepancies = activeDiscrepancies.filter(
|
||||
(d) => d.discrepancyType === 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE',
|
||||
);
|
||||
const hasLogseqActive = logseqActiveDiscrepancies.length >= 1;
|
||||
|
||||
if (logseqActiveCount > 1) {
|
||||
html +=
|
||||
'<p><strong>Mehrere Tasks sind in Logseq als NOW/DOING markiert:</strong></p>';
|
||||
html +=
|
||||
'<p style="margin-bottom: 12px;">In Super Productivity kann nur ein Task aktiv sein. Welchen möchten Sie aktivieren?</p>';
|
||||
html += '<div id="active-task-list" style="margin-left: 16px;">';
|
||||
// Active tasks in Logseq - show radio selection (always, even for single task)
|
||||
if (hasLogseqActive) {
|
||||
const headerText =
|
||||
logseqActiveDiscrepancies.length > 1
|
||||
? 'Mehrere Tasks sind in Logseq als NOW/DOING markiert:'
|
||||
: 'Ein Task ist in Logseq als NOW/DOING markiert:';
|
||||
html += `<p><strong>${headerText}</strong></p>`;
|
||||
html += '<p style="margin-bottom: 12px;">Welchen Task möchten Sie aktivieren?</p>';
|
||||
html += '<div id="active-task-list" style="margin-left: 16px;">';
|
||||
|
||||
activeDiscrepancies
|
||||
.filter((d) => d.discrepancyType === 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE')
|
||||
.forEach((d, index) => {
|
||||
html += `
|
||||
// Option to activate none
|
||||
html += `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input type="radio" name="activeTask" value="__none__" style="margin-right: 8px;">
|
||||
<span style="font-style: italic; color: #888;">Keinen aktivieren (alle in Logseq deaktivieren)</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
logseqActiveDiscrepancies.forEach((d, index) => {
|
||||
html += `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input type="radio" name="activeTask" value="${d.task.id}" ${index === 0 ? 'checked' : ''} style="margin-right: 8px;">
|
||||
|
|
@ -689,30 +577,115 @@ export class LogseqIssueEffects {
|
|||
</label>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
return html + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Standard case: Show all discrepancies grouped
|
||||
if (activeDiscrepancies.length > 0) {
|
||||
html += `<p><strong>🟢 Aktive Tasks (${activeDiscrepancies.length}):</strong></p>`;
|
||||
html += '<ul style="margin-left: 20px; margin-bottom: 12px;">';
|
||||
// Add CSS for toggle button styling (used by both ACTIVE and DONE)
|
||||
if (activeDiscrepancies.length > 0 || doneDiscrepancies.length > 0) {
|
||||
html += `<style>
|
||||
.toggle-label { padding:4px 10px;cursor:pointer;background:transparent;color:inherit; }
|
||||
.toggle-label-right { border-left:1px solid #666; }
|
||||
input:checked + .toggle-label { background:#1976d2;color:white; }
|
||||
</style>`;
|
||||
}
|
||||
|
||||
// Single/few active discrepancies with per-task toggle (not multiple DOING from Logseq)
|
||||
if (!hasLogseqActive && activeDiscrepancies.length > 0) {
|
||||
html += `<p><strong>Aktive Tasks (${activeDiscrepancies.length}):</strong></p>`;
|
||||
html += '<div style="margin-bottom: 12px;">';
|
||||
|
||||
activeDiscrepancies.forEach((d) => {
|
||||
html += `<li>${this._escapeHtml(d.task.title)} - ${this._getDialogMessage(d.discrepancyType, d.task.title)}</li>`;
|
||||
const isActiveInLogseq =
|
||||
d.discrepancyType === 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE';
|
||||
const statusText = isActiveInLogseq
|
||||
? 'Logseq: DOING, SuperProd: inaktiv'
|
||||
: 'SuperProd: aktiv, Logseq: TODO';
|
||||
const title = this._escapeHtml(d.task.title);
|
||||
const taskId = d.task.id;
|
||||
|
||||
const rowStyle =
|
||||
'display:flex;align-items:center;justify-content:space-between;' +
|
||||
'padding:8px 0;border-bottom:1px solid #ccc';
|
||||
const toggleStyle =
|
||||
'display:flex;border:1px solid #666;border-radius:4px;overflow:hidden';
|
||||
|
||||
// Default: accept Logseq value (activate if Logseq is DOING, deactivate if Logseq is TODO)
|
||||
const spChecked = 'checked';
|
||||
const lqChecked = '';
|
||||
const spLabel = isActiveInLogseq ? 'Aktivieren' : 'Deaktivieren';
|
||||
const lqLabel = isActiveInLogseq ? 'Logseq: TODO' : 'Logseq: DOING';
|
||||
|
||||
html += `<div style="${rowStyle}">`;
|
||||
html += `<div style="flex:1;min-width:0;margin-right:12px">`;
|
||||
html +=
|
||||
`<strong style="display:block;overflow:hidden;text-overflow:ellipsis;` +
|
||||
`white-space:nowrap">${title}</strong>`;
|
||||
html += `<small style="color:#666">${statusText}</small></div>`;
|
||||
|
||||
html += `<div class="toggle-group" data-task-id="${taskId}" style="${toggleStyle}">`;
|
||||
html +=
|
||||
`<input type="radio" name="active-action-${taskId}" value="superprod" ` +
|
||||
`id="active-sp-${taskId}" ${spChecked} style="display:none">`;
|
||||
html += `<label for="active-sp-${taskId}" class="toggle-label">${spLabel}</label>`;
|
||||
html +=
|
||||
`<input type="radio" name="active-action-${taskId}" value="logseq" ` +
|
||||
`id="active-lq-${taskId}" ${lqChecked} style="display:none">`;
|
||||
html +=
|
||||
`<label for="active-lq-${taskId}" class="toggle-label toggle-label-right">` +
|
||||
`${lqLabel}</label>`;
|
||||
html += `</div></div>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// DONE discrepancies with per-task action selection
|
||||
if (doneDiscrepancies.length > 0) {
|
||||
html += `<p><strong>✅ Abgeschlossene Tasks (${doneDiscrepancies.length}):</strong></p>`;
|
||||
html += '<ul style="margin-left: 20px; margin-bottom: 12px;">';
|
||||
html += `<p><strong>DONE Status (${doneDiscrepancies.length}):</strong></p>`;
|
||||
html += '<div style="margin-bottom: 12px;">';
|
||||
|
||||
doneDiscrepancies.forEach((d) => {
|
||||
html += `<li>${this._escapeHtml(d.task.title)} - ${this._getDialogMessage(d.discrepancyType, d.task.title)}</li>`;
|
||||
const isDoneInLogseq = d.discrepancyType === 'LOGSEQ_DONE_SUPERPROD_NOT_DONE';
|
||||
const statusText = isDoneInLogseq
|
||||
? 'Logseq: DONE, SuperProd: offen'
|
||||
: 'SuperProd: DONE, Logseq: offen';
|
||||
const logseqLabel = isDoneInLogseq ? 'TODO' : 'DONE';
|
||||
const title = this._escapeHtml(d.task.title);
|
||||
const taskId = d.task.id;
|
||||
|
||||
const rowStyle =
|
||||
'display:flex;align-items:center;justify-content:space-between;' +
|
||||
'padding:8px 0;border-bottom:1px solid #ccc';
|
||||
const toggleStyle =
|
||||
'display:flex;border:1px solid #666;border-radius:4px;overflow:hidden';
|
||||
|
||||
html += `<div style="${rowStyle}">`;
|
||||
html += `<div style="flex:1;min-width:0;margin-right:12px">`;
|
||||
html +=
|
||||
`<strong style="display:block;overflow:hidden;text-overflow:ellipsis;` +
|
||||
`white-space:nowrap">${title}</strong>`;
|
||||
html += `<small style="color:#666">${statusText}</small></div>`;
|
||||
// Always default to accepting Logseq value
|
||||
const spChecked = 'checked';
|
||||
const lqChecked = '';
|
||||
|
||||
html += `<div class="toggle-group" data-task-id="${taskId}" style="${toggleStyle}">`;
|
||||
html +=
|
||||
`<input type="radio" name="action-${taskId}" value="superprod" ` +
|
||||
`id="sp-${taskId}" ${spChecked} style="display:none">`;
|
||||
html += `<label for="sp-${taskId}" class="toggle-label">Abschließen</label>`;
|
||||
html +=
|
||||
`<input type="radio" name="action-${taskId}" value="logseq" ` +
|
||||
`id="lq-${taskId}" ${lqChecked} style="display:none">`;
|
||||
html +=
|
||||
`<label for="lq-${taskId}" class="toggle-label toggle-label-right">` +
|
||||
`Logseq: ${logseqLabel}</label>`;
|
||||
html += `</div></div>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
|
@ -722,92 +695,159 @@ export class LogseqIssueEffects {
|
|||
private _buildDiscrepancyButtons(
|
||||
allDiscrepancies: DiscrepancyItem[],
|
||||
activeDiscrepancies: DiscrepancyItem[],
|
||||
doneDiscrepancies: DiscrepancyItem[],
|
||||
): any[] {
|
||||
const logseqActiveCount = activeDiscrepancies.filter(
|
||||
const logseqActiveDiscrepancies = activeDiscrepancies.filter(
|
||||
(d) => d.discrepancyType === 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE',
|
||||
).length;
|
||||
);
|
||||
const hasLogseqActive = logseqActiveDiscrepancies.length >= 1;
|
||||
const hasDoneDiscrepancies = doneDiscrepancies.length > 0;
|
||||
|
||||
// Special buttons for multiple active tasks
|
||||
if (logseqActiveCount > 1) {
|
||||
// Helper function to handle active task selection
|
||||
const handleActiveTaskSelection = async (): Promise<void> => {
|
||||
const selectedRadio = document.querySelector<HTMLInputElement>(
|
||||
'input[name="activeTask"]:checked',
|
||||
);
|
||||
if (selectedRadio) {
|
||||
const selectedTaskId = selectedRadio.value;
|
||||
if (selectedTaskId === '__none__') {
|
||||
// Deactivate all in Logseq, don't activate any in SuperProd
|
||||
for (const d of logseqActiveDiscrepancies) {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
} else {
|
||||
// Activate selected task in SuperProd
|
||||
this._store.dispatch(setCurrentTask({ id: selectedTaskId }));
|
||||
// Deactivate all others in Logseq
|
||||
for (const d of logseqActiveDiscrepancies) {
|
||||
if (d.task.id !== selectedTaskId) {
|
||||
await this._performLogseqAction(
|
||||
'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE',
|
||||
d.task,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Combined case: Multiple active tasks AND done discrepancies
|
||||
if (hasLogseqActive && hasDoneDiscrepancies) {
|
||||
return [
|
||||
{
|
||||
label: 'Ausgewählten aktivieren, andere deaktivieren',
|
||||
label: 'Anwenden',
|
||||
color: 'primary',
|
||||
onClick: async () => {
|
||||
const selectedRadio = document.querySelector<HTMLInputElement>(
|
||||
'input[name="activeTask"]:checked',
|
||||
);
|
||||
if (selectedRadio) {
|
||||
const selectedTaskId = selectedRadio.value;
|
||||
const selectedTask = activeDiscrepancies.find(
|
||||
(d) => d.task.id === selectedTaskId,
|
||||
// Handle active task selection
|
||||
await handleActiveTaskSelection();
|
||||
|
||||
// Process DONE discrepancies based on individual radio selections
|
||||
for (const d of doneDiscrepancies) {
|
||||
const radio = document.querySelector<HTMLInputElement>(
|
||||
`input[name="action-${d.task.id}"]:checked`,
|
||||
);
|
||||
if (!radio) continue;
|
||||
|
||||
if (selectedTask) {
|
||||
// Activate selected task in SuperProd
|
||||
this._store.dispatch(setCurrentTask({ id: selectedTaskId }));
|
||||
|
||||
// Deactivate all others in Logseq
|
||||
for (const d of activeDiscrepancies) {
|
||||
if (d.task.id !== selectedTaskId) {
|
||||
await this._performLogseqAction(
|
||||
'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE',
|
||||
d.task,
|
||||
);
|
||||
}
|
||||
}
|
||||
const action = radio.value;
|
||||
if (action === 'superprod') {
|
||||
this._performSuperProdAction(d.discrepancyType, d.task);
|
||||
} else if (action === 'logseq') {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Alle in Logseq deaktivieren (TODO/LATER)',
|
||||
color: 'accent',
|
||||
onClick: async () => {
|
||||
for (const d of activeDiscrepancies.filter(
|
||||
(item) => item.discrepancyType === 'LOGSEQ_ACTIVE_SUPERPROD_NOT_ACTIVE',
|
||||
)) {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Alle ignorieren',
|
||||
onClick: () => {
|
||||
allDiscrepancies.forEach((d) => {
|
||||
this._saveCurrentLogseqState(d.task, d.block);
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Standard buttons for mixed or single discrepancies
|
||||
return [
|
||||
{
|
||||
label: 'Alle in Logseq synchronisieren',
|
||||
color: 'accent',
|
||||
// Active tasks in Logseq only (no done discrepancies)
|
||||
if (hasLogseqActive) {
|
||||
return [
|
||||
{
|
||||
label: 'Anwenden',
|
||||
color: 'primary',
|
||||
onClick: handleActiveTaskSelection,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// DONE discrepancies (possibly with single active discrepancy)
|
||||
if (hasDoneDiscrepancies) {
|
||||
const buttons: any[] = [];
|
||||
|
||||
// Quick action: Sync all to Logseq (primary action)
|
||||
buttons.push({
|
||||
label: 'Alle in Logseq syncen',
|
||||
color: 'primary',
|
||||
onClick: async () => {
|
||||
for (const d of allDiscrepancies) {
|
||||
for (const d of doneDiscrepancies) {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
for (const d of activeDiscrepancies) {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Alle in SuperProd synchronisieren',
|
||||
color: 'primary',
|
||||
onClick: () => {
|
||||
allDiscrepancies.forEach((d) => {
|
||||
this._performSuperProdAction(d.discrepancyType, d.task);
|
||||
});
|
||||
});
|
||||
|
||||
// Secondary action: Process individual selections
|
||||
buttons.push({
|
||||
label: 'Anwenden',
|
||||
onClick: async () => {
|
||||
// Process active discrepancies based on individual radio selections
|
||||
for (const d of activeDiscrepancies) {
|
||||
const radio = document.querySelector<HTMLInputElement>(
|
||||
`input[name="active-action-${d.task.id}"]:checked`,
|
||||
);
|
||||
if (!radio) continue;
|
||||
|
||||
const action = radio.value;
|
||||
if (action === 'superprod') {
|
||||
this._performSuperProdAction(d.discrepancyType, d.task);
|
||||
} else if (action === 'logseq') {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
}
|
||||
|
||||
// Process DONE discrepancies based on individual radio selections
|
||||
for (const d of doneDiscrepancies) {
|
||||
const radio = document.querySelector<HTMLInputElement>(
|
||||
`input[name="action-${d.task.id}"]:checked`,
|
||||
);
|
||||
if (!radio) continue;
|
||||
|
||||
const action = radio.value;
|
||||
if (action === 'superprod') {
|
||||
this._performSuperProdAction(d.discrepancyType, d.task);
|
||||
} else if (action === 'logseq') {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
// Standard buttons for active-only discrepancies (with toggle selection)
|
||||
return [
|
||||
{
|
||||
label: 'Alle ignorieren',
|
||||
onClick: () => {
|
||||
allDiscrepancies.forEach((d) => {
|
||||
this._saveCurrentLogseqState(d.task, d.block);
|
||||
});
|
||||
label: 'Anwenden',
|
||||
color: 'primary',
|
||||
onClick: async () => {
|
||||
// Process active discrepancies based on individual radio selections
|
||||
for (const d of activeDiscrepancies) {
|
||||
const radio = document.querySelector<HTMLInputElement>(
|
||||
`input[name="active-action-${d.task.id}"]:checked`,
|
||||
);
|
||||
if (!radio) continue;
|
||||
|
||||
const action = radio.value;
|
||||
if (action === 'superprod') {
|
||||
this._performSuperProdAction(d.discrepancyType, d.task);
|
||||
} else if (action === 'logseq') {
|
||||
await this._performLogseqAction(d.discrepancyType, d.task);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
/**
|
||||
* Utilities for encoding/decoding marker with content hash
|
||||
* This allows tracking content changes without modifying common Task model
|
||||
* Utilities for content hash calculation
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -15,40 +14,3 @@ export const hashCode = (str: string): number => {
|
|||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode marker and content hash into a single string
|
||||
*/
|
||||
export const encodeMarkerWithHash = (marker: string | null, content: string): string => {
|
||||
return JSON.stringify({
|
||||
marker: marker || 'TODO',
|
||||
contentHash: hashCode(content),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode marker from encoded string (backwards compatible)
|
||||
*/
|
||||
export const decodeMarker = (
|
||||
issueMarker: string | null | undefined,
|
||||
): {
|
||||
marker: string;
|
||||
contentHash: number | null;
|
||||
} => {
|
||||
if (!issueMarker) {
|
||||
return { marker: 'TODO', contentHash: null };
|
||||
}
|
||||
|
||||
// Try to parse as JSON (new format)
|
||||
try {
|
||||
const parsed = JSON.parse(issueMarker);
|
||||
if (parsed.marker && typeof parsed.contentHash === 'number') {
|
||||
return { marker: parsed.marker, contentHash: parsed.contentHash };
|
||||
}
|
||||
} catch {
|
||||
// Not JSON - old format (plain marker string)
|
||||
}
|
||||
|
||||
// Old format: plain marker string
|
||||
return { marker: issueMarker, contentHash: null };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,11 +6,9 @@ export const DEFAULT_LOGSEQ_CFG: LogseqCfg = {
|
|||
authToken: null,
|
||||
queryFilter:
|
||||
'[:find (pull ?b [*]) :where [?b :block/marker ?m] [(contains? #{"TODO" "DOING" "LATER" "NOW"} ?m)]]',
|
||||
isUpdateBlockOnTaskDone: true,
|
||||
isIncludeMarkerInUpdateDetection: false,
|
||||
linkFormat: 'logseq-url',
|
||||
taskWorkflow: 'TODO_DOING',
|
||||
superProdReferenceMode: 'property',
|
||||
superProdReferenceProperty: 'superProductivity',
|
||||
};
|
||||
|
||||
export const LOGSEQ_POLL_INTERVAL = 0.5 * 60 * 1000; // half a minute (it's usually local)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ export interface LogseqCfg extends BaseIssueProviderCfg {
|
|||
queryFilter: string;
|
||||
|
||||
// Sync
|
||||
isUpdateBlockOnTaskDone: boolean;
|
||||
isIncludeMarkerInUpdateDetection: boolean;
|
||||
linkFormat: 'logseq-url' | 'http-url';
|
||||
taskWorkflow: LogseqTaskWorkflow;
|
||||
|
||||
// Reference
|
||||
superProdReferenceMode: 'property' | 'child-block' | 'none';
|
||||
superProdReferenceProperty: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@ export interface IssueFieldsForTask {
|
|||
issueAttachmentNr?: number;
|
||||
issueTimeTracked?: IssueTaskTimeTracked;
|
||||
issuePoints?: number;
|
||||
issueMarker?: string | null;
|
||||
}
|
||||
|
||||
// Extend the plugin Task type with app-specific fields
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue