Compare commits
43 Commits
beta
...
ef41390d2e
| Author | SHA1 | Date | |
|---|---|---|---|
| ef41390d2e | |||
| 256d2ca844 | |||
| f283dc2d40 | |||
| 16195ad1f1 | |||
| 3671044837 | |||
| a4314ee590 | |||
| 005d8923e4 | |||
| 8ef97d62ff | |||
| 55f4eb82b7 | |||
| 2ba38a30b4 | |||
| 43f57c7e14 | |||
| 8615d000b2 | |||
| 2608a54bca | |||
| 8c45610503 | |||
| 1db6584677 | |||
| 54b9e8a44f | |||
| c57b68a9e5 | |||
| 19484758a9 | |||
| ddbd9973c4 | |||
| 6f2b96366c | |||
| 55b177fc2f | |||
| 8ea84c10e6 | |||
| bb69377716 | |||
| fae8cc1698 | |||
| 6ad88b733d | |||
| f12db2071e | |||
| 6a00e98227 | |||
| 39c02c89b9 | |||
| f3615ba713 | |||
| 4f51ec2aa4 | |||
| 2ad71e0cd2 | |||
| 148827eafd | |||
| 899a383c53 | |||
| 2fba8e2ac3 | |||
| 0bec6d4ee4 | |||
| 1eba58bb92 | |||
| 8f004a9959 | |||
| 9d7af32e25 | |||
| 65dc754cae | |||
| 27a6f94f64 | |||
| a96c270bfe | |||
| 5621292cfb | |||
| 175df8b814 |
@@ -0,0 +1 @@
|
|||||||
|
* text=auto
|
||||||
@@ -25,3 +25,15 @@ pnpm-debug.log*
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
bun.lockb
|
bun.lockb
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# src/content/.obsidian
|
||||||
|
|
||||||
|
.playwright-mcp
|
||||||
|
.serena
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# .obsidian
|
||||||
|
.cache
|
||||||
|
|
||||||
|
build.log
|
||||||
|
.traces
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"newLinkFormat": "absolute",
|
||||||
|
"newFileLocation": "folder",
|
||||||
|
"newFileFolderPath": "src/content/posts",
|
||||||
|
"alwaysUpdateLinks": true,
|
||||||
|
"attachmentFolderPath": "/public/pic",
|
||||||
|
"useMarkdownLinks": true
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
"fix-public-links",
|
||||||
|
"obsidian-auto-link-title",
|
||||||
|
"obsidian-paste-image-rename"
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"file-explorer": true,
|
||||||
|
"global-search": true,
|
||||||
|
"switcher": true,
|
||||||
|
"graph": true,
|
||||||
|
"backlink": true,
|
||||||
|
"canvas": true,
|
||||||
|
"outgoing-link": true,
|
||||||
|
"tag-pane": true,
|
||||||
|
"footnotes": false,
|
||||||
|
"properties": true,
|
||||||
|
"page-preview": true,
|
||||||
|
"daily-notes": true,
|
||||||
|
"templates": true,
|
||||||
|
"note-composer": true,
|
||||||
|
"command-palette": true,
|
||||||
|
"slash-command": false,
|
||||||
|
"editor-status": true,
|
||||||
|
"bookmarks": true,
|
||||||
|
"markdown-importer": false,
|
||||||
|
"zk-prefixer": false,
|
||||||
|
"random-note": false,
|
||||||
|
"outline": true,
|
||||||
|
"word-count": true,
|
||||||
|
"slides": false,
|
||||||
|
"audio-recorder": false,
|
||||||
|
"workspaces": false,
|
||||||
|
"file-recovery": true,
|
||||||
|
"publish": false,
|
||||||
|
"sync": true,
|
||||||
|
"bases": true,
|
||||||
|
"webviewer": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const { Plugin } = require('obsidian');
|
||||||
|
|
||||||
|
module.exports = class FixPublicLinksPlugin extends Plugin {
|
||||||
|
async onload() {
|
||||||
|
console.log('Loading Fix Public Links plugin');
|
||||||
|
|
||||||
|
// 监听文件创建事件(粘贴图片时触发)
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.vault.on('create', (file) => {
|
||||||
|
// 延迟执行,确保 Obsidian 已经插入了链接
|
||||||
|
setTimeout(() => {
|
||||||
|
this.fixPublicLinksInActiveFile();
|
||||||
|
}, 100);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加命令:手动修复当前文件的所有链接
|
||||||
|
this.addCommand({
|
||||||
|
id: 'fix-public-links',
|
||||||
|
name: 'Fix public/ links in current file',
|
||||||
|
editorCallback: (editor) => {
|
||||||
|
this.fixPublicLinksInEditor(editor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fixPublicLinksInActiveFile() {
|
||||||
|
const activeView = this.app.workspace.getActiveViewOfType(require('obsidian').MarkdownView);
|
||||||
|
if (!activeView) return;
|
||||||
|
|
||||||
|
const editor = activeView.editor;
|
||||||
|
this.fixPublicLinksInEditor(editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixPublicLinksInEditor(editor) {
|
||||||
|
const cursor = editor.getCursor();
|
||||||
|
const lineCount = editor.lineCount();
|
||||||
|
let fixed = false;
|
||||||
|
|
||||||
|
// 遍历所有行
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const line = editor.getLine(i);
|
||||||
|
|
||||||
|
// 匹配 Markdown 图片语法:
|
||||||
|
const fixedLine = line.replace(/\]\(public\//g, '](/public/');
|
||||||
|
|
||||||
|
if (fixedLine !== line) {
|
||||||
|
editor.replaceRange(
|
||||||
|
fixedLine,
|
||||||
|
{ line: i, ch: 0 },
|
||||||
|
{ line: i, ch: line.length }
|
||||||
|
);
|
||||||
|
fixed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixed) {
|
||||||
|
console.log('Fixed public/ links in current file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onunload() {
|
||||||
|
console.log('Unloading Fix Public Links plugin');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "fix-public-links",
|
||||||
|
"name": "Fix Public Links",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minAppVersion": "0.15.0",
|
||||||
|
"description": "Automatically fix image links that start with public/ to /public/",
|
||||||
|
"author": "AcoFork",
|
||||||
|
"authorUrl": "https://blog.acofork.com",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"regex": {},
|
||||||
|
"lineRegex": {},
|
||||||
|
"linkRegex": {},
|
||||||
|
"linkLineRegex": {},
|
||||||
|
"imageRegex": {},
|
||||||
|
"enhanceDefaultPaste": true,
|
||||||
|
"shouldPreserveSelectionAsTitle": false,
|
||||||
|
"enhanceDropEvents": true,
|
||||||
|
"websiteBlacklist": "",
|
||||||
|
"maximumTitleLength": 0,
|
||||||
|
"useNewScraper": false,
|
||||||
|
"linkPreviewApiKey": "",
|
||||||
|
"useBetterPasteId": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
/*
|
||||||
|
THIS IS A GENERATED/BUNDLED FILE BY ROLLUP
|
||||||
|
if you want to view the source visit the plugins github repository
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var obsidian = require('obsidian');
|
||||||
|
|
||||||
|
/******************************************************************************
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
***************************************************************************** */
|
||||||
|
|
||||||
|
function __awaiter(thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
||||||
|
var e = new Error(message);
|
||||||
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
regex: /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/i,
|
||||||
|
lineRegex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi,
|
||||||
|
linkRegex: /^\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)$/i,
|
||||||
|
linkLineRegex: /\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)/gi,
|
||||||
|
imageRegex: /\.(gif|jpe?g|tiff?|png|webp|bmp|tga|psd|ai)$/i,
|
||||||
|
enhanceDefaultPaste: true,
|
||||||
|
shouldPreserveSelectionAsTitle: false,
|
||||||
|
enhanceDropEvents: true,
|
||||||
|
websiteBlacklist: "",
|
||||||
|
maximumTitleLength: 0,
|
||||||
|
useNewScraper: false,
|
||||||
|
linkPreviewApiKey: "",
|
||||||
|
useBetterPasteId: false,
|
||||||
|
};
|
||||||
|
class AutoLinkTitleSettingTab extends obsidian.PluginSettingTab {
|
||||||
|
constructor(app, plugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
display() {
|
||||||
|
let { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("Enhance Default Paste")
|
||||||
|
.setDesc("Fetch the link title when pasting a link in the editor with the default paste command")
|
||||||
|
.addToggle((val) => val
|
||||||
|
.setValue(this.plugin.settings.enhanceDefaultPaste)
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
console.log(value);
|
||||||
|
this.plugin.settings.enhanceDefaultPaste = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("Enhance Drop Events")
|
||||||
|
.setDesc("Fetch the link title when drag and dropping a link from another program")
|
||||||
|
.addToggle((val) => val
|
||||||
|
.setValue(this.plugin.settings.enhanceDropEvents)
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
console.log(value);
|
||||||
|
this.plugin.settings.enhanceDropEvents = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("Maximum title length")
|
||||||
|
.setDesc("Set the maximum length of the title. Set to 0 to disable.")
|
||||||
|
.addText((val) => val
|
||||||
|
.setValue(this.plugin.settings.maximumTitleLength.toString(10))
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const titleLength = Number(value);
|
||||||
|
this.plugin.settings.maximumTitleLength =
|
||||||
|
isNaN(titleLength) || titleLength < 0 ? 0 : titleLength;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("Preserve selection as title")
|
||||||
|
.setDesc("Whether to prefer selected text as title over fetched title when pasting")
|
||||||
|
.addToggle((val) => val
|
||||||
|
.setValue(this.plugin.settings.shouldPreserveSelectionAsTitle)
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
console.log(value);
|
||||||
|
this.plugin.settings.shouldPreserveSelectionAsTitle = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("Website Blacklist")
|
||||||
|
.setDesc("List of strings (comma separated) that disable autocompleting website titles. Can be URLs or arbitrary text.")
|
||||||
|
.addTextArea((val) => val
|
||||||
|
.setValue(this.plugin.settings.websiteBlacklist)
|
||||||
|
.setPlaceholder("localhost, tiktok.com")
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
this.plugin.settings.websiteBlacklist = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("Use New Scraper")
|
||||||
|
.setDesc("Use experimental new scraper, seems to work well on desktop but not mobile.")
|
||||||
|
.addToggle((val) => val
|
||||||
|
.setValue(this.plugin.settings.useNewScraper)
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
console.log(value);
|
||||||
|
this.plugin.settings.useNewScraper = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("Use Better Fetching Placeholder")
|
||||||
|
.setDesc("Use a more readable placeholder when fetching the title of a link.")
|
||||||
|
.addToggle((val) => val
|
||||||
|
.setValue(this.plugin.settings.useBetterPasteId)
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
console.log(value);
|
||||||
|
this.plugin.settings.useBetterPasteId = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
new obsidian.Setting(containerEl)
|
||||||
|
.setName("LinkPreview API Key")
|
||||||
|
.setDesc("API key for the LinkPreview.net service. Get one at https://my.linkpreview.net/access_keys")
|
||||||
|
.addText((text) => text
|
||||||
|
.setValue(this.plugin.settings.linkPreviewApiKey || "")
|
||||||
|
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (trimmedValue.length > 0 && trimmedValue.length !== 32) {
|
||||||
|
new obsidian.Notice("LinkPreview API key must be 32 characters long");
|
||||||
|
this.plugin.settings.linkPreviewApiKey = "";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.plugin.settings.linkPreviewApiKey = trimmedValue;
|
||||||
|
}
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CheckIf {
|
||||||
|
static isMarkdownLinkAlready(editor) {
|
||||||
|
let cursor = editor.getCursor();
|
||||||
|
// Check if the characters before the url are ]( to indicate a markdown link
|
||||||
|
var titleEnd = editor.getRange({ ch: cursor.ch - 2, line: cursor.line }, { ch: cursor.ch, line: cursor.line });
|
||||||
|
return titleEnd == "](";
|
||||||
|
}
|
||||||
|
static isAfterQuote(editor) {
|
||||||
|
let cursor = editor.getCursor();
|
||||||
|
// Check if the characters before the url are " or ' to indicate we want the url directly
|
||||||
|
// This is common in elements like <a href="linkhere"></a>
|
||||||
|
var beforeChar = editor.getRange({ ch: cursor.ch - 1, line: cursor.line }, { ch: cursor.ch, line: cursor.line });
|
||||||
|
return beforeChar == "\"" || beforeChar == "'";
|
||||||
|
}
|
||||||
|
static isUrl(text) {
|
||||||
|
let urlRegex = new RegExp(DEFAULT_SETTINGS.regex);
|
||||||
|
return urlRegex.test(text);
|
||||||
|
}
|
||||||
|
static isImage(text) {
|
||||||
|
let imageRegex = new RegExp(DEFAULT_SETTINGS.imageRegex);
|
||||||
|
return imageRegex.test(text);
|
||||||
|
}
|
||||||
|
static isLinkedUrl(text) {
|
||||||
|
let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex);
|
||||||
|
return urlRegex.test(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EditorExtensions {
|
||||||
|
static getSelectedText(editor) {
|
||||||
|
if (!editor.somethingSelected()) {
|
||||||
|
let wordBoundaries = this.getWordBoundaries(editor);
|
||||||
|
editor.setSelection(wordBoundaries.start, wordBoundaries.end);
|
||||||
|
}
|
||||||
|
return editor.getSelection();
|
||||||
|
}
|
||||||
|
static cursorWithinBoundaries(cursor, match) {
|
||||||
|
let startIndex = match.index;
|
||||||
|
let endIndex = match.index + match[0].length;
|
||||||
|
return startIndex <= cursor.ch && cursor.ch <= endIndex;
|
||||||
|
}
|
||||||
|
static getWordBoundaries(editor) {
|
||||||
|
let cursor = editor.getCursor();
|
||||||
|
// If its a normal URL token this is not a markdown link
|
||||||
|
// In this case we can simply overwrite the link boundaries as-is
|
||||||
|
let lineText = editor.getLine(cursor.line);
|
||||||
|
// First check if we're in a link
|
||||||
|
let linksInLine = lineText.matchAll(DEFAULT_SETTINGS.linkLineRegex);
|
||||||
|
for (let match of linksInLine) {
|
||||||
|
if (this.cursorWithinBoundaries(cursor, match)) {
|
||||||
|
return {
|
||||||
|
start: { line: cursor.line, ch: match.index },
|
||||||
|
end: { line: cursor.line, ch: match.index + match[0].length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not, check if we're in just a standard ol' URL.
|
||||||
|
let urlsInLine = lineText.matchAll(DEFAULT_SETTINGS.lineRegex);
|
||||||
|
for (let match of urlsInLine) {
|
||||||
|
if (this.cursorWithinBoundaries(cursor, match)) {
|
||||||
|
return {
|
||||||
|
start: { line: cursor.line, ch: match.index },
|
||||||
|
end: { line: cursor.line, ch: match.index + match[0].length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: cursor,
|
||||||
|
end: cursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
static getEditorPositionFromIndex(content, index) {
|
||||||
|
let substr = content.substr(0, index);
|
||||||
|
let l = 0;
|
||||||
|
let offset = -1;
|
||||||
|
let r = -1;
|
||||||
|
for (; (r = substr.indexOf("\n", r + 1)) !== -1; l++, offset = r)
|
||||||
|
;
|
||||||
|
offset += 1;
|
||||||
|
let ch = content.substr(offset, index - offset).length;
|
||||||
|
return { line: l, ch: ch };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blank$1(text) {
|
||||||
|
return text === undefined || text === null || text === '';
|
||||||
|
}
|
||||||
|
function notBlank$1(text) {
|
||||||
|
return !blank$1(text);
|
||||||
|
}
|
||||||
|
function scrape(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
try {
|
||||||
|
const response = yield obsidian.requestUrl(url);
|
||||||
|
if (!response.headers['content-type'].includes('text/html'))
|
||||||
|
return getUrlFinalSegment$1(url);
|
||||||
|
const html = response.text;
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const title = doc.querySelector('title');
|
||||||
|
if (blank$1(title === null || title === void 0 ? void 0 : title.innerText)) {
|
||||||
|
// If site is javascript based and has a no-title attribute when unloaded, use it.
|
||||||
|
var noTitle = title === null || title === void 0 ? void 0 : title.getAttr('no-title');
|
||||||
|
if (notBlank$1(noTitle)) {
|
||||||
|
return noTitle;
|
||||||
|
}
|
||||||
|
// Otherwise if the site has no title/requires javascript simply return Title Unknown
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return title.innerText;
|
||||||
|
}
|
||||||
|
catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getUrlFinalSegment$1(url) {
|
||||||
|
try {
|
||||||
|
const segments = new URL(url).pathname.split('/');
|
||||||
|
const last = segments.pop() || segments.pop(); // Handle potential trailing slash
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
catch (_) {
|
||||||
|
return 'File';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getPageTitle$1(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (!(url.startsWith('http') || url.startsWith('https'))) {
|
||||||
|
url = 'https://' + url;
|
||||||
|
}
|
||||||
|
return scrape(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronPkg = require("electron");
|
||||||
|
function blank(text) {
|
||||||
|
return text === undefined || text === null || text === "";
|
||||||
|
}
|
||||||
|
function notBlank(text) {
|
||||||
|
return !blank(text);
|
||||||
|
}
|
||||||
|
// async wrapper to load a url and settle on load finish or fail
|
||||||
|
function load(window, url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
window.webContents.on("did-finish-load", (event) => resolve(event));
|
||||||
|
window.webContents.on("did-fail-load", (event) => reject(event));
|
||||||
|
window.loadURL(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function electronGetPageTitle(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const { remote } = electronPkg;
|
||||||
|
const { BrowserWindow } = remote;
|
||||||
|
try {
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 1000,
|
||||||
|
height: 600,
|
||||||
|
webPreferences: {
|
||||||
|
webSecurity: false,
|
||||||
|
nodeIntegration: true,
|
||||||
|
images: false,
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
window.webContents.setAudioMuted(true);
|
||||||
|
window.webContents.on("will-navigate", (event, newUrl) => {
|
||||||
|
event.preventDefault();
|
||||||
|
window.loadURL(newUrl);
|
||||||
|
});
|
||||||
|
yield load(window, url);
|
||||||
|
try {
|
||||||
|
const title = window.webContents.getTitle();
|
||||||
|
window.destroy();
|
||||||
|
if (notBlank(title)) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ex) {
|
||||||
|
window.destroy();
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function nonElectronGetPageTitle(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
try {
|
||||||
|
const html = yield obsidian.request({ url });
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
const title = doc.querySelectorAll("title")[0];
|
||||||
|
if (title == null || blank(title === null || title === void 0 ? void 0 : title.innerText)) {
|
||||||
|
// If site is javascript based and has a no-title attribute when unloaded, use it.
|
||||||
|
var noTitle = title === null || title === void 0 ? void 0 : title.getAttr("no-title");
|
||||||
|
if (notBlank(noTitle)) {
|
||||||
|
return noTitle;
|
||||||
|
}
|
||||||
|
// Otherwise if the site has no title/requires javascript simply return Title Unknown
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return title.innerText;
|
||||||
|
}
|
||||||
|
catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getUrlFinalSegment(url) {
|
||||||
|
try {
|
||||||
|
const segments = new URL(url).pathname.split('/');
|
||||||
|
const last = segments.pop() || segments.pop(); // Handle potential trailing slash
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
catch (_) {
|
||||||
|
return "File";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tryGetFileType(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
try {
|
||||||
|
const response = yield fetch(url, { method: "HEAD" });
|
||||||
|
// Ensure site returns an ok status code before scraping
|
||||||
|
if (!response.ok) {
|
||||||
|
return "Site Unreachable";
|
||||||
|
}
|
||||||
|
// Ensure site is an actual HTML page and not a pdf or 3 gigabyte video file.
|
||||||
|
let contentType = response.headers.get("content-type");
|
||||||
|
if (!contentType.includes("text/html")) {
|
||||||
|
return getUrlFinalSegment(url);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getPageTitle(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
// If we're on Desktop use the Electron scraper
|
||||||
|
if (!(url.startsWith("http") || url.startsWith("https"))) {
|
||||||
|
url = "https://" + url;
|
||||||
|
}
|
||||||
|
// Try to do a HEAD request to see if the site is reachable and if it's an HTML page
|
||||||
|
// If we error out due to CORS, we'll just try to scrape the page anyway.
|
||||||
|
let fileType = yield tryGetFileType(url);
|
||||||
|
if (fileType) {
|
||||||
|
return fileType;
|
||||||
|
}
|
||||||
|
if (electronPkg != null) {
|
||||||
|
return electronGetPageTitle(url);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return nonElectronGetPageTitle(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoLinkTitle extends obsidian.Plugin {
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.shortTitle = (title) => {
|
||||||
|
if (this.settings.maximumTitleLength === 0) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
if (title.length < this.settings.maximumTitleLength + 3) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
const shortenedTitle = `${title.slice(0, this.settings.maximumTitleLength)}...`;
|
||||||
|
return shortenedTitle;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onload() {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
console.log("loading obsidian-auto-link-title");
|
||||||
|
yield this.loadSettings();
|
||||||
|
this.blacklist = this.settings.websiteBlacklist
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
// Listen to paste event
|
||||||
|
this.pasteFunction = this.pasteUrlWithTitle.bind(this);
|
||||||
|
// Listen to drop event
|
||||||
|
this.dropFunction = this.dropUrlWithTitle.bind(this);
|
||||||
|
this.addCommand({
|
||||||
|
id: "auto-link-title-paste",
|
||||||
|
name: "Paste URL and auto fetch title",
|
||||||
|
editorCallback: (editor) => this.manualPasteUrlWithTitle(editor),
|
||||||
|
hotkeys: [],
|
||||||
|
});
|
||||||
|
this.addCommand({
|
||||||
|
id: "auto-link-title-normal-paste",
|
||||||
|
name: "Normal paste (no fetching behavior)",
|
||||||
|
editorCallback: (editor) => this.normalPaste(editor),
|
||||||
|
hotkeys: [
|
||||||
|
{
|
||||||
|
modifiers: ["Mod", "Shift"],
|
||||||
|
key: "v",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
this.registerEvent(this.app.workspace.on("editor-paste", this.pasteFunction));
|
||||||
|
this.registerEvent(this.app.workspace.on("editor-drop", this.dropFunction));
|
||||||
|
this.addCommand({
|
||||||
|
id: "enhance-url-with-title",
|
||||||
|
name: "Enhance existing URL with link and title",
|
||||||
|
editorCallback: (editor) => this.addTitleToLink(editor),
|
||||||
|
hotkeys: [
|
||||||
|
{
|
||||||
|
modifiers: ["Mod", "Shift"],
|
||||||
|
key: "e",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
this.addSettingTab(new AutoLinkTitleSettingTab(this.app, this));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addTitleToLink(editor) {
|
||||||
|
// Only attempt fetch if online
|
||||||
|
if (!navigator.onLine)
|
||||||
|
return;
|
||||||
|
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
|
||||||
|
// If the cursor is on a raw html link, convert to a markdown link and fetch title
|
||||||
|
if (CheckIf.isUrl(selectedText)) {
|
||||||
|
this.convertUrlToTitledLink(editor, selectedText);
|
||||||
|
}
|
||||||
|
// If the cursor is on the URL part of a markdown link, fetch title and replace existing link title
|
||||||
|
else if (CheckIf.isLinkedUrl(selectedText)) {
|
||||||
|
const link = this.getUrlFromLink(selectedText);
|
||||||
|
this.convertUrlToTitledLink(editor, link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalPaste(editor) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
let clipboardText = yield navigator.clipboard.readText();
|
||||||
|
if (clipboardText === null || clipboardText === "")
|
||||||
|
return;
|
||||||
|
editor.replaceSelection(clipboardText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Simulate standard paste but using editor.replaceSelection with clipboard text since we can't seem to dispatch a paste event.
|
||||||
|
manualPasteUrlWithTitle(editor) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const clipboardText = yield navigator.clipboard.readText();
|
||||||
|
// Only attempt fetch if online
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
editor.replaceSelection(clipboardText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clipboardText == null || clipboardText == "")
|
||||||
|
return;
|
||||||
|
// If its not a URL, we return false to allow the default paste handler to take care of it.
|
||||||
|
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
|
||||||
|
// to fetch the title is a waste of bandwidth.
|
||||||
|
if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) {
|
||||||
|
editor.replaceSelection(clipboardText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If it looks like we're pasting the url into a markdown link already, don't fetch title
|
||||||
|
// as the user has already probably put a meaningful title, also it would lead to the title
|
||||||
|
// being inside the link.
|
||||||
|
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
|
||||||
|
editor.replaceSelection(clipboardText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If url is pasted over selected text and setting is enabled, no need to fetch title,
|
||||||
|
// just insert a link
|
||||||
|
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
|
||||||
|
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
|
||||||
|
editor.replaceSelection(`[${selectedText}](${clipboardText})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// At this point we're just pasting a link in a normal fashion, fetch its title.
|
||||||
|
this.convertUrlToTitledLink(editor, clipboardText);
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pasteUrlWithTitle(clipboard, editor) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (!this.settings.enhanceDefaultPaste) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clipboard.defaultPrevented)
|
||||||
|
return;
|
||||||
|
// Only attempt fetch if online
|
||||||
|
if (!navigator.onLine)
|
||||||
|
return;
|
||||||
|
let clipboardText = clipboard.clipboardData.getData("text/plain");
|
||||||
|
if (clipboardText === null || clipboardText === "")
|
||||||
|
return;
|
||||||
|
// If its not a URL, we return false to allow the default paste handler to take care of it.
|
||||||
|
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
|
||||||
|
// to fetch the title is a waste of bandwidth.
|
||||||
|
if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// We've decided to handle the paste, stop propagation to the default handler.
|
||||||
|
clipboard.stopPropagation();
|
||||||
|
clipboard.preventDefault();
|
||||||
|
// If it looks like we're pasting the url into a markdown link already, don't fetch title
|
||||||
|
// as the user has already probably put a meaningful title, also it would lead to the title
|
||||||
|
// being inside the link.
|
||||||
|
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
|
||||||
|
editor.replaceSelection(clipboardText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If url is pasted over selected text and setting is enabled, no need to fetch title,
|
||||||
|
// just insert a link
|
||||||
|
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
|
||||||
|
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
|
||||||
|
editor.replaceSelection(`[${selectedText}](${clipboardText})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// At this point we're just pasting a link in a normal fashion, fetch its title.
|
||||||
|
this.convertUrlToTitledLink(editor, clipboardText);
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dropUrlWithTitle(dropEvent, editor) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (!this.settings.enhanceDropEvents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dropEvent.defaultPrevented)
|
||||||
|
return;
|
||||||
|
// Only attempt fetch if online
|
||||||
|
if (!navigator.onLine)
|
||||||
|
return;
|
||||||
|
let dropText = dropEvent.dataTransfer.getData("text/plain");
|
||||||
|
if (dropText === null || dropText === "")
|
||||||
|
return;
|
||||||
|
// If its not a URL, we return false to allow the default paste handler to take care of it.
|
||||||
|
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
|
||||||
|
// to fetch the title is a waste of bandwidth.
|
||||||
|
if (!CheckIf.isUrl(dropText) || CheckIf.isImage(dropText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// We've decided to handle the paste, stop propagation to the default handler.
|
||||||
|
dropEvent.stopPropagation();
|
||||||
|
dropEvent.preventDefault();
|
||||||
|
// If it looks like we're pasting the url into a markdown link already, don't fetch title
|
||||||
|
// as the user has already probably put a meaningful title, also it would lead to the title
|
||||||
|
// being inside the link.
|
||||||
|
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
|
||||||
|
editor.replaceSelection(dropText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If url is pasted over selected text and setting is enabled, no need to fetch title,
|
||||||
|
// just insert a link
|
||||||
|
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
|
||||||
|
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
|
||||||
|
editor.replaceSelection(`[${selectedText}](${dropText})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// At this point we're just pasting a link in a normal fashion, fetch its title.
|
||||||
|
this.convertUrlToTitledLink(editor, dropText);
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isBlacklisted(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield this.loadSettings();
|
||||||
|
this.blacklist = this.settings.websiteBlacklist
|
||||||
|
.split(/,|\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
return this.blacklist.some((site) => url.includes(site));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
convertUrlToTitledLink(editor, url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (yield this.isBlacklisted(url)) {
|
||||||
|
let domain = new URL(url).hostname;
|
||||||
|
editor.replaceSelection(`[${domain}](${url})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Generate a unique id for find/replace operations for the title.
|
||||||
|
const pasteId = this.getPasteId();
|
||||||
|
// Instantly paste so you don't wonder if paste is broken
|
||||||
|
editor.replaceSelection(`[${pasteId}](${url})`);
|
||||||
|
// Fetch title from site, replace Fetching Title with actual title
|
||||||
|
const title = yield this.fetchUrlTitle(url);
|
||||||
|
const escapedTitle = this.escapeMarkdown(title);
|
||||||
|
const shortenedTitle = this.shortTitle(escapedTitle);
|
||||||
|
const text = editor.getValue();
|
||||||
|
const start = text.indexOf(pasteId);
|
||||||
|
if (start < 0) {
|
||||||
|
console.log(`Unable to find text "${pasteId}" in current editor, bailing out; link ${url}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const end = start + pasteId.length;
|
||||||
|
const startPos = EditorExtensions.getEditorPositionFromIndex(text, start);
|
||||||
|
const endPos = EditorExtensions.getEditorPositionFromIndex(text, end);
|
||||||
|
editor.replaceRange(shortenedTitle, startPos, endPos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
escapeMarkdown(text) {
|
||||||
|
var unescaped = text.replace(/\\(\*|_|`|~|\\|\[|\])/g, "$1"); // unescape any "backslashed" character
|
||||||
|
var escaped = unescaped.replace(/(\*|_|`|<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, [, ], <, and >
|
||||||
|
var escaped = unescaped.replace(/(\*|_|`|\||<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, |, [, ], <, and >
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
fetchUrlTitleViaLinkPreview(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (this.settings.linkPreviewApiKey.length !== 32) {
|
||||||
|
console.error("LinkPreview API key is not 32 characters long, please check your settings");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const apiEndpoint = `https://api.linkpreview.net/?q=${encodeURIComponent(url)}`;
|
||||||
|
const response = yield fetch(apiEndpoint, {
|
||||||
|
headers: {
|
||||||
|
"X-Linkpreview-Api-Key": this.settings.linkPreviewApiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = yield response.json();
|
||||||
|
return data.title;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fetchUrlTitle(url) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
try {
|
||||||
|
let title = "";
|
||||||
|
title = yield this.fetchUrlTitleViaLinkPreview(url);
|
||||||
|
console.log(`Title via Link Preview: ${title}`);
|
||||||
|
if (title === "") {
|
||||||
|
console.log("Title via Link Preview failed, falling back to scraper");
|
||||||
|
if (this.settings.useNewScraper) {
|
||||||
|
console.log("Using new scraper");
|
||||||
|
title = yield getPageTitle$1(url);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log("Using old scraper");
|
||||||
|
title = yield getPageTitle(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Title: ${title}`);
|
||||||
|
title =
|
||||||
|
title.replace(/(\r\n|\n|\r)/gm, "").trim() ||
|
||||||
|
"Title Unavailable | Site Unreachable";
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return "Error fetching title";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getUrlFromLink(link) {
|
||||||
|
let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex);
|
||||||
|
return urlRegex.exec(link)[2];
|
||||||
|
}
|
||||||
|
getPasteId() {
|
||||||
|
var base = "Fetching Title";
|
||||||
|
if (this.settings.useBetterPasteId) {
|
||||||
|
return this.getBetterPasteId(base);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return `${base}#${this.createBlockHash()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getBetterPasteId(base) {
|
||||||
|
// After every character, add 0, 1 or 2 invisible characters
|
||||||
|
// so that to the user it looks just like the base string.
|
||||||
|
// The number of combinations is 3^14 = 4782969
|
||||||
|
let result = "";
|
||||||
|
var invisibleCharacter = "\u200B";
|
||||||
|
var maxInvisibleCharacters = 2;
|
||||||
|
for (var i = 0; i < base.length; i++) {
|
||||||
|
var count = Math.floor(Math.random() * (maxInvisibleCharacters + 1));
|
||||||
|
result += base.charAt(i) + invisibleCharacter.repeat(count);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Custom hashid by @shabegom
|
||||||
|
createBlockHash() {
|
||||||
|
let result = "";
|
||||||
|
var characters = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
var charactersLength = characters.length;
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
onunload() {
|
||||||
|
console.log("unloading obsidian-auto-link-title");
|
||||||
|
}
|
||||||
|
loadSettings() {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveSettings() {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield this.saveData(this.settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AutoLinkTitle;
|
||||||
|
|
||||||
|
|
||||||
|
/* nosourcemap */
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "obsidian-auto-link-title",
|
||||||
|
"name": "Auto Link Title",
|
||||||
|
"version": "1.5.5",
|
||||||
|
"minAppVersion": "0.12.17",
|
||||||
|
"description": "This plugin automatically fetches the titles of links from the web",
|
||||||
|
"author": "Matt Furden",
|
||||||
|
"authorUrl": "https://github.com/zolrath",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* no styles */
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"imageNamePattern": "{{fileName}}",
|
||||||
|
"dupNumberAtStart": false,
|
||||||
|
"dupNumberDelimiter": "-",
|
||||||
|
"dupNumberAlways": true,
|
||||||
|
"autoRename": true,
|
||||||
|
"handleAllAttachments": true,
|
||||||
|
"excludeExtensionPattern": "",
|
||||||
|
"disableRenameNotice": false,
|
||||||
|
"useFileHashName": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,965 @@
|
|||||||
|
/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD */
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __defProps = Object.defineProperties;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
||||||
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||||||
|
var __spreadValues = (a, b) => {
|
||||||
|
for (var prop in b || (b = {}))
|
||||||
|
if (__hasOwnProp.call(b, prop))
|
||||||
|
__defNormalProp(a, prop, b[prop]);
|
||||||
|
if (__getOwnPropSymbols)
|
||||||
|
for (var prop of __getOwnPropSym )
|
||||||
|
__defNormalProp(a, prop, b[prop]);
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
|
||||||
|
var __commonJS = (cb, mod) => function __require() {
|
||||||
|
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||||
|
};
|
||||||
|
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 __async = (__this, __arguments, generator) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var fulfilled = (value) => {
|
||||||
|
try {
|
||||||
|
step(generator.next(value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var rejected = (value) => {
|
||||||
|
try {
|
||||||
|
step(generator.throw(value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
|
||||||
|
step((generator = generator.apply(__this, __arguments)).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// package.json
|
||||||
|
var require_package = __commonJS({
|
||||||
|
"package.json"(exports, module2) {
|
||||||
|
module2.exports = {
|
||||||
|
name: "obsidian-paste-image-rename",
|
||||||
|
version: "1.6.1",
|
||||||
|
main: "main.js",
|
||||||
|
scripts: {
|
||||||
|
start: "node esbuild.config.mjs",
|
||||||
|
build: "tsc -noEmit -skipLibCheck && BUILD_ENV=production node esbuild.config.mjs && cp manifest.json build",
|
||||||
|
version: "node version-bump.mjs && git add manifest.json versions.json",
|
||||||
|
release: "npm run build && gh release create ${npm_package_version} build/*"
|
||||||
|
},
|
||||||
|
keywords: [],
|
||||||
|
author: "Reorx",
|
||||||
|
license: "MIT",
|
||||||
|
devDependencies: {
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.49.0",
|
||||||
|
"@typescript-eslint/parser": "^5.49.0",
|
||||||
|
"builtin-modules": "^3.3.0",
|
||||||
|
esbuild: "0.16.17",
|
||||||
|
obsidian: "^1.1.1",
|
||||||
|
tslib: "2.5.0",
|
||||||
|
typescript: "4.9.4"
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
"cash-dom": "^8.1.2"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// src/main.ts
|
||||||
|
var main_exports = {};
|
||||||
|
__export(main_exports, {
|
||||||
|
default: () => PasteImageRenamePlugin
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(main_exports);
|
||||||
|
var import_obsidian2 = require("obsidian");
|
||||||
|
|
||||||
|
// src/batch.ts
|
||||||
|
var import_obsidian = require("obsidian");
|
||||||
|
|
||||||
|
// src/utils.ts
|
||||||
|
var DEBUG = false;
|
||||||
|
if (DEBUG)
|
||||||
|
console.log("DEBUG is enabled");
|
||||||
|
function debugLog(...args) {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(new Date().toISOString().slice(11, 23), ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createElementTree(rootEl, opts) {
|
||||||
|
const result = {
|
||||||
|
el: rootEl.createEl(opts.tag, opts),
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
const children = opts.children || [];
|
||||||
|
for (const child of children) {
|
||||||
|
result.children.push(createElementTree(result.el, child));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
var path = {
|
||||||
|
// Credit: @creationix/path.js
|
||||||
|
join(...partSegments) {
|
||||||
|
let parts = [];
|
||||||
|
for (let i = 0, l = partSegments.length; i < l; i++) {
|
||||||
|
parts = parts.concat(partSegments[i].split("/"));
|
||||||
|
}
|
||||||
|
const newParts = [];
|
||||||
|
for (let i = 0, l = parts.length; i < l; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
if (!part || part === ".")
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
newParts.push(part);
|
||||||
|
}
|
||||||
|
if (parts[0] === "")
|
||||||
|
newParts.unshift("");
|
||||||
|
return newParts.join("/");
|
||||||
|
},
|
||||||
|
// returns the last part of a path, e.g. 'foo.jpg'
|
||||||
|
basename(fullpath) {
|
||||||
|
const sp = fullpath.split("/");
|
||||||
|
return sp[sp.length - 1];
|
||||||
|
},
|
||||||
|
// return extension without dot, e.g. 'jpg'
|
||||||
|
extension(fullpath) {
|
||||||
|
const positions = [...fullpath.matchAll(new RegExp("\\.", "gi"))].map((a) => a.index);
|
||||||
|
return fullpath.slice(positions[positions.length - 1] + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var filenameNotAllowedChars = /[^\p{L}0-9~`!@$&*()\-_=+{};'",<.>? ]/ug;
|
||||||
|
var sanitizer = {
|
||||||
|
filename(s) {
|
||||||
|
return s.replace(filenameNotAllowedChars, "").trim();
|
||||||
|
},
|
||||||
|
delimiter(s) {
|
||||||
|
s = this.filename(s);
|
||||||
|
if (!s)
|
||||||
|
s = "-";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function escapeRegExp(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
function lockInputMethodComposition(el) {
|
||||||
|
const state = {
|
||||||
|
lock: false
|
||||||
|
};
|
||||||
|
el.addEventListener("compositionstart", () => {
|
||||||
|
state.lock = true;
|
||||||
|
});
|
||||||
|
el.addEventListener("compositionend", () => {
|
||||||
|
state.lock = false;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
function toHex(buffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
function sha256(buffer) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const digest = yield crypto.subtle.digest("SHA-256", buffer);
|
||||||
|
return toHex(digest);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/batch.ts
|
||||||
|
var ImageBatchRenameModal = class extends import_obsidian.Modal {
|
||||||
|
constructor(app, activeFile, renameFunc, onClose) {
|
||||||
|
super(app);
|
||||||
|
this.activeFile = activeFile;
|
||||||
|
this.renameFunc = renameFunc;
|
||||||
|
this.onCloseExtra = onClose;
|
||||||
|
this.state = {
|
||||||
|
namePattern: "",
|
||||||
|
extPattern: "",
|
||||||
|
nameReplace: "",
|
||||||
|
renameTasks: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
this.containerEl.addClass("image-rename-modal");
|
||||||
|
const { contentEl, titleEl } = this;
|
||||||
|
titleEl.setText("Batch rename embeded files");
|
||||||
|
const namePatternSetting = new import_obsidian.Setting(contentEl).setName("Name pattern").setDesc("Please input the name pattern to match files (regex)").addText((text) => text.setValue(this.state.namePattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.state.namePattern = value;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const npInputEl = namePatternSetting.controlEl.children[0];
|
||||||
|
npInputEl.focus();
|
||||||
|
const npInputState = lockInputMethodComposition(npInputEl);
|
||||||
|
npInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter" && !npInputState.lock) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!this.state.namePattern) {
|
||||||
|
errorEl.innerText = 'Error: "Name pattern" could not be empty';
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.matchImageNames(tbodyEl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const extPatternSetting = new import_obsidian.Setting(contentEl).setName("Extension pattern").setDesc("Please input the extension pattern to match files (regex)").addText((text) => text.setValue(this.state.extPattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.state.extPattern = value;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const extInputEl = extPatternSetting.controlEl.children[0];
|
||||||
|
extInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.matchImageNames(tbodyEl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const nameReplaceSetting = new import_obsidian.Setting(contentEl).setName("Name replace").setDesc("Please input the string to replace the matched name (use $1, $2 for regex groups)").addText((text) => text.setValue(this.state.nameReplace).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.state.nameReplace = value;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const nrInputEl = nameReplaceSetting.controlEl.children[0];
|
||||||
|
const nrInputState = lockInputMethodComposition(nrInputEl);
|
||||||
|
nrInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter" && !nrInputState.lock) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.matchImageNames(tbodyEl);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const matchedContainer = contentEl.createDiv({
|
||||||
|
cls: "matched-container"
|
||||||
|
});
|
||||||
|
const tableET = createElementTree(matchedContainer, {
|
||||||
|
tag: "table",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "thead",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "tr",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
text: "Original path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
text: "Renamed Name"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "tbody"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const tbodyEl = tableET.children[1].el;
|
||||||
|
const errorEl = contentEl.createDiv({
|
||||||
|
cls: "error",
|
||||||
|
attr: {
|
||||||
|
style: "display: none;"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
new import_obsidian.Setting(contentEl).addButton((button) => {
|
||||||
|
button.setButtonText("Rename all").setClass("mod-cta").onClick(() => {
|
||||||
|
new ConfirmModal(
|
||||||
|
this.app,
|
||||||
|
"Confirm rename all",
|
||||||
|
`Are you sure? This will rename all the ${this.state.renameTasks.length} images matched the pattern.`,
|
||||||
|
() => {
|
||||||
|
this.renameAll();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
).open();
|
||||||
|
});
|
||||||
|
}).addButton((button) => {
|
||||||
|
button.setButtonText("Cancel").onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
this.onCloseExtra();
|
||||||
|
}
|
||||||
|
renameAll() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
debugLog("renameAll", this.state);
|
||||||
|
for (const task of this.state.renameTasks) {
|
||||||
|
yield this.renameFunc(task.file, task.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
matchImageNames(tbodyEl) {
|
||||||
|
const { state } = this;
|
||||||
|
const renameTasks = [];
|
||||||
|
tbodyEl.empty();
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(this.activeFile);
|
||||||
|
if (!fileCache || !fileCache.embeds)
|
||||||
|
return;
|
||||||
|
const namePatternRegex = new RegExp(state.namePattern, "g");
|
||||||
|
const extPatternRegex = new RegExp(state.extPattern);
|
||||||
|
fileCache.embeds.forEach((embed) => {
|
||||||
|
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, this.activeFile.path);
|
||||||
|
if (!file) {
|
||||||
|
console.warn("file not found", embed.link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.extPattern) {
|
||||||
|
const m0 = extPatternRegex.exec(file.extension);
|
||||||
|
if (!m0)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stem = file.basename;
|
||||||
|
namePatternRegex.lastIndex = 0;
|
||||||
|
const m1 = namePatternRegex.exec(stem);
|
||||||
|
if (!m1)
|
||||||
|
return;
|
||||||
|
let renamedName = file.name;
|
||||||
|
if (state.nameReplace) {
|
||||||
|
namePatternRegex.lastIndex = 0;
|
||||||
|
renamedName = stem.replace(namePatternRegex, state.nameReplace);
|
||||||
|
renamedName = `${renamedName}.${file.extension}`;
|
||||||
|
}
|
||||||
|
renameTasks.push({
|
||||||
|
file,
|
||||||
|
name: renamedName
|
||||||
|
});
|
||||||
|
createElementTree(tbodyEl, {
|
||||||
|
tag: "tr",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: file.name
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "div",
|
||||||
|
text: file.path,
|
||||||
|
attr: {
|
||||||
|
class: "file-path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "td",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: renamedName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "div",
|
||||||
|
text: path.join(file.parent.path, renamedName),
|
||||||
|
attr: {
|
||||||
|
class: "file-path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
debugLog("new renameTasks", renameTasks);
|
||||||
|
state.renameTasks = renameTasks;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var ConfirmModal = class extends import_obsidian.Modal {
|
||||||
|
constructor(app, title, message, onConfirm) {
|
||||||
|
super(app);
|
||||||
|
this.title = title;
|
||||||
|
this.message = message;
|
||||||
|
this.onConfirm = onConfirm;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl, titleEl } = this;
|
||||||
|
titleEl.setText(this.title);
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: this.message
|
||||||
|
});
|
||||||
|
new import_obsidian.Setting(contentEl).addButton((button) => {
|
||||||
|
button.setButtonText("Yes").setClass("mod-warning").onClick(() => {
|
||||||
|
this.onConfirm();
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}).addButton((button) => {
|
||||||
|
button.setButtonText("No").onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/template.ts
|
||||||
|
var dateTmplRegex = /{{DATE:([^}]+)}}/gm;
|
||||||
|
var frontmatterTmplRegex = /{{frontmatter:([^}]+)}}/gm;
|
||||||
|
var replaceDateVar = (s, date) => {
|
||||||
|
const m = dateTmplRegex.exec(s);
|
||||||
|
if (!m)
|
||||||
|
return s;
|
||||||
|
return s.replace(m[0], date.format(m[1]));
|
||||||
|
};
|
||||||
|
var replaceFrontmatterVar = (s, frontmatter) => {
|
||||||
|
if (!frontmatter)
|
||||||
|
return s;
|
||||||
|
const m = frontmatterTmplRegex.exec(s);
|
||||||
|
if (!m)
|
||||||
|
return s;
|
||||||
|
return s.replace(m[0], frontmatter[m[1]] || "");
|
||||||
|
};
|
||||||
|
var renderTemplate = (tmpl, data, frontmatter) => {
|
||||||
|
const now = window.moment();
|
||||||
|
let text = tmpl;
|
||||||
|
let newtext;
|
||||||
|
while ((newtext = replaceDateVar(text, now)) != text) {
|
||||||
|
text = newtext;
|
||||||
|
}
|
||||||
|
while ((newtext = replaceFrontmatterVar(text, frontmatter)) != text) {
|
||||||
|
text = newtext;
|
||||||
|
}
|
||||||
|
text = text.replace(/{{imageNameKey}}/gm, data.imageNameKey).replace(/{{fileName}}/gm, data.fileName).replace(/{{dirName}}/gm, data.dirName).replace(/{{firstHeading}}/gm, data.firstHeading).replace(/{{fileHash}}/gm, data.fileHash);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/main.ts
|
||||||
|
var DEFAULT_SETTINGS = {
|
||||||
|
imageNamePattern: "{{fileName}}",
|
||||||
|
dupNumberAtStart: false,
|
||||||
|
dupNumberDelimiter: "-",
|
||||||
|
dupNumberAlways: false,
|
||||||
|
autoRename: false,
|
||||||
|
handleAllAttachments: false,
|
||||||
|
excludeExtensionPattern: "",
|
||||||
|
disableRenameNotice: false
|
||||||
|
};
|
||||||
|
var PASTED_IMAGE_PREFIX = "Pasted image ";
|
||||||
|
var PasteImageRenamePlugin = class extends import_obsidian2.Plugin {
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.modals = [];
|
||||||
|
}
|
||||||
|
onload() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const pkg = require_package();
|
||||||
|
console.log(`Plugin loading: ${pkg.name} ${pkg.version} BUILD_ENV=${"production"}`);
|
||||||
|
yield this.loadSettings();
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.vault.on("create", (file) => {
|
||||||
|
if (!(file instanceof import_obsidian2.TFile))
|
||||||
|
return;
|
||||||
|
const timeGapMs = new Date().getTime() - file.stat.ctime;
|
||||||
|
if (timeGapMs > 1e3)
|
||||||
|
return;
|
||||||
|
if (isMarkdownFile(file))
|
||||||
|
return;
|
||||||
|
if (isPastedImage(file)) {
|
||||||
|
debugLog("pasted image created", file);
|
||||||
|
this.startRenameProcess(file, this.settings.autoRename);
|
||||||
|
} else {
|
||||||
|
if (this.settings.handleAllAttachments) {
|
||||||
|
debugLog("handleAllAttachments for file", file);
|
||||||
|
if (this.testExcludeExtension(file)) {
|
||||||
|
debugLog("excluded file by ext", file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.startRenameProcess(file, this.settings.autoRename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const startBatchRenameProcess = () => {
|
||||||
|
this.openBatchRenameModal();
|
||||||
|
};
|
||||||
|
this.addCommand({
|
||||||
|
id: "batch-rename-embeded-files",
|
||||||
|
name: "Batch rename embeded files (in the current file)",
|
||||||
|
callback: startBatchRenameProcess
|
||||||
|
});
|
||||||
|
if (DEBUG) {
|
||||||
|
this.addRibbonIcon("wand-glyph", "Batch rename embeded files", startBatchRenameProcess);
|
||||||
|
}
|
||||||
|
const batchRenameAllImages = () => {
|
||||||
|
this.batchRenameAllImages();
|
||||||
|
};
|
||||||
|
this.addCommand({
|
||||||
|
id: "batch-rename-all-images",
|
||||||
|
name: "Batch rename all images instantly (in the current file)",
|
||||||
|
callback: batchRenameAllImages
|
||||||
|
});
|
||||||
|
if (DEBUG) {
|
||||||
|
this.addRibbonIcon("wand-glyph", "Batch rename all images instantly (in the current file)", batchRenameAllImages);
|
||||||
|
}
|
||||||
|
this.addSettingTab(new SettingTab(this.app, this));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
startRenameProcess(file, autoRename = false) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const activeFile = this.getActiveFile();
|
||||||
|
if (!activeFile) {
|
||||||
|
new import_obsidian2.Notice("Error: No active file found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { stem, newName, isMeaningful } = yield this.generateNewName(file, activeFile);
|
||||||
|
debugLog("generated newName:", newName, isMeaningful);
|
||||||
|
if (!isMeaningful || !autoRename) {
|
||||||
|
this.openRenameModal(file, isMeaningful ? stem : "", activeFile.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.renameFile(file, newName, activeFile.path, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renameFile(file, inputNewName, sourcePath, replaceCurrentLine) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const { name: newName } = yield this.deduplicateNewName(inputNewName, file);
|
||||||
|
debugLog("deduplicated newName:", newName);
|
||||||
|
const originName = file.name;
|
||||||
|
const linkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
|
||||||
|
const newPath = path.join(file.parent.path, newName);
|
||||||
|
try {
|
||||||
|
yield this.app.fileManager.renameFile(file, newPath);
|
||||||
|
} catch (err) {
|
||||||
|
new import_obsidian2.Notice(`Failed to rename ${newName}: ${err}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!replaceCurrentLine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newLinkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
|
||||||
|
debugLog("replace text", linkText, newLinkText);
|
||||||
|
const editor = this.getActiveEditor();
|
||||||
|
if (!editor) {
|
||||||
|
new import_obsidian2.Notice(`Failed to rename ${newName}: no active editor`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changes = [];
|
||||||
|
for (let lineNumber = 0; lineNumber < editor.lineCount(); lineNumber++) {
|
||||||
|
const line = editor.getLine(lineNumber);
|
||||||
|
const replacedLine = line.split(linkText).join(newLinkText);
|
||||||
|
if (line === replacedLine) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debugLog("replace line", lineNumber, line, replacedLine);
|
||||||
|
changes.push({
|
||||||
|
from: { line: lineNumber, ch: 0 },
|
||||||
|
to: { line: lineNumber, ch: line.length },
|
||||||
|
text: replacedLine
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (changes.length > 0) {
|
||||||
|
editor.transaction({ changes });
|
||||||
|
}
|
||||||
|
if (!this.settings.disableRenameNotice) {
|
||||||
|
new import_obsidian2.Notice(`Renamed ${originName} to ${newName}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
openRenameModal(file, newName, sourcePath) {
|
||||||
|
const modal = new ImageRenameModal(
|
||||||
|
this.app,
|
||||||
|
file,
|
||||||
|
newName,
|
||||||
|
(confirmedName) => {
|
||||||
|
debugLog("confirmedName:", confirmedName);
|
||||||
|
this.renameFile(file, confirmedName, sourcePath, true);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.modals.splice(this.modals.indexOf(modal), 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.modals.push(modal);
|
||||||
|
modal.open();
|
||||||
|
debugLog("modals count", this.modals.length);
|
||||||
|
}
|
||||||
|
openBatchRenameModal() {
|
||||||
|
const activeFile = this.getActiveFile();
|
||||||
|
const modal = new ImageBatchRenameModal(
|
||||||
|
this.app,
|
||||||
|
activeFile,
|
||||||
|
(file, name) => __async(this, null, function* () {
|
||||||
|
yield this.renameFile(file, name, activeFile.path);
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
this.modals.splice(this.modals.indexOf(modal), 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.modals.push(modal);
|
||||||
|
modal.open();
|
||||||
|
}
|
||||||
|
batchRenameAllImages() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const activeFile = this.getActiveFile();
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(activeFile);
|
||||||
|
if (!fileCache || !fileCache.embeds)
|
||||||
|
return;
|
||||||
|
const extPatternRegex = /jpe?g|png|gif|tiff|webp/i;
|
||||||
|
for (const embed of fileCache.embeds) {
|
||||||
|
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, activeFile.path);
|
||||||
|
if (!file) {
|
||||||
|
console.warn("file not found", embed.link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m0 = extPatternRegex.exec(file.extension);
|
||||||
|
if (!m0)
|
||||||
|
return;
|
||||||
|
const { newName, isMeaningful } = yield this.generateNewName(file, activeFile);
|
||||||
|
debugLog("generated newName:", newName, isMeaningful);
|
||||||
|
if (!isMeaningful) {
|
||||||
|
new import_obsidian2.Notice("Failed to batch rename images: the generated name is not meaningful");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
yield this.renameFile(file, newName, activeFile.path, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// returns a new name for the input file, with extension
|
||||||
|
generateNewName(file, activeFile) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
let imageNameKey = "";
|
||||||
|
let firstHeading = "";
|
||||||
|
let frontmatter;
|
||||||
|
const fileCache = this.app.metadataCache.getFileCache(activeFile);
|
||||||
|
if (fileCache) {
|
||||||
|
debugLog("frontmatter", fileCache.frontmatter);
|
||||||
|
frontmatter = fileCache.frontmatter;
|
||||||
|
imageNameKey = (frontmatter == null ? void 0 : frontmatter.imageNameKey) || "";
|
||||||
|
firstHeading = getFirstHeading(fileCache.headings);
|
||||||
|
} else {
|
||||||
|
console.warn("could not get file cache from active file", activeFile.name);
|
||||||
|
}
|
||||||
|
let fileHash = "";
|
||||||
|
if (this.settings.imageNamePattern.includes("{{fileHash}}")) {
|
||||||
|
const buffer = yield this.app.vault.readBinary(file);
|
||||||
|
fileHash = yield sha256(buffer);
|
||||||
|
}
|
||||||
|
const stem = renderTemplate(
|
||||||
|
this.settings.imageNamePattern,
|
||||||
|
{
|
||||||
|
imageNameKey,
|
||||||
|
fileName: activeFile.basename,
|
||||||
|
dirName: activeFile.parent.name,
|
||||||
|
firstHeading,
|
||||||
|
fileHash
|
||||||
|
},
|
||||||
|
frontmatter
|
||||||
|
);
|
||||||
|
const meaninglessRegex = new RegExp(`[${this.settings.dupNumberDelimiter}\\s]`, "gm");
|
||||||
|
return {
|
||||||
|
stem,
|
||||||
|
newName: stem + "." + file.extension,
|
||||||
|
isMeaningful: stem.replace(meaninglessRegex, "") !== ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// newName: foo.ext
|
||||||
|
deduplicateNewName(newName, file) {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
const dir = file.parent.path;
|
||||||
|
const listed = yield this.app.vault.adapter.list(dir);
|
||||||
|
debugLog("sibling files", listed);
|
||||||
|
const newNameExt = path.extension(newName), newNameStem = newName.slice(0, newName.length - newNameExt.length - 1), newNameStemEscaped = escapeRegExp(newNameStem), delimiter = this.settings.dupNumberDelimiter, delimiterEscaped = escapeRegExp(delimiter);
|
||||||
|
let dupNameRegex;
|
||||||
|
if (this.settings.dupNumberAtStart) {
|
||||||
|
dupNameRegex = new RegExp(
|
||||||
|
`^(?<number>\\d+)${delimiterEscaped}(?<name>${newNameStemEscaped})\\.${newNameExt}$`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dupNameRegex = new RegExp(
|
||||||
|
`^(?<name>${newNameStemEscaped})${delimiterEscaped}(?<number>\\d+)\\.${newNameExt}$`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
debugLog("dupNameRegex", dupNameRegex);
|
||||||
|
const dupNameNumbers = [];
|
||||||
|
let isNewNameExist = false;
|
||||||
|
for (let sibling of listed.files) {
|
||||||
|
sibling = path.basename(sibling);
|
||||||
|
if (sibling == newName) {
|
||||||
|
isNewNameExist = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = dupNameRegex.exec(sibling);
|
||||||
|
if (!m)
|
||||||
|
continue;
|
||||||
|
dupNameNumbers.push(parseInt(m.groups.number));
|
||||||
|
}
|
||||||
|
if (isNewNameExist || this.settings.dupNumberAlways) {
|
||||||
|
const newNumber = dupNameNumbers.length > 0 ? Math.max(...dupNameNumbers) + 1 : 1;
|
||||||
|
if (this.settings.dupNumberAtStart) {
|
||||||
|
newName = `${newNumber}${delimiter}${newNameStem}.${newNameExt}`;
|
||||||
|
} else {
|
||||||
|
newName = `${newNameStem}${delimiter}${newNumber}.${newNameExt}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: newName,
|
||||||
|
stem: newName.slice(0, newName.length - newNameExt.length - 1),
|
||||||
|
extension: newNameExt
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getActiveFile() {
|
||||||
|
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
|
||||||
|
const file = view == null ? void 0 : view.file;
|
||||||
|
debugLog("active file", file == null ? void 0 : file.path);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
getActiveEditor() {
|
||||||
|
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
|
||||||
|
return view == null ? void 0 : view.editor;
|
||||||
|
}
|
||||||
|
onunload() {
|
||||||
|
this.modals.map((modal) => modal.close());
|
||||||
|
}
|
||||||
|
testExcludeExtension(file) {
|
||||||
|
const pattern = this.settings.excludeExtensionPattern;
|
||||||
|
if (!pattern)
|
||||||
|
return false;
|
||||||
|
return new RegExp(pattern).test(file.extension);
|
||||||
|
}
|
||||||
|
loadSettings() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveSettings() {
|
||||||
|
return __async(this, null, function* () {
|
||||||
|
yield this.saveData(this.settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function getFirstHeading(headings) {
|
||||||
|
if (headings && headings.length > 0) {
|
||||||
|
for (const heading of headings) {
|
||||||
|
if (heading.level === 1) {
|
||||||
|
return heading.heading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function isPastedImage(file) {
|
||||||
|
if (file instanceof import_obsidian2.TFile) {
|
||||||
|
if (file.name.startsWith(PASTED_IMAGE_PREFIX)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function isMarkdownFile(file) {
|
||||||
|
if (file instanceof import_obsidian2.TFile) {
|
||||||
|
if (file.extension === "md") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var ImageRenameModal = class extends import_obsidian2.Modal {
|
||||||
|
constructor(app, src, stem, renameFunc, onClose) {
|
||||||
|
super(app);
|
||||||
|
this.src = src;
|
||||||
|
this.stem = stem;
|
||||||
|
this.renameFunc = renameFunc;
|
||||||
|
this.onCloseExtra = onClose;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
this.containerEl.addClass("image-rename-modal");
|
||||||
|
const { contentEl, titleEl } = this;
|
||||||
|
titleEl.setText("Rename image");
|
||||||
|
const imageContainer = contentEl.createDiv({
|
||||||
|
cls: "image-container"
|
||||||
|
});
|
||||||
|
imageContainer.createEl("img", {
|
||||||
|
attr: {
|
||||||
|
src: this.app.vault.getResourcePath(this.src)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let stem = this.stem;
|
||||||
|
const ext = this.src.extension;
|
||||||
|
const getNewName = (stem2) => stem2 + "." + ext;
|
||||||
|
const getNewPath = (stem2) => path.join(this.src.parent.path, getNewName(stem2));
|
||||||
|
const infoET = createElementTree(contentEl, {
|
||||||
|
tag: "ul",
|
||||||
|
cls: "info",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "li",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: "Origin path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: this.src.path
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "li",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: "New path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "span",
|
||||||
|
text: getNewPath(stem)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const doRename = () => __async(this, null, function* () {
|
||||||
|
debugLog("doRename", `stem=${stem}`);
|
||||||
|
this.renameFunc(getNewName(stem));
|
||||||
|
});
|
||||||
|
const nameSetting = new import_obsidian2.Setting(contentEl).setName("New name").setDesc("Please input the new name for the image (without extension)").addText((text) => text.setValue(stem).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
stem = sanitizer.filename(value);
|
||||||
|
infoET.children[1].children[1].el.innerText = getNewPath(stem);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const nameInputEl = nameSetting.controlEl.children[0];
|
||||||
|
nameInputEl.focus();
|
||||||
|
const nameInputState = lockInputMethodComposition(nameInputEl);
|
||||||
|
nameInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
|
||||||
|
if (e.key === "Enter" && !nameInputState.lock) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!stem) {
|
||||||
|
errorEl.innerText = 'Error: "New name" could not be empty';
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doRename();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const errorEl = contentEl.createDiv({
|
||||||
|
cls: "error",
|
||||||
|
attr: {
|
||||||
|
style: "display: none;"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
new import_obsidian2.Setting(contentEl).addButton((button) => {
|
||||||
|
button.setButtonText("Rename").onClick(() => {
|
||||||
|
doRename();
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}).addButton((button) => {
|
||||||
|
button.setButtonText("Cancel").onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
this.onCloseExtra();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var imageNamePatternDesc = `
|
||||||
|
The pattern indicates how the new name should be generated.
|
||||||
|
|
||||||
|
Available variables:
|
||||||
|
- {{fileName}}: name of the active file, without ".md" extension.
|
||||||
|
- {{imageNameKey}}: this variable is read from the markdown file's frontmatter, from the same key "imageNameKey".
|
||||||
|
- {{fileHash}}: SHA-256 hash of the image file.
|
||||||
|
- {{DATE:$FORMAT}}: use "$FORMAT" to format the current date, "$FORMAT" must be a Moment.js format string, e.g. {{DATE:YYYY-MM-DD}}.
|
||||||
|
|
||||||
|
Here are some examples from pattern to image names (repeat in sequence), variables: fileName = "My note", imageNameKey = "foo":
|
||||||
|
- {{fileName}}: My note, My note-1, My note-2
|
||||||
|
- {{imageNameKey}}: foo, foo-1, foo-2
|
||||||
|
- {{fileHash}}: <sha256>, <sha256>-1, <sha256>-2
|
||||||
|
- {{imageNameKey}}-{{DATE:YYYYMMDD}}: foo-20220408, foo-20220408-1, foo-20220408-2
|
||||||
|
`;
|
||||||
|
var SettingTab = class extends import_obsidian2.PluginSettingTab {
|
||||||
|
constructor(app, plugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
display() {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Image name pattern").setDesc(imageNamePatternDesc).setClass("long-description-setting-item").addText((text) => text.setPlaceholder("{{imageNameKey}}").setValue(this.plugin.settings.imageNamePattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.imageNamePattern = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Duplicate number at start (or end)").setDesc(`If enabled, duplicate number will be added at the start as prefix for the image name, otherwise it will be added at the end as suffix for the image name.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAtStart).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.dupNumberAtStart = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Duplicate number delimiter").setDesc(`The delimiter to generate the number prefix/suffix for duplicated names. For example, if the value is "-", the suffix will be like "-1", "-2", "-3", and the prefix will be like "1-", "2-", "3-". Only characters that are valid in file names are allowed.`).addText((text) => text.setValue(this.plugin.settings.dupNumberDelimiter).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.dupNumberDelimiter = sanitizer.delimiter(value);
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Always add duplicate number").setDesc(`If enabled, duplicate number will always be added to the image name. Otherwise, it will only be added when the name is duplicated.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAlways).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.dupNumberAlways = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Auto rename").setDesc(`By default, the rename modal will always be shown to confirm before renaming, if this option is set, the image will be auto renamed after pasting.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.autoRename).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.autoRename = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Handle all attachments").setDesc(`By default, the plugin only handles images that starts with "Pasted image " in name,
|
||||||
|
which is the prefix Obsidian uses to create images from pasted content.
|
||||||
|
If this option is set, the plugin will handle all attachments that are created in the vault.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.handleAllAttachments).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.handleAllAttachments = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Exclude extension pattern").setDesc(`This option is only useful when "Handle all attachments" is enabled.
|
||||||
|
Write a Regex pattern to exclude certain extensions from being handled. Only the first line will be used.`).setClass("single-line-textarea").addTextArea((text) => text.setPlaceholder("docx?|xlsx?|pptx?|zip|rar").setValue(this.plugin.settings.excludeExtensionPattern).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.excludeExtensionPattern = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
new import_obsidian2.Setting(containerEl).setName("Disable rename notice").setDesc(`Turn off this option if you don't want to see the notice when renaming images.
|
||||||
|
Note that Obsidian may display a notice when a link has changed, this option cannot disable that.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.disableRenameNotice).onChange(
|
||||||
|
(value) => __async(this, null, function* () {
|
||||||
|
this.plugin.settings.disableRenameNotice = value;
|
||||||
|
yield this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* nosourcemap */
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "obsidian-paste-image-rename",
|
||||||
|
"name": "Paste image rename (Edited)",
|
||||||
|
"version": "1.6.1",
|
||||||
|
"minAppVersion": "0.12.0",
|
||||||
|
"description": "Rename pasted images and all the other attchments added to the vault",
|
||||||
|
"author": "Reorx",
|
||||||
|
"authorUrl": "https://github.com/reorx",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* src/styles.css */
|
||||||
|
:root {
|
||||||
|
--shadow-color: 0deg 0% 0%;
|
||||||
|
--shadow-elevation-medium:
|
||||||
|
0.5px 0.5px 0.7px hsl(var(--shadow-color) / 0.14),
|
||||||
|
1.1px 1.1px 1.5px -0.9px hsl(var(--shadow-color) / 0.12),
|
||||||
|
2.4px 2.5px 3.3px -1.8px hsl(var(--shadow-color) / 0.1),
|
||||||
|
5.3px 5.6px 7.3px -2.7px hsl(var(--shadow-color) / 0.09),
|
||||||
|
11px 11.4px 15.1px -3.6px hsl(var(--shadow-color) / 0.07);
|
||||||
|
}
|
||||||
|
.image-rename-modal .modal {
|
||||||
|
width: 65%;
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
.image-rename-modal .modal-content {
|
||||||
|
padding: 10px 5px;
|
||||||
|
}
|
||||||
|
.image-rename-modal .image-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.image-rename-modal .info {
|
||||||
|
padding: 10px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.image-rename-modal .info li > span:nth-of-type(1) {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6em;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
.image-rename-modal .info li > span:nth-of-type(1):after {
|
||||||
|
content: ":";
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.image-rename-modal .image-container img {
|
||||||
|
display: block;
|
||||||
|
max-height: 300px;
|
||||||
|
box-shadow: var(--shadow-elevation-medium);
|
||||||
|
}
|
||||||
|
.image-rename-modal .setting-item-control input {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
.image-rename-modal .error {
|
||||||
|
border: 1px solid rgb(201, 90, 90);
|
||||||
|
color: rgb(134, 22, 22);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.image-rename-modal table {
|
||||||
|
font-size: .9em;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.image-rename-modal table td {
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
.image-rename-modal table thead td {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.image-rename-modal table tbody td .file-path {
|
||||||
|
font-size: .8em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.long-description-setting-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.long-description-setting-item .setting-item-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.3em;
|
||||||
|
}
|
||||||
|
.long-description-setting-item .setting-item-control {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.long-description-setting-item .setting-item-control input {
|
||||||
|
min-width: 300px;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"id": "16a573e2ddb25847",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "ead2c6543d1842b7",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "e32607a73f296aa4",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "markdown",
|
||||||
|
"state": {
|
||||||
|
"file": "src/content/posts/codex-warp.md",
|
||||||
|
"mode": "source",
|
||||||
|
"source": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-file",
|
||||||
|
"title": "codex-warp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "vertical"
|
||||||
|
},
|
||||||
|
"left": {
|
||||||
|
"id": "b3b818b3595a168f",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "f871c6ee02fda945",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "6719a883e9f34850",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "file-explorer",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"autoReveal": false
|
||||||
|
},
|
||||||
|
"icon": "lucide-folder-closed",
|
||||||
|
"title": "文件列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8f2d88709f3efdd0",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "search",
|
||||||
|
"state": {
|
||||||
|
"query": "",
|
||||||
|
"matchingCase": false,
|
||||||
|
"explainSearch": false,
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical"
|
||||||
|
},
|
||||||
|
"icon": "lucide-search",
|
||||||
|
"title": "搜索"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e4e31602ca6ef73f",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "bookmarks",
|
||||||
|
"state": {},
|
||||||
|
"icon": "lucide-bookmark",
|
||||||
|
"title": "书签"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "horizontal",
|
||||||
|
"width": 300
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"id": "0e5ea46bbde4f663",
|
||||||
|
"type": "split",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "9a2e329e85417358",
|
||||||
|
"type": "tabs",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "cf180cd264a5895e",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "backlink",
|
||||||
|
"state": {
|
||||||
|
"file": "src/content/posts/codex-warp.md",
|
||||||
|
"collapseAll": false,
|
||||||
|
"extraContext": false,
|
||||||
|
"sortOrder": "alphabetical",
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": "",
|
||||||
|
"backlinkCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-coming-in",
|
||||||
|
"title": "codex-warp 的反向链接列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "20df57f8cd76fbac",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outgoing-link",
|
||||||
|
"state": {
|
||||||
|
"file": "src/content/posts/codex-warp.md",
|
||||||
|
"linksCollapsed": false,
|
||||||
|
"unlinkedCollapsed": true
|
||||||
|
},
|
||||||
|
"icon": "links-going-out",
|
||||||
|
"title": "codex-warp 的出链列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc6075104c1b1a76",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "tag",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "frequency",
|
||||||
|
"useHierarchy": true,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-tags",
|
||||||
|
"title": "标签"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f9fd6fcf70c8f5c5",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "all-properties",
|
||||||
|
"state": {
|
||||||
|
"sortOrder": "frequency",
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-archive",
|
||||||
|
"title": "添加笔记属性"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "41cb4f8448efc28f",
|
||||||
|
"type": "leaf",
|
||||||
|
"state": {
|
||||||
|
"type": "outline",
|
||||||
|
"state": {
|
||||||
|
"file": "src/content/posts/codex-warp.md",
|
||||||
|
"followCursor": false,
|
||||||
|
"showSearch": false,
|
||||||
|
"searchQuery": ""
|
||||||
|
},
|
||||||
|
"icon": "lucide-list",
|
||||||
|
"title": "codex-warp 的大纲"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": "horizontal",
|
||||||
|
"width": 300,
|
||||||
|
"collapsed": true
|
||||||
|
},
|
||||||
|
"left-ribbon": {
|
||||||
|
"hiddenItems": {
|
||||||
|
"switcher:打开快速切换": false,
|
||||||
|
"graph:查看关系图谱": false,
|
||||||
|
"canvas:新建白板": false,
|
||||||
|
"daily-notes:打开/创建今天的日记": false,
|
||||||
|
"templates:插入模板": false,
|
||||||
|
"command-palette:打开命令面板": false,
|
||||||
|
"bases:新建数据库": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": "e32607a73f296aa4",
|
||||||
|
"lastOpenFiles": [
|
||||||
|
"public/pic/f1d08603a003c00e8664b4cb80b20358e9b9638bcedce5a307c91bb853b10eb7.png",
|
||||||
|
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png",
|
||||||
|
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png",
|
||||||
|
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-4.png",
|
||||||
|
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-3.png",
|
||||||
|
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-2.png",
|
||||||
|
"public/pic/122.png",
|
||||||
|
"public/pic/codex-warp-2.png",
|
||||||
|
"public/pic/codex-warp.png",
|
||||||
|
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png",
|
||||||
|
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png",
|
||||||
|
"public/pic",
|
||||||
|
"src/content/assets/images",
|
||||||
|
"src/content/assets",
|
||||||
|
"dist/pagefind/index/zh-cn_6473869.pf_index",
|
||||||
|
"dist/pagefind/index/zh-cn_52f3f92.pf_index",
|
||||||
|
"dist/pagefind/fragment/zh-cn_f061267.pf_fragment",
|
||||||
|
"dist/pagefind/fragment/zh-cn_de70424.pf_fragment",
|
||||||
|
"dist/pagefind/fragment/zh-cn_ddd5448.pf_fragment",
|
||||||
|
"dist/pagefind/fragment/zh-cn_dd6dfac.pf_fragment",
|
||||||
|
"dist/pagefind/fragment/zh-cn_c5d7eb4.pf_fragment",
|
||||||
|
"src/content/posts/codex-warp.md",
|
||||||
|
"src/content/posts/new-domain.md",
|
||||||
|
"dist/pic/Pasted image 20260420023402.png.md",
|
||||||
|
"public/pic/Pasted image 20260420023402.png.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
基于 [Fuwari](https://github.com/saicaca/fuwari) 的静态博客,使用 Astro 6.1 + TailwindCSS 3.4 + Svelte 5。
|
||||||
|
|
||||||
|
- **包管理器**: pnpm 9(`preinstall` hook 强制只允许 pnpm)
|
||||||
|
- **语言**: TypeScript (strict)
|
||||||
|
- **代码规范**: Biome(同时负责 format 和 lint),无 ESLint/Prettier
|
||||||
|
- **部署**: Cloudflare Pages(见 `wrangler.toml`)
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
| 命令 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `pnpm dev` | 启动本地开发服务器 (`localhost:4321`) |
|
||||||
|
| `pnpm build` | 构建生产版本到 `./dist/` 并运行 Pagefind 索引 |
|
||||||
|
| `pnpm preview` | 本地预览构建产物 |
|
||||||
|
| `pnpm check` | 运行 Astro 类型检查 |
|
||||||
|
| `pnpm type-check` | 运行 TypeScript 类型检查 (`tsc --noEmit`) |
|
||||||
|
| `pnpm format` | Biome 格式化 `./src` |
|
||||||
|
| `pnpm lint` | Biome 检查和修复 `./src` |
|
||||||
|
| `pnpm new-post <filename>` | 在 `src/content/posts/` 创建新文章 Markdown |
|
||||||
|
| `pnpm astro` | 直接调用 Astro CLI |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── config.ts # 站点主配置(标题、导航、个人资料、许可证等)
|
||||||
|
├── content.config.ts # Astro Content Collections 定义(posts + spec)
|
||||||
|
├── content/
|
||||||
|
│ ├── posts/ # 博客文章(.md / .mdx),通过文件系统集合加载
|
||||||
|
│ └── spec/ # 特殊页面集合
|
||||||
|
├── components/
|
||||||
|
│ ├── widget/ # 侧边栏组件(Profile, TOC, Tags, Categories 等)
|
||||||
|
│ ├── control/ # 交互控件(BackToTop, Pagination, ButtonLink 等)
|
||||||
|
│ ├── misc/ # 杂项(ImageWrapper, License, Markdown)
|
||||||
|
│ └── *.astro/*.svelte # 页面级组件(Navbar, Footer, Search, PostPage 等)
|
||||||
|
├── pages/
|
||||||
|
│ ├── [...page].astro # 首页分页路由
|
||||||
|
│ ├── posts/[...slug].astro # 文章详情页
|
||||||
|
│ ├── archive.astro # 归档页
|
||||||
|
│ ├── about.astro # 关于页
|
||||||
|
│ ├── friends.astro # 友链页
|
||||||
|
│ ├── rss.xml.ts # RSS 生成
|
||||||
|
│ └── robots.txt.ts # robots.txt
|
||||||
|
├── layouts/
|
||||||
|
│ ├── Layout.astro # 根布局(SEO、主题、Umami 分析、PhotoSwipe)
|
||||||
|
│ └── MainGridLayout.astro # 主网格布局(导航栏、Banner、侧边栏、TOC)
|
||||||
|
├── plugins/ # Remark/Rehype 插件 + Expressive Code 插件
|
||||||
|
├── i18n/ # 多语言翻译(en, zh_CN, ja, ko 等)
|
||||||
|
├── utils/ # 工具函数(content-utils, date-utils, url-utils, setting-utils)
|
||||||
|
├── types/ # TypeScript 类型定义
|
||||||
|
├── constants/ # 常量(分页大小、主题模式、Banner 高度等)
|
||||||
|
└── styles/ # CSS 文件(main.css, markdown.css, scrollbar.css 等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键架构信息
|
||||||
|
|
||||||
|
### 内容管理
|
||||||
|
- 文章以 Markdown/MDX 格式存储在 `src/content/posts/`
|
||||||
|
- 使用 Astro Content Collections 加载,schema 在 `content.config.ts` 中定义
|
||||||
|
- 文章 frontmatter 包含: `title`, `published`, `description`, `image`, `tags`, `category`, `draft`, `lang`, `showcover`, `customcover` 等
|
||||||
|
|
||||||
|
### 路由
|
||||||
|
- `[...page].astro` — 首页分页
|
||||||
|
- `posts/[...slug].astro` — 文章详情页,生成 JSON-LD 结构化数据
|
||||||
|
- `archive.astro` — 归档,tags,categories
|
||||||
|
- 自定义页面: `about.astro`, `friends.astro`
|
||||||
|
|
||||||
|
### 路径别名(tsconfig 配置)
|
||||||
|
- `@/*` → `src/*`,`@components/*` → `src/components/*`,`@assets/*` → `src/assets/*`
|
||||||
|
- `@utils/*` → `src/utils/*`,`@i18n/*` → `src/i18n/*`
|
||||||
|
- `@layouts/*` → `src/layouts/*`,`@constants/*` → `src/constants/*`
|
||||||
|
|
||||||
|
### 页面过渡
|
||||||
|
- 使用 Swup 实现 SPA 风格页面过渡
|
||||||
|
- 支持自定义滚动条 (`OverlayScrollbars`),图片点击放大 (`PhotoSwipe`)
|
||||||
|
|
||||||
|
### Markdown 扩展
|
||||||
|
- GitHub Admonitions (note, tip, important, caution, warning)
|
||||||
|
- GitHub 仓库卡片,数学公式 (KaTeX)
|
||||||
|
- 扩展代码块 (Expressive Code) 支持折叠、行号、复制按钮、语言徽章
|
||||||
|
- 自动标题锚点链接
|
||||||
|
|
||||||
|
### 外部服务
|
||||||
|
- **分析**: Umami(自建)+ Google Analytics
|
||||||
|
- **评论**: Giscus(基于 GitHub Discussions)
|
||||||
|
- **搜索**: Pagefind(构建时生成搜索索引)
|
||||||
|
- **统计**: 通过 Umami API 获取文章浏览量
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `src/config.ts` 是站点配置中心
|
||||||
|
- 构建会先运行 `astro build`,然后执行 `pagefind --site dist` 生成搜索索引
|
||||||
|
- Biome 不处理 CSS 文件(在 `biome.json` 中排除)
|
||||||
|
- Svelte 组件使用 Svelte 5 语法
|
||||||
|
- 主题色基于 CSS 变量 `--hue` 动态计算
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
基于 [Fuwari](https://github.com/saicaca/fuwari) 的静态博客,使用 Astro 6.1 + TailwindCSS 3.4 + Svelte 5。
|
||||||
|
|
||||||
|
- **包管理器**: pnpm 9(`preinstall` hook 强制只允许 pnpm)
|
||||||
|
- **语言**: TypeScript (strict)
|
||||||
|
- **代码规范**: Biome(同时负责 format 和 lint),无 ESLint/Prettier
|
||||||
|
- **部署**: Cloudflare Pages(见 `wrangler.toml`)
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
| 命令 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `pnpm dev` | 启动本地开发服务器 (`localhost:4321`) |
|
||||||
|
| `pnpm build` | 构建生产版本到 `./dist/` 并运行 Pagefind 索引 |
|
||||||
|
| `pnpm preview` | 本地预览构建产物 |
|
||||||
|
| `pnpm check` | 运行 Astro 类型检查 |
|
||||||
|
| `pnpm type-check` | 运行 TypeScript 类型检查 (`tsc --noEmit`) |
|
||||||
|
| `pnpm format` | Biome 格式化 `./src` |
|
||||||
|
| `pnpm lint` | Biome 检查和修复 `./src` |
|
||||||
|
| `pnpm new-post <filename>` | 在 `src/content/posts/` 创建新文章 Markdown |
|
||||||
|
| `pnpm astro` | 直接调用 Astro CLI |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── config.ts # 站点主配置(标题、导航、个人资料、许可证等)
|
||||||
|
├── content.config.ts # Astro Content Collections 定义(posts + spec)
|
||||||
|
├── content/
|
||||||
|
│ ├── posts/ # 博客文章(.md / .mdx),通过文件系统集合加载
|
||||||
|
│ └── spec/ # 特殊页面集合
|
||||||
|
├── components/
|
||||||
|
│ ├── widget/ # 侧边栏组件(Profile, TOC, Tags, Categories 等)
|
||||||
|
│ ├── control/ # 交互控件(BackToTop, Pagination, ButtonLink 等)
|
||||||
|
│ ├── misc/ # 杂项(ImageWrapper, License, Markdown)
|
||||||
|
│ └── *.astro/*.svelte # 页面级组件(Navbar, Footer, Search, PostPage 等)
|
||||||
|
├── pages/
|
||||||
|
│ ├── [...page].astro # 首页分页路由
|
||||||
|
│ ├── posts/[...slug].astro # 文章详情页
|
||||||
|
│ ├── archive.astro # 归档页
|
||||||
|
│ ├── about.astro # 关于页
|
||||||
|
│ ├── friends.astro # 友链页
|
||||||
|
│ ├── rss.xml.ts # RSS 生成
|
||||||
|
│ └── robots.txt.ts # robots.txt
|
||||||
|
├── layouts/
|
||||||
|
│ ├── Layout.astro # 根布局(SEO、主题、Umami 分析、PhotoSwipe)
|
||||||
|
│ └── MainGridLayout.astro # 主网格布局(导航栏、Banner、侧边栏、TOC)
|
||||||
|
├── plugins/ # Remark/Rehype 插件 + Expressive Code 插件
|
||||||
|
├── i18n/ # 多语言翻译(en, zh_CN, ja, ko 等)
|
||||||
|
├── utils/ # 工具函数(content-utils, date-utils, url-utils, setting-utils)
|
||||||
|
├── types/ # TypeScript 类型定义
|
||||||
|
├── constants/ # 常量(分页大小、主题模式、Banner 高度等)
|
||||||
|
└── styles/ # CSS 文件(main.css, markdown.css, scrollbar.css 等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键架构信息
|
||||||
|
|
||||||
|
### 内容管理
|
||||||
|
- 文章以 Markdown/MDX 格式存储在 `src/content/posts/`
|
||||||
|
- 使用 Astro Content Collections 加载,schema 在 `content.config.ts` 中定义
|
||||||
|
- 文章 frontmatter 包含: `title`, `published`, `description`, `image`, `tags`, `category`, `draft`, `lang`, `showcover`, `customcover` 等
|
||||||
|
|
||||||
|
### 路由
|
||||||
|
- `[...page].astro` — 首页分页
|
||||||
|
- `posts/[...slug].astro` — 文章详情页,生成 JSON-LD 结构化数据
|
||||||
|
- `archive.astro` — 归档,tags,categories
|
||||||
|
- 自定义页面: `about.astro`, `friends.astro`
|
||||||
|
|
||||||
|
### 路径别名(tsconfig 配置)
|
||||||
|
- `@/*` → `src/*`,`@components/*` → `src/components/*`,`@assets/*` → `src/assets/*`
|
||||||
|
- `@utils/*` → `src/utils/*`,`@i18n/*` → `src/i18n/*`
|
||||||
|
- `@layouts/*` → `src/layouts/*`,`@constants/*` → `src/constants/*`
|
||||||
|
|
||||||
|
### 页面过渡
|
||||||
|
- 使用 Swup 实现 SPA 风格页面过渡
|
||||||
|
- 支持自定义滚动条 (`OverlayScrollbars`),图片点击放大 (`PhotoSwipe`)
|
||||||
|
|
||||||
|
### Markdown 扩展
|
||||||
|
- GitHub Admonitions (note, tip, important, caution, warning)
|
||||||
|
- GitHub 仓库卡片,数学公式 (KaTeX)
|
||||||
|
- 扩展代码块 (Expressive Code) 支持折叠、行号、复制按钮、语言徽章
|
||||||
|
- 自动标题锚点链接
|
||||||
|
|
||||||
|
### 外部服务
|
||||||
|
- **分析**: Umami(自建)+ Google Analytics
|
||||||
|
- **评论**: Giscus(基于 GitHub Discussions)
|
||||||
|
- **搜索**: Pagefind(构建时生成搜索索引)
|
||||||
|
- **统计**: 通过 Umami API 获取文章浏览量
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `src/config.ts` 是站点配置中心
|
||||||
|
- 构建会先运行 `astro build`,然后执行 `pagefind --site dist` 生成搜索索引
|
||||||
|
- Biome 不处理 CSS 文件(在 `biome.json` 中排除)
|
||||||
|
- Svelte 组件使用 Svelte 5 语法
|
||||||
|
- 主题色基于 CSS 变量 `--hue` 动态计算
|
||||||
|
- 不要执行构建,除非特别要求
|
||||||
@@ -21,6 +21,7 @@ import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.m
|
|||||||
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
|
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
|
||||||
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js";
|
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js";
|
||||||
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js";
|
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js";
|
||||||
|
import { remarkPublicImagePaths } from "./src/plugins/remark-public-image-paths.mjs";
|
||||||
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs";
|
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs";
|
||||||
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js";
|
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js";
|
||||||
import rehypeExternalLinks from 'rehype-external-links';
|
import rehypeExternalLinks from 'rehype-external-links';
|
||||||
@@ -41,7 +42,7 @@ export default defineConfig({
|
|||||||
// when the Tailwind class `transition-all` is used
|
// when the Tailwind class `transition-all` is used
|
||||||
containers: ["main", "#toc"],
|
containers: ["main", "#toc"],
|
||||||
smoothScrolling: true,
|
smoothScrolling: true,
|
||||||
cache: true,
|
cache: process.env.NODE_ENV !== "development",
|
||||||
preload: true,
|
preload: true,
|
||||||
accessibility: true,
|
accessibility: true,
|
||||||
updateHead: true,
|
updateHead: true,
|
||||||
@@ -108,6 +109,7 @@ export default defineConfig({
|
|||||||
remarkMath,
|
remarkMath,
|
||||||
remarkReadingTime,
|
remarkReadingTime,
|
||||||
remarkExcerpt,
|
remarkExcerpt,
|
||||||
|
remarkPublicImagePaths,
|
||||||
remarkGithubAdmonitionsToDirectives,
|
remarkGithubAdmonitionsToDirectives,
|
||||||
remarkDirective,
|
remarkDirective,
|
||||||
remarkSectionize,
|
remarkSectionize,
|
||||||
|
|||||||
@@ -6,42 +6,45 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"build": "astro build && pagefind --site dist",
|
"build": "astro build --verbose && node scripts/rewrite-built-image-links.js && pagefind --site dist",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"type-check": "tsc --noEmit --isolatedDeclarations",
|
"type-check": "tsc --noEmit --isolatedDeclarations",
|
||||||
"new-post": "node scripts/new-post.js",
|
"new-post": "node scripts/new-post.js",
|
||||||
|
"summary": "node scripts/summary.js",
|
||||||
|
"summary:all": "node scripts/summary.js --all",
|
||||||
|
"summary:force": "node scripts/summary.js --all --force",
|
||||||
"format": "biome format --write ./src",
|
"format": "biome format --write ./src",
|
||||||
"lint": "biome check --write ./src",
|
"lint": "biome check --write ./src",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.8",
|
||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/rss": "^4.0.18",
|
||||||
"@astrojs/sitemap": "^3.4.2",
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
"@astrojs/svelte": "7.1.0",
|
"@astrojs/svelte": "8.0.5",
|
||||||
"@astrojs/tailwind": "^6.0.2",
|
"@astrojs/tailwind": "^6.0.2",
|
||||||
"@expressive-code/core": "^0.41.3",
|
"@expressive-code/core": "^0.41.7",
|
||||||
"@expressive-code/plugin-collapsible-sections": "^0.41.3",
|
"@expressive-code/plugin-collapsible-sections": "^0.41.7",
|
||||||
"@expressive-code/plugin-line-numbers": "^0.41.3",
|
"@expressive-code/plugin-line-numbers": "^0.41.7",
|
||||||
"@iconify-json/fa6-brands": "^1.2.6",
|
"@iconify-json/fa6-brands": "^1.2.6",
|
||||||
"@iconify-json/fa6-regular": "^1.2.4",
|
"@iconify-json/fa6-regular": "^1.2.4",
|
||||||
"@iconify-json/fa6-solid": "^1.2.4",
|
"@iconify-json/fa6-solid": "^1.2.4",
|
||||||
"@iconify-json/ic": "^1.2.2",
|
"@iconify-json/ic": "^1.2.4",
|
||||||
"@iconify-json/material-symbols": "^1.2.30",
|
"@iconify-json/material-symbols": "^1.2.67",
|
||||||
"@iconify/svelte": "^4.2.0",
|
"@iconify/svelte": "^4.2.0",
|
||||||
"@swup/astro": "^1.7.0",
|
"@swup/astro": "^1.8.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"astro": "5.12.8",
|
"astro": "6.1.8",
|
||||||
"astro-expressive-code": "^0.41.3",
|
"astro-expressive-code": "^0.41.7",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"hastscript": "^9.0.1",
|
"hastscript": "^9.0.1",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.45",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.1",
|
||||||
"mdast-util-to-string": "^4.0.0",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
"node-html-parser": "^7.0.1",
|
"node-html-parser": "^7.1.0",
|
||||||
"overlayscrollbars": "^2.11.4",
|
"overlayscrollbars": "^2.15.1",
|
||||||
"pagefind": "^1.3.0",
|
"pagefind": "^1.5.2",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"reading-time": "^1.5.0",
|
"reading-time": "^1.5.0",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
@@ -54,22 +57,22 @@
|
|||||||
"remark-github-admonitions-to-directives": "^1.0.5",
|
"remark-github-admonitions-to-directives": "^1.0.5",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-sectionize": "^2.1.0",
|
"remark-sectionize": "^2.1.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.3",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.5",
|
||||||
"stylus": "^0.64.0",
|
"stylus": "^0.64.0",
|
||||||
"svelte": "^5.37.3",
|
"svelte": "^5.55.4",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.3",
|
||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/ts-plugin": "^1.10.4",
|
"@astrojs/ts-plugin": "^1.10.7",
|
||||||
"@biomejs/biome": "2.1.3",
|
"@biomejs/biome": "2.1.3",
|
||||||
"@rollup/plugin-yaml": "^4.1.2",
|
"@rollup/plugin-yaml": "^4.1.2",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/mdast": "^4.0.4",
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"postcss-import": "^16.1.1",
|
"postcss-import": "^16.1.1",
|
||||||
"postcss-nesting": "^13.0.2"
|
"postcss-nesting": "^13.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 703 KiB |
|
Before Width: | Height: | Size: 786 KiB After Width: | Height: | Size: 786 KiB |
|
Before Width: | Height: | Size: 786 KiB After Width: | Height: | Size: 786 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 509 KiB |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 775 KiB After Width: | Height: | Size: 775 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 240 KiB |