The original plan had a critical bug: after stripping the Bases property prefix (note./task./file.), it never tried FieldMapper on the stripped name. Example scenario: - User configures: "state" → "status" (in FieldMapper) - Bases provides: "note.state" - Bug: Would return "state" instead of "status" Fixed logic now: 1. Try FieldMapper on full ID (edge case) 2. Strip prefix: "note.state" → "state" 3. Try FieldMapper on stripped name: "state" → "status" 4. Return internal field name: "status" This ensures TaskCard properly recognizes user-configured property names and renders the appropriate UI (e.g., status checkbox for "state" property).
43 KiB
Bases Integration Refactor Plan
Executive Summary
This document outlines a comprehensive refactor of the TaskNotes Bases integration to:
- Eliminate all internal Bases API usage - migrate fully to Bases 1.10.0+ public API
- Extract duplicated patterns - reduce ~500 lines of duplicate code across 3 views
- Improve architecture - introduce clean class-based design with clear separation of concerns
- Fix TaskCard/FieldMapper coordination - unify property mapping and rendering systems
Current State Analysis
Critical Issues
1. Internal API Dependencies
All three views (Task List, Kanban, Calendar) rely on internal Bases API:
controller.getViewConfig()- deprecated internal methodquery.getViewConfig("order")- internal query accesscontroller.query.views- undocumented internal structurefullCfg.order,fullCfg.groupBy,fullCfg.sort- internal config objects
Impact: Will break when Bases plugin updates. Already seeing issues where public API and internal API return different results.
2. Massive Code Duplication
Identical patterns repeated across all 3 view factories:
Data Extraction (~30 lines × 3 files = 90 lines):
// Repeated in base-view-factory.ts, kanban-view.ts, calendar-view.ts
const extractDataItems = (viewContext?: any): BasesDataItem[] => {
const dataItems: BasesDataItem[] = [];
// ... 25 lines of identical logic
};
Lifecycle Methods (~80 lines × 3 files = 240 lines):
// Repeated in all view factories
const viewObject = {
load() { /* ... */ },
unload() { /* ... */ },
refresh() { /* ... */ },
onDataUpdated() { /* ... */ },
destroy() { /* ... */ },
getEphemeralState() { /* ... */ },
setEphemeralState() { /* ... */ },
focus() { /* ... */ }
};
Config Access (~40 lines × 3 files = 120 lines):
- Mixed public/internal API access patterns
- Inconsistent fallback behavior
- Duplicate logging
3. TaskCard/FieldMapper Coordination Issues
Problem: Two separate systems for property management:
System 1: FieldMapper (src/services/FieldMapper.ts)
- Maps internal field names ↔ user-configured property names
- Example:
"status"(internal) ↔"task-status"(user config)
System 2: TaskCard (src/ui/TaskCard.ts)
PROPERTY_EXTRACTORS- extracts property values from TaskInfoPROPERTY_RENDERERS- renders properties to DOMmapBasesPropertyToTaskCardProperty()- converts Bases property IDs
Coordination Gap:
// In Bases views, we do:
const mapped = mapBasesPropertyToTaskCardProperty("note.status", plugin);
// Returns: "status"
// But TaskCard doesn't check FieldMapper!
PROPERTY_EXTRACTORS["status"](task); // Uses hardcoded "status"
// User has configured: status → "task-status" in frontmatter
// Result: Property not found, renders as "Unknown property"
Root Cause: Property mapping happens at 3 different levels:
- Bases property ID → TaskCard property ID (
mapBasesPropertyToTaskCardProperty) - TaskCard property ID → Internal field name (not happening!)
- Internal field name → User-configured property name (
FieldMapper)
4. Inconsistent Property Mapping
In calendar-view.ts: Uses shared helper
visibleProperties = basesProperties.map(propId =>
mapBasesPropertyToTaskCardProperty(propId, plugin)
);
In kanban-view.ts: Manual prefix stripping
// Lines 413-425: Manual implementation instead of using helper
if (propertyId.startsWith("note.")) {
propertyId = propertyId.substring(5);
} else if (propertyId.startsWith("task.")) {
propertyId = propertyId.substring(5);
}
Files Requiring Changes
Core Files (Must Change)
src/bases/api.ts- Bases API interfacessrc/bases/helpers.ts- Data transformationsrc/bases/group-by.ts- Group extractionsrc/bases/sorting.ts- Sort extractionsrc/bases/base-view-factory.ts- Task List viewsrc/bases/kanban-view.ts- Kanban viewsrc/bases/calendar-view.ts- Calendar viewsrc/ui/TaskCard.ts- Property renderingsrc/services/FieldMapper.ts- Field mapping
Supporting Files (May Change)
src/bases/registration.ts- View registrationsrc/bases/group-ordering.ts- Group sorting
Proposed Architecture
Phase 1: Create Core Abstractions
1.1: BasesDataAdapter Class
Purpose: Centralize all Bases data access using public API only.
File: src/bases/BasesDataAdapter.ts (NEW)
import { BasesView, BasesQueryResult, BasesEntry, BasesEntryGroup, BasesPropertyId } from "obsidian";
import { BasesDataItem } from "./types";
/**
* Adapter for accessing Bases data using public API (1.10.0+).
* Eliminates all internal API dependencies.
*/
export class BasesDataAdapter {
constructor(private basesView: BasesView) {}
/**
* Extract all data items from Bases query result.
* Uses public API: basesView.data.data
*/
extractDataItems(): BasesDataItem[] {
const entries = this.basesView.data.data;
return entries.map(entry => ({
key: entry.file.path,
data: entry,
file: entry.file,
path: entry.file.path,
properties: this.extractEntryProperties(entry),
basesData: entry
}));
}
/**
* Get grouped data from Bases.
* Uses public API: basesView.data.groupedData
*
* Note: Returns pre-grouped data. Bases has already applied groupBy configuration.
*/
getGroupedData(): BasesEntryGroup[] {
return this.basesView.data.groupedData;
}
/**
* Check if data is actually grouped (not just wrapped in single group).
*/
isGrouped(): boolean {
const groups = this.basesView.data.groupedData;
if (groups.length !== 1) return true;
const singleGroup = groups[0];
return singleGroup.hasKey(); // False if key is null/undefined
}
/**
* Get sort configuration.
* Uses public API: basesView.config.getSort()
*
* Note: Data from basesView.data is already pre-sorted.
* This is only needed for custom sorting logic.
*/
getSortConfig() {
return this.basesView.config.getSort();
}
/**
* Get visible property IDs.
* Uses public API: basesView.config.getOrder()
*/
getVisiblePropertyIds(): BasesPropertyId[] {
return this.basesView.config.getOrder();
}
/**
* Get display name for a property.
* Uses public API: basesView.config.getDisplayName()
*/
getPropertyDisplayName(propertyId: BasesPropertyId): string {
return this.basesView.config.getDisplayName(propertyId);
}
/**
* Get property value from a Bases entry.
* Uses public API: entry.getValue()
*/
getPropertyValue(entry: BasesEntry, propertyId: BasesPropertyId): any {
try {
const value = entry.getValue(propertyId);
return this.convertValueToNative(value);
} catch (e) {
console.warn(`[BasesDataAdapter] Failed to get property ${propertyId}:`, e);
return null;
}
}
/**
* Convert Bases Value object to native JavaScript value.
* Handles: PrimitiveValue, ListValue, DateValue, FileValue, NullValue, etc.
*/
private convertValueToNative(value: any): any {
if (value == null || value.constructor?.name === "NullValue") {
return null;
}
// PrimitiveValue (string, number, boolean)
if (typeof value.data !== "undefined") {
return value.data;
}
// ListValue
if (typeof value.length === "function") {
const len = value.length();
const result = [];
for (let i = 0; i < len; i++) {
const item = value.at(i);
result.push(this.convertValueToNative(item));
}
return result;
}
// DateValue
if (value.constructor?.name === "DateValue" && value.toISOString) {
return value.toISOString();
}
// FileValue
if (value.file) {
return value.file.path;
}
// Fallback: try to extract raw data
return value;
}
/**
* Convert group key Value to display string.
*/
convertGroupKeyToString(key: any): string {
if (key == null || !key.hasKey?.()) {
return "Unknown";
}
const value = this.convertValueToNative(key);
if (value == null) return "Unknown";
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
if (typeof value === "boolean") return value ? "True" : "False";
if (Array.isArray(value)) return value.join(", ");
return String(value);
}
/**
* Extract properties from a BasesEntry.
* Handles frontmatter and computed properties.
*/
private extractEntryProperties(entry: BasesEntry): Record<string, any> {
// Use entry's built-in property access
const properties: Record<string, any> = {};
// Try to get all visible properties
const visibleProps = this.getVisiblePropertyIds();
for (const propId of visibleProps) {
try {
const value = this.getPropertyValue(entry, propId);
if (value !== null) {
// Strip property type prefix for storage
const name = this.stripPropertyPrefix(propId);
properties[name] = value;
}
} catch (e) {
// Property doesn't exist or can't be evaluated - skip
}
}
return properties;
}
/**
* Remove property type prefix (note., file., formula.)
*/
private stripPropertyPrefix(propertyId: BasesPropertyId): string {
const parts = propertyId.split(".");
if (parts.length > 1 && ["note", "file", "formula"].includes(parts[0])) {
return parts.slice(1).join(".");
}
return propertyId;
}
}
Benefits:
- Single source of truth for Bases data access
- All public API access in one place
- Easy to update when Bases API changes
- Clear conversion between Bases types and TaskNotes types
1.2: PropertyMappingService Class
Purpose: Unify all property mapping logic (Bases → TaskCard → Internal → User Config).
File: src/bases/PropertyMappingService.ts (NEW)
import { BasesPropertyId } from "obsidian";
import TaskNotesPlugin from "../main";
import { FieldMapper } from "../services/FieldMapper";
/**
* Complete property mapping chain:
* 1. Bases Property ID → Internal Field Name (via FieldMapper)
* 2. Internal Field Name → User-Configured Property Name (for frontmatter I/O)
*
* Example flow (user configured "state" as their status property):
* - User adds "note.state" column in Bases
* - basesToInternal("note.state"):
* 1. Strip prefix: "note.state" → "state"
* 2. FieldMapper.fromUserField("state") → "status"
* 3. Returns: "status" (internal field name)
* - TaskCard uses PROPERTY_EXTRACTORS["status"] to get task.status
* - TaskCard uses PROPERTY_RENDERERS["status"] to render status UI (checkbox, dot)
*/
export class PropertyMappingService {
constructor(
private plugin: TaskNotesPlugin,
private fieldMapper: FieldMapper
) {}
/**
* Map Bases property ID to the internal field name used by TaskInfo.
* This is the complete chain: Bases → TaskCard → Internal.
*
* Example: User configures "state" as their status property
* - Bases gives us: "note.state"
* - We strip prefix: "state"
* - FieldMapper maps: "state" → "status"
* - Returns: "status" (internal field name)
*
* @param basesPropertyId - Property ID from Bases (e.g., "note.state", "file.name")
* @returns Internal field name (e.g., "status", "title")
*/
basesToInternal(basesPropertyId: BasesPropertyId): string {
// Step 1: Try custom field mapping on full ID first (edge case: user configured "note.state")
if (this.fieldMapper) {
const internalFieldName = this.fieldMapper.fromUserField(basesPropertyId);
if (internalFieldName) {
return this.applySpecialTransformations(internalFieldName);
}
}
// Step 2: Handle dotted prefixes - strip and try FieldMapper again
if (basesPropertyId.startsWith("note.")) {
const stripped = basesPropertyId.substring(5); // "note.state" → "state"
// Try custom field mapping on stripped name (main case!)
if (this.fieldMapper) {
const internalFieldName = this.fieldMapper.fromUserField(stripped);
if (internalFieldName) {
return this.applySpecialTransformations(internalFieldName);
}
}
// Handle known note properties
if (stripped === "dateCreated") return "dateCreated";
if (stripped === "dateModified") return "dateModified";
if (stripped === "completedDate") return "completedDate";
return this.applySpecialTransformations(stripped);
}
if (basesPropertyId.startsWith("task.")) {
const stripped = basesPropertyId.substring(5);
// Try custom field mapping on stripped name
if (this.fieldMapper) {
const internalFieldName = this.fieldMapper.fromUserField(stripped);
if (internalFieldName) {
return this.applySpecialTransformations(internalFieldName);
}
}
return this.applySpecialTransformations(stripped);
}
if (basesPropertyId.startsWith("file.")) {
// Map file properties to TaskInfo equivalents
if (basesPropertyId === "file.ctime") return "dateCreated";
if (basesPropertyId === "file.mtime") return "dateModified";
if (basesPropertyId === "file.name") return "title";
if (basesPropertyId === "file.basename") return "title";
const stripped = basesPropertyId.substring(5);
return this.applySpecialTransformations(stripped);
}
// Step 3: Keep formula properties unchanged
if (basesPropertyId.startsWith("formula.")) {
return basesPropertyId;
}
// Step 4: Direct property (no prefix) - apply special transformations
return this.applySpecialTransformations(basesPropertyId);
}
/**
* Map internal field name to user-configured property name.
* This is used when reading/writing frontmatter.
*
* @param internalFieldName - Internal field (e.g., "status")
* @returns User-configured property name (e.g., "task-status")
*/
internalToUserProperty(internalFieldName: string): string {
return this.fieldMapper.toUserField(internalFieldName);
}
/**
* Map user-configured property name back to internal field name.
*
* @param userPropertyName - User's property name (e.g., "task-status")
* @returns Internal field name (e.g., "status")
*/
userPropertyToInternal(userPropertyName: string): string {
return this.fieldMapper.fromUserField(userPropertyName) || userPropertyName;
}
/**
* Complete mapping: Bases property ID → User-configured property name.
* Use this when you need to read/write frontmatter based on Bases config.
*/
basesToUserProperty(basesPropertyId: BasesPropertyId): string {
const internal = this.basesToInternal(basesPropertyId);
return this.internalToUserProperty(internal);
}
/**
* Apply TaskCard-specific property transformations.
* These are display-only transformations that don't affect data storage.
*/
private applySpecialTransformations(propId: string): string {
// timeEntries → totalTrackedTime (show computed total instead of raw array)
if (propId === "timeEntries") return "totalTrackedTime";
// blockedBy → blocked (show status pill instead of dependency list)
if (propId === "blockedBy") return "blocked";
// Keep everything else unchanged
return propId;
}
/**
* Check if a Bases property list includes blockedBy (in any form).
* Used to determine if dependency pills should be shown.
*/
hasBlockedByProperty(basesPropertyIds: BasesPropertyId[]): boolean {
return basesPropertyIds.some(id =>
id === "blockedBy" ||
id === "note.blockedBy" ||
id === "task.blockedBy"
);
}
/**
* Map a list of Bases property IDs to TaskCard property IDs.
* Filters out computed dependency properties unless explicitly requested.
*
* @param basesPropertyIds - Property IDs from Bases config
* @returns Mapped property IDs for TaskCard rendering
*/
mapVisibleProperties(basesPropertyIds: BasesPropertyId[]): string[] {
const hasBlockedBy = this.hasBlockedByProperty(basesPropertyIds);
return basesPropertyIds
.map(id => this.basesToInternal(id))
.filter(propId => {
// Filter out computed dependency properties unless explicitly requested
if (propId === "blocked" || propId === "blocking") {
return hasBlockedBy;
}
return true;
});
}
}
Benefits:
- Complete property mapping chain in one place
- Clear separation of concerns (Bases → TaskCard → Internal → User)
- Fixes TaskCard/FieldMapper coordination gap
- Easy to trace property transformations
- Eliminates duplicate mapping logic
1.3: BasesViewBase Abstract Class
Purpose: Extract common lifecycle and rendering patterns shared by all 3 views.
File: src/bases/BasesViewBase.ts (NEW)
import { Component, BasesView, QueryController } from "obsidian";
import TaskNotesPlugin from "../main";
import { BasesDataAdapter } from "./BasesDataAdapter";
import { PropertyMappingService } from "./PropertyMappingService";
import { TaskInfo, EVENT_TASK_UPDATED } from "../types";
/**
* Abstract base class for all TaskNotes Bases views.
* Handles common lifecycle, data extraction, and event management.
*/
export abstract class BasesViewBase extends BasesView {
protected plugin: TaskNotesPlugin;
protected dataAdapter: BasesDataAdapter;
protected propertyMapper: PropertyMappingService;
protected containerEl: HTMLElement;
protected rootElement: HTMLElement | null = null;
protected taskUpdateListener: any = null;
protected updateDebounceTimer: number | null = null;
constructor(controller: QueryController, containerEl: HTMLElement, plugin: TaskNotesPlugin) {
super(controller);
this.plugin = plugin;
this.containerEl = containerEl;
this.dataAdapter = new BasesDataAdapter(this);
this.propertyMapper = new PropertyMappingService(plugin, plugin.fieldMapper);
}
/**
* Lifecycle: Called when view is first loaded.
*/
load(): void {
this.setupContainer();
this.setupTaskUpdateListener();
this.render();
}
/**
* Lifecycle: Called when view is unloaded (tab closed, etc).
*/
unload(): void {
this.cleanup();
}
/**
* Lifecycle: Called when Bases data changes.
* Public API callback - Bases will call this automatically.
*/
onDataUpdated(): void {
this.render();
}
/**
* Lifecycle: Save ephemeral state (scroll position, etc).
*/
getEphemeralState(): any {
return {
scrollTop: this.rootElement?.scrollTop || 0
};
}
/**
* Lifecycle: Restore ephemeral state.
*/
setEphemeralState(state: any): void {
if (!state || !this.rootElement || !this.rootElement.isConnected) return;
try {
if (state.scrollTop !== undefined) {
this.rootElement.scrollTop = state.scrollTop;
}
} catch (e) {
console.debug("[TaskNotes][Bases] Failed to restore ephemeral state:", e);
}
}
/**
* Lifecycle: Focus this view.
*/
focus(): void {
try {
if (this.rootElement?.isConnected && typeof this.rootElement.focus === "function") {
this.rootElement.focus();
}
} catch (e) {
console.debug("[TaskNotes][Bases] Failed to focus view:", e);
}
}
/**
* Lifecycle: Refresh/re-render the view.
*/
refresh(): void {
this.render();
}
/**
* Setup container element for this view.
*/
protected setupContainer(): void {
this.containerEl.empty();
const root = document.createElement("div");
root.className = `tn-bases-integration tasknotes-plugin tasknotes-container tn-${this.type}`;
root.tabIndex = -1; // Make focusable without adding to tab order
this.containerEl.appendChild(root);
this.rootElement = root;
}
/**
* Setup listener for real-time task updates.
* Allows selective updates without full re-render.
*/
protected setupTaskUpdateListener(): void {
if (this.taskUpdateListener) return;
this.taskUpdateListener = this.plugin.emitter.on(EVENT_TASK_UPDATED, async (eventData: any) => {
try {
const updatedTask = eventData?.task || eventData?.taskInfo;
if (!updatedTask?.path) return;
// Check if this task is in our current view
const dataItems = this.dataAdapter.extractDataItems();
const isRelevant = dataItems.some(item => item.path === updatedTask.path);
if (isRelevant) {
await this.handleTaskUpdate(updatedTask);
}
} catch (error) {
console.error("[TaskNotes][Bases] Error in task update handler:", error);
this.debouncedRefresh();
}
});
}
/**
* Cleanup all resources.
*/
protected cleanup(): void {
// Clean up task update listener
if (this.taskUpdateListener) {
this.plugin.emitter.offref(this.taskUpdateListener);
this.taskUpdateListener = null;
}
// Clean up debounce timer
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
this.updateDebounceTimer = null;
}
// Clean up DOM
if (this.rootElement) {
this.rootElement.remove();
this.rootElement = null;
}
}
/**
* Debounced refresh to prevent multiple rapid re-renders.
*/
protected debouncedRefresh(): void {
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
}
this.updateDebounceTimer = window.setTimeout(() => {
this.render();
this.updateDebounceTimer = null;
}, 150);
}
/**
* Get visible properties for rendering task cards.
*/
protected getVisibleProperties(): string[] {
const basesPropertyIds = this.dataAdapter.getVisiblePropertyIds();
let visibleProperties = this.propertyMapper.mapVisibleProperties(basesPropertyIds);
// Fallback to plugin defaults if no properties configured
if (!visibleProperties || visibleProperties.length === 0) {
visibleProperties = this.plugin.settings.defaultVisibleProperties || [
"due", "scheduled", "projects", "contexts", "tags"
];
// Filter out computed properties from defaults
visibleProperties = visibleProperties.filter(
p => p !== "blocked" && p !== "blocking"
);
}
return visibleProperties;
}
// Abstract methods that subclasses must implement
/**
* Render the view with current data.
* Subclasses implement view-specific rendering (list, kanban, calendar).
*/
protected abstract render(): void;
/**
* Handle a single task update for selective rendering.
* Subclasses can implement efficient updates or fall back to full refresh.
*/
protected abstract handleTaskUpdate(task: TaskInfo): Promise<void>;
/**
* The view type identifier.
*/
abstract type: string;
}
Benefits:
- Eliminates 240+ lines of duplicate lifecycle code
- Consistent event handling across all views
- Centralized data access via adapters
- Extensible for custom view logic
Phase 2: Refactor View Implementations
2.1: Refactor Task List View
File: src/bases/TaskListView.ts (RENAME from base-view-factory.ts)
import { QueryController } from "obsidian";
import TaskNotesPlugin from "../main";
import { BasesViewBase } from "./BasesViewBase";
import { TaskInfo } from "../types";
import { identifyTaskNotesFromBasesData } from "./helpers";
export class TaskListView extends BasesViewBase {
type = "tasknoteTaskList";
private itemsContainer: HTMLElement | null = null;
private currentTaskElements = new Map<string, HTMLElement>();
constructor(controller: QueryController, containerEl: HTMLElement, plugin: TaskNotesPlugin) {
super(controller, containerEl, plugin);
}
protected setupContainer(): void {
super.setupContainer();
// Create items container
const itemsContainer = document.createElement("div");
itemsContainer.className = "tn-bases-items-container";
itemsContainer.style.cssText = "margin-top: 12px;";
this.rootElement?.appendChild(itemsContainer);
this.itemsContainer = itemsContainer;
}
protected async render(): Promise<void> {
if (!this.itemsContainer || !this.rootElement) return;
try {
// Extract data using adapter
const dataItems = this.dataAdapter.extractDataItems();
const taskNotes = await identifyTaskNotesFromBasesData(dataItems, this.plugin);
// Clear previous render
this.itemsContainer.empty();
this.currentTaskElements.clear();
if (taskNotes.length === 0) {
this.renderEmptyState();
return;
}
// Check if we have grouped data
if (this.dataAdapter.isGrouped()) {
await this.renderGrouped(taskNotes);
} else {
await this.renderFlat(taskNotes);
}
} catch (error: any) {
console.error("[TaskNotes][TaskListView] Error rendering:", error);
this.renderError(error);
}
}
private async renderFlat(taskNotes: TaskInfo[]): Promise<void> {
const { createTaskCard } = await import("../ui/TaskCard");
const visibleProperties = this.getVisibleProperties();
const cardOptions = {
showCheckbox: false,
showArchiveButton: false,
showTimeTracking: false,
showRecurringControls: true,
groupByDate: false,
};
for (const taskInfo of taskNotes) {
const cardEl = createTaskCard(
taskInfo,
this.plugin,
visibleProperties,
cardOptions
);
this.itemsContainer!.appendChild(cardEl);
this.currentTaskElements.set(taskInfo.path, cardEl);
}
}
private async renderGrouped(taskNotes: TaskInfo[]): Promise<void> {
const { createTaskCard } = await import("../ui/TaskCard");
const visibleProperties = this.getVisibleProperties();
const groups = this.dataAdapter.getGroupedData();
const cardOptions = {
showCheckbox: false,
showArchiveButton: false,
showTimeTracking: false,
showRecurringControls: true,
groupByDate: false,
};
for (const group of groups) {
// Create group header
const groupHeader = document.createElement("div");
groupHeader.className = "tn-bases-group-header";
const groupTitle = this.dataAdapter.convertGroupKeyToString(group.key);
groupHeader.textContent = groupTitle;
this.itemsContainer!.appendChild(groupHeader);
// Create group container
const groupContainer = document.createElement("div");
groupContainer.className = "tn-bases-group-container";
this.itemsContainer!.appendChild(groupContainer);
// Get tasks for this group
const groupPaths = new Set(group.entries.map(e => e.file.path));
const groupTasks = taskNotes.filter(t => groupPaths.has(t.path));
// Render tasks in group
for (const taskInfo of groupTasks) {
const cardEl = createTaskCard(
taskInfo,
this.plugin,
visibleProperties,
cardOptions
);
groupContainer.appendChild(cardEl);
this.currentTaskElements.set(taskInfo.path, cardEl);
}
}
}
protected async handleTaskUpdate(task: TaskInfo): Promise<void> {
const taskElement = this.currentTaskElements.get(task.path);
if (taskElement) {
// Selective update - just update this one task card
const { updateTaskCard } = await import("../ui/TaskCard");
const visibleProperties = this.getVisibleProperties();
updateTaskCard(taskElement, task, this.plugin, visibleProperties, {
showDueDate: true,
showCheckbox: false,
showArchiveButton: false,
showTimeTracking: false,
showRecurringControls: true,
targetDate: new Date(),
});
// Add update animation
taskElement.classList.add("task-card--updated");
window.setTimeout(() => {
taskElement.classList.remove("task-card--updated");
}, 1000);
} else {
// Task not visible or newly added - full refresh
this.debouncedRefresh();
}
}
private renderEmptyState(): void {
const emptyEl = document.createElement("div");
emptyEl.className = "tn-bases-empty";
emptyEl.style.cssText = "padding: 20px; text-align: center; color: #666;";
emptyEl.textContent = "No TaskNotes tasks found for this Base.";
this.itemsContainer!.appendChild(emptyEl);
}
private renderError(error: Error): void {
const errorEl = document.createElement("div");
errorEl.className = "tn-bases-error";
errorEl.style.cssText = "padding: 20px; color: #d73a49; background: #ffeaea; border-radius: 4px; margin: 10px 0;";
errorEl.textContent = `Error loading tasks: ${error.message || "Unknown error"}`;
this.itemsContainer!.appendChild(errorEl);
}
protected cleanup(): void {
super.cleanup();
this.currentTaskElements.clear();
this.itemsContainer = null;
}
}
// Factory function for Bases registration
export function buildTaskListViewFactory(plugin: TaskNotesPlugin) {
return function(controller: QueryController, containerEl: HTMLElement) {
return new TaskListView(controller, containerEl, plugin);
};
}
Benefits:
- Reduced from 414 lines → ~180 lines
- Inherits common lifecycle from base class
- Uses adapters for all data access
- Clean separation of flat vs grouped rendering
2.2: Refactor Kanban View
File: src/bases/KanbanView.ts (RENAME from kanban-view.ts)
Similar structure to TaskListView but with kanban-specific rendering:
export class KanbanView extends BasesViewBase {
type = "tasknoteKanban";
// ... kanban-specific implementation
protected async render(): Promise<void> {
// Use this.dataAdapter.getGroupedData() for columns
// Use this.propertyMapper.mapVisibleProperties() for cards
// Eliminate manual prefix stripping (lines 413-425 in current file)
}
}
Key Changes:
- Remove duplicate
extractDataItems- usethis.dataAdapter.extractDataItems() - Remove manual property prefix stripping - use
this.propertyMapper.basesToInternal() - Use
this.dataAdapter.getGroupedData()for column data - Inherit lifecycle from
BasesViewBase
2.3: Refactor Calendar View
File: src/bases/CalendarView.ts (RENAME from calendar-view.ts)
export class CalendarView extends BasesViewBase {
type = "tasknoteCalendar";
private calendar: Calendar | null = null;
// ... calendar-specific implementation
protected async render(): Promise<void> {
// Use this.dataAdapter.extractDataItems() for events
// Use this.propertyMapper.mapVisibleProperties() for property-based events
// Use FullCalendar for rendering
}
}
Key Changes:
- Remove duplicate
extractDataItems- use adapter - Use
propertyMapperfor all property conversions - Maintain FullCalendar integration but with cleaner data flow
Phase 3: Update TaskCard Integration
3.1: Update TaskCard Property System
File: src/ui/TaskCard.ts
Current Issue: PROPERTY_EXTRACTORS uses hardcoded property names, doesn't check FieldMapper.
Fix: Modify getPropertyValue() to use FieldMapper:
function getPropertyValue(
task: TaskInfo,
propertyId: string,
plugin: TaskNotesPlugin
): unknown {
try {
// Map display property ID to internal field name
// This handles user-configured property mappings
const internalFieldName = plugin.fieldMapper?.fromUserField(propertyId) || propertyId;
// Use extractors for standard properties (now using internal field names)
if (internalFieldName in PROPERTY_EXTRACTORS) {
return PROPERTY_EXTRACTORS[internalFieldName](task);
}
// Handle user properties
if (propertyId.startsWith("user:")) {
const userFieldId = propertyId.substring(5);
const userFields = plugin.settings.userFields || [];
const userField = userFields.find(f => f.id === userFieldId);
if (userField) {
const userPropName = plugin.fieldMapper?.toUserField(`user:${userFieldId}`);
return task.customProperties?.[userPropName || userFieldId];
}
}
// Handle formula properties from Bases
if (propertyId.startsWith("formula.")) {
return task.customProperties?.[propertyId];
}
// Fallback to custom properties
return task.customProperties?.[propertyId];
} catch (e) {
console.warn(`[TaskCard] Error getting property value for ${propertyId}:`, e);
return undefined;
}
}
Impact: Now TaskCard respects user-configured property names from FieldMapper.
3.2: Update Property Extractors
Update PROPERTY_EXTRACTORS to handle both internal and user-configured names:
const PROPERTY_EXTRACTORS: Record<string, (task: TaskInfo) => any> = {
// Core properties (internal names)
status: (task) => task.status,
priority: (task) => task.priority,
due: (task) => task.due,
scheduled: (task) => task.scheduled,
// ... etc
// Also support common user-configured alternatives
// These will be handled by FieldMapper mapping first
};
Phase 4: Cleanup and Migration
4.1: Remove Deprecated Files
Delete:
src/bases/view-factory.ts- replaced byTaskListView.tssrc/bases/base-view-factory.ts- replaced byTaskListView.ts
Keep but simplify:
src/bases/helpers.ts- KeepidentifyTaskNotesFromBasesData(), remove duplicate logicsrc/bases/group-by.ts- REMOVE (replaced byBasesDataAdapter.getGroupedData())src/bases/sorting.ts- REMOVE (data is pre-sorted by Bases)
4.2: Update Registration
File: src/bases/registration.ts
Update to use new view classes:
import { buildTaskListViewFactory } from "./TaskListView";
import { buildKanbanViewFactory } from "./KanbanView";
import { buildCalendarViewFactory } from "./CalendarView";
// Register views
const taskListFactory = buildTaskListViewFactory(plugin);
const kanbanFactory = buildKanbanViewFactory(plugin);
const calendarFactory = buildCalendarViewFactory(plugin);
await registerBasesView(basesPlugin, "tasknoteTaskList", {
name: "TaskNotes: Task List",
icon: "list",
factory: taskListFactory,
// ... options
});
// etc for kanban and calendar
4.3: Update Imports
Update all imports throughout codebase:
import { BasesDataAdapter } from "./BasesDataAdapter"import { PropertyMappingService } from "./PropertyMappingService"import { BasesViewBase } from "./BasesViewBase"
Migration Strategy
Step 1: Create New Files (Non-Breaking)
- Create
BasesDataAdapter.ts - Create
PropertyMappingService.ts - Create
BasesViewBase.ts - Add tests for new abstractions
Step 2: Refactor Task List View
- Create
TaskListView.tsusing new base class - Test thoroughly with various Bases configurations
- Keep
base-view-factory.tstemporarily for rollback
Step 3: Refactor Kanban View
- Create
KanbanView.tsusing new base class - Test drag-and-drop, grouping, swimlanes
- Keep
kanban-view.tstemporarily for rollback
Step 4: Refactor Calendar View
- Create
CalendarView.tsusing new base class - Test event rendering, drag/drop, all calendar features
- Keep
calendar-view.tstemporarily for rollback
Step 5: Update TaskCard
- Modify
getPropertyValue()to use FieldMapper - Test property rendering with custom field mappings
- Verify formula properties still work
Step 6: Clean Up
- Delete old view factory files
- Remove deprecated helpers
- Remove internal API fallback code
- Update documentation
Testing Checklist
Functional Testing
- Task List view renders correctly
- Kanban view renders columns and cards
- Calendar view shows events correctly
- Grouped views work (all 3 view types)
- Flat (ungrouped) views work
- Property visibility respects user selection
- Custom field mappings work end-to-end
- Drag and drop works (Kanban, Calendar)
- Real-time task updates work
- Formula properties display correctly
- Empty states display correctly
- Error states display correctly
Edge Cases
- Views with no data
- Views with 1000+ tasks (performance)
- Rapid task updates (debouncing)
- Invalid property configurations
- Missing Bases plugin
- View switching
- Multiple bases open simultaneously
Property Mapping
- Standard properties (status, priority, due)
- File properties (file.name, file.mtime)
- Formula properties (formula.customFormula)
- User-configured property names
- Blocked/blocking properties (conditional)
- Time tracking properties (timeEntries → totalTrackedTime)
Success Metrics
Code Quality
- Lines of code: Reduce from ~4000 → ~2500 (40% reduction in duplication)
- Files: Increase from 10 → 12 (better organization)
- Cyclomatic complexity: Reduce average complexity by 30%
- Test coverage: Achieve 80%+ coverage for new classes
Maintainability
- Internal API usage: 0 instances (currently 15+ instances)
- Public API usage: 100% (currently 60%)
- Code duplication: < 5% (currently ~20%)
- Property mapping paths: 1 unified path (currently 3 separate paths)
Performance
- Render time: < 100ms for 1000 tasks (same as current)
- Memory usage: No increase
- Update latency: < 50ms for single task update (selective update)
Risks and Mitigation
Risk 1: Breaking Changes
Risk: Refactor breaks existing functionality
Mitigation:
- Keep old files during migration for rollback
- Comprehensive testing checklist
- Incremental rollout (one view at a time)
- Beta testing with users
Risk 2: Bases API Changes
Risk: Bases public API doesn't provide needed functionality
Mitigation:
- Confirmed all needed APIs exist in obsidian.d.ts
- Document any limitations
- Have fallback strategy for edge cases
- Communicate with Bases developers if needed
Risk 3: Performance Regression
Risk: New abstraction layers slow down rendering
Mitigation:
- Profile before and after
- Optimize hot paths (data extraction, rendering)
- Use caching where appropriate
- Lazy initialization of expensive objects
Timeline
Estimated effort: 3-5 days for experienced developer
- Day 1: Create abstractions (BasesDataAdapter, PropertyMappingService, BasesViewBase)
- Day 2: Refactor Task List and Kanban views
- Day 3: Refactor Calendar view
- Day 4: Update TaskCard, fix property mapping
- Day 5: Testing, cleanup, documentation
Future Improvements
After Refactor
- Add unit tests for all new classes
- Add integration tests for Bases views
- Performance profiling and optimization
- Documentation for custom view development
- Migration guide for users with custom configurations
Potential Enhancements
- View composition: Allow combining multiple views
- Custom view templates: User-defined view configurations
- Property transformers: User-defined property rendering
- Advanced filtering: Client-side filtering on top of Bases queries
- Export functionality: Export view data to CSV/JSON
Conclusion
This refactor will:
- ✅ Eliminate all internal Bases API dependencies
- ✅ Reduce code duplication by 40%
- ✅ Fix TaskCard/FieldMapper coordination issues
- ✅ Establish clean architecture for future development
- ✅ Improve maintainability and testability
- ✅ Preserve all existing functionality
The clean class-based architecture will make it much easier to:
- Add new view types
- Debug property mapping issues
- Adapt to future Bases API changes
- Onboard new developers to the codebase
Recommended approach: Start with Phase 1 (abstractions), then tackle views one at a time with thorough testing between each phase.