Compare commits

...

40 commits
1.1.0 ... main

Author SHA1 Message Date
Jiya Hai
2eed60cfc8
Fix README by removing link and updating commands
Removed a broken link and updated command options for timeline generation.
2025-11-26 20:57:12 +08:00
Jiya Hai
843b14f48b feat(timeline): Improve file link functionality
- Enhance the timeline generation from file links to automatically use the currently active file
2025-11-26 20:48:19 +08:00
Jiya Hai
274f95dbce docs: Update README 2025-11-26 19:57:09 +08:00
Jiya Hai
510041dc3a feat: Add filtering functionality for the timelines folder
Add filtering for the timelines folder and its contents in the file suggestions, folder suggestions, and timeline generation functions to prevent recursive processing of self-generated timeline files
Update the version number to 1.2.5 and improve the description
2025-11-26 19:48:35 +08:00
Jiya Hai
df33834102 feat(event listening): Add file opening event listening to automatically update timeline content
When opening a markdown file located in the timelines/ directory, the timeline content is automatically regenerated based on the generated_from field in the frontmatter. It supports updating the timeline from four sources: folders, tags, files, and metadata.
2025-11-26 19:19:14 +08:00
Shanshuimei
745e1ea68a Merge branch 'main' of https://github.com/Shanshuimei/obsidian-generate-timeline 2025-05-17 12:12:58 +08:00
Shanshuimei
79ab8d05c6 feat: Add the function of generating a timeline from metadata 2025-05-17 12:12:54 +08:00
Jiya Hai
826571ed0a
Update README.md 2025-04-28 22:40:56 +08:00
Shanshuimei
fa408a28cd fix(timeline): Fix milestone 2025-04-28 22:14:19 +08:00
Shanshuimei
e3762a41e3 Merge branch 'main' of https://github.com/Shanshuimei/obsidian-generate-timeline 2025-04-28 21:51:01 +08:00
Shanshuimei
6acf59794a feat: multilingual support 2025-04-28 21:50:56 +08:00
Jiya Hai
c1be74d404
Update README.md 2025-04-28 20:34:44 +08:00
Shanshuimei
bb86e01592 chore: Update the version and adjust the style configuration 2025-04-28 20:23:35 +08:00
Shanshuimei
4519835968 fix(style): Update the era title style 2025-04-28 19:40:30 +08:00
Shanshuimei
ec48601199 feat: Update version number to 1.2.2 and optimize timeline preview
- Update version number from 1.2.1 to 1.2.2
- Optimizes the timeline preview to support accurate matching and display of related rows based on tags
- Update the README.md documentation to add milestones Description
2025-04-28 18:36:24 +08:00
Shanshuimei
13bf2cfd6c refactor(i18n): Translate the new settings into English 2025-04-28 17:32:37 +08:00
Shanshuimei
418175eaab feat: Add milestones and update style configurations 2025-04-28 17:19:42 +08:00
Shanshuimei
957f10f3b6 feat(i18n): Add multilingual support 2025-04-06 11:32:07 +08:00
Shanshuimei
001a54ec9a docs: Update README.md to include more features and instructions 2025-04-06 10:58:51 +08:00
Shanshuimei
4f9d9641f6 Chore: update version number to 1.2.0, optimize timeline view logic 2025-04-02 19:07:00 +08:00
Shanshuimei
74bdc24b62 feat: Added the function of generating timelines from file links 2025-04-02 17:52:07 +08:00
Shanshuimei
692374c99f Adjust the style : change the width of the line from 10 % to 15 % 2025-02-22 11:46:43 +08:00
Shanshuimei
a4aa333723 Optimize the closing logic of the timeline view and use setViewState to recreate the view ; remove the Timeline Settings tab 2025-02-22 11:42:26 +08:00
Jiya Hai
eb02ebdaf0
Update LICENCE 2025-02-22 11:15:05 +08:00
Jiya Hai
974779d309
Update README.md 2025-02-08 16:03:49 +08:00
Shanshuimei
df9612a674 Refactor timeline view activation to ensure single view and configurable position 2025-02-06 21:35:14 +08:00
Shanshuimei
5ef27f7c55 Add configurable default position for timeline view 2025-02-06 21:12:53 +08:00
Shanshuimei
ebe3e09ed8 更新样式 2025-02-05 22:42:20 +08:00
Shanshuimei
5cd8023d77 Merge branch 'main' of github.com:Shanshuimei/obsidian-generate-timeline 2025-02-05 22:35:57 +08:00
Shanshuimei
aa21b1035c 改进时间轴视图页面布局 2025-02-05 22:35:50 +08:00
Jiya Hai
9c804ef1e8
Update README.md 2025-02-04 14:00:51 +08:00
Jiya Hai
d63da62160
Update README.md 2025-02-04 13:45:20 +08:00
Jiya Hai
c4daa425a9
Update README.md 2025-02-04 13:37:55 +08:00
Jiya Hai
020eeee0d8
Update README.md 2025-02-04 13:34:53 +08:00
Jiya Hai
2ab7707a71
Update README.md 2025-02-04 13:31:32 +08:00
Shanshuimei
878f8ab3f2 更新 .gitignore,添加 .history/ 目录,修改 styles.css 以支持全宽和隐藏横向溢出 2025-02-04 12:05:00 +08:00
Shanshuimei
ed7b91c1ab Add folder path validation to ensure that the selected path is a valid folder 2025-02-02 19:57:03 +08:00
Shanshuimei
0abe316b7a Reconstruct the time view, extract constants, optimize the rendering logic, add check and overwrite old files. 2025-02-02 19:47:55 +08:00
Shanshuimei
e2843102ed Update .gitignore 2025-02-02 18:54:32 +08:00
Shanshuimei
b391efd658 Remove the main.js file from the repo and add it to your .gitignore. 2025-02-02 18:52:43 +08:00
18 changed files with 1317 additions and 972 deletions

6
.gitignore vendored
View file

@ -1 +1,5 @@
/CODE_OF_CONDUCT.md
/CODE_OF_CONDUCT.md
/main.js
.history/
/data.json
node_modules/

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 April GRAS
Copyright (c) 2025 Jiya Hai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

118
README.md
View file

@ -1,28 +1,104 @@
# Generate Timeline
**Generate Timeline** is a plugin developed for the Obsidian to help users generate timelines based on folders or tags. With this plugin, users can easily view and manage the content of notes in chronological order.
中文版文档请进入此链接https://kivgf4fnsy5.feishu.cn/drive/folder/ELWHf58RmlCOv6dBLSKcEG44nRd?from=from_copylink
<h1 align="center">
Generate Timeline
</h1>
<p align="center">
<a href="https://github.com/Shanshuimei/obsidian-generate-timeline/stargazers"><img src="https://img.shields.io/github/stars/Shanshuimei/obsidian-generate-timeline?colorA=363a4f&colorB=e0ac00&style=for-the-badge" alt="GitHub star count"></a>
<a href="https://github.com/Shanshuimei/obsidian-generate-timeline/issues"><img src="https://img.shields.io/github/issues/Shanshuimei/obsidian-generate-timeline?colorA=363a4f&colorB=e93147&style=for-the-badge" alt="Open issues on GitHub"></a>
<a href="https://github.com/Shanshuimei/obsidian-generate-timeline/contributors"><img src="https://img.shields.io/github/contributors/Shanshuimei/obsidian-generate-timeline?colorA=363a4f&colorB=08b94e&style=for-the-badge" alt="List of contributors"></a>
<a href="./LICENCE"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&colorA=363a4f&colorB=b7bdf8" alt="MIT license"/></a>
<a href="https://github.com/Shanshuimei/obsidian-generate-timeline/releases"><img src="https://img.shields.io/github/downloads/Shanshuimei/obsidian-generate-timeline/total?colorA=363a4f&colorB=1a72f5&style=for-the-badge" alt="Total downloads"></a>
<br/><br/>
<b>Generate Timeline</b> It is a plugin that helps users generate timelines based on folders, tags, files, or metadata. With this plugin, users can easily view and manage their notes in chronological order.
</p>
<p align="center"><a href="https://kivgf4fnsy5.feishu.cn/drive/folder/ELWHf58RmlCOv6dBLSKcEG44nRd?from=from_copylink">中文文档</a></p>
## Functional Overview
1. **Generate a timeline based on folders**
- Select a folder, the plugin will scan all the files in that folder and generate a timeline based on the time attributes in the files. The timeline will be arranged in chronological order for users to view and manage.
2. **Generate timeline based on tags**
- Select a tag, the plugin will scan all the content with the tag, and generate a timeline according to the time attribute in the file. The timeline will be arranged in chronological order to facilitate users to view and manage.
3. **Generate links based on tags or folders**
- This plug-in generates a timeline while generating an md file that links all related files in chronological order.
## Installation
1. Please install the `Update time on edit` plugin before installing this plugin.
2. Download the `main.js`, `manifest.json`, and `styles.css` files in the release.
3. Create a new `obsidian-generate-timeline` folder under the `\.obsidian \ plugins` folder and copy or move all `main.js`, `manifest.json` and `styles.css` files to the `obsidian-generate-timeline` folder.
4. Open `Generate timeline` in the obsidian installed plugins.
## How to use
1. Generate Timeline Based on Folder
- Open the Command Palette, click Generate Timeline: Generate Timeline from Folder, and select the folder you want to generate the timeline for. This will create both a timeline Markdown file and a timeline view.
- In the generated timeline Markdown file, click on the "More Options" menu and select Open Timeline View to bring up the timeline view at any time.
2. Generate Timeline Based on Tags
- Open the Command Palette, click Generate Timeline: Generate Timeline from Tags, and select the tag you want to generate the timeline for. This will create both a timeline Markdown file and a timeline view.
- In the generated timeline Markdown file, click on the "More Options" menu and select Open Timeline View to bring up the timeline view at any time.
1. **Generate Timeline Based on Folders**:
- Select a folder, and the plugin will scan all files (including subfolders) within it to generate a timeline based on the time attributes in the files.
- Supports both timeline view and Markdown file formats.
<p></p>
https://github.com/user-attachments/assets/10b64bef-4a07-4e2e-bded-152dc82ab241
2. **Generate Timeline Based on Metadata**:
- Input metadata attributes and its value, the plugin will scan all files with matching metadata to generate a timeline based on time attributes.
- Supports both timeline view and Markdown file formats.
<p></p>
3. **Generate Timeline Based on Tags**:
- Select a tag, and the plugin will scan all content with that tag (including subtags) to generate a timeline based on the time attributes.
- Supports both timeline view and Markdown file formats.
<p></p>
https://github.com/user-attachments/assets/5da9d7ac-67f1-4c65-bbef-f703696463d8
4. **Generate Timeline Based on File Links**:
- Extract all internal links from a specified file and generate a timeline based on the linked files' time attributes.
- Supports both timeline view and Markdown file formats.
<p></p>
5. **Milestone functionality** :
- milestones can be marked with a custom frontmatter attribute.
- milestone cards have a special style.
<p></p>
https://github.com/user-attachments/assets/3e92a73a-2af5-4f75-90be-a13aca6c1375
## How to Use
1. **Generate Timeline from Folder**
- Open the Command Palette and choose one of the following:
- `Generate Timeline: Generate Timeline View from Folder` for a timeline view
- `Generate Timeline: Generate Timeline File from Folder` for a Markdown file
- Select your target folder and the timeline will be generated automatically
2. **Generate Timeline from Metadata**
- Open the Command Palette and choose one of the following:
- `Generate Timeline: Generate Timeline View from Metadata` for a timeline view
- `Generate Timeline: Generate Timeline File from Metadata` for a Markdown file
- Select the metadata attributes and values to filter by
3. **Generate Timeline from Tags**
- Open the Command Palette and choose one of the following:
- `Generate Timeline: Generate Timeline View from Tags` for a timeline view
- `Generate Timeline: Generate Timeline File from Tags` for a Markdown file
- Select your desired tag and the timeline will be generated automatically
4. **Generate Timeline from File Links**
- Open the Command Palette and choose one of the following:
- `Generate Timeline: Generate Timeline View from Current File Links` for a timeline view
- `Generate Timeline: Generate Timeline File from Current File Links` for a Markdown file
- Select the source file containing the links
## Customization Options
The plugin offers various customization settings:
1. **Timeline Appearance**:
- Timeline line width and color
- Item spacing
- Card background color, text color and border color
- Animation duration
- Milestone card background color, text color and border color
2. **Content Settings**:
- Date attribute: Choose which frontmatter date property to use for sorting (e.g., created, updated, date)
- File name prefix and suffix: Customize the naming pattern for generated timeline files
- Milestone property settings: custom property names and matching values
- The card preview preferentially displays the row where the label is located.
3. **View Position**:
- Choose whether the timeline view appears in the left or right sidebar
4. **Language Settings**:
- Support Chinese and English
## Contribution
We welcome contributions of any kind! If you have suggestions, questions, or would like to add new features, please submit an Issue or Pull Request on the GitHub repository.
## License
This plugin is released under the MIT License. For more details, please refer to the LICENSE file.

View file

@ -1,9 +1,19 @@
{
"lineWidth": 2,
"lineColor": "var(--interactive-accent)",
"nodeSize": 16,
"nodeColor": "var(--interactive-accent)",
"itemSpacing": 30,
"cardBackground": "var(--background-primary-alt)",
"animationDuration": 200
"cardTextColor": "var(--text-normal)",
"cardBorderColor": "var(--background-modifier-border)",
"milestoneCardBackground": "#ffffcc",
"milestoneCardTextColor": "var(--text-normal)",
"milestoneCardBorderColor": "#f0e68c",
"animationDuration": 200,
"dateAttribute": "created",
"fileNamePrefix": "",
"fileNameSuffix": "",
"defaultPosition": "right",
"language": "en-US",
"milestoneAttribute": "",
"milestoneValue": ""
}

674
main.js
View file

@ -1,674 +0,0 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => TimelinePlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian5 = require("obsidian");
// src/TimelineView.ts
var import_obsidian2 = require("obsidian");
// src/Timeline.ts
var import_obsidian = require("obsidian");
var Timeline = class {
constructor(app, settings) {
__publicField(this, "app");
__publicField(this, "settings");
this.app = app;
this.settings = settings;
}
async getFilePreview(file) {
const content = await this.app.vault.cachedRead(file);
const previewContent = content.replace(/^---[\s\S]*?---/, "").trim();
return previewContent.slice(0, 100) + (previewContent.length > 100 ? "..." : "");
}
async createTimelineItem(file) {
var _a;
const metadata = this.app.metadataCache.getFileCache(file);
const dateValue = (_a = metadata == null ? void 0 : metadata.frontmatter) == null ? void 0 : _a[this.settings.dateAttribute];
if (dateValue) {
return {
date: new Date(dateValue),
title: file.basename,
path: file.path,
preview: await this.getFilePreview(file)
};
}
return null;
}
sortItemsByDate(items) {
return items.sort((a, b) => b.date.getTime() - a.date.getTime());
}
async generateFromFolder(folder) {
if (folder.path === "timelines") {
return [];
}
const timelineItems = [];
const processFolder = async (currentFolder) => {
var _a;
for (const item of currentFolder.children) {
if (item instanceof import_obsidian.TFile && item.extension === "md") {
const metadata = this.app.metadataCache.getFileCache(item);
const dateValue = (_a = metadata == null ? void 0 : metadata.frontmatter) == null ? void 0 : _a[this.settings.dateAttribute];
if (dateValue) {
timelineItems.push({
date: new Date(dateValue),
title: item.basename,
path: item.path,
preview: await this.getFilePreview(item)
});
}
} else if (item instanceof import_obsidian.TFolder && item.path !== "timelines") {
await processFolder(item);
}
}
};
await processFolder(folder);
return timelineItems.sort((a, b) => b.date.getTime() - a.date.getTime());
}
async generateFromTag(tag) {
const allFiles = this.app.vault.getMarkdownFiles();
const items = [];
const normalizedTag = tag.replace(/^#/, "");
for (const file of allFiles) {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache)
continue;
const hasMatchingTag = cache.tags && cache.tags.some((tagObj) => {
const fileTag = tagObj.tag.replace(/^#/, "");
return fileTag === normalizedTag || // 完全匹配
fileTag.startsWith(normalizedTag + "/");
}) || cache.frontmatter && cache.frontmatter.tags && cache.frontmatter.tags.some((frontmatterTag) => {
const fileTag = frontmatterTag.replace(/^#/, "");
return fileTag === normalizedTag || // 完全匹配
fileTag.startsWith(normalizedTag + "/");
});
if (hasMatchingTag) {
const item = await this.createTimelineItem(file);
if (item) {
items.push(item);
}
}
}
return this.sortItemsByDate(items);
}
async generateTimelineMarkdown(items, title, source) {
let markdown = `---
generated_from: ${source.type}:${source.value}
---
`;
markdown += `# ${title}
`;
let currentYear = null;
let currentMonth = null;
for (const item of items) {
const date = item.date;
const year = date.getFullYear();
const month = date.getMonth();
if (currentYear !== year) {
markdown += `
## ${year}
`;
currentYear = year;
currentMonth = null;
}
if (currentMonth !== month) {
markdown += `
### ${date.toLocaleString("default", { month: "long" })}
`;
currentMonth = month;
}
markdown += `#### ${date.toLocaleDateString("zh-CN")} - [[${item.title}]]
`;
}
return markdown;
}
};
// src/TimelineView.ts
var VIEW_TYPE_TIMELINE = "timeline-view";
var TimelineView = class extends import_obsidian2.ItemView {
// 新增:存储当前标题
constructor(leaf, settings) {
super(leaf);
__publicField(this, "timeline");
__publicField(this, "settings");
__publicField(this, "items", []);
__publicField(this, "currentTitle", "");
this.timeline = new Timeline(this.app, settings);
}
getViewType() {
return VIEW_TYPE_TIMELINE;
}
getDisplayText() {
return "\u65F6\u95F4\u8F74\u89C6\u56FE";
}
async onOpen() {
await this.render();
}
async render() {
const container = this.containerEl.children[1];
container.empty();
if (this.currentTitle) {
const titleContainer = container.createEl("div", { cls: "timeline-header" });
titleContainer.createEl("h2", { text: this.currentTitle });
}
const timelineContainer = container.createEl("div", { cls: "timeline-container" });
const timelineLine = timelineContainer.createEl("div", { cls: "timeline-line" });
const itemsByYear = /* @__PURE__ */ new Map();
this.items.forEach((item) => {
var _a;
const year = item.date.getFullYear();
if (!itemsByYear.has(year)) {
itemsByYear.set(year, []);
}
(_a = itemsByYear.get(year)) == null ? void 0 : _a.push(item);
});
Array.from(itemsByYear.entries()).sort(([yearA], [yearB]) => yearB - yearA).forEach(([year, items]) => {
const era = timelineContainer.createEl("div", { cls: "timeline-era" });
era.createEl("div", {
cls: "timeline-era-title",
text: `${year}`
});
items.forEach((item, index) => {
const itemEl = era.createEl("div", {
cls: "timeline-item" + (index === 0 ? " first-item" : "") + (index === items.length - 1 ? " last-item" : "")
});
const card = itemEl.createEl("div", { cls: "timeline-card" });
card.createEl("div", {
cls: "timeline-date",
text: item.date.toLocaleDateString("zh-CN")
});
const titleEl = card.createEl("div", {
cls: "timeline-title"
});
titleEl.innerHTML = item.title;
if (item.preview) {
const previewEl = card.createEl("div", {
cls: "timeline-preview"
});
previewEl.innerHTML = item.preview;
}
titleEl.addEventListener("click", async () => {
const file = this.app.vault.getAbstractFileByPath(item.path);
if (file instanceof import_obsidian2.TFile) {
await this.app.workspace.getLeaf().openFile(file);
}
});
});
});
}
async updateFromFolder(folderPath) {
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (folder) {
this.currentTitle = `\u{1F4C2} ${folder.name}`;
this.items = await this.timeline.generateFromFolder(folder);
await this.render();
}
}
async updateFromTag(tag) {
try {
this.currentTitle = `\u{1F3F7}\uFE0F ${tag}`;
const items = await this.timeline.generateFromTag(tag);
this.items = items;
if (this.items.length === 0) {
new import_obsidian2.Notice(`\u6CA1\u6709\u627E\u5230\u5305\u542B\u6807\u7B7E #${tag} \u53CA\u5176\u5B50\u6807\u7B7E\u7684\u6587\u4EF6`);
return;
}
await this.render();
} catch (error) {
new import_obsidian2.Notice("\u751F\u6210\u65F6\u95F4\u8F74\u5931\u8D25");
throw error;
}
}
getIcon() {
return "history";
}
};
// src/FolderSuggest.ts
var import_obsidian3 = require("obsidian");
var FolderSuggestModal = class extends import_obsidian3.FuzzySuggestModal {
constructor(app) {
super(app);
__publicField(this, "resolve");
this.setPlaceholder("\u9009\u62E9\u4E00\u4E2A\u6587\u4EF6\u5939");
}
getItems() {
const folders = [];
const pushFolder = (folder) => {
folders.push(folder);
folder.children.filter((child) => child instanceof import_obsidian3.TFolder).forEach(pushFolder);
};
const rootFolder = this.app.vault.getRoot();
pushFolder(rootFolder);
return folders;
}
getItemText(folder) {
return folder.path;
}
onChooseItem(folder) {
this.resolve(folder.path);
this.close();
}
async openAndGetValue() {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
}).then((value) => {
if (value === void 0) {
return null;
}
return value;
});
}
};
// src/TagSuggest.ts
var import_obsidian4 = require("obsidian");
var TagSuggestModal = class extends import_obsidian4.FuzzySuggestModal {
constructor(app) {
super(app);
__publicField(this, "resolve");
this.setPlaceholder("\u9009\u62E9\u4E00\u4E2A\u6807\u7B7E");
}
getItems() {
const tags = /* @__PURE__ */ new Set();
const files = this.app.vault.getMarkdownFiles();
files.forEach((file) => {
var _a, _b;
const cache = this.app.metadataCache.getFileCache(file);
(_a = cache == null ? void 0 : cache.tags) == null ? void 0 : _a.forEach((tag) => {
tags.add(tag.tag.replace("#", ""));
});
const frontmatterTags = (_b = cache == null ? void 0 : cache.frontmatter) == null ? void 0 : _b.tags;
if (Array.isArray(frontmatterTags)) {
frontmatterTags.forEach((tag) => {
tags.add(tag.replace("#", ""));
});
} else if (typeof frontmatterTags === "string") {
frontmatterTags.split("-").forEach((tag) => {
tags.add(tag.trim().replace("#", ""));
});
}
});
const tagArray = Array.from(tags);
console.log("\u53EF\u7528\u7684\u6807\u7B7E:", tagArray);
return tagArray;
}
getItemText(tag) {
return tag;
}
onChooseItem(tag) {
console.log("\u9009\u62E9\u7684\u6807\u7B7E:", tag);
this.resolve(tag);
this.close();
}
async openAndGetValue() {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
};
// src/TimelineSettings.ts
function getCssVariable(name) {
return `var(${name})`;
}
var DEFAULT_SETTINGS = {
lineWidth: 2,
lineColor: getCssVariable("--interactive-accent"),
nodeSize: 16,
nodeColor: getCssVariable("--interactive-accent"),
itemSpacing: 30,
cardBackground: getCssVariable("--background-primary-alt"),
animationDuration: 200,
dateAttribute: "created",
// 默认使用 frontmatter 中的 date 属性作为时间轴排序依据
fileNamePrefix: "",
fileNameSuffix: ""
};
// src/main.ts
var TimelinePlugin = class extends import_obsidian5.Plugin {
constructor() {
super(...arguments);
__publicField(this, "settings");
}
async onload() {
await this.loadSettings();
this.addSettingTab(new TimelineSettingTab(this.app, this));
console.log("\u52A0\u8F7D Timeline \u63D2\u4EF6");
try {
this.registerView(
VIEW_TYPE_TIMELINE,
(leaf) => new TimelineView(leaf, this.settings)
);
this.registerEvent(
this.app.workspace.on("file-menu", (menu, abstractFile) => {
var _a;
if (abstractFile instanceof import_obsidian5.TFile && abstractFile.path.startsWith("timelines/") && abstractFile.extension === "md") {
const cache = this.app.metadataCache.getFileCache(abstractFile);
const generatedFrom = (_a = cache == null ? void 0 : cache.frontmatter) == null ? void 0 : _a.generated_from;
if (generatedFrom) {
const [type, value] = generatedFrom.split(":");
menu.addItem((item) => {
item.setTitle("\u6253\u5F00\u65F6\u95F4\u8F74\u89C6\u56FE").setIcon("clock").onClick(async () => {
await this.activateView();
const view = this.getTimelineView();
if (view) {
if (type === "folder") {
await view.updateFromFolder(value);
} else if (type === "tag") {
await view.updateFromTag(value);
}
}
});
});
}
}
})
);
this.addCommand({
id: "generate-timeline-view-from-folder",
name: "\u4ECE\u6587\u4EF6\u5939\u751F\u6210\u65F6\u95F4\u8F74\u89C6\u56FE",
callback: async () => {
try {
const folderPath = await this.selectFolder();
if (folderPath) {
await this.activateView();
const view = this.getTimelineView();
if (view) {
await view.updateFromFolder(folderPath);
}
}
} catch (error) {
console.error("\u751F\u6210\u6587\u4EF6\u5939\u65F6\u95F4\u8F74\u89C6\u56FE\u65F6\u51FA\u9519:", error);
}
}
});
this.addCommand({
id: "generate-timeline-file-from-folder",
name: "\u4ECE\u6587\u4EF6\u5939\u751F\u6210\u65F6\u95F4\u8F74\u6587\u4EF6",
callback: async () => {
try {
const folderPath = await this.selectFolder();
if (folderPath) {
const timeline = new Timeline(this.app, this.settings);
const items = await timeline.generateFromFolder(this.app.vault.getAbstractFileByPath(folderPath));
const content = await timeline.generateTimelineMarkdown(items, `Timeline - ${folderPath}`, { type: "folder", value: folderPath });
const { folderPath: targetFolder, fileName } = await this.createNestedFolders(folderPath);
const finalFileName = this.generateFileName(fileName || folderPath.split("/").pop() || "");
const filePath = `${targetFolder}/${finalFileName}.md`;
const file = await this.app.vault.create(filePath, content);
await this.app.workspace.getLeaf().openFile(file);
}
} catch (error) {
console.error("\u751F\u6210\u6587\u4EF6\u5939\u65F6\u95F4\u8F74\u6587\u4EF6\u65F6\u51FA\u9519:", error);
}
}
});
this.addCommand({
id: "generate-timeline-view-from-tag",
name: "\u4ECE\u6807\u7B7E\u751F\u6210\u65F6\u95F4\u8F74\u89C6\u56FE",
callback: async () => {
try {
const tag = await this.selectTag();
if (tag) {
await this.activateView();
const view = this.getTimelineView();
if (view) {
await view.updateFromTag(tag);
}
}
} catch (error) {
console.error("\u751F\u6210\u6807\u7B7E\u65F6\u95F4\u8F74\u89C6\u56FE\u65F6\u51FA\u9519:", error);
}
}
});
this.addCommand({
id: "generate-timeline-file-from-tag",
name: "\u4ECE\u6807\u7B7E\u751F\u6210\u65F6\u95F4\u8F74\u6587\u4EF6",
callback: async () => {
try {
const tag = await this.selectTag();
if (tag) {
await this.generateTimelineFileFromTag(tag);
}
} catch (error) {
console.error("\u751F\u6210\u6807\u7B7E\u65F6\u95F4\u8F74\u6587\u4EF6\u65F6\u51FA\u9519:", error);
}
}
});
} catch (error) {
console.error("\u63D2\u4EF6\u52A0\u8F7D\u65F6\u51FA\u9519:", error);
}
}
async selectFolder() {
const modal = new FolderSuggestModal(this.app);
return await modal.openAndGetValue();
}
async selectTag() {
const modal = new TagSuggestModal(this.app);
return await modal.openAndGetValue();
}
async activateView() {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_TIMELINE)[0];
if (!leaf) {
const newLeaf = workspace.getRightLeaf(false);
if (newLeaf) {
leaf = newLeaf;
await leaf.setViewState({ type: VIEW_TYPE_TIMELINE });
}
}
if (leaf) {
workspace.revealLeaf(leaf);
return leaf;
}
return null;
}
getTimelineView() {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_TIMELINE);
if (leaves.length === 0) {
return null;
}
return leaves[0].view;
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.updateStyles();
}
async saveSettings() {
await this.saveData(this.settings);
this.updateStyles();
}
updateStyles() {
var _a;
const style = document.createElement("style");
style.id = "timeline-custom-styles";
style.textContent = `
.timeline-line {
width: ${this.settings.lineWidth}px !important;
background: ${this.settings.lineColor} !important;
}
.timeline-item::before {
width: ${this.settings.nodeSize}px !important;
height: ${this.settings.nodeSize}px !important;
border-color: ${this.settings.nodeColor} !important;
left: ${-(this.settings.nodeSize / 2 + 6)}px !important;
}
.timeline-item::after {
background: ${this.settings.lineColor} !important;
}
.timeline-item {
margin: ${this.settings.itemSpacing}px 0 !important;
}
.timeline-card {
background: ${this.settings.cardBackground} !important;
transition: transform ${this.settings.animationDuration}ms !important;
}
`;
(_a = document.getElementById("timeline-custom-styles")) == null ? void 0 : _a.remove();
document.head.appendChild(style);
}
async createNestedFolders(tagPath) {
const rootPath = "timelines";
if (!this.app.vault.getAbstractFileByPath(rootPath)) {
try {
await this.app.vault.createFolder(rootPath);
} catch (error) {
new import_obsidian5.Notice(`\u521B\u5EFA\u6839\u76EE\u5F55\u5931\u8D25: ${rootPath}`);
throw error;
}
}
const parts = tagPath.split("/");
const fileName = parts.pop();
let currentPath = rootPath;
for (const part of parts) {
currentPath = (0, import_obsidian5.normalizePath)(`${currentPath}/${part}`);
const folder = this.app.vault.getAbstractFileByPath(currentPath);
if (!folder) {
try {
await this.app.vault.createFolder(currentPath);
} catch (error) {
new import_obsidian5.Notice(`\u521B\u5EFA\u6587\u4EF6\u5939\u5931\u8D25: ${currentPath}`);
throw error;
}
}
}
return { folderPath: currentPath, fileName };
}
generateFileName(baseName) {
const prefix = this.settings.fileNamePrefix || "";
const suffix = this.settings.fileNameSuffix || "";
return `${prefix}${baseName}${suffix}`;
}
async generateTimelineFileFromTag(tag) {
try {
const tagPath = tag.replace("#", "");
const { folderPath, fileName } = await this.createNestedFolders(tagPath);
const finalFileName = this.generateFileName(fileName || "");
const filePath = `${folderPath}/${finalFileName}.md`;
const timeline = new Timeline(this.app, this.settings);
const items = await timeline.generateFromTag(tag);
const content = await timeline.generateTimelineMarkdown(items, `Timeline - ${tag}`, { type: "tag", value: tag });
const file = await this.app.vault.create(filePath, content);
await this.app.workspace.getLeaf().openFile(file);
} catch (error) {
console.error("\u751F\u6210\u6807\u7B7E\u65F6\u95F4\u8F74\u6587\u4EF6\u65F6\u51FA\u9519:", error);
throw error;
}
}
};
var TimelineSettingTab = class extends import_obsidian5.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
__publicField(this, "plugin");
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "\u65F6\u95F4\u8F74\u8BBE\u7F6E" });
new import_obsidian5.Setting(containerEl).setName("\u91CD\u7F6E\u8BBE\u7F6E").setDesc("\u5C06\u6240\u6709\u8BBE\u7F6E\u6062\u590D\u4E3A\u9ED8\u8BA4\u503C").addButton((button) => button.setButtonText("\u91CD\u7F6E\u4E3A\u9ED8\u8BA4\u503C").onClick(async () => {
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
await this.plugin.saveSettings();
this.display();
}));
new import_obsidian5.Setting(containerEl).setName("\u65F6\u95F4\u8F74\u7EBF\u5BBD\u5EA6").setDesc("\u8BBE\u7F6E\u4E3B\u65F6\u95F4\u8F74\u7EBF\u7684\u5BBD\u5EA6\uFF08\u50CF\u7D20\uFF09").addSlider((slider) => slider.setLimits(1, 10, 1).setValue(this.plugin.settings.lineWidth).onChange(async (value) => {
this.plugin.settings.lineWidth = value;
await this.plugin.saveSettings();
}));
new import_obsidian5.Setting(containerEl).setName("\u65F6\u95F4\u8F74\u7EBF\u989C\u8272").setDesc("\u8BBE\u7F6E\u65F6\u95F4\u8F74\u7EBF\u7684\u989C\u8272").addColorPicker((color) => color.setValue(this.plugin.settings.lineColor).onChange(async (value) => {
this.plugin.settings.lineColor = value;
await this.plugin.saveSettings();
})).addText((text) => text.setPlaceholder("\u70B9\u51FB\u5DE6\u4FA7\u8272\u76D8\u9009\u62E9\u989C\u8272").setValue("").onChange(async (value) => {
if (value) {
this.plugin.settings.lineColor = value;
await this.plugin.saveSettings();
}
}));
new import_obsidian5.Setting(containerEl).setName("\u8282\u70B9\u5927\u5C0F").setDesc("\u8BBE\u7F6E\u65F6\u95F4\u8282\u70B9\u7684\u5927\u5C0F\uFF08\u50CF\u7D20\uFF09").addSlider((slider) => slider.setLimits(8, 32, 2).setValue(this.plugin.settings.nodeSize).onChange(async (value) => {
this.plugin.settings.nodeSize = value;
await this.plugin.saveSettings();
}));
new import_obsidian5.Setting(containerEl).setName("\u8282\u70B9\u989C\u8272").setDesc("\u8BBE\u7F6E\u65F6\u95F4\u8282\u70B9\u7684\u989C\u8272").addColorPicker((color) => color.setValue(this.plugin.settings.nodeColor).onChange(async (value) => {
this.plugin.settings.nodeColor = value;
await this.plugin.saveSettings();
})).addText((text) => text.setPlaceholder("\u70B9\u51FB\u5DE6\u4FA7\u8272\u76D8\u9009\u62E9\u989C\u8272").setValue("").onChange(async (value) => {
if (value) {
this.plugin.settings.nodeColor = value;
await this.plugin.saveSettings();
}
}));
new import_obsidian5.Setting(containerEl).setName("\u9879\u76EE\u95F4\u8DDD").setDesc("\u8BBE\u7F6E\u65F6\u95F4\u8F74\u9879\u76EE\u4E4B\u95F4\u7684\u95F4\u8DDD\uFF08\u50CF\u7D20\uFF09").addSlider((slider) => slider.setLimits(10, 100, 5).setValue(this.plugin.settings.itemSpacing).onChange(async (value) => {
this.plugin.settings.itemSpacing = value;
await this.plugin.saveSettings();
}));
new import_obsidian5.Setting(containerEl).setName("\u5361\u7247\u80CC\u666F\u8272").setDesc("\u8BBE\u7F6E\u5185\u5BB9\u5361\u7247\u7684\u80CC\u666F\u989C\u8272").addColorPicker((color) => color.setValue(this.plugin.settings.cardBackground).onChange(async (value) => {
this.plugin.settings.cardBackground = value;
await this.plugin.saveSettings();
})).addText((text) => text.setPlaceholder("\u70B9\u51FB\u5DE6\u4FA7\u8272\u76D8\u9009\u62E9\u989C\u8272").setValue("").onChange(async (value) => {
if (value) {
this.plugin.settings.cardBackground = value;
await this.plugin.saveSettings();
}
}));
new import_obsidian5.Setting(containerEl).setName("\u52A8\u753B\u6301\u7EED\u65F6\u95F4").setDesc("\u8BBE\u7F6E\u60AC\u505C\u52A8\u753B\u7684\u6301\u7EED\u65F6\u95F4\uFF08\u6BEB\u79D2\uFF09").addSlider((slider) => slider.setLimits(0, 1e3, 50).setValue(this.plugin.settings.animationDuration).onChange(async (value) => {
this.plugin.settings.animationDuration = value;
await this.plugin.saveSettings();
}));
new import_obsidian5.Setting(containerEl).setName("\u65E5\u671F\u5C5E\u6027").setDesc("\u9009\u62E9\u7528\u4E8E\u65F6\u95F4\u8F74\u6392\u5E8F\u7684 frontmatter \u65E5\u671F\u5C5E\u6027\uFF08\u5982\uFF1Acreated, updated, date \u7B49\uFF09").addText((text) => text.setPlaceholder("\u8F93\u5165\u65E5\u671F\u5C5E\u6027\u540D").setValue(this.plugin.settings.dateAttribute).onChange(async (value) => {
this.plugin.settings.dateAttribute = value;
await this.plugin.saveSettings();
}));
new import_obsidian5.Setting(containerEl).setName("\u6587\u4EF6\u540D\u524D\u7F00").setDesc("\u8BBE\u7F6E\u751F\u6210\u7684\u65F6\u95F4\u8F74\u6587\u4EF6\u540D\u524D\u7F00").addText((text) => text.setPlaceholder("\u8F93\u5165\u524D\u7F00").setValue(this.plugin.settings.fileNamePrefix).onChange(async (value) => {
this.plugin.settings.fileNamePrefix = value;
await this.plugin.saveSettings();
}));
new import_obsidian5.Setting(containerEl).setName("\u6587\u4EF6\u540D\u540E\u7F00").setDesc("\u8BBE\u7F6E\u751F\u6210\u7684\u65F6\u95F4\u8F74\u6587\u4EF6\u540D\u540E\u7F00").addText((text) => text.setPlaceholder("\u8F93\u5165\u540E\u7F00").setValue(this.plugin.settings.fileNameSuffix).onChange(async (value) => {
this.plugin.settings.fileNameSuffix = value;
await this.plugin.saveSettings();
}));
}
};

View file

@ -1,9 +1,9 @@
{
"id": "generate-timeline",
"name": "Generate Timeline",
"version": "1.1.0",
"version": "1.2.6",
"minAppVersion": "0.15.0",
"description": "Generate timelines from tag or folder automatically by any time properties.",
"description": "Generate timelines from tag folder file or metadata automatically by any time properties.",
"author": "Shanshuimei",
"authorUrl": "https://github.com/Shanshuimei",
"isDesktopOnly": false

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-timeline-plugin",
"version": "1.0.0",
"version": "1.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-timeline-plugin",
"version": "1.0.0",
"version": "1.2.2",
"license": "MIT",
"dependencies": {
"html2canvas": "^1.4.1"

View file

@ -1,7 +1,7 @@
{
"name": "generate-timeline",
"version": "1.1.0",
"description": "Generate timelines from tag or folder automatically by any time properties.",
"version": "1.2.6",
"description": "Generate timelines from tag, folder, file or metadata automatically by any time properties.",
"main": "main.js",
"type": "module",
"scripts": {

33
src/FileSuggest.ts Normal file
View file

@ -0,0 +1,33 @@
import { App, FuzzySuggestModal, TFile } from 'obsidian';
export class FileSuggestModal extends FuzzySuggestModal<TFile> {
private resolve: (value: TFile | null) => void;
constructor(app: App, private settings: {language: string}) {
super(app);
this.setPlaceholder(this.settings.language === 'zh-CN' ? "选择一个Markdown文件" : "Select a Markdown file");
}
async openAndGetValue(): Promise<TFile | null> {
return new Promise<TFile | null>((resolve) => {
this.resolve = resolve;
this.open();
});
}
getItems(): TFile[] {
// 返回所有Markdown文件但过滤掉timelines文件夹中的文件
return this.app.vault.getMarkdownFiles().filter(file =>
!file.path.startsWith('timelines/') && file.path !== 'timelines'
);
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
this.resolve(file);
this.close();
}
}

View file

@ -3,18 +3,27 @@ import { App, FuzzySuggestModal, TFolder } from 'obsidian';
export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
private resolve: (value: string | null) => void;
constructor(app: App) {
constructor(app: App, private settings: {language: string}) {
super(app);
this.setPlaceholder("选择一个文件夹");
this.setPlaceholder(this.settings.language === 'zh-CN' ? "选择一个文件夹" : "Select a folder");
}
getItems(): TFolder[] {
const folders: TFolder[] = [];
// 检查文件夹是否应该被过滤掉timelines文件夹及其子文件夹
const shouldFilterFolder = (folderPath: string): boolean => {
return folderPath === 'timelines' || folderPath.startsWith('timelines/');
};
const pushFolder = (folder: TFolder) => {
folders.push(folder);
folder.children
.filter((child): child is TFolder => child instanceof TFolder)
.forEach(pushFolder);
// 只添加非timelines文件夹及其子文件夹
if (!shouldFilterFolder(folder.path)) {
folders.push(folder);
folder.children
.filter((child): child is TFolder => child instanceof TFolder)
.forEach(pushFolder);
}
};
// 获取根文件夹
@ -43,4 +52,4 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
return value;
});
}
}
}

View file

@ -3,9 +3,9 @@ import { App, FuzzySuggestModal, TFile } from 'obsidian';
export class TagSuggestModal extends FuzzySuggestModal<string> {
private resolve: (value: string | null) => void;
constructor(app: App) {
constructor(app: App, private settings: {language: string}) {
super(app);
this.setPlaceholder("选择一个标签");
this.setPlaceholder(this.settings.language === 'zh-CN' ? "选择一个标签" : "Select a tag");
}
getItems(): string[] {
@ -25,8 +25,12 @@ export class TagSuggestModal extends FuzzySuggestModal<string> {
tags.add(tag.replace('#', ''));
});
} else if (typeof frontmatterTags === 'string') {
frontmatterTags.split('-').forEach((tag: string) => {
tags.add(tag.trim().replace('#', ''));
// 处理字符串形式的标签,支持逗号分隔和空格分隔
const normalizedTags = frontmatterTags.replace(/[,;]/g, ' ').trim();
normalizedTags.split(/\s+/).forEach((tag: string) => {
if (tag) {
tags.add(tag.trim().replace('#', ''));
}
});
}
});

View file

@ -6,6 +6,7 @@ export interface TimelineItem {
title: string;
path: string;
preview: string;
isMilestone?: boolean; // 添加里程碑标志
}
export class Timeline {
@ -17,33 +18,187 @@ export class Timeline {
this.settings = settings;
}
private async getFilePreview(file: TFile): Promise<string> {
const content = await this.app.vault.cachedRead(file);
const previewContent = content.replace(/^---[\s\S]*?---/, '').trim();
return previewContent.slice(0, 100) + (previewContent.length > 100 ? '...' : '');
// 辅助函数检查文件是否在timelines文件夹中
private isInTimelinesFolder(file: TFile): boolean {
return file.path.startsWith('timelines/') || file.path === 'timelines';
}
private async createTimelineItem(file: TFile): Promise<TimelineItem | null> {
private async getFilePreview(file: TFile, tag: string): Promise<string> {
const content = await this.app.vault.cachedRead(file);
const normalizedTag = tag.replace(/^#/, ''); // 使用传入的 tag
// 查找所有包含特定标签的行
const tagLines = content.split('\n').filter(line => {
// 精确匹配行内标签,避免匹配 frontmatter 中的 tags 数组
const tagRegex = new RegExp(`(^|\s)#${normalizedTag}(?=\s|$|[^\w\/])`); // 使用正向预查确保精确匹配标签
return tagRegex.test(line);
});
// 如果有找到包含特定标签的行,则返回第一行
if (tagLines.length > 0) {
const firstTagLine = tagLines[0];
return firstTagLine.slice(0, 100) + (firstTagLine.length > 100 ? '...' : '');
}
// 否则返回文件开头内容(去除 frontmatter
const previewContent = content.replace(/^---[\s\S]*?---/, '').trim();
return previewContent.slice(0, 50) + (previewContent.length > 50 ? '...' : '');
}
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];
if (dateValue) {
// 只有在提供了 tag 时才使用它来生成预览
const preview = tag ? await this.getFilePreview(file, tag) : await this.getFilePreview(file, ''); // 如果没有 tag传递空字符串
return {
date: new Date(dateValue),
title: file.basename,
path: file.path,
preview: await this.getFilePreview(file)
preview: preview,
isMilestone: this.checkMilestone(metadata?.frontmatter) // 检查是否为里程碑
};
}
return null;
}
private checkMilestone(frontmatter: any): boolean {
if (!this.settings.milestoneAttribute || !frontmatter) {
return false;
}
const attributeValue = frontmatter[this.settings.milestoneAttribute];
if (attributeValue === undefined || attributeValue === null) {
return false;
}
// 如果 milestoneValue 为空,则只要属性存在就标记为里程碑
if (this.settings.milestoneValue === '') {
return true;
}
// 否则,检查属性值是否与设置的值匹配(不区分大小写和类型)
return String(attributeValue).toLowerCase() === String(this.settings.milestoneValue).toLowerCase();
}
private sortItemsByDate(items: TimelineItem[]): TimelineItem[] {
return items.sort((a, b) => b.date.getTime() - a.date.getTime());
}
private extractLinkedFiles(content: string): TFile[] {
const linkedFiles: TFile[] = [];
const linkRegex = /\[\[([^\]]+)\]\]/g;
let match;
while ((match = linkRegex.exec(content)) !== null) {
const linkPath = match[1].split('|')[0]; // 处理带别名的链接
const file = this.app.metadataCache.getFirstLinkpathDest(linkPath, '');
if (file && file instanceof TFile) {
linkedFiles.push(file);
}
}
return linkedFiles;
}
async generateFromFileLinks(file: TFile): Promise<TimelineItem[]> {
const content = await this.app.vault.cachedRead(file);
const linkedFiles = this.extractLinkedFiles(content);
const items: TimelineItem[] = [];
for (const linkedFile of linkedFiles) {
// 忽略timelines文件夹中的文件
if (!this.isInTimelinesFolder(linkedFile)) {
const item = await this.createTimelineItem(linkedFile);
if (item) {
items.push(item);
}
}
}
return this.sortItemsByDate(items);
}
async generateFromMetadata(metadataQuery: string): Promise<TimelineItem[]> {
const allFiles = this.app.vault.getMarkdownFiles();
// 过滤掉timelines文件夹中的文件
const filteredFiles = allFiles.filter(file => !this.isInTimelinesFolder(file));
const items: TimelineItem[] = [];
// 更健壮的查询解析
let queryKey: string;
let queryValue: string | null = null;
// 尝试寻找第一个冒号作为分隔符
const firstColonIndex = metadataQuery.indexOf(':');
if (firstColonIndex > 0) {
queryKey = metadataQuery.substring(0, firstColonIndex).trim();
queryValue = metadataQuery.substring(firstColonIndex + 1).trim();
// 如果值为空,则只查询键
if (queryValue === '') {
queryValue = null;
}
} else {
// 如果没有冒号,则只查询键
queryKey = metadataQuery.trim();
}
for (const file of filteredFiles) {
try {
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 && fmValue !== null) {
if (Array.isArray(fmValue)) {
// 如果 frontmatter 值是数组,检查数组中是否包含查询值
// 更健壮的数组项匹配,支持多种格式
const cleanedQueryValue = queryValue.startsWith('- ') ? queryValue.substring(2) : queryValue;
// 转换所有数组项为字符串并修剪,然后进行不区分大小写的匹配
match = fmValue.some(item => {
const itemStr = String(item).trim().toLowerCase();
const queryStr = cleanedQueryValue.toLowerCase();
return itemStr === queryStr ||
itemStr.includes(queryStr) ||
queryStr.includes(itemStr);
});
} else {
// 非数组值的匹配,支持部分匹配和大小写不敏感
const fmValueStr = String(fmValue).trim().toLowerCase();
const queryValueStr = queryValue.toLowerCase();
match = fmValueStr === queryValueStr ||
fmValueStr.includes(queryValueStr) ||
queryValueStr.includes(fmValueStr);
}
}
} else {
// 只检查 key 是否存在
match = Object.prototype.hasOwnProperty.call(frontmatter, queryKey);
}
if (match) {
const item = await this.createTimelineItem(file, undefined, metadataQuery);
if (item) {
items.push(item);
}
}
} catch (error) {
console.error(`处理文件 ${file.path} 时出错:`, error);
continue;
}
}
return this.sortItemsByDate(items);
}
async generateFromFolder(folder: TFolder): Promise<TimelineItem[]> {
if (folder.path === 'timelines') {
// 检查文件夹是否为timelines或其子文件夹
if (folder.path === 'timelines' || folder.path.startsWith('timelines/')) {
return [];
}
@ -51,6 +206,11 @@ export class Timeline {
// 递归处理文件夹的函数
const processFolder = async (currentFolder: TFolder) => {
// 确保不处理timelines文件夹及其子文件夹
if (currentFolder.path === 'timelines' || currentFolder.path.startsWith('timelines/')) {
return;
}
for (const item of currentFolder.children) {
if (item instanceof TFile && item.extension === 'md') {
const metadata = this.app.metadataCache.getFileCache(item);
@ -61,10 +221,11 @@ export class Timeline {
date: new Date(dateValue),
title: item.basename,
path: item.path,
preview: await this.getFilePreview(item)
preview: await this.getFilePreview(item, ''),
isMilestone: this.checkMilestone(metadata?.frontmatter)
});
}
} else if (item instanceof TFolder && item.path !== 'timelines') {
} else if (item instanceof TFolder) {
// 递归处理子文件夹
await processFolder(item);
}
@ -79,31 +240,67 @@ export class Timeline {
async generateFromTag(tag: string): Promise<TimelineItem[]> {
const allFiles = this.app.vault.getMarkdownFiles();
// 过滤掉timelines文件夹中的文件
const filteredFiles = allFiles.filter(file => !this.isInTimelinesFolder(file));
const items: TimelineItem[] = [];
// 移除开头的#号(如果存在)
const normalizedTag = tag.replace(/^#/, '');
// 移除开头的#号(如果存在)并标准化标签
const normalizedTag = tag.replace(/^#/, '').trim();
for (const file of allFiles) {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache) continue;
// 辅助函数:检查两个标签是否匹配(支持精确匹配和子标签匹配)
const isMatchingTag = (fileTag: string, searchTag: string): boolean => {
const cleanFileTag = fileTag.replace(/^#/, '').trim().toLowerCase();
const cleanSearchTag = searchTag.toLowerCase();
return cleanFileTag === cleanSearchTag ||
cleanFileTag.startsWith(cleanSearchTag + '/') ||
cleanSearchTag.includes(cleanFileTag); // 支持部分匹配
};
for (const file of filteredFiles) {
try {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache) continue;
// 检查文件是否包含目标标签或其子标签
const hasMatchingTag = (cache.tags && cache.tags.some(tagObj => {
const fileTag = tagObj.tag.replace(/^#/, '');
return fileTag === normalizedTag || // 完全匹配
fileTag.startsWith(normalizedTag + '/'); // 子标签匹配
})) || (cache.frontmatter && cache.frontmatter.tags && cache.frontmatter.tags.some((frontmatterTag: string) => {
const fileTag = frontmatterTag.replace(/^#/, '');
return fileTag === normalizedTag || // 完全匹配
fileTag.startsWith(normalizedTag + '/'); // 子标签匹配
}));
let hasMatchingTag = false;
if (hasMatchingTag) {
const item = await this.createTimelineItem(file);
if (item) {
items.push(item);
// 检查文件内容中的标签cache.tags
if (cache.tags && cache.tags.length > 0) {
hasMatchingTag = cache.tags.some(tagObj => {
return isMatchingTag(tagObj.tag, normalizedTag);
});
}
// 如果内容中没有匹配的标签检查frontmatter中的标签
if (!hasMatchingTag && cache.frontmatter?.tags) {
const frontmatterTags = cache.frontmatter.tags;
if (Array.isArray(frontmatterTags)) {
// 数组形式的标签
hasMatchingTag = frontmatterTags.some((frontmatterTag: any) => {
if (typeof frontmatterTag !== 'string') return false;
return isMatchingTag(frontmatterTag, normalizedTag);
});
} else if (typeof frontmatterTags === 'string') {
// 字符串形式的标签,支持多种分隔符
const normalizedTags = frontmatterTags.replace(/[,;]/g, ' ').trim();
const tagList = normalizedTags.split(/\s+/);
hasMatchingTag = tagList.some((tagItem: string) => {
return isMatchingTag(tagItem, normalizedTag);
});
}
}
if (hasMatchingTag) {
// 将 normalizedTag 传递给 createTimelineItem
const item = await this.createTimelineItem(file, normalizedTag);
if (item) {
items.push(item);
}
}
} catch (error) {
console.error(`处理文件 ${file.path} 时出错:`, error);
continue;
}
}
@ -111,8 +308,15 @@ export class Timeline {
return this.sortItemsByDate(items);
}
async generateTimelineMarkdown(items: TimelineItem[], title: string, source: { type: 'tag' | 'folder', 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;
@ -130,11 +334,17 @@ export class Timeline {
}
if (currentMonth !== month) {
markdown += `\n### ${date.toLocaleString('default', { month: 'long' })}\n\n`;
const monthName = this.settings.language === 'zh-CN'
? date.toLocaleString('zh-CN', { month: 'long' })
: date.toLocaleString('en-US', { month: 'long' });
markdown += `\n### ${monthName}\n\n`;
currentMonth = month;
}
markdown += `#### ${date.toLocaleDateString('zh-CN')} - [[${item.title}]]\n\n`;
const dateFormat = this.settings.language === 'zh-CN'
? date.toLocaleDateString('zh-CN')
: date.toLocaleDateString('en-US');
markdown += `#### ${dateFormat} - [[${item.title}]]\n\n`;
}
return markdown;

View file

@ -4,11 +4,18 @@ export interface TimelineSettings {
fileNameSuffix: string;
lineWidth: number; // 时间轴线宽度
lineColor: string; // 时间轴线颜色
nodeSize: number; // 节点大小
nodeColor: string; // 节点颜色
itemSpacing: number; // 项目间距
cardBackground: string; // 卡片背景色
cardTextColor: string; // 卡片文字颜色
cardBorderColor: string; // 卡片边框颜色
milestoneCardBackground: string; // 里程碑卡片背景色
milestoneCardTextColor: string; // 里程碑卡片文字颜色
milestoneCardBorderColor: string; // 里程碑卡片边框颜色
animationDuration: number; // 动画持续时间(毫秒)
defaultPosition: 'left' | 'right'; // 时间轴视图默认位置
language: 'zh-CN' | 'en-US'; // 界面语言
milestoneAttribute: string; // 用于标记里程碑的自定义属性名
milestoneValue: string; // 里程碑属性需要匹配的值(为空则表示只要属性存在即可)
}
// 使用 CSS 变量作为默认值
@ -19,12 +26,19 @@ function getCssVariable(name: string): string {
export const DEFAULT_SETTINGS: TimelineSettings = {
lineWidth: 2,
lineColor: getCssVariable('--interactive-accent'),
nodeSize: 16,
nodeColor: getCssVariable('--interactive-accent'),
itemSpacing: 30,
cardBackground: getCssVariable('--background-primary-alt'),
cardTextColor: getCssVariable('--text-normal'),
cardBorderColor: getCssVariable('--background-modifier-border'),
milestoneCardBackground: '#ffffcc',
milestoneCardTextColor: getCssVariable('--text-normal'),
milestoneCardBorderColor: '#f0e68c',
animationDuration: 200,
dateAttribute: 'created', // 默认使用 frontmatter 中的 date 属性作为时间轴排序依据
fileNamePrefix: '',
fileNameSuffix: ''
fileNameSuffix: '',
defaultPosition: 'right', // 默认在右侧边栏
language: 'zh-CN', // 默认使用中文
milestoneAttribute: '', // 默认不使用里程碑属性
milestoneValue: '', // 默认值为空
};

View file

@ -1,19 +1,34 @@
import { ItemView, WorkspaceLeaf, TFolder, TFile, Notice, App } from 'obsidian';
import { Timeline, TimelineItem } from './Timeline';
import { TimelineSettings } from './TimelineSettings';
import { I18nStrings } from './i18n';
export const VIEW_TYPE_TIMELINE = 'timeline-view';
// 提取常量类名
const CLASS_TIMELINE_HEADER = 'timeline-header';
const CLASS_TIMELINE_CONTAINER = 'timeline-container';
const CLASS_TIMELINE_LINE = 'timeline-line';
const CLASS_TIMELINE_ERA = 'timeline-era';
const CLASS_TIMELINE_ERA_TITLE = 'timeline-era-title';
const CLASS_TIMELINE_ITEM = 'timeline-item';
const CLASS_TIMELINE_CARD = 'timeline-card';
const CLASS_TIMELINE_DATE = 'timeline-date';
const CLASS_TIMELINE_TITLE = 'timeline-title';
const CLASS_TIMELINE_PREVIEW = 'timeline-preview';
export class TimelineView extends ItemView {
private timeline: Timeline;
private settings: TimelineSettings;
private i18n: I18nStrings;
items: TimelineItem[] = [];
currentTitle: string = ''; // 新增:存储当前标题
currentTitle: string = '';
constructor(leaf: WorkspaceLeaf, settings: TimelineSettings) {
constructor(leaf: WorkspaceLeaf, settings: TimelineSettings, i18n: I18nStrings) {
super(leaf);
// 通过构造函数注入设置,而不是直接访问插件实例
this.timeline = new Timeline(this.app, settings);
this.settings = settings;
this.i18n = i18n;
}
getViewType(): string {
@ -21,29 +36,41 @@ export class TimelineView extends ItemView {
}
getDisplayText(): string {
return '时间轴视图';
return this.i18n.commands.openTimelineView;
}
async onOpen() {
await this.render();
try {
await this.render();
} catch (error) {
new Notice(this.i18n.errors.renderFailed);
}
}
async render() {
const container = this.containerEl.children[1];
container.empty();
// 添加标题显示
if (this.currentTitle) {
const titleContainer = container.createEl('div', { cls: 'timeline-header' });
titleContainer.createEl('h2', { text: this.currentTitle });
}
const timelineContainer = container.createEl('div', { cls: 'timeline-container' });
// 创建时间轴线容器
const timelineLine = timelineContainer.createEl('div', { cls: 'timeline-line' });
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 });
}
private groupItemsByYear(): Map<number, TimelineItem[]> {
const itemsByYear = new Map<number, TimelineItem[]>();
this.items.forEach(item => {
const year = item.date.getFullYear();
@ -52,65 +79,82 @@ export class TimelineView extends ItemView {
}
itemsByYear.get(year)?.push(item);
});
return itemsByYear;
}
// 渲染每个时间段
private renderItem(container: HTMLElement, item: TimelineItem, index: number, itemsLength: number) {
const itemClasses = [`${CLASS_TIMELINE_ITEM}${this.getItemClasses(index, itemsLength)}`];
if (item.isMilestone) {
itemClasses.push('timeline-item-milestone'); // 添加里程碑类
}
const itemEl = container.createEl('div', {
cls: itemClasses.join(' '),
attr: { style: `--timeline-line-color: ${this.settings.lineColor}` }
});
const card = itemEl.createEl('div', { cls: CLASS_TIMELINE_CARD });
card.createEl('div', {
cls: CLASS_TIMELINE_DATE,
text: item.date.toLocaleDateString(this.settings.language === 'zh-CN' ? 'zh-CN' : 'en-US')
});
const titleEl = card.createEl('div', {
cls: CLASS_TIMELINE_TITLE,
attr: { style: `color: ${item.isMilestone ? this.settings.milestoneCardTextColor : this.settings.cardTextColor} !important` }
});
titleEl.textContent = item.title;
titleEl.dataset.colorSetting = item.isMilestone ? 'milestoneCardTextColor' : 'cardTextColor';
if (item.preview) {
const previewEl = card.createEl('div', {
cls: CLASS_TIMELINE_PREVIEW,
attr: { style: `color: ${item.isMilestone ? this.settings.milestoneCardTextColor : this.settings.cardTextColor} !important` }
});
previewEl.textContent = item.preview;
previewEl.dataset.colorSetting = item.isMilestone ? 'milestoneCardTextColor' : 'cardTextColor';
}
titleEl.addEventListener('click', async () => {
const file = this.app.vault.getAbstractFileByPath(item.path);
if (file instanceof TFile) {
await this.app.workspace.getLeaf().openFile(file);
}
});
}
private getItemClasses(index: number, itemsLength: number): string {
return (index === 0 ? ' first-item' : '') +
(index === itemsLength - 1 ? ' last-item' : '');
}
private renderEra(container: HTMLElement, year: number, items: TimelineItem[]) {
const era = container.createEl('div', { cls: CLASS_TIMELINE_ERA });
era.createEl('div', { cls: CLASS_TIMELINE_ERA_TITLE, text: `${year}` });
items.forEach((item, index) => this.renderItem(era, item, index, items.length));
}
async render() {
const container = this.containerEl.children[1] as HTMLElement;
container.empty();
if (this.currentTitle) {
this.renderTitle(container);
}
const timelineContainer = container.createEl('div', { cls: CLASS_TIMELINE_CONTAINER });
timelineContainer.createEl('div', { cls: CLASS_TIMELINE_LINE });
const itemsByYear = this.groupItemsByYear();
Array.from(itemsByYear.entries())
.sort(([yearA], [yearB]) => yearB - yearA)
.forEach(([year, items]) => {
const era = timelineContainer.createEl('div', { cls: 'timeline-era' });
// 创建年份标题
era.createEl('div', {
cls: 'timeline-era-title',
text: `${year}`
});
// 渲染该年份的所有项目
items.forEach((item, index) => {
const itemEl = era.createEl('div', {
cls: 'timeline-item' +
(index === 0 ? ' first-item' : '') +
(index === items.length - 1 ? ' last-item' : '')
});
// 创建内容卡片
const card = itemEl.createEl('div', { cls: 'timeline-card' });
// 日期
card.createEl('div', {
cls: 'timeline-date',
text: item.date.toLocaleDateString('zh-CN')
});
// 标题
const titleEl = card.createEl('div', {
cls: 'timeline-title'
});
titleEl.innerHTML = item.title;
// 预览内容
if (item.preview) {
const previewEl = card.createEl('div', {
cls: 'timeline-preview'
});
previewEl.innerHTML = item.preview;
}
// 点击事件
titleEl.addEventListener('click', async () => {
const file = this.app.vault.getAbstractFileByPath(item.path);
if (file instanceof TFile) {
await this.app.workspace.getLeaf().openFile(file);
}
});
});
});
.forEach(([year, items]) => this.renderEra(timelineContainer, year, items));
}
async updateFromFolder(folderPath: string) {
const folder = this.app.vault.getAbstractFileByPath(folderPath) as TFolder;
if (folder) {
this.currentTitle = `📂 ${folder.name}`; // 设置文件夹标题
this.currentTitle = `📂 ${folder.name}`;
this.items = await this.timeline.generateFromFolder(folder);
await this.render();
}
@ -119,11 +163,10 @@ export class TimelineView extends ItemView {
async updateFromTag(tag: string) {
try {
this.currentTitle = `🏷️ ${tag}`;
const items = await this.timeline.generateFromTag(tag);
this.items = items;
this.items = await this.timeline.generateFromTag(tag);
if (this.items.length === 0) {
new Notice(`没有找到包含标签 #${tag} 及其子标签的文件`);
new Notice(this.i18n.errors.noTaggedFiles.replace('{tag}', tag));
return;
}
@ -134,7 +177,27 @@ export class TimelineView extends ItemView {
}
}
async updateFromFile(filePath: string) {
try {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file && file instanceof TFile) {
this.currentTitle = `📄 ${file.basename}`;
this.items = await this.timeline.generateFromFileLinks(file);
if (this.items.length === 0) {
new Notice(this.i18n.errors.noFileLinks.replace('{filename}', file.basename));
return;
}
await this.render();
}
} catch (error) {
new Notice(this.i18n.errors.generateFileFailed);
console.error(error);
}
}
getIcon(): string {
return 'history'; // 或者使用 'clock', 'alarm-clock' 等
return 'history';
}
}

227
src/i18n.ts Normal file
View file

@ -0,0 +1,227 @@
export interface I18nStrings {
settings: {
resetSettings: string;
resetSettingsDesc: string;
resetSettingsButton: string;
lineWidth: string;
lineWidthDesc: string;
lineColor: string;
lineColorDesc: string;
colorPickerPlaceholder: string;
itemSpacing: string;
itemSpacingDesc: string;
cardBackground: string;
cardBackgroundDesc: string;
cardTextColor: string;
cardTextColorDesc: string;
cardBorderColor: string;
cardBorderColorDesc: string;
milestoneCardBackground: string;
milestoneCardBackgroundDesc: string;
milestoneCardTextColor: string;
milestoneCardTextColorDesc: string;
milestoneCardBorderColor: string;
milestoneCardBorderColorDesc: string;
animationDuration: string;
animationDurationDesc: string;
dateAttribute: string;
dateAttributeDesc: string;
fileNamePrefix: string;
fileNamePrefixDesc: string;
fileNameSuffix: string;
fileNameSuffixDesc: string;
defaultPosition: string;
defaultPositionDesc: string;
leftSidebar: string;
rightSidebar: string;
language: string;
languageDesc: string;
milestoneAttribute: string;
milestoneAttributeDesc: string;
milestoneValue: string;
milestoneValueDesc: string;
};
commands: {
openTimelineView: string;
generateFromFolder: string;
generateFileFromFolder: string;
generateFromTag: string;
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;
generateFolderFailed: string;
generateTagFailed: string;
generateFileFailed: string;
noTaggedFiles: string;
noFileLinks: string;
createViewFailed: string;
noMetadataFiles: string; // New error
generateMetadataFailed: string; // New error
};
}
export const zhCN: I18nStrings = {
settings: {
resetSettings: '重置设置',
resetSettingsDesc: '将所有设置恢复为默认值',
resetSettingsButton: '重置为默认值',
lineWidth: '时间轴线宽度',
lineWidthDesc: '设置主时间轴线的宽度(像素)',
lineColor: '时间轴线颜色',
lineColorDesc: '设置时间轴线的颜色',
colorPickerPlaceholder: '点击左侧色盘选择颜色',
itemSpacing: '项目间距',
itemSpacingDesc: '设置时间轴项目之间的间距(像素)',
cardBackground: '卡片背景色',
cardBackgroundDesc: '设置内容卡片的背景颜色',
cardTextColor: '卡片文字颜色',
cardTextColorDesc: '设置普通事件卡片的文字颜色',
cardBorderColor: '卡片边框颜色',
cardBorderColorDesc: '设置普通事件卡片的边框颜色',
milestoneCardBackground: '里程碑卡片背景色',
milestoneCardBackgroundDesc: '设置里程碑事件卡片的背景颜色',
milestoneCardTextColor: '里程碑卡片文字颜色',
milestoneCardTextColorDesc: '设置里程碑事件卡片的文字颜色',
milestoneCardBorderColor: '里程碑卡片边框颜色',
milestoneCardBorderColorDesc: '设置里程碑事件卡片的边框颜色',
animationDuration: '动画持续时间',
animationDurationDesc: '设置悬停动画的持续时间(毫秒)',
dateAttribute: '日期属性',
dateAttributeDesc: '选择用于时间轴排序的 frontmatter 日期属性created, updated, date 等)',
fileNamePrefix: '文件名前缀',
fileNamePrefixDesc: '设置生成的时间轴文件名前缀',
fileNameSuffix: '文件名后缀',
fileNameSuffixDesc: '设置生成的时间轴文件名后缀',
defaultPosition: '默认位置',
defaultPositionDesc: '选择时间轴视图在左侧还是右侧边栏显示',
leftSidebar: '左侧边栏',
rightSidebar: '右侧边栏',
language: '语言',
languageDesc: '选择插件界面语言',
milestoneAttribute: '里程碑属性',
milestoneAttributeDesc: '用于标记里程碑事件的 frontmatter 属性名称例如milestone',
milestoneValue: '里程碑值',
milestoneValueDesc: '里程碑属性需要匹配的值留空则表示只要属性存在即可例如true, 1'
},
commands: {
openTimelineView: '打开时间轴视图',
generateFromFolder: '从文件夹生成时间轴视图',
generateFileFromFolder: '从文件夹生成时间轴文件',
generateFromTag: '从标签生成时间轴视图',
generateFileFromTag: '从标签生成时间轴文件',
generateFromFileLinks: '从当前文件链接生成时间轴视图',
generateFileFromFileLinks: '从当前文件链接生成时间轴文件',
generateFromMetadata: '从元数据生成时间轴视图',
generateFileFromMetadata: '从元数据生成时间轴文件'
},
modal: {
metadataInputTitle: '输入元数据字符串',
metadataQueryName: '元数据查询',
metadataQueryDesc: '例如author: - 我 或 status: ongoing',
metadataInputPlaceholder: '输入元数据...',
submitButton: '确定',
cancelButton: '取消',
emptyInputNotice: '请输入元数据字符串'
},
errors: {
renderFailed: '时间轴视图渲染失败',
generateFolderFailed: '生成文件夹时间轴视图时出错',
generateTagFailed: '生成标签时间轴视图时出错',
generateFileFailed: '从文件链接生成时间轴视图时出错',
noTaggedFiles: '没有找到包含标签 #{tag} 及其子标签的文件',
noFileLinks: '文件 {filename} 中没有找到可用的链接或日期信息',
createViewFailed: '无法创建时间轴视图:请检查侧边栏空间',
noMetadataFiles: '没有找到与元数据查询 “{query}” 匹配的文件',
generateMetadataFailed: '从元数据生成时间轴失败'
}
};
export const enUS: I18nStrings = {
settings: {
resetSettings: 'Reset Settings',
resetSettingsDesc: 'Restore all settings to default values',
resetSettingsButton: 'Reset to Defaults',
lineWidth: 'Timeline Line Width',
lineWidthDesc: 'Set the width of the main timeline line (pixels)',
lineColor: 'Timeline Line Color',
lineColorDesc: 'Set the color of the timeline line',
colorPickerPlaceholder: 'Click the color picker on the left',
itemSpacing: 'Item Spacing',
itemSpacingDesc: 'Set the spacing between timeline items (pixels)',
cardBackground: 'Card Background Color',
cardBackgroundDesc: 'Set the background color of content cards',
cardTextColor: 'Card Text Color',
cardTextColorDesc: 'Set the text color of regular event cards',
cardBorderColor: 'Card Border Color',
cardBorderColorDesc: 'Set the border color of regular event cards',
milestoneCardBackground: 'Milestone Card Background',
milestoneCardBackgroundDesc: 'Set the background color of milestone event cards',
milestoneCardTextColor: 'Milestone Card Text Color',
milestoneCardTextColorDesc: 'Set the text color of milestone event cards',
milestoneCardBorderColor: 'Milestone Card Border Color',
milestoneCardBorderColorDesc: 'Set the border color of milestone event cards',
animationDuration: 'Animation Duration',
animationDurationDesc: 'Set the duration of hover animations (milliseconds)',
dateAttribute: 'Date Attribute',
dateAttributeDesc: 'Choose the frontmatter date attribute for timeline sorting (e.g., created, updated, date)',
fileNamePrefix: 'File Name Prefix',
fileNamePrefixDesc: 'Set the prefix for generated timeline file names',
fileNameSuffix: 'File Name Suffix',
fileNameSuffixDesc: 'Set the suffix for generated timeline file names',
defaultPosition: 'Default Position',
defaultPositionDesc: 'Choose whether to display the timeline view in the left or right sidebar',
leftSidebar: 'Left Sidebar',
rightSidebar: 'Right Sidebar',
language: 'Language',
languageDesc: 'Select plugin interface language',
milestoneAttribute: 'Milestone Attribute',
milestoneAttributeDesc: 'The frontmatter attribute name used to mark milestone events (e.g., milestone)',
milestoneValue: 'Milestone Value',
milestoneValueDesc: 'The value the milestone attribute needs to match (leave empty to match if the attribute exists, e.g., true, 1)'
},
commands: {
openTimelineView: 'Open Timeline View',
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 Current File Links',
generateFileFromFileLinks: 'Generate Timeline File from Current 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',
generateFolderFailed: 'Error generating folder timeline view',
generateTagFailed: 'Error generating tag timeline view',
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',
noMetadataFiles: 'No files found matching metadata query "{query}"',
generateMetadataFailed: 'Failed to generate timeline from metadata'
}
};

View file

@ -1,26 +1,32 @@
import {
App,
Plugin,
PluginSettingTab,
Setting,
TFile,
import {
App,
Plugin,
PluginSettingTab,
Setting,
TFile,
Menu,
TAbstractFile,
normalizePath,
Notice
Notice,
Modal,
TextComponent
} from 'obsidian';
import { I18nStrings, zhCN, enUS } from './i18n';
import { TimelineView, VIEW_TYPE_TIMELINE } from './TimelineView';
import { FolderSuggestModal } from './FolderSuggest';
import { TagSuggestModal } from './TagSuggest';
import { FileSuggestModal } from './FileSuggest';
import { TimelineSettings, DEFAULT_SETTINGS } from './TimelineSettings';
import { Timeline } from './Timeline';
import { Timeline, TimelineItem } from './Timeline';
import { TFolder } from 'obsidian';
export default class TimelinePlugin extends Plugin {
settings: TimelineSettings;
i18n: I18nStrings;
async onload() {
await this.loadSettings();
this.i18n = this.settings.language === 'zh-CN' ? zhCN : enUS;
// 添加设置标签页
this.addSettingTab(new TimelineSettingTab(this.app, this));
@ -30,7 +36,7 @@ export default class TimelinePlugin extends Plugin {
try {
this.registerView(
VIEW_TYPE_TIMELINE,
(leaf) => new TimelineView(leaf, this.settings)
(leaf) => new TimelineView(leaf, this.settings, this.i18n)
);
// 添加文件菜单项
@ -46,20 +52,25 @@ 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
.setTitle('打开时间轴视图')
.setTitle(this.i18n.commands.openTimelineView)
.setIcon('clock')
.onClick(async () => {
await this.activateView();
await this.activateView(this.settings.defaultPosition);
const view = this.getTimelineView();
if (view) {
if (type === 'folder') {
await view.updateFromFolder(value);
} else if (type === 'tag') {
await view.updateFromTag(value);
} else if (type === 'file') {
await view.updateFromFile(value);
} else if (type === 'metadata') {
await (view as any).updateFromMetadata(value);
}
}
});
@ -72,12 +83,12 @@ export default class TimelinePlugin extends Plugin {
// 从文件夹生成时间轴视图
this.addCommand({
id: 'generate-timeline-view-from-folder',
name: '从文件夹生成时间轴视图',
name: this.i18n.commands.generateFromFolder,
callback: async () => {
try {
const folderPath = await this.selectFolder();
if (folderPath) {
await this.activateView();
await this.activateView(this.settings.defaultPosition);
const view = this.getTimelineView();
if (view) {
await view.updateFromFolder(folderPath);
@ -92,19 +103,32 @@ export default class TimelinePlugin extends Plugin {
// 从文件夹生成时间轴文件
this.addCommand({
id: 'generate-timeline-file-from-folder',
name: '从文件夹生成时间轴文件',
name: this.i18n.commands.generateFileFromFolder,
callback: async () => {
try {
const folderPath = await this.selectFolder();
if (folderPath) {
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) {
throw new Error(`Invalid folder path: ${folderPath}`);
}
const timeline = new Timeline(this.app, this.settings);
const items = await timeline.generateFromFolder(this.app.vault.getAbstractFileByPath(folderPath) as TFolder);
const items = await timeline.generateFromFolder(folder);
const content = await timeline.generateTimelineMarkdown(items, `Timeline - ${folderPath}`, { type: 'folder', value: folderPath });
// 创建并打开文件
const { folderPath: targetFolder, fileName } = await this.createNestedFolders(folderPath);
const finalFileName = this.generateFileName(fileName || folderPath.split('/').pop() || '');
const filePath = `${targetFolder}/${finalFileName}.md`;
// 检查文件是否存在,如果存在则删除
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (existingFile) {
await this.app.vault.delete(existingFile);
}
const file = await this.app.vault.create(filePath, content);
await this.app.workspace.getLeaf().openFile(file);
}
@ -117,12 +141,12 @@ export default class TimelinePlugin extends Plugin {
// 从标签生成时间轴视图
this.addCommand({
id: 'generate-timeline-view-from-tag',
name: '从标签生成时间轴视图',
name: this.i18n.commands.generateFromTag,
callback: async () => {
try {
const tag = await this.selectTag();
if (tag) {
await this.activateView();
await this.activateView(this.settings.defaultPosition);
const view = this.getTimelineView();
if (view) {
await view.updateFromTag(tag);
@ -137,7 +161,7 @@ export default class TimelinePlugin extends Plugin {
// 从标签生成时间轴文件
this.addCommand({
id: 'generate-timeline-file-from-tag',
name: '从标签生成时间轴文件',
name: this.i18n.commands.generateFileFromTag,
callback: async () => {
try {
const tag = await this.selectTag();
@ -149,38 +173,232 @@ export default class TimelinePlugin extends Plugin {
}
}
});
// 从文件链接生成时间轴视图
this.addCommand({
id: 'generate-timeline-view-from-file-links',
name: this.i18n.commands.generateFromFileLinks,
callback: async () => {
try {
// 获取当前活动文件
const file = this.app.workspace.getActiveFile();
if (!file || !(file instanceof TFile) || file.extension !== 'md') {
new Notice(this.i18n.errors.generateFileFailed);
return;
}
// 检查文件是否包含链接
const cache = this.app.metadataCache.getFileCache(file);
if (!cache || !cache.links || cache.links.length === 0) {
new Notice(this.i18n.errors.noFileLinks.replace('{filename}', file.basename));
return;
}
await this.activateView(this.settings.defaultPosition);
const view = this.getTimelineView();
if (view) {
const timeline = new Timeline(this.app, this.settings);
const items = await timeline.generateFromFileLinks(file);
view.items = items;
view.currentTitle = `🔗 ${file.basename} `;
await view.render();
}
} catch (error) {
console.error('从文件链接生成时间轴视图时出错:', error);
new Notice(this.i18n.errors.generateFileFailed);
}
}
});
// 从文件链接生成时间轴文件
this.addCommand({
id: 'generate-timeline-file-from-file-links',
name: this.i18n.commands.generateFileFromFileLinks,
callback: async () => {
try {
// 获取当前活动文件
const file = this.app.workspace.getActiveFile();
if (!file || !(file instanceof TFile) || file.extension !== 'md') {
new Notice(this.i18n.errors.generateFileFailed);
return;
}
// 检查文件是否包含链接
const cache = this.app.metadataCache.getFileCache(file);
if (!cache || !cache.links || cache.links.length === 0) {
new Notice(this.i18n.errors.noFileLinks.replace('{filename}', file.basename));
return;
}
const timeline = new Timeline(this.app, this.settings);
const items = await timeline.generateFromFileLinks(file);
const content = await timeline.generateTimelineMarkdown(
items,
`Timeline - Links in ${file.basename}`,
{ type: 'file', value: file.path }
);
const { folderPath } = await this.createNestedFolders('linked-files');
const finalFileName = this.generateFileName(file.basename);
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.generateFileFailed);
}
}
});
// 从元数据生成时间轴视图
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);
}
}
});
// 添加file-open事件监听器
this.registerEvent(
this.app.workspace.on("file-open", async (file) => {
if (file instanceof TFile && file.path.startsWith("timelines/") && file.extension === "md") {
const cache = this.app.metadataCache.getFileCache(file);
const generatedFrom = cache?.frontmatter?.generated_from;
if (generatedFrom) {
const [type, ...valueParts] = generatedFrom.split(":");
const value = valueParts.join(":");
const timeline = new Timeline(this.app, this.settings);
let items: TimelineItem[] = [];
if (type === "folder") {
const folder = this.app.vault.getAbstractFileByPath(value);
if (folder instanceof TFolder) {
items = await timeline.generateFromFolder(folder);
}
} else if (type === "tag") {
items = await timeline.generateFromTag(value);
} else if (type === "file") {
const targetFile = this.app.vault.getAbstractFileByPath(value);
if (targetFile instanceof TFile) {
items = await timeline.generateFromFileLinks(targetFile);
}
} else if (type === "metadata") {
items = await timeline.generateFromMetadata(value);
}
const content = await timeline.generateTimelineMarkdown(items, `Timeline - ${value}`, { type, value });
await this.app.vault.modify(file, content);
}
}
})
);
} catch (error) {
console.error('插件加载时出错:', error);
}
}
async selectFolder(): Promise<string | null> {
const modal = new FolderSuggestModal(this.app);
const modal = new FolderSuggestModal(this.app, this.settings);
return await modal.openAndGetValue();
}
async selectFile(): Promise<TFile | null> {
const modal = new FileSuggestModal(this.app, this.settings);
return await modal.openAndGetValue();
}
async selectTag(): Promise<string | null> {
const modal = new TagSuggestModal(this.app);
const modal = new TagSuggestModal(this.app, this.settings);
return await modal.openAndGetValue();
}
async activateView() {
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;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_TIMELINE)[0];
if (!leaf) {
const newLeaf = workspace.getRightLeaf(false);
if (newLeaf) {
leaf = newLeaf;
await leaf.setViewState({ type: VIEW_TYPE_TIMELINE });
}
}
// 获取所有时间轴视图的叶子节点
const leaves = workspace.getLeavesOfType(VIEW_TYPE_TIMELINE);
if (leaf) {
workspace.revealLeaf(leaf);
return leaf;
// 如果已存在时间轴视图,直接使用第一个
if (leaves.length > 0) {
workspace.revealLeaf(leaves[0]);
return leaves[0];
}
return null;
// 不存在时创建新视图
const newLeaf = position === 'left'
? workspace.getLeftLeaf(false)
: workspace.getRightLeaf(false);
if (!newLeaf) {
new Notice('无法创建时间轴视图:请检查侧边栏空间');
return null;
}
await newLeaf.setViewState({ type: VIEW_TYPE_TIMELINE });
workspace.revealLeaf(newLeaf);
return newLeaf;
}
getTimelineView(): TimelineView | null {
@ -209,28 +427,39 @@ export default class TimelinePlugin extends Plugin {
width: ${this.settings.lineWidth}px !important;
background: ${this.settings.lineColor} !important;
}
.timeline-item::before {
width: ${this.settings.nodeSize}px !important;
height: ${this.settings.nodeSize}px !important;
border-color: ${this.settings.nodeColor} !important;
left: ${-(this.settings.nodeSize/2 + 6)}px !important;
}
.timeline-item::after {
background: ${this.settings.lineColor} !important;
background: var(--timeline-line-color, ${this.settings.lineColor}) !important;
}
.timeline-item {
margin: ${this.settings.itemSpacing}px 0 !important;
}
.timeline-card {
background: ${this.settings.cardBackground} !important;
color: ${this.settings.cardTextColor} !important;
border: 1px solid ${this.settings.cardBorderColor} !important;
transition: transform ${this.settings.animationDuration}ms !important;
}
.timeline-item-milestone .timeline-card {
background: ${this.settings.milestoneCardBackground} !important;
color: ${this.settings.milestoneCardTextColor} !important;
border: 1px solid ${this.settings.milestoneCardBorderColor} !important;
}
[data-color-setting="cardTextColor"] {
color: ${this.settings.cardTextColor} !important;
}
[data-color-setting="milestoneCardTextColor"] {
color: ${this.settings.milestoneCardTextColor} !important;
}
.timeline-date {
color: ${this.settings.cardTextColor} !important;
}
.timeline-item-milestone .timeline-date {
color: ${this.settings.milestoneCardTextColor} !important;
}
.timeline-era-title {
color: var(--timeline-line-color, ${this.settings.lineColor}) !important;
}
`;
// 移除旧样式
document.getElementById('timeline-custom-styles')?.remove();
document.head.appendChild(style);
@ -282,6 +511,12 @@ export default class TimelinePlugin extends Plugin {
const { folderPath, fileName } = await this.createNestedFolders(tagPath);
const finalFileName = this.generateFileName(fileName || '');
const filePath = `${folderPath}/${finalFileName}.md`;
// 检查文件是否存在,如果存在则删除
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (existingFile) {
await this.app.vault.delete(existingFile);
}
const timeline = new Timeline(this.app, this.settings);
const items = await timeline.generateFromTag(tag);
@ -297,6 +532,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;
@ -309,13 +623,11 @@ class TimelineSettingTab extends PluginSettingTab {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: '时间轴设置'});
new Setting(containerEl)
.setName('重置设置')
.setDesc('将所有设置恢复为默认值')
.setName(this.plugin.i18n.settings.resetSettings)
.setDesc(this.plugin.i18n.settings.resetSettingsDesc)
.addButton(button => button
.setButtonText('重置为默认值')
.setButtonText(this.plugin.i18n.settings.resetSettingsButton)
.onClick(async () => {
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
await this.plugin.saveSettings();
@ -323,9 +635,49 @@ class TimelineSettingTab extends PluginSettingTab {
this.display();
}));
// 添加语言选择设置
new Setting(containerEl)
.setName('时间轴线宽度')
.setDesc('设置主时间轴线的宽度(像素)')
.setName(this.plugin.i18n.settings.language)
.setDesc(this.plugin.i18n.settings.languageDesc)
.addDropdown(dropdown => dropdown
.addOption('zh-CN', '中文')
.addOption('en-US', 'English')
.setValue(this.plugin.settings.language)
.onChange(async (value) => {
this.plugin.settings.language = value as 'zh-CN' | 'en-US';
this.plugin.i18n = value === 'zh-CN' ? zhCN : enUS;
await this.plugin.saveSettings();
// 重新显示设置面板以更新语言
this.display();
}));
// 添加里程碑属性设置
new Setting(containerEl)
.setName(this.plugin.i18n.settings.milestoneAttribute)
.setDesc(this.plugin.i18n.settings.milestoneAttributeDesc) //
.addText(text => text
.setPlaceholder('milestone')
.setValue(this.plugin.settings.milestoneAttribute)
.onChange(async (value) => {
this.plugin.settings.milestoneAttribute = value;
await this.plugin.saveSettings();
}));
// 添加里程碑值设置
new Setting(containerEl)
.setName(this.plugin.i18n.settings.milestoneValue)
.setDesc(this.plugin.i18n.settings.milestoneValueDesc)
.addText(text => text
.setPlaceholder('true or 1 (leave empty to match any value)')
.setValue(this.plugin.settings.milestoneValue)
.onChange(async (value) => {
this.plugin.settings.milestoneValue = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName(this.plugin.i18n.settings.lineWidth)
.setDesc(this.plugin.i18n.settings.lineWidthDesc)
.addSlider(slider => slider
.setLimits(1, 10, 1)
.setValue(this.plugin.settings.lineWidth)
@ -335,8 +687,8 @@ class TimelineSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('时间轴线颜色')
.setDesc('设置时间轴线的颜色')
.setName(this.plugin.i18n.settings.lineColor)
.setDesc(this.plugin.i18n.settings.lineColorDesc)
.addColorPicker(color => color
.setValue(this.plugin.settings.lineColor)
.onChange(async (value) => {
@ -344,7 +696,7 @@ class TimelineSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}))
.addText(text => text
.setPlaceholder('点击左侧色盘选择颜色')
.setPlaceholder(this.plugin.i18n.settings.colorPickerPlaceholder)
.setValue('')
.onChange(async (value) => {
if (value) {
@ -354,38 +706,8 @@ class TimelineSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('节点大小')
.setDesc('设置时间节点的大小(像素)')
.addSlider(slider => slider
.setLimits(8, 32, 2)
.setValue(this.plugin.settings.nodeSize)
.onChange(async (value) => {
this.plugin.settings.nodeSize = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('节点颜色')
.setDesc('设置时间节点的颜色')
.addColorPicker(color => color
.setValue(this.plugin.settings.nodeColor)
.onChange(async (value) => {
this.plugin.settings.nodeColor = value;
await this.plugin.saveSettings();
}))
.addText(text => text
.setPlaceholder('点击左侧色盘选择颜色')
.setValue('')
.onChange(async (value) => {
if (value) {
this.plugin.settings.nodeColor = value;
await this.plugin.saveSettings();
}
}));
new Setting(containerEl)
.setName('项目间距')
.setDesc('设置时间轴项目之间的间距(像素)')
.setName(this.plugin.i18n.settings.itemSpacing)
.setDesc(this.plugin.i18n.settings.itemSpacingDesc)
.addSlider(slider => slider
.setLimits(10, 100, 5)
.setValue(this.plugin.settings.itemSpacing)
@ -394,28 +716,74 @@ class TimelineSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
// 卡片样式设置
new Setting(containerEl)
.setName('卡片背景色')
.setDesc('设置内容卡片的背景颜色')
.setName(this.plugin.i18n.settings.cardBackground)
.setDesc(this.plugin.i18n.settings.cardBackgroundDesc)
.addColorPicker(color => color
.setValue(this.plugin.settings.cardBackground)
.onChange(async (value) => {
this.plugin.settings.cardBackground = value;
await this.plugin.saveSettings();
}))
.addText(text => text
.setPlaceholder('点击左侧色盘选择颜色')
.setValue('')
})
)
.setTooltip(this.plugin.i18n.settings.colorPickerPlaceholder);
new Setting(containerEl)
.setName(this.plugin.i18n.settings.cardTextColor)
.setDesc(this.plugin.i18n.settings.cardTextColorDesc)
.addColorPicker(color => color
.setValue(this.plugin.settings.cardTextColor)
.onChange(async (value) => {
if (value) {
this.plugin.settings.cardBackground = value;
await this.plugin.saveSettings();
}
}));
this.plugin.settings.cardTextColor = value;
await this.plugin.saveSettings();
})
)
new Setting(containerEl)
.setName(this.plugin.i18n.settings.cardBorderColor)
.setDesc(this.plugin.i18n.settings.cardBorderColorDesc)
.addColorPicker(color => color
.setValue(this.plugin.settings.cardBorderColor)
.onChange(async (value) => {
this.plugin.settings.cardBorderColor = value;
await this.plugin.saveSettings();
})
)
// 里程碑卡片样式设置
new Setting(containerEl)
.setName(this.plugin.i18n.settings.milestoneCardBackground)
.setDesc(this.plugin.i18n.settings.milestoneCardBackgroundDesc)
.addColorPicker(color => color
.setValue(this.plugin.settings.milestoneCardBackground)
.onChange(async (value) => {
this.plugin.settings.milestoneCardBackground = value;
await this.plugin.saveSettings();
})
)
new Setting(containerEl)
.setName('动画持续时间')
.setDesc('设置悬停动画的持续时间(毫秒)')
.setName(this.plugin.i18n.settings.milestoneCardTextColor)
.setDesc(this.plugin.i18n.settings.milestoneCardTextColorDesc)
.addColorPicker(color => color
.setValue(this.plugin.settings.milestoneCardTextColor)
.onChange(async (value) => {
this.plugin.settings.milestoneCardTextColor = value;
await this.plugin.saveSettings();
})
)
new Setting(containerEl)
.setName(this.plugin.i18n.settings.milestoneCardBorderColor)
.setDesc(this.plugin.i18n.settings.milestoneCardBorderColorDesc)
.addColorPicker(color => color
.setValue(this.plugin.settings.milestoneCardBorderColor)
.onChange(async (value) => {
this.plugin.settings.milestoneCardBorderColor = value;
await this.plugin.saveSettings();
})
)
new Setting(containerEl)
.setName(this.plugin.i18n.settings.animationDuration)
.setDesc(this.plugin.i18n.settings.animationDurationDesc)
.addSlider(slider => slider
.setLimits(0, 1000, 50)
.setValue(this.plugin.settings.animationDuration)
@ -424,40 +792,50 @@ class TimelineSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
// 在文件名设置前添加日期属性设置
new Setting(containerEl)
.setName('日期属性')
.setDesc('选择用于时间轴排序的 frontmatter 日期属性created, updated, date 等)')
.setName(this.plugin.i18n.settings.dateAttribute)
.setDesc(this.plugin.i18n.settings.dateAttributeDesc)
.addText(text => text
.setPlaceholder('输入日期属性名')
.setPlaceholder('created')
.setValue(this.plugin.settings.dateAttribute)
.onChange(async (value) => {
this.plugin.settings.dateAttribute = value;
await this.plugin.saveSettings();
}));
// 添加文件名前缀设置
new Setting(containerEl)
.setName('文件名前缀')
.setDesc('设置生成的时间轴文件名前缀')
.setName(this.plugin.i18n.settings.fileNamePrefix)
.setDesc(this.plugin.i18n.settings.fileNamePrefixDesc)
.addText(text => text
.setPlaceholder('输入前缀')
.setPlaceholder('')
.setValue(this.plugin.settings.fileNamePrefix)
.onChange(async (value) => {
this.plugin.settings.fileNamePrefix = value;
await this.plugin.saveSettings();
}));
// 添加文件名后缀设置
new Setting(containerEl)
.setName('文件名后缀')
.setDesc('设置生成的时间轴文件名后缀')
.setName(this.plugin.i18n.settings.fileNameSuffix)
.setDesc(this.plugin.i18n.settings.fileNameSuffixDesc)
.addText(text => text
.setPlaceholder('输入后缀')
.setPlaceholder('')
.setValue(this.plugin.settings.fileNameSuffix)
.onChange(async (value) => {
this.plugin.settings.fileNameSuffix = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName(this.plugin.i18n.settings.defaultPosition)
.setDesc(this.plugin.i18n.settings.defaultPositionDesc)
.addDropdown(dropdown => dropdown
.addOption('left', this.plugin.i18n.settings.leftSidebar)
.addOption('right', this.plugin.i18n.settings.rightSidebar)
.setValue(this.plugin.settings.defaultPosition)
.onChange(async (value) => {
this.plugin.settings.defaultPosition = value as 'left' | 'right';
await this.plugin.saveSettings();
})
);
}
}

View file

@ -8,7 +8,9 @@ If your plugin does not need CSS, delete this file.
*/
.timeline-container {
padding: 20px 20px 20px 100px;
width: 100%;
max-width: 100%;
overflow-x: hidden;
position: relative;
}
@ -20,9 +22,9 @@ If your plugin does not need CSS, delete this file.
/* 时间轴线 */
.timeline-line {
position: absolute;
left: 50px;
top: 80px; /* 从第一个项目开始 */
bottom: 20px; /* 到最后一个项目结束 */
left: 10%;
top: 60px; /* 从第一个项目开始 */
bottom: 30px; /* 到最后一个项目结束 */
width: 2px;
background: var(--interactive-accent);
}
@ -35,9 +37,9 @@ If your plugin does not need CSS, delete this file.
.timeline-era-title {
font-size: 1.5em;
color: var(--text-accent);
margin-bottom: 20px;
margin-left: 30px;
margin-left: 20%;
margin-right:10%;
}
.timeline-era-date {
@ -48,32 +50,15 @@ If your plugin does not need CSS, delete this file.
/* 时间轴项目 */
.timeline-item {
position: relative;
margin: 30px 0;
padding-left: 30px;
}
/* 时间节点圆点 */
.timeline-item::before {
content: '';
position: absolute;
left: -8px;
top: 50%;
width: 16px;
height: 16px;
background: var(--background-primary);
border: 2px solid var(--interactive-accent);
border-radius: 50%;
transform: translateY(-50%);
z-index: 2;
}
/* 连接到主时间轴的横线 */
.timeline-item::after {
content: '';
position: absolute;
left: -50px;
left: 10%;
width: 15%;
top: 50%;
width: 42px;
height: 2px;
background: var(--interactive-accent);
transform: translateY(-50%);
@ -81,9 +66,13 @@ If your plugin does not need CSS, delete this file.
/* 内容卡片 */
.timeline-card {
position: relative;
z-index: 2; /* 确保卡片在横线上方 */
background: var(--background-primary-alt);
margin-left: 20%;
margin-right: 10%;
border-radius: 8px;
padding: 15px;
padding : 15px 15px 15px 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
@ -115,7 +104,7 @@ If your plugin does not need CSS, delete this file.
font-size: 0.9em;
color: var(--text-normal);
line-height: 1.5;
border-left: 3px solid var(--interactive-accent);
border-left: 3px solid var(--timeline-line-color);
padding-left: 10px;
margin-top: 10px;
}

View file

@ -1,3 +1,5 @@
{
"1.1.0": "0.15.0"
}
"1.2.4": "0.15.0",
"1.2.5": "0.15.0",
"1.2.6": "0.15.0"
}