Repository: raycast/extensions
Pull request #17742: Update omnifocus extension
Author: login: alanwill
Labels: name: extension fix / improvement
description: Label for PRs with extension's fix improvements
name: extension: omnifocus
description: Issues related to the omnifocus extension
Created: 2025-03-11T03:52:45Z | Merged: 2025-03-14T12:48:33Z

## Description

This PR was intended to provide some quality of life improvements to the extension, specifically in the List tasks command. As included in the CHANGELOG, I added:
  - Two new Preferences options for controlling List Task behavior. The first one to control the default behavior of showing task details, and the next to specify a default Perspective.
  - Ability to group tasks by various categories
  - Ability to perform a Clean Up of completed tasks

## Checklist

- [x] I read the [extension guidelines](https://developers.raycast.com/basics/prepare-an-extension-for-store)
- [x] I read the [documentation about publishing](https://developers.raycast.com/basics/publish-an-extension)
- [x] I ran `npm run build` and [tested this distribution build in Raycast](https://developers.raycast.com/basics/prepare-an-extension-for-store#metadata-and-configuration)
- [x] I checked that files in the `assets` folder are used by the extension itself
- [x] I checked that assets used by the `README` are placed outside of the `metadata` folder



Diff:
diff --git a/extensions/omnifocus/CHANGELOG.md b/extensions/omnifocus/CHANGELOG.md
index 00a8559fc5a..2cc7b580ded 100644
--- a/extensions/omnifocus/CHANGELOG.md
+++ b/extensions/omnifocus/CHANGELOG.md
@@ -1,5 +1,14 @@
 # OmniFocus Changelog
 
+## [1.2.0] - 2025-03-14
+
+### Added
+
+- General quality of life improvements, specifically:
+  - Two new Preferences options for controlling List Task behavior
+  - Ability to group tasks by various categories
+  - Ability to perform a Clean Up of completed tasks
+
 ## [1.1.0] - 2025-02-26
 
 ### Removed
diff --git a/extensions/omnifocus/package.json b/extensions/omnifocus/package.json
index ba78bc71536..c29e8d026cc 100644
--- a/extensions/omnifocus/package.json
+++ b/extensions/omnifocus/package.json
@@ -5,6 +5,9 @@
   "description": "Perform basic actions on your OmniFocus database.",
   "icon": "extension-icon.png",
   "author": "nicolas-marien",
+  "contributors": [
+    "alanwill"
+  ],
   "categories": [
     "Productivity",
     "Applications"
@@ -38,7 +41,52 @@
       "title": "List the Tasks of Your Perspectives",
       "subtitle": "OmniFocus",
       "description": "List the tasks of your perspectives",
-      "mode": "view"
+      "mode": "view",
+      "preferences": [
+        {
+          "name": "showDetailsByDefault",
+          "type": "checkbox",
+          "required": false,
+          "title": "Show Details by Default",
+          "description": "Whether to show task details by default in list views",
+          "default": true,
+          "label": "Show task details by default"
+        },
+        {
+          "name": "defaultPerspective",
+          "type": "textfield",
+          "required": false,
+          "title": "Default Perspective",
+          "description": "The perspective to show by default when opening the command (defaults to Inbox if not specified)",
+          "placeholder": "Inbox"
+        },
+        {
+          "name": "defaultGrouping",
+          "type": "dropdown",
+          "required": false,
+          "title": "Default Grouping",
+          "description": "How tasks should be grouped by default when opening the command",
+          "default": "none",
+          "data": [
+            {
+              "title": "No Grouping",
+              "value": "none"
+            },
+            {
+              "title": "By Project",
+              "value": "project"
+            },
+            {
+              "title": "By Tags",
+              "value": "tags"
+            },
+            {
+              "title": "By Priority",
+              "value": "priority"
+            }
+          ]
+        }
+      ]
     }
   ],
   "dependencies": {
diff --git a/extensions/omnifocus/src/lib/api/cleanup.ts b/extensions/omnifocus/src/lib/api/cleanup.ts
new file mode 100644
index 00000000000..da12f859139
--- /dev/null
+++ b/extensions/omnifocus/src/lib/api/cleanup.ts
@@ -0,0 +1,19 @@
+import { executeScript } from "../utils/executeScript";
+
+export async function cleanupPerspective() {
+  const result = await executeScript<boolean>(`
+    try {
+      const omnifocus = Application('OmniFocus');
+      omnifocus.evaluateJavascript('cleanUp()');
+      return true;
+    } catch (err) {
+      return false;
+    }
+  `);
+
+  if (!result) {
+    throw new Error("Failed to clean up the perspective");
+  }
+
+  return true;
+}
diff --git a/extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts b/extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts
index 9b7458b5d53..17d38a4dc56 100644
--- a/extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts
+++ b/extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts
@@ -1,41 +1,58 @@
 import { OmniFocusTask } from "../types/task";
 import { executeScript } from "../utils/executeScript";
+import { showFailureToast } from "@raycast/utils";
 
 export async function listPerspectiveTasks(perspectiveName?: string): Promise<OmniFocusTask[]> {
   if (!perspectiveName) {
     return [];
   }
-  return await executeScript(`
+  const result = await executeScript<string>(`
 const omnifocus = Application("OmniFocus");
 const document = omnifocus.defaultDocument();
 const window = document.documentWindows[0];
 
 window.perspectiveName = "${perspectiveName}";
 
+function safeString(value) {
+  return value !== undefined ? value.toString() : null;
+}
+
 const leaves = window
   .content()
   .leaves()
   .map((l) => {
     const task = l.value();
-// NOTE: leaves are possibly not tasks
+    // NOTE: leaves are possibly not tasks
     try {
-      return {
+      const containingProject = task.containingProject();
+      const taskData = {
         id: task.id(),
         name: task.name(),
         flagged: task.flagged(),
-        deferDate: task.deferDate() ? task.deferDate().toString() : null,
-        dueDate: task.dueDate() ? task.dueDate().toString() : null,
+        deferDate: task.deferDate() ? safeString(task.deferDate()) : null,
+        dueDate: task.dueDate() ? safeString(task.dueDate()) : null,
         dropped: task.dropped(),
         completed: task.completed(),
         tags: task.tags ? task.tags().map((tt) => tt.name()) : [],
+        note: task.note ? safeString(task.note()) : null,
+        projectName: containingProject ? safeString(containingProject.name()) : null
       };
+      return taskData;
     } catch (e) {
+      console.error('Error processing task:', e);
       return null;
     }
   });
 
-return leaves.filter(Boolean);
-
-
+const validLeaves = leaves.filter(Boolean);
+return JSON.stringify(validLeaves);
 `);
+
+  // Parse the JSON string back into an array of tasks
+  try {
+    return JSON.parse(result) as OmniFocusTask[];
+  } catch (e) {
+    await showFailureToast("Failed to load tasks from perspective");
+    throw new Error(`Failed to parse tasks result: ${e}`);
+  }
 }
diff --git a/extensions/omnifocus/src/lib/api/list-tasks.ts b/extensions/omnifocus/src/lib/api/list-tasks.ts
index e2dfe87515e..3f96d1950aa 100644
--- a/extensions/omnifocus/src/lib/api/list-tasks.ts
+++ b/extensions/omnifocus/src/lib/api/list-tasks.ts
@@ -19,7 +19,8 @@ return tasks.reduce((ts, t) => {
       dueDate: t.dueDate() ? t.dueDate().toString() : null,
       dropped: t.dropped(),
       completed,
-      tags: t.tags ? t.tags().map(tt => tt.name()) : []
+      tags: t.tags ? t.tags().map(tt => tt.name()) : [],
+      note: t.note ? t.note() : null
     });
   }
   return ts;
diff --git a/extensions/omnifocus/src/lib/components/task-detail-view.tsx b/extensions/omnifocus/src/lib/components/task-detail-view.tsx
new file mode 100644
index 00000000000..5bf61e2d30f
--- /dev/null
+++ b/extensions/omnifocus/src/lib/components/task-detail-view.tsx
@@ -0,0 +1,60 @@
+import { Action, ActionPanel, Detail, Icon, Keyboard, open } from "@raycast/api";
+import { OmniFocusTask } from "../types/task";
+
+type TaskMetadataItem = {
+  title: string;
+  value: string;
+};
+
+interface TaskDetailViewProps {
+  task: OmniFocusTask;
+  onComplete: () => Promise<void>;
+  onDelete: () => Promise<void>;
+  onCleanup: () => Promise<void>;
+}
+
+export function TaskDetailView({ task, onComplete, onDelete, onCleanup }: TaskDetailViewProps) {
+  const metadata: TaskMetadataItem[] = [
+    { title: "Status", value: task.completed ? "Completed" : "Active" },
+    { title: "Flagged", value: task.flagged ? "Yes" : "No" },
+    ...(task.projectName ? [{ title: "Project", value: task.projectName }] : []),
+    ...(task.deferDate ? [{ title: "Defer Date", value: new Date(task.deferDate).toLocaleDateString() }] : []),
+    ...(task.dueDate ? [{ title: "Due Date", value: new Date(task.dueDate).toLocaleDateString() }] : []),
+    ...(task.tags.length > 0 ? [{ title: "Tags", value: task.tags.join(", ") }] : []),
+  ];
+
+  const markdown = `
+# ${task.name}
+
+${metadata.map((item) => `**${item.title}:** ${item.value}`).join("\n\n")}
+
+${task.note ? `\n## Notes\n\n${task.note}` : ""}
+  `.trim();
+
+  return (
+    <Detail
+      markdown={markdown}
+      actions={
+        <ActionPanel>
+          <ActionPanel.Section>
+            <Action title="Open in Omnifocus" onAction={() => open(`omnifocus:///task/${task.id}`)} icon={Icon.Eye} />
+            <Action title="Complete" onAction={onComplete} icon={Icon.CheckCircle} />
+            <Action
+              title="Clean Up"
+              onAction={onCleanup}
+              icon={Icon.Checkmark}
+              shortcut={{ modifiers: ["cmd", "shift"], key: "k" }}
+            />
+            <Action
+              title="Delete"
+              style={Action.Style.Destructive}
+              onAction={onDelete}
+              icon={Icon.Trash}
+              shortcut={Keyboard.Shortcut.Common.Remove}
+            />
+          </ActionPanel.Section>
+        </ActionPanel>
+      }
+    />
+  );
+}
diff --git a/extensions/omnifocus/src/lib/components/task-list.tsx b/extensions/omnifocus/src/lib/components/task-list.tsx
index 632ee35575d..70bbe310c94 100644
--- a/extensions/omnifocus/src/lib/components/task-list.tsx
+++ b/extensions/omnifocus/src/lib/components/task-list.tsx
@@ -1,15 +1,19 @@
-import { Action, ActionPanel, Color, Icon, Keyboard, List, showToast, Toast, open } from "@raycast/api";
+import { Action, ActionPanel, Color, Icon, Keyboard, List } from "@raycast/api";
 import { OmniFocusTask } from "../types/task";
-import { completeTask } from "../api/complete.task";
-import { deleteTask } from "../api/delete-task";
+import { groupTasks } from "../utils/group-tasks";
+import { useTaskActions } from "../hooks/use-task-actions";
+import { TaskDetailView } from "./task-detail-view";
+
+export type GroupBy = "none" | "project" | "tags" | "priority";
 
 export type TaskListProps = {
   isLoading: boolean;
   tasks?: OmniFocusTask[];
   title?: string;
-  onTaskUpdated?: () => unknown;
+  onTaskUpdated?: () => Promise<void>;
   searchBarAccessory?: List.Props["searchBarAccessory"];
   isShowingDetail?: List.Props["isShowingDetail"];
+  groupBy?: GroupBy;
 };
 
 function getAccessories(task: OmniFocusTask, isShowingDetail: TaskListProps["isShowingDetail"]): List.Item.Accessory[] {
@@ -52,43 +56,15 @@ function getAccessories(task: OmniFocusTask, isShowingDetail: TaskListProps["isS
 
 export const TaskList: React.FunctionComponent<TaskListProps> = ({
   isLoading,
-  tasks,
+  tasks = [],
   title,
   onTaskUpdated,
   searchBarAccessory,
   isShowingDetail,
+  groupBy = "none",
 }) => {
-  async function actionDelete(id: string) {
-    try {
-      await deleteTask(id);
-      await showToast({
-        title: "Task deleted!",
-        style: Toast.Style.Success,
-      });
-      await onTaskUpdated?.();
-    } catch {
-      await showToast({
-        title: "An error occurred while deleting the task.",
-        style: Toast.Style.Failure,
-      });
-    }
-  }
-
-  async function actionComplete(id: string) {
-    try {
-      await completeTask(id);
-      await showToast({
-        title: "Task completed!",
-        style: Toast.Style.Success,
-      });
-      await onTaskUpdated?.();
-    } catch {
-      await showToast({
-        title: "An error occurred while completing the task.",
-        style: Toast.Style.Failure,
-      });
-    }
-  }
+  const { actionDelete, actionComplete, actionCleanup } = useTaskActions(onTaskUpdated);
+  const groupedTasks = groupTasks(tasks, groupBy);
 
   return (
     <List
@@ -100,62 +76,88 @@ export const TaskList: React.FunctionComponent<TaskListProps> = ({
       {!tasks?.length ? (
         <List.EmptyView title={`No tasks in ${title}`} />
       ) : (
-        tasks?.map((t) => {
-          return (
-            <List.Item
-              key={t.id}
-              title={t.name}
-              icon={!t.completed ? Icon.Circle : Icon.Checkmark}
-              detail={
-                <List.Item.Detail
-                  markdown={t.name}
-                  metadata={
-                    <List.Item.Detail.Metadata>
-                      <List.Item.Detail.Metadata.Label
-                        title="Defer date"
-                        text={t.deferDate ? new Date(t.deferDate).toLocaleDateString() : undefined}
-                        icon={Icon.ArrowClockwise}
-                      />
-                      <List.Item.Detail.Metadata.Label
-                        title="Due date"
-                        text={{ value: t.dueDate ? new Date(t.dueDate).toLocaleDateString() : "", color: Color.Orange }}
-                        icon={Icon.Calculator}
-                      />
-                      <List.Item.Detail.Metadata.TagList title="Tags">
-                        {t.tags.map((tag) => (
-                          <List.Item.Detail.Metadata.TagList.Item text={tag} key={tag} />
-                        ))}
-                      </List.Item.Detail.Metadata.TagList>
-                      <List.Item.Detail.Metadata.Label title="Note" text={t.note} />
-                    </List.Item.Detail.Metadata>
+        groupedTasks.map((group) => (
+          <List.Section key={group.title} title={group.title}>
+            {group.tasks.map((t) => {
+              return (
+                <List.Item
+                  key={t.id}
+                  title={t.name}
+                  icon={!t.completed ? Icon.Circle : Icon.Checkmark}
+                  detail={
+                    <List.Item.Detail
+                      markdown={`# ${t.name}`}
+                      metadata={
+                        <List.Item.Detail.Metadata>
+                          <List.Item.Detail.Metadata.Label
+                            title="Defer date"
+                            text={t.deferDate ? new Date(t.deferDate).toLocaleDateString() : undefined}
+                            icon={Icon.ArrowClockwise}
+                          />
+                          <List.Item.Detail.Metadata.Label
+                            title="Due date"
+                            text={{
+                              value: t.dueDate ? new Date(t.dueDate).toLocaleDateString() : "",
+                              color: Color.Orange,
+                            }}
+                            icon={Icon.Calculator}
+                          />
+                          <List.Item.Detail.Metadata.TagList title="Tags">
+                            {t.tags.map((tag) => (
+                              <List.Item.Detail.Metadata.TagList.Item text={tag} key={tag} />
+                            ))}
+                          </List.Item.Detail.Metadata.TagList>
+                          <List.Item.Detail.Metadata.Label title="Note" text={t.note} />
+                        </List.Item.Detail.Metadata>
+                      }
+                    />
+                  }
+                  accessories={getAccessories(t, isShowingDetail)}
+                  actions={
+                    <ActionPanel>
+                      <ActionPanel.Section>
+                        <Action.Push
+                          title="View Details"
+                          target={
+                            <TaskDetailView
+                              task={t}
+                              onComplete={async () => await actionComplete(t.id)}
+                              onDelete={async () => await actionDelete(t.id)}
+                              onCleanup={actionCleanup}
+                            />
+                          }
+                          icon={Icon.Eye}
+                        />
+                        <Action
+                          title="Complete"
+                          onAction={async () => {
+                            await actionComplete(t.id);
+                          }}
+                          icon={Icon.CheckCircle}
+                        />
+                        <Action
+                          title="Clean Up"
+                          onAction={actionCleanup}
+                          icon={Icon.Checkmark}
+                          shortcut={{ modifiers: ["cmd", "shift"], key: "k" }}
+                        />
+                        <Action
+                          title="Delete"
+                          style={Action.Style.Destructive}
+                          onAction={async () => {
+                            await actionDelete(t.id);
+                          }}
+                          icon={Icon.Trash}
+                          shortcut={Keyboard.Shortcut.Common.Remove}
+                        />
+                      </ActionPanel.Section>
+                    </ActionPanel>
                   }
                 />
-              }
-              accessories={getAccessories(t, isShowingDetail)}
-              actions={
-                <ActionPanel>
-                  <Action title="Open" onAction={() => open(`omnifocus:///task/${t.id}`)} icon={Icon.Eye} />
-                  <Action
-                    title="Complete"
-                    onAction={async () => {
-                      await actionComplete(t.id);
-                    }}
-                    icon={Icon.CheckCircle}
-                  />
-                  <Action
-                    title="Delete"
-                    style={Action.Style.Destructive}
-                    onAction={async () => {
-                      await actionDelete(t.id);
-                    }}
-                    icon={Icon.Trash}
-                    shortcut={Keyboard.Shortcut.Common.Remove}
-                  />
-                </ActionPanel>
-              }
-            />
-          );
-        })
+              );
+            })}
+          </List.Section>
+        ))
       )}
     </List>
   );
diff --git a/extensions/omnifocus/src/lib/hooks/use-task-actions.ts b/extensions/omnifocus/src/lib/hooks/use-task-actions.ts
new file mode 100644
index 00000000000..0064f089a47
--- /dev/null
+++ b/extensions/omnifocus/src/lib/hooks/use-task-actions.ts
@@ -0,0 +1,52 @@
+import { showToast, Toast } from "@raycast/api";
+import { showFailureToast } from "@raycast/utils";
+import { completeTask } from "../api/complete.task";
+import { deleteTask } from "../api/delete-task";
+import { cleanupPerspective } from "../api/cleanup";
+
+export function useTaskActions(onTaskUpdated?: () => Promise<void>) {
+  async function actionDelete(id: string) {
+    try {
+      await deleteTask(id);
+      await showToast({
+        title: "Task deleted!",
+        style: Toast.Style.Success,
+      });
+      await onTaskUpdated?.();
+    } catch {
+      await showFailureToast("An error occurred while deleting the task.");
+    }
+  }
+
+  async function actionComplete(id: string) {
+    try {
+      await completeTask(id);
+      await showToast({
+        title: "Task completed!",
+        style: Toast.Style.Success,
+      });
+      await onTaskUpdated?.();
+    } catch {
+      await showFailureToast("An error occurred while completing the task.");
+    }
+  }
+
+  async function actionCleanup() {
+    try {
+      await cleanupPerspective();
+      await showToast({
+        title: "Perspective cleaned up!",
+        style: Toast.Style.Success,
+      });
+      await onTaskUpdated?.();
+    } catch {
+      await showFailureToast("An error occurred while cleaning up the perspective.");
+    }
+  }
+
+  return {
+    actionDelete,
+    actionComplete,
+    actionCleanup,
+  };
+}
diff --git a/extensions/omnifocus/src/lib/types/task.ts b/extensions/omnifocus/src/lib/types/task.ts
index 451c09a3bfa..8a60357cf7d 100644
--- a/extensions/omnifocus/src/lib/types/task.ts
+++ b/extensions/omnifocus/src/lib/types/task.ts
@@ -8,6 +8,7 @@ export type OmniFocusTask = {
   dueDate?: Date;
   dropped: boolean;
   tags: string[];
+  projectName?: string;
 };
 
 export type CreateOmniFocusTaskOptions = {
diff --git a/extensions/omnifocus/src/lib/utils/group-tasks.ts b/extensions/omnifocus/src/lib/utils/group-tasks.ts
new file mode 100644
index 00000000000..47b71f1c57e
--- /dev/null
+++ b/extensions/omnifocus/src/lib/utils/group-tasks.ts
@@ -0,0 +1,100 @@
+import { OmniFocusTask } from "../types/task";
+import { GroupBy } from "../components/task-list";
+
+export function groupTasks(tasks: OmniFocusTask[], groupBy: GroupBy): { title: string; tasks: OmniFocusTask[] }[] {
+  // Ensure tasks is an array
+  const taskArray = Array.isArray(tasks) ? tasks : [];
+
+  if (!taskArray.length || groupBy === "none") {
+    return [{ title: "All Tasks", tasks: taskArray }];
+  }
+
+  switch (groupBy) {
+    case "project": {
+      const groupedByProject = new Map<string, OmniFocusTask[]>();
+      taskArray.forEach((task) => {
+        const projectName = task.projectName || "No Project";
+        if (!groupedByProject.has(projectName)) {
+          groupedByProject.set(projectName, []);
+        }
+        const tasks = groupedByProject.get(projectName)!;
+        tasks.push(task);
+      });
+      return Array.from(groupedByProject.entries())
+        .sort(([a], [b]) => {
+          if (a === "No Project") return 1;
+          if (b === "No Project") return -1;
+          return a.localeCompare(b);
+        })
+        .map(([projectName, tasks]) => ({
+          title: projectName,
+          tasks,
+        }));
+    }
+
+    case "tags": {
+      const noTags = "No Tags";
+      const groupedByTags = new Map<string, OmniFocusTask[]>();
+
+      taskArray.forEach((task) => {
+        if (task.tags.length === 0) {
+          if (!groupedByTags.has(noTags)) {
+            groupedByTags.set(noTags, []);
+          }
+          const tasks = groupedByTags.get(noTags)!;
+          tasks.push(task);
+        } else {
+          task.tags.forEach((tag) => {
+            if (!groupedByTags.has(tag)) {
+              groupedByTags.set(tag, []);
+            }
+            const tasks = groupedByTags.get(tag)!;
+            tasks.push(task);
+          });
+        }
+      });
+      return Array.from(groupedByTags.entries())
+        .sort(([a], [b]) => {
+          if (a === noTags) return 1;
+          if (b === noTags) return -1;
+          return a.localeCompare(b);
+        })
+        .map(([title, tasks]) => ({
+          title,
+          tasks: tasks.sort((a, b) => a.name.localeCompare(b.name)),
+        }))
+        .filter((group) => group.tasks.length > 0);
+    }
+
+    case "priority": {
+      const priorityOrder = ["Flagged", "Due", "None"];
+      const groupedByPriority = new Map<string, OmniFocusTask[]>();
+
+      // Initialize priority groups
+      priorityOrder.forEach((p) => groupedByPriority.set(p, []));
+
+      // Sort tasks into priority groups
+      taskArray.forEach((task) => {
+        let priority = "None";
+        if (task.flagged) {
+          priority = "Flagged";
+        } else if (task.dueDate) {
+          priority = "Due";
+        }
+        const tasks = groupedByPriority.get(priority)!;
+        tasks.push(task);
+      });
+
+      // Return only non-empty priority groups in the specified order
+      return priorityOrder
+        .filter((priority) => groupedByPriority.get(priority)?.length)
+        .map((priority) => ({
+          title: priority,
+          tasks: groupedByPriority.get(priority) || [],
+        }));
+    }
+
+    default:
+      return [{ title: "All Tasks", tasks: taskArray }];
+  }
+}
diff --git a/extensions/omnifocus/src/list-inbox.tsx b/extensions/omnifocus/src/list-inbox.tsx
index b0693e32ee6..f35a24d6ef3 100644
--- a/extensions/omnifocus/src/list-inbox.tsx
+++ b/extensions/omnifocus/src/list-inbox.tsx
@@ -1,10 +1,9 @@
 import { Action, ActionPanel, Color, Icon, Keyboard, List, open, showToast, Toast } from "@raycast/api";
 import { useEffect, useState } from "react";
-import { completeTask } from "./lib/api/complete.task";
-import { deleteTask } from "./lib/api/delete-task";
 import { listTasks } from "./lib/api/list-tasks";
 import { OmniFocusTask } from "./lib/types/task";
 import { useValidateRequirements } from "./lib/utils/useValidateRequirements";
+import { useTaskActions } from "./lib/hooks/use-task-actions";
 
 function getAccessories(task: OmniFocusTask): List.Item.Accessory[] {
   const accessories: List.Item.Accessory[] = [];
@@ -67,37 +66,7 @@ export default function ListInboxTasks() {
     }
   };
 
-  async function actionComplete(id: string) {
-    try {
-      await completeTask(id);
-      await showToast({
-        title: "Task completed!",
-        style: Toast.Style.Success,
-      });
-      await fetchTasks();
-    } catch {
-      await showToast({
-        title: "An error occurred while completing the task.",
-        style: Toast.Style.Failure,
-      });
-    }
-  }
-
-  async function actionDelete(id: string) {
-    try {
-      await deleteTask(id);
-      await showToast({
-        title: "Task deleted!",
-        style: Toast.Style.Success,
-      });
-      await fetchTasks();
-    } catch {
-      await showToast({
-        title: "An error occurred while deleting the task.",
-        style: Toast.Style.Failure,
-      });
-    }
-  }
+  const { actionComplete, actionDelete } = useTaskActions(fetchTasks);
 
   useEffect(() => {
     if (!loading) {
diff --git a/extensions/omnifocus/src/list-tasks.tsx b/extensions/omnifocus/src/list-tasks.tsx
index 41fe20e60e5..4479f971563 100644
--- a/extensions/omnifocus/src/list-tasks.tsx
+++ b/extensions/omnifocus/src/list-tasks.tsx
@@ -1,13 +1,30 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type ViewMode = {
+  perspective: string;
+  groupBy: GroupBy;
+};
+
+const isValidGroupBy = (value: string | undefined): value is GroupBy => {
+  return value === "none" || value === "project" || value === "tags" || value === "priority";
+};
 
 export default function PerspectivesCommand() {
-  const [perspective, setPerspective] = useState<string>("Inbox");
+  const preferences = getPreferenceValues();
+
+  // Initialize view mode with preferences but maintain state during session
+  const [viewMode, setViewMode] = useState<ViewMode>({
+    perspective: preferences.defaultPerspective || "Inbox",
+    groupBy: preferences.defaultGrouping || "none",
+  });
+
   const {
     isLoading: perspectiveLoading,
     data: perspectives,
@@ -16,7 +33,12 @@ export default function PerspectivesCommand() {
 
   const listPerspectiveTasksMemo = useCallback((name: string) => listPerspectiveTasks(name), []);
 
-  const { isLoading, data, revalidate, error: taskError } = usePromise(listPerspectiveTasksMemo, [perspective]);
+  const {
+    isLoading,
+    data,
+    revalidate,
+    error: taskError,
+  } = usePromise(listPerspectiveTasksMemo, [viewMode.perspective]);
 
   if (perspectiveError || taskError) {
     return (
@@ -29,25 +51,61 @@ export default function PerspectivesCommand() {
   const customNames = perspectives?.customPerspectives.map((p) => p.name);
   const builtInNames = perspectives?.names.filter((name) => !customNames?.includes(name));
 
+  const handleViewChange = (value: string) => {
+    if (value.startsWith("group:")) {
+      // Update only grouping, maintain current perspective
+      const newGroupBy = value.replace("group:", "");
+      if (isValidGroupBy(newGroupBy)) {
+        setViewMode((current) => ({
+          ...current,
+          groupBy: newGroupBy,
+        }));
+      }
+    } else {
+      // Update only perspective, maintain current grouping
+      setViewMode((current) => ({
+        ...current,
+        perspective: value,
+      }));
+    }
+  };
+
+  const getDropdownValue = () => {
+    if (viewMode.groupBy !== "none") {
+      return `group:${viewMode.groupBy}`;
+    }
+    return viewMode.perspective;
+  };
+
   return (
     <ValidateRequirements>
       <TaskList
         isLoading={perspectiveLoading || isLoading}
         tasks={data}
-        title={perspective}
-        onTaskUpdated={async () => await revalidate()}
-        isShowingDetail
+        title={viewMode.groupBy === "none" ? viewMode.perspective : `${viewMode.perspective} (${viewMode.groupBy})`}
+        onTaskUpdated={async () => {
+          await revalidate();
+        }}
+        isShowingDetail={preferences.showDetailsByDefault}
+        groupBy={viewMode.groupBy}
         searchBarAccessory={
-          <List.Dropdown tooltip="Name of the perspective" onChange={setPerspective} defaultValue="Inbox">
-            <List.Dropdown.Section title="Built-in perspectives">
+          <List.Dropdown tooltip="View Options" onChange={handleViewChange} defaultValue={getDropdownValue()}>
+            <List.Dropdown.Section title="Built-in Perspectives">
               {builtInNames?.map((n) => <List.Dropdown.Item key={n} title={n} value={n} />)}
             </List.Dropdown.Section>
 
-            <List.Dropdown.Section title="Custom perspectives">
+            <List.Dropdown.Section title="Custom Perspectives">
               {perspectives?.customPerspectives.map((p) => (
                 <List.Dropdown.Item key={p.id} title={p.name} value={p.name} />
               ))}
             </List.Dropdown.Section>
+
+            <List.Dropdown.Section title="Group Current View">
+              <List.Dropdown.Item title="No Grouping" value="group:none" />
+              <List.Dropdown.Item title="By Project" value="group:project" />
+              <List.Dropdown.Item title="By Tags" value="group:tags" />
+              <List.Dropdown.Item title="By Priority" value="group:priority" />
+            </List.Dropdown.Section>
           </List.Dropdown>
         }
       />


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR enhances the OmniFocus extension with new task organization and cleanup features, along with improved error handling and UI components.

- Added task grouping functionality (by project, tags, priority) with dropdown UI controls in `/extensions/omnifocus/src/list-tasks.tsx`
- Implemented new cleanup perspective feature in `/extensions/omnifocus/src/lib/api/cleanup.ts` for managing completed tasks
- Added preferences for default perspective, grouping options, and detail view behavior in `package.json`
- Enhanced task data with project name and note fields in list-perspectives-tasks.ts
- Improved error handling with `safeString` helper and proper JSON serialization in API calls

Note: The `showToast` error handling in task-list.tsx could be simplified using `showFailureToast` from `@raycast/utils`.

<sub>💡 (1/5) You can manually trigger the bot by mentioning @greptileai in a comment!</sub>

<sub>8 file(s) reviewed, 8 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=extensions_17742)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T03:57:58Z
user: login: nicolas-marien
body: Thank you for your contribution 🤘! That really cool!
Here are a few feedback. I'll test it asap.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T08:33:07Z
user: login: alanwill
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T16:47:15Z
user: login: alanwill
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T16:52:54Z
user: login: alanwill
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T17:16:53Z
user: login: alanwill
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T17:46:01Z
user: login: nicolas-marien
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:12:51Z
user: login: nicolas-marien
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:24:28Z
user: login: nicolas-marien
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:27:20Z
user: login: alanwill
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T21:54:21Z
user: login: nicolas-marien
body: lgtm 🤘 Looking forward to using the grouping!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T08:15:53Z
user: login: pernielsentikaer
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-14T12:47:20Z

Review comments:
diff_hunk: @@ -5,37 +5,48 @@ export async function listPerspectiveTasks(perspectiveName?: string): Promise<Om
   if (!perspectiveName) {
     return [];
   }
-  return await executeScript(`
+  const result = await executeScript<string>(`
 const omnifocus = Application("OmniFocus");
 const document = omnifocus.defaultDocument();
 const window = document.documentWindows[0];
 
 window.perspectiveName = "${perspectiveName}";
 
+function safeString(value) {
+  return value !== undefined ? value.toString() : null;
+}
+
 const leaves = window
   .content()
   .leaves()
   .map((l) => {
     const task = l.value();
-// NOTE: leaves are possibly not tasks
+    // NOTE: leaves are possibly not tasks
     try {
-      return {
+      const containingProject = task.containingProject();
+      const taskData = {
         id: task.id(),
         name: task.name(),
         flagged: task.flagged(),
-        deferDate: task.deferDate() ? task.deferDate().toString() : null,
-        dueDate: task.dueDate() ? task.dueDate().toString() : null,
+        deferDate: task.deferDate() ? safeString(task.deferDate()) : null,
+        dueDate: task.dueDate() ? safeString(task.dueDate()) : null,
         dropped: task.dropped(),
         completed: task.completed(),
         tags: task.tags ? task.tags().map((tt) => tt.name()) : [],
+        note: task.note ? safeString(task.note()) : null,
+        projectName: containingProject ? safeString(containingProject.name()) : null
       };
+      return taskData;
     } catch (e) {
+      console.log('Error processing task:', e);
path: extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts
user: login: greptile-apps[bot]
body: logic: console.log in AppleScript context may not work as expected. Consider using Application('System Events').log()
created_at: 2025-03-11T03:54:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,37 +5,48 @@ export async function listPerspectiveTasks(perspectiveName?: string): Promise<Om
   if (!perspectiveName) {
     return [];
   }
-  return await executeScript(`
+  const result = await executeScript<string>(`
 const omnifocus = Application("OmniFocus");
 const document = omnifocus.defaultDocument();
 const window = document.documentWindows[0];
 
 window.perspectiveName = "${perspectiveName}";
 
+function safeString(value) {
+  return value !== undefined ? value.toString() : null;
+}
+
 const leaves = window
   .content()
   .leaves()
   .map((l) => {
     const task = l.value();
-// NOTE: leaves are possibly not tasks
+    // NOTE: leaves are possibly not tasks
     try {
-      return {
+      const containingProject = task.containingProject();
+      const taskData = {
         id: task.id(),
         name: task.name(),
         flagged: task.flagged(),
-        deferDate: task.deferDate() ? task.deferDate().toString() : null,
-        dueDate: task.dueDate() ? task.dueDate().toString() : null,
+        deferDate: task.deferDate() ? safeString(task.deferDate()) : null,
+        dueDate: task.dueDate() ? safeString(task.dueDate()) : null,
         dropped: task.dropped(),
         completed: task.completed(),
         tags: task.tags ? task.tags().map((tt) => tt.name()) : [],
+        note: task.note ? safeString(task.note()) : null,
+        projectName: containingProject ? safeString(containingProject.name()) : null
       };
+      return taskData;
     } catch (e) {
+      console.log('Error processing task:', e);
       return null;
     }
   });
 
-return leaves.filter(Boolean);
-
-
+const validLeaves = leaves.filter(Boolean);
+return JSON.stringify(validLeaves);
 `);
+
+  // Parse the JSON string back into an array of tasks
+  return JSON.parse(result) as OmniFocusTask[];
path: extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts
user: login: greptile-apps[bot]
body: logic: JSON.parse could fail if result is malformed. Should be in try/catch

```suggestion
  // Parse the JSON string back into an array of tasks
  try {
    return JSON.parse(result) as OmniFocusTask[];
  } catch (e) {
    throw new Error(`Failed to parse tasks result: ${e}`);
  }
```
created_at: 2025-03-11T03:54:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,13 +54,168 @@ function getAccessories(task: OmniFocusTask, isShowingDetail: TaskListProps["isS
   return accessories;
 }
 
+function groupTasks(tasks: OmniFocusTask[], groupBy: GroupBy): { title: string; tasks: OmniFocusTask[] }[] {
+  // Ensure tasks is an array
+  const taskArray = Array.isArray(tasks) ? tasks : [];
+
+  if (!taskArray.length || groupBy === "none") {
+    return [{ title: "All Tasks", tasks: taskArray }];
+  }
+
+  switch (groupBy) {
+    case "project": {
+      const groupedByProject = new Map<string, OmniFocusTask[]>();
+      taskArray.forEach((task) => {
+        const projectName = task.projectName || "No Project";
+        if (!groupedByProject.has(projectName)) {
+          groupedByProject.set(projectName, []);
+        }
+        groupedByProject.get(projectName)?.push(task);
path: extensions/omnifocus/src/lib/components/task-list.tsx
user: login: greptile-apps[bot]
body: logic: Optional chaining on Map.get() could lead to undefined being pushed to array

```suggestion
        const tasks = groupedByProject.get(projectName)!;
        tasks.push(task);
```
created_at: 2025-03-11T03:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,13 +54,168 @@ function getAccessories(task: OmniFocusTask, isShowingDetail: TaskListProps["isS
   return accessories;
 }
 
+function groupTasks(tasks: OmniFocusTask[], groupBy: GroupBy): { title: string; tasks: OmniFocusTask[] }[] {
+  // Ensure tasks is an array
+  const taskArray = Array.isArray(tasks) ? tasks : [];
+
+  if (!taskArray.length || groupBy === "none") {
+    return [{ title: "All Tasks", tasks: taskArray }];
+  }
+
+  switch (groupBy) {
+    case "project": {
+      const groupedByProject = new Map<string, OmniFocusTask[]>();
+      taskArray.forEach((task) => {
+        const projectName = task.projectName || "No Project";
+        if (!groupedByProject.has(projectName)) {
+          groupedByProject.set(projectName, []);
+        }
+        groupedByProject.get(projectName)?.push(task);
+      });
+      return Array.from(groupedByProject.entries())
+        .sort(([a], [b]) => {
+          if (a === "No Project") return 1;
+          if (b === "No Project") return -1;
+          return a.localeCompare(b);
+        })
+        .map(([projectName, tasks]) => ({
+          title: projectName,
+          tasks,
+        }));
+    }
+
+    case "tags": {
+      const groupedByTags = new Map<string, OmniFocusTask[]>();
+      taskArray.forEach((task) => {
+        if (task.tags.length === 0) {
+          const noTags = "No Tags";
+          if (!groupedByTags.has(noTags)) {
+            groupedByTags.set(noTags, []);
+          }
+          groupedByTags.get(noTags)?.push(task);
+        } else {
+          task.tags.forEach((tag) => {
+            if (!groupedByTags.has(tag)) {
+              groupedByTags.set(tag, []);
+            }
+            groupedByTags.get(tag)?.push(task);
+          });
+        }
+      });
+      return Array.from(groupedByTags.entries())
+        .sort(([a], [b]) => {
+          if (a === "No Tags") return 1;
+          if (b === "No Tags") return -1;
+          return a.localeCompare(b);
+        })
+        .map(([title, tasks]) => ({
+          title,
+          tasks: tasks.sort((a, b) => a.name.localeCompare(b.name)),
+        }))
+        .filter((group) => group.tasks.length > 0);
+    }
+
+    case "priority": {
+      const priorityOrder = ["Flagged", "Due", "None"];
+      const groupedByPriority = new Map<string, OmniFocusTask[]>();
+
+      // Initialize priority groups
+      priorityOrder.forEach((p) => groupedByPriority.set(p, []));
+
+      // Sort tasks into priority groups
+      taskArray.forEach((task) => {
+        let priority = "None";
+        if (task.flagged) {
+          priority = "Flagged";
+        } else if (task.dueDate) {
+          priority = "Due";
+        }
+        groupedByPriority.get(priority)?.push(task);
path: extensions/omnifocus/src/lib/components/task-list.tsx
user: login: greptile-apps[bot]
body: logic: Optional chaining on Map.get() could lead to undefined being pushed to array

```suggestion
        const tasks = groupedByPriority.get(priority)!;
        tasks.push(task);
```
created_at: 2025-03-11T03:55:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -100,62 +277,88 @@ export const TaskList: React.FunctionComponent<TaskListProps> = ({
       {!tasks?.length ? (
         <List.EmptyView title={`No tasks in ${title}`} />
       ) : (
-        tasks?.map((t) => {
-          return (
-            <List.Item
-              key={t.id}
-              title={t.name}
-              icon={!t.completed ? Icon.Circle : Icon.Checkmark}
-              detail={
-                <List.Item.Detail
-                  markdown={t.name}
-                  metadata={
-                    <List.Item.Detail.Metadata>
-                      <List.Item.Detail.Metadata.Label
-                        title="Defer date"
-                        text={t.deferDate ? new Date(t.deferDate).toLocaleDateString() : undefined}
-                        icon={Icon.ArrowClockwise}
-                      />
-                      <List.Item.Detail.Metadata.Label
-                        title="Due date"
-                        text={{ value: t.dueDate ? new Date(t.dueDate).toLocaleDateString() : "", color: Color.Orange }}
-                        icon={Icon.Calculator}
-                      />
-                      <List.Item.Detail.Metadata.TagList title="Tags">
-                        {t.tags.map((tag) => (
-                          <List.Item.Detail.Metadata.TagList.Item text={tag} key={tag} />
-                        ))}
-                      </List.Item.Detail.Metadata.TagList>
-                      <List.Item.Detail.Metadata.Label title="Note" text={t.note} />
-                    </List.Item.Detail.Metadata>
+        groupedTasks.map((group) => (
+          <List.Section key={group.title} title={group.title}>
+            {group.tasks.map((t) => {
+              return (
+                <List.Item
+                  key={t.id}
+                  title={t.name}
+                  icon={!t.completed ? Icon.Circle : Icon.Checkmark}
+                  detail={
+                    <List.Item.Detail
+                      markdown={t.name}
path: extensions/omnifocus/src/lib/components/task-list.tsx
user: login: greptile-apps[bot]
body: style: markdown prop just repeats task name without any markdown formatting
created_at: 2025-03-11T03:55:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,7 +19,8 @@ return tasks.reduce((ts, t) => {
       dueDate: t.dueDate() ? t.dueDate().toString() : null,
       dropped: t.dropped(),
       completed,
-      tags: t.tags ? t.tags().map(tt => tt.name()) : []
+      tags: t.tags ? t.tags().map(tt => tt.name()) : [],
+      note: t.note ? t.note() : undefined
path: extensions/omnifocus/src/lib/api/list-tasks.ts
user: login: greptile-apps[bot]
body: style: Consider using null instead of undefined for consistency with other nullable fields in this object (deferDate and dueDate use null)
created_at: 2025-03-11T03:56:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,32 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type Preferences = {
+  showDetailsByDefault: boolean;
+  defaultPerspective?: string;
+  defaultGrouping?: GroupBy;
+};
+
+type ViewMode = {
+  perspective: string;
+  groupBy: GroupBy;
+};
 
 export default function PerspectivesCommand() {
-  const [perspective, setPerspective] = useState<string>("Inbox");
+  const preferences = getPreferenceValues<Preferences>();
+
+  // Initialize view mode with preferences but maintain state during session
+  const [viewMode, setViewMode] = useState<ViewMode>({
+    perspective: preferences.defaultPerspective || "Inbox",
+    groupBy: (preferences.defaultGrouping as GroupBy) || "none",
path: extensions/omnifocus/src/list-tasks.tsx
user: login: greptile-apps[bot]
body: logic: Type casting defaultGrouping to GroupBy could fail silently if preference value is invalid
created_at: 2025-03-11T03:57:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,25 +53,56 @@ export default function PerspectivesCommand() {
   const customNames = perspectives?.customPerspectives.map((p) => p.name);
   const builtInNames = perspectives?.names.filter((name) => !customNames?.includes(name));
 
+  const handleViewChange = (value: string) => {
+    if (value.startsWith("group:")) {
+      // Update only grouping, maintain current perspective
+      setViewMode((current) => ({
+        ...current,
+        groupBy: value.replace("group:", "") as GroupBy,
+      }));
+    } else {
+      // Update only perspective, maintain current grouping
+      setViewMode((current) => ({
+        ...current,
+        perspective: value,
+      }));
+    }
+  };
+
+  const getDropdownValue = () => {
+    if (viewMode.groupBy !== "none") {
+      return `group:${viewMode.groupBy}`;
+    }
+    return viewMode.perspective;
+  };
+
   return (
     <ValidateRequirements>
       <TaskList
         isLoading={perspectiveLoading || isLoading}
         tasks={data}
-        title={perspective}
+        title={viewMode.groupBy === "none" ? viewMode.perspective : `${viewMode.perspective} (${viewMode.groupBy})`}
         onTaskUpdated={async () => await revalidate()}
path: extensions/omnifocus/src/list-tasks.tsx
user: login: greptile-apps[bot]
body: style: revalidate() is already async, no need for async/await here

```suggestion
        onTaskUpdated={() => revalidate()}
```
created_at: 2025-03-11T03:57:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { executeScript } from "../utils/executeScript";
+
+export async function cleanupPerspective() {
+  console.log("Starting cleanup operation");
+
+  try {
+    const result = await executeScript<string>(`
+      try {
+        const omnifocus = Application('com.omnigroup.OmniFocus4');
path: extensions/omnifocus/src/lib/api/cleanup.ts
user: login: nicolas-marien
body: You used this identifier to ensure that v4 is used?
Then maybe we could also update the other scripts to use the same identifier.
created_at: 2025-03-11T08:16:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { executeScript } from "../utils/executeScript";
+
+export async function cleanupPerspective() {
+  console.log("Starting cleanup operation");
+
+  try {
+    const result = await executeScript<string>(`
+      try {
+        const omnifocus = Application('com.omnigroup.OmniFocus4');
+        omnifocus.evaluateJavascript('cleanUp()');
+
+        return JSON.stringify({
+          success: true,
+          message: "Cleanup completed successfully"
+        });
path: extensions/omnifocus/src/lib/api/cleanup.ts
user: login: nicolas-marien
body: I do not think you need to stringify the result.
created_at: 2025-03-11T08:18:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { executeScript } from "../utils/executeScript";
+
+export async function cleanupPerspective() {
+  console.log("Starting cleanup operation");
+
+  try {
+    const result = await executeScript<string>(`
+      try {
+        const omnifocus = Application('com.omnigroup.OmniFocus4');
+        omnifocus.evaluateJavascript('cleanUp()');
+
+        return JSON.stringify({
+          success: true,
+          message: "Cleanup completed successfully"
+        });
+      } catch (err) {
+        return JSON.stringify({
+          success: false,
+          error: "Operation failed: " + String(err)
+        });
+      }
+    `);
+
+    console.log("Cleanup result:", result);
+
+    const parsedResult = JSON.parse(result);
path: extensions/omnifocus/src/lib/api/cleanup.ts
user: login: nicolas-marien
body: This is not required as the `executeScript` already does a parse.
created_at: 2025-03-11T08:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,37 +5,53 @@ export async function listPerspectiveTasks(perspectiveName?: string): Promise<Om
   if (!perspectiveName) {
     return [];
   }
-  return await executeScript(`
+  const result = await executeScript<string>(`
 const omnifocus = Application("OmniFocus");
 const document = omnifocus.defaultDocument();
 const window = document.documentWindows[0];
+const systemEvents = Application('System Events');
 
 window.perspectiveName = "${perspectiveName}";
 
+function safeString(value) {
+  return value !== undefined ? value.toString() : null;
+}
+
 const leaves = window
   .content()
   .leaves()
   .map((l) => {
     const task = l.value();
-// NOTE: leaves are possibly not tasks
+    // NOTE: leaves are possibly not tasks
     try {
-      return {
+      const containingProject = task.containingProject();
+      const taskData = {
         id: task.id(),
         name: task.name(),
         flagged: task.flagged(),
-        deferDate: task.deferDate() ? task.deferDate().toString() : null,
-        dueDate: task.dueDate() ? task.dueDate().toString() : null,
+        deferDate: task.deferDate() ? safeString(task.deferDate()) : null,
+        dueDate: task.dueDate() ? safeString(task.dueDate()) : null,
         dropped: task.dropped(),
         completed: task.completed(),
         tags: task.tags ? task.tags().map((tt) => tt.name()) : [],
+        note: task.note ? safeString(task.note()) : null,
+        projectName: containingProject ? safeString(containingProject.name()) : null
       };
+      return taskData;
     } catch (e) {
+      systemEvents.log('Error processing task: ' + e);
path: extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts
user: login: nicolas-marien
body: Will the user see this?
created_at: 2025-03-11T08:20:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,13 +55,172 @@ function getAccessories(task: OmniFocusTask, isShowingDetail: TaskListProps["isS
   return accessories;
 }
 
+function groupTasks(tasks: OmniFocusTask[], groupBy: GroupBy): { title: string; tasks: OmniFocusTask[] }[] {
path: extensions/omnifocus/src/lib/components/task-list.tsx
user: login: nicolas-marien
body: This function could be extracted into a dedicated file to ease readability.
created_at: 2025-03-11T08:22:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,13 +55,172 @@ function getAccessories(task: OmniFocusTask, isShowingDetail: TaskListProps["isS
   return accessories;
 }
 
+function groupTasks(tasks: OmniFocusTask[], groupBy: GroupBy): { title: string; tasks: OmniFocusTask[] }[] {
+  // Ensure tasks is an array
+  const taskArray = Array.isArray(tasks) ? tasks : [];
+
+  if (!taskArray.length || groupBy === "none") {
+    return [{ title: "All Tasks", tasks: taskArray }];
+  }
+
+  switch (groupBy) {
+    case "project": {
+      const groupedByProject = new Map<string, OmniFocusTask[]>();
+      taskArray.forEach((task) => {
+        const projectName = task.projectName || "No Project";
+        if (!groupedByProject.has(projectName)) {
+          groupedByProject.set(projectName, []);
+        }
+        const tasks = groupedByProject.get(projectName)!;
+        tasks.push(task);
+      });
+      return Array.from(groupedByProject.entries())
+        .sort(([a], [b]) => {
+          if (a === "No Project") return 1;
+          if (b === "No Project") return -1;
+          return a.localeCompare(b);
+        })
+        .map(([projectName, tasks]) => ({
+          title: projectName,
+          tasks,
+        }));
+    }
+
+    case "tags": {
+      const groupedByTags = new Map<string, OmniFocusTask[]>();
+      taskArray.forEach((task) => {
+        if (task.tags.length === 0) {
+          const noTags = "No Tags";
path: extensions/omnifocus/src/lib/components/task-list.tsx
user: login: nicolas-marien
body: nitpick: could be hoisted to the `case` scope.
created_at: 2025-03-11T08:26:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,36 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type Preferences = {
+  showDetailsByDefault: boolean;
+  defaultPerspective?: string;
+  defaultGrouping?: GroupBy;
+};
+
+type ViewMode = {
+  perspective: string;
+  groupBy: GroupBy;
+};
+
+const isValidGroupBy = (value: string | undefined): value is GroupBy => {
+  return value === "none" || value === "project" || value === "tags" || value === "priority";
+};
 
 export default function PerspectivesCommand() {
-  const [perspective, setPerspective] = useState<string>("Inbox");
+  const preferences = getPreferenceValues<Preferences>();
+
+  // Initialize view mode with preferences but maintain state during session
+  const [viewMode, setViewMode] = useState<ViewMode>({
+    perspective: preferences.defaultPerspective || "Inbox",
path: extensions/omnifocus/src/list-tasks.tsx
user: login: nicolas-marien
body: Thanks for this! That is a good idea. More convenient that what I previously did!
created_at: 2025-03-11T08:30:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,36 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type Preferences = {
+  showDetailsByDefault: boolean;
+  defaultPerspective?: string;
+  defaultGrouping?: GroupBy;
+};
+
+type ViewMode = {
+  perspective: string;
+  groupBy: GroupBy;
+};
+
+const isValidGroupBy = (value: string | undefined): value is GroupBy => {
+  return value === "none" || value === "project" || value === "tags" || value === "priority";
+};
 
 export default function PerspectivesCommand() {
-  const [perspective, setPerspective] = useState<string>("Inbox");
+  const preferences = getPreferenceValues<Preferences>();
+
+  // Initialize view mode with preferences but maintain state during session
+  const [viewMode, setViewMode] = useState<ViewMode>({
+    perspective: preferences.defaultPerspective || "Inbox",
+    groupBy: isValidGroupBy(preferences.defaultGrouping) ? preferences.defaultGrouping : "none",
path: extensions/omnifocus/src/list-tasks.tsx
user: login: nicolas-marien
body: nitpick: What you receive from the preferences should be valid already since you defined it w/ a dropdown and tackled the default value in the `package.json`.
created_at: 2025-03-11T08:32:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { executeScript } from "../utils/executeScript";
+
+export async function cleanupPerspective() {
+  console.log("Starting cleanup operation");
+
+  try {
+    const result = await executeScript<string>(`
+      try {
+        const omnifocus = Application('com.omnigroup.OmniFocus4');
path: extensions/omnifocus/src/lib/api/cleanup.ts
user: login: alanwill
body: Yes but thinking about it now I think it's better to stick with what you have instead as that way when OF5 comes out it doesn't cause an immediate breaking change 😬 
created_at: 2025-03-11T16:47:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { executeScript } from "../utils/executeScript";
+
+export async function cleanupPerspective() {
+  console.log("Starting cleanup operation");
+
+  try {
+    const result = await executeScript<string>(`
+      try {
+        const omnifocus = Application('com.omnigroup.OmniFocus4');
+        omnifocus.evaluateJavascript('cleanUp()');
+
+        return JSON.stringify({
+          success: true,
+          message: "Cleanup completed successfully"
+        });
path: extensions/omnifocus/src/lib/api/cleanup.ts
user: login: alanwill
body: Yep, will cleanup. This was an artifact of some debugging I was doing
created_at: 2025-03-11T16:52:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,36 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type Preferences = {
+  showDetailsByDefault: boolean;
+  defaultPerspective?: string;
+  defaultGrouping?: GroupBy;
+};
+
+type ViewMode = {
+  perspective: string;
+  groupBy: GroupBy;
+};
+
+const isValidGroupBy = (value: string | undefined): value is GroupBy => {
+  return value === "none" || value === "project" || value === "tags" || value === "priority";
+};
 
 export default function PerspectivesCommand() {
-  const [perspective, setPerspective] = useState<string>("Inbox");
+  const preferences = getPreferenceValues<Preferences>();
+
+  // Initialize view mode with preferences but maintain state during session
+  const [viewMode, setViewMode] = useState<ViewMode>({
+    perspective: preferences.defaultPerspective || "Inbox",
+    groupBy: isValidGroupBy(preferences.defaultGrouping) ? preferences.defaultGrouping : "none",
path: extensions/omnifocus/src/list-tasks.tsx
user: login: alanwill
body: The preferences isn't actually a drop down because Raycast doesn't provide a way to call OF before populating the preference modal so it's just a text box that we validate 
created_at: 2025-03-11T17:16:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,37 +5,53 @@ export async function listPerspectiveTasks(perspectiveName?: string): Promise<Om
   if (!perspectiveName) {
     return [];
   }
-  return await executeScript(`
+  const result = await executeScript<string>(`
 const omnifocus = Application("OmniFocus");
 const document = omnifocus.defaultDocument();
 const window = document.documentWindows[0];
+const systemEvents = Application('System Events');
 
 window.perspectiveName = "${perspectiveName}";
 
+function safeString(value) {
+  return value !== undefined ? value.toString() : null;
+}
+
 const leaves = window
   .content()
   .leaves()
   .map((l) => {
     const task = l.value();
-// NOTE: leaves are possibly not tasks
+    // NOTE: leaves are possibly not tasks
     try {
-      return {
+      const containingProject = task.containingProject();
+      const taskData = {
         id: task.id(),
         name: task.name(),
         flagged: task.flagged(),
-        deferDate: task.deferDate() ? task.deferDate().toString() : null,
-        dueDate: task.dueDate() ? task.dueDate().toString() : null,
+        deferDate: task.deferDate() ? safeString(task.deferDate()) : null,
+        dueDate: task.dueDate() ? safeString(task.dueDate()) : null,
         dropped: task.dropped(),
         completed: task.completed(),
         tags: task.tags ? task.tags().map((tt) => tt.name()) : [],
+        note: task.note ? safeString(task.note()) : null,
+        projectName: containingProject ? safeString(containingProject.name()) : null
       };
+      return taskData;
     } catch (e) {
+      systemEvents.log('Error processing task: ' + e);
path: extensions/omnifocus/src/lib/api/list-perspectives-tasks.ts
user: login: alanwill
body: Made it cleaner
created_at: 2025-03-11T17:46:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,36 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type Preferences = {
+  showDetailsByDefault: boolean;
+  defaultPerspective?: string;
+  defaultGrouping?: GroupBy;
+};
path: extensions/omnifocus/src/list-tasks.tsx
user: login: nicolas-marien
body: You can also remove this type, and use the `Preferences` namespace that Raycast will provide.

![CleanShot 2025-03-11 at 21 10 43@2x](https://github.com/user-attachments/assets/4d5d7525-9816-4883-b963-fba4f3512eb5)

![CleanShot 2025-03-11 at 21 11 54@2x](https://github.com/user-attachments/assets/1c089929-ba92-40af-9625-9b04f097d773)

It will use a string union instead of your `GroupBy` type, but they are identical.
created_at: 2025-03-11T20:12:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,36 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type Preferences = {
+  showDetailsByDefault: boolean;
+  defaultPerspective?: string;
+  defaultGrouping?: GroupBy;
+};
+
+type ViewMode = {
+  perspective: string;
+  groupBy: GroupBy;
+};
+
+const isValidGroupBy = (value: string | undefined): value is GroupBy => {
+  return value === "none" || value === "project" || value === "tags" || value === "priority";
+};
 
 export default function PerspectivesCommand() {
-  const [perspective, setPerspective] = useState<string>("Inbox");
+  const preferences = getPreferenceValues<Preferences>();
+
+  // Initialize view mode with preferences but maintain state during session
+  const [viewMode, setViewMode] = useState<ViewMode>({
+    perspective: preferences.defaultPerspective || "Inbox",
+    groupBy: isValidGroupBy(preferences.defaultGrouping) ? preferences.defaultGrouping : "none",
path: extensions/omnifocus/src/list-tasks.tsx
user: login: nicolas-marien
body: ![CleanShot 2025-03-11 at 21 22 55@2x](https://github.com/user-attachments/assets/14a19bfa-2a87-4959-be99-9855332598a5)

But Raycast will not let user choose something that is not part of the options you provided. 
The code is perfectly fine though!
created_at: 2025-03-11T20:24:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,28 @@
+import { executeScript } from "../utils/executeScript";
+
+export async function cleanupPerspective() {
+  console.log("Starting cleanup operation");
path: extensions/omnifocus/src/lib/api/cleanup.ts
user: login: nicolas-marien
body: Do we need this in production?
created_at: 2025-03-11T20:27:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,36 @@
-import { List } from "@raycast/api";
+import { List, getPreferenceValues } from "@raycast/api";
 import { usePromise } from "@raycast/utils";
 import { useCallback, useState } from "react";
 import { listPerspectives } from "./lib/api/list-perspectives";
 import { listPerspectiveTasks } from "./lib/api/list-perspectives-tasks";
 import { TaskList } from "./lib/components/task-list";
 import { ValidateRequirements } from "./lib/components/with-requirements";
+import { GroupBy } from "./lib/components/task-list";
+
+type Preferences = {
+  showDetailsByDefault: boolean;
+  defaultPerspective?: string;
+  defaultGrouping?: GroupBy;
+};
+
+type ViewMode = {
+  perspective: string;
+  groupBy: GroupBy;
+};
+
+const isValidGroupBy = (value: string | undefined): value is GroupBy => {
+  return value === "none" || value === "project" || value === "tags" || value === "priority";
+};
 
 export default function PerspectivesCommand() {
-  const [perspective, setPerspective] = useState<string>("Inbox");
+  const preferences = getPreferenceValues<Preferences>();
+
+  // Initialize view mode with preferences but maintain state during session
+  const [viewMode, setViewMode] = useState<ViewMode>({
+    perspective: preferences.defaultPerspective || "Inbox",
+    groupBy: isValidGroupBy(preferences.defaultGrouping) ? preferences.defaultGrouping : "none",
path: extensions/omnifocus/src/list-tasks.tsx
user: login: alanwill
body: Ah yes, sorry I misunderstood your first comment on this. Will fix.
created_at: 2025-03-11T21:54:21Z
author_association: CONTRIBUTOR

PR comments:
user: login: raycastbot
created_at: 2025-03-11T03:53:04Z
body: Thank you for your first  contribution! :tada:

🔔 @nicolas-marien you might want to have a look.

You can use [this guide](https://developers.raycast.com/basics/review-pullrequest) to learn how to check out the Pull Request locally in order to test it.

Due to our current reduced availability, the initial review may take up to 10-15 business days
author_association: COLLABORATOR
user: login: alanwill
created_at: 2025-03-11T22:04:14Z
body: Thanks for the all feedback @nicolas-marien, keep it coming :) 
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-14T12:49:25Z
body: <!-- add-pr-comment:add-pr-comment -->

**Published to the Raycast Store:**
https://raycast.com/nicolas-marien/omnifocus
author_association: CONTRIBUTOR
user: login: raycastbot
created_at: 2025-03-14T12:49:28Z
body: :tada: :tada: :tada:

We've rewarded your Raycast account with some credits. You will soon be able to exchange them for some swag.
author_association: COLLABORATOR