UX changes

This commit is contained in:
baflo 2026-01-21 18:33:51 +01:00
parent 85362bc35f
commit 90e3e03d8b
12 changed files with 842 additions and 523 deletions

View file

@ -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,

View file

@ -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);

View file

@ -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',
},
},
],
},
];

View file

@ -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> {

View file

@ -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}`,
};

View file

@ -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);
});
});
});

View file

@ -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)

View file

@ -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);
}
}
},
},
];

View file

@ -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 };
};

View file

@ -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)

View file

@ -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;
}

View file

@ -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