105 Commits

Author SHA1 Message Date
Ad-closeNN e9d50e2735 feat: add FastAPI feedback backend 2026-05-23 21:43:40 +08:00
Ad-closeNN b2313d1796 chore: ignore feedback-api/* except tracked files 2026-05-23 21:43:03 +08:00
Ad-closeNN ef41390d2e chore: add .gitattributes with text=auto 2026-05-23 21:22:51 +08:00
Ad-closeNN 256d2ca844 chore: format & feedback beautification 2026-05-23 21:18:44 +08:00
Ad-closeNN f283dc2d40 fix(summary): 修复 AI 摘要脚本删除 ::: 标记和换行符不一致
- bodyContent 不再写回文件,避免 ::: admonition 被 strip
- 写文件时检测原文换行符(CRLF/LF),避免混用
- 换用 deepseek-v4-flash-free 免费模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-05-16 23:32:58 +08:00
Ad-closeNN 16195ad1f1 feat(viewSource): 新增 CNB 源码链接
- 新增 cnb.cool 镜像源码链接(Gitea 格式 /-/blob/main/)
- CNB 置顶排在 Gitea 之后第二位

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-05-16 22:13:10 +08:00
Ad-closeNN 3671044837 feat(viewSource): AI 链接改用 raw URL,新增 Gitea 源码链接
- AI prompt 改用 raw.githubusercontent.com 纯文本链接
- 新增 Gitea 网页版源码链接,置顶显示

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-05-16 21:12:26 +08:00
Ad-closeNN a4314ee590 revert(viewSource): 还原“在 GitHub 查看” 2026-05-02 14:11:36 +08:00
Ad-closeNN 005d8923e4 feat(theme): 图片加载动画、Blur 2026-05-02 13:34:18 +08:00
Ad-closeNN 8ef97d62ff fix(post): 修复一处 AI 总结的错: giscus.md 2026-05-02 01:17:01 +08:00
Ad-closeNN 55f4eb82b7 fix(viewSource): 修复一些 2026-05-01 23:01:32 +08:00
Ad-closeNN 2ba38a30b4 feat(theme): 在 LLM 中打开 Markdown 2026-05-01 21:38:46 +08:00
Ad-closeNN 43f57c7e14 feat(theme): 主题相关合并一起、查看源代码按钮 2026-05-01 20:27:31 +08:00
Ad-closeNN 8615d000b2 chore(friends): 更改友链 2026-05-01 01:08:10 +08:00
Ad-closeNN 2608a54bca feat(aiSummary): AI 文章总结 2026-05-01 00:40:53 +08:00
Ad-closeNN 8c45610503 feat(warning): DNS 污染提示条 2026-04-26 13:35:24 +08:00
Ad-closeNN 1db6584677 chore: 灯箱优化 2026-04-25 20:14:07 +08:00
Ad-closeNN 54b9e8a44f perf(pic): 使用 cnb.cool 作为图床 2026-04-25 19:48:47 +08:00
Ad-closeNN c57b68a9e5 feat: 文章详细页加入顶部按钮翻页 2026-04-25 19:32:27 +08:00
Ad-closeNN 19484758a9 chore: 置顶 Tag 加大到 lg 2026-04-25 18:11:11 +08:00
Ad-closeNN ddbd9973c4 style: PostCard 置顶 2026-04-25 17:59:47 +08:00
Ad-closeNN 6f2b96366c style: 已失效提示 2026-04-25 17:25:45 +08:00
Ad-closeNN 55b177fc2f style(navbar): 加入新 blur 效果 2026-04-25 17:14:48 +08:00
Ad-closeNN 8ea84c10e6 feat: 加入失效警告 2026-04-25 15:13:54 +08:00
Ad-closeNN bb69377716 feat: 加入过时警告 2026-04-25 15:02:39 +08:00
Ad-closeNN fae8cc1698 style(slug): 为帖子详情页顶上的数据加入不可点击 2026-04-25 14:23:33 +08:00
Ad-closeNN 6ad88b733d chore(ai): 新增 CLAUDE.md 2026-04-25 14:22:26 +08:00
Ad-closeNN f12db2071e del(posts): 删除 CF Worker 代理部署文章 2026-04-24 23:08:07 +08:00
Ad-closeNN 6a00e98227 chore: 测试提交 2026-04-20 13:10:27 +08:00
Ad-closeNN 39c02c89b9 chore; 测试双平台同步提交 2026-04-20 13:06:16 +08:00
Ad-closeNN f3615ba713 chore: 测试 cnb 平台同步提交 2026-04-20 13:03:21 +08:00
Ad-closeNN 4f51ec2aa4 chore(obsidian): 更改 obsidian-paste-image-rename 配置 2026-04-20 12:55:52 +08:00
Ad-closeNN 2ad71e0cd2 chore(obsidian): 遵循原作者插件命名 2026-04-20 12:51:43 +08:00
Ad-closeNN 148827eafd chore: 公开黑曜石 obsidian 配置文件夹 2026-04-20 12:42:03 +08:00
Ad-closeNN 899a383c53 chore(.gitignore): 构建期间使用 verbose 模式 2026-04-20 12:00:42 +08:00
Ad-closeNN 2fba8e2ac3 feat(migrate): 迁移所有的图片至 /public/pic 下 2026-04-20 10:48:57 +08:00
Ad_closeNN 0bec6d4ee4 Merge pull request #3 from Ad-closeNN/feat/update-deps
feat: update deps
2026-04-20 10:19:06 +08:00
Ad-closeNN 1eba58bb92 feat: 编译图片时可以把开头 /public 字段删除
2. feat(component): 查看照片新增打开原图更改
3. fix(component): 修复查看图片时放大图片按钮失效的问题
2026-04-20 10:08:44 +08:00
Ad-closeNN 8f004a9959 feat(component): 加入总字数统计模块 2026-04-20 01:16:10 +08:00
Ad-closeNN 9d7af32e25 chore(ui): PostCard 过渡动画 2026-04-19 23:27:22 +08:00
Ad-closeNN 65dc754cae feat(ui): PostCard 详情页新增浏览量显示 2026-04-19 22:58:21 +08:00
Ad-closeNN 27a6f94f64 feat(upgrade): 迁移到 Astro V6 2026-04-19 22:12:00 +08:00
Ad-closeNN a96c270bfe docs(posts): 最新Codex注册机+Warp教程! 2026-04-06 14:44:05 +08:00
Ad-closeNN 5621292cfb friends(style&update): 友链界面新增本站信息&修改Clina's Blog站点图标 2026-03-29 17:38:50 +08:00
Ad-closeNN 175df8b814 friends(new): 雪诺的小博客
2. feat(analytics): 加入 Google Analytics
3. fix(style): 修复 Umami 请求重复多次请求的问题
2026-03-22 14:03:18 +08:00
Ad-closeNN c4940140b5 friends(new): AkiNard Blog 2026-03-14 17:47:56 +08:00
Ad-closeNN 24096bba21 Merge branch 'main' of https://github.com/Ad-closeNN/blog-fuwari 2026-03-07 00:30:06 +08:00
Ad-closeNN ceab8f24a5 friends(update): 更新“MeowCata 小站”的信息 2026-03-07 00:29:56 +08:00
Ad-closeNN 9599c73b85 style(theme): 主题色从 140 改为 160 2026-03-07 00:29:44 +08:00
Ad-closeNN ae087396f6 friends(update): 更新“MeowCata 小站”的信息 2026-03-07 00:15:38 +08:00
Ad-closeNN 078fda5f95 style(theme): 主题色从 140 改为 150 2026-03-07 00:11:17 +08:00
Ad-closeNN 037e52bbcb chore(docs): 标点小变化 2026-03-03 22:32:35 +08:00
Ad-closeNN 5f1003ff09 style: PostCard 动画延迟改为 300ms 2026-03-03 20:32:37 +08:00
Ad-closeNN 82fd2ae20b fix(style): 恢复 /info/ 页 <pre> 的换行问题 2026-03-03 20:29:52 +08:00
Ad-closeNN 9a90bd6143 style(friends): 更新友链
2. 移除“中域科技” blog.zhongyudata.com
3. 新增友链 Card 卡片边框
2026-03-03 20:08:51 +08:00
Ad-closeNN 776b750fa2 chore(friends): 更新友链 2026-03-03 19:30:22 +08:00
Ad-closeNN 0cdf41e5b7 style: 新增卡片缩放效果 2026-03-03 19:16:13 +08:00
Ad-closeNN 71226be673 fix(style): 修复选中背景颜色过亮的问题 2026-03-03 18:18:40 +08:00
Ad-closeNN 1374061945 style: 鼠标选中文字不再下沉 2026-03-01 16:56:21 +08:00
Ad-closeNN 96d323cda5 style: Markdown 链接按钮效果更改 2026-03-01 16:54:13 +08:00
Ad-closeNN 769a0a99fa style: NavBar增加模糊效果 2026-03-01 16:49:45 +08:00
Ad-closeNN 7b11b5a215 fix: 修复卡片载入动画重复播放的问题
CodeGeeX GLM 4.7 牛逼
2026-03-01 16:18:16 +08:00
Ad-closeNN f7ed7e2e7f style: 在 Cloudflare 应用自定义 404 页面 2026-02-28 18:41:14 +08:00
Ad-closeNN 2eb5868936 config: 迁移到 Cloudflare 2026-02-28 18:20:23 +08:00
Ad-closeNN 22409c9222 style: 迁移 Cloudflare 2026-02-28 17:38:53 +08:00
Ad-closeNN 5a3a0ca1a2 friends&style: MeowCata 小站 2026-02-28 14:59:04 +08:00
Ad-closeNN 61fd878285 friends&typo: Horean's Blog & RATING3PRO Today
https://blog.hxrch.top
https://www.xie.today
2026-02-13 11:13:13 +08:00
Ad-closeNN ab4d40223c friends: 新增 Horean's Blog
https://blog.hxrch.top
2026-02-13 00:46:57 +08:00
Ad-closeNN 361b4d4e37 friends: 新增1个友链
https://xhc861.top
2026-02-11 23:15:28 +08:00
Ad-closeNN e0b33a32b9 friends: 新增8个友链
https://wyf9.top
https://tbmiao.dpdns.org
https://blog.lenmei233.top
https://cs.gt.tc
https://blog.yaooa.cn
https://blog.zhongyudata.com
https://blog.150191.xyz
2026-02-10 20:47:35 +08:00
Ad-closeNN 0b7c974d8b docs(pages): 教你免费获取酷狗音乐歌曲
2. docs(pages): 修复 umami.md 中的一些表达问题
3. docs(pages): 修复 cloudflare-workers-proxy.md 的一些表达问题
4. style(404): 修改 404 页面字样
5. style(..slug): 为文章卡片顶端加入详细信息
2025-10-18 20:14:38 +08:00
Ad-closeNN ae4fc5e9c3 doc(pages): 使用 CF Workers 搭建 Vless/Trojan 节点并优化
2. chore(about): 修改关于页的描述
2025-10-12 13:40:39 +08:00
Ad-closeNN 3945ee08d4 fix(script): 修复从其他页面切入时,关于页无法正确获取主机名的问题 2025-09-19 23:08:19 +08:00
Ad-closeNN e97de007a8 style(font): 移除默认代码字体,改为 Cascadia Mono
2. del: 删除 setting-utils.ts 中的 AUTO_MODE
2025-09-19 22:54:15 +08:00
Ad-closeNN eb4fcea50e fix(pages): 修复关于页中网址渲染导致的粗字判断失败的问题 2025-09-13 14:45:43 +08:00
Ad-closeNN e28a589945 chore(pages): 删除 GitHub 警报样式中多余的 " 符号 2025-09-13 14:39:24 +08:00
Ad-closeNN ff0b6e28c2 chore(pages): 修改分流站点的优选描述为 www.visa.com.sg 2025-09-13 14:37:16 +08:00
Ad-closeNN 33225891fa style(friends): 为友链卡片加入动画效果
2. chore(config): 更改 API 调用链接,从 Netlify Function 改为 GitHub API
3. GitHub Card 中头像 URL avatars.githubusercontent.com 改为 EdgeOne CDN 链接 avatars.gh.api.adclosenn.dev
2025-09-13 00:52:21 +08:00
Ad-closeNN 7e48caa994 feat(config): 在 Wrangler 加入构建命令 2025-09-07 12:04:49 +08:00
Ad-closeNN 2792f2824e feat(config): 加入对 Cloudflare Workers 的适配 2025-09-07 11:55:52 +08:00
Ad-closeNN a6ad148b72 docs(pages): "[置顶] 新域名!" 与 "获得了一个新的免费 .dev 域名" 合并
2. feat(script): 加入站点类型检测
3. del(music): 删除网易云音乐外链挂件
2025-09-07 11:40:20 +08:00
Ad-closeNN a1c10eea3c restore(config): 恢复预加载配置 2025-09-06 22:22:37 +08:00
Ad-closeNN 4a5e6b5cc5 docs(pages): 在 CF Workers 上运行 Discord 机器人 2025-09-06 21:39:07 +08:00
Ad-closeNN 18735a39a9 Merge branch 'test/prefetch' 2025-09-06 18:37:16 +08:00
Ad-closeNN db002fe84b style(pages): 更新 404 页面 HTML 2025-09-06 17:53:01 +08:00
Ad-closeNN b9ae6a7ea7 test(config): 测试 404 页面 2025-09-06 17:21:51 +08:00
Ad-closeNN b7abec5dd1 feat(config): 强制预获取所有链接
https://docs.astro.build/zh-cn/guides/prefetch/#%E9%BB%98%E8%AE%A4%E9%A2%84%E8%8E%B7%E5%8F%96%E6%89%80%E6%9C%89%E9%93%BE%E6%8E%A5
2025-09-06 16:59:23 +08:00
Ad-closeNN 3034f1ac34 docs(pages): 为 Astro 加上在新标签页打开链接的功能
2. del(music): 删除音乐:在银河中孤独摇摆
3. fix(config): 简单修复即使 background 为关闭时依然加载背景图片的问题
4. chore(friends): 加入新的友链
5. style(friends): 优化友情链接页面的样式
6. style(footer): 修改 footer 页脚文本
7. fix(css): 修复复制按钮无法显示的问题
2025-08-31 14:34:21 +08:00
Ad-closeNN 29102fc51b docs(pages): 获得了一个新的免费 .dev 域名
2. del(theme): 删除周日知更鸟主题的代码部分,资源文件未删除
3. chore(profile): 左侧个人资料 Profile 更换音乐:茉子の日常
4. chore(config): 删除 navbar 的“仓库信息”,重新加入 Home 按钮
5. docs(pages): 更新相关文档页面内容
6. fix(css): 修复复制按钮未正确显示的问题
7. chore(footer): 新增茶备案号 2025080144
2025-08-31 00:52:12 +08:00
Ad-closeNN 08aafdd0fa revert 2025-08-24 16:31:47 +08:00
GHL-beta 58789b7532 feat(bing): 测试 bing 的 IndexNow 2025-08-24 16:28:36 +08:00
Ad_closeNN 021a956e3a chore(friends): 更新友链 mckero 的网站图标 2025-08-18 14:50:25 +08:00
Ad_closeNN c9d924d74d docs(pages):将 Giscus 评论插件添加到博客:补充完未完成的文章内容
🧠:妈妈生的
2025-08-17 18:38:23 +08:00
Ad-closeNN 4690ce39b2 docs(pages): 将 Giscus 评论插件添加到博客
2. fix(config): 修复特殊日期判断失败的问题
3. feat(giscus): 加入 Giscus 评论系统
2025-08-17 12:19:05 +08:00
Ad-closeNN 359d428204 Merge branch 'main' of https://github.com/Ad-closeNN/blog-fuwari 2025-08-16 13:31:46 +08:00
Ad-closeNN 26365e08b4 docs(pages): 增加仓库信息页 /info/
2. feat(config): banner 使用 WebP 格式图片
3. style(config): search 搜索条长度固定
4. style(profile): 将个人档案 profile 中的提交记录改为 /info/
5. chore(profile): 将 profile 电子邮箱地址改为 admin@adclosenn.top
6. style(config): 删除了顶部 Nav 的 主页
7. docs(pages): about 关于页面更新 2025/8/16
8. style(css): 加入专对于代码框的字体 JetBrainsMono-VF
9. fix(config): 解决misans.astro 文件中的解释 description 被定义为了 I18nKey.about 关于 的问题
10. fix(config): 修复网易云音乐音乐外链挂件未使用 https 的问题
2025-08-16 13:30:13 +08:00
Ad-closeNN 4c446c7f6a docs(pages): 增加仓库信息页 /info/
2. feat(config): banner 使用 WebP 格式图片
3. style(config): search 搜索条长度固定
4. style(profile): 将个人档案 profile 中的提交记录改为 /info/
5. chore(profile): 将 profile 电子邮箱地址改为 admin@adclosenn.top
6. style(config): 删除了顶部 Nav 的 主页
7. docs(pages): about 关于页面更新 2025/8/16
8. style(css): 加入专对于代码框的字体 JetBrainsMono-VF
9. fix(config): 解决misans.astro 文件中的解释 description 被定义为了 I18nKey.about 关于 的问题
2025-08-16 12:33:54 +08:00
Ad-closeNN 704345b8fa chore(config): 将 @import misans 提至最顶 2025-08-16 09:49:23 +08:00
Ad-closeNN 598fcd7cff chore(config): 使用了 import 的方式导入 misans 2025-08-15 23:38:35 +08:00
Ad-closeNN f9ccb0ca71 feat(config): 加入 cache: no-cache
2. feat(config): color-theme 支持在星期日自动更改为知更鸟颜色 #9e8eef
3. style(background): 关闭 background 功能
2025-08-15 23:18:32 +08:00
Ad-closeNN 0f1954d020 style(config): 在 Profile 页加入了提交信息
2. typo(pages): new-domain.md 多拼写了双引号
2025-08-15 22:02:17 +08:00
Ad-closeNN dc7d2cbfc0 restore(css): 恢复原本背景样式 2025-08-14 18:26:28 +08:00
Ad-closeNN 1492795896 style(config): 加入 Mica 云母效果
1. style(config): 默认亮暗色改为 LIGHT_MODE
2. 更新知更鸟 Credit: Pixiv @KiraraShss
3. style(config): 加入 theme-color 顶部横幅
2025-08-14 14:26:29 +08:00
Ad-closeNN 895eb0dae7 style(config): 添加 Badge Built with Netlify 2025-08-14 12:29:17 +08:00
Ad-closeNN 26ccc0fb60 docs(pages): 恢复 Chrome 对 Manifest V2 的支持
1. docs(pages): 恢复 Chrome 对 Manifest V2 的支持
2. style(pages): 文本美化
3. feat(config):  target="_blank" 插件:rehype-external-links
4. style(music): 增加一个音乐:准备出发
2025-08-14 11:49:26 +08:00
201 changed files with 11014 additions and 4613 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto
+17 -4
View File
@@ -26,7 +26,20 @@ package-lock.json
bun.lockb bun.lockb
yarn.lock yarn.lock
# My test files # src/content/.obsidian
duolingo.py
duolingo copy.py .playwright-mcp
test.py .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
+8
View File
@@ -0,0 +1,8 @@
{
"newLinkFormat": "absolute",
"newFileLocation": "folder",
"newFileFolderPath": "src/content/posts",
"alwaysUpdateLinks": true,
"attachmentFolderPath": "/public/pic",
"useMarkdownLinks": true
}
+1
View File
@@ -0,0 +1 @@
{}
+5
View File
@@ -0,0 +1,5 @@
[
"fix-public-links",
"obsidian-auto-link-title",
"obsidian-paste-image-rename"
]
+33
View File
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
+65
View File
@@ -0,0 +1,65 @@
const { Plugin } = require('obsidian');
module.exports = class FixPublicLinksPlugin extends Plugin {
async onload() {
console.log('Loading Fix Public Links plugin');
// 监听文件创建事件(粘贴图片时触发)
this.registerEvent(
this.app.vault.on('create', (file) => {
// 延迟执行,确保 Obsidian 已经插入了链接
setTimeout(() => {
this.fixPublicLinksInActiveFile();
}, 100);
})
);
// 添加命令:手动修复当前文件的所有链接
this.addCommand({
id: 'fix-public-links',
name: 'Fix public/ links in current file',
editorCallback: (editor) => {
this.fixPublicLinksInEditor(editor);
}
});
}
fixPublicLinksInActiveFile() {
const activeView = this.app.workspace.getActiveViewOfType(require('obsidian').MarkdownView);
if (!activeView) return;
const editor = activeView.editor;
this.fixPublicLinksInEditor(editor);
}
fixPublicLinksInEditor(editor) {
const cursor = editor.getCursor();
const lineCount = editor.lineCount();
let fixed = false;
// 遍历所有行
for (let i = 0; i < lineCount; i++) {
const line = editor.getLine(i);
// 匹配 Markdown 图片语法:![...](public/...)
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');
}
};
+10
View File
@@ -0,0 +1,10 @@
{
"id": "fix-public-links",
"name": "Fix Public Links",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Automatically fix image links that start with public/ to /public/",
"author": "AcoFork",
"authorUrl": "https://blog.acofork.com",
"isDesktopOnly": false
}
+15
View File
@@ -0,0 +1,15 @@
{
"regex": {},
"lineRegex": {},
"linkRegex": {},
"linkLineRegex": {},
"imageRegex": {},
"enhanceDefaultPaste": true,
"shouldPreserveSelectionAsTitle": false,
"enhanceDropEvents": true,
"websiteBlacklist": "",
"maximumTitleLength": 0,
"useNewScraper": false,
"linkPreviewApiKey": "",
"useBetterPasteId": true
}
+771
View File
@@ -0,0 +1,771 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ROLLUP
if you want to view the source visit the plugins github repository
*/
'use strict';
var obsidian = require('obsidian');
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
const DEFAULT_SETTINGS = {
regex: /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/i,
lineRegex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi,
linkRegex: /^\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)$/i,
linkLineRegex: /\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)/gi,
imageRegex: /\.(gif|jpe?g|tiff?|png|webp|bmp|tga|psd|ai)$/i,
enhanceDefaultPaste: true,
shouldPreserveSelectionAsTitle: false,
enhanceDropEvents: true,
websiteBlacklist: "",
maximumTitleLength: 0,
useNewScraper: false,
linkPreviewApiKey: "",
useBetterPasteId: false,
};
class AutoLinkTitleSettingTab extends obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
let { containerEl } = this;
containerEl.empty();
new obsidian.Setting(containerEl)
.setName("Enhance Default Paste")
.setDesc("Fetch the link title when pasting a link in the editor with the default paste command")
.addToggle((val) => val
.setValue(this.plugin.settings.enhanceDefaultPaste)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.enhanceDefaultPaste = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Enhance Drop Events")
.setDesc("Fetch the link title when drag and dropping a link from another program")
.addToggle((val) => val
.setValue(this.plugin.settings.enhanceDropEvents)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.enhanceDropEvents = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Maximum title length")
.setDesc("Set the maximum length of the title. Set to 0 to disable.")
.addText((val) => val
.setValue(this.plugin.settings.maximumTitleLength.toString(10))
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
const titleLength = Number(value);
this.plugin.settings.maximumTitleLength =
isNaN(titleLength) || titleLength < 0 ? 0 : titleLength;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Preserve selection as title")
.setDesc("Whether to prefer selected text as title over fetched title when pasting")
.addToggle((val) => val
.setValue(this.plugin.settings.shouldPreserveSelectionAsTitle)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.shouldPreserveSelectionAsTitle = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Website Blacklist")
.setDesc("List of strings (comma separated) that disable autocompleting website titles. Can be URLs or arbitrary text.")
.addTextArea((val) => val
.setValue(this.plugin.settings.websiteBlacklist)
.setPlaceholder("localhost, tiktok.com")
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
this.plugin.settings.websiteBlacklist = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Use New Scraper")
.setDesc("Use experimental new scraper, seems to work well on desktop but not mobile.")
.addToggle((val) => val
.setValue(this.plugin.settings.useNewScraper)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.useNewScraper = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Use Better Fetching Placeholder")
.setDesc("Use a more readable placeholder when fetching the title of a link.")
.addToggle((val) => val
.setValue(this.plugin.settings.useBetterPasteId)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.useBetterPasteId = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("LinkPreview API Key")
.setDesc("API key for the LinkPreview.net service. Get one at https://my.linkpreview.net/access_keys")
.addText((text) => text
.setValue(this.plugin.settings.linkPreviewApiKey || "")
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
const trimmedValue = value.trim();
if (trimmedValue.length > 0 && trimmedValue.length !== 32) {
new obsidian.Notice("LinkPreview API key must be 32 characters long");
this.plugin.settings.linkPreviewApiKey = "";
}
else {
this.plugin.settings.linkPreviewApiKey = trimmedValue;
}
yield this.plugin.saveSettings();
})));
}
}
class CheckIf {
static isMarkdownLinkAlready(editor) {
let cursor = editor.getCursor();
// Check if the characters before the url are ]( to indicate a markdown link
var titleEnd = editor.getRange({ ch: cursor.ch - 2, line: cursor.line }, { ch: cursor.ch, line: cursor.line });
return titleEnd == "](";
}
static isAfterQuote(editor) {
let cursor = editor.getCursor();
// Check if the characters before the url are " or ' to indicate we want the url directly
// This is common in elements like <a href="linkhere"></a>
var beforeChar = editor.getRange({ ch: cursor.ch - 1, line: cursor.line }, { ch: cursor.ch, line: cursor.line });
return beforeChar == "\"" || beforeChar == "'";
}
static isUrl(text) {
let urlRegex = new RegExp(DEFAULT_SETTINGS.regex);
return urlRegex.test(text);
}
static isImage(text) {
let imageRegex = new RegExp(DEFAULT_SETTINGS.imageRegex);
return imageRegex.test(text);
}
static isLinkedUrl(text) {
let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex);
return urlRegex.test(text);
}
}
class EditorExtensions {
static getSelectedText(editor) {
if (!editor.somethingSelected()) {
let wordBoundaries = this.getWordBoundaries(editor);
editor.setSelection(wordBoundaries.start, wordBoundaries.end);
}
return editor.getSelection();
}
static cursorWithinBoundaries(cursor, match) {
let startIndex = match.index;
let endIndex = match.index + match[0].length;
return startIndex <= cursor.ch && cursor.ch <= endIndex;
}
static getWordBoundaries(editor) {
let cursor = editor.getCursor();
// If its a normal URL token this is not a markdown link
// In this case we can simply overwrite the link boundaries as-is
let lineText = editor.getLine(cursor.line);
// First check if we're in a link
let linksInLine = lineText.matchAll(DEFAULT_SETTINGS.linkLineRegex);
for (let match of linksInLine) {
if (this.cursorWithinBoundaries(cursor, match)) {
return {
start: { line: cursor.line, ch: match.index },
end: { line: cursor.line, ch: match.index + match[0].length },
};
}
}
// If not, check if we're in just a standard ol' URL.
let urlsInLine = lineText.matchAll(DEFAULT_SETTINGS.lineRegex);
for (let match of urlsInLine) {
if (this.cursorWithinBoundaries(cursor, match)) {
return {
start: { line: cursor.line, ch: match.index },
end: { line: cursor.line, ch: match.index + match[0].length },
};
}
}
return {
start: cursor,
end: cursor,
};
}
static getEditorPositionFromIndex(content, index) {
let substr = content.substr(0, index);
let l = 0;
let offset = -1;
let r = -1;
for (; (r = substr.indexOf("\n", r + 1)) !== -1; l++, offset = r)
;
offset += 1;
let ch = content.substr(offset, index - offset).length;
return { line: l, ch: ch };
}
}
function blank$1(text) {
return text === undefined || text === null || text === '';
}
function notBlank$1(text) {
return !blank$1(text);
}
function scrape(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
const response = yield obsidian.requestUrl(url);
if (!response.headers['content-type'].includes('text/html'))
return getUrlFinalSegment$1(url);
const html = response.text;
const doc = new DOMParser().parseFromString(html, 'text/html');
const title = doc.querySelector('title');
if (blank$1(title === null || title === void 0 ? void 0 : title.innerText)) {
// If site is javascript based and has a no-title attribute when unloaded, use it.
var noTitle = title === null || title === void 0 ? void 0 : title.getAttr('no-title');
if (notBlank$1(noTitle)) {
return noTitle;
}
// Otherwise if the site has no title/requires javascript simply return Title Unknown
return url;
}
return title.innerText;
}
catch (ex) {
console.error(ex);
return '';
}
});
}
function getUrlFinalSegment$1(url) {
try {
const segments = new URL(url).pathname.split('/');
const last = segments.pop() || segments.pop(); // Handle potential trailing slash
return last;
}
catch (_) {
return 'File';
}
}
function getPageTitle$1(url) {
return __awaiter(this, void 0, void 0, function* () {
if (!(url.startsWith('http') || url.startsWith('https'))) {
url = 'https://' + url;
}
return scrape(url);
});
}
const electronPkg = require("electron");
function blank(text) {
return text === undefined || text === null || text === "";
}
function notBlank(text) {
return !blank(text);
}
// async wrapper to load a url and settle on load finish or fail
function load(window, url) {
return __awaiter(this, void 0, void 0, function* () {
return new Promise((resolve, reject) => {
window.webContents.on("did-finish-load", (event) => resolve(event));
window.webContents.on("did-fail-load", (event) => reject(event));
window.loadURL(url);
});
});
}
function electronGetPageTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
const { remote } = electronPkg;
const { BrowserWindow } = remote;
try {
const window = new BrowserWindow({
width: 1000,
height: 600,
webPreferences: {
webSecurity: false,
nodeIntegration: true,
images: false,
},
show: false,
});
window.webContents.setAudioMuted(true);
window.webContents.on("will-navigate", (event, newUrl) => {
event.preventDefault();
window.loadURL(newUrl);
});
yield load(window, url);
try {
const title = window.webContents.getTitle();
window.destroy();
if (notBlank(title)) {
return title;
}
else {
return url;
}
}
catch (ex) {
window.destroy();
return url;
}
}
catch (ex) {
console.error(ex);
return "";
}
});
}
function nonElectronGetPageTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
const html = yield obsidian.request({ url });
const doc = new DOMParser().parseFromString(html, "text/html");
const title = doc.querySelectorAll("title")[0];
if (title == null || blank(title === null || title === void 0 ? void 0 : title.innerText)) {
// If site is javascript based and has a no-title attribute when unloaded, use it.
var noTitle = title === null || title === void 0 ? void 0 : title.getAttr("no-title");
if (notBlank(noTitle)) {
return noTitle;
}
// Otherwise if the site has no title/requires javascript simply return Title Unknown
return url;
}
return title.innerText;
}
catch (ex) {
console.error(ex);
return "";
}
});
}
function getUrlFinalSegment(url) {
try {
const segments = new URL(url).pathname.split('/');
const last = segments.pop() || segments.pop(); // Handle potential trailing slash
return last;
}
catch (_) {
return "File";
}
}
function tryGetFileType(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
const response = yield fetch(url, { method: "HEAD" });
// Ensure site returns an ok status code before scraping
if (!response.ok) {
return "Site Unreachable";
}
// Ensure site is an actual HTML page and not a pdf or 3 gigabyte video file.
let contentType = response.headers.get("content-type");
if (!contentType.includes("text/html")) {
return getUrlFinalSegment(url);
}
return null;
}
catch (err) {
return null;
}
});
}
function getPageTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
// If we're on Desktop use the Electron scraper
if (!(url.startsWith("http") || url.startsWith("https"))) {
url = "https://" + url;
}
// Try to do a HEAD request to see if the site is reachable and if it's an HTML page
// If we error out due to CORS, we'll just try to scrape the page anyway.
let fileType = yield tryGetFileType(url);
if (fileType) {
return fileType;
}
if (electronPkg != null) {
return electronGetPageTitle(url);
}
else {
return nonElectronGetPageTitle(url);
}
});
}
class AutoLinkTitle extends obsidian.Plugin {
constructor() {
super(...arguments);
this.shortTitle = (title) => {
if (this.settings.maximumTitleLength === 0) {
return title;
}
if (title.length < this.settings.maximumTitleLength + 3) {
return title;
}
const shortenedTitle = `${title.slice(0, this.settings.maximumTitleLength)}...`;
return shortenedTitle;
};
}
onload() {
return __awaiter(this, void 0, void 0, function* () {
console.log("loading obsidian-auto-link-title");
yield this.loadSettings();
this.blacklist = this.settings.websiteBlacklist
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Listen to paste event
this.pasteFunction = this.pasteUrlWithTitle.bind(this);
// Listen to drop event
this.dropFunction = this.dropUrlWithTitle.bind(this);
this.addCommand({
id: "auto-link-title-paste",
name: "Paste URL and auto fetch title",
editorCallback: (editor) => this.manualPasteUrlWithTitle(editor),
hotkeys: [],
});
this.addCommand({
id: "auto-link-title-normal-paste",
name: "Normal paste (no fetching behavior)",
editorCallback: (editor) => this.normalPaste(editor),
hotkeys: [
{
modifiers: ["Mod", "Shift"],
key: "v",
},
],
});
this.registerEvent(this.app.workspace.on("editor-paste", this.pasteFunction));
this.registerEvent(this.app.workspace.on("editor-drop", this.dropFunction));
this.addCommand({
id: "enhance-url-with-title",
name: "Enhance existing URL with link and title",
editorCallback: (editor) => this.addTitleToLink(editor),
hotkeys: [
{
modifiers: ["Mod", "Shift"],
key: "e",
},
],
});
this.addSettingTab(new AutoLinkTitleSettingTab(this.app, this));
});
}
addTitleToLink(editor) {
// Only attempt fetch if online
if (!navigator.onLine)
return;
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
// If the cursor is on a raw html link, convert to a markdown link and fetch title
if (CheckIf.isUrl(selectedText)) {
this.convertUrlToTitledLink(editor, selectedText);
}
// If the cursor is on the URL part of a markdown link, fetch title and replace existing link title
else if (CheckIf.isLinkedUrl(selectedText)) {
const link = this.getUrlFromLink(selectedText);
this.convertUrlToTitledLink(editor, link);
}
}
normalPaste(editor) {
return __awaiter(this, void 0, void 0, function* () {
let clipboardText = yield navigator.clipboard.readText();
if (clipboardText === null || clipboardText === "")
return;
editor.replaceSelection(clipboardText);
});
}
// Simulate standard paste but using editor.replaceSelection with clipboard text since we can't seem to dispatch a paste event.
manualPasteUrlWithTitle(editor) {
return __awaiter(this, void 0, void 0, function* () {
const clipboardText = yield navigator.clipboard.readText();
// Only attempt fetch if online
if (!navigator.onLine) {
editor.replaceSelection(clipboardText);
return;
}
if (clipboardText == null || clipboardText == "")
return;
// If its not a URL, we return false to allow the default paste handler to take care of it.
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
// to fetch the title is a waste of bandwidth.
if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) {
editor.replaceSelection(clipboardText);
return;
}
// If it looks like we're pasting the url into a markdown link already, don't fetch title
// as the user has already probably put a meaningful title, also it would lead to the title
// being inside the link.
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
editor.replaceSelection(clipboardText);
return;
}
// If url is pasted over selected text and setting is enabled, no need to fetch title,
// just insert a link
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
editor.replaceSelection(`[${selectedText}](${clipboardText})`);
return;
}
// At this point we're just pasting a link in a normal fashion, fetch its title.
this.convertUrlToTitledLink(editor, clipboardText);
return;
});
}
pasteUrlWithTitle(clipboard, editor) {
return __awaiter(this, void 0, void 0, function* () {
if (!this.settings.enhanceDefaultPaste) {
return;
}
if (clipboard.defaultPrevented)
return;
// Only attempt fetch if online
if (!navigator.onLine)
return;
let clipboardText = clipboard.clipboardData.getData("text/plain");
if (clipboardText === null || clipboardText === "")
return;
// If its not a URL, we return false to allow the default paste handler to take care of it.
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
// to fetch the title is a waste of bandwidth.
if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) {
return;
}
// We've decided to handle the paste, stop propagation to the default handler.
clipboard.stopPropagation();
clipboard.preventDefault();
// If it looks like we're pasting the url into a markdown link already, don't fetch title
// as the user has already probably put a meaningful title, also it would lead to the title
// being inside the link.
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
editor.replaceSelection(clipboardText);
return;
}
// If url is pasted over selected text and setting is enabled, no need to fetch title,
// just insert a link
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
editor.replaceSelection(`[${selectedText}](${clipboardText})`);
return;
}
// At this point we're just pasting a link in a normal fashion, fetch its title.
this.convertUrlToTitledLink(editor, clipboardText);
return;
});
}
dropUrlWithTitle(dropEvent, editor) {
return __awaiter(this, void 0, void 0, function* () {
if (!this.settings.enhanceDropEvents) {
return;
}
if (dropEvent.defaultPrevented)
return;
// Only attempt fetch if online
if (!navigator.onLine)
return;
let dropText = dropEvent.dataTransfer.getData("text/plain");
if (dropText === null || dropText === "")
return;
// If its not a URL, we return false to allow the default paste handler to take care of it.
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
// to fetch the title is a waste of bandwidth.
if (!CheckIf.isUrl(dropText) || CheckIf.isImage(dropText)) {
return;
}
// We've decided to handle the paste, stop propagation to the default handler.
dropEvent.stopPropagation();
dropEvent.preventDefault();
// If it looks like we're pasting the url into a markdown link already, don't fetch title
// as the user has already probably put a meaningful title, also it would lead to the title
// being inside the link.
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
editor.replaceSelection(dropText);
return;
}
// If url is pasted over selected text and setting is enabled, no need to fetch title,
// just insert a link
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
editor.replaceSelection(`[${selectedText}](${dropText})`);
return;
}
// At this point we're just pasting a link in a normal fashion, fetch its title.
this.convertUrlToTitledLink(editor, dropText);
return;
});
}
isBlacklisted(url) {
return __awaiter(this, void 0, void 0, function* () {
yield this.loadSettings();
this.blacklist = this.settings.websiteBlacklist
.split(/,|\n/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
return this.blacklist.some((site) => url.includes(site));
});
}
convertUrlToTitledLink(editor, url) {
return __awaiter(this, void 0, void 0, function* () {
if (yield this.isBlacklisted(url)) {
let domain = new URL(url).hostname;
editor.replaceSelection(`[${domain}](${url})`);
return;
}
// Generate a unique id for find/replace operations for the title.
const pasteId = this.getPasteId();
// Instantly paste so you don't wonder if paste is broken
editor.replaceSelection(`[${pasteId}](${url})`);
// Fetch title from site, replace Fetching Title with actual title
const title = yield this.fetchUrlTitle(url);
const escapedTitle = this.escapeMarkdown(title);
const shortenedTitle = this.shortTitle(escapedTitle);
const text = editor.getValue();
const start = text.indexOf(pasteId);
if (start < 0) {
console.log(`Unable to find text "${pasteId}" in current editor, bailing out; link ${url}`);
}
else {
const end = start + pasteId.length;
const startPos = EditorExtensions.getEditorPositionFromIndex(text, start);
const endPos = EditorExtensions.getEditorPositionFromIndex(text, end);
editor.replaceRange(shortenedTitle, startPos, endPos);
}
});
}
escapeMarkdown(text) {
var unescaped = text.replace(/\\(\*|_|`|~|\\|\[|\])/g, "$1"); // unescape any "backslashed" character
var escaped = unescaped.replace(/(\*|_|`|<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, [, ], <, and >
var escaped = unescaped.replace(/(\*|_|`|\||<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, |, [, ], <, and >
return escaped;
}
fetchUrlTitleViaLinkPreview(url) {
return __awaiter(this, void 0, void 0, function* () {
if (this.settings.linkPreviewApiKey.length !== 32) {
console.error("LinkPreview API key is not 32 characters long, please check your settings");
return "";
}
try {
const apiEndpoint = `https://api.linkpreview.net/?q=${encodeURIComponent(url)}`;
const response = yield fetch(apiEndpoint, {
headers: {
"X-Linkpreview-Api-Key": this.settings.linkPreviewApiKey,
},
});
const data = yield response.json();
return data.title;
}
catch (error) {
console.error(error);
return "";
}
});
}
fetchUrlTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
let title = "";
title = yield this.fetchUrlTitleViaLinkPreview(url);
console.log(`Title via Link Preview: ${title}`);
if (title === "") {
console.log("Title via Link Preview failed, falling back to scraper");
if (this.settings.useNewScraper) {
console.log("Using new scraper");
title = yield getPageTitle$1(url);
}
else {
console.log("Using old scraper");
title = yield getPageTitle(url);
}
}
console.log(`Title: ${title}`);
title =
title.replace(/(\r\n|\n|\r)/gm, "").trim() ||
"Title Unavailable | Site Unreachable";
return title;
}
catch (error) {
console.error(error);
return "Error fetching title";
}
});
}
getUrlFromLink(link) {
let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex);
return urlRegex.exec(link)[2];
}
getPasteId() {
var base = "Fetching Title";
if (this.settings.useBetterPasteId) {
return this.getBetterPasteId(base);
}
else {
return `${base}#${this.createBlockHash()}`;
}
}
getBetterPasteId(base) {
// After every character, add 0, 1 or 2 invisible characters
// so that to the user it looks just like the base string.
// The number of combinations is 3^14 = 4782969
let result = "";
var invisibleCharacter = "\u200B";
var maxInvisibleCharacters = 2;
for (var i = 0; i < base.length; i++) {
var count = Math.floor(Math.random() * (maxInvisibleCharacters + 1));
result += base.charAt(i) + invisibleCharacter.repeat(count);
}
return result;
}
// Custom hashid by @shabegom
createBlockHash() {
let result = "";
var characters = "abcdefghijklmnopqrstuvwxyz0123456789";
var charactersLength = characters.length;
for (var i = 0; i < 4; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
onunload() {
console.log("unloading obsidian-auto-link-title");
}
loadSettings() {
return __awaiter(this, void 0, void 0, function* () {
this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData());
});
}
saveSettings() {
return __awaiter(this, void 0, void 0, function* () {
yield this.saveData(this.settings);
});
}
}
module.exports = AutoLinkTitle;
/* nosourcemap */
@@ -0,0 +1,10 @@
{
"id": "obsidian-auto-link-title",
"name": "Auto Link Title",
"version": "1.5.5",
"minAppVersion": "0.12.17",
"description": "This plugin automatically fetches the titles of links from the web",
"author": "Matt Furden",
"authorUrl": "https://github.com/zolrath",
"isDesktopOnly": false
}
+1
View File
@@ -0,0 +1 @@
/* no styles */
+11
View File
@@ -0,0 +1,11 @@
{
"imageNamePattern": "{{fileName}}",
"dupNumberAtStart": false,
"dupNumberDelimiter": "-",
"dupNumberAlways": true,
"autoRename": true,
"handleAllAttachments": true,
"excludeExtensionPattern": "",
"disableRenameNotice": false,
"useFileHashName": true
}
+965
View File
@@ -0,0 +1,965 @@
/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD */
var __defProp = Object.defineProperty;
var __defProps = Object.defineProperties;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b || (b = {}))
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSym )
__defNormalProp(a, prop, b[prop]);
return a;
};
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
var fulfilled = (value) => {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
};
var rejected = (value) => {
try {
step(generator.throw(value));
} catch (e) {
reject(e);
}
};
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
step((generator = generator.apply(__this, __arguments)).next());
});
};
// package.json
var require_package = __commonJS({
"package.json"(exports, module2) {
module2.exports = {
name: "obsidian-paste-image-rename",
version: "1.6.1",
main: "main.js",
scripts: {
start: "node esbuild.config.mjs",
build: "tsc -noEmit -skipLibCheck && BUILD_ENV=production node esbuild.config.mjs && cp manifest.json build",
version: "node version-bump.mjs && git add manifest.json versions.json",
release: "npm run build && gh release create ${npm_package_version} build/*"
},
keywords: [],
author: "Reorx",
license: "MIT",
devDependencies: {
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"builtin-modules": "^3.3.0",
esbuild: "0.16.17",
obsidian: "^1.1.1",
tslib: "2.5.0",
typescript: "4.9.4"
},
dependencies: {
"cash-dom": "^8.1.2"
}
};
}
});
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => PasteImageRenamePlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian2 = require("obsidian");
// src/batch.ts
var import_obsidian = require("obsidian");
// src/utils.ts
var DEBUG = false;
if (DEBUG)
console.log("DEBUG is enabled");
function debugLog(...args) {
if (DEBUG) {
console.log(new Date().toISOString().slice(11, 23), ...args);
}
}
function createElementTree(rootEl, opts) {
const result = {
el: rootEl.createEl(opts.tag, opts),
children: []
};
const children = opts.children || [];
for (const child of children) {
result.children.push(createElementTree(result.el, child));
}
return result;
}
var path = {
// Credit: @creationix/path.js
join(...partSegments) {
let parts = [];
for (let i = 0, l = partSegments.length; i < l; i++) {
parts = parts.concat(partSegments[i].split("/"));
}
const newParts = [];
for (let i = 0, l = parts.length; i < l; i++) {
const part = parts[i];
if (!part || part === ".")
continue;
else
newParts.push(part);
}
if (parts[0] === "")
newParts.unshift("");
return newParts.join("/");
},
// returns the last part of a path, e.g. 'foo.jpg'
basename(fullpath) {
const sp = fullpath.split("/");
return sp[sp.length - 1];
},
// return extension without dot, e.g. 'jpg'
extension(fullpath) {
const positions = [...fullpath.matchAll(new RegExp("\\.", "gi"))].map((a) => a.index);
return fullpath.slice(positions[positions.length - 1] + 1);
}
};
var filenameNotAllowedChars = /[^\p{L}0-9~`!@$&*()\-_=+{};'",<.>? ]/ug;
var sanitizer = {
filename(s) {
return s.replace(filenameNotAllowedChars, "").trim();
},
delimiter(s) {
s = this.filename(s);
if (!s)
s = "-";
return s;
}
};
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function lockInputMethodComposition(el) {
const state = {
lock: false
};
el.addEventListener("compositionstart", () => {
state.lock = true;
});
el.addEventListener("compositionend", () => {
state.lock = false;
});
return state;
}
function toHex(buffer) {
return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function sha256(buffer) {
return __async(this, null, function* () {
const digest = yield crypto.subtle.digest("SHA-256", buffer);
return toHex(digest);
});
}
// src/batch.ts
var ImageBatchRenameModal = class extends import_obsidian.Modal {
constructor(app, activeFile, renameFunc, onClose) {
super(app);
this.activeFile = activeFile;
this.renameFunc = renameFunc;
this.onCloseExtra = onClose;
this.state = {
namePattern: "",
extPattern: "",
nameReplace: "",
renameTasks: []
};
}
onOpen() {
this.containerEl.addClass("image-rename-modal");
const { contentEl, titleEl } = this;
titleEl.setText("Batch rename embeded files");
const namePatternSetting = new import_obsidian.Setting(contentEl).setName("Name pattern").setDesc("Please input the name pattern to match files (regex)").addText((text) => text.setValue(this.state.namePattern).onChange(
(value) => __async(this, null, function* () {
this.state.namePattern = value;
})
));
const npInputEl = namePatternSetting.controlEl.children[0];
npInputEl.focus();
const npInputState = lockInputMethodComposition(npInputEl);
npInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter" && !npInputState.lock) {
e.preventDefault();
if (!this.state.namePattern) {
errorEl.innerText = 'Error: "Name pattern" could not be empty';
errorEl.style.display = "block";
return;
}
this.matchImageNames(tbodyEl);
}
}));
const extPatternSetting = new import_obsidian.Setting(contentEl).setName("Extension pattern").setDesc("Please input the extension pattern to match files (regex)").addText((text) => text.setValue(this.state.extPattern).onChange(
(value) => __async(this, null, function* () {
this.state.extPattern = value;
})
));
const extInputEl = extPatternSetting.controlEl.children[0];
extInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter") {
e.preventDefault();
this.matchImageNames(tbodyEl);
}
}));
const nameReplaceSetting = new import_obsidian.Setting(contentEl).setName("Name replace").setDesc("Please input the string to replace the matched name (use $1, $2 for regex groups)").addText((text) => text.setValue(this.state.nameReplace).onChange(
(value) => __async(this, null, function* () {
this.state.nameReplace = value;
})
));
const nrInputEl = nameReplaceSetting.controlEl.children[0];
const nrInputState = lockInputMethodComposition(nrInputEl);
nrInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter" && !nrInputState.lock) {
e.preventDefault();
this.matchImageNames(tbodyEl);
}
}));
const matchedContainer = contentEl.createDiv({
cls: "matched-container"
});
const tableET = createElementTree(matchedContainer, {
tag: "table",
children: [
{
tag: "thead",
children: [
{
tag: "tr",
children: [
{
tag: "td",
text: "Original path"
},
{
tag: "td",
text: "Renamed Name"
}
]
}
]
},
{
tag: "tbody"
}
]
});
const tbodyEl = tableET.children[1].el;
const errorEl = contentEl.createDiv({
cls: "error",
attr: {
style: "display: none;"
}
});
new import_obsidian.Setting(contentEl).addButton((button) => {
button.setButtonText("Rename all").setClass("mod-cta").onClick(() => {
new ConfirmModal(
this.app,
"Confirm rename all",
`Are you sure? This will rename all the ${this.state.renameTasks.length} images matched the pattern.`,
() => {
this.renameAll();
this.close();
}
).open();
});
}).addButton((button) => {
button.setButtonText("Cancel").onClick(() => {
this.close();
});
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
this.onCloseExtra();
}
renameAll() {
return __async(this, null, function* () {
debugLog("renameAll", this.state);
for (const task of this.state.renameTasks) {
yield this.renameFunc(task.file, task.name);
}
});
}
matchImageNames(tbodyEl) {
const { state } = this;
const renameTasks = [];
tbodyEl.empty();
const fileCache = this.app.metadataCache.getFileCache(this.activeFile);
if (!fileCache || !fileCache.embeds)
return;
const namePatternRegex = new RegExp(state.namePattern, "g");
const extPatternRegex = new RegExp(state.extPattern);
fileCache.embeds.forEach((embed) => {
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, this.activeFile.path);
if (!file) {
console.warn("file not found", embed.link);
return;
}
if (state.extPattern) {
const m0 = extPatternRegex.exec(file.extension);
if (!m0)
return;
}
const stem = file.basename;
namePatternRegex.lastIndex = 0;
const m1 = namePatternRegex.exec(stem);
if (!m1)
return;
let renamedName = file.name;
if (state.nameReplace) {
namePatternRegex.lastIndex = 0;
renamedName = stem.replace(namePatternRegex, state.nameReplace);
renamedName = `${renamedName}.${file.extension}`;
}
renameTasks.push({
file,
name: renamedName
});
createElementTree(tbodyEl, {
tag: "tr",
children: [
{
tag: "td",
children: [
{
tag: "span",
text: file.name
},
{
tag: "div",
text: file.path,
attr: {
class: "file-path"
}
}
]
},
{
tag: "td",
children: [
{
tag: "span",
text: renamedName
},
{
tag: "div",
text: path.join(file.parent.path, renamedName),
attr: {
class: "file-path"
}
}
]
}
]
});
});
debugLog("new renameTasks", renameTasks);
state.renameTasks = renameTasks;
}
};
var ConfirmModal = class extends import_obsidian.Modal {
constructor(app, title, message, onConfirm) {
super(app);
this.title = title;
this.message = message;
this.onConfirm = onConfirm;
}
onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText(this.title);
contentEl.createEl("p", {
text: this.message
});
new import_obsidian.Setting(contentEl).addButton((button) => {
button.setButtonText("Yes").setClass("mod-warning").onClick(() => {
this.onConfirm();
this.close();
});
}).addButton((button) => {
button.setButtonText("No").onClick(() => {
this.close();
});
});
}
};
// src/template.ts
var dateTmplRegex = /{{DATE:([^}]+)}}/gm;
var frontmatterTmplRegex = /{{frontmatter:([^}]+)}}/gm;
var replaceDateVar = (s, date) => {
const m = dateTmplRegex.exec(s);
if (!m)
return s;
return s.replace(m[0], date.format(m[1]));
};
var replaceFrontmatterVar = (s, frontmatter) => {
if (!frontmatter)
return s;
const m = frontmatterTmplRegex.exec(s);
if (!m)
return s;
return s.replace(m[0], frontmatter[m[1]] || "");
};
var renderTemplate = (tmpl, data, frontmatter) => {
const now = window.moment();
let text = tmpl;
let newtext;
while ((newtext = replaceDateVar(text, now)) != text) {
text = newtext;
}
while ((newtext = replaceFrontmatterVar(text, frontmatter)) != text) {
text = newtext;
}
text = text.replace(/{{imageNameKey}}/gm, data.imageNameKey).replace(/{{fileName}}/gm, data.fileName).replace(/{{dirName}}/gm, data.dirName).replace(/{{firstHeading}}/gm, data.firstHeading).replace(/{{fileHash}}/gm, data.fileHash);
return text;
};
// src/main.ts
var DEFAULT_SETTINGS = {
imageNamePattern: "{{fileName}}",
dupNumberAtStart: false,
dupNumberDelimiter: "-",
dupNumberAlways: false,
autoRename: false,
handleAllAttachments: false,
excludeExtensionPattern: "",
disableRenameNotice: false
};
var PASTED_IMAGE_PREFIX = "Pasted image ";
var PasteImageRenamePlugin = class extends import_obsidian2.Plugin {
constructor() {
super(...arguments);
this.modals = [];
}
onload() {
return __async(this, null, function* () {
const pkg = require_package();
console.log(`Plugin loading: ${pkg.name} ${pkg.version} BUILD_ENV=${"production"}`);
yield this.loadSettings();
this.registerEvent(
this.app.vault.on("create", (file) => {
if (!(file instanceof import_obsidian2.TFile))
return;
const timeGapMs = new Date().getTime() - file.stat.ctime;
if (timeGapMs > 1e3)
return;
if (isMarkdownFile(file))
return;
if (isPastedImage(file)) {
debugLog("pasted image created", file);
this.startRenameProcess(file, this.settings.autoRename);
} else {
if (this.settings.handleAllAttachments) {
debugLog("handleAllAttachments for file", file);
if (this.testExcludeExtension(file)) {
debugLog("excluded file by ext", file);
return;
}
this.startRenameProcess(file, this.settings.autoRename);
}
}
})
);
const startBatchRenameProcess = () => {
this.openBatchRenameModal();
};
this.addCommand({
id: "batch-rename-embeded-files",
name: "Batch rename embeded files (in the current file)",
callback: startBatchRenameProcess
});
if (DEBUG) {
this.addRibbonIcon("wand-glyph", "Batch rename embeded files", startBatchRenameProcess);
}
const batchRenameAllImages = () => {
this.batchRenameAllImages();
};
this.addCommand({
id: "batch-rename-all-images",
name: "Batch rename all images instantly (in the current file)",
callback: batchRenameAllImages
});
if (DEBUG) {
this.addRibbonIcon("wand-glyph", "Batch rename all images instantly (in the current file)", batchRenameAllImages);
}
this.addSettingTab(new SettingTab(this.app, this));
});
}
startRenameProcess(file, autoRename = false) {
return __async(this, null, function* () {
const activeFile = this.getActiveFile();
if (!activeFile) {
new import_obsidian2.Notice("Error: No active file found.");
return;
}
const { stem, newName, isMeaningful } = yield this.generateNewName(file, activeFile);
debugLog("generated newName:", newName, isMeaningful);
if (!isMeaningful || !autoRename) {
this.openRenameModal(file, isMeaningful ? stem : "", activeFile.path);
return;
}
this.renameFile(file, newName, activeFile.path, true);
});
}
renameFile(file, inputNewName, sourcePath, replaceCurrentLine) {
return __async(this, null, function* () {
const { name: newName } = yield this.deduplicateNewName(inputNewName, file);
debugLog("deduplicated newName:", newName);
const originName = file.name;
const linkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
const newPath = path.join(file.parent.path, newName);
try {
yield this.app.fileManager.renameFile(file, newPath);
} catch (err) {
new import_obsidian2.Notice(`Failed to rename ${newName}: ${err}`);
throw err;
}
if (!replaceCurrentLine) {
return;
}
const newLinkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
debugLog("replace text", linkText, newLinkText);
const editor = this.getActiveEditor();
if (!editor) {
new import_obsidian2.Notice(`Failed to rename ${newName}: no active editor`);
return;
}
const changes = [];
for (let lineNumber = 0; lineNumber < editor.lineCount(); lineNumber++) {
const line = editor.getLine(lineNumber);
const replacedLine = line.split(linkText).join(newLinkText);
if (line === replacedLine) {
continue;
}
debugLog("replace line", lineNumber, line, replacedLine);
changes.push({
from: { line: lineNumber, ch: 0 },
to: { line: lineNumber, ch: line.length },
text: replacedLine
});
}
if (changes.length > 0) {
editor.transaction({ changes });
}
if (!this.settings.disableRenameNotice) {
new import_obsidian2.Notice(`Renamed ${originName} to ${newName}`);
}
});
}
openRenameModal(file, newName, sourcePath) {
const modal = new ImageRenameModal(
this.app,
file,
newName,
(confirmedName) => {
debugLog("confirmedName:", confirmedName);
this.renameFile(file, confirmedName, sourcePath, true);
},
() => {
this.modals.splice(this.modals.indexOf(modal), 1);
}
);
this.modals.push(modal);
modal.open();
debugLog("modals count", this.modals.length);
}
openBatchRenameModal() {
const activeFile = this.getActiveFile();
const modal = new ImageBatchRenameModal(
this.app,
activeFile,
(file, name) => __async(this, null, function* () {
yield this.renameFile(file, name, activeFile.path);
}),
() => {
this.modals.splice(this.modals.indexOf(modal), 1);
}
);
this.modals.push(modal);
modal.open();
}
batchRenameAllImages() {
return __async(this, null, function* () {
const activeFile = this.getActiveFile();
const fileCache = this.app.metadataCache.getFileCache(activeFile);
if (!fileCache || !fileCache.embeds)
return;
const extPatternRegex = /jpe?g|png|gif|tiff|webp/i;
for (const embed of fileCache.embeds) {
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, activeFile.path);
if (!file) {
console.warn("file not found", embed.link);
return;
}
const m0 = extPatternRegex.exec(file.extension);
if (!m0)
return;
const { newName, isMeaningful } = yield this.generateNewName(file, activeFile);
debugLog("generated newName:", newName, isMeaningful);
if (!isMeaningful) {
new import_obsidian2.Notice("Failed to batch rename images: the generated name is not meaningful");
break;
}
yield this.renameFile(file, newName, activeFile.path, false);
}
});
}
// returns a new name for the input file, with extension
generateNewName(file, activeFile) {
return __async(this, null, function* () {
let imageNameKey = "";
let firstHeading = "";
let frontmatter;
const fileCache = this.app.metadataCache.getFileCache(activeFile);
if (fileCache) {
debugLog("frontmatter", fileCache.frontmatter);
frontmatter = fileCache.frontmatter;
imageNameKey = (frontmatter == null ? void 0 : frontmatter.imageNameKey) || "";
firstHeading = getFirstHeading(fileCache.headings);
} else {
console.warn("could not get file cache from active file", activeFile.name);
}
let fileHash = "";
if (this.settings.imageNamePattern.includes("{{fileHash}}")) {
const buffer = yield this.app.vault.readBinary(file);
fileHash = yield sha256(buffer);
}
const stem = renderTemplate(
this.settings.imageNamePattern,
{
imageNameKey,
fileName: activeFile.basename,
dirName: activeFile.parent.name,
firstHeading,
fileHash
},
frontmatter
);
const meaninglessRegex = new RegExp(`[${this.settings.dupNumberDelimiter}\\s]`, "gm");
return {
stem,
newName: stem + "." + file.extension,
isMeaningful: stem.replace(meaninglessRegex, "") !== ""
};
});
}
// newName: foo.ext
deduplicateNewName(newName, file) {
return __async(this, null, function* () {
const dir = file.parent.path;
const listed = yield this.app.vault.adapter.list(dir);
debugLog("sibling files", listed);
const newNameExt = path.extension(newName), newNameStem = newName.slice(0, newName.length - newNameExt.length - 1), newNameStemEscaped = escapeRegExp(newNameStem), delimiter = this.settings.dupNumberDelimiter, delimiterEscaped = escapeRegExp(delimiter);
let dupNameRegex;
if (this.settings.dupNumberAtStart) {
dupNameRegex = new RegExp(
`^(?<number>\\d+)${delimiterEscaped}(?<name>${newNameStemEscaped})\\.${newNameExt}$`
);
} else {
dupNameRegex = new RegExp(
`^(?<name>${newNameStemEscaped})${delimiterEscaped}(?<number>\\d+)\\.${newNameExt}$`
);
}
debugLog("dupNameRegex", dupNameRegex);
const dupNameNumbers = [];
let isNewNameExist = false;
for (let sibling of listed.files) {
sibling = path.basename(sibling);
if (sibling == newName) {
isNewNameExist = true;
continue;
}
const m = dupNameRegex.exec(sibling);
if (!m)
continue;
dupNameNumbers.push(parseInt(m.groups.number));
}
if (isNewNameExist || this.settings.dupNumberAlways) {
const newNumber = dupNameNumbers.length > 0 ? Math.max(...dupNameNumbers) + 1 : 1;
if (this.settings.dupNumberAtStart) {
newName = `${newNumber}${delimiter}${newNameStem}.${newNameExt}`;
} else {
newName = `${newNameStem}${delimiter}${newNumber}.${newNameExt}`;
}
}
return {
name: newName,
stem: newName.slice(0, newName.length - newNameExt.length - 1),
extension: newNameExt
};
});
}
getActiveFile() {
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
const file = view == null ? void 0 : view.file;
debugLog("active file", file == null ? void 0 : file.path);
return file;
}
getActiveEditor() {
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
return view == null ? void 0 : view.editor;
}
onunload() {
this.modals.map((modal) => modal.close());
}
testExcludeExtension(file) {
const pattern = this.settings.excludeExtensionPattern;
if (!pattern)
return false;
return new RegExp(pattern).test(file.extension);
}
loadSettings() {
return __async(this, null, function* () {
this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData());
});
}
saveSettings() {
return __async(this, null, function* () {
yield this.saveData(this.settings);
});
}
};
function getFirstHeading(headings) {
if (headings && headings.length > 0) {
for (const heading of headings) {
if (heading.level === 1) {
return heading.heading;
}
}
}
return "";
}
function isPastedImage(file) {
if (file instanceof import_obsidian2.TFile) {
if (file.name.startsWith(PASTED_IMAGE_PREFIX)) {
return true;
}
}
return false;
}
function isMarkdownFile(file) {
if (file instanceof import_obsidian2.TFile) {
if (file.extension === "md") {
return true;
}
}
return false;
}
var ImageRenameModal = class extends import_obsidian2.Modal {
constructor(app, src, stem, renameFunc, onClose) {
super(app);
this.src = src;
this.stem = stem;
this.renameFunc = renameFunc;
this.onCloseExtra = onClose;
}
onOpen() {
this.containerEl.addClass("image-rename-modal");
const { contentEl, titleEl } = this;
titleEl.setText("Rename image");
const imageContainer = contentEl.createDiv({
cls: "image-container"
});
imageContainer.createEl("img", {
attr: {
src: this.app.vault.getResourcePath(this.src)
}
});
let stem = this.stem;
const ext = this.src.extension;
const getNewName = (stem2) => stem2 + "." + ext;
const getNewPath = (stem2) => path.join(this.src.parent.path, getNewName(stem2));
const infoET = createElementTree(contentEl, {
tag: "ul",
cls: "info",
children: [
{
tag: "li",
children: [
{
tag: "span",
text: "Origin path"
},
{
tag: "span",
text: this.src.path
}
]
},
{
tag: "li",
children: [
{
tag: "span",
text: "New path"
},
{
tag: "span",
text: getNewPath(stem)
}
]
}
]
});
const doRename = () => __async(this, null, function* () {
debugLog("doRename", `stem=${stem}`);
this.renameFunc(getNewName(stem));
});
const nameSetting = new import_obsidian2.Setting(contentEl).setName("New name").setDesc("Please input the new name for the image (without extension)").addText((text) => text.setValue(stem).onChange(
(value) => __async(this, null, function* () {
stem = sanitizer.filename(value);
infoET.children[1].children[1].el.innerText = getNewPath(stem);
})
));
const nameInputEl = nameSetting.controlEl.children[0];
nameInputEl.focus();
const nameInputState = lockInputMethodComposition(nameInputEl);
nameInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter" && !nameInputState.lock) {
e.preventDefault();
if (!stem) {
errorEl.innerText = 'Error: "New name" could not be empty';
errorEl.style.display = "block";
return;
}
doRename();
this.close();
}
}));
const errorEl = contentEl.createDiv({
cls: "error",
attr: {
style: "display: none;"
}
});
new import_obsidian2.Setting(contentEl).addButton((button) => {
button.setButtonText("Rename").onClick(() => {
doRename();
this.close();
});
}).addButton((button) => {
button.setButtonText("Cancel").onClick(() => {
this.close();
});
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
this.onCloseExtra();
}
};
var imageNamePatternDesc = `
The pattern indicates how the new name should be generated.
Available variables:
- {{fileName}}: name of the active file, without ".md" extension.
- {{imageNameKey}}: this variable is read from the markdown file's frontmatter, from the same key "imageNameKey".
- {{fileHash}}: SHA-256 hash of the image file.
- {{DATE:$FORMAT}}: use "$FORMAT" to format the current date, "$FORMAT" must be a Moment.js format string, e.g. {{DATE:YYYY-MM-DD}}.
Here are some examples from pattern to image names (repeat in sequence), variables: fileName = "My note", imageNameKey = "foo":
- {{fileName}}: My note, My note-1, My note-2
- {{imageNameKey}}: foo, foo-1, foo-2
- {{fileHash}}: <sha256>, <sha256>-1, <sha256>-2
- {{imageNameKey}}-{{DATE:YYYYMMDD}}: foo-20220408, foo-20220408-1, foo-20220408-2
`;
var SettingTab = class extends import_obsidian2.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new import_obsidian2.Setting(containerEl).setName("Image name pattern").setDesc(imageNamePatternDesc).setClass("long-description-setting-item").addText((text) => text.setPlaceholder("{{imageNameKey}}").setValue(this.plugin.settings.imageNamePattern).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.imageNamePattern = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Duplicate number at start (or end)").setDesc(`If enabled, duplicate number will be added at the start as prefix for the image name, otherwise it will be added at the end as suffix for the image name.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAtStart).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.dupNumberAtStart = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Duplicate number delimiter").setDesc(`The delimiter to generate the number prefix/suffix for duplicated names. For example, if the value is "-", the suffix will be like "-1", "-2", "-3", and the prefix will be like "1-", "2-", "3-". Only characters that are valid in file names are allowed.`).addText((text) => text.setValue(this.plugin.settings.dupNumberDelimiter).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.dupNumberDelimiter = sanitizer.delimiter(value);
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Always add duplicate number").setDesc(`If enabled, duplicate number will always be added to the image name. Otherwise, it will only be added when the name is duplicated.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAlways).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.dupNumberAlways = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Auto rename").setDesc(`By default, the rename modal will always be shown to confirm before renaming, if this option is set, the image will be auto renamed after pasting.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.autoRename).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.autoRename = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Handle all attachments").setDesc(`By default, the plugin only handles images that starts with "Pasted image " in name,
which is the prefix Obsidian uses to create images from pasted content.
If this option is set, the plugin will handle all attachments that are created in the vault.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.handleAllAttachments).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.handleAllAttachments = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Exclude extension pattern").setDesc(`This option is only useful when "Handle all attachments" is enabled.
Write a Regex pattern to exclude certain extensions from being handled. Only the first line will be used.`).setClass("single-line-textarea").addTextArea((text) => text.setPlaceholder("docx?|xlsx?|pptx?|zip|rar").setValue(this.plugin.settings.excludeExtensionPattern).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.excludeExtensionPattern = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Disable rename notice").setDesc(`Turn off this option if you don't want to see the notice when renaming images.
Note that Obsidian may display a notice when a link has changed, this option cannot disable that.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.disableRenameNotice).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.disableRenameNotice = value;
yield this.plugin.saveSettings();
})
));
}
};
/* nosourcemap */
@@ -0,0 +1,10 @@
{
"id": "obsidian-paste-image-rename",
"name": "Paste image rename (Edited)",
"version": "1.6.1",
"minAppVersion": "0.12.0",
"description": "Rename pasted images and all the other attchments added to the vault",
"author": "Reorx",
"authorUrl": "https://github.com/reorx",
"isDesktopOnly": false
}
@@ -0,0 +1,79 @@
/* src/styles.css */
:root {
--shadow-color: 0deg 0% 0%;
--shadow-elevation-medium:
0.5px 0.5px 0.7px hsl(var(--shadow-color) / 0.14),
1.1px 1.1px 1.5px -0.9px hsl(var(--shadow-color) / 0.12),
2.4px 2.5px 3.3px -1.8px hsl(var(--shadow-color) / 0.1),
5.3px 5.6px 7.3px -2.7px hsl(var(--shadow-color) / 0.09),
11px 11.4px 15.1px -3.6px hsl(var(--shadow-color) / 0.07);
}
.image-rename-modal .modal {
width: 65%;
min-width: 600px;
}
.image-rename-modal .modal-content {
padding: 10px 5px;
}
.image-rename-modal .image-container {
display: flex;
justify-content: center;
}
.image-rename-modal .info {
padding: 10px 0;
color: var(--text-muted);
user-select: text;
}
.image-rename-modal .info li > span:nth-of-type(1) {
display: inline-block;
width: 6em;
margin-right: .5em;
}
.image-rename-modal .info li > span:nth-of-type(1):after {
content: ":";
float: right;
}
.image-rename-modal .image-container img {
display: block;
max-height: 300px;
box-shadow: var(--shadow-elevation-medium);
}
.image-rename-modal .setting-item-control input {
min-width: 300px;
}
.image-rename-modal .error {
border: 1px solid rgb(201, 90, 90);
color: rgb(134, 22, 22);
padding: 10px;
}
.image-rename-modal table {
font-size: .9em;
line-height: 1.8;
margin-bottom: 1.5em;
user-select: text;
}
.image-rename-modal table td {
padding-right: 1em;
}
.image-rename-modal table thead td {
font-weight: 700;
}
.image-rename-modal table tbody td .file-path {
font-size: .8em;
color: var(--text-faint);
line-height: 1;
}
.long-description-setting-item {
flex-wrap: wrap;
}
.long-description-setting-item .setting-item-description {
white-space: pre-wrap;
line-height: 1.3em;
}
.long-description-setting-item .setting-item-control {
padding-top: 10px;
}
.long-description-setting-item .setting-item-control input {
min-width: 300px;
width: 50%;
}
+214
View File
@@ -0,0 +1,214 @@
{
"main": {
"id": "16a573e2ddb25847",
"type": "split",
"children": [
{
"id": "ead2c6543d1842b7",
"type": "tabs",
"children": [
{
"id": "e32607a73f296aa4",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "src/content/posts/codex-warp.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "codex-warp"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "b3b818b3595a168f",
"type": "split",
"children": [
{
"id": "f871c6ee02fda945",
"type": "tabs",
"children": [
{
"id": "6719a883e9f34850",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "文件列表"
}
},
{
"id": "8f2d88709f3efdd0",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "搜索"
}
},
{
"id": "e4e31602ca6ef73f",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "书签"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "0e5ea46bbde4f663",
"type": "split",
"children": [
{
"id": "9a2e329e85417358",
"type": "tabs",
"children": [
{
"id": "cf180cd264a5895e",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"file": "src/content/posts/codex-warp.md",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "codex-warp 的反向链接列表"
}
},
{
"id": "20df57f8cd76fbac",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"file": "src/content/posts/codex-warp.md",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "codex-warp 的出链列表"
}
},
{
"id": "fc6075104c1b1a76",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "标签"
}
},
{
"id": "f9fd6fcf70c8f5c5",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-archive",
"title": "添加笔记属性"
}
},
{
"id": "41cb4f8448efc28f",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "src/content/posts/codex-warp.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "codex-warp 的大纲"
}
}
]
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
},
"left-ribbon": {
"hiddenItems": {
"switcher:打开快速切换": false,
"graph:查看关系图谱": false,
"canvas:新建白板": false,
"daily-notes:打开/创建今天的日记": false,
"templates:插入模板": false,
"command-palette:打开命令面板": false,
"bases:新建数据库": false
}
},
"active": "e32607a73f296aa4",
"lastOpenFiles": [
"public/pic/f1d08603a003c00e8664b4cb80b20358e9b9638bcedce5a307c91bb853b10eb7.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-4.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-3.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-2.png",
"public/pic/122.png",
"public/pic/codex-warp-2.png",
"public/pic/codex-warp.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png",
"public/pic",
"src/content/assets/images",
"src/content/assets",
"dist/pagefind/index/zh-cn_6473869.pf_index",
"dist/pagefind/index/zh-cn_52f3f92.pf_index",
"dist/pagefind/fragment/zh-cn_f061267.pf_fragment",
"dist/pagefind/fragment/zh-cn_de70424.pf_fragment",
"dist/pagefind/fragment/zh-cn_ddd5448.pf_fragment",
"dist/pagefind/fragment/zh-cn_dd6dfac.pf_fragment",
"dist/pagefind/fragment/zh-cn_c5d7eb4.pf_fragment",
"src/content/posts/codex-warp.md",
"src/content/posts/new-domain.md",
"dist/pic/Pasted image 20260420023402.png.md",
"public/pic/Pasted image 20260420023402.png.md"
]
}
+6 -1
View File
@@ -24,5 +24,10 @@
}, },
"[html]": { "[html]": {
"editor.defaultFormatter": "vscode.html-language-features" "editor.defaultFormatter": "vscode.html-language-features"
} },
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"markdown.validate.enabled": false,
"Codegeex.RepoIndex": true
} }
+101
View File
@@ -0,0 +1,101 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
基于 [Fuwari](https://github.com/saicaca/fuwari) 的静态博客,使用 Astro 6.1 + TailwindCSS 3.4 + Svelte 5。
- **包管理器**: pnpm 9`preinstall` hook 强制只允许 pnpm
- **语言**: TypeScript (strict)
- **代码规范**: Biome(同时负责 format 和 lint),无 ESLint/Prettier
- **部署**: Cloudflare Pages(见 `wrangler.toml`
## 常用命令
| 命令 | 用途 |
|---|---|
| `pnpm dev` | 启动本地开发服务器 (`localhost:4321`) |
| `pnpm build` | 构建生产版本到 `./dist/` 并运行 Pagefind 索引 |
| `pnpm preview` | 本地预览构建产物 |
| `pnpm check` | 运行 Astro 类型检查 |
| `pnpm type-check` | 运行 TypeScript 类型检查 (`tsc --noEmit`) |
| `pnpm format` | Biome 格式化 `./src` |
| `pnpm lint` | Biome 检查和修复 `./src` |
| `pnpm new-post <filename>` | 在 `src/content/posts/` 创建新文章 Markdown |
| `pnpm astro` | 直接调用 Astro CLI |
## 项目结构
```
src/
├── config.ts # 站点主配置(标题、导航、个人资料、许可证等)
├── content.config.ts # Astro Content Collections 定义(posts + spec
├── content/
│ ├── posts/ # 博客文章(.md / .mdx),通过文件系统集合加载
│ └── spec/ # 特殊页面集合
├── components/
│ ├── widget/ # 侧边栏组件(Profile, TOC, Tags, Categories 等)
│ ├── control/ # 交互控件(BackToTop, Pagination, ButtonLink 等)
│ ├── misc/ # 杂项(ImageWrapper, License, Markdown
│ └── *.astro/*.svelte # 页面级组件(Navbar, Footer, Search, PostPage 等)
├── pages/
│ ├── [...page].astro # 首页分页路由
│ ├── posts/[...slug].astro # 文章详情页
│ ├── archive.astro # 归档页
│ ├── about.astro # 关于页
│ ├── friends.astro # 友链页
│ ├── rss.xml.ts # RSS 生成
│ └── robots.txt.ts # robots.txt
├── layouts/
│ ├── Layout.astro # 根布局(SEO、主题、Umami 分析、PhotoSwipe
│ └── MainGridLayout.astro # 主网格布局(导航栏、Banner、侧边栏、TOC)
├── plugins/ # Remark/Rehype 插件 + Expressive Code 插件
├── i18n/ # 多语言翻译(en, zh_CN, ja, ko 等)
├── utils/ # 工具函数(content-utils, date-utils, url-utils, setting-utils
├── types/ # TypeScript 类型定义
├── constants/ # 常量(分页大小、主题模式、Banner 高度等)
└── styles/ # CSS 文件(main.css, markdown.css, scrollbar.css 等)
```
## 关键架构信息
### 内容管理
- 文章以 Markdown/MDX 格式存储在 `src/content/posts/`
- 使用 Astro Content Collections 加载,schema 在 `content.config.ts` 中定义
- 文章 frontmatter 包含: `title`, `published`, `description`, `image`, `tags`, `category`, `draft`, `lang`, `showcover`, `customcover`
### 路由
- `[...page].astro` — 首页分页
- `posts/[...slug].astro` — 文章详情页,生成 JSON-LD 结构化数据
- `archive.astro` — 归档,tagscategories
- 自定义页面: `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` 动态计算
+102
View File
@@ -0,0 +1,102 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
基于 [Fuwari](https://github.com/saicaca/fuwari) 的静态博客,使用 Astro 6.1 + TailwindCSS 3.4 + Svelte 5。
- **包管理器**: pnpm 9`preinstall` hook 强制只允许 pnpm
- **语言**: TypeScript (strict)
- **代码规范**: Biome(同时负责 format 和 lint),无 ESLint/Prettier
- **部署**: Cloudflare Pages(见 `wrangler.toml`
## 常用命令
| 命令 | 用途 |
|---|---|
| `pnpm dev` | 启动本地开发服务器 (`localhost:4321`) |
| `pnpm build` | 构建生产版本到 `./dist/` 并运行 Pagefind 索引 |
| `pnpm preview` | 本地预览构建产物 |
| `pnpm check` | 运行 Astro 类型检查 |
| `pnpm type-check` | 运行 TypeScript 类型检查 (`tsc --noEmit`) |
| `pnpm format` | Biome 格式化 `./src` |
| `pnpm lint` | Biome 检查和修复 `./src` |
| `pnpm new-post <filename>` | 在 `src/content/posts/` 创建新文章 Markdown |
| `pnpm astro` | 直接调用 Astro CLI |
## 项目结构
```
src/
├── config.ts # 站点主配置(标题、导航、个人资料、许可证等)
├── content.config.ts # Astro Content Collections 定义(posts + spec
├── content/
│ ├── posts/ # 博客文章(.md / .mdx),通过文件系统集合加载
│ └── spec/ # 特殊页面集合
├── components/
│ ├── widget/ # 侧边栏组件(Profile, TOC, Tags, Categories 等)
│ ├── control/ # 交互控件(BackToTop, Pagination, ButtonLink 等)
│ ├── misc/ # 杂项(ImageWrapper, License, Markdown
│ └── *.astro/*.svelte # 页面级组件(Navbar, Footer, Search, PostPage 等)
├── pages/
│ ├── [...page].astro # 首页分页路由
│ ├── posts/[...slug].astro # 文章详情页
│ ├── archive.astro # 归档页
│ ├── about.astro # 关于页
│ ├── friends.astro # 友链页
│ ├── rss.xml.ts # RSS 生成
│ └── robots.txt.ts # robots.txt
├── layouts/
│ ├── Layout.astro # 根布局(SEO、主题、Umami 分析、PhotoSwipe
│ └── MainGridLayout.astro # 主网格布局(导航栏、Banner、侧边栏、TOC)
├── plugins/ # Remark/Rehype 插件 + Expressive Code 插件
├── i18n/ # 多语言翻译(en, zh_CN, ja, ko 等)
├── utils/ # 工具函数(content-utils, date-utils, url-utils, setting-utils
├── types/ # TypeScript 类型定义
├── constants/ # 常量(分页大小、主题模式、Banner 高度等)
└── styles/ # CSS 文件(main.css, markdown.css, scrollbar.css 等)
```
## 关键架构信息
### 内容管理
- 文章以 Markdown/MDX 格式存储在 `src/content/posts/`
- 使用 Astro Content Collections 加载,schema 在 `content.config.ts` 中定义
- 文章 frontmatter 包含: `title`, `published`, `description`, `image`, `tags`, `category`, `draft`, `lang`, `showcover`, `customcover`
### 路由
- `[...page].astro` — 首页分页
- `posts/[...slug].astro` — 文章详情页,生成 JSON-LD 结构化数据
- `archive.astro` — 归档,tagscategories
- 自定义页面: `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` 动态计算
- 不要执行构建,除非特别要求
+12 -3
View File
@@ -21,12 +21,14 @@ import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.m
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs"; import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js"; import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js";
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js"; import { remarkExcerpt } from "./src/plugins/remark-excerpt.js";
import { remarkPublicImagePaths } from "./src/plugins/remark-public-image-paths.mjs";
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs"; import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs";
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js"; import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js";
import rehypeExternalLinks from 'rehype-external-links';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: "https://adclosenn.top", site: "https://blog.adclosenn.top",
base: "/", base: "/",
trailingSlash: "always", trailingSlash: "always",
integrations: [ integrations: [
@@ -40,7 +42,7 @@ export default defineConfig({
// when the Tailwind class `transition-all` is used // when the Tailwind class `transition-all` is used
containers: ["main", "#toc"], containers: ["main", "#toc"],
smoothScrolling: true, smoothScrolling: true,
cache: true, cache: process.env.NODE_ENV !== "development",
preload: true, preload: true,
accessibility: true, accessibility: true,
updateHead: true, updateHead: true,
@@ -76,7 +78,7 @@ export default defineConfig({
borderRadius: "0.75rem", borderRadius: "0.75rem",
borderColor: "none", borderColor: "none",
codeFontSize: "0.875rem", codeFontSize: "0.875rem",
codeFontFamily: "'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", codeFontFamily: "'Cascadia Mono', 'JetBrains Mono'",
codeLineHeight: "1.5rem", codeLineHeight: "1.5rem",
frames: { frames: {
editorBackground: "var(--codeblock-bg)", editorBackground: "var(--codeblock-bg)",
@@ -107,6 +109,7 @@ export default defineConfig({
remarkMath, remarkMath,
remarkReadingTime, remarkReadingTime,
remarkExcerpt, remarkExcerpt,
remarkPublicImagePaths,
remarkGithubAdmonitionsToDirectives, remarkGithubAdmonitionsToDirectives,
remarkDirective, remarkDirective,
remarkSectionize, remarkSectionize,
@@ -128,6 +131,12 @@ export default defineConfig({
}, },
}, },
], ],
[
rehypeExternalLinks,
{
target: '_blank',
},
],
[ [
rehypeAutolinkHeadings, rehypeAutolinkHeadings,
{ {
+57
View File
@@ -0,0 +1,57 @@
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),
}
+70
View File
@@ -0,0 +1,70 @@
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)
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
aiosqlite>=0.20.0
+31 -29
View File
@@ -6,48 +6,50 @@
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"check": "astro check", "check": "astro check",
"build": "astro build && pagefind --site dist", "build": "astro build --verbose && node scripts/rewrite-built-image-links.js && pagefind --site dist",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"type-check": "tsc --noEmit --isolatedDeclarations", "type-check": "tsc --noEmit --isolatedDeclarations",
"new-post": "node scripts/new-post.js", "new-post": "node scripts/new-post.js",
"summary": "node scripts/summary.js",
"summary:all": "node scripts/summary.js --all",
"summary:force": "node scripts/summary.js --all --force",
"format": "biome format --write ./src", "format": "biome format --write ./src",
"lint": "biome check --write ./src", "lint": "biome check --write ./src",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.8",
"@astrojs/rss": "^4.0.12", "@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.4.2", "@astrojs/sitemap": "^3.7.2",
"@astrojs/svelte": "7.1.0", "@astrojs/svelte": "8.0.5",
"@astrojs/tailwind": "^6.0.2", "@astrojs/tailwind": "^6.0.2",
"@expressive-code/core": "^0.41.3", "@expressive-code/core": "^0.41.7",
"@expressive-code/plugin-collapsible-sections": "^0.41.3", "@expressive-code/plugin-collapsible-sections": "^0.41.7",
"@expressive-code/plugin-line-numbers": "^0.41.3", "@expressive-code/plugin-line-numbers": "^0.41.7",
"@fontsource-variable/jetbrains-mono": "^5.2.6",
"@fontsource/roboto": "^5.2.6",
"@iconify-json/fa6-brands": "^1.2.6", "@iconify-json/fa6-brands": "^1.2.6",
"@iconify-json/fa6-regular": "^1.2.4", "@iconify-json/fa6-regular": "^1.2.4",
"@iconify-json/fa6-solid": "^1.2.4", "@iconify-json/fa6-solid": "^1.2.4",
"@iconify-json/ic": "^1.2.2", "@iconify-json/ic": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.30", "@iconify-json/material-symbols": "^1.2.67",
"@iconify/svelte": "^4.2.0", "@iconify/svelte": "^4.2.0",
"@swup/astro": "^1.7.0", "@swup/astro": "^1.8.0",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.19",
"astro": "5.12.8", "astro": "6.1.8",
"astro-expressive-code": "^0.41.3", "astro-expressive-code": "^0.41.7",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"hastscript": "^9.0.1", "hastscript": "^9.0.1",
"katex": "^0.16.22", "katex": "^0.16.45",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.1",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"node-html-parser": "^7.0.1", "node-html-parser": "^7.1.0",
"overlayscrollbars": "^2.11.4", "overlayscrollbars": "^2.15.1",
"pagefind": "^1.3.0", "pagefind": "^1.5.2",
"photoswipe": "^5.4.4", "photoswipe": "^5.4.4",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-components": "^0.3.0", "rehype-components": "^0.3.0",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-directive": "^3.0.1", "remark-directive": "^3.0.1",
@@ -55,22 +57,22 @@
"remark-github-admonitions-to-directives": "^1.0.5", "remark-github-admonitions-to-directives": "^1.0.5",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0", "remark-sectionize": "^2.1.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.3",
"sharp": "^0.34.3", "sharp": "^0.34.5",
"stylus": "^0.64.0", "stylus": "^0.64.0",
"svelte": "^5.37.3", "svelte": "^5.55.4",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.19",
"typescript": "^5.9.2", "typescript": "^5.9.3",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/ts-plugin": "^1.10.4", "@astrojs/ts-plugin": "^1.10.7",
"@biomejs/biome": "2.1.3", "@biomejs/biome": "2.1.3",
"@rollup/plugin-yaml": "^4.1.2", "@rollup/plugin-yaml": "^4.1.2",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/mdast": "^4.0.4", "@types/mdast": "^4.0.4",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.1",
"postcss-import": "^16.1.1", "postcss-import": "^16.1.1",
"postcss-nesting": "^13.0.2" "postcss-nesting": "^13.0.2"
}, },
+2871 -3348
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
<!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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+2
View File
@@ -0,0 +1,2 @@
<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>

After

Width:  |  Height:  |  Size: 600 B

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 786 KiB

After

Width:  |  Height:  |  Size: 786 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 786 KiB

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Before

Width:  |  Height:  |  Size: 710 KiB

After

Width:  |  Height:  |  Size: 710 KiB

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Some files were not shown because too many files have changed in this diff Show More