Skip to content

Commit 269e0ea

Browse files
committed
plugin: add support for default project in settings
1 parent 38e5217 commit 269e0ea

File tree

8 files changed

+200
-14
lines changed

8 files changed

+200
-14
lines changed

plugin/CLAUDE.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,26 @@ This is the main plugin source code for an unofficial Obsidian plugin that enabl
1111
All commands should be run from this `plugin/` directory:
1212

1313
### Development
14+
1415
- `npm run dev` - Build plugin in development mode with type checking
1516
- `npm run build` - Build plugin for production
1617
- `npm run check` - Run TypeScript type checking only
1718

1819
### Testing and Quality
20+
1921
- `npm run test` - Run all tests with Vitest
2022
- `npm run test ./src/utils` - Run tests for specific directory/file
2123
- `npm run lint:check` - Check code formatting and linting with BiomeJS
2224
- `npm run lint:fix` - Auto-fix formatting and linting issues
2325

2426
### Other
27+
2528
- `npm run gen` - Generate language status file
2629

2730
## Architecture Overview
2831

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

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

4348
### UI Architecture
49+
4450
- Built with React 19 and React Aria Components
4551
- Uses Framer Motion for animations
4652
- SCSS with component-scoped styles
4753
- Supports both light and dark themes matching Obsidian
4854

4955
### Query Language
56+
5057
The plugin supports a custom query language in `todoist` code blocks with options for:
58+
5159
- Filtering tasks by project, labels, due dates
5260
- Sorting by priority, date, order
5361
- Grouping by project, section, priority, date, labels
@@ -56,18 +64,42 @@ The plugin supports a custom query language in `todoist` code blocks with option
5664
## Development Environment
5765

5866
### Local Development
67+
5968
Set `VITE_OBSIDIAN_VAULT` in `.env.local` to automatically copy build output to your Obsidian vault for testing:
69+
6070
```
6171
export VITE_OBSIDIAN_VAULT=/path/to/your/obsidian/vault
6272
```
6373

6474
### Code Style
75+
6576
- Uses BiomeJS for formatting and linting
6677
- 2-space indentation, 100 character line width
6778
- Automatic import organization with package/alias/path grouping
6879
- React functional components with hooks
6980

81+
### Internationalization
82+
83+
- **Always use translations for user-facing text** - never hardcode strings in UI components
84+
- Import translations with `import { t } from "@/i18n"` and use `const i18n = t().section`
85+
- For simple text: define as `string` in translation interface and return string value
86+
- For text with interpolation: define as `(param: Type) => string` function in translation interface
87+
- Example with interpolation:
88+
89+
```typescript
90+
// translation.ts
91+
deleteNotice: (itemName: string) => string;
92+
93+
// en.ts
94+
deleteNotice: (itemName: string) => `Item "${itemName}" was deleted`,
95+
// component.tsx
96+
new Notice(i18n.deleteNotice(item.name));
97+
```
98+
99+
- Translation files are in `src/i18n/` with interface in `translation.ts` and implementations in `langs/`
100+
70101
### Testing
102+
71103
- Vitest with jsdom environment for React component testing
72104
- Mocked Obsidian API (`src/mocks/obsidian.ts`)
73105
- Tests focus on data transformations and utility functions
@@ -86,9 +118,10 @@ export VITE_OBSIDIAN_VAULT=/path/to/your/obsidian/vault
86118
## Build Process
87119

88120
Uses Vite with:
121+
89122
- TypeScript compilation with path aliases
90123
- React JSX transformation
91124
- SCSS processing
92125
- Library mode targeting CommonJS for Obsidian compatibility
93126
- Manifest copying and build stamping
94-
- Development mode auto-copying to vault
127+
- Development mode auto-copying to vault

plugin/src/i18n/langs/en.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ export const en: Translations = {
7272
tomorrow: "Tomorrow",
7373
},
7474
},
75+
defaultProject: {
76+
label: "Default project",
77+
description: "The default project to set when creating new tasks",
78+
placeholder: "Select a project",
79+
noDefault: "Inbox",
80+
deletedWarning: "This project no longer exists",
81+
deleted: "deleted",
82+
},
7583
},
7684
advanced: {
7785
header: "Advanced",
@@ -100,6 +108,8 @@ export const en: Translations = {
100108
cancelButtonLabel: "Cancel",
101109
addTaskButtonLabel: "Add task",
102110
failedToFindInboxNotice: "Error: could not find inbox project",
111+
defaultProjectDeletedNotice: (projectName: string) =>
112+
`Default project "${projectName}" no longer exists. Using Inbox instead.`,
103113
dateSelector: {
104114
buttonLabel: "Set due date",
105115
dialogLabel: "Due date selector",

plugin/src/i18n/translation.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ export type Translations = {
6868
tomorrow: string;
6969
};
7070
};
71+
defaultProject: {
72+
label: string;
73+
description: string;
74+
placeholder: string;
75+
noDefault: string;
76+
deletedWarning: string;
77+
deleted: string;
78+
};
7179
};
7280
advanced: {
7381
header: string;
@@ -95,6 +103,7 @@ export type Translations = {
95103
cancelButtonLabel: string;
96104
addTaskButtonLabel: string;
97105
failedToFindInboxNotice: string;
106+
defaultProjectDeletedNotice: (projectName: string) => string;
98107
dateSelector: {
99108
buttonLabel: string;
100109
dialogLabel: string;

plugin/src/settings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ export type AddPageLinkSetting = "off" | "description" | "content";
44

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

7+
export type ProjectDefaultSetting = {
8+
projectId: string;
9+
projectName: string;
10+
} | null;
11+
712
const defaultSettings: Settings = {
813
fadeToggle: true,
914

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

2025
taskCreationDefaultDueDate: "none",
2126

27+
taskCreationDefaultProject: null,
28+
2229
debugLogging: false,
2330
};
2431

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

3946
taskCreationDefaultDueDate: DueDateDefaultSetting;
4047

48+
taskCreationDefaultProject: ProjectDefaultSetting;
49+
4150
debugLogging: boolean;
4251
};
4352

plugin/src/ui/createTaskModal/index.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { Button } from "react-aria-components";
66

77
import { t } from "@/i18n";
88
import { timezone, today } from "@/infra/time";
9-
import { type DueDateDefaultSetting, useSettingsStore } from "@/settings";
9+
import {
10+
type DueDateDefaultSetting,
11+
type ProjectDefaultSetting,
12+
useSettingsStore,
13+
} from "@/settings";
1014
import { ModalContext, PluginContext } from "@/ui/context";
1115

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

72+
const calculateDefaultProject = (
73+
plugin: TodoistPlugin,
74+
projectSetting: ProjectDefaultSetting,
75+
): ProjectIdentifier => {
76+
if (projectSetting === null) {
77+
return getInboxProject(plugin);
78+
}
79+
80+
const project = plugin.services.todoist.data().projects.byId(projectSetting.projectId);
81+
if (project === undefined) {
82+
const noticeMsg = t().createTaskModal.defaultProjectDeletedNotice(projectSetting.projectName);
83+
new Notice(noticeMsg);
84+
return getInboxProject(plugin);
85+
}
86+
87+
return {
88+
projectId: projectSetting.projectId,
89+
};
90+
};
91+
6892
export const CreateTaskModal: React.FC<CreateTaskProps> = (props) => {
6993
const plugin = PluginContext.use();
7094

@@ -109,7 +133,9 @@ const CreateTaskModalContent: React.FC<CreateTaskProps> = ({
109133
);
110134
const [priority, setPriority] = useState<Priority>(1);
111135
const [labels, setLabels] = useState<Label[]>([]);
112-
const [project, setProject] = useState<ProjectIdentifier>(getDefaultProject(plugin));
136+
const [project, setProject] = useState<ProjectIdentifier>(
137+
calculateDefaultProject(plugin, settings.taskCreationDefaultProject),
138+
);
113139

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

@@ -225,7 +251,7 @@ const CreateTaskModalContent: React.FC<CreateTaskProps> = ({
225251
);
226252
};
227253

228-
const getDefaultProject = (plugin: TodoistPlugin): ProjectIdentifier => {
254+
const getInboxProject = (plugin: TodoistPlugin): ProjectIdentifier => {
229255
const { todoist } = plugin.services;
230256
const projects = Array.from(todoist.data().projects.iter());
231257

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type React from "react";
2+
import { useMemo, useState } from "react";
3+
4+
import { t } from "@/i18n";
5+
import type { ProjectDefaultSetting } from "@/settings";
6+
import { ObsidianIcon } from "@/ui/components/obsidian-icon";
7+
import { PluginContext } from "@/ui/context";
8+
9+
type Props = {
10+
value: ProjectDefaultSetting;
11+
onChange: (val: ProjectDefaultSetting) => Promise<void>;
12+
};
13+
14+
export const ProjectDropdownControl: React.FC<Props> = ({ value, onChange }) => {
15+
const [selected, setSelected] = useState(value);
16+
const plugin = PluginContext.use();
17+
const todoist = plugin.services.todoist;
18+
const i18n = t().settings.taskCreation.defaultProject;
19+
20+
const projects = useMemo(() => {
21+
if (!todoist.isReady()) {
22+
return [];
23+
}
24+
25+
const allProjects = Array.from(todoist.data().projects.iter());
26+
return allProjects
27+
.filter((project) => !project.inboxProject)
28+
.sort((a, b) => a.name.localeCompare(b.name));
29+
}, [todoist]);
30+
31+
const selectedProject =
32+
selected !== null ? projects.find((p) => p.id === selected.projectId) : null;
33+
const isProjectDeleted = selected !== null && !selectedProject;
34+
35+
const handleChange = async (ev: React.ChangeEvent<HTMLSelectElement>) => {
36+
const selectedValue = ev.target.value;
37+
38+
let newValue: ProjectDefaultSetting;
39+
if (selectedValue === "") {
40+
newValue = null;
41+
} else {
42+
const project = projects.find((p) => p.id === selectedValue);
43+
if (project === undefined) {
44+
return;
45+
}
46+
47+
newValue = {
48+
projectId: project.id,
49+
projectName: project.name,
50+
};
51+
}
52+
53+
setSelected(newValue);
54+
await onChange(newValue);
55+
};
56+
57+
return (
58+
<div className="project-dropdown-container">
59+
{isProjectDeleted && (
60+
<div className="project-dropdown-warning-icon" title={i18n.deletedWarning}>
61+
<ObsidianIcon size="s" id="lucide-alert-triangle" />
62+
</div>
63+
)}
64+
<select className="dropdown" value={selected?.projectId ?? ""} onChange={handleChange}>
65+
<option value="">{i18n.noDefault}</option>
66+
{projects.map((project) => (
67+
<option key={project.id} value={project.id}>
68+
{project.name}
69+
</option>
70+
))}
71+
{isProjectDeleted && selected && (
72+
<option value={selected.projectId} disabled>
73+
{selected.projectName} ({i18n.deleted})
74+
</option>
75+
)}
76+
</select>
77+
</div>
78+
);
79+
};

plugin/src/ui/settings/index.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type TodoistPlugin from "../..";
99
import { type Settings, useSettingsStore } from "../../settings";
1010
import { TokenValidation } from "../../token";
1111
import { AutoRefreshIntervalControl } from "./AutoRefreshIntervalControl";
12+
import { ProjectDropdownControl } from "./ProjectDropdownControl";
1213
import { Setting } from "./SettingItem";
1314
import { TokenChecker } from "./TokenChecker";
1415
import "./styles.scss";
@@ -46,13 +47,16 @@ type SettingsKeys<V> = {
4647
const SettingsRoot: React.FC<Props> = ({ plugin }) => {
4748
const settings = useSettingsStore();
4849

49-
const toggleProps = (key: SettingsKeys<boolean>) => {
50-
const onClick = async (val: boolean) => {
50+
const mkOptionUpdate = <K extends keyof Settings>(key: K) => {
51+
return async (val: Settings[K]) => {
5152
await plugin.writeOptions({
5253
[key]: val,
5354
});
5455
};
56+
};
5557

58+
const toggleProps = (key: SettingsKeys<boolean>) => {
59+
const onClick = mkOptionUpdate(key);
5660
const value = settings[key];
5761

5862
return {
@@ -61,12 +65,6 @@ const SettingsRoot: React.FC<Props> = ({ plugin }) => {
6165
};
6266
};
6367

64-
const updateAutoRefreshInterval = async (val: number) => {
65-
await plugin.writeOptions({
66-
autoRefreshInterval: val,
67-
});
68-
};
69-
7068
const i18n = t().settings;
7169

7270
return (
@@ -119,7 +117,7 @@ const SettingsRoot: React.FC<Props> = ({ plugin }) => {
119117
>
120118
<AutoRefreshIntervalControl
121119
initialValue={settings.autoRefreshInterval}
122-
onChange={updateAutoRefreshInterval}
120+
onChange={mkOptionUpdate("autoRefreshInterval")}
123121
/>
124122
</Setting.Root>
125123

@@ -211,6 +209,15 @@ const SettingsRoot: React.FC<Props> = ({ plugin }) => {
211209
}}
212210
/>
213211
</Setting.Root>
212+
<Setting.Root
213+
name={i18n.taskCreation.defaultProject.label}
214+
description={i18n.taskCreation.defaultProject.description}
215+
>
216+
<ProjectDropdownControl
217+
value={settings.taskCreationDefaultProject}
218+
onChange={mkOptionUpdate("taskCreationDefaultProject")}
219+
/>
220+
</Setting.Root>
214221

215222
<h2>{i18n.advanced.header}</h2>
216223
<Setting.Root

0 commit comments

Comments
 (0)