feat(settings): add modal settings interface with vertical tabs

Introduce a new SettingsModal component that provides a full-featured
settings interface using Obsidian's vertical tabs layout pattern.
The modal includes categorized navigation, deep search functionality,
and mobile-responsive design with back navigation.

Key changes:
- Add SettingsModal with searchable settings, category grouping, and
  smooth transitions between tabs
- Refactor ProjectSettingsTab with comprehensive documentation and
  clearer section organization
- Support boolean metadata values for project detection (uses filename
  as project name when `project: true`)
- Integrate settings modal into FluentTopNavigation
- Add corresponding SCSS styles for the modal layout
This commit is contained in:
Quorafind 2025-12-14 21:57:55 +08:00
parent d03f938aa6
commit c3165c57a9
14 changed files with 3428 additions and 1699 deletions

View file

@ -21,7 +21,7 @@ class MockTFile {
constructor(
public path: string,
public name: string,
public parent: MockTFolder | null = null
public parent: MockTFolder | null = null,
) {
this.stat = { mtime: Date.now() };
}
@ -33,7 +33,7 @@ class MockTFolder {
public path: string,
public name: string,
public parent: MockTFolder | null = null,
public children: (MockTFile | MockTFolder)[] = []
public children: (MockTFile | MockTFolder)[] = [],
) {}
}
@ -58,6 +58,10 @@ class MockVault {
return this.files.get(path) || null;
}
getFileByPath(path: string): MockTFile | null {
return this.files.get(path) || null;
}
async read(file: MockTFile): Promise<string> {
return this.fileContents.get(file.path) || "";
}
@ -129,7 +133,7 @@ describe("ProjectConfigManager", () => {
manager.updateOptions({ pathMappings });
const workProject = await manager.determineTgProject(
"Projects/Work/task.md"
"Projects/Work/task.md",
);
expect(workProject).toEqual({
type: "path",
@ -138,9 +142,8 @@ describe("ProjectConfigManager", () => {
readonly: true,
});
const personalProject = await manager.determineTgProject(
"Personal/notes.md"
);
const personalProject =
await manager.determineTgProject("Personal/notes.md");
expect(personalProject).toEqual({
type: "path",
name: "Personal Project",
@ -161,7 +164,7 @@ describe("ProjectConfigManager", () => {
manager.updateOptions({ pathMappings });
const project = await manager.determineTgProject(
"Projects/Work/task.md"
"Projects/Work/task.md",
);
expect(project).toBeUndefined();
});
@ -178,7 +181,7 @@ describe("ProjectConfigManager", () => {
manager.updateOptions({ pathMappings });
const project = await manager.determineTgProject(
"Projects/SomeProject/task.md"
"Projects/SomeProject/task.md",
);
expect(project).toEqual({
type: "path",
@ -223,6 +226,41 @@ describe("ProjectConfigManager", () => {
const project = await manager.determineTgProject("nonexistent.md");
expect(project).toBeUndefined();
});
it("should use filename as project name when project: true (boolean)", async () => {
vault.addFile("Projects/MyAwesomeProject.md", "# My Project");
metadataCache.setFileMetadata("Projects/MyAwesomeProject.md", {
project: true,
});
const project = await manager.determineTgProject(
"Projects/MyAwesomeProject.md",
);
expect(project).toEqual({
type: "metadata",
name: "MyAwesomeProject",
source: "project (filename)",
readonly: true,
});
});
it("should use filename when custom metadata key has boolean true", async () => {
manager.updateOptions({ metadataKey: "isProject" });
vault.addFile("Tasks/ImportantTask.md", "# Task");
metadataCache.setFileMetadata("Tasks/ImportantTask.md", {
isProject: true,
});
const project = await manager.determineTgProject(
"Tasks/ImportantTask.md",
);
expect(project).toEqual({
type: "metadata",
name: "ImportantTask",
source: "isProject (filename)",
readonly: true,
});
});
});
describe("Config file-based project detection", () => {
@ -235,14 +273,14 @@ project: Config Project
---
# Project Configuration
`
`,
);
// Mock the folder structure
const file = vault.addFile("Projects/task.md", "- [ ] Test task");
const folder = vault.addFolder("Projects");
const configFile = vault.getAbstractFileByPath(
"Projects/project.md"
"Projects/project.md",
);
if (configFile) {
folder.children.push(configFile);
@ -254,9 +292,8 @@ project: Config Project
project: "Config Project",
});
const project = await manager.determineTgProject(
"Projects/task.md"
);
const project =
await manager.determineTgProject("Projects/task.md");
expect(project).toEqual({
type: "config",
name: "Config Project",
@ -278,16 +315,15 @@ description: A project defined in content
const file = vault.addFile("Projects/task.md", "- [ ] Test task");
const folder = vault.addFolder("Projects");
const configFile = vault.getAbstractFileByPath(
"Projects/project.md"
"Projects/project.md",
);
if (configFile) {
folder.children.push(configFile);
file.parent = folder;
}
const project = await manager.determineTgProject(
"Projects/task.md"
);
const project =
await manager.determineTgProject("Projects/task.md");
expect(project).toEqual({
type: "config",
name: "Content Project",
@ -321,9 +357,8 @@ description: A project defined in content
other: "value",
});
const enhancedMetadata = await manager.getEnhancedMetadata(
"test.md"
);
const enhancedMetadata =
await manager.getEnhancedMetadata("test.md");
expect(enhancedMetadata).toEqual({
proj: "Mapped Project",
due_date: "2024-01-01",
@ -349,9 +384,8 @@ description: A project defined in content
proj: "Should Not Map",
});
const enhancedMetadata = await manager.getEnhancedMetadata(
"test.md"
);
const enhancedMetadata =
await manager.getEnhancedMetadata("test.md");
expect(enhancedMetadata).toEqual({
proj: "Should Not Map",
});
@ -370,7 +404,7 @@ description: A project defined in content
manager.updateOptions({ defaultProjectNaming });
const project = await manager.determineTgProject(
"Projects/my-document.md"
"Projects/my-document.md",
);
expect(project).toEqual({
type: "default",
@ -390,7 +424,7 @@ description: A project defined in content
manager.updateOptions({ defaultProjectNaming });
const project = await manager.determineTgProject(
"Projects/my-document.md"
"Projects/my-document.md",
);
expect(project).toEqual({
type: "default",
@ -409,7 +443,7 @@ description: A project defined in content
manager.updateOptions({ defaultProjectNaming });
const project = await manager.determineTgProject(
"Projects/WorkFolder/task.md"
"Projects/WorkFolder/task.md",
);
expect(project).toEqual({
type: "default",
@ -433,9 +467,8 @@ description: A project defined in content
manager.updateOptions({ defaultProjectNaming });
const project = await manager.determineTgProject(
"anywhere/task.md"
);
const project =
await manager.determineTgProject("anywhere/task.md");
expect(project).toEqual({
type: "default",
name: "Global Project",
@ -454,7 +487,7 @@ description: A project defined in content
manager.updateOptions({ defaultProjectNaming });
const project = await manager.determineTgProject(
"Projects/my-document.md"
"Projects/my-document.md",
);
expect(project).toBeUndefined();
});
@ -477,9 +510,8 @@ description: A project defined in content
project: "Metadata Project",
});
const project = await manager.determineTgProject(
"Projects/task.md"
);
const project =
await manager.determineTgProject("Projects/task.md");
expect(project).toEqual({
type: "path",
name: "Path Project",
@ -499,16 +531,15 @@ description: A project defined in content
const file = vault.getAbstractFileByPath("Projects/task.md");
const folder = vault.addFolder("Projects");
const configFile = vault.getAbstractFileByPath(
"Projects/project.md"
"Projects/project.md",
);
if (file && configFile) {
folder.children.push(configFile);
file.parent = folder;
}
const project = await manager.determineTgProject(
"Projects/task.md"
);
const project =
await manager.determineTgProject("Projects/task.md");
expect(project).toEqual({
type: "metadata",
name: "Metadata Project",
@ -533,16 +564,15 @@ description: A project defined in content
const file = vault.getAbstractFileByPath("Projects/task.md");
const folder = vault.addFolder("Projects");
const configFile = vault.getAbstractFileByPath(
"Projects/project.md"
"Projects/project.md",
);
if (file && configFile) {
folder.children.push(configFile);
file.parent = folder;
}
const project = await manager.determineTgProject(
"Projects/task.md"
);
const project =
await manager.determineTgProject("Projects/task.md");
expect(project).toEqual({
type: "config",
name: "Config Project",
@ -561,7 +591,7 @@ description: A project defined in content
const file = vault.getAbstractFileByPath("Projects/task.md");
const folder = vault.addFolder("Projects");
const configFile = vault.getAbstractFileByPath(
"Projects/project.md"
"Projects/project.md",
);
if (file && configFile) {
folder.children.push(configFile);
@ -569,14 +599,12 @@ description: A project defined in content
}
// First call should read and cache
const project1 = await manager.determineTgProject(
"Projects/task.md"
);
const project1 =
await manager.determineTgProject("Projects/task.md");
// Second call should use cache
const project2 = await manager.determineTgProject(
"Projects/task.md"
);
const project2 =
await manager.determineTgProject("Projects/task.md");
expect(project1).toEqual(project2);
expect(project1?.name).toBe("Cached Project");
@ -621,7 +649,7 @@ description: A project defined in content
it("should handle malformed config files gracefully", async () => {
vault.addFile(
"Projects/project.md",
"Invalid content without proper format"
"Invalid content without proper format",
);
vault.addFile("Projects/task.md", "# Test file");
@ -629,16 +657,15 @@ description: A project defined in content
const file = vault.getAbstractFileByPath("Projects/task.md");
const folder = vault.addFolder("Projects");
const configFile = vault.getAbstractFileByPath(
"Projects/project.md"
"Projects/project.md",
);
if (file && configFile) {
folder.children.push(configFile);
file.parent = folder;
}
const project = await manager.determineTgProject(
"Projects/task.md"
);
const project =
await manager.determineTgProject("Projects/task.md");
expect(project).toBeUndefined();
});
});
@ -659,14 +686,14 @@ description: A project defined in content
// All methods should return null/empty when disabled
expect(
await disabledManager.getProjectConfig("test.md")
await disabledManager.getProjectConfig("test.md"),
).toBeNull();
expect(disabledManager.getFileMetadata("test.md")).toBeNull();
expect(
await disabledManager.determineTgProject("test.md")
await disabledManager.determineTgProject("test.md"),
).toBeUndefined();
expect(
await disabledManager.getEnhancedMetadata("test.md")
await disabledManager.getEnhancedMetadata("test.md"),
).toEqual({});
expect(disabledManager.isEnhancedProjectEnabled()).toBe(false);
});
@ -727,12 +754,12 @@ description: A project defined in content
// All metadata-related methods should return null/empty when disabled
expect(disabledManager.getFileMetadata("test.md")).toBeNull();
expect(
await disabledManager.getEnhancedMetadata("test.md")
await disabledManager.getEnhancedMetadata("test.md"),
).toEqual({});
// Even if frontmatter exists, it should not be accessible through disabled manager
expect(
await disabledManager.determineTgProject("test.md")
await disabledManager.determineTgProject("test.md"),
).toBeUndefined();
});
});

View file

@ -12,6 +12,7 @@ import TaskProgressBarPlugin from "@/index";
import { Task } from "@/types/task";
import { t } from "@/translations/helper";
import { Events, on } from "@/dataflow/events/Events";
import { SettingsModal } from "@/components/features/settings/SettingsModal";
export type ViewMode = "list" | "kanban" | "tree" | "calendar";
@ -250,9 +251,10 @@ export class TopNavigation extends Component {
cls: "fluent-nav-icon-button",
});
setIcon(settingsBtn, "settings");
this.registerDomEvent(settingsBtn, "click", () =>
this.onSettingsClick(),
);
this.registerDomEvent(settingsBtn, "click", () => {
const modal = new SettingsModal(this.plugin.app, this.plugin);
modal.open();
});
}
private createViewTab(

File diff suppressed because it is too large Load diff

View file

@ -18,3 +18,4 @@ export { renderIndexSettingsTab } from "./tabs/IndexSettingsTab";
export { createFileSourceSettings } from "./components/FileSourceSettingsSection";
export { renderDesktopIntegrationSettingsTab } from "./tabs/DesktopIntegrationSettingsTab";
export { renderCalendarViewSettingsTab } from "./tabs/CalendarViewSettingTab";
export { SettingsModal } from "./SettingsModal";

File diff suppressed because it is too large Load diff

View file

@ -80,7 +80,7 @@ export class Augmentor {
this.dateInheritanceAugmentor = new DateInheritanceAugmentor(
options.app,
options.vault,
options.metadataCache
options.metadataCache,
);
}
}
@ -95,7 +95,7 @@ export class Augmentor {
inheritFromFrontmatter?: boolean;
};
projectConfig?: ProjectConfiguration;
}>
}>,
): void {
const f = settings.fileMetadataInheritance;
if (f) {
@ -114,7 +114,7 @@ export class Augmentor {
async merge(ctx: AugmentContext): Promise<Task[]> {
// First apply standard augmentation
let augmentedTasks = ctx.tasks.map((task) =>
this.augmentTask(task, ctx)
this.augmentTask(task, ctx),
);
// Then apply date inheritance for time-only expressions if available
@ -123,12 +123,12 @@ export class Augmentor {
augmentedTasks =
await this.dateInheritanceAugmentor.augmentTasksWithDateInheritance(
augmentedTasks,
ctx.filePath
ctx.filePath,
);
} catch (error) {
console.warn(
"[Augmentor] Date inheritance augmentation failed:",
error
error,
);
// Continue with standard augmentation if date inheritance fails
}
@ -157,7 +157,7 @@ export class Augmentor {
*/
private augmentTask(task: Task, ctx: AugmentContext): Task {
const originalMetadata = task.metadata || {};
const enhancedMetadata = {...originalMetadata};
const enhancedMetadata = { ...originalMetadata };
// Special handling for priority: check both task.priority and metadata.priority
// Priority might be at task root level (from parser) or in metadata
@ -182,7 +182,7 @@ export class Augmentor {
} else if (enhancedMetadata.priority !== undefined) {
const originalPriority = enhancedMetadata.priority;
enhancedMetadata.priority = this.convertPriorityValue(
enhancedMetadata.priority
enhancedMetadata.priority,
);
}
@ -211,7 +211,7 @@ export class Augmentor {
*/
private applyScalarInheritance(
metadata: Record<string, any>,
ctx: AugmentContext
ctx: AugmentContext,
): void {
const scalarFields = [
"priority",
@ -285,7 +285,7 @@ export class Augmentor {
*/
private applyArrayInheritance(
metadata: Record<string, any>,
ctx: AugmentContext
ctx: AugmentContext,
): void {
const arrayFields = ["tags", "dependsOn"];
@ -376,7 +376,7 @@ export class Augmentor {
*/
private applySpecialFieldRules(
metadata: Record<string, any>,
ctx: AugmentContext
ctx: AugmentContext,
): void {
// Status and completion: only from task level (never inherit)
if (this.strategy.statusCompletionSource === "task-only") {
@ -423,8 +423,14 @@ export class Augmentor {
*/
private applyProjectReference(
metadata: Record<string, any>,
ctx: AugmentContext
ctx: AugmentContext,
): void {
// Helper function to get filename without extension from path
const getFilenameFromPath = (filePath: string): string => {
const fileName = filePath.split("/").pop() || filePath;
return fileName.replace(/\.md$/i, "");
};
// Derive project name from multiple sources with priority:
// 1) ctx.projectName (resolver-provided tgProject name)
// 2) ctx.projectMeta.project
@ -434,21 +440,38 @@ export class Augmentor {
ctx.projectMeta.project.trim()
? ctx.projectMeta.project.trim()
: undefined;
const projectFromFrontmatter =
// Handle project from frontmatter - support boolean true (use filename)
let projectFromFrontmatter: string | undefined;
if (ctx.fileMeta?.project === true && ctx.filePath) {
// Boolean true means use filename as project name
projectFromFrontmatter = getFilenameFromPath(ctx.filePath);
} else if (
typeof ctx.fileMeta?.project === "string" &&
ctx.fileMeta.project.trim()
? ctx.fileMeta.project.trim()
: undefined;
) {
projectFromFrontmatter = ctx.fileMeta.project.trim();
}
// Also consider configured metadataKey in frontmatter (e.g., projectName)
const metadataKeyFromConfig =
this.projectConfig?.metadataConfig?.metadataKey;
const projectFromMetadataKey =
let projectFromMetadataKey: string | undefined;
if (
metadataKeyFromConfig &&
typeof ctx.fileMeta?.[metadataKeyFromConfig] === "string" &&
String(ctx.fileMeta?.[metadataKeyFromConfig]).trim().length > 0
? String(ctx.fileMeta?.[metadataKeyFromConfig]).trim()
: undefined;
ctx.fileMeta?.[metadataKeyFromConfig] !== undefined
) {
const metadataValue = ctx.fileMeta[metadataKeyFromConfig];
// Handle boolean true: use filename as project name
if (metadataValue === true && ctx.filePath) {
projectFromMetadataKey = getFilenameFromPath(ctx.filePath);
} else if (
typeof metadataValue === "string" &&
metadataValue.trim().length > 0
) {
projectFromMetadataKey = metadataValue.trim();
}
}
let effectiveProjectName =
ctx.projectName ||
@ -459,8 +482,9 @@ export class Augmentor {
// Normalize effectiveProjectName to string: handle arrays (take first element), non-strings, etc.
if (effectiveProjectName) {
if (Array.isArray(effectiveProjectName)) {
effectiveProjectName = effectiveProjectName[0]?.toString() || undefined;
} else if (typeof effectiveProjectName !== 'string') {
effectiveProjectName =
effectiveProjectName[0]?.toString() || undefined;
} else if (typeof effectiveProjectName !== "string") {
effectiveProjectName = String(effectiveProjectName);
}
}
@ -527,7 +551,7 @@ export class Augmentor {
private applySubtaskInheritance(
parentTask: Task,
parentMetadata: Record<string, any>,
ctx: AugmentContext
ctx: AugmentContext,
): void {
// This would typically involve finding child tasks and applying inheritance
// For now, we'll store the inheritance rules on the parent for child processing
@ -613,14 +637,14 @@ export class Augmentor {
* Update inheritance strategy
*/
updateStrategy(strategy: Partial<InheritanceStrategy>): void {
this.strategy = {...this.strategy, ...strategy};
this.strategy = { ...this.strategy, ...strategy };
}
/**
* Get current inheritance strategy
*/
getStrategy(): InheritanceStrategy {
return {...this.strategy};
return { ...this.strategy };
}
/**
@ -630,7 +654,7 @@ export class Augmentor {
field: string,
taskValue: any,
fileValue: any,
projectValue: any
projectValue: any,
): any {
// Handle arrays specially
if (

View file

@ -1572,6 +1572,19 @@ export class MarkdownTaskParser {
const metadataKey = config.metadataConfig.metadataKey || "project";
const projectFromMetadata = this.fileMetadata[metadataKey];
// Handle boolean true: use filename as project name
if (projectFromMetadata === true) {
const fileName = filePath.split("/").pop() || filePath;
const nameWithoutExt = fileName.replace(/\.md$/i, "");
return {
type: "metadata",
name: nameWithoutExt,
source: `${metadataKey} (filename)`,
readonly: true,
};
}
// Handle string values
if (
projectFromMetadata &&
typeof projectFromMetadata === "string"

View file

@ -70,6 +70,7 @@ import { MinimalQuickCaptureSuggest } from "./components/features/quick-capture/
import { SuggestManager } from "@/components/ui/suggest";
import { t } from "./translations/helper";
import { TASK_VIEW_TYPE, TaskView } from "./pages/TaskView";
import { SettingsModal } from "./components/features/settings/SettingsModal";
import "./styles/global.scss";
import "./styles/setting.scss";
import "./styles/view.scss";
@ -465,12 +466,10 @@ export default class TaskProgressBarPlugin extends Plugin {
await this.ensureFluentIntegration();
if (this.settings.enableView) {
this.registerTaskViews();
this.installWorkspaceGuards();
this.registerViewCommands();
this.deferIconRegistration();
}
this.registerTaskViews();
this.installWorkspaceGuards();
this.registerViewCommands();
this.deferIconRegistration();
const dataflowInitialized = await this.initializeDataflowOrchestrator();
if (!dataflowInitialized) {
@ -603,6 +602,14 @@ export default class TaskProgressBarPlugin extends Plugin {
this.changelogManager.openChangelog(targetVersion, isBeta);
},
});
this.addCommand({
id: "open-task-genius-settings-modal",
name: t("Open Task Genius settings"),
callback: () => {
this.openSettingsModal();
},
});
}
private deferIconRegistration(): void {
@ -2251,4 +2258,16 @@ export default class TaskProgressBarPlugin extends Plugin {
);
return Promise.resolve();
}
/**
* Open the settings modal
* @param tabId Optional tab ID to open directly
*/
openSettingsModal(tabId?: string): void {
const modal = new SettingsModal(this.app, this);
modal.open();
if (tabId) {
modal.openTab(tabId);
}
}
}

View file

@ -125,7 +125,7 @@ export class ProjectConfigManager {
* Get project configuration for a given file path
*/
async getProjectConfig(
filePath: string
filePath: string,
): Promise<ProjectConfigData | null> {
// Early return if enhanced project features are disabled
if (!this.enhancedProjectEnabled) {
@ -172,7 +172,7 @@ export class ProjectConfigManager {
} catch (error) {
console.warn(
`Failed to read project config for ${filePath}:`,
error
error,
);
return null;
}
@ -258,7 +258,7 @@ export class ProjectConfigManager {
if (this.matchesPathPattern(filePath, mapping.pathPattern)) {
// Normalize the project name to support nested paths
const normalizedName = this.normalizeProjectPath(
mapping.projectName
mapping.projectName,
);
return {
type: "path",
@ -289,7 +289,7 @@ export class ProjectConfigManager {
return {
type: "metadata",
name: String(
fileMetadata[method.propertyKey]
fileMetadata[method.propertyKey],
),
source: method.propertyKey,
readonly: true,
@ -301,21 +301,21 @@ export class ProjectConfigManager {
// Check if file has the specified tag (consider both inline and frontmatter tags)
{
const targetTag = method.propertyKey.startsWith(
"#"
"#",
)
? method.propertyKey
: `#${method.propertyKey}`;
const normalizedTarget =
targetTag.toLowerCase();
const inlineTags = (fileCache?.tags || []).map(
(tc) => tc.tag
(tc) => tc.tag,
);
const fmTagsRaw = fileCache?.frontmatter?.tags;
const fmTagsArr = Array.isArray(fmTagsRaw)
? fmTagsRaw
: fmTagsRaw !== undefined
? [fmTagsRaw]
: [];
? [fmTagsRaw]
: [];
const fmTagsNorm = fmTagsArr.map((t: any) => {
const s = String(t || "");
return s.startsWith("#") ? s : `#${s}`;
@ -326,7 +326,7 @@ export class ProjectConfigManager {
].map((t) => String(t || "").toLowerCase());
// For file-level detection: require exact match; do NOT treat hierarchical '#project/xxx' as match unless configured exactly
const hasTag = allTags.some(
(t) => t === normalizedTarget
(t) => t === normalizedTarget,
);
if (hasTag) {
@ -352,7 +352,7 @@ export class ProjectConfigManager {
filePath.split("/").pop() || filePath;
const nameWithoutExt = fileName.replace(
/\.md$/i,
""
"",
);
return {
type: "metadata",
@ -374,7 +374,7 @@ export class ProjectConfigManager {
if (method.linkFilter) {
if (
linkedNote.includes(
method.linkFilter
method.linkFilter,
)
) {
// First try to use title or name from frontmatter as project name
@ -382,7 +382,7 @@ export class ProjectConfigManager {
return {
type: "metadata",
name: String(
fileMetadata.title
fileMetadata.title,
),
source: "title (via link)",
readonly: true,
@ -392,7 +392,7 @@ export class ProjectConfigManager {
return {
type: "metadata",
name: String(
fileMetadata.name
fileMetadata.name,
),
source: "name (via link)",
readonly: true,
@ -418,12 +418,14 @@ export class ProjectConfigManager {
fileMetadata[method.propertyKey]
) {
const propValue = String(
fileMetadata[method.propertyKey]
fileMetadata[
method.propertyKey
],
);
// Check if this link is mentioned in the property
if (
propValue.includes(
`[[${linkedNote}]]`
`[[${linkedNote}]]`,
)
) {
// First try to use title or name from frontmatter as project name
@ -431,7 +433,7 @@ export class ProjectConfigManager {
return {
type: "metadata",
name: String(
fileMetadata.title
fileMetadata.title,
),
source: "title (via link)",
readonly: true,
@ -441,7 +443,7 @@ export class ProjectConfigManager {
return {
type: "metadata",
name: String(
fileMetadata.name
fileMetadata.name,
),
source: "name (via link)",
readonly: true,
@ -454,7 +456,7 @@ export class ProjectConfigManager {
const nameWithoutExt =
fileName.replace(
/\.md$/i,
""
"",
);
return {
type: "metadata",
@ -478,6 +480,20 @@ export class ProjectConfigManager {
const fileMetadata = this.getFileMetadata(filePath);
if (fileMetadata && fileMetadata[this.metadataKey]) {
const projectFromMetadata = fileMetadata[this.metadataKey];
// Handle boolean true: use filename as project name
if (projectFromMetadata === true) {
const fileName = filePath.split("/").pop() || filePath;
const nameWithoutExt = fileName.replace(/\.md$/i, "");
return {
type: "metadata",
name: nameWithoutExt,
source: `${this.metadataKey} (filename)`,
readonly: true,
};
}
// Handle string values
if (
typeof projectFromMetadata === "string" &&
projectFromMetadata.trim()
@ -583,7 +599,7 @@ export class ProjectConfigManager {
} catch (error) {
console.warn(
`Failed to get enhanced metadata for ${filePath}:`,
error
error,
);
return {};
}
@ -621,7 +637,7 @@ export class ProjectConfigManager {
* Find project configuration file for a given file path
*/
private async findProjectConfigFile(
filePath: string
filePath: string,
): Promise<TFile | null> {
// Early return if enhanced project features are disabled
if (!this.enhancedProjectEnabled) {
@ -641,7 +657,7 @@ export class ProjectConfigManager {
(child: any) =>
child &&
child.name === this.configFileName &&
"stat" in child // Check if it's a file-like object
"stat" in child, // Check if it's a file-like object
) as TFile | undefined;
if (configFile) {
@ -681,7 +697,7 @@ export class ProjectConfigManager {
(child: any) =>
child &&
child.name === this.configFileName &&
"stat" in child // Check if it's a file-like object
"stat" in child, // Check if it's a file-like object
) as TFile | undefined;
if (configFile) {
@ -763,7 +779,7 @@ export class ProjectConfigManager {
* Apply metadata mappings to transform source metadata keys to target keys
*/
private applyMetadataMappings(
metadata: Record<string, any>
metadata: Record<string, any>,
): Record<string, any> {
const result = { ...metadata };
@ -775,7 +791,7 @@ export class ProjectConfigManager {
// Apply intelligent type conversion for common field types
result[mapping.targetKey] = this.convertMetadataValue(
mapping.targetKey,
sourceValue
sourceValue,
);
}
}
@ -811,12 +827,12 @@ export class ProjectConfigManager {
// Check if it's a date field
const isDateField = dateFieldPatterns.some((pattern) =>
targetKey.toLowerCase().includes(pattern.toLowerCase())
targetKey.toLowerCase().includes(pattern.toLowerCase()),
);
// Check if it's a priority field
const isPriorityField = priorityFieldPatterns.some((pattern) =>
targetKey.toLowerCase().includes(pattern.toLowerCase())
targetKey.toLowerCase().includes(pattern.toLowerCase()),
);
if (isDateField && typeof value === "string") {
@ -862,7 +878,7 @@ export class ProjectConfigManager {
* Public method to apply metadata mappings to any metadata object
*/
public applyMappingsToMetadata(
metadata: Record<string, any>
metadata: Record<string, any>,
): Record<string, any> {
return this.applyMetadataMappings(metadata);
}
@ -898,7 +914,7 @@ export class ProjectConfigManager {
const projectRootIndex = pathParts.findIndex(
(part) =>
part.toLowerCase() === "projects" ||
part.toLowerCase() === "project"
part.toLowerCase() === "project",
);
if (
@ -908,7 +924,7 @@ export class ProjectConfigManager {
// Build project path from folders after the project root
const projectParts = pathParts.slice(
projectRootIndex + 1,
pathParts.length - 1
pathParts.length - 1,
);
return projectParts.join("/");
}
@ -1008,7 +1024,7 @@ export class ProjectConfigManager {
* Get project config data for a file (alias for getProjectConfig for compatibility)
*/
async getProjectConfigData(
filePath: string
filePath: string,
): Promise<ProjectConfigData | null> {
return await this.getProjectConfig(filePath);
}
@ -1039,13 +1055,13 @@ export class ProjectConfigManager {
.reduce((sum, size) => sum + size, 0);
const fileMetadataCacheSize = Array.from(
this.fileMetadataCache.values()
this.fileMetadataCache.values(),
)
.map((metadata) => JSON.stringify(metadata).length)
.reduce((sum, size) => sum + size, 0);
const enhancedMetadataCacheSize = Array.from(
this.enhancedMetadataCache.values()
this.enhancedMetadataCache.values(),
)
.map((metadata) => JSON.stringify(metadata).length)
.reduce((sum, size) => sum + size, 0);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,576 @@
/* Settings Modal - Obsidian vertical-tabs style */
.modal.mod-settings-modal {
width: 90vw;
height: 85vh;
max-width: 1100px;
max-height: 850px;
.modal-content {
padding: 0;
height: 100%;
overflow: hidden;
}
.modal-close-button {
z-index: 10;
}
}
/* Vertical tabs container */
/* Vertical tab header (sidebar) */
/* .modal.mod-settings-modal .vertical-tab-header {
width: 240px;
min-width: 200px;
background-color: var(--background-secondary);
border-right: 1px solid var(--background-modifier-border);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
} */
/* Search in header */
.modal.mod-settings .vertical-tab-header-search {
padding: var(--size-4-3);
border-bottom: 1px solid var(--background-modifier-border);
flex-shrink: 0;
input {
width: 100%;
padding: var(--size-2-2) var(--size-2-3);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-primary);
font-size: var(--font-ui-small);
}
}
/* Header group */
.modal.mod-settings-modal .vertical-tab-header-group {
margin-bottom: var(--size-2-2);
.vertical-tab-header-group-title {
padding: var(--size-2-2) var(--size-4-3);
font-size: var(--font-ui-smaller);
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vertical-tab-header-group-items {
display: flex;
flex-direction: column;
}
}
/* Navigation item */
/* .modal.mod-settings-modal .vertical-tab-nav-item {
padding: var(--size-2-2) var(--size-4-3);
margin: 1px var(--size-2-2);
border-radius: var(--radius-s);
cursor: pointer;
color: var(--text-muted);
font-size: var(--font-ui-small);
display: flex;
align-items: center;
gap: var(--size-2-3);
transition: all 0.15s ease;
user-select: none;
&:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
&.is-active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
font-weight: var(--font-weight-medium);
}
&.is-hidden {
display: none;
}
&.is-search-match {
background-color: var(--background-modifier-hover);
}
.vertical-tab-nav-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
opacity: 0.8;
}
&.is-active .vertical-tab-nav-item-icon {
opacity: 1;
}
.vertical-tab-nav-item-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vertical-tab-nav-item-chevron {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
opacity: 0.5;
flex-shrink: 0;
}
&.is-active .vertical-tab-nav-item-chevron {
opacity: 0.8;
}
} */
/* Content header */
.modal.mod-settings .vertical-tab-content-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-2-2);
padding: var(--size-4-2) var(--size-4-2);
border-bottom: 1px solid var(--background-modifier-border);
flex-shrink: 0;
background-color: var(--background-primary);
margin-bottom: var(--size-4-4);
align-items: center;
.vertical-tab-content-header-title {
display: flex;
align-items: center;
gap: var(--size-2-3);
margin: 0;
font-size: var(--font-ui-large);
font-weight: var(--font-weight-semibold);
color: var(--text-normal);
padding: 0;
.vertical-tab-content-header-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--interactive-accent);
}
}
.vertical-tab-content-header-actions {
display: flex;
align-items: center;
gap: var(--size-2-3);
flex-shrink: 0;
button {
display: flex;
align-items: center;
gap: var(--size-2-2);
font-size: var(--font-ui-small);
}
}
.vertical-tab-content-header-desc {
width: 100%;
margin: 0;
font-size: var(--font-ui-small);
color: var(--text-muted);
line-height: 1.5;
}
}
/* Content body */
.modal.mod-settings-modal .vertical-tab-content-body {
flex: 1;
overflow-y: auto;
padding: var(--size-4-4) var(--size-4-6);
animation: settingsFadeIn 0.15s ease-out;
/* Ensure setting items don't have excessive padding */
.setting-item {
padding-top: var(--size-4-3);
padding-bottom: var(--size-4-3);
}
.setting-item:first-child {
padding-top: 0;
border-top: none;
}
/* Section headers within content */
h3 {
margin-top: var(--size-4-4);
margin-bottom: var(--size-2-3);
font-size: var(--font-ui-medium);
font-weight: var(--font-weight-semibold);
color: var(--text-normal);
&:first-child {
margin-top: 0;
}
}
}
/* Empty state */
.modal.mod-settings-modal .vertical-tab-content-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--size-4-8);
text-align: center;
color: var(--text-muted);
}
/* Animation for tab switching */
@keyframes settingsFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive adjustments */
/* @media (max-width: 768px) {
.modal.mod-settings-modal {
width: 95vw;
height: 90vh;
}
.modal.mod-settings-modal .vertical-tab-header {
width: 180px;
min-width: 150px;
.vertical-tab-header-group-title {
padding: var(--size-2-1) var(--size-2-3);
font-size: 10px;
}
.vertical-tab-nav-item {
padding: var(--size-2-1) var(--size-2-3);
font-size: var(--font-ui-smaller);
}
}
.modal.mod-settings-modal .vertical-tab-content-header {
padding: var(--size-4-3) var(--size-4-4);
}
.modal.mod-settings-modal .vertical-tab-content-body {
padding: var(--size-4-3) var(--size-4-4);
}
} */
@media (max-width: 600px) {
.modal.mod-settings-modal .vertical-tabs-container {
flex-direction: column;
}
.modal.mod-settings-modal .vertical-tab-header {
width: 100%;
height: auto;
max-height: 200px;
border-right: none;
border-bottom: 1px solid var(--background-modifier-border);
overflow-y: auto;
.vertical-tab-header-group {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-1);
margin-bottom: 0;
padding: var(--size-2-2);
.vertical-tab-header-group-title {
display: none;
}
.vertical-tab-header-group-items {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--size-2-1);
}
}
/* .vertical-tab-nav-item {
margin: 0;
padding: var(--size-2-1) var(--size-2-2);
.vertical-tab-nav-item-chevron {
display: none;
}
} */
}
}
/* Mobile-specific styles */
.is-mobile .modal.mod-settings {
.vertical-tab-header {
width: 100%;
height: 100%;
max-height: none;
border-right: none;
overflow-y: auto;
&.is-mobile-hidden {
display: none;
}
.vertical-tab-header-group {
margin-bottom: var(--size-4-2);
.vertical-tab-header-group-title {
display: block;
}
.vertical-tab-header-group-items {
flex-direction: column;
}
}
/* .vertical-tab-nav-item {
padding: var(--size-4-3) var(--size-4-4);
.vertical-tab-nav-item-chevron {
display: flex;
}
} */
}
.vertical-tab-content-container {
width: 100%;
height: 100%;
&.is-mobile-hidden {
display: none;
}
}
.vertical-tab-content-header {
padding: var(--size-4-3) var(--size-4-4);
}
.vertical-tab-content-body {
padding: var(--size-4-3) var(--size-4-4);
}
}
/* ========================================
Settings Entry Page (PluginSettingTab)
======================================== */
.task-genius-settings-entry {
max-width: 800px;
margin: 0 auto;
padding: var(--size-4-4);
}
.settings-entry-header {
text-align: center;
margin-bottom: var(--size-4-8);
padding-bottom: var(--size-4-6);
margin-block-start: var(--size-4-8);
border-bottom: 1px solid var(--background-modifier-border);
}
.settings-entry-title-container {
display: flex;
align-items: center;
justify-content: center;
gap: var(--size-4-3);
margin-bottom: var(--size-4-3);
}
.settings-entry-logo {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
color: var(--interactive-accent);
--icon-size: 48px;
}
.settings-entry-title-text {
display: flex;
flex-direction: column;
align-items: flex-start;
h2 {
margin: 0;
padding: 0;
font-size: var(--font-ui-large);
font-weight: var(--font-weight-bold);
color: var(--text-normal);
}
.settings-entry-version {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
}
.settings-entry-desc {
max-width: 500px;
margin: 0 auto;
p {
margin: 0;
font-size: var(--font-ui-small);
color: var(--text-muted);
line-height: 1.6;
}
}
.settings-entry-action {
margin-bottom: var(--size-4-6);
padding: var(--size-4-4);
background-color: var(--background-secondary);
border-radius: var(--radius-m);
border: 1px solid var(--background-modifier-border);
.setting-item {
border: none;
padding: 0;
}
.setting-item-info {
flex: 1;
}
.setting-item-control button.mod-cta {
padding: var(--size-4-2) var(--size-4-6);
font-size: var(--font-ui-medium);
}
}
.settings-entry-links {
margin-block-end: var(--size-4-6);
margin-block-start: var(--size-4-4);
h3 {
margin: 0 0 var(--size-4-3);
font-size: var(--font-ui-medium);
font-weight: var(--font-weight-semibold);
color: var(--text-normal);
}
}
.settings-entry-links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--size-4-2);
}
.settings-entry-link {
display: flex;
align-items: center;
gap: var(--size-2-3);
padding: var(--size-4-2) var(--size-4-3);
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
color: var(--text-muted);
text-decoration: none;
font-size: var(--font-ui-small);
transition: all 0.15s ease;
&:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
border-color: var(--interactive-accent);
}
.settings-entry-link-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--interactive-accent);
}
}
.settings-entry-features {
h3 {
margin: 0 0 var(--size-4-3);
font-size: var(--font-ui-medium);
font-weight: var(--font-weight-semibold);
color: var(--text-normal);
}
}
.settings-entry-features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--size-4-3);
}
.settings-entry-feature {
display: flex;
align-items: flex-start;
gap: var(--size-4-2);
padding: var(--size-4-3);
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
transition: all 0.15s ease;
&:hover {
border-color: var(--background-modifier-border-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.settings-entry-feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background-color: var(--interactive-accent);
border-radius: var(--radius-s);
color: var(--text-on-accent);
flex-shrink: 0;
svg {
width: 18px;
height: 18px;
}
}
.settings-entry-feature-text {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
strong {
font-size: var(--font-ui-small);
font-weight: var(--font-weight-medium);
color: var(--text-normal);
}
span {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
line-height: 1.4;
}
}
}

View file

@ -2491,6 +2491,10 @@ const translations = {
"Fixed File": "Fixed File",
"Save to Inbox.md": "Save to Inbox.md",
"Open Task Genius Setup": "Open Task Genius Setup",
"Open Task Genius settings": "Open Task Genius settings",
"Open Task Genius settings modal": "Open Task Genius settings modal",
"Search Results": "Search Results",
results: "results",
"MCP Integration": "MCP Integration",
Beginner: "Beginner",
"Basic task management with essential features":
@ -2630,6 +2634,99 @@ const translations = {
"Automatically start the timer when creating a new task via quick capture (checkbox mode only)":
"Automatically start the timer when creating a new task via quick capture (checkbox mode only)",
"Timer started for new task": "Timer started for new task",
// ============================================================
// Project Auto-Detection Settings (Redesigned in v9.x)
// ============================================================
// Main heading and description
"Project Auto-Detection": "Project Auto-Detection",
"Configure how tasks are automatically assigned to projects based on file paths, metadata, or configuration files.":
"Configure how tasks are automatically assigned to projects based on file paths, metadata, or configuration files.",
// Main toggle
"Enable project auto-detection": "Enable project auto-detection",
"When enabled, tasks will be automatically assigned to projects based on file location, frontmatter metadata, or project configuration files.":
"When enabled, tasks will be automatically assigned to projects based on file location, frontmatter metadata, or project configuration files.",
// Source 1: Path-based Detection
"Source 1: Path-based Detection": "Source 1: Path-based Detection",
"Map file paths to project names. Files in matched paths will automatically belong to the specified project.":
"Map file paths to project names. Files in matched paths will automatically belong to the specified project.",
"No path mappings yet. Add a mapping to auto-detect projects from file paths.":
"No path mappings yet. Add a mapping to auto-detect projects from file paths.",
Rule: "Rule",
"Enable this rule": "Enable this rule",
"Delete this rule": "Delete this rule",
"Add path rule": "Add path rule",
// Source 2: Metadata-based Detection
"Source 2: Metadata-based Detection": "Source 2: Metadata-based Detection",
"Read project name from file frontmatter. This has the highest priority among all detection methods.":
"Read project name from file frontmatter. This has the highest priority among all detection methods.",
"Enable metadata detection": "Enable metadata detection",
"Read project name from the frontmatter of each file. Example: 'project: MyProject' in YAML header.":
"Read project name from the frontmatter of each file. Example: 'project: MyProject' in YAML header.",
"Metadata field name": "Metadata field name",
"The frontmatter field name to read project from. Default is 'project'.":
"The frontmatter field name to read project from. Default is 'project'.",
// Source 3: Config File-based Detection
"Source 3: Config File-based Detection":
"Source 3: Config File-based Detection",
"Use a special file in each folder to define project settings for all files in that folder.":
"Use a special file in each folder to define project settings for all files in that folder.",
"Enable config file detection": "Enable config file detection",
"Look for project configuration files (e.g., project.md) in folders to determine project membership.":
"Look for project configuration files (e.g., project.md) in folders to determine project membership.",
"Name of the project configuration file to look for. The file should contain project settings in its frontmatter.":
"Name of the project configuration file to look for. The file should contain project settings in its frontmatter.",
// Advanced: Custom Detection Methods
"Advanced: Custom Detection Methods": "Advanced: Custom Detection Methods",
"Additional methods to detect projects from tags, links, or other metadata fields.":
"Additional methods to detect projects from tags, links, or other metadata fields.",
Method: "Method",
"Metadata field": "Metadata field",
"Tag pattern": "Tag pattern",
"Link target": "Link target",
"Field name (e.g., project)": "Field name (e.g., project)",
"Tag prefix (e.g., project)": "Tag prefix (e.g., project)",
"Link filter (e.g., Projects/)": "Link filter (e.g., Projects/)",
"Delete this method": "Delete this method",
"Link path filter": "Link path filter",
"Only match links containing this path":
"Only match links containing this path",
"Add detection method": "Add detection method",
// Advanced: Metadata Field Mapping
"Advanced: Metadata Field Mapping": "Advanced: Metadata Field Mapping",
"Map custom frontmatter fields to standard task properties. Useful for custom naming conventions.":
"Map custom frontmatter fields to standard task properties. Useful for custom naming conventions.",
"No field mappings yet. Add a mapping to use custom frontmatter field names.":
"No field mappings yet. Add a mapping to use custom frontmatter field names.",
"Your field name (e.g., proj)": "Your field name (e.g., proj)",
"→ Standard field": "→ Standard field",
"Enable this mapping": "Enable this mapping",
"Delete this mapping": "Delete this mapping",
"Add field mapping": "Add field mapping",
// Fallback: Default Project Naming
"Fallback: Default Project Naming": "Fallback: Default Project Naming",
"When no project is detected by the above methods, use this strategy to generate a default project name.":
"When no project is detected by the above methods, use this strategy to generate a default project name.",
"Enable fallback naming": "Enable fallback naming",
"Generate a default project name when no project is detected. If disabled, tasks without detected projects will have no project assigned.":
"Generate a default project name when no project is detected. If disabled, tasks without detected projects will have no project assigned.",
"How to generate the default project name":
"How to generate the default project name",
"Use file name": "Use file name",
"Metadata field for default name": "Metadata field for default name",
"Frontmatter field to use as the default project name":
"Frontmatter field to use as the default project name",
"e.g., category": "e.g., category",
"Remove file extension": "Remove file extension",
"Remove the file extension (.md) from the filename when using as project name":
"Remove the file extension (.md) from the filename when using as project name",
};
export default translations;

View file

@ -2324,6 +2324,10 @@ const translations = {
"Fixed File": "固定文件",
"Save to Inbox.md": "保存至 Inbox.md",
"Open Task Genius Setup": "打开 Task Genius 设置",
"Open Task Genius settings": "打开 Task Genius 设置",
"Open Task Genius settings modal": "打开 Task Genius 设置面板",
"Search Results": "搜索结果",
results: "个结果",
"MCP Integration": "MCP 集成",
Beginner: "初学者",
"Basic task management with essential features": "基础任务管理与必要功能",
@ -2420,6 +2424,96 @@ const translations = {
"Automatically start the timer when creating a new task via quick capture (checkbox mode only)":
"通过快速捕获创建新任务时自动启动计时器(仅限复选框模式)",
"Timer started for new task": "已为新任务启动计时器",
// ============================================================
// 项目自动识别设置v9.x 重新设计)
// ============================================================
// 页面标题和描述
"Project Auto-Detection": "项目自动识别",
"Configure how tasks are automatically assigned to projects based on file paths, metadata, or configuration files.":
"配置如何根据文件路径、元数据或配置文件自动将任务分配到项目。",
// 主开关
"Enable project auto-detection": "启用项目自动识别",
"When enabled, tasks will be automatically assigned to projects based on file location, frontmatter metadata, or project configuration files.":
"启用后,任务将根据文件位置、前言元数据或项目配置文件自动分配到项目。",
// 数据源一:基于路径的检测
"Source 1: Path-based Detection": "数据源一:基于路径的检测",
"Map file paths to project names. Files in matched paths will automatically belong to the specified project.":
"将文件路径映射到项目名称。匹配路径中的文件将自动归入指定项目。",
"No path mappings yet. Add a mapping to auto-detect projects from file paths.":
"尚无路径映射。添加映射以从文件路径自动检测项目。",
Rule: "规则",
"Enable this rule": "启用此规则",
"Delete this rule": "删除此规则",
"Add path rule": "添加路径规则",
// 数据源二:基于元数据的检测
"Source 2: Metadata-based Detection": "数据源二:基于元数据的检测",
"Read project name from file frontmatter. This has the highest priority among all detection methods.":
"从文件前言读取项目名称。这在所有检测方法中优先级最高。",
"Enable metadata detection": "启用元数据检测",
"Read project name from the frontmatter of each file. Example: 'project: MyProject' in YAML header.":
"从每个文件的前言读取项目名称。例如YAML 头部的 'project: MyProject'。",
"Metadata field name": "元数据字段名",
"The frontmatter field name to read project from. Default is 'project'.":
"用于读取项目的前言字段名称。默认为 'project'。",
// 数据源三:基于配置文件的检测
"Source 3: Config File-based Detection": "数据源三:基于配置文件的检测",
"Use a special file in each folder to define project settings for all files in that folder.":
"在每个文件夹中使用特殊文件为该文件夹中的所有文件定义项目设置。",
"Enable config file detection": "启用配置文件检测",
"Look for project configuration files (e.g., project.md) in folders to determine project membership.":
"在文件夹中查找项目配置文件(如 project.md以确定项目归属。",
"Name of the project configuration file to look for. The file should contain project settings in its frontmatter.":
"要查找的项目配置文件名称。该文件应在其前言中包含项目设置。",
// 高级功能:自定义检测方法
"Advanced: Custom Detection Methods": "高级:自定义检测方法",
"Additional methods to detect projects from tags, links, or other metadata fields.":
"从标签、链接或其他元数据字段检测项目的其他方法。",
Method: "方法",
"Metadata field": "元数据字段",
"Tag pattern": "标签模式",
"Link target": "链接目标",
"Field name (e.g., project)": "字段名(如 project",
"Tag prefix (e.g., project)": "标签前缀(如 project",
"Link filter (e.g., Projects/)": "链接过滤器(如 Projects/",
"Delete this method": "删除此方法",
"Link path filter": "链接路径过滤器",
"Only match links containing this path": "仅匹配包含此路径的链接",
"Add detection method": "添加检测方法",
// 高级功能:元数据字段映射
"Advanced: Metadata Field Mapping": "高级:元数据字段映射",
"Map custom frontmatter fields to standard task properties. Useful for custom naming conventions.":
"将自定义前言字段映射到标准任务属性。适用于自定义命名规范。",
"No field mappings yet. Add a mapping to use custom frontmatter field names.":
"尚无字段映射。添加映射以使用自定义前言字段名。",
"Your field name (e.g., proj)": "你的字段名(如 proj",
"→ Standard field": "→ 标准字段",
"Enable this mapping": "启用此映射",
"Delete this mapping": "删除此映射",
"Add field mapping": "添加字段映射",
// 后备策略:默认项目命名
"Fallback: Default Project Naming": "后备策略:默认项目命名",
"When no project is detected by the above methods, use this strategy to generate a default project name.":
"当上述方法都无法检测到项目时,使用此策略生成默认项目名称。",
"Enable fallback naming": "启用后备命名",
"Generate a default project name when no project is detected. If disabled, tasks without detected projects will have no project assigned.":
"在未检测到项目时生成默认项目名称。如果禁用,未检测到项目的任务将不会分配项目。",
"How to generate the default project name": "如何生成默认项目名称",
"Use file name": "使用文件名",
"Metadata field for default name": "用于默认名称的元数据字段",
"Frontmatter field to use as the default project name":
"用作默认项目名称的前言字段",
"e.g., category": "例如 category",
"Remove file extension": "移除文件扩展名",
"Remove the file extension (.md) from the filename when using as project name":
"用作项目名称时从文件名中移除文件扩展名(.md",
};
export default translations;

File diff suppressed because one or more lines are too long