feat(quick-capture): auto-fill metadata from active filter state

This commit is contained in:
Quorafind 2025-12-07 09:33:56 +08:00
parent 54285bdee3
commit bb79032aa9
3 changed files with 297 additions and 4 deletions

View file

@ -19,6 +19,8 @@ import {
} from "@/utils/date/date-utils";
import { SuggestManager } from "@/components/ui/suggest";
import { EmbeddableMarkdownEditor } from "@/editor-extensions/core/markdown-editor";
import { extractMetadataFromFilter } from "../../task/filter/filter-metadata-extractor";
import type { RootFilterState } from "../../task/filter/ViewTaskFilter";
/**
* Quick capture save strategy types
@ -102,11 +104,39 @@ export abstract class BaseQuickCaptureModal extends Modal {
// Initialize suggest manager
this.suggestManager = new SuggestManager(app, plugin);
// Initialize metadata
// Initialize metadata with filter-based pre-filling
let finalMetadata: TaskMetadata = {};
// 1. Extract metadata from global filter state (if active)
const globalFilterState = this.app.loadLocalStorage(
"task-genius-view-filter",
) as RootFilterState | null;
if (globalFilterState) {
const extractedFilterMetadata =
extractMetadataFromFilter(globalFilterState);
finalMetadata = { ...extractedFilterMetadata };
}
// 2. Merge with incoming metadata (incoming has higher priority)
if (metadata) {
this.taskMetadata = this.normalizeMetadataDates(metadata);
const extractedTags = finalMetadata.tags || [];
const incomingTags = metadata.tags || [];
finalMetadata = { ...finalMetadata, ...metadata };
// Special handling for tags: merge and deduplicate
if (extractedTags.length > 0 || incomingTags.length > 0) {
finalMetadata.tags = [
...new Set([...extractedTags, ...incomingTags]),
];
}
}
// 3. Apply final metadata
if (Object.keys(finalMetadata).length > 0) {
this.taskMetadata = this.normalizeMetadataDates(finalMetadata);
// Auto-switch to file mode if location is file
if (metadata.location === "file") {
if (finalMetadata.location === "file") {
this.currentMode = "file";
}
}

View file

@ -0,0 +1,257 @@
/**
* Filter Metadata Extractor
*
* Extracts task metadata from filter state for auto-filling when creating new tasks.
* This enables context-aware task creation based on active filters.
*/
import { moment } from "obsidian";
import type { RootFilterState, FilterGroup, Filter } from "./ViewTaskFilter";
import type { TaskMetadata } from "../../quick-capture/modals/BaseQuickCaptureModal";
/**
* Extracts task metadata from the current filter state.
*
* Strategy:
* - For `rootCondition === 'all'`: Merge metadata from all filter groups (union)
* - For `rootCondition === 'any'`: Only extract metadata that is common across all groups (intersection)
* - For `rootCondition === 'none'`: Return empty metadata (not suitable for pre-filling)
*
* @param filterState - The current root filter state
* @returns TaskMetadata object with extracted values
*/
export function extractMetadataFromFilter(
filterState: RootFilterState | null | undefined,
): TaskMetadata {
const metadata: TaskMetadata = {};
if (
!filterState ||
!filterState.filterGroups ||
filterState.filterGroups.length === 0
) {
return metadata;
}
// 'none' condition is not suitable for pre-filling
if (filterState.rootCondition === "none") {
return metadata;
}
if (filterState.rootCondition === "all") {
// Strategy: Union/Merge
// For single-value fields, later groups override earlier ones (Last Write Wins)
// For multi-value fields (tags), merge all occurrences
filterState.filterGroups.forEach((group) => {
const groupMeta = extractFromGroup(group);
mergeMetadata(metadata, groupMeta);
});
} else if (filterState.rootCondition === "any") {
// Strategy: Intersection
// Only extract metadata that is common across ALL filter groups
const allGroupsMeta = filterState.filterGroups.map(extractFromGroup);
if (allGroupsMeta.length > 0) {
const potential = allGroupsMeta[0];
// Check single-value fields
const singleFields: (keyof TaskMetadata)[] = [
"priority",
"project",
"status",
"context",
];
singleFields.forEach((field) => {
const val = potential[field];
if (val !== undefined) {
const allMatch = allGroupsMeta.every(
(g) => g[field] === val,
);
if (allMatch) {
(metadata as Record<string, unknown>)[field] = val;
}
}
});
// Check date fields (compare timestamps)
const dateFields: (keyof TaskMetadata)[] = [
"startDate",
"dueDate",
"scheduledDate",
];
dateFields.forEach((field) => {
const val = potential[field];
if (val instanceof Date) {
const allMatch = allGroupsMeta.every((g) => {
const gVal = g[field];
return (
gVal instanceof Date &&
gVal.getTime() === val.getTime()
);
});
if (allMatch) {
(metadata as Record<string, unknown>)[field] = val;
}
}
});
// Check tags (array intersection)
if (potential.tags && potential.tags.length > 0) {
const commonTags = potential.tags.filter((tag) => {
return allGroupsMeta.every(
(g) => g.tags && g.tags.includes(tag),
);
});
if (commonTags.length > 0) {
metadata.tags = commonTags;
}
}
}
}
return metadata;
}
/**
* Extracts metadata from a single filter group.
* Only processes groups with `groupCondition === 'all'` for reliable extraction.
*/
function extractFromGroup(group: FilterGroup): TaskMetadata {
const groupMeta: TaskMetadata = {};
// Only extract from 'all' (AND) groups for reliable pre-filling
if (group.groupCondition !== "all") {
return groupMeta;
}
group.filters.forEach((filter) => {
extractFromFilter(filter, groupMeta);
});
return groupMeta;
}
/**
* Extracts metadata from a single filter condition.
* Only extracts from conditions that provide definite values (e.g., 'is', 'contains').
*/
function extractFromFilter(filter: Filter, metadata: TaskMetadata): void {
if (!filter.property || !filter.condition) {
return;
}
const val = filter.value;
// Ignore empty values
if (val === undefined || val === "" || val === null) {
return;
}
switch (filter.property) {
case "priority":
if (filter.condition === "is") {
const parsed = parseInt(val, 10);
if (!isNaN(parsed)) {
metadata.priority = parsed;
}
}
break;
case "project":
if (filter.condition === "is") {
metadata.project = String(val).trim();
}
break;
case "status":
if (filter.condition === "is") {
metadata.status = String(val).trim();
}
break;
case "tags":
if (filter.condition === "contains" || filter.condition === "is") {
if (!metadata.tags) {
metadata.tags = [];
}
const tagVal = String(val).trim();
if (tagVal && !metadata.tags.includes(tagVal)) {
metadata.tags.push(tagVal);
}
}
break;
case "dueDate":
if (filter.condition === "is") {
const date = parseFilterDate(val);
if (date) {
metadata.dueDate = date;
}
}
break;
case "startDate":
if (filter.condition === "is") {
const date = parseFilterDate(val);
if (date) {
metadata.startDate = date;
}
}
break;
case "scheduledDate":
if (filter.condition === "is") {
const date = parseFilterDate(val);
if (date) {
metadata.scheduledDate = date;
}
}
break;
}
}
/**
* Merges source metadata into target metadata.
* Single-value fields are overwritten; multi-value fields (tags) are merged.
*/
function mergeMetadata(target: TaskMetadata, source: TaskMetadata): void {
// Single-value fields: overwrite
if (source.priority !== undefined) target.priority = source.priority;
if (source.project !== undefined) target.project = source.project;
if (source.status !== undefined) target.status = source.status;
if (source.context !== undefined) target.context = source.context;
if (source.startDate !== undefined) target.startDate = source.startDate;
if (source.dueDate !== undefined) target.dueDate = source.dueDate;
if (source.scheduledDate !== undefined)
target.scheduledDate = source.scheduledDate;
// Multi-value fields: merge and deduplicate
if (source.tags) {
if (!target.tags) {
target.tags = [];
}
source.tags.forEach((t) => {
if (!target.tags!.includes(t)) {
target.tags!.push(t);
}
});
}
}
/**
* Parses a date string from filter value.
* Supports ISO format and YYYY-MM-DD.
*/
function parseFilterDate(dateStr: unknown): Date | undefined {
if (typeof dateStr !== "string") {
return undefined;
}
// Strict parsing for ISO and YYYY-MM-DD formats
const m = moment(dateStr, ["YYYY-MM-DD", "YYYY-MM-DDTHH:mm"], true);
if (m.isValid()) {
return m.toDate();
}
return undefined;
}

View file

@ -1,5 +1,11 @@
import { TaskFilterComponent } from "./ViewTaskFilter";
import { ViewTaskFilterModal } from "./ViewTaskFilterModal";
import { ViewTaskFilterPopover } from "./ViewTaskFilterPopover";
import { extractMetadataFromFilter } from "./filter-metadata-extractor";
export { TaskFilterComponent, ViewTaskFilterModal, ViewTaskFilterPopover };
export {
TaskFilterComponent,
ViewTaskFilterModal,
ViewTaskFilterPopover,
extractMetadataFromFilter,
};