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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
data.json
49 changes: 25 additions & 24 deletions main.js

Large diffs are not rendered by default.

67 changes: 44 additions & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ const ZH: Record<string, string> = {
'modal.groupExample': '例如:自定义、项目、团队',
'modal.groupIdDesc': '分组的唯一标识符(仅限小写字母、数字、连字符)',
'modal.groupIdExample': '例如:custom-group',
'modal.groupNoteProperty': '笔记筛选属性',
'modal.groupNotePropertyDesc': '设置后,此分组会显示匹配该 frontmatter 属性值的笔记下拉框。Tasks 查询中的 query.file 将指向所选笔记。',
'modal.groupNotePropertyExample': '例如:project',
'modal.groupNoteValues': '属性值筛选',
'modal.groupNoteValuesDesc': '仅显示该属性包含所选值之一的笔记。不选则匹配任何非空值。',
'modal.groupNoteValuesAny': '任意非空值',
'modal.groupNoteValuesNone': '没有笔记使用此属性',
'modal.icon': '图标',
'modal.iconDesc': '从下拉列表中选择,或搜索任意 Obsidian 图标名(不在列表里也能用)',
'modal.iconExample': '例如:folder',
Expand Down Expand Up @@ -118,6 +125,10 @@ const ZH: Record<string, string> = {
'nav.about': '关于',
'nav.general': '全局',
'nav.groups': '分组',
'view.group.selectNote': '选择 {group} 笔记',
'view.group.noMatchingNotes': '没有包含该属性值的笔记',
'view.group.limit': '上限',
'view.group.limitDesc': '此分组每个查询最多显示的任务数(留空则使用查询自身设置)',
'panel.collapse': '收起 ↑',
'panel.doneToday': '今日完成',
'panel.doneWeek': '本周完成',
Expand Down Expand Up @@ -275,6 +286,13 @@ const EN: Record<string, string> = {
'modal.groupExample': 'e.g. Custom, Project, Team',
'modal.groupIdDesc': 'Unique identifier of the group (lowercase letters, digits and hyphens only)',
'modal.groupIdExample': 'e.g. custom-group',
'modal.groupNoteProperty': 'Note selector property',
'modal.groupNotePropertyDesc': 'When set, this group shows a dropdown of notes matching this frontmatter property. Tasks queries will use the selected note as query.file.',
'modal.groupNotePropertyExample': 'e.g. project',
'modal.groupNoteValues': 'Property values',
'modal.groupNoteValuesDesc': 'Only show notes whose property contains one of the selected values. Leave empty to match any non-empty value.',
'modal.groupNoteValuesAny': 'Any non-empty value',
'modal.groupNoteValuesNone': 'No notes use this property',
'modal.icon': 'Icon',
'modal.iconDesc': 'Pick from the dropdown, or search any Obsidian icon name (custom names work too)',
'modal.iconExample': 'e.g. folder',
Expand Down Expand Up @@ -309,6 +327,10 @@ const EN: Record<string, string> = {
'nav.about': 'About',
'nav.general': 'General',
'nav.groups': 'Groups',
'view.group.selectNote': 'Select {group} note',
'view.group.noMatchingNotes': 'No notes with this property value',
'view.group.limit': 'Limit',
'view.group.limitDesc': 'Maximum tasks per query in this group (empty uses the query\'s own limit)',
'panel.collapse': 'Collapse ↑',
'panel.doneToday': 'Done today',
'panel.doneWeek': 'Done this week',
Expand Down
108 changes: 101 additions & 7 deletions src/modals/board-config-modal.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { App, Notice } from 'obsidian';
import type TaskViewsPlugin from '../main';
import type { TabConfig, TabGroup } from '../types';
import { getTabGroup } from '../types';
import type { TabConfig, TabGroup, TabGroupConfig } from '../types';
import { getFrontmatterValue, getTabGroup, normalizeFrontmatterValues } from '../types';
import { TAB_GROUPS } from '../settings';
import { TFEditModal } from './tf-edit-modal';
import { ConfirmModal } from './confirm-modal';
Expand Down Expand Up @@ -297,16 +297,18 @@ export class TabEditModal extends TFEditModal {
* 分组编辑弹窗
*/
export class GroupEditModal extends TFEditModal {
private group: { id: string; label: string; icon: string };
private onSave: (group: { id: string; label: string; icon: string }) => void;
private group: TabGroupConfig;
private onSave: (group: TabGroupConfig) => void;
private valuesHost: HTMLElement | null = null;
private valuesRefreshTimer: number | null = null;

constructor(
app: App,
group: { id: string; label: string; icon: string },
onSave: (group: { id: string; label: string; icon: string }) => void,
group: TabGroupConfig,
onSave: (group: TabGroupConfig) => void,
) {
super(app);
this.group = { ...group };
this.group = { ...group, frontmatterValues: group.frontmatterValues ? [...group.frontmatterValues] : undefined };
this.onSave = onSave;
this.forceSave = !group.id;
}
Expand Down Expand Up @@ -357,6 +359,98 @@ export class GroupEditModal extends TFEditModal {
this.markDirty();
},
});

this.addField({
key: 'frontmatterProperty',
label: t('modal.groupNoteProperty'),
description: t('modal.groupNotePropertyDesc'),
type: 'text',
placeholder: t('modal.groupNotePropertyExample'),
value: this.group.frontmatterProperty ?? '',
onChange: (v) => {
const property = (v as string).trim();
if (property !== (this.group.frontmatterProperty ?? '')) {
this.group.frontmatterValues = undefined;
}
this.group.frontmatterProperty = property || undefined;
this.markDirty();
this.scheduleValuesRefresh();
},
});

this.valuesHost = this.formEl?.createDiv({ cls: 'tf-field' }) ?? null;
this.renderValuesPicker();
}

onClose(): void {
if (this.valuesRefreshTimer !== null) window.clearTimeout(this.valuesRefreshTimer);
super.onClose();
}

private scheduleValuesRefresh(): void {
if (this.valuesRefreshTimer !== null) window.clearTimeout(this.valuesRefreshTimer);
this.valuesRefreshTimer = window.setTimeout(() => {
this.valuesRefreshTimer = null;
this.renderValuesPicker();
}, 300);
}

private collectPropertyValues(property: string): string[] {
const values = new Set<string>();
for (const file of this.app.vault.getMarkdownFiles()) {
const raw = getFrontmatterValue(this.app.metadataCache.getFileCache(file)?.frontmatter, property);
for (const value of normalizeFrontmatterValues(raw)) values.add(value);
}
return [...values].sort((a, b) => a.localeCompare(b));
}

private renderValuesPicker(): void {
const host = this.valuesHost;
if (!host) return;
host.empty();
const property = this.group.frontmatterProperty?.trim();
if (!property) {
host.hide();
return;
}
host.show();

const labelText = host.createEl('label', { cls: 'tf-field-label' }).createDiv({ cls: 'tf-field-label-text' });
labelText.createSpan({ cls: 'tf-field-name', text: t('modal.groupNoteValues') });
labelText.createSpan({ cls: 'tf-field-desc', text: t('modal.groupNoteValuesDesc') });

const selected = new Set(this.group.frontmatterValues ?? []);
// Keep previously saved values visible even if no note currently uses them
const options = [...new Set([...this.collectPropertyValues(property), ...selected])]
.sort((a, b) => a.localeCompare(b));

const dropdown = host.createDiv({ cls: 'tf-field-input-wrapper' }).createEl('details', { cls: 'tf-multiselect' });
const summary = dropdown.createEl('summary', { cls: 'tf-field-select tf-multiselect-summary' });
const updateSummary = () => {
summary.textContent = selected.size
? [...selected].join(', ')
: t('modal.groupNoteValuesAny');
};
updateSummary();

const list = dropdown.createDiv({ cls: 'tf-multiselect-list' });
if (options.length === 0) {
list.createDiv({ cls: 'tf-multiselect-empty', text: t('modal.groupNoteValuesNone') });
return;
}
for (const value of options) {
const row = list.createEl('label', { cls: 'tf-multiselect-option' });
const checkbox = row.createEl('input', { attr: { type: 'checkbox' } });
checkbox.checked = selected.has(value);
row.createSpan({ text: value });
checkbox.addEventListener('change', () => {
if (checkbox.checked) selected.add(value);
else selected.delete(value);
this.group.frontmatterValues = selected.size ? [...selected] : undefined;
updateSummary();
this.markDirty();
});
}
}

protected doSave(): void {
Expand Down
4 changes: 2 additions & 2 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { App, PluginSettingTab, TFile, setIcon } from 'obsidian';
import type TaskViewsPlugin from './main';
import type { Board, OpenLocation, TabConfig, TabGroup, TaskViewsSettings } from './types';
import type { Board, OpenLocation, TabConfig, TabGroup, TabGroupConfig, TaskViewsSettings } from './types';
import {
clampCoverPosition,
COVER_DIR,
Expand Down Expand Up @@ -1380,7 +1380,7 @@ export class SettingsManager {
}

/** 打开编辑分组的模态框 */
private openGroupEditModal(group: { id: string; label: string; icon: string }): void {
private openGroupEditModal(group: TabGroupConfig): void {
const modal = new GroupEditModal(this.plugin.app, group, (updatedGroup) => {
const index = this.plugin.settings.data.groups.findIndex((g) => g.id === group.id);
if (index === -1) return;
Expand Down
35 changes: 34 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,39 @@ export interface TabGroupConfig {
id: string;
label: string;
icon: string;
frontmatterProperty?: string;
/** Empty/undefined means any non-empty value matches */
frontmatterValues?: string[];
/** Appended as `limit N` to every query in this group */
taskLimit?: number;
}

/** Read a frontmatter property, falling back to a case-insensitive key match. */
export function getFrontmatterValue(frontmatter: Record<string, unknown> | undefined, property: string): unknown {
if (!frontmatter) return undefined;
if (property in frontmatter) return frontmatter[property];
const lower = property.toLowerCase();
const key = Object.keys(frontmatter).find((k) => k.toLowerCase() === lower);
return key === undefined ? undefined : frontmatter[key];
}

/** Flatten a frontmatter value (list, comma-separated string, or wikilink) into non-empty strings. */
export function normalizeFrontmatterValues(value: unknown): string[] {
if (value === undefined || value === null) return [];
if (Array.isArray(value)) return value.flatMap((item) => normalizeFrontmatterValues(item));
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') return [];
const parts = typeof value === 'string' ? value.split(/,(?![^[]*\]\])/) : [String(value)];
return parts
.map((part) => part.trim().replace(/^\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]$/, '$1').trim())
.filter((part) => part !== '');
}

export function matchesFrontmatterFilter(value: unknown, wanted: string[] | undefined): boolean {
const values = normalizeFrontmatterValues(value);
if (values.length === 0) return false;
if (!wanted || wanted.length === 0) return true;
const wantedSet = new Set(wanted.map((item) => item.toLowerCase()));
return values.some((item) => wantedSet.has(item.toLowerCase()));
}

/**
Expand Down Expand Up @@ -72,7 +105,7 @@ export interface TaskViewsData {
excludedFolders: string;
globalTabs: TabConfig[]; // Global default tabs
boards: Board[]; // All boards (including default dashboard)
groups: { id: string, label: string, icon: string }[]; // Available tab groups
groups: TabGroupConfig[]; // Available tab groups
/* ── 头部(工作台头部横幅)───────────────────────────
对应 Lyra 的 wbTitle + banner:名字可自定义,封面图可换可移除。 */
workbenchTitle: string; // 头部大字,也是「工作台名」
Expand Down
Loading
Loading