Compare commits
3 Commits
e9d50e2735
..
robin
| Author | SHA1 | Date | |
|---|---|---|---|
| b5775dbe8f | |||
| b5b44b3cb4 | |||
| d114372822 |
@@ -1 +0,0 @@
|
||||
* text=auto
|
||||
@@ -26,20 +26,7 @@ package-lock.json
|
||||
bun.lockb
|
||||
yarn.lock
|
||||
|
||||
# src/content/.obsidian
|
||||
|
||||
.playwright-mcp
|
||||
.serena
|
||||
.claude
|
||||
|
||||
# .obsidian
|
||||
.cache
|
||||
|
||||
build.log
|
||||
.traces
|
||||
|
||||
# 2026/5/23 Feedback module api backend
|
||||
feedback-api/*
|
||||
!feedback-api/database.py
|
||||
!feedback-api/main.py
|
||||
!feedback-api/requirements.txt
|
||||
# My test files
|
||||
duolingo.py
|
||||
duolingo copy.py
|
||||
test.py
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"newLinkFormat": "absolute",
|
||||
"newFileLocation": "folder",
|
||||
"newFileFolderPath": "src/content/posts",
|
||||
"alwaysUpdateLinks": true,
|
||||
"attachmentFolderPath": "/public/pic",
|
||||
"useMarkdownLinks": true
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
"fix-public-links",
|
||||
"obsidian-auto-link-title",
|
||||
"obsidian-paste-image-rename"
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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');
|
||||
}
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"regex": {},
|
||||
"lineRegex": {},
|
||||
"linkRegex": {},
|
||||
"linkLineRegex": {},
|
||||
"imageRegex": {},
|
||||
"enhanceDefaultPaste": true,
|
||||
"shouldPreserveSelectionAsTitle": false,
|
||||
"enhanceDropEvents": true,
|
||||
"websiteBlacklist": "",
|
||||
"maximumTitleLength": 0,
|
||||
"useNewScraper": false,
|
||||
"linkPreviewApiKey": "",
|
||||
"useBetterPasteId": true
|
||||
}
|
||||
@@ -1,771 +0,0 @@
|
||||
/*
|
||||
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 */
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/* no styles */
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"imageNamePattern": "{{fileName}}",
|
||||
"dupNumberAtStart": false,
|
||||
"dupNumberDelimiter": "-",
|
||||
"dupNumberAlways": true,
|
||||
"autoRename": true,
|
||||
"handleAllAttachments": true,
|
||||
"excludeExtensionPattern": "",
|
||||
"disableRenameNotice": false,
|
||||
"useFileHashName": true
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
/* 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 */
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/* 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%;
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -24,10 +24,5 @@
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"markdown.validate.enabled": false,
|
||||
"Codegeex.RepoIndex": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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` 动态计算
|
||||
@@ -1,102 +0,0 @@
|
||||
# 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,14 +21,12 @@ import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.m
|
||||
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
|
||||
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.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 { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js";
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://blog.adclosenn.top",
|
||||
site: "https://adclosenn.top",
|
||||
base: "/",
|
||||
trailingSlash: "always",
|
||||
integrations: [
|
||||
@@ -42,7 +40,7 @@ export default defineConfig({
|
||||
// when the Tailwind class `transition-all` is used
|
||||
containers: ["main", "#toc"],
|
||||
smoothScrolling: true,
|
||||
cache: process.env.NODE_ENV !== "development",
|
||||
cache: true,
|
||||
preload: true,
|
||||
accessibility: true,
|
||||
updateHead: true,
|
||||
@@ -78,7 +76,7 @@ export default defineConfig({
|
||||
borderRadius: "0.75rem",
|
||||
borderColor: "none",
|
||||
codeFontSize: "0.875rem",
|
||||
codeFontFamily: "'Cascadia Mono', 'JetBrains Mono'",
|
||||
codeFontFamily: "'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
codeLineHeight: "1.5rem",
|
||||
frames: {
|
||||
editorBackground: "var(--codeblock-bg)",
|
||||
@@ -109,7 +107,6 @@ export default defineConfig({
|
||||
remarkMath,
|
||||
remarkReadingTime,
|
||||
remarkExcerpt,
|
||||
remarkPublicImagePaths,
|
||||
remarkGithubAdmonitionsToDirectives,
|
||||
remarkDirective,
|
||||
remarkSectionize,
|
||||
@@ -131,12 +128,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
rehypeExternalLinks,
|
||||
{
|
||||
target: '_blank',
|
||||
},
|
||||
],
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import aiosqlite
|
||||
|
||||
DB_PATH = "feedback.db"
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
choice TEXT NOT NULL CHECK(choice IN ('yes', 'no')),
|
||||
timestamp TEXT NOT NULL,
|
||||
ip_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_ip_hash
|
||||
ON feedbacks(ip_hash, created_at)
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def add_feedback(ip_hash: str, url: str, choice: str, timestamp: str):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO feedbacks (url, choice, timestamp, ip_hash) VALUES (?, ?, ?, ?)",
|
||||
(url, choice, timestamp, ip_hash),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def check_rate_limit(ip_hash: str, limit: int = 5, window_seconds: int = 3600) -> bool:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(*) FROM feedbacks WHERE ip_hash = ? AND created_at > datetime('now', ?)",
|
||||
(ip_hash, f"-{window_seconds} seconds"),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] >= limit
|
||||
|
||||
|
||||
async def get_stats() -> dict:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute("SELECT COUNT(*) FROM feedbacks")
|
||||
total = (await cursor.fetchone())[0]
|
||||
|
||||
cursor = await db.execute("SELECT choice, COUNT(*) FROM feedbacks GROUP BY choice")
|
||||
rows = await cursor.fetchall()
|
||||
counts = {row[0]: row[1] for row in rows}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"yes": counts.get("yes", 0),
|
||||
"no": counts.get("no", 0),
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import hashlib
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import add_feedback, check_rate_limit, get_stats, init_db
|
||||
|
||||
|
||||
class FeedbackIn(BaseModel):
|
||||
url: str
|
||||
choice: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
RATE_LIMIT = 5
|
||||
RATE_WINDOW = 3600
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
cf_ip = request.headers.get("cf-connecting-ip")
|
||||
if cf_ip:
|
||||
return cf_ip
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host or "unknown"
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
async def receive_feedback(body: FeedbackIn, request: Request):
|
||||
ip = get_client_ip(request)
|
||||
ip_hash = hashlib.sha256(ip.encode()).hexdigest()
|
||||
|
||||
if not await check_rate_limit(ip_hash, RATE_LIMIT, RATE_WINDOW):
|
||||
await add_feedback(ip_hash, body.url, body.choice, body.timestamp)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/feedback/stats")
|
||||
async def stats():
|
||||
return await get_stats()
|
||||
|
||||
|
||||
@app.get("/api/feedback/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8005)
|
||||
@@ -1,3 +0,0 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
aiosqlite>=0.20.0
|
||||
@@ -6,50 +6,48 @@
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"check": "astro check",
|
||||
"build": "astro build --verbose && node scripts/rewrite-built-image-links.js && pagefind --site dist",
|
||||
"build": "astro build && pagefind --site dist",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "tsc --noEmit --isolatedDeclarations",
|
||||
"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",
|
||||
"lint": "biome check --write ./src",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.8",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@astrojs/svelte": "8.0.5",
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@astrojs/sitemap": "^3.4.2",
|
||||
"@astrojs/svelte": "7.1.0",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@expressive-code/core": "^0.41.7",
|
||||
"@expressive-code/plugin-collapsible-sections": "^0.41.7",
|
||||
"@expressive-code/plugin-line-numbers": "^0.41.7",
|
||||
"@expressive-code/core": "^0.41.3",
|
||||
"@expressive-code/plugin-collapsible-sections": "^0.41.3",
|
||||
"@expressive-code/plugin-line-numbers": "^0.41.3",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.6",
|
||||
"@fontsource/roboto": "^5.2.6",
|
||||
"@iconify-json/fa6-brands": "^1.2.6",
|
||||
"@iconify-json/fa6-regular": "^1.2.4",
|
||||
"@iconify-json/fa6-solid": "^1.2.4",
|
||||
"@iconify-json/ic": "^1.2.4",
|
||||
"@iconify-json/material-symbols": "^1.2.67",
|
||||
"@iconify-json/ic": "^1.2.2",
|
||||
"@iconify-json/material-symbols": "^1.2.30",
|
||||
"@iconify/svelte": "^4.2.0",
|
||||
"@swup/astro": "^1.8.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"astro": "6.1.8",
|
||||
"astro-expressive-code": "^0.41.7",
|
||||
"@swup/astro": "^1.7.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"astro": "5.12.8",
|
||||
"astro-expressive-code": "^0.41.3",
|
||||
"astro-icon": "^1.1.5",
|
||||
"hastscript": "^9.0.1",
|
||||
"katex": "^0.16.45",
|
||||
"markdown-it": "^14.1.1",
|
||||
"katex": "^0.16.22",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"overlayscrollbars": "^2.15.1",
|
||||
"pagefind": "^1.5.2",
|
||||
"node-html-parser": "^7.0.1",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"pagefind": "^1.3.0",
|
||||
"photoswipe": "^5.4.4",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-components": "^0.3.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-directive": "^3.0.1",
|
||||
@@ -57,22 +55,22 @@
|
||||
"remark-github-admonitions-to-directives": "^1.0.5",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-sectionize": "^2.1.0",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"sharp": "^0.34.5",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "^0.34.3",
|
||||
"stylus": "^0.64.0",
|
||||
"svelte": "^5.55.4",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
"svelte": "^5.37.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.9.2",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/ts-plugin": "^1.10.7",
|
||||
"@astrojs/ts-plugin": "^1.10.4",
|
||||
"@biomejs/biome": "2.1.3",
|
||||
"@rollup/plugin-yaml": "^4.1.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"postcss-import": "^16.1.1",
|
||||
"postcss-nesting": "^13.0.2"
|
||||
},
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>404 未找到 - Ad_closeNN 的小站</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="refresh" content="5;url=/">
|
||||
<meta id="theme-color-meta" name="theme-color" content="#48823b">
|
||||
<link rel="icon" href="/assets/avatar.jpg">
|
||||
<style>
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #fefefe center bottom fixed;
|
||||
-webkit-background-size: cover;
|
||||
-moz-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
background-size: cover;
|
||||
color: #2E2F30;
|
||||
margin: 0;
|
||||
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿蒙黑体, Times New Roman, sans-serif;
|
||||
|
||||
}
|
||||
|
||||
.dialog {
|
||||
float: left;
|
||||
text-align: left;
|
||||
width: 50%;
|
||||
margin: 2% auto 0;
|
||||
padding-left: 10%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 5em;
|
||||
color: #393939;
|
||||
line-height: 1em;
|
||||
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿蒙黑体, Times New Roman, sans-serif;
|
||||
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2em;
|
||||
color: #393939;
|
||||
line-height: .5em;
|
||||
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿蒙黑体, Times New Roman, sans-serif;
|
||||
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 1.4em;
|
||||
color: #393939;
|
||||
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿蒙黑体, Times New Roman, sans-serif;
|
||||
|
||||
}
|
||||
|
||||
.link {
|
||||
color: grey
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<div class="dialog">
|
||||
<h1>Woops!</h1>
|
||||
<span>我们找不到您要访问的页面</span>
|
||||
<span id="fullpath" class="link"></span>
|
||||
<br>
|
||||
<br>
|
||||
<h2>将在 5 秒后跳转回首页</h2>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let fullpath = window.location.href
|
||||
document.getElementById("fullpath").innerText = fullpath
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
@@ -1,2 +0,0 @@
|
||||
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M16 8.016A8.522 8.522 0 008.016 16h-.032A8.521 8.521 0 000 8.016v-.032A8.521 8.521 0 007.984 0h.032A8.522 8.522 0 0016 7.984v.032z" fill="url(#prefix__paint0_radial_980_20147)"/><defs><radialGradient id="prefix__paint0_radial_980_20147" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)"><stop offset=".067" stop-color="#9168C0"/><stop offset=".343" stop-color="#5684D1"/><stop offset=".672" stop-color="#1BA1E3"/></radialGradient></defs></svg>
|
||||
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 652 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 703 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 509 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 775 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,70 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const DIST_DIR = path.resolve("dist");
|
||||
const REMOTE_RAW_ORIGIN = "https://cnb.cool/CLN-Grated/blog-fuwari/-/git/raw/main/public";
|
||||
const TEXT_FILE_EXTENSIONS = new Set([".html", ".xml", ".json", ".js", ".css", ".txt"]);
|
||||
const PIC_PATH_PATTERN = /(?:https:\/\/blog\.adclosenn\.top)?\/pic\/([^"'\s)<>]+)/g;
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return walk(fullPath);
|
||||
}
|
||||
return [fullPath];
|
||||
}),
|
||||
);
|
||||
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function rewriteFile(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!TEXT_FILE_EXTENSIONS.has(ext)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const original = await fs.readFile(filePath, "utf8");
|
||||
let replacements = 0;
|
||||
const rewritten = original.replace(PIC_PATH_PATTERN, (_match, rest) => {
|
||||
replacements += 1;
|
||||
return `${REMOTE_RAW_ORIGIN}/pic/${rest}`;
|
||||
});
|
||||
|
||||
if (rewritten === original) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, rewritten, "utf8");
|
||||
return replacements;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await fs.access(DIST_DIR);
|
||||
} catch {
|
||||
console.error(`[rewrite-built-image-links] dist directory not found: ${DIST_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = await walk(DIST_DIR);
|
||||
let touchedFiles = 0;
|
||||
let totalReplacements = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
const replacements = await rewriteFile(filePath);
|
||||
if (replacements > 0) {
|
||||
touchedFiles += 1;
|
||||
totalReplacements += replacements;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[rewrite-built-image-links] Replaced ${totalReplacements} link(s) in ${touchedFiles} file(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,459 +0,0 @@
|
||||
/* This is a script to generate AI summary for a post */
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import https from "https"
|
||||
import readline from "readline"
|
||||
|
||||
const targetDir = "./src/content/posts/"
|
||||
const summaryModel = "deepseek-v4-flash-free"
|
||||
const batchDelayMs = 1500
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
|
||||
|
||||
function parseAiSummaryValue(value) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
if (trimmed.startsWith('"')) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function extractAiSummary(frontmatter) {
|
||||
const summaryMatch = frontmatter.match(/^aiSummary:[ \t]*(.*)$/m)
|
||||
if (!summaryMatch) return null
|
||||
|
||||
const inlineValue = summaryMatch[1].trim()
|
||||
if (inlineValue !== ">" && inlineValue !== "|") {
|
||||
return parseAiSummaryValue(inlineValue)
|
||||
}
|
||||
|
||||
const afterSummary = frontmatter.slice(summaryMatch.index + summaryMatch[0].length)
|
||||
const lines = afterSummary.split(/\r?\n/)
|
||||
const blockLines = []
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith(" ") && line.trim()) break
|
||||
blockLines.push(line.replace(/^ {1,2}/, ""))
|
||||
}
|
||||
return blockLines.join("\n").trim() || null
|
||||
}
|
||||
|
||||
function formatFrontmatterField(key, value) {
|
||||
return `${key}: ${JSON.stringify(value.replace(/\r?\n/g, " "))}`
|
||||
}
|
||||
|
||||
function upsertFrontmatterField(frontmatter, key, value) {
|
||||
const fieldPattern = new RegExp(`^${key}:[ \\t]*(?:.*(?:\\r?\\n[ \\t].*)*)`, "m")
|
||||
const formattedField = formatFrontmatterField(key, value)
|
||||
return fieldPattern.test(frontmatter)
|
||||
? frontmatter.replace(fieldPattern, formattedField)
|
||||
: `${frontmatter.trimEnd()}\n${formattedField}`
|
||||
}
|
||||
|
||||
function stripAdmonitionMarkers(text) {
|
||||
return text
|
||||
.replace(/^:::(note|tip|important|caution|warning)(?:\[[^\]]*\])?\s*$/gim, "")
|
||||
.replace(/^:::\s*$/gm, "")
|
||||
.replace(/^>\s*\[!(note|tip|important|caution|warning)\]\s*$/gim, "")
|
||||
.replace(/^\s*\[!(note|tip|important|caution|warning)\]\s*/gim, "")
|
||||
}
|
||||
|
||||
function cleanGeneratedSummary(summary) {
|
||||
return stripAdmonitionMarkers(summary)
|
||||
.replace(/^(note|tip|important|caution|warning|警告|注意|提示)[::\s-]+/i, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getPostFiles() {
|
||||
const files = fs.readdirSync(targetDir)
|
||||
return files.filter(f => f.endsWith(".md") || f.endsWith(".mdx"))
|
||||
}
|
||||
|
||||
function getCurrentAiSummary(fileName) {
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
const content = fs.readFileSync(fullPath, "utf-8")
|
||||
const match = content.match(frontmatterRegex)
|
||||
if (match) {
|
||||
const frontmatter = match[1]
|
||||
const summary = extractAiSummary(frontmatter)
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function selectFile(files) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = createInterface()
|
||||
const summaries = new Map(files.map(file => [file, getCurrentAiSummary(file)]))
|
||||
const wasRaw = process.stdin.isRaw
|
||||
let selectedIndex = 0
|
||||
let closed = false
|
||||
|
||||
readline.emitKeypressEvents(process.stdin, rl)
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
process.stdin.removeListener("keypress", onKey)
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(Boolean(wasRaw))
|
||||
}
|
||||
process.stdout.write("\x1b[?25h")
|
||||
rl.close()
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const rows = process.stdout.rows || 24
|
||||
const listHeight = Math.max(5, rows - 8)
|
||||
const start = Math.min(
|
||||
Math.max(0, selectedIndex - Math.floor(listHeight / 2)),
|
||||
Math.max(0, files.length - listHeight),
|
||||
)
|
||||
const visibleFiles = files.slice(start, start + listHeight)
|
||||
let output = "\x1b[?25l\x1b[2J\x1b[H"
|
||||
output += "选择文章(↑/↓ 或 j/k 移动,Enter 确认,q/Esc 取消)\n\n"
|
||||
|
||||
visibleFiles.forEach((file, offset) => {
|
||||
const index = start + offset
|
||||
const currentSummary = summaries.get(file)
|
||||
const prefix = index === selectedIndex ? "❯" : " "
|
||||
const hasSummary = currentSummary ? " 已有摘要" : ""
|
||||
output += `${prefix} ${file}${hasSummary}\n`
|
||||
})
|
||||
|
||||
const currentFile = files[selectedIndex]
|
||||
const currentSummary = summaries.get(currentFile)
|
||||
output += `\n${selectedIndex + 1}/${files.length} ${currentFile}\n`
|
||||
if (currentSummary) {
|
||||
output += `当前摘要:${currentSummary}\n`
|
||||
}
|
||||
process.stdout.write(output)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
cleanup()
|
||||
process.stdout.write("已取消\n")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const onKey = (_str, key = {}) => {
|
||||
if (key.ctrl && key.name === "c") {
|
||||
cancel()
|
||||
}
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
selectedIndex = Math.max(0, selectedIndex - 1)
|
||||
draw()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
selectedIndex = Math.min(files.length - 1, selectedIndex + 1)
|
||||
draw()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "return") {
|
||||
const selectedFile = files[selectedIndex]
|
||||
cleanup()
|
||||
resolve(selectedFile)
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "escape" || key.name === "q") {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on("keypress", onKey)
|
||||
draw()
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getRetryAfterMs(value) {
|
||||
if (!value) return null
|
||||
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) {
|
||||
return Math.max(0, seconds * 1000)
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value)
|
||||
if (Number.isFinite(timestamp)) {
|
||||
return Math.max(0, timestamp - Date.now())
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getRetryDelayMs(error, attempt) {
|
||||
const retryAfterMs = getRetryAfterMs(error.retryAfter)
|
||||
if (retryAfterMs !== null) {
|
||||
return Math.min(retryAfterMs, 120000)
|
||||
}
|
||||
|
||||
const exponentialDelay = 3000 * 2 ** (attempt - 1)
|
||||
const jitter = Math.floor(Math.random() * 1000)
|
||||
return Math.min(exponentialDelay + jitter, 120000)
|
||||
}
|
||||
|
||||
function shouldRetry(error) {
|
||||
return !error.statusCode || error.statusCode === 429 || error.statusCode >= 500
|
||||
}
|
||||
|
||||
function requestSummary(options, requestBody) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
let data = ""
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
|
||||
res.on("end", () => {
|
||||
if (res.statusCode !== 200) {
|
||||
const error = new Error(`HTTP ${res.statusCode}`)
|
||||
error.statusCode = res.statusCode
|
||||
error.retryAfter = res.headers["retry-after"]
|
||||
error.responseBody = data.slice(0, 500)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(data)
|
||||
const output = response.output
|
||||
if (!output || !Array.isArray(output)) {
|
||||
reject(new Error("Failed to generate summary"))
|
||||
return
|
||||
}
|
||||
|
||||
const messageOutput = output.find(o => o.type === "message")
|
||||
if (!messageOutput || !messageOutput.content) {
|
||||
reject(new Error("No message output found"))
|
||||
return
|
||||
}
|
||||
|
||||
const textBlock = messageOutput.content.find((block) => block.type === "output_text")
|
||||
if (!textBlock) {
|
||||
reject(new Error("No output_text block found"))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(textBlock.text.trim())
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on("error", (error) => {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
req.write(requestBody)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function generateSummary(fileName) {
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
const fileContent = fs.readFileSync(fullPath, "utf-8")
|
||||
const frontmatterMatch = fileContent.match(frontmatterRegex)
|
||||
const bodyOriginal = frontmatterMatch
|
||||
? fileContent.slice(frontmatterMatch[0].length).trimStart()
|
||||
: fileContent;
|
||||
const bodyForAI = stripAdmonitionMarkers(bodyOriginal)
|
||||
const eol = fileContent.includes("\r\n") ? "\r\n" : "\n"
|
||||
|
||||
console.log("\n生成 AI 摘要中...\n")
|
||||
|
||||
const apiKey = "public"
|
||||
const apiUrl = "opencode.ai/zen"
|
||||
const prompt = `请为以下博客文章生成一个不超过100字的中文摘要。
|
||||
|
||||
输出要求:
|
||||
- 只输出摘要正文,不要解释,不要加标题。
|
||||
- 摘要必须以“本文介绍了”开头。
|
||||
- 使用一句话概括文章主题、关键内容和用途。
|
||||
- 不要输出 Markdown 语法、admonition 标记或提示框类型,例如 :::warning、:::caution、[!warning]、警告、注意。
|
||||
|
||||
文章内容:
|
||||
${bodyForAI}`
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
model: summaryModel,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: prompt }]
|
||||
}
|
||||
],
|
||||
max_output_tokens: 500,
|
||||
stream: false,
|
||||
reasoning: { effort: "minimal" }
|
||||
})
|
||||
|
||||
const url = new URL(`https://${apiUrl}/v1/responses`)
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"anthropic-version": "2023-06-01"
|
||||
}
|
||||
}
|
||||
|
||||
const maxAttempts = 6
|
||||
let summary = ""
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
summary = cleanGeneratedSummary(await requestSummary(options, requestBody))
|
||||
break
|
||||
} catch (error) {
|
||||
if (!shouldRetry(error) || attempt === maxAttempts) {
|
||||
if (error.responseBody) {
|
||||
console.error("Error response:", error.responseBody)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const delayMs = getRetryDelayMs(error, attempt)
|
||||
console.warn(`请求失败:${error.message},${Math.ceil(delayMs / 1000)} 秒后重试(${attempt}/${maxAttempts - 1})`)
|
||||
await sleep(delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
let newContent
|
||||
if (frontmatterMatch) {
|
||||
const frontmatter = frontmatterMatch[1]
|
||||
const newFrontmatter = upsertFrontmatterField(
|
||||
upsertFrontmatterField(frontmatter, "aiSummary", summary),
|
||||
"aiSummaryModel",
|
||||
summaryModel,
|
||||
)
|
||||
|
||||
newContent = `---${eol}${newFrontmatter}${eol}---${eol}${bodyOriginal}`
|
||||
} else {
|
||||
newContent = `---${eol}${formatFrontmatterField("aiSummary", summary)}${eol}${formatFrontmatterField("aiSummaryModel", summaryModel)}${eol}---${eol}${bodyOriginal}`
|
||||
}
|
||||
|
||||
fs.writeFileSync(fullPath, newContent)
|
||||
return summary
|
||||
}
|
||||
|
||||
function createInterface() {
|
||||
return readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
}
|
||||
|
||||
async function generateMissingSummaries(files, { force = false } = {}) {
|
||||
const pendingFiles = force ? files : files.filter(file => !getCurrentAiSummary(file))
|
||||
|
||||
if (pendingFiles.length === 0) {
|
||||
console.log("所有文章都已有 AI 摘要")
|
||||
return
|
||||
}
|
||||
|
||||
if (force) {
|
||||
console.log(`将强制为 ${pendingFiles.length} 篇文章重新生成 AI 摘要。\n`)
|
||||
} else {
|
||||
console.log(`将为 ${pendingFiles.length} 篇文章生成 AI 摘要,跳过 ${files.length - pendingFiles.length} 篇已有摘要的文章。\n`)
|
||||
}
|
||||
|
||||
const failedFiles = []
|
||||
|
||||
for (const [index, file] of pendingFiles.entries()) {
|
||||
console.log(`[${index + 1}/${pendingFiles.length}] ${file}`)
|
||||
try {
|
||||
const summary = await generateSummary(file)
|
||||
console.log(`完成:${summary}\n`)
|
||||
if (index < pendingFiles.length - 1) {
|
||||
await sleep(batchDelayMs)
|
||||
}
|
||||
} catch (err) {
|
||||
failedFiles.push({ file, message: err.message })
|
||||
console.error(`失败:${file} - ${err.message}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
console.error("以下文章生成失败:")
|
||||
failedFiles.forEach(({ file, message }) => {
|
||||
console.error(`- ${file}: ${message}`)
|
||||
})
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = getPostFiles()
|
||||
const args = process.argv.slice(2)
|
||||
const force = args.includes("--force")
|
||||
const all = args.includes("--all")
|
||||
const positionalArgs = args.filter(arg => !arg.startsWith("--"))
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("没有找到任何文章文件")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (all) {
|
||||
await generateMissingSummaries(files, { force })
|
||||
} else if (positionalArgs.length > 0) {
|
||||
const fileName = positionalArgs[0]
|
||||
let targetFile = fileName
|
||||
|
||||
const fileExtensionRegex = /\.md(x)?$/i
|
||||
if (!fileExtensionRegex.test(targetFile)) {
|
||||
targetFile += ".md"
|
||||
}
|
||||
|
||||
if (!files.includes(targetFile)) {
|
||||
console.error(`Error: File ${targetFile} does not exist`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await generateSummary(targetFile)
|
||||
console.log(`AI 摘要已生成: \n${summary}`)
|
||||
} catch (err) {
|
||||
console.error(`生成失败: ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
const selectedFile = await selectFile(files)
|
||||
console.log(`已选择: ${selectedFile}\n`)
|
||||
|
||||
try {
|
||||
const summary = await generateSummary(selectedFile)
|
||||
console.log(`AI 摘要已生成: ${summary}`)
|
||||
} catch (err) {
|
||||
console.error(`生成失败: ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -19,9 +19,8 @@ interface Post {
|
||||
data: {
|
||||
title: string;
|
||||
tags: string[];
|
||||
category?: string | null;
|
||||
category?: string;
|
||||
published: Date;
|
||||
pinned?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,14 +131,9 @@ onMount(async () => {
|
||||
<div
|
||||
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
|
||||
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
|
||||
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden flex items-center gap-2"
|
||||
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
>
|
||||
{#if post.data.pinned}
|
||||
<span class="inline-flex shrink-0 items-center gap-1 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-2 py-0.5 text-[11px] font-semibold text-[var(--primary)] leading-none">
|
||||
置顶
|
||||
</span>
|
||||
{/if}
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{post.data.title}</span>
|
||||
{post.data.title}
|
||||
</div>
|
||||
|
||||
<!-- tag list -->
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
---
|
||||
|
||||
import { siteConfig } from "../config";
|
||||
---
|
||||
|
||||
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
|
||||
</div>
|
||||
// 周日判断
|
||||
const isSunday = true;
|
||||
|
||||
// 这tm重置色
|
||||
---
|
||||
{isSunday &&
|
||||
<div id="config-carrier" data-hue={290}></div>
|
||||
}
|
||||
{!isSunday &&
|
||||
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}></div>
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,9 @@ const currentYear = new Date().getFullYear();
|
||||
<!--<div class="transition bg-[oklch(92%_0.01_var(--hue))] dark:bg-black rounded-2xl py-8 mt-4 mb-8 flex flex-col items-center justify-center px-6">-->
|
||||
<div class="transition border-dashed border-[oklch(85%_0.01_var(--hue))] dark:border-white/15 rounded-2xl mb-12 flex flex-col items-center justify-center px-6">
|
||||
<div class="transition text-50 text-sm text-center">
|
||||
<a href="https://icp.redcha.cn/beian/ICP-2025080144.html" title="茶ICP备2025080144号" target="_blank">茶ICP备2025080144号</a>
|
||||
<br>
|
||||
<a href="https://icp.gov.moe/?keyword=20256087" target="_blank">萌ICP备20256087号</a>
|
||||
<br>
|
||||
© <span id="copyright-year">2025-present</span> <span><a class="transition link text-[var(--primary)] font-medium" href="https://github.com/Ad-closeNN" target="blank">{profileConfig.name}</a></span>. 博客代码 <a href="https://github.com/Ad-closeNN/blog-fuwari" class="transition link text-[var(--primary)] font-medium" target="_blank">已开源</a>
|
||||
© <span id="copyright-year">{currentYear}</span> <span><a class="transition link text-[var(--primary)] font-medium" href="https://github.com/Ad-closeNN" target="blank">{profileConfig.name}</a></span>. All Rights Reserved.
|
||||
<br>
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('rss.xml')}>RSS</a> /
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('sitemap-index.xml')}>Sitemap</a><br>
|
||||
@@ -23,17 +21,5 @@ const currentYear = new Date().getFullYear();
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://astro.build">Astro</a> 和
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://github.com/saicaca/fuwari">Fuwari</a>
|
||||
强力驱动
|
||||
<br>
|
||||
由
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://www.cloudflare.com">Cloudflare</a>
|
||||
构建并部署至全球 Cloudflare CDN 节点
|
||||
<br>
|
||||
</div>
|
||||
<img
|
||||
src="https://www.cloudflare.com/img/logo-cloudflare-dark.svg"
|
||||
alt="Cloudflare"
|
||||
draggable="false"
|
||||
ondragstart="return false;"
|
||||
style="height: 30px; user-select: none; -webkit-user-drag: none;"
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { AUTO_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants.ts";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import Icon from "@iconify/svelte";
|
||||
import {
|
||||
applyThemeToDocument,
|
||||
getStoredTheme,
|
||||
setTheme,
|
||||
} from "@utils/setting-utils.ts";
|
||||
import { onMount } from "svelte";
|
||||
import type { LIGHT_DARK_MODE } from "@/types/config.ts";
|
||||
|
||||
const seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE];
|
||||
let mode: LIGHT_DARK_MODE = LIGHT_MODE;
|
||||
|
||||
onMount(() => {
|
||||
mode = getStoredTheme();
|
||||
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const changeThemeWhenSchemeChanged: Parameters<
|
||||
typeof darkModePreference.addEventListener<"change">
|
||||
>[1] = (_e) => {
|
||||
applyThemeToDocument(mode);
|
||||
};
|
||||
darkModePreference.addEventListener("change", changeThemeWhenSchemeChanged);
|
||||
return () => {
|
||||
darkModePreference.removeEventListener(
|
||||
"change",
|
||||
changeThemeWhenSchemeChanged,
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
function switchScheme(newMode: LIGHT_DARK_MODE) {
|
||||
mode = newMode;
|
||||
setTheme(newMode);
|
||||
}
|
||||
|
||||
function toggleScheme() {
|
||||
let i = 0;
|
||||
for (; i < seq.length; i++) {
|
||||
if (seq[i] === mode) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
switchScheme(seq[(i + 1) % seq.length]);
|
||||
}
|
||||
|
||||
function showPanel() {
|
||||
const panel = document.querySelector("#light-dark-panel");
|
||||
panel.classList.remove("float-panel-closed");
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const panel = document.querySelector("#light-dark-panel");
|
||||
panel.classList.add("float-panel-closed");
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- z-50 make the panel higher than other float panels -->
|
||||
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={hidePanel}>
|
||||
<button aria-label="Light/Dark Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch" onclick={toggleScheme} onmouseenter={showPanel}>
|
||||
<div class="absolute" class:opacity-0={mode !== LIGHT_MODE}>
|
||||
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
|
||||
</div>
|
||||
<div class="absolute" class:opacity-0={mode !== DARK_MODE}>
|
||||
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div id="light-dark-panel" class="hidden lg:block absolute transition float-panel-closed top-11 -right-2 pt-5" >
|
||||
<div class="card-base float-panel p-2">
|
||||
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
|
||||
class:current-theme-btn={mode === LIGHT_MODE}
|
||||
onclick={() => switchScheme(LIGHT_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.lightMode)}
|
||||
</button>
|
||||
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
|
||||
class:current-theme-btn={mode === DARK_MODE}
|
||||
onclick={() => switchScheme(DARK_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.darkMode)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,9 +4,10 @@ import { navBarConfig, siteConfig } from "../config";
|
||||
import { LinkPresets } from "../constants/link-presets";
|
||||
import { LinkPreset, type NavBarLink } from "../types/config";
|
||||
import { url } from "../utils/url-utils";
|
||||
import LightDarkSwitch from "./LightDarkSwitch.svelte";
|
||||
import Search from "./Search.svelte";
|
||||
import DisplaySettings from "./widget/DisplaySettings.svelte";
|
||||
import NavMenuPanel from "./widget/NavMenuPanel.astro";
|
||||
import ThemeSettingsBlock from "./widget/ThemeSettingsBlock.svelte";
|
||||
|
||||
const className = Astro.props.class;
|
||||
|
||||
@@ -23,11 +24,10 @@ let links: NavBarLink[] = navBarConfig.links.map(
|
||||
<div class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"></div> <!-- used for onload animation -->
|
||||
<div class:list={[
|
||||
className,
|
||||
"navbar-blur card-base relative isolate !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4 !bg-white/45 dark:!bg-slate-900/35 backdrop-blur-xl backdrop-saturate-150 border-x border-b border-white/35 dark:border-white/10 shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:shadow-[0_8px_24px_rgba(2,6,23,0.22)]"]}>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/55 dark:bg-white/12"></div>
|
||||
"card-base !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4"]}>
|
||||
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95">
|
||||
<div class="flex flex-row text-[var(--primary)] items-center text-md">
|
||||
<Icon is:inline name="material-symbols:home-outline-rounded" class="text-[1.75rem] mb-1 mr-2" />
|
||||
<Icon name="material-symbols:home-outline-rounded" class="text-[1.75rem] mb-1 mr-2" />
|
||||
{siteConfig.title}
|
||||
</div>
|
||||
</a>
|
||||
@@ -38,7 +38,7 @@ let links: NavBarLink[] = navBarConfig.links.map(
|
||||
>
|
||||
<div class="flex items-center">
|
||||
{l.name}
|
||||
{l.external && <Icon is:inline name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>}
|
||||
{l.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>}
|
||||
</div>
|
||||
</a>;
|
||||
})}
|
||||
@@ -46,17 +46,50 @@ let links: NavBarLink[] = navBarConfig.links.map(
|
||||
<div class="flex">
|
||||
<!--<SearchPanel client:load>-->
|
||||
<Search client:only="svelte"></Search>
|
||||
<ThemeSettingsBlock showThemeColor={!siteConfig.themeColor.fixed} client:only="svelte"></ThemeSettingsBlock>
|
||||
{!siteConfig.themeColor.fixed && (
|
||||
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
|
||||
<Icon name="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
|
||||
</button>
|
||||
)}
|
||||
<LightDarkSwitch client:only="svelte"></LightDarkSwitch>
|
||||
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden" id="nav-menu-switch">
|
||||
<Icon is:inline name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
|
||||
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
<NavMenuPanel links={links}></NavMenuPanel>
|
||||
<DisplaySettings client:only="svelte"></DisplaySettings>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTheme() {
|
||||
if (localStorage.theme === 'dark') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.theme = 'light';
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.theme = 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
function loadButtonScript() {
|
||||
let switchBtn = document.getElementById("scheme-switch");
|
||||
if (switchBtn) {
|
||||
switchBtn.onclick = function () {
|
||||
switchTheme()
|
||||
};
|
||||
}
|
||||
|
||||
let settingBtn = document.getElementById("display-settings-switch");
|
||||
if (settingBtn) {
|
||||
settingBtn.onclick = function () {
|
||||
let settingPanel = document.getElementById("display-setting");
|
||||
if (settingPanel) {
|
||||
settingPanel.classList.toggle("float-panel-closed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let menuBtn = document.getElementById("nav-menu-switch");
|
||||
if (menuBtn) {
|
||||
menuBtn.onclick = function () {
|
||||
@@ -71,18 +104,6 @@ function loadButtonScript() {
|
||||
loadButtonScript();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.navbar-blur {
|
||||
background-color: color-mix(in oklab, var(--card-bg) 58%, transparent);
|
||||
backdrop-filter: blur(18px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||
}
|
||||
|
||||
:global(.dark) .navbar-blur {
|
||||
background-color: color-mix(in oklab, var(--card-bg) 44%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
{import.meta.env.PROD && <script is:inline define:vars={{scriptUrl: url('/pagefind/pagefind.js')}}>
|
||||
async function loadPagefind() {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { render } from "astro:content";
|
||||
import path from "node:path";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
@@ -8,6 +7,7 @@ import { i18n } from "../i18n/translation";
|
||||
import { getDir } from "../utils/url-utils";
|
||||
import ImageWrapper from "./misc/ImageWrapper.astro";
|
||||
import PostMetadata from "./PostMeta.astro";
|
||||
import { umamiConfig } from "../config";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
@@ -21,7 +21,7 @@ interface Props {
|
||||
image: string;
|
||||
description: string;
|
||||
draft: boolean;
|
||||
style?: string;
|
||||
style: string;
|
||||
}
|
||||
const {
|
||||
entry,
|
||||
@@ -33,19 +33,17 @@ const {
|
||||
category,
|
||||
image,
|
||||
description,
|
||||
style = "",
|
||||
style,
|
||||
} = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
|
||||
const hasCover = image !== undefined && image !== null && image !== "";
|
||||
|
||||
const coverWidth = "28%";
|
||||
const isPinned = entry.data.pinned;
|
||||
const isOutdated = entry.data.outdated;
|
||||
|
||||
const { remarkPluginFrontmatter } = await render(entry);
|
||||
const { remarkPluginFrontmatter } = await entry.render();
|
||||
---
|
||||
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative border border-black/20 dark:border-white/20 hover:scale-[1.02] hover:shadow-xl transition-all duration-[300ms]", className]} style={style}>
|
||||
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
|
||||
<div class:list={["pl-6 md:pl-9 pr-6 md:pr-2 pt-6 md:pt-7 pb-6 relative", {"w-full md:w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-full md:w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
|
||||
<a href={url}
|
||||
class="transition group w-full block font-bold mb-3 text-3xl text-90
|
||||
@@ -54,21 +52,9 @@ const { remarkPluginFrontmatter } = await render(entry);
|
||||
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
|
||||
">
|
||||
<span class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{isPinned && (
|
||||
<span class="inline-flex items-center gap-1 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-2.5 py-1 text-xs font-semibold text-[var(--primary)] leading-none">
|
||||
<Icon is:inline name="material-symbols:keep-rounded" class="text-lg"></Icon>
|
||||
置顶
|
||||
</span>
|
||||
)}
|
||||
<span class="min-w-0">
|
||||
{title}
|
||||
<span class="inline-flex items-center align-middle whitespace-nowrap">
|
||||
<Icon is:inline class="inline text-[2rem] text-[var(--primary)] translate-y-0.5 md:hidden" name="material-symbols:chevron-right-rounded" ></Icon>
|
||||
<Icon is:inline class="text-[var(--primary)] text-[2rem] transition hidden md:inline translate-y-0.5 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<Icon class="inline text-[2rem] text-[var(--primary)] md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" ></Icon>
|
||||
<Icon class="text-[var(--primary)] text-[2rem] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
|
||||
</a>
|
||||
|
||||
<!-- metadata -->
|
||||
@@ -81,18 +67,13 @@ const { remarkPluginFrontmatter } = await render(entry);
|
||||
|
||||
|
||||
<!-- word count, read time and page views https://github.com/afoim/fuwari/blob/81f22decb17ff7ee1dd480c10773f7ba8f4df296/src/components/PostCard.astro -->
|
||||
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition flex-wrap items-center">
|
||||
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
|
||||
<div>{remarkPluginFrontmatter.words} {" " + i18n(I18nKey.wordsCount)}</div>
|
||||
<div>|</div>
|
||||
<div>{remarkPluginFrontmatter.minutes} {" " + i18n(I18nKey.minutesCount)}</div>
|
||||
<div>|</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-50 text-sm font-medium" id={`page-views-${entry.id}`}>加载中...</span>
|
||||
{isOutdated && (
|
||||
<span class="inline-flex items-center rounded-full border border-red-500/20 bg-red-500/8 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300">
|
||||
已失效
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<span class="text-50 text-sm font-medium" id={`page-views-${entry.slug}`}>加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,12 +86,12 @@ const { remarkPluginFrontmatter } = await render(entry);
|
||||
]} >
|
||||
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
|
||||
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
|
||||
<Icon is:inline name="material-symbols:chevron-right-rounded"
|
||||
<Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
|
||||
</Icon>
|
||||
</div>
|
||||
<ImageWrapper src={image} basePath={path.join("content/posts/", getDir(entry.id))} alt="Cover Image of the Post"
|
||||
class="w-full h-full" loader>
|
||||
class="w-full h-full">
|
||||
</ImageWrapper>
|
||||
</a>}
|
||||
|
||||
@@ -119,7 +100,7 @@ const { remarkPluginFrontmatter } = await render(entry);
|
||||
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
|
||||
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
|
||||
">
|
||||
<Icon is:inline name="material-symbols:chevron-right-rounded"
|
||||
<Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition text-[var(--primary)] text-4xl mx-auto">
|
||||
</Icon>
|
||||
</a>
|
||||
@@ -130,39 +111,56 @@ const { remarkPluginFrontmatter } = await render(entry);
|
||||
|
||||
<!-- https://github.com/afoim/fuwari/blob/81f22decb17ff7ee1dd480c10773f7ba8f4df296/src/components/PostCard.astro -->
|
||||
|
||||
<script define:vars={{ slug: entry.id }}>
|
||||
<script define:vars={{ entry, umamiConfig }}>
|
||||
// 获取文章浏览量统计
|
||||
async function fetchPostCardViews(slug) {
|
||||
const displayElement = document.getElementById(`page-views-${slug}`);
|
||||
if (!displayElement || displayElement.dataset.umamiState === 'loading' || displayElement.dataset.umamiState === 'loaded') {
|
||||
if (!umamiConfig.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
displayElement.dataset.umamiState = 'loading';
|
||||
|
||||
try {
|
||||
const umamiStore = window['__blogUmami'];
|
||||
const statsData = await umamiStore?.getStats(`post:${slug}`, (websiteId) => {
|
||||
// 第一步:获取网站ID和token
|
||||
const shareResponse = await fetch(`${umamiConfig.baseUrl}/api/share/${umamiConfig.shareId}`);
|
||||
if (!shareResponse.ok) {
|
||||
throw new Error('获取分享信息失败');
|
||||
}
|
||||
const shareData = await shareResponse.json();
|
||||
const { websiteId, token } = shareData;
|
||||
|
||||
// 第二步:获取统计数据
|
||||
const currentTimestamp = Date.now();
|
||||
return `https://umami.adclosenn.top/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent('Asia/Shanghai')}&url=%2Fposts%2F${slug}%2F&compare=false`;
|
||||
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
|
||||
|
||||
const statsResponse = await fetch(statsUrl, {
|
||||
headers: {
|
||||
'x-umami-share-token': token
|
||||
}
|
||||
});
|
||||
|
||||
if (!statsData) {
|
||||
throw new Error('统计功能未启用');
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error('获取统计数据失败');
|
||||
}
|
||||
|
||||
const statsData = await statsResponse.json();
|
||||
const pageViews = statsData.pageviews?.value || 0;
|
||||
// const visits = statsData.visits?.value || 0;
|
||||
|
||||
const displayElement = document.getElementById(`page-views-${slug}`);
|
||||
if (displayElement) {
|
||||
displayElement.textContent = `浏览量 ${pageViews}`;
|
||||
displayElement.dataset.umamiState = 'loaded';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching page views for', slug, ':', error);
|
||||
const displayElement = document.getElementById(`page-views-${slug}`);
|
||||
if (displayElement) {
|
||||
displayElement.textContent = '统计不可用';
|
||||
displayElement.dataset.umamiState = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后获取统计数据
|
||||
function initPostCardStats() {
|
||||
const slug = entry.slug;
|
||||
if (slug) {
|
||||
fetchPostCardViews(slug);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Icon } from "astro-icon/components";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import { i18n } from "../i18n/translation";
|
||||
import { formatDateToYYYYMMDD } from "../utils/date-utils";
|
||||
import { getCategoryUrl, getDir, getTagUrl, url } from "../utils/url-utils";
|
||||
import { getCategoryUrl, getTagUrl, getDir, url } from "../utils/url-utils";
|
||||
import { umamiConfig } from "../config";
|
||||
|
||||
interface Props {
|
||||
class: string;
|
||||
@@ -33,7 +34,7 @@ const className = Astro.props.class;
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon is:inline name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
|
||||
<Icon name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(published)}</span>
|
||||
</div>
|
||||
@@ -43,7 +44,7 @@ const className = Astro.props.class;
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon is:inline name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
|
||||
<Icon name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(updated)}</span>
|
||||
</div>
|
||||
@@ -54,7 +55,7 @@ const className = Astro.props.class;
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon is:inline name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
|
||||
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-row flex-nowrap items-center">
|
||||
<a href={getCategoryUrl(category)} aria-label={`View all posts in the ${category} category`}
|
||||
@@ -69,7 +70,7 @@ const className = Astro.props.class;
|
||||
<div class:list={["items-center", {"flex": !hideTagsForMobile, "hidden md:flex": hideTagsForMobile}]}>
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon is:inline name="material-symbols:tag-rounded" class="text-xl"></Icon>
|
||||
<Icon name="material-symbols:tag-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-row flex-nowrap items-center">
|
||||
{(tags && tags.length > 0) && tags.map((tag, i) => (
|
||||
@@ -87,7 +88,7 @@ const className = Astro.props.class;
|
||||
{slug && (
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon">
|
||||
<Icon is:inline name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
|
||||
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<span class="text-50 text-sm font-medium" id="page-views-display">加载中...</span>
|
||||
</div>
|
||||
@@ -97,36 +98,50 @@ const className = Astro.props.class;
|
||||
<!-- https://github.com/afoim/fuwari/blob/81f22decb17ff7ee1dd480c10773f7ba8f4df296/src/components/PostMeta.astro -->
|
||||
|
||||
{slug && (
|
||||
<script define:vars={{ slug }}>
|
||||
<script define:vars={{ slug, umamiConfig }}>
|
||||
// 获取访问量统计
|
||||
async function fetchPageViews() {
|
||||
const displayElement = document.getElementById('page-views-display');
|
||||
if (!displayElement || displayElement.dataset.umamiState === 'loading' || displayElement.dataset.umamiState === 'loaded') {
|
||||
if (!umamiConfig.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
displayElement.dataset.umamiState = 'loading';
|
||||
|
||||
try {
|
||||
const umamiStore = window['__blogUmami'];
|
||||
const statsData = await umamiStore?.getStats(`post:${slug}`, (websiteId) => {
|
||||
// 第一步:获取网站ID和token
|
||||
const shareResponse = await fetch(`${umamiConfig.baseUrl}/api/share/${umamiConfig.shareId}`);
|
||||
if (!shareResponse.ok) {
|
||||
throw new Error('获取分享信息失败');
|
||||
}
|
||||
const shareData = await shareResponse.json();
|
||||
const { websiteId, token } = shareData;
|
||||
|
||||
// 第二步:获取统计数据
|
||||
const currentTimestamp = Date.now();
|
||||
return `https://umami.adclosenn.top/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent('Asia/Shanghai')}&url=%2Fposts%2F${slug}%2F&compare=false`;
|
||||
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
|
||||
|
||||
const statsResponse = await fetch(statsUrl, {
|
||||
headers: {
|
||||
'x-umami-share-token': token
|
||||
}
|
||||
});
|
||||
|
||||
if (!statsData) {
|
||||
throw new Error('统计功能未启用');
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error('获取统计数据失败');
|
||||
}
|
||||
|
||||
const statsData = await statsResponse.json();
|
||||
const pageViews = statsData.pageviews?.value || 0;
|
||||
const visits = statsData.visits?.value || 0;
|
||||
|
||||
const displayElement = document.getElementById('page-views-display');
|
||||
if (displayElement) {
|
||||
displayElement.textContent = `浏览量 ${pageViews} · 访问数 ${visits}`;
|
||||
displayElement.dataset.umamiState = 'loaded';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching page views:', error);
|
||||
const displayElement = document.getElementById('page-views-display');
|
||||
if (displayElement) {
|
||||
displayElement.textContent = '统计不可用';
|
||||
displayElement.dataset.umamiState = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const { page } = Astro.props;
|
||||
let delay = 0;
|
||||
const interval = 50;
|
||||
---
|
||||
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-[var(--card-bg)] py-1 md:py-0 md:bg-transparent md:gap-4 mb-8 overflow-visible">
|
||||
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-[var(--card-bg)] py-1 md:py-0 md:bg-transparent md:gap-4 mb-4">
|
||||
{page.data.map((entry: CollectionEntry<"posts">) => (
|
||||
<PostCard
|
||||
entry={entry}
|
||||
@@ -17,10 +17,12 @@ const interval = 50;
|
||||
category={entry.data.category}
|
||||
published={entry.data.published}
|
||||
updated={entry.data.updated}
|
||||
url={getPostUrlBySlug(entry.id)}
|
||||
url={getPostUrlBySlug(entry.slug)}
|
||||
image={entry.data.image}
|
||||
description={entry.data.description}
|
||||
draft={entry.data.draft}
|
||||
class:list="onload-animation"
|
||||
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
|
||||
></PostCard>
|
||||
))}
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ $: if (initialized && keywordMobile) {
|
||||
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
|
||||
<input placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
|
||||
class="transition-all pl-10 text-sm bg-transparent outline-0
|
||||
h-full w-40 active:w-40 focus:w-40 text-black/50 dark:text-white/50"
|
||||
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
|
||||
>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Icon } from "astro-icon/components";
|
||||
<div class="back-to-top-wrapper hidden lg:block">
|
||||
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition" onclick="backToTop()">
|
||||
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
|
||||
<Icon is:inline name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
|
||||
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,12 +57,12 @@ const getPageUrl = (p: number) => {
|
||||
{"disabled": page.url.prev == undefined}
|
||||
]}
|
||||
>
|
||||
<Icon is:inline name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
|
||||
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
|
||||
</a>
|
||||
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold">
|
||||
{pages.map((p) => {
|
||||
if (p == HIDDEN)
|
||||
return <Icon is:inline name="material-symbols:more-horiz" class="mx-1"/>;
|
||||
return <Icon name="material-symbols:more-horiz" class="mx-1"/>;
|
||||
if (p == page.currentPage)
|
||||
return <div class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
|
||||
font-bold text-white dark:text-black/70"
|
||||
@@ -79,6 +79,6 @@ const getPageUrl = (p: number) => {
|
||||
{"disabled": page.url.next == undefined}
|
||||
]}
|
||||
>
|
||||
<Icon is:inline name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
|
||||
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
|
||||
</a>
|
||||
</div>
|
||||
@@ -8,22 +8,12 @@ interface Props {
|
||||
alt?: string;
|
||||
position?: string;
|
||||
basePath?: string;
|
||||
loader?: boolean;
|
||||
blur?: boolean;
|
||||
}
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import { url } from "../../utils/url-utils";
|
||||
|
||||
const {
|
||||
id,
|
||||
src,
|
||||
alt,
|
||||
position = "center",
|
||||
basePath = "/",
|
||||
loader = false,
|
||||
blur = false,
|
||||
} = Astro.props;
|
||||
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
|
||||
const isLocal = !(
|
||||
@@ -56,19 +46,9 @@ if (isLocal) {
|
||||
|
||||
const imageClass = "w-full h-full object-cover";
|
||||
const imageStyle = `object-position: ${position}`;
|
||||
const normalizedPublicSrc =
|
||||
src === "/public"
|
||||
? "/"
|
||||
: src.startsWith("/public/")
|
||||
? src.slice("/public".length)
|
||||
: src;
|
||||
const originalSrc =
|
||||
isLocal && img ? img.src : isPublic ? url(normalizedPublicSrc) : src;
|
||||
const originalWidth = isLocal && img ? img.width : undefined;
|
||||
const originalHeight = isLocal && img ? img.height : undefined;
|
||||
---
|
||||
<div id={id} class:list={[className, "overflow-hidden relative", loader && "image-loading-shell is-loading", blur && "image-blur-shell is-loading"]}>
|
||||
<div id={id} class:list={[className, 'overflow-hidden relative']}>
|
||||
<div class="transition absolute inset-0 dark:bg-black/10 bg-opacity-50 pointer-events-none"></div>
|
||||
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle} data-pswp-src={originalSrc} data-pswp-width={originalWidth} data-pswp-height={originalHeight} data-original-src={originalSrc}/>}
|
||||
{!isLocal && <img src={originalSrc} alt={alt || ""} class={imageClass} style={imageStyle} data-pswp-src={originalSrc} data-original-src={originalSrc}/>}
|
||||
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle}/>}
|
||||
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle}/>}
|
||||
</div>
|
||||
|
||||
@@ -39,5 +39,5 @@ const postUrl = decodeURIComponent(Astro.url.toString());
|
||||
<a href={licenseConf.url} target="_blank" class="link text-[var(--primary)] line-clamp-2">{licenseConf.name}</a>
|
||||
</div>
|
||||
</div>
|
||||
<Icon is:inline name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
|
||||
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
import "@fontsource-variable/jetbrains-mono/wght-italic.css";
|
||||
|
||||
interface Props {
|
||||
class: string;
|
||||
}
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
<div data-pagefind-body class={`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`} style="overflow-wrap: break-word; word-wrap: break-word; word-break: break-word;">
|
||||
<div data-pagefind-body class={`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`}>
|
||||
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
|
||||
<!--<div class="max-w-none custom-md">-->
|
||||
<slot/>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getDefaultHue, getHue, setHue } from "@utils/setting-utils";
|
||||
|
||||
let hue = getHue();
|
||||
const defaultHue = getDefaultHue();
|
||||
|
||||
function resetHue() {
|
||||
hue = getDefaultHue();
|
||||
}
|
||||
|
||||
$: if (hue || hue === 0) {
|
||||
setHue(hue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
|
||||
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
|
||||
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||
>
|
||||
{i18n(I18nKey.themeColor)}
|
||||
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
|
||||
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
|
||||
<div class="text-[var(--btn-content)]">
|
||||
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<div id="hueValue" class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
|
||||
font-bold text-sm items-center text-[var(--btn-content)]">
|
||||
{hue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none">
|
||||
<input aria-label={i18n(I18nKey.themeColor)} type="range" min="0" max="360" bind:value={hue}
|
||||
class="slider" id="colorSlider" step="5" style="width: 100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="stylus">
|
||||
#display-setting
|
||||
input[type="range"]
|
||||
-webkit-appearance none
|
||||
height 1.5rem
|
||||
background-image var(--color-selection-bar)
|
||||
transition background-image 0.15s ease-in-out
|
||||
|
||||
/* Input Thumb */
|
||||
&::-webkit-slider-thumb
|
||||
-webkit-appearance none
|
||||
height 1rem
|
||||
width 0.5rem
|
||||
border-radius 0.125rem
|
||||
background rgba(255, 255, 255, 0.7)
|
||||
box-shadow none
|
||||
&:hover
|
||||
background rgba(255, 255, 255, 0.8)
|
||||
&:active
|
||||
background rgba(255, 255, 255, 0.6)
|
||||
|
||||
&::-moz-range-thumb
|
||||
-webkit-appearance none
|
||||
height 1rem
|
||||
width 0.5rem
|
||||
border-radius 0.125rem
|
||||
border-width 0
|
||||
background rgba(255, 255, 255, 0.7)
|
||||
box-shadow none
|
||||
&:hover
|
||||
background rgba(255, 255, 255, 0.8)
|
||||
&:active
|
||||
background rgba(255, 255, 255, 0.6)
|
||||
|
||||
&::-ms-thumb
|
||||
-webkit-appearance none
|
||||
height 1rem
|
||||
width 0.5rem
|
||||
border-radius 0.125rem
|
||||
background rgba(255, 255, 255, 0.7)
|
||||
box-shadow none
|
||||
&:hover
|
||||
background rgba(255, 255, 255, 0.8)
|
||||
&:active
|
||||
background rgba(255, 255, 255, 0.6)
|
||||
|
||||
</style>
|
||||