sixtarocyan_smart-folder-view/main.js

2049 lines
91 KiB
JavaScript
Raw Normal View History

var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
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 __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
// src/main.ts
var obsidian = __toESM(require("obsidian"));
var VIEW_TYPE = "smart-folder-view";
var FILTER_STORAGE_PREFIX = "smart-folder-view-filter:";
var FILTER_PRESET_STORAGE_PREFIX = "smart-folder-view-presets:";
var MANUAL_ORDER_STORAGE_PREFIX = "smart-folder-view-manual-order:";
var EMPTY_OPTION_VALUE = "__SFV_EMPTY__";
var DEFAULT_COLOR_CONFIG = {
2026-05-03 09:49:29 +00:00
saturationBase: 55,
saturationRange: 20,
lightnessBase: 44,
lightnessRange: 14,
fallbackColor: "var(--interactive-accent)"
2026-05-03 09:49:29 +00:00
};
var I18N = {
2026-05-03 09:49:29 +00:00
zh: {
title: "Smart Folder View",
openBuilder: "\u65B0\u5EFA Smart Folder \u9875\u9762",
openManager: "\u7BA1\u7406\u5DF2\u4FDD\u5B58\u9875\u9762",
openLast: "\u6253\u5F00\u4E0A\u6B21\u9875\u9762",
noProfile: "\u8FD8\u6CA1\u6709\u4FDD\u5B58\u4EFB\u4F55\u9875\u9762\uFF0C\u8BF7\u5148\u65B0\u5EFA\u3002",
setupTitle: "\u914D\u7F6E\u9875\u9762",
setupDesc: "\u9875\u9762\u8BBE\u7F6E\u7528\u4E8E\u951A\u5B9A\u6570\u636E\u6765\u6E90\uFF1A\u6587\u4EF6\u5939\u3001\u6A21\u677F\u6587\u4EF6\u3001\u53EF\u7B5B\u9009\u5C5E\u6027\u3002\u8BBE\u7F6E\u540E\u5148\u9884\u89C8\uFF0C\u518D\u4FDD\u5B58\u3002",
profileName: "\u9875\u9762\u540D\u79F0",
sourceFolder: "\u6765\u6E90\u6587\u4EF6\u5939",
sourceFolderDesc: "\u4ECE\u8BE5\u6587\u4EF6\u5939\u8BFB\u53D6\u5361\u7247\u6570\u636E",
templateFile: "\u6A21\u677F\u6587\u4EF6",
templateFileDesc: "\u4ECE\u5168\u5E93\u9009\u62E9\u6A21\u677F\u6587\u4EF6\u8BFB\u53D6\u5143\u6570\u636E\u5B57\u6BB5\uFF08\u5E76\u878D\u5408\u8BE5\u6587\u4EF6\u5939\u5171\u7528\u5B57\u6BB5\uFF09",
colorField: "\u989C\u8272\u5B57\u6BB5",
sortField: "\u6392\u5E8F\u5B57\u6BB5",
displayFields: "\u5C55\u793A\u5B57\u6BB5",
sortOrder: "\u6392\u5E8F\u65B9\u5411",
viewMode: "\u89C6\u56FE\u6A21\u5F0F",
boardField: "\u770B\u677F\u5206\u7EC4\u5B57\u6BB5",
preview: "\u9884\u89C8",
saveAndOpen: "\u4FDD\u5B58\u5E76\u6253\u5F00",
choose: "(\u8BF7\u9009\u62E9)",
rootFolder: "(\u6839\u6587\u4EF6\u5939)",
timeline: "\u65F6\u95F4\u8F74",
board: "\u770B\u677F",
asc: "\u5347\u5E8F\uFF08\u65E7\u2192\u65B0\uFF09",
desc: "\u5012\u5E8F\uFF08\u65B0\u2192\u65E7\uFF09",
savePage: "\u4FDD\u5B58\u9875\u9762",
updatePage: "\u66F4\u65B0\u9875\u9762",
openBuilderBtn: "\u65B0\u5EFA\u9875\u9762",
openManagerBtn: "\u9875\u9762\u7BA1\u7406",
startActionTitle: "Smart Folder",
startActionDesc: "\u8BF7\u9009\u62E9\u64CD\u4F5C\uFF1A",
startOpenPage: "\u6253\u5F00\u9875\u9762",
startNewPage: "\u65B0\u5EFA\u9875\u9762",
pageSaved: "\u9875\u9762\u5DF2\u4FDD\u5B58",
pageUpdated: "\u9875\u9762\u5DF2\u66F4\u65B0",
saveFailed: "\u9875\u9762\u4FDD\u5B58\u5931\u8D25",
pageNamePrompt: "\u8BF7\u8F93\u5165\u9875\u9762\u540D\u79F0",
managerTitle: "\u5DF2\u4FDD\u5B58\u9875\u9762",
open: "\u6253\u5F00",
edit: "\u7F16\u8F91",
delete: "\u5220\u9664",
deleteConfirm: "\u786E\u8BA4\u5220\u9664\u9875\u9762\uFF1A{{name}}\uFF1F",
noFiles: '\u6587\u4EF6\u5939 "{{folder}}" \u91CC\u6682\u65E0\u7B14\u8BB0\u3002',
matched: "\u5339\u914D\u5230 {{count}} \u6761\u8BB0\u5F55",
noMatch: "\u6CA1\u6709\u5339\u914D\u7B5B\u9009\u6761\u4EF6\u7684\u7B14\u8BB0\u3002",
preset: "\u9884\u8BBE",
presetScopeDesc: "\u9884\u8BBE\u53EA\u4FDD\u5B58\u5F53\u524D\u9875\u9762\u4E0B\u7684\u53EF\u53D8\u89C6\u56FE\u72B6\u6001\uFF1A\u7B5B\u9009\u3001\u6392\u5E8F\u3001\u663E\u793A\u6A21\u5F0F\uFF08\u65F6\u95F4\u8F74/\u770B\u677F\uFF09\u3002",
noPreset: "(\u4E0D\u4F7F\u7528\u9884\u8BBE)",
savePreset: "\u4FDD\u5B58\u9884\u8BBE",
delPreset: "\u5220\u9664\u9884\u8BBE",
presetPrompt: "\u9884\u8BBE\u540D\u79F0",
presetNameRequired: "\u8BF7\u8F93\u5165\u9884\u8BBE\u540D\u79F0",
presetSaveHint: "\u8BF4\u660E\uFF1A\u540D\u79F0\u4E0D\u53D8=\u8986\u76D6\u66F4\u65B0\u5F53\u524D\u9884\u8BBE\uFF1B\u540D\u79F0\u6539\u4E3A\u65B0\u540D\u5B57=\u53E6\u5B58\u4E3A\u65B0\u9884\u8BBE\uFF0C\u65E7\u9884\u8BBE\u4FDD\u6301\u4E0D\u53D8\u3002",
clear: "\u6E05\u7A7A",
noneSelected: "(\u5168\u4E0D\u9009)",
allText: "(\u5168\u90E8)",
selectAll: "\u5168\u9009",
selectNone: "\u5168\u4E0D\u9009",
selected: "\u5DF2\u9009 {{count}} \u9879",
allWhenEmpty: "\u672A\u9009\u62E9\uFF0C\u7B49\u540C\u4E8E\u5168\u90E8",
search: "\u641C\u7D22\u9009\u9879...",
saveOrder: "\u4FDD\u5B58\u5F53\u524D\u6392\u5E8F",
saveOrderDone: "\u5DF2\u4FDD\u5B58\u5F53\u524D\u987A\u5E8F",
resetOrder: "\u6062\u590D\u9ED8\u8BA4\u987A\u5E8F",
resetOrderDone: "\u5DF2\u6062\u590D\u9ED8\u8BA4\u987A\u5E8F",
undoLastAction: "\u64A4\u9500\u4E0A\u4E00\u6B65",
undoDone: "\u5DF2\u64A4\u9500\u4E0A\u4E00\u6B65\u64CD\u4F5C",
undoNone: "\u6CA1\u6709\u53EF\u64A4\u9500\u7684\u64CD\u4F5C",
boardUnset: "(\u672A\u8BBE\u7F6E)",
emptyValue: "\u7A7A\u503C",
moveAsk: '\u8DE8\u5217\u62D6\u62FD\uFF1A\u662F\u5426\u5C06 {{field}} \u66F4\u65B0\u4E3A "{{value}}"\uFF1F\n\u786E\u5B9A=\u81EA\u52A8\u63A8\u8350\uFF0C\u53D6\u6D88=\u624B\u52A8\u8F93\u5165',
moveManual: "\u8BF7\u8F93\u5165 {{field}} \u65B0\u503C\uFF08\u7559\u7A7A\u6E05\u9664\uFF09\uFF1A",
moveDone: "\u5C5E\u6027\u5DF2\u66F4\u65B0\uFF1A{{field}} = {{value}}",
moveFail: "\u5C5E\u6027\u66F4\u65B0\u5931\u8D25",
timelineMoveTitle: "\u65F6\u95F4\u8F74\u62D6\u52A8",
timelineMoveChoice: "\u8BF7\u9009\u62E9\u62D6\u52A8\u540E\u7684\u5904\u7406\u65B9\u5F0F\uFF08\u5B57\u6BB5\uFF1A{{field}}\uFF09",
timelineMoveCancel: "\u53D6\u6D88\uFF08\u4E0D\u4FDD\u5B58\u4FEE\u6539\uFF09",
timelineMoveOrderOnly: "\u4EC5\u66F4\u65B0\u65F6\u95F4\u8F74\u987A\u5E8F",
timelineMoveOrderAndProp: "\u66F4\u65B0\u65F6\u95F4\u8F74\u548C\u5C5E\u6027",
timelineUpdatePrompt: "\u8BF7\u8F93\u5165 {{field}} \u65B0\u503C\uFF08\u7559\u7A7A\u6E05\u9664\uFF09\uFF1A",
timelineMoveDone: "\u5DF2\u66F4\u65B0\u65F6\u95F4\u8F74\u987A\u5E8F",
timelineMoveNoChange: "\u5DF2\u53D6\u6D88\uFF0C\u672A\u4FDD\u5B58\u4FEE\u6539",
boardMoveTitle: "\u770B\u677F\u62D6\u52A8",
boardMoveChoice: "\u8BF7\u9009\u62E9\u62D6\u52A8\u540E\u7684\u5904\u7406\u65B9\u5F0F\uFF08\u5B57\u6BB5\uFF1A{{field}}\uFF09",
boardMoveCancel: "\u53D6\u6D88\uFF08\u4E0D\u4FDD\u5B58\u4FEE\u6539\uFF09",
boardMoveOrderOnly: "\u4EC5\u66F4\u65B0\u770B\u677F\u987A\u5E8F",
boardMoveOrderAndProp: "\u66F4\u65B0\u770B\u677F\u987A\u5E8F\u548C\u5C5E\u6027",
boardMoveDone: "\u5DF2\u66F4\u65B0\u770B\u677F\u987A\u5E8F",
boardMoveNoChange: "\u5DF2\u53D6\u6D88\uFF0C\u672A\u4FDD\u5B58\u4FEE\u6539",
savePreviewOnClose: "\u5F53\u524D\u4E3A\u9884\u89C8\u914D\u7F6E\uFF0C\u5173\u95ED\u524D\u662F\u5426\u4FDD\u5B58\u4E3A\u9875\u9762\uFF1F",
refreshPreview: "\u5237\u65B0\u9884\u89C8",
refreshPreviewDone: "\u5361\u7247\u9884\u89C8\u5DF2\u5237\u65B0",
exportPage: "\u5BFC\u51FA\u9875\u9762",
exportFolderTitle: "\u9009\u62E9\u5BFC\u51FA\u6587\u4EF6\u5939",
exportFolderDesc: "\u8BF7\u9009\u62E9\u5BFC\u51FA\u5230\u54EA\u4E2A\u6587\u4EF6\u5939\uFF08\u53EF\u9009\u6839\u6587\u4EF6\u5939\uFF09",
exportFolderConfirm: "\u5BFC\u51FA\u5230\u6B64\u6587\u4EF6\u5939",
exportPageDone: "\u9875\u9762\u914D\u7F6E\u5DF2\u5BFC\u51FA\uFF1A{{path}}",
exportPageFailed: "\u9875\u9762\u5BFC\u51FA\u5931\u8D25",
exportBlockTitle: "Smart Folder \u9875\u9762\u5BFC\u51FA",
exportBlockDesc: "\u70B9\u51FB\u4E0B\u65B9\u6309\u94AE\u53EF\u6253\u5F00\u8BE5\u9875\u9762\u914D\u7F6E\u3002",
exportBlockOpen: "\u6253\u5F00\u6B64\u9875\u9762",
exportBlockImport: "\u5BFC\u5165\u5E76\u4FDD\u5B58\u5230\u9875\u9762\u7BA1\u7406",
exportBlockInvalid: "\u5BFC\u51FA\u5757\u89E3\u6790\u5931\u8D25",
colorSettings: "\u989C\u8272\u8BBE\u7F6E",
saturationBase: "\u9971\u548C\u5EA6\u57FA\u7EBF",
saturationRange: "\u9971\u548C\u5EA6\u6CE2\u52A8",
lightnessBase: "\u4EAE\u5EA6\u57FA\u7EBF",
lightnessRange: "\u4EAE\u5EA6\u6CE2\u52A8",
fallbackColor: "\u9ED8\u8BA4\u989C\u8272",
resetDefault: "\u6062\u590D\u9ED8\u8BA4",
languageMode: "\u754C\u9762\u8BED\u8A00",
languageAuto: "\u81EA\u52A8\uFF08\u8DDF\u968F Obsidian\uFF09",
languageZh: "\u4E2D\u6587",
languageEn: "English",
languageModeDesc: "\u9ED8\u8BA4\u81EA\u52A8\u5339\u914D\uFF1B\u4F60\u4E5F\u53EF\u4EE5\u624B\u52A8\u56FA\u5B9A\u8BED\u8A00\u3002"
2026-05-03 09:49:29 +00:00
},
en: {
title: "Smart Folder View",
openBuilder: "Create Smart Folder page",
openManager: "Manage saved pages",
openLast: "Open last page",
noProfile: "No saved pages yet. Please create one first.",
setupTitle: "Configure page",
setupDesc: "Page settings anchor data source: folder, template file, and filterable fields. Preview first, then save.",
profileName: "Page name",
sourceFolder: "Source folder",
sourceFolderDesc: "Load cards from this folder",
templateFile: "Template file",
templateFileDesc: "Choose a template file from the whole vault; fields come from it plus common folder fields",
colorField: "Color field",
sortField: "Sort field",
displayFields: "Display fields",
sortOrder: "Sort order",
viewMode: "View mode",
boardField: "Board group field",
preview: "Preview",
saveAndOpen: "Save and Open",
choose: "(Choose)",
rootFolder: "(Vault Root)",
timeline: "Timeline",
board: "Board",
asc: "Ascending (old -> new)",
desc: "Descending (new -> old)",
savePage: "Save page",
updatePage: "Update page",
openBuilderBtn: "Create page",
openManagerBtn: "Page manager",
startActionTitle: "Smart Folder",
startActionDesc: "Choose an action:",
startOpenPage: "Open page",
startNewPage: "Create page",
pageSaved: "Page saved",
pageUpdated: "Page updated",
saveFailed: "Failed to save page",
pageNamePrompt: "Enter page name",
managerTitle: "Saved pages",
open: "Open",
edit: "Edit",
delete: "Delete",
deleteConfirm: "Delete page: {{name}}?",
2026-05-03 09:49:29 +00:00
noFiles: 'No notes found in "{{folder}}".',
matched: "{{count}} notes matched",
noMatch: "No notes match current filters.",
preset: "Preset",
presetScopeDesc: "Presets only save mutable view state in this page: filters, sort, and view mode (timeline/board).",
noPreset: "(No preset)",
savePreset: "Save preset",
delPreset: "Delete preset",
presetPrompt: "Preset name",
presetNameRequired: "Please enter a preset name",
presetSaveHint: "Rule: same name = update this preset; new name = save as a new preset and keep the old one unchanged.",
clear: "Clear",
noneSelected: "(None selected)",
allText: "(All)",
selectAll: "Select all",
selectNone: "Select none",
selected: "{{count}} selected",
allWhenEmpty: "Nothing selected, equivalent to all",
search: "Search options...",
saveOrder: "Save current order",
saveOrderDone: "Current order saved",
resetOrder: "Reset default order",
resetOrderDone: "Default order restored",
undoLastAction: "Undo last action",
undoDone: "Last action undone",
undoNone: "Nothing to undo",
boardUnset: "(Unset)",
emptyValue: "empty",
2026-05-03 09:49:29 +00:00
moveAsk: 'Cross-column move: update {{field}} to "{{value}}"?\nOK=recommended, Cancel=manual input',
moveManual: "Enter new value for {{field}} (empty to clear):",
moveDone: "Updated: {{field}} = {{value}}",
moveFail: "Failed to update property",
timelineMoveTitle: "Timeline move",
timelineMoveChoice: "Choose how to apply this move (field: {{field}})",
timelineMoveCancel: "Cancel (do not save)",
timelineMoveOrderOnly: "Update timeline order only",
timelineMoveOrderAndProp: "Update timeline order and property",
timelineUpdatePrompt: "Enter new value for {{field}} (empty to clear):",
timelineMoveDone: "Timeline order updated",
timelineMoveNoChange: "Canceled, no changes saved",
boardMoveTitle: "Board move",
boardMoveChoice: "Choose how to apply this move (field: {{field}})",
boardMoveCancel: "Cancel (do not save)",
boardMoveOrderOnly: "Update board order only",
boardMoveOrderAndProp: "Update board order and property",
boardMoveDone: "Board order updated",
boardMoveNoChange: "Canceled, no changes saved",
savePreviewOnClose: "This is a preview configuration. Save it before closing?",
refreshPreview: "Refresh preview",
refreshPreviewDone: "Card preview refreshed",
exportPage: "Export page",
exportFolderTitle: "Choose export folder",
exportFolderDesc: "Select where to export (including vault root).",
exportFolderConfirm: "Export here",
exportPageDone: "Page config exported: {{path}}",
exportPageFailed: "Failed to export page",
exportBlockTitle: "Smart Folder page export",
exportBlockDesc: "Click the button below to open this page configuration.",
exportBlockOpen: "Open this page",
exportBlockImport: "Import and save to page manager",
exportBlockInvalid: "Failed to parse export block",
colorSettings: "Color settings",
saturationBase: "Saturation base",
saturationRange: "Saturation range",
lightnessBase: "Lightness base",
lightnessRange: "Lightness range",
fallbackColor: "Fallback color",
resetDefault: "Reset default",
languageMode: "UI language",
languageAuto: "Auto (follow Obsidian)",
languageZh: "Chinese",
languageEn: "English",
languageModeDesc: "Auto by default; you can also force a fixed language."
}
2026-05-03 09:49:29 +00:00
};
function tpl(text, vars) {
if (!vars) return text;
return text.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(vars[key] ?? ""));
2026-05-03 09:49:29 +00:00
}
function detectLanguage() {
try {
const locale = window.moment?.locale?.() || "";
if (locale.toLowerCase().startsWith("zh")) return "zh";
} catch {
}
return "en";
2026-05-03 09:49:29 +00:00
}
function newId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function hashCode(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
2026-05-03 09:49:29 +00:00
h |= 0;
}
return Math.abs(h);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function colorForValue(value, config) {
if (!value) return null;
const hash = hashCode(value);
const h = hash % 360;
const s = clamp(config.saturationBase + hash % Math.max(1, config.saturationRange), 20, 90);
const l = clamp(config.lightnessBase + hash % Math.max(1, config.lightnessRange), 20, 80);
2026-05-03 09:49:29 +00:00
return `hsl(${h} ${s}% ${l}%)`;
}
function uniqueSorted(items) {
return [...new Set(items)].sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
2026-05-03 09:49:29 +00:00
}
function toFlatArray(value) {
if (value == null) return [];
if (Array.isArray(value)) return value.flatMap((v) => toFlatArray(v));
if (typeof value === "string") return [value.trim()].filter(Boolean);
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return [`${value}`.trim()].filter(Boolean);
}
return [];
2026-05-03 09:49:29 +00:00
}
function getFrontmatterValues(cache, key) {
if (!cache?.frontmatter) return [];
return toFlatArray(cache.frontmatter[key]);
}
function getAllFrontmatterKeys(caches) {
const keys = /* @__PURE__ */ new Set();
2026-05-03 09:49:29 +00:00
for (const cache of caches) {
if (cache?.frontmatter) {
for (const key of Object.keys(cache.frontmatter)) {
if (key !== "position") keys.add(key);
2026-05-03 09:49:29 +00:00
}
}
}
return uniqueSorted([...keys]);
}
function stripFrontmatter(text) {
return text.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
2026-05-03 09:49:29 +00:00
}
function parseDateLike(value) {
const raw = String(value || "").trim();
2026-05-03 09:49:29 +00:00
if (!raw) return null;
const toChrono = (year, month = 1, day = 1) => {
const safeMonth = clamp(Number(month) || 1, 1, 12);
const safeDay = clamp(Number(day) || 1, 1, 31);
return Number(year) + (safeMonth - 1) / 12 + (safeDay - 1) / 366;
};
const compact = raw.replace(/[,\s]/g, "");
const plainNumber = compact.match(/^-?\d+(?:\.\d+)?$/);
if (plainNumber) return Number(plainNumber[0]);
const relative = compact.match(/^(?:约|大约|距今)?(?:公元前|前)?(\d+(?:\.\d+)?)(亿|万)?年?(?:前)$/i) || compact.match(/^(?:约|大约)?(?:公元前|前)(\d+(?:\.\d+)?)(亿|万)?年?$/i);
if (relative) {
const unit = relative[2] === "\u4EBF" ? 1e8 : relative[2] === "\u4E07" ? 1e4 : 1;
return -Number(relative[1]) * unit;
}
const centuryDecade = compact.match(/^(\d{1,2})世纪(\d{1,2})年代$/);
if (centuryDecade) return (Number(centuryDecade[1]) - 1) * 100 + Number(centuryDecade[2]);
const decade = compact.match(/^(-?\d{1,9})年代$/);
if (decade) return Number(decade[1]);
const normalized = raw.replace(/[年./]/g, "-").replace(/月/g, "-").replace(/日/g, "").replace(/\s+/g, "").trim();
const ymd = normalized.match(/^(-?\d{1,9})(?:-(\d{1,4})(?:-(\d{1,2}))?)?/);
if (ymd) {
const year = Number(ymd[1]);
const second = ymd[2] ? Number(ymd[2]) : null;
if (second == null || second > 12) return year;
return toChrono(year, second, ymd[3] || 1);
}
2026-05-03 09:49:29 +00:00
return null;
}
function isChronologicalSortField(field) {
const normalized = String(field || "").toLowerCase().replace(/\s+/g, "");
return /时间|日期|发生于|年份|年代|date|time|year|created|updated/.test(normalized);
}
function parseSortValue(value, sortField = "") {
const raw = String(value == null ? "" : value).trim();
if (isChronologicalSortField(sortField)) {
const ts = parseDateLike(raw);
if (ts != null) return { kind: "number", value: ts, raw };
}
const numericText = raw.replace(/,/g, "");
if (/^-?\d+(\.\d+)?$/.test(numericText)) return { kind: "number", value: Number(numericText), raw };
return { kind: "string", value: raw, raw };
2026-05-03 09:49:29 +00:00
}
function clonePreset(preset) {
return {
name: String(preset?.name || ""),
2026-05-03 09:49:29 +00:00
enabledFilters: Array.isArray(preset?.enabledFilters) ? [...preset.enabledFilters] : [],
selectedByAttr: JSON.parse(JSON.stringify(preset?.selectedByAttr || {})),
sortOrder: preset?.sortOrder === "asc" ? "asc" : "desc",
viewMode: preset?.viewMode === "board" ? "board" : "timeline",
boardField: typeof preset?.boardField === "string" ? preset.boardField : ""
2026-05-03 09:49:29 +00:00
};
}
function clonePresetList(presets) {
return (Array.isArray(presets) ? presets : []).map(clonePreset);
}
function cloneSelectedByAttr(state) {
return JSON.parse(JSON.stringify(state || {}));
}
function isSmartFolderExportCache(cache) {
const fm = cache?.frontmatter;
return fm?.type === "smart-folder-view-page-export" || fm?.["smart foldertype"] === "smart-folder-view-page-export";
}
function isLikelySmartFolderExportPath(path) {
const name = String(path || "").split("/").pop()?.toLowerCase() || "";
return name.endsWith(".smart-folder-page.md") || /smart[-_\s]*folder[-_\s]*page/.test(name) || /smart[-_\s]*foder[-_\s]*page/.test(name);
}
2026-05-03 09:49:29 +00:00
function makeSelectSearchable(selectEl, placeholder) {
void selectEl;
void placeholder;
2026-05-03 09:49:29 +00:00
}
var SetupModal = class extends obsidian.Modal {
2026-05-03 09:49:29 +00:00
constructor(app, plugin, profile) {
super(app);
this.plugin = plugin;
this.hasPreviewedDraft = false;
this.skipCloseSavePrompt = false;
const folders = this.plugin.getFolderOptions();
const sourceFolder = profile?.sourceFolder || folders[0] || "";
const template = profile?.templateFile || this.plugin.getTemplateFileOptions()[0] || "";
2026-05-03 09:49:29 +00:00
this.draft = profile ? { ...profile, displayFields: [...profile.displayFields] } : {
id: newId(),
name: "",
2026-05-03 09:49:29 +00:00
sourceFolder,
templateFile: template,
colorField: "",
2026-05-03 09:49:29 +00:00
displayFields: [],
sortField: "",
sortOrder: "desc",
viewMode: "timeline",
boardField: "\u72B6\u6001",
2026-05-03 09:49:29 +00:00
createdAt: Date.now(),
updatedAt: Date.now()
2026-05-03 09:49:29 +00:00
};
}
render() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: this.plugin.t("setupTitle") });
contentEl.createEl("p", { text: this.plugin.t("setupDesc") });
2026-05-03 09:49:29 +00:00
const folders = this.plugin.getFolderOptions();
if (!folders.includes(this.draft.sourceFolder)) this.draft.sourceFolder = folders[0] || "";
2026-05-03 09:49:29 +00:00
const templates = this.plugin.getTemplateFileOptions();
if (!templates.includes(this.draft.templateFile)) this.draft.templateFile = templates[0] || "";
const fieldOptions = this.plugin.getFieldCandidates(this.draft.sourceFolder, this.draft.templateFile, this.draft.sourceNotePath);
if (!fieldOptions.includes(this.draft.colorField)) this.draft.colorField = fieldOptions[0] || "";
if (!fieldOptions.includes(this.draft.sortField)) this.draft.sortField = fieldOptions[0] || "";
2026-05-03 09:49:29 +00:00
if (!fieldOptions.includes(this.draft.boardField)) {
this.draft.boardField = fieldOptions.includes("\u72B6\u6001") ? "\u72B6\u6001" : fieldOptions[0] || "";
2026-05-03 09:49:29 +00:00
}
this.draft.displayFields = this.draft.displayFields.filter((k) => fieldOptions.includes(k));
2026-05-03 09:49:29 +00:00
if (!this.draft.displayFields.length) this.draft.displayFields = fieldOptions.slice(0, 4);
new obsidian.Setting(contentEl).setName(this.plugin.t("profileName")).addText((t) => t.setValue(this.draft.name).onChange((v) => {
this.draft.name = v.trim();
}));
new obsidian.Setting(contentEl).setName(this.plugin.t("sourceFolder")).setDesc(this.plugin.t("sourceFolderDesc")).addDropdown((d) => {
for (const f of folders) d.addOption(f, f || this.plugin.t("rootFolder"));
d.setValue(this.draft.sourceFolder || "");
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange((v) => {
this.draft.sourceFolder = v;
this.render();
2026-05-03 09:49:29 +00:00
});
});
new obsidian.Setting(contentEl).setName(this.plugin.t("templateFile")).setDesc(this.plugin.t("templateFileDesc")).addDropdown((d) => {
d.addOption("", this.plugin.t("choose"));
for (const f of templates) d.addOption(f, f);
d.setValue(this.draft.templateFile || "");
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange((v) => {
this.draft.templateFile = v;
this.render();
2026-05-03 09:49:29 +00:00
});
});
new obsidian.Setting(contentEl).setName(this.plugin.t("colorField")).addDropdown((d) => {
d.addOption("", this.plugin.t("choose"));
for (const k of fieldOptions) d.addOption(k, k);
d.setValue(this.draft.colorField || "");
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange((v) => {
this.draft.colorField = v;
2026-05-03 09:49:29 +00:00
});
});
new obsidian.Setting(contentEl).setName(this.plugin.t("sortField")).addDropdown((d) => {
d.addOption("", this.plugin.t("choose"));
for (const k of fieldOptions) d.addOption(k, k);
d.setValue(this.draft.sortField || "");
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange((v) => {
this.draft.sortField = v;
2026-05-03 09:49:29 +00:00
});
});
new obsidian.Setting(contentEl).setName(this.plugin.t("displayFields")).addText((t) => t.setValue(this.draft.displayFields.join(", ")).onChange((v) => {
this.draft.displayFields = v.split(",").map((s) => s.trim()).filter(Boolean);
}));
new obsidian.Setting(contentEl).setName(this.plugin.t("sortOrder")).addDropdown((d) => {
d.addOption("desc", this.plugin.t("desc"));
d.addOption("asc", this.plugin.t("asc"));
d.setValue(this.draft.sortOrder);
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange((v) => {
this.draft.sortOrder = v;
2026-05-03 09:49:29 +00:00
});
});
new obsidian.Setting(contentEl).setName(this.plugin.t("viewMode")).addDropdown((d) => {
d.addOption("timeline", this.plugin.t("timeline"));
d.addOption("board", this.plugin.t("board"));
d.setValue(this.draft.viewMode);
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange((v) => {
this.draft.viewMode = v === "board" ? "board" : "timeline";
this.render();
2026-05-03 09:49:29 +00:00
});
});
new obsidian.Setting(contentEl).setName(this.plugin.t("boardField")).addDropdown((d) => {
d.addOption("", this.plugin.t("choose"));
for (const k of fieldOptions) d.addOption(k, k);
d.setValue(this.draft.boardField || "");
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange((v) => {
this.draft.boardField = v;
2026-05-03 09:49:29 +00:00
});
});
const action = contentEl.createEl("div");
action.addClass("sfv-setup-action");
action.createEl("button", { text: this.plugin.t("preview") }).addEventListener("click", () => {
2026-05-03 09:49:29 +00:00
this.plugin.setDraftProfile(this.draft);
void this.plugin.activateView();
2026-05-03 09:49:29 +00:00
this.hasPreviewedDraft = true;
});
const saveBtn = action.createEl("button", { text: this.plugin.t("saveAndOpen") });
saveBtn.addClass("mod-cta");
saveBtn.addEventListener("click", () => {
void (async () => {
await this.plugin.saveProfile(this.draft, true, false);
await this.plugin.activateView({ profile: this.draft });
this.skipCloseSavePrompt = true;
this.close();
})();
2026-05-03 09:49:29 +00:00
});
}
onOpen() {
this.render();
}
onClose() {
2026-05-03 09:49:29 +00:00
this.contentEl.empty();
if (this.skipCloseSavePrompt || !this.hasPreviewedDraft) return;
void askConfirm(this.app, this.plugin.t("savePreviewOnClose")).then((ok) => {
if (!ok) return;
void this.plugin.saveProfile(this.draft, true, false).then(
() => void this.plugin.activateView({ profile: this.draft })
);
});
2026-05-03 09:49:29 +00:00
}
};
var TextInputModal = class extends obsidian.Modal {
2026-05-03 09:49:29 +00:00
constructor(app, title, placeholder, initial, onSubmit, hint) {
super(app);
this.titleText = title;
this.hintText = hint;
this.placeholderText = placeholder;
this.initialValue = initial;
this.onSubmitValue = onSubmit;
this.resolved = false;
}
submit(value) {
if (this.resolved) return;
this.resolved = true;
this.onSubmitValue(value);
2026-05-03 09:49:29 +00:00
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", { text: this.titleText });
if (this.hintText) contentEl.createEl("p", { text: this.hintText }).addClass("sfv-modal-hint");
const input = contentEl.createEl("input");
input.type = "text";
2026-05-03 09:49:29 +00:00
input.placeholder = this.placeholderText;
input.value = this.initialValue;
input.addClass("sfv-modal-input");
const action = contentEl.createEl("div");
action.addClass("sfv-modal-action");
action.createEl("button", { text: "Cancel" }).addEventListener("click", () => {
this.submit(null);
2026-05-03 09:49:29 +00:00
this.close();
});
const okBtn = action.createEl("button", { text: "OK" });
okBtn.addClass("mod-cta");
okBtn.addEventListener("click", () => {
this.submit(input.value.trim());
2026-05-03 09:49:29 +00:00
this.close();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
2026-05-03 09:49:29 +00:00
e.preventDefault();
this.submit(input.value.trim());
2026-05-03 09:49:29 +00:00
this.close();
}
});
window.setTimeout(() => input.focus(), 0);
}
onClose() {
this.submit(null);
2026-05-03 09:49:29 +00:00
this.contentEl.empty();
}
};
2026-05-03 09:49:29 +00:00
function askTextInput(app, title, placeholder, initial, hint) {
return new Promise((resolve) => {
2026-05-03 09:49:29 +00:00
new TextInputModal(app, title, placeholder, initial, resolve, hint).open();
});
}
var ThreeChoiceModal = class extends obsidian.Modal {
2026-05-03 09:49:29 +00:00
constructor(app, title, message, labels, onSubmit) {
super(app);
this.titleText = title;
this.messageText = message;
this.labels = labels;
this.onSubmitValue = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", { text: this.titleText });
contentEl.createEl("p", { text: this.messageText }).addClass("sfv-modal-hint");
const action = contentEl.createEl("div");
action.addClass("sfv-modal-action");
2026-05-03 09:49:29 +00:00
const addChoice = (key, label, isCta = false) => {
if (!label) return;
const btn = action.createEl("button", { text: label });
if (isCta) btn.addClass("mod-cta");
btn.addEventListener("click", () => {
2026-05-03 09:49:29 +00:00
this.onSubmitValue(key);
this.close();
});
};
addChoice("cancel", this.labels.cancel);
addChoice("order", this.labels.order);
addChoice("order+prop", this.labels.orderAndProp, true);
2026-05-03 09:49:29 +00:00
}
onClose() {
this.contentEl.empty();
}
};
2026-05-03 09:49:29 +00:00
function askThreeChoices(app, title, message, labels) {
return new Promise((resolve) => {
2026-05-03 09:49:29 +00:00
new ThreeChoiceModal(app, title, message, labels, resolve).open();
});
}
var StartActionModal = class extends obsidian.Modal {
2026-05-03 09:49:29 +00:00
constructor(app, plugin, onSubmit) {
super(app);
this.plugin = plugin;
this.onSubmitValue = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", { text: this.plugin.t("startActionTitle") });
contentEl.createEl("p", { text: this.plugin.t("startActionDesc") }).addClass("sfv-modal-hint");
const action = contentEl.createEl("div");
action.addClass("sfv-modal-action");
action.createEl("button", { text: this.plugin.t("startOpenPage") }).addEventListener("click", () => {
this.onSubmitValue("open");
2026-05-03 09:49:29 +00:00
this.close();
});
const createBtn = action.createEl("button", { text: this.plugin.t("startNewPage") });
createBtn.addClass("mod-cta");
createBtn.addEventListener("click", () => {
this.onSubmitValue("create");
2026-05-03 09:49:29 +00:00
this.close();
});
}
onClose() {
this.contentEl.empty();
}
};
2026-05-03 09:49:29 +00:00
function askStartAction(app, plugin) {
return new Promise((resolve) => {
2026-05-03 09:49:29 +00:00
new StartActionModal(app, plugin, resolve).open();
});
}
var FolderSelectModal = class extends obsidian.Modal {
2026-05-03 09:49:29 +00:00
constructor(app, plugin, folders, initialValue, onSubmit) {
super(app);
this.plugin = plugin;
this.folders = folders;
this.initialValue = initialValue;
this.onSubmitValue = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", { text: this.plugin.t("exportFolderTitle") });
contentEl.createEl("p", { text: this.plugin.t("exportFolderDesc") }).addClass("sfv-modal-hint");
const select = contentEl.createEl("select");
select.addClass("sfv-modal-select");
2026-05-03 09:49:29 +00:00
for (const f of this.folders) {
select.createEl("option", { value: f, text: f || this.plugin.t("rootFolder") });
2026-05-03 09:49:29 +00:00
}
select.value = this.initialValue;
makeSelectSearchable(select, this.plugin.t("search"));
const action = contentEl.createEl("div");
action.addClass("sfv-modal-action");
action.createEl("button", { text: "Cancel" }).addEventListener("click", () => {
2026-05-03 09:49:29 +00:00
this.onSubmitValue(null);
this.close();
});
const okBtn = action.createEl("button", { text: this.plugin.t("exportFolderConfirm") });
okBtn.addClass("mod-cta");
okBtn.addEventListener("click", () => {
2026-05-03 09:49:29 +00:00
this.onSubmitValue(select.value);
this.close();
});
}
onClose() {
this.contentEl.empty();
}
};
2026-05-03 09:49:29 +00:00
function askFolderSelect(app, plugin, folders, initialValue) {
return new Promise((resolve) => {
2026-05-03 09:49:29 +00:00
new FolderSelectModal(app, plugin, folders, initialValue, resolve).open();
});
}
var ConfirmModal = class extends obsidian.Modal {
constructor(app, message, onResult) {
super(app);
this.message = message;
this.onResultValue = onResult;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("p", { text: this.message });
const action = contentEl.createEl("div");
action.addClass("sfv-modal-action");
action.createEl("button", { text: "Cancel" }).addEventListener("click", () => {
this.onResultValue(false);
this.close();
});
const ok = action.createEl("button", { text: "OK" });
ok.addClass("mod-cta");
ok.addEventListener("click", () => {
this.onResultValue(true);
this.close();
});
}
onClose() {
this.contentEl.empty();
}
};
function askConfirm(app, message) {
return new Promise((resolve) => {
new ConfirmModal(app, message, resolve).open();
});
}
var ProfileManagerModal = class extends obsidian.Modal {
2026-05-03 09:49:29 +00:00
constructor(app, plugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: this.plugin.t("managerTitle") });
2026-05-03 09:49:29 +00:00
for (const profile of this.plugin.data.profiles) {
const s = new obsidian.Setting(contentEl).setName(profile.name || profile.sourceFolder).setDesc(profile.sourceFolder);
s.addButton((b) => b.setButtonText(this.plugin.t("open")).onClick(() => {
void (async () => {
this.plugin.clearDraftProfile();
this.plugin.data.lastActiveProfileId = profile.id;
await this.plugin.persist();
await this.plugin.activateView({ profile, newLeaf: true });
this.close();
})();
2026-05-03 09:49:29 +00:00
}));
s.addButton((b) => b.setButtonText(this.plugin.t("edit")).onClick(() => {
2026-05-03 09:49:29 +00:00
new SetupModal(this.app, this.plugin, profile).open();
}));
s.addButton((b) => b.setButtonText(this.plugin.t("delete")).onClick(() => {
void askConfirm(this.app, this.plugin.t("deleteConfirm", { name: profile.name || profile.sourceFolder })).then(async (confirmed) => {
if (!confirmed) return;
this.plugin.data.profiles = this.plugin.data.profiles.filter((p) => p.id !== profile.id);
if (this.plugin.data.lastActiveProfileId === profile.id) this.plugin.data.lastActiveProfileId = "";
await this.plugin.persist();
this.onOpen();
});
2026-05-03 09:49:29 +00:00
}));
}
}
onClose() {
this.contentEl.empty();
}
};
var SmartFolderView = class extends obsidian.ItemView {
2026-05-03 09:49:29 +00:00
constructor(leaf, plugin) {
super(leaf);
this.plugin = plugin;
this.pinnedProfile = null;
this.stateOwnerId = "";
this.profileDefaultsKey = "";
2026-05-03 09:49:29 +00:00
this.timelineDropBusy = false;
this.timelineDragToken = "";
2026-05-03 09:49:29 +00:00
this.pendingBoardFieldValueByPath = {};
this.lastUndoSnapshot = null;
}
getViewType() {
return VIEW_TYPE;
}
2026-05-03 09:49:29 +00:00
getDisplayText() {
const p = this.pinnedProfile || this.plugin.getCurrentProfile();
return p?.name || p?.sourceFolder || this.plugin.t("title");
}
getIcon() {
return "layout-grid";
2026-05-03 09:49:29 +00:00
}
updateLeafTitle() {
const title = this.getDisplayText();
const leafAny = this.leaf;
if (leafAny?.setTitle) leafAny.setTitle(title);
if (leafAny?.tabHeaderInnerTitleEl?.setText) leafAny.tabHeaderInnerTitleEl.setText(title);
}
setPinnedProfile(profile) {
if (!profile) {
this.pinnedProfile = null;
} else {
this.pinnedProfile = { ...profile, displayFields: [...profile.displayFields || []] };
2026-05-03 09:49:29 +00:00
}
this.stateOwnerId = "";
this.profileDefaultsKey = "";
2026-05-03 09:49:29 +00:00
this.lastUndoSnapshot = null;
this.updateLeafTitle();
}
getRenderProfile() {
if (!this.pinnedProfile) return this.plugin.getCurrentProfile();
const latest = this.plugin.data.profiles.find((p) => p.id === this.pinnedProfile.id) || this.pinnedProfile;
this.pinnedProfile = {
...latest,
sourceNotePath: this.pinnedProfile.sourceNotePath || latest.sourceNotePath || "",
displayFields: [...latest.displayFields || []]
};
2026-05-03 09:49:29 +00:00
return this.pinnedProfile;
}
ensureState(ownerId, defaultSort, defaultViewMode) {
const nextDefaultsKey = `${defaultSort}|${defaultViewMode}`;
if (this.stateOwnerId === ownerId && this.profileDefaultsKey === nextDefaultsKey) return;
this.stateOwnerId = ownerId;
this.profileDefaultsKey = nextDefaultsKey;
try {
const saved = this.app.loadLocalStorage(FILTER_STORAGE_PREFIX + ownerId) || {};
2026-05-03 09:49:29 +00:00
this.isFreshFilterState = !saved || Object.keys(saved).length === 0;
const savedDefaultsKey = typeof saved.profileDefaultsKey === "string" ? saved.profileDefaultsKey : "";
2026-05-03 09:49:29 +00:00
const defaultsChanged = !!savedDefaultsKey && savedDefaultsKey !== this.profileDefaultsKey;
this.filterState = {
enabledFilters: Array.isArray(saved.enabledFilters) ? saved.enabledFilters : [],
selectedByAttr: saved.selectedByAttr && typeof saved.selectedByAttr === "object" ? saved.selectedByAttr : {},
sortOrder: defaultsChanged ? defaultSort : saved.sortOrder === "asc" || saved.sortOrder === "desc" ? saved.sortOrder : defaultSort,
viewMode: defaultsChanged ? defaultViewMode : saved.viewMode === "board" || saved.viewMode === "timeline" ? saved.viewMode : defaultViewMode,
presetName: typeof saved.presetName === "string" ? saved.presetName : "",
boardField: typeof saved.boardField === "string" ? saved.boardField : null
2026-05-03 09:49:29 +00:00
};
} catch {
this.isFreshFilterState = true;
this.filterState = { enabledFilters: [], selectedByAttr: {}, sortOrder: defaultSort, viewMode: defaultViewMode, presetName: "", boardField: null };
2026-05-03 09:49:29 +00:00
}
}
saveState() {
this.app.saveLocalStorage(FILTER_STORAGE_PREFIX + this.stateOwnerId, {
2026-05-03 09:49:29 +00:00
...this.filterState,
profileDefaultsKey: this.profileDefaultsKey
});
2026-05-03 09:49:29 +00:00
}
loadPresets() {
try {
const parsed = this.app.loadLocalStorage(FILTER_PRESET_STORAGE_PREFIX + this.stateOwnerId);
2026-05-03 09:49:29 +00:00
if (!Array.isArray(parsed)) return [];
return clonePresetList(parsed);
} catch {
return [];
}
2026-05-03 09:49:29 +00:00
}
savePresets(presets) {
this.app.saveLocalStorage(FILTER_PRESET_STORAGE_PREFIX + this.stateOwnerId, clonePresetList(presets));
2026-05-03 09:49:29 +00:00
}
contextKey(profile) {
return JSON.stringify({ profileId: profile.id, selectedByAttr: this.filterState.selectedByAttr, enabled: this.filterState.enabledFilters, sort: this.filterState.sortOrder, viewMode: this.filterState.viewMode, boardField: profile.boardField });
}
orderKey(profile, column) {
return `${this.contextKey(profile)}:${column || "timeline"}`;
2026-05-03 09:49:29 +00:00
}
loadOrderStore(ownerId) {
try {
const parsed = this.app.loadLocalStorage(MANUAL_ORDER_STORAGE_PREFIX + ownerId);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
2026-05-03 09:49:29 +00:00
}
saveOrderStore(ownerId, store) {
this.app.saveLocalStorage(MANUAL_ORDER_STORAGE_PREFIX + ownerId, store);
2026-05-03 09:49:29 +00:00
}
applyOrder(ownerId, key, items) {
const store = this.loadOrderStore(ownerId);
const order = store[key] || [];
if (!order.length) return items;
const rank = /* @__PURE__ */ new Map();
2026-05-03 09:49:29 +00:00
order.forEach((p, i) => rank.set(p, i));
return [...items].sort((a, b) => (rank.get(a.file.path) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.file.path) ?? Number.MAX_SAFE_INTEGER));
}
saveOrder(ownerId, key, files) {
const store = this.loadOrderStore(ownerId);
store[key] = [...new Set(files.map((f) => f.path))];
2026-05-03 09:49:29 +00:00
this.saveOrderStore(ownerId, store);
}
clearOrder(ownerId, key) {
const store = this.loadOrderStore(ownerId);
delete store[key];
this.saveOrderStore(ownerId, store);
}
recordUndoSnapshot(profile) {
this.lastUndoSnapshot = {
ownerId: profile.id,
orderStore: this.loadOrderStore(profile.id),
pendingBoardFieldValueByPath: { ...this.pendingBoardFieldValueByPath }
2026-05-03 09:49:29 +00:00
};
}
async undoLastAction(profile) {
if (!this.lastUndoSnapshot || this.lastUndoSnapshot.ownerId !== profile.id) {
new obsidian.Notice(this.plugin.t("undoNone"));
2026-05-03 09:49:29 +00:00
return;
}
this.saveOrderStore(profile.id, this.lastUndoSnapshot.orderStore || {});
this.pendingBoardFieldValueByPath = { ...this.lastUndoSnapshot.pendingBoardFieldValueByPath || {} };
2026-05-03 09:49:29 +00:00
this.lastUndoSnapshot = null;
await this.rerenderPreserveScroll();
new obsidian.Notice(this.plugin.t("undoDone"));
2026-05-03 09:49:29 +00:00
}
onOpen() {
this.renderView();
2026-05-03 09:49:29 +00:00
}
async rerenderPreserveScroll() {
const container = this.containerEl.children[1];
const scrollTop = container?.scrollTop || 0;
this.renderView();
2026-05-03 09:49:29 +00:00
const nextContainer = this.containerEl.children[1];
await new Promise((resolve) => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve)));
2026-05-03 09:49:29 +00:00
if (nextContainer) nextContainer.scrollTop = scrollTop;
}
async askAndMaybeUpdateField(file, field, targetColumn, unsetLabel) {
const suggested = targetColumn === unsetLabel ? "" : targetColumn;
const useSuggested = await askConfirm(this.app, this.plugin.t("moveAsk", { field, value: suggested || this.plugin.t("emptyValue") }));
const finalValue = useSuggested ? suggested : await askTextInput(this.app, this.plugin.t("moveManual", { field }), this.plugin.t("moveManual", { field }), suggested, "");
if (finalValue == null) return void 0;
2026-05-03 09:49:29 +00:00
const value = finalValue.trim();
try {
await this.app.fileManager.processFrontMatter(file, (fm) => {
2026-05-03 09:49:29 +00:00
if (value) fm[field] = value;
else delete fm[field];
});
new obsidian.Notice(this.plugin.t("moveDone", { field, value: value || this.plugin.t("emptyValue") }));
2026-05-03 09:49:29 +00:00
return value;
} catch {
new obsidian.Notice(this.plugin.t("moveFail"));
return void 0;
2026-05-03 09:49:29 +00:00
}
}
getBoardFieldValue(file, cache, boardField) {
if (Object.prototype.hasOwnProperty.call(this.pendingBoardFieldValueByPath, file.path)) {
return this.pendingBoardFieldValueByPath[file.path] || "";
2026-05-03 09:49:29 +00:00
}
return getFrontmatterValues(cache, boardField)[0] || "";
2026-05-03 09:49:29 +00:00
}
getBoardFieldValues(file, cache, boardField) {
if (Object.prototype.hasOwnProperty.call(this.pendingBoardFieldValueByPath, file.path)) {
return toFlatArray(this.pendingBoardFieldValueByPath[file.path]);
}
return getFrontmatterValues(cache, boardField);
}
reconcilePendingBoardValue(file, cache, boardField) {
if (!Object.prototype.hasOwnProperty.call(this.pendingBoardFieldValueByPath, file.path)) return;
const pending = this.pendingBoardFieldValueByPath[file.path] || "";
const actual = getFrontmatterValues(cache, boardField)[0] || "";
2026-05-03 09:49:29 +00:00
if (pending === actual) delete this.pendingBoardFieldValueByPath[file.path];
}
renderCard(parent, file, cache, profile, color, showDot) {
const card = parent.createEl("section");
card.addClass("sfv-card");
card.setCssProps({ "--sfv-color": color });
2026-05-03 09:49:29 +00:00
if (showDot) {
card.createEl("span").addClass("sfv-card-dot");
2026-05-03 09:49:29 +00:00
}
const top = card.createEl("div");
top.addClass("sfv-card-top");
const meta = top.createEl("span");
meta.setText(profile.displayFields.map((k) => `${k}: ${getFrontmatterValues(cache, k).join("\u3001") || "-"}`).join(" | "));
meta.addClass("sfv-card-meta");
const link = top.createEl("a");
2026-05-03 09:49:29 +00:00
link.setText(file.basename);
link.className = "internal-link";
link.setAttribute("data-href", file.path);
link.setAttribute("href", file.path);
link.addClass("sfv-card-link");
2026-05-03 09:49:29 +00:00
const triggerHoverPreview = (e) => {
this.app.workspace?.trigger?.("hover-link", {
2026-05-03 09:49:29 +00:00
event: e,
source: VIEW_TYPE,
hoverParent: card,
targetEl: link,
linktext: file.path
2026-05-03 09:49:29 +00:00
});
};
link.addEventListener("mouseenter", triggerHoverPreview);
link.addEventListener("mousemove", triggerHoverPreview);
link.addEventListener("mouseover", triggerHoverPreview);
link.addEventListener("pointerenter", triggerHoverPreview);
link.addEventListener("pointermove", triggerHoverPreview);
link.addEventListener("click", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
void this.app.workspace.openLinkText(file.path, file.path, e.ctrlKey || e.metaKey);
2026-05-03 09:49:29 +00:00
});
const triggerHoverPreviewForTarget = (e, targetEl) => {
const linktext = targetEl.getAttribute("data-href") || targetEl.getAttribute("href") || targetEl.textContent?.trim() || "";
2026-05-03 09:49:29 +00:00
if (!linktext) return;
this.app.workspace?.trigger?.("hover-link", {
2026-05-03 09:49:29 +00:00
event: e,
source: VIEW_TYPE,
hoverParent: card,
targetEl,
linktext
2026-05-03 09:49:29 +00:00
});
};
const getInternalLinkElement = (target) => {
if (target instanceof Element) return target.closest(".internal-link");
if (target instanceof Node && target.parentElement) return target.parentElement.closest(".internal-link");
2026-05-03 09:49:29 +00:00
return null;
};
const bodyEl = card.createEl("div");
bodyEl.addClass("sfv-card-body");
bodyEl.addEventListener("mouseover", (e) => {
2026-05-03 09:49:29 +00:00
const el = getInternalLinkElement(e.target);
if (!el) return;
triggerHoverPreviewForTarget(e, el);
}, true);
bodyEl.addEventListener("pointerover", (e) => {
2026-05-03 09:49:29 +00:00
const el = getInternalLinkElement(e.target);
if (!el) return;
triggerHoverPreviewForTarget(e, el);
}, true);
bodyEl.addEventListener("mousemove", (e) => {
2026-05-03 09:49:29 +00:00
const el = getInternalLinkElement(e.target);
if (!el) return;
triggerHoverPreviewForTarget(e, el);
}, true);
bodyEl.addEventListener("pointermove", (e) => {
2026-05-03 09:49:29 +00:00
const el = getInternalLinkElement(e.target);
if (!el) return;
triggerHoverPreviewForTarget(e, el);
}, true);
bodyEl.addEventListener("click", (e) => {
2026-05-03 09:49:29 +00:00
const el = getInternalLinkElement(e.target);
if (!el) return;
const href = el.getAttribute("data-href") || el.getAttribute("href");
2026-05-03 09:49:29 +00:00
if (!href) return;
e.preventDefault();
void this.app.workspace.openLinkText(href, file.path, e.ctrlKey || e.metaKey);
2026-05-03 09:49:29 +00:00
}, true);
void this.app.vault.read(file).then((raw) => {
const body = stripFrontmatter(raw) || "\uFF08\u65E0\u6B63\u6587\uFF09";
void obsidian.MarkdownRenderer.render(this.app, body, bodyEl, file.path, this);
}).catch(() => bodyEl.setText("\uFF08\u6B63\u6587\u8BFB\u53D6\u5931\u8D25\uFF09"));
2026-05-03 09:49:29 +00:00
}
buildDropdown(container, label, options, selectedValues, onChange, onCommit) {
const wrap = container.createEl("div");
wrap.addClass("sfv-dd-wrap");
wrap.createEl("span", { text: label }).addClass("sfv-dd-label");
const box = wrap.createEl("details");
box.addClass("sfv-dd-box");
const summary = box.createEl("summary");
summary.addClass("sfv-dd-summary");
2026-05-03 09:49:29 +00:00
const initialValues = [...selectedValues];
let currentValues = [...initialValues];
const summarySpan = summary.createEl("span");
summarySpan.addClass("sfv-dd-summary-text");
const toDisplayText = (v) => v === EMPTY_OPTION_VALUE ? this.plugin.t("emptyValue") : v;
2026-05-03 09:49:29 +00:00
const refreshSummary = () => {
const isAll = currentValues.length === options.length && options.length > 0;
const full = !currentValues.length ? this.plugin.t("noneSelected") : currentValues.map(toDisplayText).join("\u3001");
const short = !currentValues.length ? this.plugin.t("noneSelected") : isAll ? this.plugin.t("selectAll") : currentValues.length > 3 ? `${this.plugin.t("selected", { count: currentValues.length })}` : full;
2026-05-03 09:49:29 +00:00
summarySpan.setText(short);
summarySpan.title = isAll ? this.plugin.t("selectAll") : full;
2026-05-03 09:49:29 +00:00
};
refreshSummary();
const panel = box.createEl("div");
panel.addClass("sfv-dd-panel");
const actionBar = panel.createEl("div");
actionBar.addClass("sfv-dd-action-bar");
const allBtn = actionBar.createEl("button", { text: this.plugin.t("selectAll") });
allBtn.addClass("sfv-dd-action-btn");
allBtn.addEventListener("click", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
e.stopPropagation();
currentValues = [...options];
for (const { row } of rows) {
const cb = row.querySelector('input[type="checkbox"]');
if (cb) cb.checked = true;
}
onChange(currentValues);
refreshSummary();
});
const clearBtn = actionBar.createEl("button", { text: this.plugin.t("selectNone") });
clearBtn.addClass("sfv-dd-action-btn");
clearBtn.addEventListener("click", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
e.stopPropagation();
currentValues = [];
for (const { row } of rows) {
const cb = row.querySelector('input[type="checkbox"]');
if (cb) cb.checked = false;
}
onChange([]);
refreshSummary();
});
const search = panel.createEl("input");
search.type = "search";
search.placeholder = this.plugin.t("search");
search.addClass("sfv-dd-search");
const list = panel.createEl("div");
2026-05-03 09:49:29 +00:00
const rows = [];
for (const option of options) {
const row = list.createEl("label");
row.addClass("sfv-dd-row");
const cb = row.createEl("input");
cb.type = "checkbox";
2026-05-03 09:49:29 +00:00
cb.checked = currentValues.includes(option);
row.createEl("span", { text: toDisplayText(option) });
cb.addEventListener("change", () => {
const next = cb.checked ? [...currentValues, option] : currentValues.filter((v) => v !== option);
2026-05-03 09:49:29 +00:00
currentValues = [...new Set(next)];
onChange(currentValues);
refreshSummary();
});
rows.push({ option, row });
}
search.addEventListener("input", () => {
2026-05-03 09:49:29 +00:00
const q = search.value.toLowerCase();
for (const { option, row } of rows) {
if (!q || option.toLowerCase().includes(q)) row.removeClass("sfv-hidden");
else row.addClass("sfv-hidden");
}
2026-05-03 09:49:29 +00:00
});
box.addEventListener("toggle", () => {
2026-05-03 09:49:29 +00:00
if (box.open) {
wrap.addClass("sfv-elevated");
box.addClass("sfv-elevated");
2026-05-03 09:49:29 +00:00
} else {
wrap.removeClass("sfv-elevated");
box.removeClass("sfv-elevated");
2026-05-03 09:49:29 +00:00
if (onCommit) onCommit();
}
});
}
renderView() {
2026-05-03 09:49:29 +00:00
const profile = this.getRenderProfile();
const container = this.containerEl.children[1];
container.empty();
container.classList.add("smart-folder-view-root");
2026-05-03 09:49:29 +00:00
this.updateLeafTitle();
if (!profile) {
container.createEl("p", { text: this.plugin.t("noProfile") });
2026-05-03 09:49:29 +00:00
return;
}
this.ensureState(profile.id, profile.sortOrder, profile.viewMode);
const topBar = container.createEl("div");
topBar.addClass("sfv-top-bar");
const saveBtn = topBar.createEl("button", { text: this.plugin.isDraft(profile.id) ? this.plugin.t("savePage") : this.plugin.t("updatePage") });
saveBtn.addClass("mod-cta");
saveBtn.addEventListener("click", () => {
void (async () => {
try {
await this.plugin.saveProfile(profile, true, false);
await this.rerenderPreserveScroll();
} catch {
new obsidian.Notice(this.plugin.t("saveFailed"));
}
})();
2026-05-03 09:49:29 +00:00
});
topBar.createEl("button", { text: this.plugin.t("openBuilderBtn") }).addEventListener("click", () => new SetupModal(this.app, this.plugin).open());
topBar.createEl("button", { text: this.plugin.t("openManagerBtn") }).addEventListener("click", () => new ProfileManagerModal(this.app, this.plugin).open());
topBar.createEl("button", { text: this.plugin.t("exportPage") }).addEventListener("click", () => {
void this.plugin.exportProfileToMarkdown(profile);
2026-05-03 09:49:29 +00:00
});
topBar.createEl("button", { text: this.plugin.t("refreshPreview") }).addEventListener("click", () => {
this.renderView();
new obsidian.Notice(this.plugin.t("refreshPreviewDone"));
2026-05-03 09:49:29 +00:00
});
const files = this.plugin.getFilesByFolder(profile.sourceFolder, profile.sourceNotePath);
2026-05-03 09:49:29 +00:00
if (!files.length) {
container.createEl("p", { text: this.plugin.t("noFiles", { folder: profile.sourceFolder }) });
2026-05-03 09:49:29 +00:00
return;
}
if (this.filterState.boardField !== null && this.filterState.boardField !== void 0) {
profile.boardField = this.filterState.boardField;
}
const entries = files.map((file) => ({ file, cache: this.app.metadataCache.getFileCache(file) }));
2026-05-03 09:49:29 +00:00
for (const entry of entries) {
this.reconcilePendingBoardValue(entry.file, entry.cache, profile.boardField);
}
const attrKeys = getAllFrontmatterKeys(entries.map((e) => e.cache));
2026-05-03 09:49:29 +00:00
const optionsByAttr = {};
for (const key of attrKeys) {
const values = uniqueSorted(entries.flatMap((e) => getFrontmatterValues(e.cache, key)));
const hasEmpty = entries.some((e) => getFrontmatterValues(e.cache, key).length === 0);
2026-05-03 09:49:29 +00:00
optionsByAttr[key] = hasEmpty ? [...values, EMPTY_OPTION_VALUE] : values;
}
if (!this.filterState.enabledFilters.length) this.filterState.enabledFilters = attrKeys.slice(0, 4);
let defaultSelectionInitialized = false;
for (const key of attrKeys) {
if (!Array.isArray(this.filterState.selectedByAttr[key])) {
this.filterState.selectedByAttr[key] = [...optionsByAttr[key] || []];
2026-05-03 09:49:29 +00:00
defaultSelectionInitialized = true;
}
}
if (defaultSelectionInitialized) this.saveState();
const switchesEl = container.createEl("div");
switchesEl.addClass("sfv-switches");
2026-05-03 09:49:29 +00:00
for (const key of attrKeys) {
const enabled = this.filterState.enabledFilters.includes(key);
const chip = switchesEl.createEl("label");
chip.addClass("sfv-chip");
const cb = chip.createEl("input");
cb.type = "checkbox";
2026-05-03 09:49:29 +00:00
cb.checked = enabled;
chip.createEl("span", { text: key });
cb.addEventListener("change", () => {
2026-05-03 09:49:29 +00:00
const s = new Set(this.filterState.enabledFilters);
if (cb.checked) s.add(key);
else s.delete(key);
this.filterState.enabledFilters = attrKeys.filter((k) => s.has(k));
2026-05-03 09:49:29 +00:00
this.saveState();
void this.renderView();
2026-05-03 09:49:29 +00:00
});
}
const controls = container.createEl("div");
controls.addClass("sfv-controls");
controls.createEl("p", { text: this.plugin.t("presetScopeDesc") }).addClass("sfv-controls-hint");
2026-05-03 09:49:29 +00:00
const presets = this.loadPresets();
const presetWrap = controls.createEl("div");
presetWrap.addClass("sfv-preset-wrap");
presetWrap.createEl("span", { text: this.plugin.t("preset") }).addClass("sfv-dd-label");
const presetSel = presetWrap.createEl("select");
presetSel.createEl("option", { text: this.plugin.t("noPreset"), value: "" });
2026-05-03 09:49:29 +00:00
for (const p of presets) {
const opt = presetSel.createEl("option", { text: p.name, value: p.name });
2026-05-03 09:49:29 +00:00
if (p.name === this.filterState.presetName) opt.selected = true;
}
presetSel.addEventListener("change", () => {
2026-05-03 09:49:29 +00:00
const currentPresets = this.loadPresets();
const p = currentPresets.find((x) => x.name === presetSel.value);
2026-05-03 09:49:29 +00:00
if (!p) {
this.filterState.presetName = "";
2026-05-03 09:49:29 +00:00
} else {
this.filterState.enabledFilters = [...p.enabledFilters];
this.filterState.selectedByAttr = cloneSelectedByAttr(p.selectedByAttr);
this.filterState.sortOrder = p.sortOrder;
this.filterState.viewMode = p.viewMode;
this.filterState.presetName = p.name;
this.filterState.boardField = p.boardField || null;
if (p.boardField !== void 0) {
profile.boardField = p.boardField;
const idx = this.plugin.data.profiles.findIndex((q) => q.id === profile.id);
if (idx >= 0) this.plugin.data.profiles[idx].boardField = p.boardField;
if (this.plugin.draftProfile?.id === profile.id) this.plugin.draftProfile.boardField = p.boardField;
void this.plugin.persist();
}
2026-05-03 09:49:29 +00:00
}
this.saveState();
void this.renderView();
2026-05-03 09:49:29 +00:00
});
makeSelectSearchable(presetSel, this.plugin.t("search"));
presetWrap.createEl("button", { text: this.plugin.t("savePreset") }).addEventListener("click", () => {
void (async () => {
try {
const result = await askTextInput(this.app, this.plugin.t("savePreset"), this.plugin.t("presetPrompt"), this.filterState.presetName || "", this.plugin.t("presetSaveHint"));
if (result === null) return;
const name = result.trim();
if (!name) {
new obsidian.Notice(this.plugin.t("presetNameRequired"));
return;
}
const currentPresets = this.loadPresets();
const next = currentPresets.filter((p) => p.name !== name);
const currentBoardField = this.filterState.boardField !== null ? this.filterState.boardField : profile.boardField || "";
next.push(clonePreset({ name, enabledFilters: [...this.filterState.enabledFilters], selectedByAttr: cloneSelectedByAttr(this.filterState.selectedByAttr), sortOrder: this.filterState.sortOrder, viewMode: this.filterState.viewMode, boardField: currentBoardField }));
this.savePresets(next);
this.filterState.presetName = name;
this.saveState();
this.renderView();
} catch (e) {
console.error("[SmartFolderView] Preset save error:", e);
new obsidian.Notice(this.plugin.t("saveFailed"));
}
})();
2026-05-03 09:49:29 +00:00
});
presetWrap.createEl("button", { text: this.plugin.t("delPreset") }).addEventListener("click", () => {
2026-05-03 09:49:29 +00:00
if (!this.filterState.presetName) return;
const currentPresets = this.loadPresets();
this.savePresets(currentPresets.filter((p) => p.name !== this.filterState.presetName));
this.filterState.presetName = "";
2026-05-03 09:49:29 +00:00
this.saveState();
void this.renderView();
2026-05-03 09:49:29 +00:00
});
const filterGrid = controls.createEl("div");
filterGrid.addClass("sfv-filter-grid");
2026-05-03 09:49:29 +00:00
for (const key of this.filterState.enabledFilters) {
this.buildDropdown(filterGrid, key, optionsByAttr[key] || [], this.filterState.selectedByAttr[key] || [], (vals) => {
this.filterState.selectedByAttr[key] = [...vals];
this.saveState();
}, () => {
this.renderView();
});
}
this.isFreshFilterState = false;
const sortWrap = controls.createEl("label");
sortWrap.addClass("sfv-sort-wrap");
sortWrap.createEl("span", { text: this.plugin.t("sortOrder") }).addClass("sfv-dd-label");
const sortSel = sortWrap.createEl("select");
sortSel.createEl("option", { value: "desc", text: this.plugin.t("desc") });
sortSel.createEl("option", { value: "asc", text: this.plugin.t("asc") });
2026-05-03 09:49:29 +00:00
sortSel.value = this.filterState.sortOrder;
makeSelectSearchable(sortSel, this.plugin.t("search"));
sortSel.dispatchEvent(new Event("change"));
sortSel.addEventListener("change", () => {
2026-05-03 09:49:29 +00:00
this.filterState.sortOrder = sortSel.value;
this.saveState();
void this.renderView();
2026-05-03 09:49:29 +00:00
});
const modeWrap = controls.createEl("label");
modeWrap.addClass("sfv-sort-wrap");
modeWrap.createEl("span", { text: this.plugin.t("viewMode") }).addClass("sfv-dd-label");
const modeSel = modeWrap.createEl("select");
modeSel.createEl("option", { value: "timeline", text: this.plugin.t("timeline") });
modeSel.createEl("option", { value: "board", text: this.plugin.t("board") });
2026-05-03 09:49:29 +00:00
modeSel.value = this.filterState.viewMode;
makeSelectSearchable(modeSel, this.plugin.t("search"));
modeSel.dispatchEvent(new Event("change"));
modeSel.addEventListener("change", () => {
this.filterState.viewMode = modeSel.value === "board" ? "board" : "timeline";
2026-05-03 09:49:29 +00:00
this.saveState();
void this.renderView();
2026-05-03 09:49:29 +00:00
});
if (this.filterState.viewMode === "board") {
const boardFieldWrap = controls.createEl("label");
boardFieldWrap.addClass("sfv-sort-wrap");
boardFieldWrap.createEl("span", { text: this.plugin.t("boardField") }).addClass("sfv-dd-label");
const boardFieldSel = boardFieldWrap.createEl("select");
boardFieldSel.createEl("option", { value: "", text: this.plugin.t("choose") });
for (const k of attrKeys) boardFieldSel.createEl("option", { value: k, text: k });
const effectiveBoardField = this.filterState.boardField !== null ? this.filterState.boardField : profile.boardField || "";
boardFieldSel.value = effectiveBoardField;
makeSelectSearchable(boardFieldSel, this.plugin.t("search"));
boardFieldSel.addEventListener("change", () => {
const next = boardFieldSel.value;
this.filterState.boardField = next;
profile.boardField = next;
const idx = this.plugin.data.profiles.findIndex((p) => p.id === profile.id);
if (idx >= 0) this.plugin.data.profiles[idx].boardField = next;
if (this.plugin.draftProfile?.id === profile.id) this.plugin.draftProfile.boardField = next;
this.saveState();
void this.plugin.persist();
this.renderView();
2026-05-03 09:49:29 +00:00
});
}
const filtered = entries.filter(({ file, cache }) => {
if (!this.filterState.enabledFilters.length) return true;
for (const key of this.filterState.enabledFilters) {
const selected = Array.isArray(this.filterState.selectedByAttr[key]) ? this.filterState.selectedByAttr[key] : [...optionsByAttr[key] || []];
2026-05-03 09:49:29 +00:00
if (!selected.length) return false;
const vals = key === profile.boardField ? this.getBoardFieldValues(file, cache, profile.boardField) : getFrontmatterValues(cache, key);
2026-05-03 09:49:29 +00:00
const matchEmpty = selected.includes(EMPTY_OPTION_VALUE) && vals.length === 0;
const matchNormal = selected.some((v) => v !== EMPTY_OPTION_VALUE && vals.includes(v));
2026-05-03 09:49:29 +00:00
if (!(matchEmpty || matchNormal)) return false;
}
return true;
});
filtered.sort((a, b) => {
const va = getFrontmatterValues(a.cache, profile.sortField)[0] || a.file.basename;
const vb = getFrontmatterValues(b.cache, profile.sortField)[0] || b.file.basename;
const pa = parseSortValue(va, profile.sortField);
const pb = parseSortValue(vb, profile.sortField);
const cmp = pa.kind === "number" && pb.kind === "number" ? Number(pa.value) - Number(pb.value) : String(pa.raw).localeCompare(String(pb.raw), "zh-Hans-CN", { numeric: true, sensitivity: "base" });
return this.filterState.sortOrder === "asc" ? cmp : -cmp;
2026-05-03 09:49:29 +00:00
});
container.createEl("p", { text: this.plugin.t("matched", { count: filtered.length }) }).addClass("sfv-matched");
2026-05-03 09:49:29 +00:00
if (!filtered.length) {
container.createEl("p", { text: this.plugin.t("noMatch") });
2026-05-03 09:49:29 +00:00
return;
}
if (this.filterState.viewMode === "timeline") {
2026-05-03 09:49:29 +00:00
const ordered = this.applyOrder(profile.id, this.orderKey(profile), filtered);
const orderActions2 = container.createEl("div");
orderActions2.addClass("sfv-order-actions");
orderActions2.createEl("button", { text: this.plugin.t("saveOrder") }).addEventListener("click", () => {
this.saveOrder(profile.id, this.orderKey(profile), ordered.map((e) => e.file));
new obsidian.Notice(this.plugin.t("saveOrderDone"));
2026-05-03 09:49:29 +00:00
});
orderActions2.createEl("button", { text: this.plugin.t("resetOrder") }).addEventListener("click", () => {
void (async () => {
this.recordUndoSnapshot(profile);
this.clearOrder(profile.id, this.orderKey(profile));
new obsidian.Notice(this.plugin.t("resetOrderDone"));
await this.rerenderPreserveScroll();
})();
2026-05-03 09:49:29 +00:00
});
orderActions2.createEl("button", { text: this.plugin.t("undoLastAction") }).addEventListener("click", () => {
void this.undoLastAction(profile);
2026-05-03 09:49:29 +00:00
});
const timelineEl = container.createEl("div");
timelineEl.addClass("sfv-timeline");
const lineEl = timelineEl.createEl("div");
lineEl.addClass("sfv-timeline-line");
const indicator = timelineEl.createEl("div");
indicator.addClass("sfv-timeline-indicator");
2026-05-03 09:49:29 +00:00
let dropBeforePath = null;
const hideIndicator = () => {
indicator.removeClass("sfv-visible");
2026-05-03 09:49:29 +00:00
};
const updateDropIndicator = (clientY) => {
const cards = Array.from(timelineEl.querySelectorAll("section[data-sfv-path]")).filter((el) => !el.classList.contains("sfv-dragging"));
2026-05-03 09:49:29 +00:00
if (!cards.length) {
dropBeforePath = null;
indicator.setCssProps({ "--sfv-top": "0px" });
indicator.addClass("sfv-visible");
2026-05-03 09:49:29 +00:00
return;
}
let beforeCard = null;
for (const cardEl of cards) {
const rect = cardEl.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
beforeCard = cardEl;
break;
}
}
dropBeforePath = beforeCard?.getAttribute("data-sfv-path") || null;
2026-05-03 09:49:29 +00:00
let top = 0;
if (beforeCard) {
top = beforeCard.offsetTop - 2;
} else {
const last = cards[cards.length - 1];
top = last.offsetTop + last.offsetHeight - 2;
}
indicator.setCssProps({ "--sfv-top": `${Math.max(0, top)}px` });
indicator.addClass("sfv-visible");
2026-05-03 09:49:29 +00:00
};
timelineEl.addEventListener("dragover", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
2026-05-03 09:49:29 +00:00
updateDropIndicator(e.clientY);
});
timelineEl.addEventListener("dragleave", (e) => {
2026-05-03 09:49:29 +00:00
const related = e.relatedTarget;
if (related instanceof Node && timelineEl.contains(related)) return;
hideIndicator();
});
const handleTimelineDrop = async (path, beforePath) => {
if (this.timelineDropBusy) return;
if (beforePath && path === beforePath) return;
const from = ordered.findIndex((e) => e.file.path === path);
2026-05-03 09:49:29 +00:00
if (from < 0) return;
const moved = ordered[from];
const nextOrdered = ordered.filter((e) => e.file.path !== path);
const to = beforePath ? nextOrdered.findIndex((e) => e.file.path === beforePath) : -1;
2026-05-03 09:49:29 +00:00
const insertAt = to >= 0 ? to : nextOrdered.length;
nextOrdered.splice(insertAt, 0, moved);
const targetField = profile.sortField || "\u65F6\u95F4";
const action = await askThreeChoices(this.app, this.plugin.t("timelineMoveTitle"), this.plugin.t("timelineMoveChoice", { field: targetField }), {
cancel: this.plugin.t("timelineMoveCancel"),
order: this.plugin.t("timelineMoveOrderOnly"),
orderAndProp: this.plugin.t("timelineMoveOrderAndProp")
2026-05-03 09:49:29 +00:00
});
if (action === "cancel") {
new obsidian.Notice(this.plugin.t("timelineMoveNoChange"));
2026-05-03 09:49:29 +00:00
return;
}
this.timelineDropBusy = true;
try {
this.recordUndoSnapshot(profile);
ordered.splice(0, ordered.length, ...nextOrdered);
this.saveOrder(profile.id, this.orderKey(profile), ordered.map((e) => e.file));
if (action === "order+prop") {
const field = profile.sortField || "\u65F6\u95F4";
const current = getFrontmatterValues(this.app.metadataCache.getFileCache(moved.file), field)[0] || "";
const next = await askTextInput(this.app, this.plugin.t("timelineMoveOrderAndProp"), this.plugin.t("timelineUpdatePrompt", { field }), current, this.plugin.t("timelineUpdatePrompt", { field }));
2026-05-03 09:49:29 +00:00
if (next != null) {
const value = next.trim();
await this.app.fileManager.processFrontMatter(moved.file, (fm) => {
2026-05-03 09:49:29 +00:00
if (value) fm[field] = value;
else delete fm[field];
});
}
new obsidian.Notice(this.plugin.t("timelineMoveDone"));
2026-05-03 09:49:29 +00:00
await this.rerenderPreserveScroll();
} else {
const cardEls = /* @__PURE__ */ new Map();
for (const el of timelineEl.querySelectorAll("section[data-sfv-path]")) {
cardEls.set(el.getAttribute("data-sfv-path"), el);
2026-05-03 09:49:29 +00:00
}
for (const entry of ordered) {
const el = cardEls.get(entry.file.path);
if (el) timelineEl.appendChild(el);
2026-05-03 09:49:29 +00:00
}
new obsidian.Notice(this.plugin.t("timelineMoveDone"));
2026-05-03 09:49:29 +00:00
}
} finally {
this.timelineDropBusy = false;
}
};
timelineEl.addEventListener("drop", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
hideIndicator();
const payload = e.dataTransfer?.getData("text/plain") || "";
2026-05-03 09:49:29 +00:00
if (!payload) return;
const obj = JSON.parse(payload);
if (obj.token && this.timelineDragToken && obj.token !== this.timelineDragToken) return;
if (!obj.path || obj.from !== "timeline") return;
2026-05-03 09:49:29 +00:00
const beforePath = dropBeforePath;
void handleTimelineDrop(obj.path, beforePath);
2026-05-03 09:49:29 +00:00
});
for (const { file, cache } of ordered) {
const color = colorForValue(getFrontmatterValues(cache, profile.colorField)[0] || null, this.plugin.data.colorConfig) || this.plugin.data.colorConfig.fallbackColor;
this.renderCard(timelineEl, file, cache, profile, color, true);
const card = timelineEl.lastElementChild;
card.setAttribute("data-sfv-path", file.path);
2026-05-03 09:49:29 +00:00
card.draggable = true;
card.addEventListener("dragstart", (e) => {
2026-05-03 09:49:29 +00:00
this.timelineDragToken = newId();
card.classList.add("sfv-dragging");
2026-05-03 09:49:29 +00:00
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
2026-05-03 09:49:29 +00:00
e.dataTransfer.setDragImage(card, 28, 20);
}
e.dataTransfer?.setData("text/plain", JSON.stringify({ path: file.path, from: "timeline", token: this.timelineDragToken }));
2026-05-03 09:49:29 +00:00
});
card.addEventListener("dragend", () => {
card.classList.remove("sfv-dragging");
this.timelineDragToken = "";
2026-05-03 09:49:29 +00:00
hideIndicator();
});
card.addEventListener("dragover", (e) => e.preventDefault());
2026-05-03 09:49:29 +00:00
}
return;
}
const grouped = /* @__PURE__ */ new Map();
const unsetLabel = this.plugin.t("boardUnset");
2026-05-03 09:49:29 +00:00
for (const entry of filtered) {
const cols = this.getBoardFieldValues(entry.file, entry.cache, profile.boardField);
const targets = cols.length ? cols : [unsetLabel];
for (const col of targets) {
if (!grouped.has(col)) grouped.set(col, []);
grouped.get(col).push(entry);
}
}
const columns = uniqueSorted([...grouped.keys()]);
const orderActions = container.createEl("div");
orderActions.addClass("sfv-order-actions");
orderActions.createEl("button", { text: this.plugin.t("saveOrder") }).addEventListener("click", () => {
2026-05-03 09:49:29 +00:00
for (const col of columns) {
const arr = grouped.get(col) || [];
this.saveOrder(profile.id, this.orderKey(profile, col), arr.map((e) => e.file));
2026-05-03 09:49:29 +00:00
}
new obsidian.Notice(this.plugin.t("saveOrderDone"));
2026-05-03 09:49:29 +00:00
});
orderActions.createEl("button", { text: this.plugin.t("resetOrder") }).addEventListener("click", () => {
2026-05-03 09:49:29 +00:00
this.recordUndoSnapshot(profile);
for (const col of columns) this.clearOrder(profile.id, this.orderKey(profile, col));
new obsidian.Notice(this.plugin.t("resetOrderDone"));
this.renderView();
2026-05-03 09:49:29 +00:00
});
orderActions.createEl("button", { text: this.plugin.t("undoLastAction") }).addEventListener("click", () => {
void this.undoLastAction(profile);
2026-05-03 09:49:29 +00:00
});
const boardEl = container.createEl("div");
boardEl.addClass("sfv-board");
2026-05-03 09:49:29 +00:00
const handleDrop = async (payload, to, beforePath) => {
const fromArr = grouped.get(payload.from) || [];
const toArr = grouped.get(to) || [];
const idx = fromArr.findIndex((e) => e.file.path === payload.path);
2026-05-03 09:49:29 +00:00
if (idx < 0) return;
let action = "order";
let nextBoardValue = void 0;
2026-05-03 09:49:29 +00:00
if (payload.from !== to && profile.boardField) {
action = await askThreeChoices(this.app, this.plugin.t("boardMoveTitle"), this.plugin.t("boardMoveChoice", { field: profile.boardField }), {
cancel: this.plugin.t("boardMoveCancel"),
orderAndProp: this.plugin.t("boardMoveOrderAndProp")
2026-05-03 09:49:29 +00:00
});
if (action === "cancel") {
new obsidian.Notice(this.plugin.t("boardMoveNoChange"));
2026-05-03 09:49:29 +00:00
return;
}
nextBoardValue = to === unsetLabel ? "" : to;
2026-05-03 09:49:29 +00:00
}
this.recordUndoSnapshot(profile);
const removed = fromArr.splice(idx, 1)[0];
const pos = beforePath ? toArr.findIndex((e) => e.file.path === beforePath) : -1;
2026-05-03 09:49:29 +00:00
const insertAt = pos >= 0 ? pos : toArr.length;
toArr.splice(insertAt, 0, removed);
grouped.set(payload.from, fromArr);
grouped.set(to, toArr);
this.saveOrder(profile.id, this.orderKey(profile, payload.from), fromArr.map((e) => e.file));
this.saveOrder(profile.id, this.orderKey(profile, to), toArr.map((e) => e.file));
if (payload.from !== to && profile.boardField && action === "order+prop") {
2026-05-03 09:49:29 +00:00
this.pendingBoardFieldValueByPath[removed.file.path] = nextBoardValue;
if (removed.cache?.frontmatter) {
if (nextBoardValue) removed.cache.frontmatter[profile.boardField] = nextBoardValue;
else delete removed.cache.frontmatter[profile.boardField];
}
await this.app.fileManager.processFrontMatter(removed.file, (fm) => {
2026-05-03 09:49:29 +00:00
if (nextBoardValue) fm[profile.boardField] = nextBoardValue;
else delete fm[profile.boardField];
});
}
new obsidian.Notice(this.plugin.t("boardMoveDone"));
2026-05-03 09:49:29 +00:00
await this.rerenderPreserveScroll();
};
for (const col of columns) {
const ordered = this.applyOrder(profile.id, this.orderKey(profile, col), grouped.get(col) || []);
grouped.set(col, ordered);
const colWrap = boardEl.createEl("section");
colWrap.addClass("sfv-board-col");
colWrap.createEl("strong", { text: `${col} (${ordered.length})` });
const cardsEl = colWrap.createEl("div");
cardsEl.addClass("sfv-board-cards");
const dropIndicator = cardsEl.createEl("div");
dropIndicator.addClass("sfv-drop-indicator");
2026-05-03 09:49:29 +00:00
let dropBeforePath = null;
const hideDropIndicator = () => {
dropIndicator.removeClass("sfv-visible");
2026-05-03 09:49:29 +00:00
dropBeforePath = null;
};
const getNextCardPath = (cardEl) => {
let next = cardEl.nextElementSibling;
while (next) {
if (next.matches?.("section[data-sfv-path]")) return next.getAttribute("data-sfv-path") || null;
2026-05-03 09:49:29 +00:00
next = next.nextElementSibling;
}
return null;
};
const getCardDropBeforePath = (cardEl, clientY) => {
const rect = cardEl.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) return cardEl.getAttribute("data-sfv-path") || null;
2026-05-03 09:49:29 +00:00
return getNextCardPath(cardEl);
};
const updateDropIndicator = (clientY) => {
const cards = Array.from(cardsEl.querySelectorAll("section[data-sfv-path]")).filter((el) => !el.classList.contains("sfv-dragging"));
2026-05-03 09:49:29 +00:00
if (!cards.length) {
dropBeforePath = null;
cardsEl.appendChild(dropIndicator);
dropIndicator.addClass("sfv-visible");
2026-05-03 09:49:29 +00:00
return;
}
let beforeCard = null;
for (const cardEl of cards) {
const rect = cardEl.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
beforeCard = cardEl;
break;
}
}
dropBeforePath = beforeCard?.getAttribute("data-sfv-path") || null;
2026-05-03 09:49:29 +00:00
if (beforeCard) cardsEl.insertBefore(dropIndicator, beforeCard);
else cardsEl.appendChild(dropIndicator);
dropIndicator.addClass("sfv-visible");
2026-05-03 09:49:29 +00:00
};
cardsEl.addEventListener("dragover", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
2026-05-03 09:49:29 +00:00
updateDropIndicator(e.clientY);
});
cardsEl.addEventListener("dragleave", (e) => {
2026-05-03 09:49:29 +00:00
const related = e.relatedTarget;
if (related instanceof Node && cardsEl.contains(related)) return;
hideDropIndicator();
});
cardsEl.addEventListener("drop", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
const beforePath = dropBeforePath;
hideDropIndicator();
const payload = e.dataTransfer?.getData("text/plain") || "";
2026-05-03 09:49:29 +00:00
if (!payload) return;
void handleDrop(JSON.parse(payload), col, beforePath);
2026-05-03 09:49:29 +00:00
});
for (const { file, cache } of ordered) {
const color = colorForValue(getFrontmatterValues(cache, profile.colorField)[0] || null, this.plugin.data.colorConfig) || this.plugin.data.colorConfig.fallbackColor;
this.renderCard(cardsEl, file, cache, profile, color, false);
const card = cardsEl.lastElementChild;
card.setAttribute("data-sfv-path", file.path);
2026-05-03 09:49:29 +00:00
card.draggable = true;
card.addEventListener("dragstart", (e) => {
card.classList.add("sfv-dragging");
e.dataTransfer?.setData("text/plain", JSON.stringify({ path: file.path, from: col }));
2026-05-03 09:49:29 +00:00
});
card.addEventListener("dragend", () => {
card.classList.remove("sfv-dragging");
2026-05-03 09:49:29 +00:00
hideDropIndicator();
});
card.addEventListener("dragover", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
updateDropIndicator(e.clientY);
});
card.addEventListener("drop", (e) => {
2026-05-03 09:49:29 +00:00
e.preventDefault();
e.stopPropagation();
const beforePath = getCardDropBeforePath(card, e.clientY);
hideDropIndicator();
const payload = e.dataTransfer?.getData("text/plain") || "";
2026-05-03 09:49:29 +00:00
if (!payload) return;
void handleDrop(JSON.parse(payload), col, beforePath);
2026-05-03 09:49:29 +00:00
});
}
}
}
onClose() {
}
};
var SmartFolderSettingTab = class extends obsidian.PluginSettingTab {
2026-05-03 09:49:29 +00:00
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: this.plugin.t("title") });
new obsidian.Setting(containerEl).setName(this.plugin.t("openBuilder")).addButton((b) => b.setButtonText(this.plugin.t("openBuilder")).onClick(() => new SetupModal(this.app, this.plugin).open()));
new obsidian.Setting(containerEl).setName(this.plugin.t("openManager")).addButton((b) => b.setButtonText(this.plugin.t("openManager")).onClick(() => new ProfileManagerModal(this.app, this.plugin).open()));
new obsidian.Setting(containerEl).setName(this.plugin.t("languageMode")).setDesc(this.plugin.t("languageModeDesc")).addDropdown((d) => {
d.addOption("auto", this.plugin.t("languageAuto"));
d.addOption("zh", this.plugin.t("languageZh"));
d.addOption("en", this.plugin.t("languageEn"));
d.setValue(this.plugin.data.languageMode);
makeSelectSearchable(d.selectEl, this.plugin.t("search"));
d.onChange(async (v) => {
this.plugin.data.languageMode = v;
await this.plugin.persist();
this.display();
2026-05-03 09:49:29 +00:00
});
});
containerEl.createEl("h3", { text: this.plugin.t("colorSettings") });
new obsidian.Setting(containerEl).setName(this.plugin.t("saturationBase")).setDesc("20-80").addSlider((s) => s.setLimits(20, 80, 1).setValue(this.plugin.data.colorConfig.saturationBase).setDynamicTooltip().onChange(async (v) => {
this.plugin.data.colorConfig.saturationBase = v;
await this.plugin.persist();
}));
new obsidian.Setting(containerEl).setName(this.plugin.t("saturationRange")).setDesc("1-40").addSlider((s) => s.setLimits(1, 40, 1).setValue(this.plugin.data.colorConfig.saturationRange).setDynamicTooltip().onChange(async (v) => {
this.plugin.data.colorConfig.saturationRange = v;
await this.plugin.persist();
}));
new obsidian.Setting(containerEl).setName(this.plugin.t("lightnessBase")).setDesc("20-70").addSlider((s) => s.setLimits(20, 70, 1).setValue(this.plugin.data.colorConfig.lightnessBase).setDynamicTooltip().onChange(async (v) => {
this.plugin.data.colorConfig.lightnessBase = v;
await this.plugin.persist();
}));
new obsidian.Setting(containerEl).setName(this.plugin.t("lightnessRange")).setDesc("1-30").addSlider((s) => s.setLimits(1, 30, 1).setValue(this.plugin.data.colorConfig.lightnessRange).setDynamicTooltip().onChange(async (v) => {
this.plugin.data.colorConfig.lightnessRange = v;
await this.plugin.persist();
}));
new obsidian.Setting(containerEl).setName(this.plugin.t("fallbackColor")).setDesc("CSS color value, e.g. #3b82f6 or var(--interactive-accent)").addText((t) => t.setValue(this.plugin.data.colorConfig.fallbackColor).onChange(async (v) => {
this.plugin.data.colorConfig.fallbackColor = v.trim() || DEFAULT_COLOR_CONFIG.fallbackColor;
await this.plugin.persist();
})).addButton((b) => b.setButtonText(this.plugin.t("resetDefault")).onClick(async () => {
this.plugin.data.colorConfig = { ...DEFAULT_COLOR_CONFIG };
await this.plugin.persist();
this.display();
}));
2026-05-03 09:49:29 +00:00
}
};
var SmartFolderPlugin = class extends obsidian.Plugin {
2026-05-03 09:49:29 +00:00
t(key, vars) {
const mode = this.data?.languageMode || "auto";
const lang = mode === "auto" ? detectLanguage() : mode;
2026-05-03 09:49:29 +00:00
return tpl(I18N[lang]?.[key] || key, vars);
}
getFolderOptions() {
const set = /* @__PURE__ */ new Set();
2026-05-03 09:49:29 +00:00
for (const f of this.app.vault.getMarkdownFiles()) {
if (f.parent?.path) set.add(f.parent.path);
}
return ["", ...uniqueSorted([...set])];
2026-05-03 09:49:29 +00:00
}
getFilesByFolder(sourceFolder, excludedPath = "") {
const files = this.app.vault.getMarkdownFiles().filter((f) => {
const cache = this.app.metadataCache.getFileCache(f);
return f.path !== excludedPath && !isLikelySmartFolderExportPath(f.path) && !isSmartFolderExportCache(cache);
});
if (!sourceFolder) return files;
const prefix = sourceFolder.replace(/\/$/, "") + "/";
return files.filter((f) => f.path.startsWith(prefix) || f.parent?.path === sourceFolder);
2026-05-03 09:49:29 +00:00
}
getTemplateFileOptions() {
return this.app.vault.getMarkdownFiles().map((f) => f.path);
2026-05-03 09:49:29 +00:00
}
getFieldCandidates(sourceFolder, templateFile, excludedPath = "") {
2026-05-03 09:49:29 +00:00
const template = templateFile ? this.app.vault.getAbstractFileByPath(templateFile) : null;
const templateKeys = template instanceof obsidian.TFile ? getAllFrontmatterKeys([this.app.metadataCache.getFileCache(template)]) : [];
const files = this.getFilesByFolder(sourceFolder, excludedPath);
const caches = files.map((f) => this.app.metadataCache.getFileCache(f));
2026-05-03 09:49:29 +00:00
const allKeys = getAllFrontmatterKeys(caches);
const common = allKeys.filter((k) => caches.every((c) => !c?.frontmatter || k in c.frontmatter));
2026-05-03 09:49:29 +00:00
return uniqueSorted([...templateKeys, ...common]);
}
getCurrentProfile() {
if (this.draftProfile) return this.draftProfile;
const id = this.data.lastActiveProfileId;
return this.data.profiles.find((p) => p.id === id) || null;
2026-05-03 09:49:29 +00:00
}
isDraft(id) {
return !!this.draftProfile && this.draftProfile.id === id;
}
setDraftProfile(profile) {
this.draftProfile = { ...profile, displayFields: [...profile.displayFields || []], updatedAt: Date.now() };
2026-05-03 09:49:29 +00:00
}
clearDraftProfile() {
this.draftProfile = null;
}
async saveProfile(profile, setActive, askNameIfEmpty) {
const next = { ...profile, displayFields: [...profile.displayFields || []], updatedAt: Date.now() };
if (!next.name && askNameIfEmpty) next.name = (await askTextInput(this.app, this.t("pageNamePrompt"), this.t("pageNamePrompt"), next.sourceFolder || "", "") || "").trim();
if (!next.name) next.name = next.sourceFolder || `Page-${(/* @__PURE__ */ new Date()).toISOString()}`;
const idx = this.data.profiles.findIndex((p) => p.id === next.id);
2026-05-03 09:49:29 +00:00
if (idx >= 0) {
this.data.profiles[idx] = { ...this.data.profiles[idx], ...next };
new obsidian.Notice(this.t("pageUpdated"));
2026-05-03 09:49:29 +00:00
} else {
next.createdAt = next.createdAt || Date.now();
this.data.profiles.push(next);
new obsidian.Notice(this.t("pageSaved"));
2026-05-03 09:49:29 +00:00
}
if (setActive) this.data.lastActiveProfileId = next.id;
if (this.draftProfile?.id === next.id) this.draftProfile = null;
await this.persist();
}
normalizeExportProfile(raw) {
if (!raw || typeof raw !== "object") return null;
2026-05-03 09:49:29 +00:00
const profile = {
id: String(raw.id || newId()),
name: String(raw.name || ""),
sourceFolder: String(raw.sourceFolder || ""),
templateFile: String(raw.templateFile || ""),
colorField: String(raw.colorField || ""),
displayFields: Array.isArray(raw.displayFields) ? raw.displayFields.map((v) => String(v)).filter(Boolean) : [],
sortField: String(raw.sortField || ""),
sortOrder: raw.sortOrder === "asc" ? "asc" : "desc",
viewMode: raw.viewMode === "board" ? "board" : "timeline",
boardField: String(raw.boardField || ""),
sourceNotePath: String(raw.sourceNotePath || ""),
createdAt: typeof raw.createdAt === "number" ? raw.createdAt : Date.now(),
updatedAt: Date.now()
2026-05-03 09:49:29 +00:00
};
return profile;
}
async openProfileFromExport(raw, opts = {}) {
2026-05-03 09:49:29 +00:00
const profile = this.normalizeExportProfile(raw);
if (!profile) return false;
profile.sourceNotePath = String(opts.sourcePath || profile.sourceNotePath || "");
await this.activateView({
profile,
newLeaf: opts.newLeaf === true,
persistLastActive: false,
leaf: opts.leaf || null
});
2026-05-03 09:49:29 +00:00
return true;
}
async importProfileFromExport(raw, opts = {}) {
2026-05-03 09:49:29 +00:00
const profile = this.normalizeExportProfile(raw);
if (!profile) return false;
profile.sourceNotePath = String(opts.sourcePath || profile.sourceNotePath || "");
const existing = this.data.profiles.find((p) => p.id === profile.id) || this.data.profiles.find((p) => p.name === profile.name && p.sourceFolder === profile.sourceFolder);
2026-05-03 09:49:29 +00:00
if (existing) {
profile.id = existing.id;
profile.createdAt = existing.createdAt || profile.createdAt;
}
await this.saveProfile(profile, true, false);
await this.activateView({ profile, newLeaf: true });
return true;
}
async exportProfileToMarkdown(profile) {
if (!profile) {
new obsidian.Notice(this.t("noProfile"));
2026-05-03 09:49:29 +00:00
return;
}
try {
const folders = this.getFolderOptions();
const defaultFolder = this.app.vault.getAbstractFileByPath("00 Homepage") instanceof obsidian.TFolder ? "00 Homepage" : "";
2026-05-03 09:49:29 +00:00
const chosenFolder = await askFolderSelect(this.app, this, folders, defaultFolder);
if (chosenFolder == null) return;
const safeBase = (profile.name || profile.sourceFolder || "smart-folder-page").replace(/[\\/:*?"<>|]/g, "-").trim() || "smart-folder-page";
const folderPrefix = chosenFolder ? `${chosenFolder}/` : "";
2026-05-03 09:49:29 +00:00
let path = `${folderPrefix}${safeBase}.smart-folder-page.md`;
let i = 1;
while (this.app.vault.getAbstractFileByPath(path)) {
path = `${folderPrefix}${safeBase}.smart-folder-page-${i}.md`;
i += 1;
}
const payload = {
...profile,
exportedAt: (/* @__PURE__ */ new Date()).toISOString()
2026-05-03 09:49:29 +00:00
};
const content = [
"# Smart Folder \u9875\u9762\u5BFC\u51FA",
"",
"<!-- smart-folder-view-page-export -->",
"",
"```smart-folder-page",
2026-05-03 09:49:29 +00:00
JSON.stringify(payload, null, 2),
"```",
""
].join("\n");
2026-05-03 09:49:29 +00:00
await this.app.vault.create(path, content);
new obsidian.Notice(this.t("exportPageDone", { path }));
2026-05-03 09:49:29 +00:00
} catch {
new obsidian.Notice(this.t("exportPageFailed"));
2026-05-03 09:49:29 +00:00
}
}
async onload() {
await this.loadPluginData();
this.registerView(VIEW_TYPE, (leaf) => new SmartFolderView(leaf, this));
this.registerMarkdownCodeBlockProcessor("smart-folder-page", (source, el, ctx) => {
2026-05-03 09:49:29 +00:00
el.empty();
let raw;
try {
raw = JSON.parse(source || "{}");
2026-05-03 09:49:29 +00:00
} catch {
el.createEl("p", { text: this.t("exportBlockInvalid") });
2026-05-03 09:49:29 +00:00
return;
}
const profile = this.normalizeExportProfile(raw);
if (!profile) {
el.createEl("p", { text: this.t("exportBlockInvalid") });
2026-05-03 09:49:29 +00:00
return;
}
const wrap = el.createEl("div");
wrap.addClass("sfv-export-block");
wrap.createEl("strong", { text: this.t("exportBlockTitle") });
wrap.createEl("p", { text: `${this.t("exportBlockDesc")} ${profile.name || profile.sourceFolder}` }).addClass("sfv-export-desc");
const row = wrap.createEl("div");
row.addClass("sfv-export-row");
const openBtn = row.createEl("button", { text: this.t("exportBlockOpen") });
openBtn.addClass("mod-cta");
openBtn.addEventListener("click", () => {
const currentLeaf = this.app.workspace.getMostRecentLeaf();
void this.openProfileFromExport(raw, { leaf: currentLeaf, sourcePath: ctx.sourcePath }).then((ok) => {
if (!ok) new obsidian.Notice(this.t("exportBlockInvalid"));
});
2026-05-03 09:49:29 +00:00
});
const importBtn = row.createEl("button", { text: this.t("exportBlockImport") });
importBtn.addEventListener("click", () => {
void this.importProfileFromExport(raw, { sourcePath: ctx.sourcePath }).then((ok) => {
if (!ok) new obsidian.Notice(this.t("exportBlockInvalid"));
});
2026-05-03 09:49:29 +00:00
});
});
this.registerDomEvent(document, "pointerdown", (ev) => {
2026-05-03 09:49:29 +00:00
const target = ev.target;
if (!(target instanceof Element)) return;
const openBoxes = document.querySelectorAll(".smart-folder-view-root details[open]");
2026-05-03 09:49:29 +00:00
for (const box of openBoxes) {
if (box.contains(target)) continue;
box.removeAttribute("open");
2026-05-03 09:49:29 +00:00
}
}, true);
this.registerDomEvent(document, "keydown", (ev) => {
if (ev.key !== "Escape") return;
const openBoxes = document.querySelectorAll(".smart-folder-view-root details[open]");
for (const box of openBoxes) box.removeAttribute("open");
2026-05-03 09:49:29 +00:00
});
this.addRibbonIcon("layout-grid", this.t("title"), () => {
void askStartAction(this.app, this).then((action) => {
if (action === "open") new ProfileManagerModal(this.app, this).open();
else if (action === "create") new SetupModal(this.app, this).open();
});
2026-05-03 09:49:29 +00:00
});
this.addCommand({ id: "create-page", name: this.t("openBuilder"), callback: () => new SetupModal(this.app, this).open() });
this.addCommand({ id: "create-page-2", name: this.t("openBuilder"), callback: () => new SetupModal(this.app, this).open() });
this.addCommand({ id: "open-manager", name: this.t("openManager"), callback: () => new ProfileManagerModal(this.app, this).open() });
this.addCommand({ id: "open-last", name: this.t("openLast"), callback: () => {
void (async () => {
if (!this.getCurrentProfile()) {
new obsidian.Notice(this.t("noProfile"));
new SetupModal(this.app, this).open();
return;
}
await this.activateView({ profile: this.getCurrentProfile(), newLeaf: true });
})();
2026-05-03 09:49:29 +00:00
} });
this.addCommand({ id: "export-page", name: this.t("exportPage"), callback: () => {
void (async () => {
const profile = this.getCurrentProfile();
if (!profile) {
new obsidian.Notice(this.t("noProfile"));
return;
}
await this.exportProfileToMarkdown(profile);
})();
2026-05-03 09:49:29 +00:00
} });
this.addSettingTab(new SmartFolderSettingTab(this.app, this));
}
async activateView(opts = {}) {
const { profile = null, newLeaf = false, persistLastActive = true, leaf: preferredLeaf = null } = opts;
2026-05-03 09:49:29 +00:00
const { workspace } = this.app;
let leaf = preferredLeaf;
if (!leaf && !newLeaf) leaf = workspace.getLeavesOfType(VIEW_TYPE)[0] || null;
2026-05-03 09:49:29 +00:00
if (!leaf) {
leaf = workspace.getLeaf("tab");
}
if (leaf.view.getViewType?.() !== VIEW_TYPE) {
2026-05-03 09:49:29 +00:00
await leaf.setViewState({ type: VIEW_TYPE, active: true });
}
void workspace.revealLeaf(leaf);
2026-05-03 09:49:29 +00:00
const view = leaf.view;
if (view instanceof SmartFolderView) {
view.setPinnedProfile(profile || null);
if (persistLastActive && profile?.id) {
this.data.lastActiveProfileId = profile.id;
await this.persist();
}
view.onOpen();
2026-05-03 09:49:29 +00:00
}
}
onunload() {
}
async loadPluginData() {
const raw = await this.loadData();
this.data = {
language: raw?.language === "zh" || raw?.language === "en" ? raw.language : detectLanguage(),
languageMode: raw?.languageMode === "auto" || raw?.languageMode === "zh" || raw?.languageMode === "en" ? raw.languageMode : "auto",
2026-05-03 09:49:29 +00:00
profiles: Array.isArray(raw?.profiles) ? raw.profiles : [],
lastActiveProfileId: typeof raw?.lastActiveProfileId === "string" ? raw.lastActiveProfileId : "",
2026-05-03 09:49:29 +00:00
colorConfig: {
saturationBase: typeof raw?.colorConfig?.saturationBase === "number" ? clamp(raw.colorConfig.saturationBase, 20, 80) : DEFAULT_COLOR_CONFIG.saturationBase,
saturationRange: typeof raw?.colorConfig?.saturationRange === "number" ? clamp(raw.colorConfig.saturationRange, 1, 40) : DEFAULT_COLOR_CONFIG.saturationRange,
lightnessBase: typeof raw?.colorConfig?.lightnessBase === "number" ? clamp(raw.colorConfig.lightnessBase, 20, 70) : DEFAULT_COLOR_CONFIG.lightnessBase,
lightnessRange: typeof raw?.colorConfig?.lightnessRange === "number" ? clamp(raw.colorConfig.lightnessRange, 1, 30) : DEFAULT_COLOR_CONFIG.lightnessRange,
fallbackColor: typeof raw?.colorConfig?.fallbackColor === "string" && raw.colorConfig.fallbackColor.trim() ? raw.colorConfig.fallbackColor : DEFAULT_COLOR_CONFIG.fallbackColor
}
2026-05-03 09:49:29 +00:00
};
this.draftProfile = null;
}
async persist() {
await this.saveData(this.data);
}
};
2026-05-03 09:49:29 +00:00
module.exports = SmartFolderPlugin;