mirror of
https://github.com/ckelsoe/obsidian-shell-path-copy.git
synced 2026-07-22 13:10:27 +00:00
Add a Show in ribbon toggle per custom format that registers a left-ribbon icon copying that format from the active note. Off by default; icons register in format-list order. Add a Browse all icons button to the format icon picker, opening a searchable virtualized grid of every Obsidian icon (getIconIds) so a format can use any icon. Pure filter/window helpers are unit tested.
175 lines
8.7 KiB
TypeScript
175 lines
8.7 KiB
TypeScript
import { PathWrapping } from './path-utils';
|
|
|
|
// Custom-format model, the seeded built-in formats, and the migration helpers
|
|
// that produce them. Kept free of the Obsidian API so it can be unit-tested.
|
|
|
|
// Settings schema version. Bumped when a one-time migration is needed.
|
|
// 1: token engine; built-ins seeded as custom formats.
|
|
// 2: heading-aware link seeds added.
|
|
// 3: block-aware link seeds added.
|
|
// 4: plain-words line reference example seed added.
|
|
// 5: pinToRoot field added to CustomFormat (defaults to false on read).
|
|
// 6: appliesTo field added to CustomFormat (defaults to 'both' on read).
|
|
export const SETTINGS_VERSION = 6;
|
|
|
|
// Which kinds of items a format applies to. This is the user's *preference*; a
|
|
// hard capability gate (templateSupportsFolders) can still keep a format off
|
|
// folders when its template uses file-only tokens, regardless of this value.
|
|
export type FormatTarget = 'files' | 'folders' | 'both';
|
|
|
|
// A user-defined copy format. Each entry produces its own context-menu item and
|
|
// command-palette command via the token engine. The built-ins ship as seeded
|
|
// CustomFormat entries; there is no separate built-in code path.
|
|
export interface CustomFormat {
|
|
id: string; // stable unique id, used for command ids
|
|
name: string;
|
|
template: string;
|
|
wrapping: PathWrapping; // applied around the rendered result
|
|
icon: string; // Lucide icon name shown in the menu
|
|
enabled: boolean;
|
|
showInMenu: boolean;
|
|
showInCommands: boolean;
|
|
showInRibbon: boolean; // also add a left-ribbon icon that copies this format
|
|
pinToRoot: boolean; // also show at the root menu when the submenu is on
|
|
appliesTo: FormatTarget; // files, folders, or both (subject to token capability)
|
|
}
|
|
|
|
export const VALID_WRAPPINGS: PathWrapping[] = ['none', 'double-quotes', 'single-quotes', 'backticks'];
|
|
export const VALID_TARGETS: FormatTarget[] = ['files', 'folders', 'both'];
|
|
|
|
// Specification for a seeded built-in format. `legacyKey` maps to the pre-1.19
|
|
// boolean setting (or 'unix'/'windows' for the menuDisplay-governed paths) so
|
|
// migration can carry the user's prior choices forward. `sinceVersion` is the
|
|
// settings version that introduced the seed, for incremental migration.
|
|
interface SeedSpec {
|
|
name: string;
|
|
template: string;
|
|
icon: string;
|
|
wrapMode: 'path' | 'filename' | 'plain';
|
|
core: boolean; // enabled by default on a fresh install
|
|
legacyKey: string;
|
|
sinceVersion: number;
|
|
}
|
|
|
|
const BUILTIN_SEEDS: SeedSpec[] = [
|
|
{ name: 'Relative Linux/macOS path', template: '<relative-path-unix>', icon: 'terminal', wrapMode: 'path', core: true, legacyKey: 'unix', sinceVersion: 1 },
|
|
{ name: 'Relative Windows path', template: '<relative-path-windows>', icon: 'folder-closed', wrapMode: 'path', core: true, legacyKey: 'windows', sinceVersion: 1 },
|
|
{ name: 'Absolute path', template: '<absolute-path>', icon: 'folder-closed', wrapMode: 'path', core: false, legacyKey: 'showAbsolutePath', sinceVersion: 1 },
|
|
{ name: 'file:// URL', template: '<file-url>', icon: 'globe', wrapMode: 'plain', core: false, legacyKey: 'showFileUrl', sinceVersion: 1 },
|
|
{ name: 'Obsidian URL', template: '<obsidian-url>', icon: 'link-2', wrapMode: 'plain', core: true, legacyKey: 'showObsidianUrl', sinceVersion: 1 },
|
|
{ name: 'Markdown link', template: '<markdown-link>', icon: 'link', wrapMode: 'plain', core: true, legacyKey: 'showMarkdownLink', sinceVersion: 1 },
|
|
{ name: 'Filename', template: '<filename>', icon: 'file-text', wrapMode: 'filename', core: false, legacyKey: 'showFilename', sinceVersion: 1 },
|
|
{ name: 'Filename with extension', template: '<filename-ext>', icon: 'file', wrapMode: 'filename', core: false, legacyKey: 'showFilenameWithExt', sinceVersion: 1 },
|
|
// Example formats from issue 13. Seeded disabled as starting points.
|
|
{ name: 'Example: name and Obsidian URL', template: '<filename> -> <obsidian-url>', icon: 'link-2', wrapMode: 'plain', core: false, legacyKey: '', sinceVersion: 1 },
|
|
{ name: 'Example: line reference', template: '<filename-ext>#L<line-number>', icon: 'hash', wrapMode: 'plain', core: false, legacyKey: '', sinceVersion: 1 },
|
|
{ name: 'Example: name and line number', template: '<filename-ext> Line <line-number>', icon: 'hash', wrapMode: 'plain', core: false, legacyKey: '', sinceVersion: 4 },
|
|
// Heading-aware links: jump to the cursor's heading, or the file if none.
|
|
{ name: 'Obsidian URL (to heading)', template: '<obsidian-url-heading>', icon: 'link-2', wrapMode: 'plain', core: false, legacyKey: '', sinceVersion: 2 },
|
|
{ name: 'Wiki link (to heading)', template: '<wikilink-heading>', icon: 'hash', wrapMode: 'plain', core: false, legacyKey: '', sinceVersion: 2 },
|
|
// Block-aware links: jump to the cursor's block, creating a block id if needed.
|
|
{ name: 'Obsidian URL (to block)', template: '<obsidian-url-block>', icon: 'link-2', wrapMode: 'plain', core: false, legacyKey: '', sinceVersion: 3 },
|
|
{ name: 'Wiki link (to block)', template: '<wikilink-block>', icon: 'hash', wrapMode: 'plain', core: false, legacyKey: '', sinceVersion: 3 }
|
|
];
|
|
|
|
// Generates a stable id for a custom format. Prefers crypto.randomUUID (available
|
|
// in Obsidian's Electron and modern mobile runtimes); falls back to a timestamp +
|
|
// random suffix where it is not.
|
|
export function generateFormatId(): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `fmt-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
}
|
|
|
|
// Builds one CustomFormat from a seed spec. With no legacy data (fresh install)
|
|
// the core set is enabled with default wrapping. With legacy data, enabled state
|
|
// and wrapping are migrated from the pre-1.19 boolean settings so existing users
|
|
// keep exactly what they had.
|
|
function makeSeed(spec: SeedSpec, legacy: Record<string, unknown> | null): CustomFormat {
|
|
const oldWrap: PathWrapping = legacy && VALID_WRAPPINGS.includes(legacy.pathWrapping as PathWrapping)
|
|
? (legacy.pathWrapping as PathWrapping)
|
|
: 'backticks';
|
|
const menuDisplay = legacy ? legacy.menuDisplay : undefined;
|
|
const filenameWrap = legacy ? legacy.filenameUseWrapping === true : false;
|
|
|
|
let enabled: boolean;
|
|
if (!legacy || spec.legacyKey === '') {
|
|
// Fresh install, or a seed with no pre-1.19 equivalent.
|
|
enabled = spec.core;
|
|
} else if (spec.legacyKey === 'unix') {
|
|
enabled = menuDisplay !== 'windows';
|
|
} else if (spec.legacyKey === 'windows') {
|
|
enabled = menuDisplay !== 'linux-mac';
|
|
} else {
|
|
// Pre-1.19 show* booleans defaulted to true; absent counts as enabled.
|
|
enabled = legacy[spec.legacyKey] !== false;
|
|
}
|
|
|
|
let wrapping: PathWrapping = 'none';
|
|
if (spec.wrapMode === 'path') {
|
|
wrapping = legacy ? oldWrap : 'backticks';
|
|
} else if (spec.wrapMode === 'filename') {
|
|
wrapping = legacy && filenameWrap ? oldWrap : 'none';
|
|
}
|
|
|
|
return {
|
|
id: generateFormatId(),
|
|
name: spec.name,
|
|
template: spec.template,
|
|
wrapping,
|
|
icon: spec.icon,
|
|
enabled,
|
|
showInMenu: true,
|
|
showInCommands: true,
|
|
showInRibbon: false,
|
|
pinToRoot: false,
|
|
appliesTo: 'both'
|
|
};
|
|
}
|
|
|
|
// All seeded formats, for a fresh install or a pre-1.19 upgrade.
|
|
export function seedAllFormats(legacy: Record<string, unknown> | null): CustomFormat[] {
|
|
return BUILTIN_SEEDS.map((spec) => makeSeed(spec, legacy));
|
|
}
|
|
|
|
// Seeds introduced in a specific settings version, for incremental migration.
|
|
// These are always new (no legacy mapping), so they use fresh-install defaults.
|
|
export function seedFormatsForVersion(version: number): CustomFormat[] {
|
|
return BUILTIN_SEEDS.filter((spec) => spec.sinceVersion === version).map((spec) => makeSeed(spec, null));
|
|
}
|
|
|
|
// Coerces possibly-corrupt or hand-edited persisted data into a valid CustomFormat
|
|
// array. Missing fields fall back to safe defaults; duplicate ids are reassigned.
|
|
export function normalizeCustomFormats(value: unknown): CustomFormat[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
const seenIds = new Set<string>();
|
|
return value.map((raw): CustomFormat => {
|
|
const item = (raw ?? {}) as Partial<CustomFormat>;
|
|
const wrapping = VALID_WRAPPINGS.includes(item.wrapping as PathWrapping)
|
|
? (item.wrapping as PathWrapping)
|
|
: 'none';
|
|
let id = typeof item.id === 'string' && item.id ? item.id : generateFormatId();
|
|
while (seenIds.has(id)) {
|
|
id = generateFormatId();
|
|
}
|
|
seenIds.add(id);
|
|
return {
|
|
id,
|
|
name: typeof item.name === 'string' ? item.name : 'Custom format',
|
|
template: typeof item.template === 'string' ? item.template : '',
|
|
wrapping,
|
|
icon: typeof item.icon === 'string' && item.icon ? item.icon : 'clipboard-copy',
|
|
enabled: item.enabled !== false,
|
|
showInMenu: item.showInMenu !== false,
|
|
showInCommands: item.showInCommands !== false,
|
|
showInRibbon: item.showInRibbon === true,
|
|
pinToRoot: item.pinToRoot === true,
|
|
appliesTo: VALID_TARGETS.includes(item.appliesTo as FormatTarget)
|
|
? (item.appliesTo as FormatTarget)
|
|
: 'both'
|
|
};
|
|
});
|
|
}
|