Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### ✨ Features

- You can now configure a default due date for new tasks in the plugin settings. Options include no default, today, or tomorrow.
- You can now configure default values for due date & project when creating tasks from inside Obsidian.
- For due date, you can select none, today, or tomorrow.
- For project, you can select any project or the Inbox.

### ⚙ Internal

Expand Down
10 changes: 10 additions & 0 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ When enabled, page links added to tasks created via the [command](./commands/add

When enabled, the embedded add task button in queries will add a link to the page to the task in the specified place. This behaviour can also be disabled completely.

### Default due date

This defines the default due date assigned to tasks created via [commands](./commands/add-task). This can be one of: none, today, or tomorrow.

### Default project

This defines the default project assigned to tasks created via [commands](./commands/add-task). This can be configured to any of your projects, or the Inbox.

If the project referenced here no longer exists, you will get a warning when opening the task creation modal and the Inbox will be used instead.

## Advanced

### Debug logging
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/translation-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
{
"name": "English",
"code": "en",
"completed": 170,
"completed": 178,
"missing": 0,
"percent": 100
},
{
"name": "Nederlands",
"code": "nl",
"completed": 148,
"missing": 22,
"percent": 87
"missing": 30,
"percent": 83
}
]
35 changes: 34 additions & 1 deletion plugin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,26 @@ This is the main plugin source code for an unofficial Obsidian plugin that enabl
All commands should be run from this `plugin/` directory:

### Development

- `npm run dev` - Build plugin in development mode with type checking
- `npm run build` - Build plugin for production
- `npm run check` - Run TypeScript type checking only

### Testing and Quality

- `npm run test` - Run all tests with Vitest
- `npm run test ./src/utils` - Run tests for specific directory/file
- `npm run lint:check` - Check code formatting and linting with BiomeJS
- `npm run lint:fix` - Auto-fix formatting and linting issues

### Other

- `npm run gen` - Generate language status file

## Architecture Overview

### Plugin Structure

- **Main Plugin** (`src/index.ts`): Core plugin class that initializes services, registers commands, and handles Obsidian lifecycle
- **API Layer** (`src/api/`): Todoist REST API client with domain models for tasks, projects, sections, and labels
- **Query System** (`src/query/`): Markdown code block processor that renders Todoist queries in notes
Expand All @@ -35,19 +39,23 @@ All commands should be run from this `plugin/` directory:
- **Data Layer** (`src/data/`): Repository pattern for caching and managing Todoist data with transformations

### Key Components

- **Query Injector** (`src/query/injector.tsx`): Processes `todoist` code blocks and renders interactive task lists
- **Repository Pattern** (`src/data/repository.ts`): Generic caching layer for API data with sync capabilities
- **Settings Store** (`src/settings.ts`): Zustand-based state management for plugin configuration
- **Token Accessor** (`src/services/tokenAccessor.ts`): Secure storage and retrieval of Todoist API tokens

### UI Architecture

- Built with React 19 and React Aria Components
- Uses Framer Motion for animations
- SCSS with component-scoped styles
- Supports both light and dark themes matching Obsidian

### Query Language

The plugin supports a custom query language in `todoist` code blocks with options for:

- Filtering tasks by project, labels, due dates
- Sorting by priority, date, order
- Grouping by project, section, priority, date, labels
Expand All @@ -56,18 +64,42 @@ The plugin supports a custom query language in `todoist` code blocks with option
## Development Environment

### Local Development

Set `VITE_OBSIDIAN_VAULT` in `.env.local` to automatically copy build output to your Obsidian vault for testing:

```
export VITE_OBSIDIAN_VAULT=/path/to/your/obsidian/vault
```

### Code Style

- Uses BiomeJS for formatting and linting
- 2-space indentation, 100 character line width
- Automatic import organization with package/alias/path grouping
- React functional components with hooks

### Internationalization

- **Always use translations for user-facing text** - never hardcode strings in UI components
- Import translations with `import { t } from "@/i18n"` and use `const i18n = t().section`
- For simple text: define as `string` in translation interface and return string value
- For text with interpolation: define as `(param: Type) => string` function in translation interface
- Example with interpolation:

```typescript
// translation.ts
deleteNotice: (itemName: string) => string;

// en.ts
deleteNotice: (itemName: string) => `Item "${itemName}" was deleted`,
// component.tsx
new Notice(i18n.deleteNotice(item.name));
```

- Translation files are in `src/i18n/` with interface in `translation.ts` and implementations in `langs/`

### Testing

- Vitest with jsdom environment for React component testing
- Mocked Obsidian API (`src/mocks/obsidian.ts`)
- Tests focus on data transformations and utility functions
Expand All @@ -86,9 +118,10 @@ export VITE_OBSIDIAN_VAULT=/path/to/your/obsidian/vault
## Build Process

Uses Vite with:

- TypeScript compilation with path aliases
- React JSX transformation
- SCSS processing
- Library mode targeting CommonJS for Obsidian compatibility
- Manifest copying and build stamping
- Development mode auto-copying to vault
- Development mode auto-copying to vault
10 changes: 10 additions & 0 deletions plugin/src/i18n/langs/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export const en: Translations = {
tomorrow: "Tomorrow",
},
},
defaultProject: {
label: "Default project",
description: "The default project to set when creating new tasks",
placeholder: "Select a project",
noDefault: "Inbox",
deletedWarning: "This project no longer exists",
deleted: "deleted",
},
},
advanced: {
header: "Advanced",
Expand Down Expand Up @@ -100,6 +108,8 @@ export const en: Translations = {
cancelButtonLabel: "Cancel",
addTaskButtonLabel: "Add task",
failedToFindInboxNotice: "Error: could not find inbox project",
defaultProjectDeletedNotice: (projectName: string) =>
`Default project "${projectName}" no longer exists. Using Inbox instead.`,
dateSelector: {
buttonLabel: "Set due date",
dialogLabel: "Due date selector",
Expand Down
9 changes: 9 additions & 0 deletions plugin/src/i18n/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ export type Translations = {
tomorrow: string;
};
};
defaultProject: {
label: string;
description: string;
placeholder: string;
noDefault: string;
deletedWarning: string;
deleted: string;
};
};
advanced: {
header: string;
Expand Down Expand Up @@ -95,6 +103,7 @@ export type Translations = {
cancelButtonLabel: string;
addTaskButtonLabel: string;
failedToFindInboxNotice: string;
defaultProjectDeletedNotice: (projectName: string) => string;
dateSelector: {
buttonLabel: string;
dialogLabel: string;
Expand Down
9 changes: 9 additions & 0 deletions plugin/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ export type AddPageLinkSetting = "off" | "description" | "content";

export type DueDateDefaultSetting = "none" | "today" | "tomorrow";

export type ProjectDefaultSetting = {
projectId: string;
projectName: string;
} | null;

const defaultSettings: Settings = {
fadeToggle: true,

Expand All @@ -19,6 +24,8 @@ const defaultSettings: Settings = {

taskCreationDefaultDueDate: "none",

taskCreationDefaultProject: null,

debugLogging: false,
};

Expand All @@ -38,6 +45,8 @@ export type Settings = {

taskCreationDefaultDueDate: DueDateDefaultSetting;

taskCreationDefaultProject: ProjectDefaultSetting;

debugLogging: boolean;
};

Expand Down
32 changes: 29 additions & 3 deletions plugin/src/ui/createTaskModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { Button } from "react-aria-components";

import { t } from "@/i18n";
import { timezone, today } from "@/infra/time";
import { type DueDateDefaultSetting, useSettingsStore } from "@/settings";
import {
type DueDateDefaultSetting,
type ProjectDefaultSetting,
useSettingsStore,
} from "@/settings";
import { ModalContext, PluginContext } from "@/ui/context";

import type TodoistPlugin from "../..";
Expand Down Expand Up @@ -65,6 +69,26 @@ const calculateDefaultDueDate = (setting: DueDateDefaultSetting): DueDate | unde
}
};

const calculateDefaultProject = (
plugin: TodoistPlugin,
projectSetting: ProjectDefaultSetting,
): ProjectIdentifier => {
if (projectSetting === null) {
return getInboxProject(plugin);
}

const project = plugin.services.todoist.data().projects.byId(projectSetting.projectId);
if (project === undefined) {
const noticeMsg = t().createTaskModal.defaultProjectDeletedNotice(projectSetting.projectName);
new Notice(noticeMsg);
return getInboxProject(plugin);
}

return {
projectId: projectSetting.projectId,
};
};

export const CreateTaskModal: React.FC<CreateTaskProps> = (props) => {
const plugin = PluginContext.use();

Expand Down Expand Up @@ -109,7 +133,9 @@ const CreateTaskModalContent: React.FC<CreateTaskProps> = ({
);
const [priority, setPriority] = useState<Priority>(1);
const [labels, setLabels] = useState<Label[]>([]);
const [project, setProject] = useState<ProjectIdentifier>(getDefaultProject(plugin));
const [project, setProject] = useState<ProjectIdentifier>(
calculateDefaultProject(plugin, settings.taskCreationDefaultProject),
);

const [options, setOptions] = useState<TaskCreationOptions>(initialOptions);

Expand Down Expand Up @@ -225,7 +251,7 @@ const CreateTaskModalContent: React.FC<CreateTaskProps> = ({
);
};

const getDefaultProject = (plugin: TodoistPlugin): ProjectIdentifier => {
const getInboxProject = (plugin: TodoistPlugin): ProjectIdentifier => {
const { todoist } = plugin.services;
const projects = Array.from(todoist.data().projects.iter());

Expand Down
79 changes: 79 additions & 0 deletions plugin/src/ui/settings/ProjectDropdownControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type React from "react";
import { useMemo, useState } from "react";

import { t } from "@/i18n";
import type { ProjectDefaultSetting } from "@/settings";
import { ObsidianIcon } from "@/ui/components/obsidian-icon";
import { PluginContext } from "@/ui/context";

type Props = {
value: ProjectDefaultSetting;
onChange: (val: ProjectDefaultSetting) => Promise<void>;
};

export const ProjectDropdownControl: React.FC<Props> = ({ value, onChange }) => {
const [selected, setSelected] = useState(value);
const plugin = PluginContext.use();
const todoist = plugin.services.todoist;
const i18n = t().settings.taskCreation.defaultProject;

const projects = useMemo(() => {
if (!todoist.isReady()) {
return [];
}

const allProjects = Array.from(todoist.data().projects.iter());
return allProjects
.filter((project) => !project.inboxProject)
.sort((a, b) => a.name.localeCompare(b.name));
}, [todoist]);

const selectedProject =
selected !== null ? projects.find((p) => p.id === selected.projectId) : null;
const isProjectDeleted = selected !== null && !selectedProject;

const handleChange = async (ev: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValue = ev.target.value;

let newValue: ProjectDefaultSetting;
if (selectedValue === "") {
newValue = null;
} else {
const project = projects.find((p) => p.id === selectedValue);
if (project === undefined) {
return;
}

newValue = {
projectId: project.id,
projectName: project.name,
};
}

setSelected(newValue);
await onChange(newValue);
};

return (
<div className="project-dropdown-container">
{isProjectDeleted && (
<div className="project-dropdown-warning-icon" title={i18n.deletedWarning}>
<ObsidianIcon size="s" id="lucide-alert-triangle" />
</div>
)}
<select className="dropdown" value={selected?.projectId ?? ""} onChange={handleChange}>
<option value="">{i18n.noDefault}</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
{isProjectDeleted && selected && (
<option value={selected.projectId} disabled>
{selected.projectName} ({i18n.deleted})
</option>
)}
</select>
</div>
);
};
Loading