feat: Add the function of generating a timeline from metadata

This commit is contained in:
Shanshuimei 2025-05-17 12:12:54 +08:00
parent fa408a28cd
commit 79ab8d05c6
10 changed files with 288 additions and 29 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/main.js
.history/
/data.json
node_modules/

View file

@ -3,17 +3,17 @@
"lineColor": "var(--interactive-accent)",
"itemSpacing": 30,
"cardBackground": "var(--background-primary-alt)",
"cardTextColor": "var(--text-normal)",
"cardTextColor": "#ffffff",
"cardBorderColor": "var(--background-modifier-border)",
"milestoneCardBackground": "#ffffcc",
"milestoneCardTextColor": "var(--text-normal)",
"milestoneCardBackground": "#ffffff",
"milestoneCardTextColor": "#000000",
"milestoneCardBorderColor": "#f0e68c",
"animationDuration": 200,
"dateAttribute": "created",
"fileNamePrefix": "",
"fileNameSuffix": "",
"defaultPosition": "right",
"language": "en-US",
"language": "zh-CN",
"milestoneAttribute": "milestone",
"milestoneValue": "yes"
}

View file

@ -1,7 +1,7 @@
{
"id": "generate-timeline",
"name": "Generate Timeline",
"version": "1.2.3",
"version": "1.2.4",
"minAppVersion": "0.15.0",
"description": "Generate timelines from tag folder or file automatically by any time properties.",
"author": "Shanshuimei",

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "obsidian-timeline-plugin",
"version": "1.2.2",
"version": "1.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {

View file

@ -1,6 +1,6 @@
{
"name": "generate-timeline",
"version": "1.2.3",
"version": "1.2.4",
"description": "Generate timelines from tag, folder or file automatically by any time properties.",
"main": "main.js",
"type": "module",

View file

@ -40,7 +40,7 @@ export class Timeline {
return previewContent.slice(0, 50) + (previewContent.length > 50 ? '...' : '');
}
private async createTimelineItem(file: TFile, tag?: string): Promise<TimelineItem | null> { // tag 参数变为可选
private async createTimelineItem(file: TFile, tag?: string, metadataQuery?: string): Promise<TimelineItem | null> { // tag 和 metadataQuery 参数变为可选
const metadata = this.app.metadataCache.getFileCache(file);
const dateValue = metadata?.frontmatter?.[this.settings.dateAttribute];
@ -108,6 +108,52 @@ export class Timeline {
return this.sortItemsByDate(items);
}
async generateFromMetadata(metadataQuery: string): Promise<TimelineItem[]> {
const allFiles = this.app.vault.getMarkdownFiles();
const items: TimelineItem[] = [];
const queryParts = metadataQuery.split(':').map(p => p.trim());
const queryKey = queryParts[0];
const queryValue = queryParts.length > 1 ? queryParts.slice(1).join(':').trim() : null;
for (const file of allFiles) {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache || !cache.frontmatter) continue;
const frontmatter = cache.frontmatter;
let match = false;
if (queryValue !== null) {
// 检查 key: value 匹配
const fmValue = frontmatter[queryKey];
if (fmValue !== undefined) {
if (Array.isArray(fmValue)) {
// 如果 frontmatter 值是数组,检查数组中是否包含查询值
// 特殊处理 "- 我" 这种带 "- " 前缀的查询值
const cleanedQueryValue = queryValue.startsWith('- ') ? queryValue.substring(2) : queryValue;
if (fmValue.map(String).map(v => v.trim()).includes(cleanedQueryValue)) {
match = true;
}
} else if (String(fmValue).trim() === queryValue) {
match = true;
}
}
} else {
// 只检查 key 是否存在
if (frontmatter.hasOwnProperty(queryKey)) {
match = true;
}
}
if (match) {
const item = await this.createTimelineItem(file, undefined, metadataQuery);
if (item) {
items.push(item);
}
}
}
return this.sortItemsByDate(items);
}
async generateFromFolder(folder: TFolder): Promise<TimelineItem[]> {
if (folder.path === 'timelines') {
return [];
@ -180,8 +226,15 @@ export class Timeline {
return this.sortItemsByDate(items);
}
async generateTimelineMarkdown(items: TimelineItem[], title: string, source: { type: 'tag' | 'folder' | 'file', value: string }): Promise<string> {
let markdown = `---\ngenerated_from: ${source.type}:${source.value}\n---\n\n`;
async generateTimelineMarkdown(items: TimelineItem[], title: string, source: { type: 'tag' | 'folder' | 'file' | 'metadata', value: string }): Promise<string> {
let generatedFromValue = source.value;
if (source.type === 'metadata') {
const parts = source.value.split(':');
const key = parts[0].trim();
const value = parts.slice(1).join(':').trim();
generatedFromValue = `${key}:${value}`;
}
let markdown = `---\ngenerated_from: ${source.type}:${generatedFromValue}\n---\n\n`;
markdown += `# ${title}\n\n`;
let currentYear = null;

View file

@ -47,6 +47,24 @@ export class TimelineView extends ItemView {
}
}
async updateFromMetadata(metadataQuery: string) {
try {
this.currentTitle = `🔍 ${metadataQuery}`;
// Assuming generateFromMetadata is added to Timeline class
this.items = await (this.timeline as any).generateFromMetadata(metadataQuery);
if (this.items.length === 0) {
new Notice(this.i18n.errors.noMetadataFiles.replace('{query}', metadataQuery));
return;
}
await this.render();
} catch (error) {
new Notice(this.i18n.errors.generateMetadataFailed);
console.error('Error generating timeline from metadata:', error);
}
}
private renderTitle(container: HTMLElement) {
const titleContainer = container.createEl('div', { cls: CLASS_TIMELINE_HEADER });
titleContainer.createEl('h2', { text: this.currentTitle });

View file

@ -49,6 +49,17 @@ export interface I18nStrings {
generateFileFromTag: string;
generateFromFileLinks: string;
generateFileFromFileLinks: string;
generateFromMetadata: string; // New command
generateFileFromMetadata: string; // New command
};
modal: {
metadataInputTitle: string;
metadataQueryName: string;
metadataQueryDesc: string;
metadataInputPlaceholder: string;
submitButton: string;
cancelButton: string;
emptyInputNotice: string;
};
errors: {
renderFailed: string;
@ -58,6 +69,8 @@ export interface I18nStrings {
noTaggedFiles: string;
noFileLinks: string;
createViewFailed: string;
noMetadataFiles: string; // New error
generateMetadataFailed: string; // New error
};
}
@ -111,7 +124,18 @@ export const zhCN: I18nStrings = {
generateFromTag: '从标签生成时间轴视图',
generateFileFromTag: '从标签生成时间轴文件',
generateFromFileLinks: '从文件链接生成时间轴视图',
generateFileFromFileLinks: '从文件链接生成时间轴文件'
generateFileFromFileLinks: '从文件链接生成时间轴文件',
generateFromMetadata: '从元数据生成时间轴视图',
generateFileFromMetadata: '从元数据生成时间轴文件'
},
modal: {
metadataInputTitle: '输入元数据字符串',
metadataQueryName: '元数据查询',
metadataQueryDesc: '例如author: - 我 或 status: ongoing',
metadataInputPlaceholder: '输入元数据...',
submitButton: '确定',
cancelButton: '取消',
emptyInputNotice: '请输入元数据字符串'
},
errors: {
renderFailed: '时间轴视图渲染失败',
@ -120,7 +144,9 @@ export const zhCN: I18nStrings = {
generateFileFailed: '从文件链接生成时间轴视图时出错',
noTaggedFiles: '没有找到包含标签 #{tag} 及其子标签的文件',
noFileLinks: '文件 {filename} 中没有找到可用的链接或日期信息',
createViewFailed: '无法创建时间轴视图:请检查侧边栏空间'
createViewFailed: '无法创建时间轴视图:请检查侧边栏空间',
noMetadataFiles: '没有找到与元数据查询 “{query}” 匹配的文件',
generateMetadataFailed: '从元数据生成时间轴失败'
}
};
@ -169,12 +195,23 @@ export const enUS: I18nStrings = {
},
commands: {
openTimelineView: 'Open Timeline View',
generateFromFolder: 'Generate Timeline View from Folder',
generateFileFromFolder: 'Generate Timeline File from Folder',
generateFromTag: 'Generate Timeline View from Tag',
generateFileFromTag: 'Generate Timeline File from Tag',
generateFromFolder: 'Generate Timeline View from any Folder',
generateFileFromFolder: 'Generate Timeline File from any Folder',
generateFromTag: 'Generate Timeline View from any Tag',
generateFileFromTag: 'Generate Timeline File from any Tag',
generateFromFileLinks: 'Generate Timeline View from File Links',
generateFileFromFileLinks: 'Generate Timeline File from File Links'
generateFileFromFileLinks: 'Generate Timeline File from File Links',
generateFromMetadata: 'Generate Timeline View from any Metadata',
generateFileFromMetadata: 'Generate Timeline File from any Metadata'
},
modal: {
metadataInputTitle: 'Enter Metadata String',
metadataQueryName: 'Metadata Query',
metadataQueryDesc: 'e.g., author: - Me or status: ongoing',
metadataInputPlaceholder: 'Enter metadata...',
submitButton: 'Submit',
cancelButton: 'Cancel',
emptyInputNotice: 'Please enter a metadata string'
},
errors: {
renderFailed: 'Failed to render timeline view',
@ -183,6 +220,8 @@ export const enUS: I18nStrings = {
generateFileFailed: 'Error generating file links timeline view',
noTaggedFiles: 'No files found with tag #{tag} or its subtags',
noFileLinks: 'No usable links or date information found in file {filename}',
createViewFailed: 'Cannot create timeline view: Please check sidebar space'
createViewFailed: 'Cannot create timeline view: Please check sidebar space',
noMetadataFiles: 'No files found matching metadata query "{query}"',
generateMetadataFailed: 'Failed to generate timeline from metadata'
}
};

View file

@ -7,7 +7,9 @@ import {
Menu,
TAbstractFile,
normalizePath,
Notice
Notice,
Modal,
TextComponent
} from 'obsidian';
import { I18nStrings, zhCN, enUS } from './i18n';
import { TimelineView, VIEW_TYPE_TIMELINE } from './TimelineView';
@ -50,7 +52,8 @@ export default class TimelinePlugin extends Plugin {
const generatedFrom = cache?.frontmatter?.generated_from;
if (generatedFrom) {
const [type, value] = generatedFrom.split(':');
const [type, ...valueParts] = generatedFrom.split(':');
const value = valueParts.join(':');
menu.addItem((item) => {
item
@ -66,6 +69,8 @@ export default class TimelinePlugin extends Plugin {
await view.updateFromTag(value);
} else if (type === 'file') {
await view.updateFromFile(value);
} else if (type === 'metadata') {
await (view as any).updateFromMetadata(value);
}
}
});
@ -227,6 +232,65 @@ export default class TimelinePlugin extends Plugin {
}
});
// 从元数据生成时间轴视图
this.addCommand({
id: 'generate-timeline-view-from-metadata',
name: this.i18n.commands.generateFromMetadata,
callback: async () => {
try {
const metadataQuery = await this.selectMetadataString();
if (metadataQuery) {
await this.activateView(this.settings.defaultPosition);
const view = this.getTimelineView();
if (view) {
// TimelineView.ts 需添加 updateFromMetadata 方法
await (view as any).updateFromMetadata(metadataQuery);
}
}
} catch (error) {
console.error('从元数据生成时间轴视图时出错:', error);
new Notice(this.i18n.errors.generateMetadataFailed);
}
}
});
// 从元数据生成时间轴文件
this.addCommand({
id: 'generate-timeline-file-from-metadata',
name: this.i18n.commands.generateFileFromMetadata,
callback: async () => {
try {
const metadataQuery = await this.selectMetadataString();
if (metadataQuery) {
const timeline = new Timeline(this.app, this.settings);
// Timeline.ts 需添加 generateFromMetadata 方法
const items = await (timeline as any).generateFromMetadata(metadataQuery);
const content = await timeline.generateTimelineMarkdown(
items,
`Timeline - Metadata: ${metadataQuery}`,
{ type: 'metadata', value: metadataQuery }
);
const { folderPath } = await this.createNestedFolders('metadata-search');
const safeBaseName = metadataQuery.substring(0, 30).replace(/[^a-zA-Z0-9_\-]+/g, '_') || 'metadata_query';
const finalFileName = this.generateFileName(safeBaseName);
const filePath = `${folderPath}/${finalFileName}.md`;
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (existingFile) {
await this.app.vault.delete(existingFile);
}
const newFile = await this.app.vault.create(filePath, content);
await this.app.workspace.getLeaf().openFile(newFile);
}
} catch (error) {
console.error('从元数据生成时间轴文件时出错:', error);
new Notice(this.i18n.errors.generateMetadataFailed);
}
}
});
} catch (error) {
console.error('插件加载时出错:', error);
}
@ -247,6 +311,11 @@ export default class TimelinePlugin extends Plugin {
return await modal.openAndGetValue();
}
async selectMetadataString(): Promise<string | null> {
const modal = new MetadataInputModal(this.app, this); // Pass plugin to modal
return await modal.openAndGetValue();
}
async activateView(position: 'left' | 'right' = this.settings.defaultPosition) {
const { workspace } = this.app;
@ -406,6 +475,85 @@ export default class TimelinePlugin extends Plugin {
}
}
export class MetadataInputModal extends Modal {
plugin: TimelinePlugin; // Add plugin reference
private value: string = '';
private resolvePromise: (value: string | null) => void;
private inputEl: TextComponent;
constructor(app: App, plugin: TimelinePlugin) { // Accept plugin in constructor
super(app);
this.plugin = plugin; // Store plugin reference
}
async openAndGetValue(): Promise<string | null> {
return new Promise((resolve) => {
this.resolvePromise = resolve;
this.open();
});
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('metadata-input-modal');
contentEl.createEl('h2', { text: this.plugin.i18n.modal.metadataInputTitle });
new Setting(contentEl)
.setName(this.plugin.i18n.modal.metadataQueryName)
.setDesc(this.plugin.i18n.modal.metadataQueryDesc)
.addText(text => {
this.inputEl = text;
text.setPlaceholder(this.plugin.i18n.modal.metadataInputPlaceholder)
.onChange(value => this.value = value.trim());
text.inputEl.style.width = '100%';
text.inputEl.addEventListener('keydown', (evt: KeyboardEvent) => {
if (evt.key === 'Enter') {
evt.preventDefault();
this.submit();
}
});
});
new Setting(contentEl)
.addButton(button => button
.setButtonText(this.plugin.i18n.modal.submitButton)
.setCta()
.onClick(() => this.submit()))
.addButton(button => button
.setButtonText(this.plugin.i18n.modal.cancelButton)
.onClick(() => this.closeAndResolve(null)));
// Focus the input field when the modal opens
setTimeout(() => {
if (this.inputEl && this.inputEl.inputEl) {
this.inputEl.inputEl.focus();
}
}, 50);
}
submit() {
if (this.value) {
this.closeAndResolve(this.value);
} else {
new Notice(this.plugin.i18n.modal.emptyInputNotice);
}
}
closeAndResolve(value: string | null) {
if (this.resolvePromise) {
this.resolvePromise(value);
}
this.close();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
class TimelineSettingTab extends PluginSettingTab {
plugin: TimelinePlugin;

View file

@ -1,3 +1,3 @@
{
"1.2.2": "0.15.0"
"1.2.4": "0.15.0"
}