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:
Quorafind 2025-10-12 14:25:20 +08:00
parent 0b0d4af0fe
commit 59a2d42f9f
20 changed files with 1010 additions and 1045 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -471,6 +471,9 @@ const createMockPlugin = (
app: mockApp,
dataflowOrchestrator: mockDataflowOrchestrator,
writeAPI: mockWriteAPI,
taskManager: {
getCanvasTaskUpdater: jest.fn(),
},
rewardManager: {
// Mock RewardManager
showReward: jest.fn(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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