mirror of
https://github.com/shanshuimei/obsidian-generate-timeline.git
synced 2026-07-22 05:37:40 +00:00
feat: Add the function of generating a timeline from metadata
This commit is contained in:
parent
fa408a28cd
commit
79ab8d05c6
10 changed files with 288 additions and 29 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
|||
/main.js
|
||||
.history/
|
||||
/data.json
|
||||
node_modules/
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
2
package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-timeline-plugin",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
55
src/i18n.ts
55
src/i18n.ts
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
152
src/main.ts
152
src/main.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"1.2.2": "0.15.0"
|
||||
"1.2.4": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue