mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
refactor(workflow): improve code quality and type safety
- Remove debug console.log statements from workflow handlers - Add proper TypeScript type definitions (WorkflowInfo, ResolvedWorkflowInfo) - Extract regex patterns and magic strings as constants - Create helper functions for cleaner code (getIndentation, getEditorFromView) - Improve error handling with bounds checking - Enhance time parsing service with better fallback logic - Add fallback property names for date fields in file task manager - Fix test import paths for relocated modules - Consolidate duplicate code in workflow stage transitions - Improve indentation handling in parent task updater Breaking changes: None
This commit is contained in:
parent
0b0d4af0fe
commit
59a2d42f9f
20 changed files with 1010 additions and 1045 deletions
|
|
@ -12,6 +12,7 @@ module.exports = {
|
|||
"^@codemirror/language$":
|
||||
"<rootDir>/src/__mocks__/codemirror-language.ts",
|
||||
"^@codemirror/search$": "<rootDir>/src/__mocks__/codemirror-search.ts",
|
||||
"^@/.*\\.(css|less|scss|sass)$": "<rootDir>/src/__mocks__/styleMock.js",
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
"\\.(css|less|scss|sass)$": "<rootDir>/src/__mocks__/styleMock.js",
|
||||
".*\\.worker$": "<rootDir>/src/__mocks__/ProjectData.worker.ts",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ const moment = function(input) {
|
|||
clone: function() {
|
||||
return moment(date);
|
||||
},
|
||||
isValid: function() {
|
||||
return !isNaN(date.getTime());
|
||||
},
|
||||
add: function(amount, unit) {
|
||||
return this;
|
||||
},
|
||||
|
|
@ -123,4 +126,6 @@ moment.monthsShort = function() {
|
|||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
};
|
||||
|
||||
module.exports = moment;
|
||||
moment.ISO_8601 = "ISO_8601";
|
||||
|
||||
module.exports = moment;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ export class App {
|
|||
extension: "md",
|
||||
};
|
||||
},
|
||||
|
||||
onLayoutReady: function (callback: () => void) {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
fileManager = {
|
||||
|
|
@ -421,6 +427,40 @@ export class Component {
|
|||
}
|
||||
}
|
||||
|
||||
export class Modal extends Component {
|
||||
app: App;
|
||||
modalEl: HTMLElement | Record<string, unknown>;
|
||||
contentEl: HTMLElement | Record<string, unknown>;
|
||||
|
||||
constructor(app: App) {
|
||||
super();
|
||||
this.app = app;
|
||||
if (typeof document !== "undefined" && document.createElement) {
|
||||
this.modalEl = document.createElement("div");
|
||||
this.contentEl = document.createElement("div");
|
||||
} else {
|
||||
this.modalEl = {};
|
||||
this.contentEl = {};
|
||||
}
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.onOpen();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.onClose();
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
}
|
||||
|
||||
// Mock other common Obsidian utilities
|
||||
export function setIcon(el: HTMLElement, iconId: string): void {
|
||||
// Mock implementation
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ import { createMockPlugin, createMockApp } from "./mockUtils";
|
|||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
// Mock all action executors
|
||||
jest.mock("../utils/onCompletion/DeleteActionExecutor");
|
||||
jest.mock("../utils/onCompletion/KeepActionExecutor");
|
||||
jest.mock("../utils/onCompletion/CompleteActionExecutor");
|
||||
jest.mock("../utils/onCompletion/MoveActionExecutor");
|
||||
jest.mock("../utils/onCompletion/ArchiveActionExecutor");
|
||||
jest.mock("../utils/onCompletion/DuplicateActionExecutor");
|
||||
jest.mock("../executors/completion/delete-executor");
|
||||
jest.mock("../executors/completion/keep-executor");
|
||||
jest.mock("../executors/completion/complete-executor");
|
||||
jest.mock("../executors/completion/move-executor");
|
||||
jest.mock("../executors/completion/archive-executor");
|
||||
jest.mock("../executors/completion/duplicate-executor");
|
||||
|
||||
describe("OnCompletionManager", () => {
|
||||
let manager: OnCompletionManager;
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ jest.mock("moment", () => {
|
|||
return moment;
|
||||
});
|
||||
|
||||
jest.mock("../editor-ext/markdownEditor", () => ({
|
||||
jest.mock("../editor-extensions/core/markdown-editor", () => ({
|
||||
createEmbeddableMarkdownEditor: jest.fn(() => ({
|
||||
value: "",
|
||||
editor: { focus: jest.fn() },
|
||||
|
|
@ -101,7 +101,7 @@ jest.mock("../editor-ext/markdownEditor", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/fileUtils", () => ({
|
||||
jest.mock("../utils/file/file-operations", () => ({
|
||||
saveCapture: jest.fn(),
|
||||
processDateTemplates: jest.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -39,19 +39,19 @@ jest.mock('obsidian', () => {
|
|||
});
|
||||
|
||||
// Mock other dependencies
|
||||
jest.mock('../components/QuickCaptureModal', () => ({
|
||||
jest.mock('../components/features/quick-capture/modals/QuickCaptureModal', () => ({
|
||||
QuickCaptureModal: class MockQuickCaptureModal {},
|
||||
}));
|
||||
|
||||
jest.mock('../editor-ext/markdownEditor', () => ({
|
||||
jest.mock('../editor-extensions/core/markdown-editor', () => ({
|
||||
createEmbeddableMarkdownEditor: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/fileUtils', () => ({
|
||||
jest.mock('../utils/file/file-operations', () => ({
|
||||
saveCapture: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../components/task-view/details', () => ({
|
||||
jest.mock('../components/features/task/view/details', () => ({
|
||||
createTaskCheckbox: jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -378,4 +378,4 @@ describe('TimelineSidebarView Date Deduplication', () => {
|
|||
expect(events[0].task?.content).toBe('Active task');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -471,6 +471,9 @@ const createMockPlugin = (
|
|||
app: mockApp,
|
||||
dataflowOrchestrator: mockDataflowOrchestrator,
|
||||
writeAPI: mockWriteAPI,
|
||||
taskManager: {
|
||||
getCanvasTaskUpdater: jest.fn(),
|
||||
},
|
||||
rewardManager: {
|
||||
// Mock RewardManager
|
||||
showReward: jest.fn(),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { createMockPlugin, createMockApp } from "./mockUtils";
|
|||
import { OnCompletionActionType } from "../types/onCompletion";
|
||||
|
||||
// Mock all the actual executor implementations
|
||||
jest.mock("../utils/onCompletion/DeleteActionExecutor", () => ({
|
||||
jest.mock("../executors/completion/delete-executor", () => ({
|
||||
DeleteActionExecutor: jest.fn().mockImplementation(() => ({
|
||||
execute: jest
|
||||
.fn()
|
||||
|
|
@ -24,7 +24,7 @@ jest.mock("../utils/onCompletion/DeleteActionExecutor", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/onCompletion/CompleteActionExecutor", () => ({
|
||||
jest.mock("../executors/completion/complete-executor", () => ({
|
||||
CompleteActionExecutor: jest.fn().mockImplementation(() => ({
|
||||
execute: jest
|
||||
.fn()
|
||||
|
|
@ -34,7 +34,7 @@ jest.mock("../utils/onCompletion/CompleteActionExecutor", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/onCompletion/MoveActionExecutor", () => ({
|
||||
jest.mock("../executors/completion/move-executor", () => ({
|
||||
MoveActionExecutor: jest.fn().mockImplementation(() => ({
|
||||
execute: jest
|
||||
.fn()
|
||||
|
|
@ -44,7 +44,7 @@ jest.mock("../utils/onCompletion/MoveActionExecutor", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/onCompletion/ArchiveActionExecutor", () => ({
|
||||
jest.mock("../executors/completion/archive-executor", () => ({
|
||||
ArchiveActionExecutor: jest.fn().mockImplementation(() => ({
|
||||
execute: jest
|
||||
.fn()
|
||||
|
|
@ -54,7 +54,7 @@ jest.mock("../utils/onCompletion/ArchiveActionExecutor", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/onCompletion/DuplicateActionExecutor", () => ({
|
||||
jest.mock("../executors/completion/duplicate-executor", () => ({
|
||||
DuplicateActionExecutor: jest.fn().mockImplementation(() => ({
|
||||
execute: jest
|
||||
.fn()
|
||||
|
|
@ -64,7 +64,7 @@ jest.mock("../utils/onCompletion/DuplicateActionExecutor", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/onCompletion/KeepActionExecutor", () => ({
|
||||
jest.mock("../executors/completion/keep-executor", () => ({
|
||||
KeepActionExecutor: jest.fn().mockImplementation(() => ({
|
||||
execute: jest
|
||||
.fn()
|
||||
|
|
|
|||
|
|
@ -717,7 +717,8 @@ export class FluentSidebar extends Component {
|
|||
);
|
||||
}
|
||||
},
|
||||
view // Pass current view as copy source
|
||||
view, // Pass current view as copy source
|
||||
view.id
|
||||
).open();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -118,27 +118,27 @@ function handleParentTaskUpdateTransaction(
|
|||
);
|
||||
|
||||
// Check if there are any child tasks at all
|
||||
const hasAnyChildTasks = hasAnyChildTasksAtLevel(
|
||||
doc,
|
||||
parentLineNumber,
|
||||
indentationLevel,
|
||||
app
|
||||
);
|
||||
const hasAnyChildTasks = hasAnyChildTasksAtLevel(
|
||||
doc,
|
||||
parentLineNumber,
|
||||
indentationLevel,
|
||||
app
|
||||
);
|
||||
|
||||
// Mark as in-progress if:
|
||||
// 1. Parent is currently empty and any sibling has status, OR
|
||||
// 2. Parent is currently complete but not all siblings are complete and there are child tasks
|
||||
if (
|
||||
(parentCurrentStatus === " " && anySiblingHasStatus) ||
|
||||
(parentCurrentStatus === "x" &&
|
||||
!allSiblingsCompleted &&
|
||||
hasAnyChildTasks)
|
||||
) {
|
||||
const inProgressMarker =
|
||||
plugin.settings.taskStatuses.inProgress.split("|")[0] || "/";
|
||||
// Mark as in-progress if:
|
||||
// 1. Parent is currently empty and any sibling has status, OR
|
||||
// 2. Parent is currently complete but not all siblings are complete and there are child tasks
|
||||
if (
|
||||
(parentCurrentStatus === " " && anySiblingHasStatus) ||
|
||||
(parentCurrentStatus === "x" &&
|
||||
!allSiblingsCompleted &&
|
||||
hasAnyChildTasks)
|
||||
) {
|
||||
const inProgressMarker =
|
||||
plugin.settings.taskStatuses.inProgress.split("|")[0] || "/";
|
||||
|
||||
return markParentAsInProgress(tr, parentLineNumber, doc, [
|
||||
inProgressMarker,
|
||||
return markParentAsInProgress(tr, parentLineNumber, doc, [
|
||||
inProgressMarker,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -312,13 +312,13 @@ function findTaskStatusChange(tr: Transaction): {
|
|||
// Line might not exist in old document
|
||||
}
|
||||
|
||||
// If we couldn't get the old line or the content has changed in the task marker area
|
||||
if (
|
||||
!oldLine ||
|
||||
(inserted.length > 0 &&
|
||||
line.from + lineText.indexOf("[") <= toB &&
|
||||
line.from + lineText.indexOf("]") >= fromB)
|
||||
) {
|
||||
const newStatus = taskMatch[2];
|
||||
const oldStatus = oldLine
|
||||
? (oldLine.text.match(/^[\s|\t]*([-*+]|\d+\.)\s\[(.)\]/i)?.[2] ?? null)
|
||||
: null;
|
||||
|
||||
// If the status character changed or we couldn't get the old line, mark as changed
|
||||
if (!oldLine || newStatus !== oldStatus) {
|
||||
taskChangedLine = line.number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EditorView } from "@codemirror/view";
|
||||
import { App, editorInfoField, Menu } from "obsidian";
|
||||
import { App, Editor, editorInfoField, Menu } from "obsidian";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { keymap } from "@codemirror/view";
|
||||
|
|
@ -9,11 +9,30 @@ import {
|
|||
determineNextStage,
|
||||
generateWorkflowTaskText,
|
||||
createWorkflowStageTransition,
|
||||
type WorkflowInfo,
|
||||
} from "@/editor-extensions/workflow/workflow-handler";
|
||||
import type { WorkflowStage } from "@/common/setting-definition";
|
||||
import { t } from "@/translations/helper";
|
||||
import { buildIndentString } from "@/utils";
|
||||
import { taskStatusChangeAnnotation } from "@/editor-extensions/task-operations/status-switcher";
|
||||
|
||||
const TASK_REGEX = /^(\s*)([-*+]|\d+\.)\s+\[(.)]/;
|
||||
const TASK_PREFIX = "- [ ] ";
|
||||
type WorkflowSubStage = NonNullable<WorkflowStage["subStages"]>[number];
|
||||
|
||||
function getIndentation(text: string): string {
|
||||
const match = text.match(/^(\s*)/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function getEditorFromView(view: EditorView): Editor | null {
|
||||
return view.state.field(editorInfoField)?.editor ?? null;
|
||||
}
|
||||
|
||||
function cursorAfterTaskPrefix(lineEnd: number, indentation: string): number {
|
||||
return lineEnd + 1 + indentation.length + TASK_PREFIX.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show workflow menu at cursor position
|
||||
* @param view The editor view
|
||||
|
|
@ -27,46 +46,25 @@ function showWorkflowMenu(
|
|||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
lineNumber: number,
|
||||
workflowInfo: {
|
||||
workflowType: string;
|
||||
currentStage: string;
|
||||
subStage?: string;
|
||||
}
|
||||
workflowInfo: WorkflowInfo,
|
||||
): boolean {
|
||||
const menu = new Menu();
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const doc = view.state.doc;
|
||||
if (lineNumber < 1 || lineNumber > doc.lines) {
|
||||
return false;
|
||||
}
|
||||
const line = doc.line(lineNumber);
|
||||
const lineText = line.text;
|
||||
|
||||
console.log("showWorkflowMenu called with:", {
|
||||
lineNumber,
|
||||
workflowInfo,
|
||||
lineText,
|
||||
});
|
||||
|
||||
// Resolve complete workflow information
|
||||
const resolvedInfo = resolveWorkflowInfo(
|
||||
lineText,
|
||||
view.state.doc,
|
||||
lineNumber,
|
||||
plugin
|
||||
);
|
||||
|
||||
console.log("resolvedInfo in showWorkflowMenu:", resolvedInfo);
|
||||
const resolvedInfo = resolveWorkflowInfo(lineText, doc, lineNumber, plugin);
|
||||
|
||||
if (!resolvedInfo) {
|
||||
console.log("No resolved info, returning early");
|
||||
return false;
|
||||
}
|
||||
|
||||
const { currentStage, currentSubStage, workflow, isRootTask } =
|
||||
resolvedInfo;
|
||||
|
||||
console.log("Current stage type:", currentStage.type);
|
||||
console.log("Is root task:", isRootTask);
|
||||
|
||||
// Handle different workflow states
|
||||
if (workflowInfo.currentStage === "root" || isRootTask) {
|
||||
// Root workflow task options
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Start workflow"))
|
||||
.setIcon("play")
|
||||
|
|
@ -75,46 +73,40 @@ function showWorkflowMenu(
|
|||
});
|
||||
});
|
||||
} else if (currentStage.type === "terminal") {
|
||||
console.log("Adding terminal stage menu item");
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Complete workflow"))
|
||||
.setIcon("check")
|
||||
.onClick(() => {
|
||||
completeWorkflow(view, app, plugin, lineNumber);
|
||||
completeWorkflow(view, plugin, lineNumber);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Use determineNextStage to find the next stage
|
||||
const { nextStageId, nextSubStageId } = determineNextStage(
|
||||
currentStage,
|
||||
workflow,
|
||||
currentSubStage
|
||||
currentSubStage,
|
||||
);
|
||||
|
||||
if (nextStageId) {
|
||||
const nextStage = workflow.stages.find((s) => s.id === nextStageId);
|
||||
if (nextStage) {
|
||||
// Determine the menu title based on the transition type
|
||||
let menuTitle: string;
|
||||
|
||||
if (
|
||||
nextStageId === currentStage.id &&
|
||||
nextSubStageId === currentSubStage?.id
|
||||
) {
|
||||
// Same stage and substage - cycling the same substage
|
||||
menuTitle = `${t("Continue")} ${nextStage.name}${
|
||||
nextSubStageId ? ` (${currentSubStage?.name})` : ""
|
||||
}`;
|
||||
} else if (nextStageId === currentStage.id && nextSubStageId) {
|
||||
// Same stage but different substage
|
||||
const nextSubStage = nextStage.subStages?.find(
|
||||
(ss) => ss.id === nextSubStageId
|
||||
(ss) => ss.id === nextSubStageId,
|
||||
);
|
||||
menuTitle = `${t("Move to")} ${nextStage.name} (${
|
||||
nextSubStage?.name || nextSubStageId
|
||||
})`;
|
||||
} else {
|
||||
// Different stage
|
||||
menuTitle = `${t("Move to")} ${nextStage.name}`;
|
||||
}
|
||||
|
||||
|
|
@ -131,136 +123,70 @@ function showWorkflowMenu(
|
|||
false,
|
||||
nextSubStageId
|
||||
? nextStage.subStages?.find(
|
||||
(ss) => ss.id === nextSubStageId
|
||||
)
|
||||
(ss) => ss.id === nextSubStageId,
|
||||
)
|
||||
: undefined,
|
||||
currentSubStage
|
||||
currentSubStage,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add option to complete current substage and move to next main stage
|
||||
// This is only available when we're in a substage of a cycle stage
|
||||
if (currentSubStage && currentStage.type === "cycle") {
|
||||
// Check if there's a next main stage available using canProceedTo
|
||||
const canProceedTo = currentStage.canProceedTo as
|
||||
| string[]
|
||||
| undefined;
|
||||
if (canProceedTo && canProceedTo.length > 0) {
|
||||
canProceedTo.forEach((nextStageId: string) => {
|
||||
const nextMainStage = workflow.stages.find(
|
||||
(s) => s.id === nextStageId
|
||||
);
|
||||
if (nextMainStage) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(
|
||||
`${t("Complete substage and move to")} ${
|
||||
nextMainStage.name
|
||||
}`
|
||||
)
|
||||
.setIcon("skip-forward")
|
||||
.onClick(() => {
|
||||
completeSubstageAndMoveToNextMainStage(
|
||||
view,
|
||||
app,
|
||||
plugin,
|
||||
lineNumber,
|
||||
nextMainStage,
|
||||
currentSubStage
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// Also check for explicit next stage
|
||||
else if (typeof currentStage.next === "string") {
|
||||
const nextMainStage = workflow.stages.find(
|
||||
(s) => s.id === currentStage.next
|
||||
const candidateStageIds = new Set<string>();
|
||||
|
||||
if (currentStage.canProceedTo?.length) {
|
||||
currentStage.canProceedTo.forEach((id) =>
|
||||
candidateStageIds.add(id),
|
||||
);
|
||||
if (nextMainStage) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(
|
||||
`${t("Complete substage and move to")} ${
|
||||
nextMainStage.name
|
||||
}`
|
||||
)
|
||||
.setIcon("skip-forward")
|
||||
.onClick(() => {
|
||||
completeSubstageAndMoveToNextMainStage(
|
||||
view,
|
||||
app,
|
||||
plugin,
|
||||
lineNumber,
|
||||
nextMainStage,
|
||||
currentSubStage
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
} else if (typeof currentStage.next === "string") {
|
||||
candidateStageIds.add(currentStage.next);
|
||||
} else if (
|
||||
Array.isArray(currentStage.next) &&
|
||||
currentStage.next.length > 0
|
||||
) {
|
||||
const nextMainStage = workflow.stages.find(
|
||||
(s) => s.id === currentStage.next![0]
|
||||
);
|
||||
if (nextMainStage) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(
|
||||
`${t("Complete substage and move to")} ${
|
||||
nextMainStage.name
|
||||
}`
|
||||
)
|
||||
.setIcon("skip-forward")
|
||||
.onClick(() => {
|
||||
completeSubstageAndMoveToNextMainStage(
|
||||
view,
|
||||
app,
|
||||
plugin,
|
||||
lineNumber,
|
||||
nextMainStage,
|
||||
currentSubStage
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// Finally check sequential next stage
|
||||
else {
|
||||
candidateStageIds.add(currentStage.next[0]);
|
||||
} else {
|
||||
const currentIndex = workflow.stages.findIndex(
|
||||
(s) => s.id === currentStage.id
|
||||
(s) => s.id === currentStage.id,
|
||||
);
|
||||
if (
|
||||
currentIndex >= 0 &&
|
||||
currentIndex < workflow.stages.length - 1
|
||||
) {
|
||||
const nextMainStage = workflow.stages[currentIndex + 1];
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(
|
||||
`${t("Complete substage and move to")} ${
|
||||
nextMainStage.name
|
||||
}`
|
||||
)
|
||||
.setIcon("skip-forward")
|
||||
.onClick(() => {
|
||||
completeSubstageAndMoveToNextMainStage(
|
||||
view,
|
||||
app,
|
||||
plugin,
|
||||
lineNumber,
|
||||
nextMainStage,
|
||||
currentSubStage
|
||||
);
|
||||
});
|
||||
});
|
||||
candidateStageIds.add(workflow.stages[currentIndex + 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
candidateStageIds.forEach((nextStageCandidate) => {
|
||||
const nextMainStage = workflow.stages.find(
|
||||
(stage) => stage.id === nextStageCandidate,
|
||||
);
|
||||
if (!nextMainStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(
|
||||
`${t("Complete substage and move to")} ${
|
||||
nextMainStage.name
|
||||
}`,
|
||||
)
|
||||
.setIcon("skip-forward")
|
||||
.onClick(() => {
|
||||
completeSubstageAndMoveToNextMainStage(
|
||||
view,
|
||||
plugin,
|
||||
lineNumber,
|
||||
nextMainStage,
|
||||
currentSubStage,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add child task with same stage option
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Add child task with same stage"))
|
||||
|
|
@ -272,21 +198,19 @@ function showWorkflowMenu(
|
|||
plugin,
|
||||
lineNumber,
|
||||
currentStage,
|
||||
currentSubStage
|
||||
currentSubStage,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Common options for all workflow tasks
|
||||
menu.addSeparator();
|
||||
|
||||
// Add new task option (same level)
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Add new task"))
|
||||
.setIcon("plus")
|
||||
.onClick(() => {
|
||||
addNewSiblingTask(view, app, lineNumber);
|
||||
addNewSiblingTask(view, lineNumber);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -299,15 +223,12 @@ function showWorkflowMenu(
|
|||
});
|
||||
});
|
||||
|
||||
// Calculate menu position based on cursor
|
||||
const selection = view.state.selection.main;
|
||||
const coords = view.coordsAtPos(selection.head);
|
||||
|
||||
if (coords) {
|
||||
// Show menu at cursor position
|
||||
menu.showAtPosition({ x: coords.left, y: coords.bottom });
|
||||
} else {
|
||||
// Fallback to mouse position
|
||||
menu.showAtMouseEvent(window.event as MouseEvent);
|
||||
}
|
||||
|
||||
|
|
@ -317,31 +238,20 @@ function showWorkflowMenu(
|
|||
/**
|
||||
* Add a new sibling task after the current line (same indentation level)
|
||||
* @param view The editor view
|
||||
* @param app The Obsidian app instance
|
||||
* @param lineNumber The current line number
|
||||
*/
|
||||
function addNewSiblingTask(
|
||||
view: EditorView,
|
||||
app: App,
|
||||
lineNumber: number
|
||||
): void {
|
||||
function addNewSiblingTask(view: EditorView, lineNumber: number): void {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const indentMatch = line.text.match(/^([\s|\t]*)/);
|
||||
const indentation = indentMatch ? indentMatch[1] : "";
|
||||
const indentation = getIndentation(line.text);
|
||||
|
||||
// Insert a new task at the same indentation level
|
||||
const insert = `\n${indentation}${TASK_PREFIX}`;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: line.to,
|
||||
to: line.to,
|
||||
insert: `\n${indentation}- [ ] `,
|
||||
},
|
||||
changes: { from: line.to, to: line.to, insert },
|
||||
selection: {
|
||||
anchor: line.to + indentation.length + 7, // Position cursor after "- [ ] "
|
||||
anchor: cursorAfterTaskPrefix(line.to, indentation),
|
||||
},
|
||||
});
|
||||
|
||||
// Focus the editor
|
||||
view.focus();
|
||||
}
|
||||
|
||||
|
|
@ -353,24 +263,18 @@ function addNewSiblingTask(
|
|||
*/
|
||||
function addNewSubTask(view: EditorView, app: App, lineNumber: number): void {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const indentMatch = line.text.match(/^([\s|\t]*)/);
|
||||
const indentation = indentMatch ? indentMatch[1] : "";
|
||||
const indentation = getIndentation(line.text);
|
||||
const defaultIndentation = buildIndentString(app);
|
||||
const newTaskIndentation = indentation + defaultIndentation;
|
||||
|
||||
// Insert a new sub-task with additional indentation
|
||||
const insert = `\n${newTaskIndentation}${TASK_PREFIX}`;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: line.to,
|
||||
to: line.to,
|
||||
insert: `\n${newTaskIndentation}- [ ] `,
|
||||
},
|
||||
changes: { from: line.to, to: line.to, insert },
|
||||
selection: {
|
||||
anchor: line.to + newTaskIndentation.length + 7, // Position cursor after "- [ ] "
|
||||
anchor: cursorAfterTaskPrefix(line.to, newTaskIndentation),
|
||||
},
|
||||
});
|
||||
|
||||
// Focus the editor
|
||||
view.focus();
|
||||
}
|
||||
|
||||
|
|
@ -385,12 +289,15 @@ function startWorkflow(
|
|||
view: EditorView,
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
lineNumber: number
|
||||
lineNumber: number,
|
||||
): void {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const doc = view.state.doc;
|
||||
if (lineNumber < 1 || lineNumber > doc.lines) {
|
||||
return;
|
||||
}
|
||||
const line = doc.line(lineNumber);
|
||||
const lineText = line.text;
|
||||
|
||||
// Extract workflow information
|
||||
const workflowInfo = extractWorkflowInfo(lineText);
|
||||
if (!workflowInfo) {
|
||||
return;
|
||||
|
|
@ -401,7 +308,7 @@ function startWorkflow(
|
|||
lineText,
|
||||
view.state.doc,
|
||||
lineNumber,
|
||||
plugin
|
||||
plugin,
|
||||
);
|
||||
|
||||
if (!resolvedInfo || !resolvedInfo.workflow.stages.length) {
|
||||
|
|
@ -411,34 +318,16 @@ function startWorkflow(
|
|||
const { workflow } = resolvedInfo;
|
||||
const firstStage = workflow.stages[0];
|
||||
|
||||
// Get indentation
|
||||
const indentMatch = lineText.match(/^([\s|\t]*)/);
|
||||
const indentation = indentMatch ? indentMatch[1] : "";
|
||||
const defaultIndentation = buildIndentString(app);
|
||||
const newTaskIndentation = indentation + defaultIndentation;
|
||||
|
||||
// Create task text for the first stage
|
||||
const timestamp = plugin.settings.workflow.autoAddTimestamp
|
||||
? ` 🛫 ${new Date().toISOString().slice(0, 19).replace("T", " ")}`
|
||||
: "";
|
||||
|
||||
let newTaskText = `${newTaskIndentation}- [ ] ${firstStage.name} [stage::${firstStage.id}]${timestamp}`;
|
||||
|
||||
// Add subtask for first substage if this is a cycle stage with substages
|
||||
if (
|
||||
firstStage.type === "cycle" &&
|
||||
firstStage.subStages &&
|
||||
firstStage.subStages.length > 0
|
||||
) {
|
||||
const firstSubStage = firstStage.subStages[0];
|
||||
const subTaskIndentation = newTaskIndentation + defaultIndentation;
|
||||
newTaskText += `\n${subTaskIndentation}- [ ] ${firstStage.name} (${firstSubStage.name}) [stage::${firstStage.id}.${firstSubStage.id}]${timestamp}`;
|
||||
}
|
||||
|
||||
// Insert the new task after the current line and move cursor to it
|
||||
const indentation = getIndentation(lineText);
|
||||
const newTaskIndentation = indentation + buildIndentString(app);
|
||||
const newTaskText = generateWorkflowTaskText(
|
||||
firstStage,
|
||||
newTaskIndentation,
|
||||
plugin,
|
||||
true,
|
||||
);
|
||||
const insertText = `\n${newTaskText}`;
|
||||
const newTaskLineStart = line.to + 1; // Start of the new line
|
||||
const cursorPosition = newTaskLineStart + newTaskIndentation.length + 7; // Position after "- [ ] "
|
||||
const cursorPosition = cursorAfterTaskPrefix(line.to, newTaskIndentation);
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
|
|
@ -451,7 +340,6 @@ function startWorkflow(
|
|||
},
|
||||
});
|
||||
|
||||
// Focus the editor
|
||||
view.focus();
|
||||
}
|
||||
|
||||
|
|
@ -463,7 +351,7 @@ function startWorkflow(
|
|||
*/
|
||||
export function workflowRootEnterHandlerExtension(
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin
|
||||
plugin: TaskProgressBarPlugin,
|
||||
) {
|
||||
// Don't enable if workflow feature is disabled
|
||||
if (!plugin.settings.workflow.enableWorkflow) {
|
||||
|
|
@ -481,8 +369,7 @@ export function workflowRootEnterHandlerExtension(
|
|||
const lineText = line.text;
|
||||
|
||||
// Check if this is a workflow root task
|
||||
const taskRegex = /^([\s|\t]*)([-*+]|\d+\.)\s+\[(.)]/;
|
||||
const taskMatch = lineText.match(taskRegex);
|
||||
const taskMatch = lineText.match(TASK_REGEX);
|
||||
|
||||
if (!taskMatch) {
|
||||
return false; // Not a task, allow default behavior
|
||||
|
|
@ -505,11 +392,11 @@ export function workflowRootEnterHandlerExtension(
|
|||
app,
|
||||
plugin,
|
||||
line.number,
|
||||
workflowInfo
|
||||
workflowInfo,
|
||||
);
|
||||
},
|
||||
},
|
||||
])
|
||||
]),
|
||||
);
|
||||
|
||||
return [keymapExtension];
|
||||
|
|
@ -531,32 +418,23 @@ function moveToNextStageWithSubStage(
|
|||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
lineNumber: number,
|
||||
nextStage: any,
|
||||
nextStage: WorkflowStage,
|
||||
isRootTask: boolean,
|
||||
nextSubStage?: any,
|
||||
currentSubStage?: any
|
||||
nextSubStage?: WorkflowSubStage,
|
||||
currentSubStage?: WorkflowSubStage,
|
||||
): void {
|
||||
const doc = view.state.doc;
|
||||
if (lineNumber < 1 || lineNumber > doc.lines) {
|
||||
return;
|
||||
}
|
||||
const line = doc.line(lineNumber);
|
||||
const lineText = line.text;
|
||||
|
||||
// Validate that the line exists and is within document bounds
|
||||
if (lineNumber > doc.lines || lineNumber < 1) {
|
||||
console.warn(
|
||||
`Invalid line number: ${lineNumber}, doc has ${doc.lines} lines`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a mock Editor object that wraps the EditorView
|
||||
const editor = view.state.field(editorInfoField)?.editor;
|
||||
|
||||
const editor = getEditorFromView(view);
|
||||
if (!editor) {
|
||||
console.warn("Editor not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the existing createWorkflowStageTransition function
|
||||
const changes = createWorkflowStageTransition(
|
||||
plugin,
|
||||
editor,
|
||||
|
|
@ -565,30 +443,21 @@ function moveToNextStageWithSubStage(
|
|||
nextStage,
|
||||
isRootTask,
|
||||
nextSubStage,
|
||||
currentSubStage
|
||||
currentSubStage,
|
||||
);
|
||||
|
||||
// Calculate cursor position for the new task
|
||||
let cursorPosition = line.to; // Default to end of current line
|
||||
|
||||
// Find the insertion point for the new task from the changes
|
||||
const insertChange = changes.find(
|
||||
(change) => change.insert && change.insert.includes("- [ ]")
|
||||
const indentation = getIndentation(lineText);
|
||||
const defaultIndentation = buildIndentString(app);
|
||||
const newTaskIndentation = isRootTask
|
||||
? indentation + defaultIndentation
|
||||
: indentation;
|
||||
const insertedTask = changes.some(
|
||||
(change) => change.insert && change.insert.includes(TASK_PREFIX),
|
||||
);
|
||||
const cursorPosition = insertedTask
|
||||
? cursorAfterTaskPrefix(line.to, newTaskIndentation)
|
||||
: line.to;
|
||||
|
||||
if (insertChange) {
|
||||
// Calculate position after the new task marker "- [ ] "
|
||||
const indentMatch = lineText.match(/^([\s|\t]*)/);
|
||||
const indentation = indentMatch ? indentMatch[1] : "";
|
||||
const defaultIndentation = buildIndentString(app);
|
||||
const newTaskIndentation =
|
||||
indentation + (isRootTask ? defaultIndentation : "");
|
||||
|
||||
// Position after the insertion point + newline + indentation + "- [ ] "
|
||||
cursorPosition = insertChange.from + 1 + newTaskIndentation.length + 6;
|
||||
}
|
||||
|
||||
// Apply all changes in a single transaction
|
||||
view.dispatch({
|
||||
changes,
|
||||
selection: {
|
||||
|
|
@ -614,30 +483,21 @@ function moveToNextStage(
|
|||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
lineNumber: number,
|
||||
nextStage: any,
|
||||
isRootTask: boolean
|
||||
nextStage: WorkflowStage,
|
||||
isRootTask: boolean,
|
||||
): void {
|
||||
const doc = view.state.doc;
|
||||
if (lineNumber < 1 || lineNumber > doc.lines) {
|
||||
return;
|
||||
}
|
||||
const line = doc.line(lineNumber);
|
||||
const lineText = line.text;
|
||||
|
||||
// Validate that the line exists and is within document bounds
|
||||
if (lineNumber > doc.lines || lineNumber < 1) {
|
||||
console.warn(
|
||||
`Invalid line number: ${lineNumber}, doc has ${doc.lines} lines`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a mock Editor object that wraps the EditorView
|
||||
const editor = view.state.field(editorInfoField)?.editor;
|
||||
|
||||
const editor = getEditorFromView(view);
|
||||
if (!editor) {
|
||||
console.warn("Editor not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the existing createWorkflowStageTransition function
|
||||
const changes = createWorkflowStageTransition(
|
||||
plugin,
|
||||
editor,
|
||||
|
|
@ -646,30 +506,21 @@ function moveToNextStage(
|
|||
nextStage,
|
||||
isRootTask,
|
||||
undefined, // nextSubStage
|
||||
undefined // currentSubStage
|
||||
undefined, // currentSubStage
|
||||
);
|
||||
|
||||
// Calculate cursor position for the new task
|
||||
let cursorPosition = line.to; // Default to end of current line
|
||||
|
||||
// Find the insertion point for the new task from the changes
|
||||
const insertChange = changes.find(
|
||||
(change) => change.insert && change.insert.includes("- [ ]")
|
||||
const indentation = getIndentation(lineText);
|
||||
const defaultIndentation = buildIndentString(app);
|
||||
const newTaskIndentation = isRootTask
|
||||
? indentation + defaultIndentation
|
||||
: indentation;
|
||||
const insertedTask = changes.some(
|
||||
(change) => change.insert && change.insert.includes(TASK_PREFIX),
|
||||
);
|
||||
const cursorPosition = insertedTask
|
||||
? cursorAfterTaskPrefix(line.to, newTaskIndentation)
|
||||
: line.to;
|
||||
|
||||
if (insertChange) {
|
||||
// Calculate position after the new task marker "- [ ] "
|
||||
const indentMatch = lineText.match(/^([\s|\t]*)/);
|
||||
const indentation = indentMatch ? indentMatch[1] : "";
|
||||
const defaultIndentation = buildIndentString(app);
|
||||
const newTaskIndentation =
|
||||
indentation + (isRootTask ? defaultIndentation : "");
|
||||
|
||||
// Position after the insertion point + newline + indentation + "- [ ] "
|
||||
cursorPosition = insertChange.from + 1 + newTaskIndentation.length + 6;
|
||||
}
|
||||
|
||||
// Apply all changes in a single transaction
|
||||
view.dispatch({
|
||||
changes,
|
||||
selection: {
|
||||
|
|
@ -684,53 +535,35 @@ function moveToNextStage(
|
|||
/**
|
||||
* Complete the workflow
|
||||
* @param view The editor view
|
||||
* @param app The Obsidian app instance
|
||||
* @param plugin The plugin instance
|
||||
* @param lineNumber The current line number
|
||||
*/
|
||||
function completeWorkflow(
|
||||
view: EditorView,
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
lineNumber: number
|
||||
lineNumber: number,
|
||||
): void {
|
||||
const doc = view.state.doc;
|
||||
if (lineNumber < 1 || lineNumber > doc.lines) {
|
||||
return;
|
||||
}
|
||||
const line = doc.line(lineNumber);
|
||||
const lineText = line.text;
|
||||
|
||||
// Validate that the line exists and is within document bounds
|
||||
if (lineNumber > doc.lines || lineNumber < 1) {
|
||||
console.warn(
|
||||
`Invalid line number: ${lineNumber}, doc has ${doc.lines} lines`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a mock Editor object that wraps the EditorView
|
||||
const editor = view.state.field(editorInfoField)?.editor;
|
||||
const editor = getEditorFromView(view);
|
||||
|
||||
if (!editor) {
|
||||
console.warn("Editor not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve workflow information to get the current stage
|
||||
const resolvedInfo = resolveWorkflowInfo(lineText, doc, lineNumber, plugin);
|
||||
|
||||
console.log("resolvedInfo", resolvedInfo);
|
||||
|
||||
if (!resolvedInfo) {
|
||||
console.warn("Could not resolve workflow information");
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentStage, currentSubStage } = resolvedInfo;
|
||||
|
||||
console.log("currentStage", currentStage);
|
||||
console.log("currentSubStage", currentSubStage);
|
||||
|
||||
// Use the existing createWorkflowStageTransition function to handle the completion
|
||||
// For terminal stages, this will complete the current task and handle root task completion
|
||||
const changes = createWorkflowStageTransition(
|
||||
plugin,
|
||||
editor,
|
||||
|
|
@ -739,11 +572,9 @@ function completeWorkflow(
|
|||
currentStage, // Pass the current stage as the "next" stage for terminal completion
|
||||
false, // Not a root task
|
||||
undefined, // No next substage
|
||||
currentSubStage
|
||||
currentSubStage,
|
||||
);
|
||||
|
||||
// Apply all changes in a single transaction
|
||||
// Use workflowChange annotation instead of workflowComplete to allow autoCompleteParent to work
|
||||
view.dispatch({
|
||||
changes,
|
||||
annotations: taskStatusChangeAnnotation.of("workflowChange"),
|
||||
|
|
@ -766,12 +597,11 @@ function addChildTaskWithSameStage(
|
|||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
lineNumber: number,
|
||||
currentStage: any,
|
||||
currentSubStage?: any
|
||||
currentStage: WorkflowStage,
|
||||
currentSubStage?: WorkflowSubStage,
|
||||
): void {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const indentMatch = line.text.match(/^([\s|\t]*)/);
|
||||
const indentation = indentMatch ? indentMatch[1] : "";
|
||||
const indentation = getIndentation(line.text);
|
||||
const defaultIndentation = buildIndentString(app);
|
||||
const newTaskIndentation = indentation + defaultIndentation;
|
||||
|
||||
|
|
@ -781,7 +611,7 @@ function addChildTaskWithSameStage(
|
|||
newTaskIndentation,
|
||||
plugin,
|
||||
false,
|
||||
currentSubStage
|
||||
currentSubStage,
|
||||
);
|
||||
|
||||
// Insert the new task after the current line
|
||||
|
|
@ -792,7 +622,7 @@ function addChildTaskWithSameStage(
|
|||
insert: `\n${newTaskText}`,
|
||||
},
|
||||
selection: {
|
||||
anchor: line.to + newTaskIndentation.length + 7,
|
||||
anchor: cursorAfterTaskPrefix(line.to, newTaskIndentation),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -802,7 +632,6 @@ function addChildTaskWithSameStage(
|
|||
/**
|
||||
* Move to the next main stage and complete both current substage and parent stage
|
||||
* @param view The editor view
|
||||
* @param app The Obsidian app instance
|
||||
* @param plugin The plugin instance
|
||||
* @param lineNumber The current line number
|
||||
* @param nextStage The next main stage to move to
|
||||
|
|
@ -810,52 +639,36 @@ function addChildTaskWithSameStage(
|
|||
*/
|
||||
function completeSubstageAndMoveToNextMainStage(
|
||||
view: EditorView,
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
lineNumber: number,
|
||||
nextStage: any,
|
||||
currentSubStage: any
|
||||
nextStage: WorkflowStage,
|
||||
currentSubStage: WorkflowSubStage,
|
||||
): void {
|
||||
const doc = view.state.doc;
|
||||
if (lineNumber < 1 || lineNumber > doc.lines) {
|
||||
return;
|
||||
}
|
||||
const line = doc.line(lineNumber);
|
||||
const lineText = line.text;
|
||||
|
||||
// Validate that the line exists and is within document bounds
|
||||
if (lineNumber > doc.lines || lineNumber < 1) {
|
||||
console.warn(
|
||||
`Invalid line number: ${lineNumber}, doc has ${doc.lines} lines`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a mock Editor object that wraps the EditorView
|
||||
const editor = view.state.field(editorInfoField)?.editor;
|
||||
const editor = getEditorFromView(view);
|
||||
|
||||
if (!editor) {
|
||||
console.warn("Editor not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let changes: { from: number; to: number; insert: string }[] = [];
|
||||
|
||||
// 1. Find and handle the parent stage task first
|
||||
const currentIndentMatch = lineText.match(/^([\s|\t]*)/);
|
||||
const currentIndent = currentIndentMatch ? currentIndentMatch[1].length : 0;
|
||||
const taskRegex = /^([\s|\t]*)([-*+]|\d+\.)\s+\[(.)]/;
|
||||
const currentIndent = getIndentation(lineText).length;
|
||||
|
||||
// Look upward to find the parent stage task (with less indentation)
|
||||
for (let i = lineNumber - 1; i >= 1; i--) {
|
||||
const checkLine = doc.line(i);
|
||||
const checkIndentMatch = checkLine.text.match(/^([\s|\t]*)/);
|
||||
const checkIndent = checkIndentMatch ? checkIndentMatch[1].length : 0;
|
||||
const checkIndent = getIndentation(checkLine.text).length;
|
||||
|
||||
// If this line has less indentation and is a task, it's likely the parent stage
|
||||
if (checkIndent < currentIndent) {
|
||||
const parentTaskMatch = checkLine.text.match(taskRegex);
|
||||
const parentTaskMatch = checkLine.text.match(TASK_REGEX);
|
||||
if (parentTaskMatch) {
|
||||
// Check if this is a stage task (has [stage::] marker)
|
||||
if (checkLine.text.includes("[stage::")) {
|
||||
// Use createWorkflowStageTransition for the parent task to handle timestamps and time calculation
|
||||
const parentTransitionChanges =
|
||||
createWorkflowStageTransition(
|
||||
plugin,
|
||||
|
|
@ -865,15 +678,14 @@ function completeSubstageAndMoveToNextMainStage(
|
|||
nextStage, // The next stage we're transitioning to
|
||||
false, // Not a root task
|
||||
undefined, // No next substage for parent
|
||||
undefined // No current substage for parent
|
||||
undefined, // No current substage for parent
|
||||
);
|
||||
|
||||
// Filter out the "insert new task" changes from parent transition since we'll handle that separately
|
||||
const parentCompletionChanges =
|
||||
parentTransitionChanges.filter(
|
||||
(change) =>
|
||||
!change.insert ||
|
||||
!change.insert.includes("- [ ]")
|
||||
!change.insert.includes(TASK_PREFIX),
|
||||
);
|
||||
|
||||
changes.push(...parentCompletionChanges);
|
||||
|
|
@ -893,13 +705,12 @@ function completeSubstageAndMoveToNextMainStage(
|
|||
nextStage,
|
||||
false, // Not a root task
|
||||
undefined, // No next substage - moving to main stage
|
||||
currentSubStage
|
||||
currentSubStage,
|
||||
);
|
||||
|
||||
// Combine all changes
|
||||
changes.push(...transitionChanges);
|
||||
|
||||
// Apply all changes in a single transaction
|
||||
view.dispatch({
|
||||
changes,
|
||||
annotations: taskStatusChangeAnnotation.of("workflowChange"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ import {
|
|||
} from "../../types/onCompletion";
|
||||
import { Task, CanvasTaskMetadata } from "../../types/task";
|
||||
import { CanvasTaskUpdater } from "../../parsers/canvas-task-updater";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
|
||||
/**
|
||||
* Abstract base class for all onCompletion action executors
|
||||
|
|
@ -18,12 +19,12 @@ export abstract class BaseActionExecutor {
|
|||
*/
|
||||
public async execute(
|
||||
context: OnCompletionExecutionContext,
|
||||
config: OnCompletionConfig
|
||||
config: OnCompletionConfig,
|
||||
): Promise<OnCompletionExecutionResult> {
|
||||
if (!this.validateConfig(config)) {
|
||||
return this.createErrorResult("Invalid configuration");
|
||||
}
|
||||
|
||||
|
||||
// Route to appropriate execution method based on task type
|
||||
if (this.isCanvasTask(context.task)) {
|
||||
return this.executeForCanvas(context, config);
|
||||
|
|
@ -40,7 +41,7 @@ export abstract class BaseActionExecutor {
|
|||
*/
|
||||
protected abstract executeForCanvas(
|
||||
context: OnCompletionExecutionContext,
|
||||
config: OnCompletionConfig
|
||||
config: OnCompletionConfig,
|
||||
): Promise<OnCompletionExecutionResult>;
|
||||
|
||||
/**
|
||||
|
|
@ -51,7 +52,7 @@ export abstract class BaseActionExecutor {
|
|||
*/
|
||||
protected abstract executeForMarkdown(
|
||||
context: OnCompletionExecutionContext,
|
||||
config: OnCompletionConfig
|
||||
config: OnCompletionConfig,
|
||||
): Promise<OnCompletionExecutionResult>;
|
||||
|
||||
/**
|
||||
|
|
@ -74,7 +75,7 @@ export abstract class BaseActionExecutor {
|
|||
* @returns Success result
|
||||
*/
|
||||
protected createSuccessResult(
|
||||
message?: string
|
||||
message?: string,
|
||||
): OnCompletionExecutionResult {
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -109,9 +110,14 @@ export abstract class BaseActionExecutor {
|
|||
* @returns CanvasTaskUpdater instance
|
||||
*/
|
||||
protected getCanvasTaskUpdater(
|
||||
context: OnCompletionExecutionContext
|
||||
context: OnCompletionExecutionContext,
|
||||
): CanvasTaskUpdater {
|
||||
// Create CanvasTaskUpdater directly without TaskManager
|
||||
// Prefer using the plugin's task manager if available (allows mocking in tests)
|
||||
const plugin = context.plugin as TaskProgressBarPlugin;
|
||||
if (plugin?.writeAPI) {
|
||||
return plugin.writeAPI.canvasTaskUpdater;
|
||||
}
|
||||
|
||||
return new CanvasTaskUpdater(context.app.vault, context.plugin);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -747,10 +747,34 @@ export class FileTaskManagerImpl implements FileTaskManager {
|
|||
): number | undefined {
|
||||
if (!propertyName) return undefined;
|
||||
try {
|
||||
const value = entry.getValue({
|
||||
type: "property",
|
||||
name: propertyName,
|
||||
});
|
||||
const fallbackNames: Record<string, string[]> = {
|
||||
createdDate: ["created"],
|
||||
startDate: ["start"],
|
||||
scheduledDate: ["scheduled"],
|
||||
dueDate: ["due"],
|
||||
completedDate: ["completion", "completed", "done"],
|
||||
};
|
||||
|
||||
const candidateNames = [
|
||||
propertyName,
|
||||
...(fallbackNames[propertyName] || []),
|
||||
];
|
||||
|
||||
let value: any = undefined;
|
||||
for (const name of candidateNames) {
|
||||
try {
|
||||
value = entry.getValue({
|
||||
type: "property",
|
||||
name,
|
||||
});
|
||||
} catch {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) return undefined;
|
||||
|
||||
|
|
@ -923,6 +947,23 @@ export class FileTaskManagerImpl implements FileTaskManager {
|
|||
enhancedDates.startDateTime = combineDateTime(dates.startDate, timeComponents.startTime);
|
||||
}
|
||||
|
||||
// Fallback for start datetime when explicit start date is missing
|
||||
if (
|
||||
!enhancedDates.startDateTime &&
|
||||
timeComponents.startTime
|
||||
) {
|
||||
const fallbackDate =
|
||||
dates.dueDate ??
|
||||
dates.scheduledDate ??
|
||||
dates.completedDate;
|
||||
if (fallbackDate) {
|
||||
enhancedDates.startDateTime = combineDateTime(
|
||||
fallbackDate,
|
||||
timeComponents.startTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine due date with due time
|
||||
if (dates.dueDate && timeComponents.dueTime) {
|
||||
enhancedDates.dueDateTime = combineDateTime(dates.dueDate, timeComponents.dueTime);
|
||||
|
|
@ -946,8 +987,15 @@ export class FileTaskManagerImpl implements FileTaskManager {
|
|||
}
|
||||
|
||||
// Handle end time - if we have start date and end time, create end datetime
|
||||
if (dates.startDate && timeComponents.endTime) {
|
||||
enhancedDates.endDateTime = combineDateTime(dates.startDate, timeComponents.endTime);
|
||||
if (timeComponents.endTime) {
|
||||
const endBaseDate =
|
||||
dates.startDate ??
|
||||
dates.dueDate ??
|
||||
dates.scheduledDate ??
|
||||
dates.completedDate;
|
||||
if (endBaseDate) {
|
||||
enhancedDates.endDateTime = combineDateTime(endBaseDate, timeComponents.endTime);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(enhancedDates).length > 0 ? enhancedDates : undefined;
|
||||
|
|
|
|||
|
|
@ -576,9 +576,9 @@ export class IcsManager extends Component {
|
|||
}
|
||||
|
||||
// Format time as HH:MM or HH:MM:SS depending on whether seconds are present
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds();
|
||||
const hours = date.getUTCHours().toString().padStart(2, '0');
|
||||
const minutes = date.getUTCMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getUTCSeconds();
|
||||
|
||||
let originalText = `${hours}:${minutes}`;
|
||||
if (seconds > 0) {
|
||||
|
|
@ -586,8 +586,8 @@ export class IcsManager extends Component {
|
|||
}
|
||||
|
||||
return {
|
||||
hour: date.getHours(),
|
||||
minute: date.getMinutes(),
|
||||
hour: parseInt(hours, 10),
|
||||
minute: parseInt(minutes, 10),
|
||||
second: seconds > 0 ? seconds : undefined,
|
||||
originalText,
|
||||
isRange: false,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import { Task, StandardTaskMetadata, EnhancedStandardTaskMetadata, EnhancedTask } from "../types/task";
|
||||
import {
|
||||
Task,
|
||||
StandardTaskMetadata,
|
||||
EnhancedStandardTaskMetadata,
|
||||
EnhancedTask,
|
||||
} from "../types/task";
|
||||
import { TimeComponent } from "../types/time-parsing";
|
||||
import {
|
||||
enhanceTaskMetadata,
|
||||
extractTimeComponentsFromMetadata,
|
||||
import {
|
||||
enhanceTaskMetadata,
|
||||
extractTimeComponentsFromMetadata,
|
||||
hasTimeComponents,
|
||||
validateTimeComponent
|
||||
validateTimeComponent,
|
||||
} from "../utils/task-metadata-utils";
|
||||
import {
|
||||
TimeParsingService,
|
||||
DEFAULT_TIME_PARSING_CONFIG,
|
||||
} from "./time-parsing-service";
|
||||
|
||||
/**
|
||||
* Service for migrating tasks to enhanced metadata format
|
||||
|
|
@ -14,6 +23,13 @@ import {
|
|||
export class TaskMigrationService {
|
||||
private migrationCache = new Map<string, boolean>();
|
||||
private migrationInProgress = new Set<string>();
|
||||
private timeParsingService: TimeParsingService;
|
||||
|
||||
constructor(timeParsingService?: TimeParsingService) {
|
||||
this.timeParsingService =
|
||||
timeParsingService ||
|
||||
new TimeParsingService(DEFAULT_TIME_PARSING_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a standard task to enhanced format
|
||||
|
|
@ -45,11 +61,18 @@ export class TaskMigrationService {
|
|||
// Only add time components if they contain meaningful time information
|
||||
// (not just 00:00:00 which indicates date-only)
|
||||
const meaningfulTimeComponents = this.filterMeaningfulTimeComponents(extractedTimeComponents);
|
||||
const parsedTimeComponents = task.content
|
||||
? this.getMeaningfulTimeComponentsFromContent(task.content)
|
||||
: {};
|
||||
const mergedTimeComponents = this.mergeTimeComponents(
|
||||
meaningfulTimeComponents,
|
||||
parsedTimeComponents
|
||||
);
|
||||
|
||||
// Create enhanced metadata
|
||||
const enhancedMetadata = enhanceTaskMetadata(
|
||||
task.metadata,
|
||||
Object.keys(meaningfulTimeComponents).length > 0 ? meaningfulTimeComponents : undefined
|
||||
Object.keys(mergedTimeComponents).length > 0 ? mergedTimeComponents : undefined
|
||||
);
|
||||
|
||||
// Create enhanced task
|
||||
|
|
@ -98,8 +121,14 @@ export class TaskMigrationService {
|
|||
// Check if task has meaningful time information
|
||||
const extractedTimeComponents = extractTimeComponentsFromMetadata(task.metadata);
|
||||
const meaningfulTimeComponents = this.filterMeaningfulTimeComponents(extractedTimeComponents);
|
||||
const parsedTimeComponents = task.content
|
||||
? this.getMeaningfulTimeComponentsFromContent(task.content)
|
||||
: {};
|
||||
|
||||
if (Object.keys(meaningfulTimeComponents).length === 0) {
|
||||
if (
|
||||
Object.keys(meaningfulTimeComponents).length === 0 &&
|
||||
Object.keys(parsedTimeComponents).length === 0
|
||||
) {
|
||||
// No meaningful time information, return original task
|
||||
return task;
|
||||
}
|
||||
|
|
@ -232,6 +261,54 @@ export class TaskMigrationService {
|
|||
return meaningful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful time components from task content using enhanced parsing
|
||||
*/
|
||||
private getMeaningfulTimeComponentsFromContent(
|
||||
content: string
|
||||
): NonNullable<EnhancedStandardTaskMetadata["timeComponents"]> {
|
||||
if (!content || !content.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const { timeComponents } =
|
||||
this.timeParsingService.parseTimeComponents(content);
|
||||
if (!timeComponents) {
|
||||
return {};
|
||||
}
|
||||
return this.filterMeaningfulTimeComponents(timeComponents);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[TaskMigrationService] Failed to parse time components from content:",
|
||||
error
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge existing and newly parsed time components without overwriting explicit values
|
||||
*/
|
||||
private mergeTimeComponents(
|
||||
base: NonNullable<EnhancedStandardTaskMetadata["timeComponents"]>,
|
||||
additional: NonNullable<EnhancedStandardTaskMetadata["timeComponents"]>
|
||||
): NonNullable<EnhancedStandardTaskMetadata["timeComponents"]> {
|
||||
const merged: NonNullable<
|
||||
EnhancedStandardTaskMetadata["timeComponents"]
|
||||
> = { ...base };
|
||||
|
||||
(
|
||||
["startTime", "endTime", "dueTime", "scheduledTime"] as const
|
||||
).forEach((key) => {
|
||||
if (!merged[key] && additional[key]) {
|
||||
merged[key] = additional[key];
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a time component represents meaningful time (not just 00:00:00)
|
||||
*/
|
||||
|
|
@ -245,4 +322,4 @@ export class TaskMigrationService {
|
|||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const taskMigrationService = new TaskMigrationService();
|
||||
export const taskMigrationService = new TaskMigrationService();
|
||||
|
|
|
|||
|
|
@ -53,19 +53,25 @@ export interface TimeParsingConfig {
|
|||
|
||||
export class TimeParsingService {
|
||||
private config: TimeParsingConfig | EnhancedTimeParsingConfig;
|
||||
private parseCache: Map<string, ParsedTimeResult | EnhancedParsedTimeResult> = new Map();
|
||||
private parseCache: Map<
|
||||
string,
|
||||
ParsedTimeResult | EnhancedParsedTimeResult
|
||||
> = new Map();
|
||||
private maxCacheSize: number = 100;
|
||||
|
||||
|
||||
// Time pattern regexes
|
||||
private readonly TIME_PATTERNS = {
|
||||
// 24-hour format: 12:00, 12:00:00
|
||||
TIME_24H: /\b([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\b/g,
|
||||
// 12-hour format: 1:30 PM, 1:30:00 PM
|
||||
TIME_12H: /\b(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)\b/g,
|
||||
TIME_12H:
|
||||
/\b(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)\b/g,
|
||||
// Time range: 12:00-13:00, 12:00~13:00, 12:00 - 13:00
|
||||
TIME_RANGE: /\b([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\s*[-~~]\s*([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\b/g,
|
||||
TIME_RANGE:
|
||||
/\b([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\s*[-~~]\s*([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\b/g,
|
||||
// Time range with 12-hour format
|
||||
TIME_RANGE_12H: /\b(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)?\s*[-~~]\s*(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)\b/g,
|
||||
TIME_RANGE_12H:
|
||||
/\b(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)?\s*[-~~]\s*(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)\b/g,
|
||||
};
|
||||
|
||||
constructor(config: TimeParsingConfig | EnhancedTimeParsingConfig) {
|
||||
|
|
@ -93,11 +99,14 @@ export class TimeParsingService {
|
|||
type: "invalid-format",
|
||||
originalText: text,
|
||||
position: 0,
|
||||
message: error instanceof Error ? error.message : "Unknown error during time parsing",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error during time parsing",
|
||||
fallbackUsed: true,
|
||||
};
|
||||
errors.push(timeError);
|
||||
|
||||
|
||||
return {
|
||||
timeComponents: {},
|
||||
errors,
|
||||
|
|
@ -140,9 +149,11 @@ export class TimeParsingService {
|
|||
private parseTimeComponent(timeText: string): TimeComponent | null {
|
||||
// Clean input
|
||||
const cleanedText = timeText.trim();
|
||||
|
||||
|
||||
// Try 12-hour format first (more specific)
|
||||
const match12h = cleanedText.match(/^(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)$/i);
|
||||
const match12h = cleanedText.match(
|
||||
/^(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)$/i,
|
||||
);
|
||||
if (match12h) {
|
||||
let hour = parseInt(match12h[1], 10);
|
||||
const minute = parseInt(match12h[2], 10);
|
||||
|
|
@ -164,28 +175,36 @@ export class TimeParsingService {
|
|||
isRange: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Try 24-hour format
|
||||
const match24h = cleanedText.match(/^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/);
|
||||
const match24h = cleanedText.match(
|
||||
/^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/,
|
||||
);
|
||||
if (match24h) {
|
||||
let hour = parseInt(match24h[1], 10);
|
||||
const minute = parseInt(match24h[2], 10);
|
||||
const second = match24h[3] ? parseInt(match24h[3], 10) : undefined;
|
||||
|
||||
|
||||
// Validate ranges
|
||||
if (hour > 23 || minute > 59 || (second !== undefined && second > 59)) {
|
||||
if (
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
(second !== undefined && second > 59)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Handle ambiguous times (e.g., 3:00 could be AM or PM)
|
||||
// Note: Only apply defaults when explicitly configured and for truly ambiguous times
|
||||
const isEnhancedConfig = (config: any): config is EnhancedTimeParsingConfig => {
|
||||
return config && 'timeDefaults' in config;
|
||||
const isEnhancedConfig = (
|
||||
config: any,
|
||||
): config is EnhancedTimeParsingConfig => {
|
||||
return config && "timeDefaults" in config;
|
||||
};
|
||||
|
||||
|
||||
// Check if this is a user-configured scenario for ambiguous time handling
|
||||
// For now, we'll keep 24-hour times as-is unless there's specific context
|
||||
|
||||
|
||||
return {
|
||||
hour,
|
||||
minute,
|
||||
|
|
@ -215,6 +234,12 @@ export class TimeParsingService {
|
|||
}>;
|
||||
} {
|
||||
const timeComponents: EnhancedParsedTimeResult["timeComponents"] = {};
|
||||
const componentSources: Partial<
|
||||
Record<
|
||||
keyof NonNullable<EnhancedParsedTimeResult["timeComponents"]>,
|
||||
"explicit" | "inferred"
|
||||
>
|
||||
> = {};
|
||||
const timeExpressions: Array<{
|
||||
text: string;
|
||||
index: number;
|
||||
|
|
@ -226,8 +251,10 @@ export class TimeParsingService {
|
|||
|
||||
// Check for time ranges first (they contain single times too)
|
||||
const rangeMatches = [...text.matchAll(this.TIME_PATTERNS.TIME_RANGE)];
|
||||
const range12hMatches = [...text.matchAll(this.TIME_PATTERNS.TIME_RANGE_12H)];
|
||||
|
||||
const range12hMatches = [
|
||||
...text.matchAll(this.TIME_PATTERNS.TIME_RANGE_12H),
|
||||
];
|
||||
|
||||
for (const match of [...rangeMatches, ...range12hMatches]) {
|
||||
const fullMatch = match[0];
|
||||
const index = match.index || 0;
|
||||
|
|
@ -253,10 +280,16 @@ export class TimeParsingService {
|
|||
});
|
||||
|
||||
// Determine context for time range
|
||||
const context = this.determineTimeContext(text, fullMatch, index);
|
||||
const context = this.determineTimeContext(
|
||||
text,
|
||||
fullMatch,
|
||||
index,
|
||||
);
|
||||
if (context === "start" || !timeComponents.startTime) {
|
||||
timeComponents.startTime = startTime;
|
||||
timeComponents.endTime = endTime;
|
||||
componentSources.startTime =
|
||||
context === "start" ? "explicit" : "inferred";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -276,15 +309,38 @@ export class TimeParsingService {
|
|||
const index = match.index || 0;
|
||||
|
||||
// Skip if this time is part of a range we already found
|
||||
const isPartOfRange = timeExpressions.some(expr =>
|
||||
expr.isRange &&
|
||||
index >= expr.index &&
|
||||
index < expr.index + expr.text.length
|
||||
const isPartOfRange = timeExpressions.some(
|
||||
(expr) =>
|
||||
expr.isRange &&
|
||||
index >= expr.index &&
|
||||
index < expr.index + expr.text.length,
|
||||
);
|
||||
|
||||
if (!isPartOfRange) {
|
||||
const timeComponent = this.parseTimeComponent(fullMatch);
|
||||
if (timeComponent) {
|
||||
const nextChar = text[index + fullMatch.length];
|
||||
const followingChar = text[index + fullMatch.length + 1];
|
||||
const prevChar = index > 0 ? text[index - 1] : undefined;
|
||||
const prevPrevChar =
|
||||
index > 1 ? text[index - 2] : undefined;
|
||||
|
||||
if (
|
||||
nextChar === ":" &&
|
||||
followingChar !== undefined &&
|
||||
/\d/.test(followingChar)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar === "-" && followingChar === "-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prevChar === "-" && prevPrevChar === "-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
processedPositions.add(index);
|
||||
timeExpressions.push({
|
||||
text: fullMatch,
|
||||
|
|
@ -294,16 +350,33 @@ export class TimeParsingService {
|
|||
});
|
||||
|
||||
// Determine context and assign to appropriate field
|
||||
const context = this.determineTimeContext(text, fullMatch, index);
|
||||
const context = this.determineTimeContext(
|
||||
text,
|
||||
fullMatch,
|
||||
index,
|
||||
);
|
||||
switch (context) {
|
||||
case "start":
|
||||
if (!timeComponents.startTime) timeComponents.startTime = timeComponent;
|
||||
timeComponents.startTime = timeComponent;
|
||||
componentSources.startTime = "explicit";
|
||||
break;
|
||||
case "due":
|
||||
if (!timeComponents.dueTime) timeComponents.dueTime = timeComponent;
|
||||
if (
|
||||
!timeComponents.dueTime ||
|
||||
componentSources.dueTime !== "explicit"
|
||||
) {
|
||||
timeComponents.dueTime = timeComponent;
|
||||
componentSources.dueTime = "explicit";
|
||||
}
|
||||
break;
|
||||
case "scheduled":
|
||||
if (!timeComponents.scheduledTime) timeComponents.scheduledTime = timeComponent;
|
||||
if (
|
||||
!timeComponents.scheduledTime ||
|
||||
componentSources.scheduledTime !== "explicit"
|
||||
) {
|
||||
timeComponents.scheduledTime = timeComponent;
|
||||
componentSources.scheduledTime = "explicit";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -321,15 +394,38 @@ export class TimeParsingService {
|
|||
}
|
||||
|
||||
// Skip if this time is part of a range we already found
|
||||
const isPartOfRange = timeExpressions.some(expr =>
|
||||
expr.isRange &&
|
||||
index >= expr.index &&
|
||||
index < expr.index + expr.text.length
|
||||
const isPartOfRange = timeExpressions.some(
|
||||
(expr) =>
|
||||
expr.isRange &&
|
||||
index >= expr.index &&
|
||||
index < expr.index + expr.text.length,
|
||||
);
|
||||
|
||||
if (!isPartOfRange) {
|
||||
const timeComponent = this.parseTimeComponent(fullMatch);
|
||||
if (timeComponent) {
|
||||
const nextChar = text[index + fullMatch.length];
|
||||
const followingChar = text[index + fullMatch.length + 1];
|
||||
const prevChar = index > 0 ? text[index - 1] : undefined;
|
||||
const prevPrevChar =
|
||||
index > 1 ? text[index - 2] : undefined;
|
||||
|
||||
if (
|
||||
nextChar === ":" &&
|
||||
followingChar !== undefined &&
|
||||
/\d/.test(followingChar)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar === "-" && followingChar === "-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prevChar === "-" && prevPrevChar === "-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
timeExpressions.push({
|
||||
text: fullMatch,
|
||||
index,
|
||||
|
|
@ -338,29 +434,54 @@ export class TimeParsingService {
|
|||
});
|
||||
|
||||
// Determine context and assign to appropriate field
|
||||
const context = this.determineTimeContext(text, fullMatch, index);
|
||||
const context = this.determineTimeContext(
|
||||
text,
|
||||
fullMatch,
|
||||
index,
|
||||
);
|
||||
switch (context) {
|
||||
case "start":
|
||||
if (!timeComponents.startTime) timeComponents.startTime = timeComponent;
|
||||
timeComponents.startTime = timeComponent;
|
||||
componentSources.startTime = "explicit";
|
||||
break;
|
||||
case "due":
|
||||
if (!timeComponents.dueTime) timeComponents.dueTime = timeComponent;
|
||||
if (
|
||||
!timeComponents.dueTime ||
|
||||
componentSources.dueTime !== "explicit"
|
||||
) {
|
||||
timeComponents.dueTime = timeComponent;
|
||||
componentSources.dueTime = "explicit";
|
||||
}
|
||||
break;
|
||||
case "scheduled":
|
||||
if (!timeComponents.scheduledTime) timeComponents.scheduledTime = timeComponent;
|
||||
if (
|
||||
!timeComponents.scheduledTime ||
|
||||
componentSources.scheduledTime !== "explicit"
|
||||
) {
|
||||
timeComponents.scheduledTime = timeComponent;
|
||||
componentSources.scheduledTime = "explicit";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timeComponents.startTime && !timeComponents.scheduledTime) {
|
||||
timeComponents.scheduledTime = timeComponents.startTime;
|
||||
}
|
||||
|
||||
return { timeComponents, timeExpressions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine time context based on surrounding keywords
|
||||
*/
|
||||
private determineTimeContext(text: string, expression: string, index: number): "start" | "due" | "scheduled" {
|
||||
private determineTimeContext(
|
||||
text: string,
|
||||
expression: string,
|
||||
index: number,
|
||||
): "start" | "due" | "scheduled" {
|
||||
// Get text before the expression (look back up to 20 characters)
|
||||
const beforeText = text
|
||||
.substring(Math.max(0, index - 20), index)
|
||||
|
|
@ -370,7 +491,7 @@ export class TimeParsingService {
|
|||
const afterText = text
|
||||
.substring(
|
||||
index + expression.length,
|
||||
Math.min(text.length, index + expression.length + 20)
|
||||
Math.min(text.length, index + expression.length + 20),
|
||||
)
|
||||
.toLowerCase();
|
||||
|
||||
|
|
@ -412,42 +533,39 @@ export class TimeParsingService {
|
|||
* @param text - Input text containing potential time expressions
|
||||
* @returns ParsedTimeResult with extracted dates and cleaned text
|
||||
*/
|
||||
parseTimeExpressions(text: string): ParsedTimeResult | EnhancedParsedTimeResult {
|
||||
parseTimeExpressions(
|
||||
text: string,
|
||||
): ParsedTimeResult | EnhancedParsedTimeResult {
|
||||
const safeText = text ?? "";
|
||||
|
||||
if (!this.config.enabled) {
|
||||
return {
|
||||
originalText: text,
|
||||
cleanedText: text,
|
||||
originalText: safeText,
|
||||
cleanedText: safeText,
|
||||
parsedExpressions: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = this.generateCacheKey(text);
|
||||
const cacheKey = this.generateCacheKey(safeText);
|
||||
if (this.parseCache.has(cacheKey)) {
|
||||
return this.parseCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
// Extract time components first
|
||||
const { timeComponents, timeExpressions } = this.extractTimeComponents(text);
|
||||
|
||||
const { timeComponents, timeExpressions } =
|
||||
this.extractTimeComponents(safeText);
|
||||
|
||||
// Create enhanced result if time components found
|
||||
const result: EnhancedParsedTimeResult = {
|
||||
originalText: text,
|
||||
cleanedText: text,
|
||||
originalText: safeText,
|
||||
cleanedText: safeText,
|
||||
parsedExpressions: [],
|
||||
timeComponents: timeComponents,
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate input
|
||||
if (typeof text !== "string") {
|
||||
console.warn(
|
||||
"TimeParsingService: Invalid input type, expected string"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (text.trim().length === 0) {
|
||||
if (safeText.trim().length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -456,18 +574,18 @@ export class TimeParsingService {
|
|||
const chronoModule = chrono;
|
||||
let parseResults;
|
||||
try {
|
||||
parseResults = chronoModule.parse(text);
|
||||
parseResults = chronoModule.parse(safeText);
|
||||
} catch (chronoError) {
|
||||
console.warn(
|
||||
"TimeParsingService: Chrono parsing failed:",
|
||||
chronoError
|
||||
chronoError,
|
||||
);
|
||||
parseResults = [];
|
||||
}
|
||||
|
||||
// If no results found with default parser and text contains Chinese characters,
|
||||
// try with different locale parsers as fallback
|
||||
if (parseResults.length === 0 && /[\u4e00-\u9fff]/.test(text)) {
|
||||
if (parseResults.length === 0 && /[\u4e00-\u9fff]/.test(safeText)) {
|
||||
try {
|
||||
// Try Chinese traditional (zh.hant) first if available
|
||||
if (
|
||||
|
|
@ -475,7 +593,7 @@ export class TimeParsingService {
|
|||
chronoModule.zh.hant &&
|
||||
typeof chronoModule.zh.hant.parse === "function"
|
||||
) {
|
||||
const zhHantResult = chronoModule.zh.parse(text);
|
||||
const zhHantResult = chronoModule.zh.parse(safeText);
|
||||
if (zhHantResult && zhHantResult.length > 0) {
|
||||
parseResults = zhHantResult;
|
||||
}
|
||||
|
|
@ -487,7 +605,7 @@ export class TimeParsingService {
|
|||
chronoModule.zh &&
|
||||
typeof chronoModule.zh.parse === "function"
|
||||
) {
|
||||
const zhResult = chronoModule.zh.parse(text);
|
||||
const zhResult = chronoModule.zh.parse(safeText);
|
||||
if (zhResult && zhResult.length > 0) {
|
||||
parseResults = zhResult;
|
||||
}
|
||||
|
|
@ -495,20 +613,22 @@ export class TimeParsingService {
|
|||
|
||||
// If still no results, fallback to custom Chinese parsing
|
||||
if (parseResults.length === 0) {
|
||||
parseResults = this.parseChineseTimeExpressions(text);
|
||||
parseResults =
|
||||
this.parseChineseTimeExpressions(safeText);
|
||||
}
|
||||
} catch (chineseParsingError) {
|
||||
console.warn(
|
||||
"TimeParsingService: Chinese parsing failed:",
|
||||
chineseParsingError
|
||||
chineseParsingError,
|
||||
);
|
||||
// Fallback to custom Chinese parsing
|
||||
try {
|
||||
parseResults = this.parseChineseTimeExpressions(text);
|
||||
parseResults =
|
||||
this.parseChineseTimeExpressions(safeText);
|
||||
} catch (customParsingError) {
|
||||
console.warn(
|
||||
"TimeParsingService: Custom Chinese parsing failed:",
|
||||
customParsingError
|
||||
customParsingError,
|
||||
);
|
||||
parseResults = [];
|
||||
}
|
||||
|
|
@ -525,7 +645,7 @@ export class TimeParsingService {
|
|||
) {
|
||||
console.warn(
|
||||
"TimeParsingService: Invalid parse result structure:",
|
||||
parseResult
|
||||
parseResult,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -537,7 +657,7 @@ export class TimeParsingService {
|
|||
} catch (dateError) {
|
||||
console.warn(
|
||||
"TimeParsingService: Failed to extract date from parse result:",
|
||||
dateError
|
||||
dateError,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -546,7 +666,7 @@ export class TimeParsingService {
|
|||
if (!date || isNaN(date.getTime())) {
|
||||
console.warn(
|
||||
"TimeParsingService: Invalid date extracted:",
|
||||
date
|
||||
date,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -558,27 +678,34 @@ export class TimeParsingService {
|
|||
let type: "start" | "due" | "scheduled";
|
||||
try {
|
||||
type = this.determineTimeType(
|
||||
text,
|
||||
safeText,
|
||||
expressionText,
|
||||
index
|
||||
index,
|
||||
);
|
||||
} catch (typeError) {
|
||||
console.warn(
|
||||
"TimeParsingService: Failed to determine time type:",
|
||||
typeError
|
||||
typeError,
|
||||
);
|
||||
type = "due"; // Default fallback
|
||||
}
|
||||
|
||||
// Check if this date expression has an associated time component
|
||||
let matchingTimeExpr = timeExpressions.find(te =>
|
||||
te.index >= index - 10 && te.index <= index + length + 10
|
||||
let matchingTimeExpr = timeExpressions.find(
|
||||
(te) =>
|
||||
te.index >= index - 10 &&
|
||||
te.index <= index + length + 10,
|
||||
);
|
||||
|
||||
// Check if time range crosses midnight
|
||||
let crossesMidnight = false;
|
||||
if (matchingTimeExpr?.rangeStart && matchingTimeExpr?.rangeEnd) {
|
||||
crossesMidnight = matchingTimeExpr.rangeStart.hour > matchingTimeExpr.rangeEnd.hour;
|
||||
if (
|
||||
matchingTimeExpr?.rangeStart &&
|
||||
matchingTimeExpr?.rangeEnd
|
||||
) {
|
||||
crossesMidnight =
|
||||
matchingTimeExpr.rangeStart.hour >
|
||||
matchingTimeExpr.rangeEnd.hour;
|
||||
}
|
||||
|
||||
const expression: EnhancedTimeExpression = {
|
||||
|
|
@ -611,14 +738,14 @@ export class TimeParsingService {
|
|||
default:
|
||||
console.warn(
|
||||
"TimeParsingService: Unknown date type:",
|
||||
type
|
||||
type,
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (expressionError) {
|
||||
console.warn(
|
||||
"TimeParsingService: Error processing expression:",
|
||||
expressionError
|
||||
expressionError,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -627,7 +754,7 @@ export class TimeParsingService {
|
|||
// Clean the text by removing parsed expressions
|
||||
result.cleanedText = this.cleanTextFromTimeExpressions(
|
||||
text,
|
||||
result.parsedExpressions
|
||||
result.parsedExpressions,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Time parsing error:", error);
|
||||
|
|
@ -684,7 +811,7 @@ export class TimeParsingService {
|
|||
*/
|
||||
cleanTextFromTimeExpressions(
|
||||
text: string,
|
||||
expressions: ParsedTimeResult["parsedExpressions"]
|
||||
expressions: ParsedTimeResult["parsedExpressions"],
|
||||
): string {
|
||||
if (!this.config.removeOriginalText || expressions.length === 0) {
|
||||
return text;
|
||||
|
|
@ -693,7 +820,7 @@ export class TimeParsingService {
|
|||
// Sort expressions by index in descending order to remove from end to start
|
||||
// This prevents index shifting issues when removing multiple expressions
|
||||
const sortedExpressions = [...expressions].sort(
|
||||
(a, b) => b.index - a.index
|
||||
(a, b) => b.index - a.index,
|
||||
);
|
||||
|
||||
let cleanedText = text;
|
||||
|
|
@ -701,7 +828,7 @@ export class TimeParsingService {
|
|||
for (const expression of sortedExpressions) {
|
||||
const beforeExpression = cleanedText.substring(0, expression.index);
|
||||
const afterExpression = cleanedText.substring(
|
||||
expression.index + expression.length
|
||||
expression.index + expression.length,
|
||||
);
|
||||
|
||||
// Check if we need to clean up extra whitespace
|
||||
|
|
@ -805,7 +932,7 @@ export class TimeParsingService {
|
|||
private determineTimeType(
|
||||
text: string,
|
||||
expression: string,
|
||||
index: number
|
||||
index: number,
|
||||
): "start" | "due" | "scheduled" {
|
||||
// Get text before the expression (look back up to 20 characters)
|
||||
const beforeText = text
|
||||
|
|
@ -816,7 +943,7 @@ export class TimeParsingService {
|
|||
const afterText = text
|
||||
.substring(
|
||||
index + expression.length,
|
||||
Math.min(text.length, index + expression.length + 20)
|
||||
Math.min(text.length, index + expression.length + 20),
|
||||
)
|
||||
.toLowerCase();
|
||||
|
||||
|
|
@ -934,7 +1061,7 @@ export class TimeParsingService {
|
|||
const today = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
now.getDate(),
|
||||
);
|
||||
|
||||
// Helper function to get weekday number (0 = Sunday, 1 = Monday, ..., 6 = Saturday)
|
||||
|
|
@ -955,7 +1082,7 @@ export class TimeParsingService {
|
|||
// Helper function to get date for specific weekday
|
||||
const getDateForWeekday = (
|
||||
targetWeekday: number,
|
||||
weekOffset: number = 0
|
||||
weekOffset: number = 0,
|
||||
): Date => {
|
||||
const currentWeekday = today.getDay();
|
||||
let daysToAdd = targetWeekday - currentWeekday;
|
||||
|
|
@ -974,7 +1101,7 @@ export class TimeParsingService {
|
|||
|
||||
// Handle weekday expressions
|
||||
const weekdayMatch = expression.match(
|
||||
/(?:(下|上|这)?(?:周|礼拜|星期)?)([一二三四五六日天])/
|
||||
/(?:(下|上|这)?(?:周|礼拜|星期)?)([一二三四五六日天])/,
|
||||
);
|
||||
if (weekdayMatch) {
|
||||
const [, weekPrefix, dayStr] = weekdayMatch;
|
||||
|
|
@ -1018,14 +1145,14 @@ export class TimeParsingService {
|
|||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() + 1,
|
||||
now.getDate()
|
||||
now.getDate(),
|
||||
);
|
||||
case "上个月":
|
||||
case "上月":
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - 1,
|
||||
now.getDate()
|
||||
now.getDate(),
|
||||
);
|
||||
case "这个月":
|
||||
case "这月":
|
||||
|
|
@ -1034,13 +1161,13 @@ export class TimeParsingService {
|
|||
return new Date(
|
||||
now.getFullYear() + 1,
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
now.getDate(),
|
||||
);
|
||||
case "去年":
|
||||
return new Date(
|
||||
now.getFullYear() - 1,
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
now.getDate(),
|
||||
);
|
||||
case "今年":
|
||||
return today;
|
||||
|
|
@ -1054,17 +1181,17 @@ export class TimeParsingService {
|
|||
switch (unit) {
|
||||
case "天":
|
||||
return new Date(
|
||||
today.getTime() + num * 24 * 60 * 60 * 1000
|
||||
today.getTime() + num * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
case "周":
|
||||
return new Date(
|
||||
today.getTime() + num * 7 * 24 * 60 * 60 * 1000
|
||||
today.getTime() + num * 7 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
case "月":
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() + num,
|
||||
now.getDate()
|
||||
now.getDate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1074,7 +1201,8 @@ export class TimeParsingService {
|
|||
}
|
||||
|
||||
// Default configuration
|
||||
export const DEFAULT_TIME_PARSING_CONFIG: TimeParsingConfig & Partial<EnhancedTimeParsingConfig> = {
|
||||
export const DEFAULT_TIME_PARSING_CONFIG: TimeParsingConfig &
|
||||
Partial<EnhancedTimeParsingConfig> = {
|
||||
enabled: true,
|
||||
supportedLanguages: ["en", "zh"],
|
||||
dateKeywords: {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
var(--size-4-2) var(--onboarding-spacing);
|
||||
}
|
||||
|
||||
.onboarding-view .onboarding-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fluent-top-navigation.component-preview {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,26 @@ import { StandardTaskMetadata, EnhancedStandardTaskMetadata } from "../types/tas
|
|||
*/
|
||||
export function timestampToTimeComponent(timestamp: number): TimeComponent {
|
||||
const date = new Date(timestamp);
|
||||
const isLikelyDateOnly =
|
||||
date.getUTCHours() === 0 &&
|
||||
date.getUTCMinutes() === 0 &&
|
||||
date.getUTCSeconds() === 0 &&
|
||||
date.getUTCMilliseconds() === 0;
|
||||
const hour = isLikelyDateOnly ? 0 : date.getHours();
|
||||
const minute = isLikelyDateOnly ? 0 : date.getMinutes();
|
||||
const secondValue = isLikelyDateOnly ? 0 : date.getSeconds();
|
||||
const second =
|
||||
secondValue !== undefined && secondValue !== 0 ? secondValue : undefined;
|
||||
return {
|
||||
hour: date.getHours(),
|
||||
minute: date.getMinutes(),
|
||||
second: date.getSeconds(),
|
||||
originalText: `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`,
|
||||
isRange: false
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
originalText: `${hour.toString().padStart(2, '0')}:${minute
|
||||
.toString()
|
||||
.padStart(2, '0')}${second !== undefined ? `:${second
|
||||
.toString()
|
||||
.padStart(2, '0')}` : ''}`,
|
||||
isRange: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +100,16 @@ export function createEnhancedDates(
|
|||
enhancedDates.dueDateTime = combineDateAndTime(metadata.dueDate, timeComponents.dueTime);
|
||||
}
|
||||
|
||||
// If we have a due date but only scheduled time, use it for scheduled and due datetimes
|
||||
if (metadata.dueDate && !metadata.scheduledDate && timeComponents.scheduledTime) {
|
||||
if (!enhancedDates.dueDateTime) {
|
||||
enhancedDates.dueDateTime = combineDateAndTime(metadata.dueDate, timeComponents.scheduledTime);
|
||||
}
|
||||
if (!enhancedDates.scheduledDateTime) {
|
||||
enhancedDates.scheduledDateTime = combineDateAndTime(metadata.dueDate, timeComponents.scheduledTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine scheduled date and time
|
||||
if (metadata.scheduledDate && timeComponents.scheduledTime) {
|
||||
enhancedDates.scheduledDateTime = combineDateAndTime(metadata.scheduledDate, timeComponents.scheduledTime);
|
||||
|
|
@ -219,4 +243,4 @@ export function createTimeComponent(
|
|||
}
|
||||
|
||||
return timeComponent;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue