Add persistent skill history and skill:: autocomplete

Track every skill ever used in a new skillHistory setting that is never
auto-pruned, so skills removed from the active list are still available.

- EditorSuggest dropdown for the skill:: inline field, sourced from the
  union of active skills and history
- Capture skills as they're added: settings edits (on blur), autocomplete
  selection, and skills typed inline (via vault modify, debounced, not
  per keystroke)
- Scan existing notes into history on load and via a new "Rebuild skill
  history from notes" command
- Settings UI lists the full history with per-entry and bulk delete
- Bump tsconfig target/lib to ES2018 for String.padStart
This commit is contained in:
pdriggett 2026-06-20 10:49:37 -07:00
parent 10977dad11
commit 0915a08f84
3 changed files with 263 additions and 4 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ data.json
.DS_Store
.vscode/
.idea/
.claude/

260
main.ts
View file

@ -1,5 +1,10 @@
import {
App,
Editor,
EditorPosition,
EditorSuggest,
EditorSuggestContext,
EditorSuggestTriggerInfo,
Notice,
Plugin,
PluginSettingTab,
@ -14,12 +19,14 @@ interface PracticePlannerSettings {
folderPath: string;
weekStartDay: DayIndex;
skills: string[];
skillHistory: string[];
}
const DEFAULT_SETTINGS: PracticePlannerSettings = {
folderPath: "Practice",
weekStartDay: 1,
skills: ["Scales", "Technique", "Repertoire", "Sight Reading"],
skillHistory: [],
};
const DAY_NAMES = [
@ -40,6 +47,10 @@ export default class PracticePlannerPlugin extends Plugin {
async onload() {
await this.loadSettings();
// Seed history from the currently active skills so existing setups
// immediately have something to autocomplete.
this.recordSkills(this.settings.skills);
this.addCommand({
id: "open-this-week",
name: "Open this week's practice plan",
@ -58,7 +69,38 @@ export default class PracticePlannerPlugin extends Plugin {
callback: () => this.openWeek(-1),
});
this.addCommand({
id: "rebuild-skill-history",
name: "Rebuild skill history from notes",
callback: async () => {
const found = await this.scanVaultForSkills();
new Notice(
`Practice Planner: scanned notes and found ${found} skill${found === 1 ? "" : "s"}.`,
);
},
});
this.registerEditorSuggest(new SkillSuggest(this));
this.addSettingTab(new PracticePlannerSettingTab(this.app, this));
// Capture skills typed inline (even brand-new ones never picked from the
// dropdown) into history. `modify` fires when the editor flushes to disk
// — debounced after a pause / blur / note switch — not per keystroke.
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (file instanceof TFile && this.isPracticeNote(file)) {
void this.extractSkills(file).then((skills) =>
this.recordSkills(skills),
);
}
}),
);
// Pick up skills used in existing notes once the vault is ready.
this.app.workspace.onLayoutReady(() => {
void this.scanVaultForSkills();
});
}
async loadSettings() {
@ -71,6 +113,93 @@ export default class PracticePlannerPlugin extends Plugin {
await this.saveData(this.settings);
}
/**
* Merge the given skill names into the persistent history. History entries
* are never auto-removed and are de-duped case-insensitively, preserving
* the first-seen casing. Saves only when something new was added.
*/
recordSkills(names: string[]): void {
const seen = new Set(
this.settings.skillHistory.map((s) => s.toLowerCase()),
);
let changed = false;
for (const raw of names) {
const name = raw.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
this.settings.skillHistory.push(name);
changed = true;
}
if (changed) {
void this.saveSettings();
}
}
/**
* Union of active skills and history, de-duped case-insensitively and
* sorted. This is the source list for the `skill::` autocomplete.
*/
knownSkills(): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of [...this.settings.skills, ...this.settings.skillHistory]) {
const name = raw.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(name);
}
out.sort((a, b) => a.localeCompare(b));
return out;
}
/** True if the file is a markdown note under the configured practice folder. */
isPracticeNote(file: TFile): boolean {
if (file.extension !== "md") return false;
const folder = this.settings.folderPath.replace(/^\/+|\/+$/g, "");
const prefix = folder ? `${folder}/` : "";
return !prefix || file.path.startsWith(prefix);
}
/** Extract distinct `skill::` inline field values from a single note. */
async extractSkills(file: TFile): Promise<string[]> {
const content = await this.app.vault.cachedRead(file);
const found = new Set<string>();
// `[ \t]*` (not `\s*`) so an empty `skill::` line never swallows the
// following `minutes::` line.
const re = /skill::[ \t]*([^\n]+)/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(content)) !== null) {
const value = match[1].trim();
if (value) found.add(value);
}
return [...found];
}
/**
* Scan markdown notes under the configured folder for `skill::` inline
* field values and merge them into history. Returns the count of distinct
* skill values encountered across the scanned notes.
*/
async scanVaultForSkills(): Promise<number> {
const files = this.app.vault
.getMarkdownFiles()
.filter((f) => this.isPracticeNote(f));
const found = new Set<string>();
for (const file of files) {
for (const skill of await this.extractSkills(file)) {
found.add(skill);
}
}
this.recordSkills([...found]);
return found.size;
}
async openWeek(weekOffset: number) {
const today = new Date();
const target = new Date(today);
@ -223,12 +352,17 @@ ${dailySections}`;
class PracticePlannerSettingTab extends PluginSettingTab {
plugin: PracticePlannerPlugin;
private historyEl: HTMLElement | null = null;
constructor(app: App, plugin: PracticePlannerPlugin) {
super(app, plugin);
this.plugin = plugin;
}
private refreshHistory(): void {
if (this.historyEl) this.renderHistory(this.historyEl);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
@ -263,6 +397,8 @@ class PracticePlannerSettingTab extends PluginSettingTab {
});
this.renderSkills(containerEl.createDiv());
this.historyEl = containerEl.createDiv();
this.renderHistory(this.historyEl);
}
private renderSkills(containerEl: HTMLElement): void {
@ -282,6 +418,12 @@ class PracticePlannerSettingTab extends PluginSettingTab {
this.plugin.settings.skills[index] = value.trim();
void this.plugin.saveSettings();
});
// Record the finished name (not every keystroke) when the
// field loses focus, then refresh the history list.
text.inputEl.addEventListener("blur", () => {
this.plugin.recordSkills([this.plugin.settings.skills[index]]);
this.refreshHistory();
});
}).addExtraButton((btn) => {
btn
.setIcon("trash")
@ -290,6 +432,7 @@ class PracticePlannerSettingTab extends PluginSettingTab {
this.plugin.settings.skills.splice(index, 1);
void this.plugin.saveSettings();
this.renderSkills(containerEl);
this.refreshHistory();
});
});
});
@ -310,9 +453,126 @@ class PracticePlannerSettingTab extends PluginSettingTab {
.setCta()
.onClick(() => {
this.plugin.settings.skills = [...DEFAULT_SETTINGS.skills];
this.plugin.recordSkills(this.plugin.settings.skills);
void this.plugin.saveSettings();
this.renderSkills(containerEl);
this.refreshHistory();
});
});
}
private renderHistory(containerEl: HTMLElement): void {
containerEl.empty();
const active = new Set(
this.plugin.settings.skills.map((s) => s.trim().toLowerCase()),
);
const history = [...this.plugin.settings.skillHistory].sort((a, b) =>
a.localeCompare(b),
);
new Setting(containerEl)
.setName("Skill history")
.setDesc(
"Every skill ever used, including ones removed from the list above. These power the skill:: autocomplete in your notes.",
)
.setHeading();
if (history.length === 0) {
containerEl.createEl("p", {
text: "No skills recorded yet.",
cls: "setting-item-description",
});
} else {
for (const name of history) {
const isActive = active.has(name.trim().toLowerCase());
const row = new Setting(containerEl).setName(
isActive ? `${name} (active)` : name,
);
row.addExtraButton((btn) => {
btn
.setIcon("trash")
.setTooltip(
isActive
? "Remove from history (still in the list above)"
: "Remove from history",
)
.onClick(() => {
this.plugin.settings.skillHistory =
this.plugin.settings.skillHistory.filter(
(s) => s !== name,
);
void this.plugin.saveSettings();
this.renderHistory(containerEl);
});
});
}
}
new Setting(containerEl).addButton((btn) => {
btn
.setButtonText("Clear history")
.setWarning()
.onClick(() => {
// Keep the active skills so autocomplete still works.
this.plugin.settings.skillHistory = [
...this.plugin.settings.skills,
]
.map((s) => s.trim())
.filter(Boolean);
void this.plugin.saveSettings();
this.renderHistory(containerEl);
});
});
}
}
class SkillSuggest extends EditorSuggest<string> {
plugin: PracticePlannerPlugin;
constructor(plugin: PracticePlannerPlugin) {
super(plugin.app);
this.plugin = plugin;
}
onTrigger(
cursor: EditorPosition,
editor: Editor,
): EditorSuggestTriggerInfo | null {
const line = editor.getLine(cursor.line).slice(0, cursor.ch);
const match = line.match(/skill::[ \t]*(.*)$/i);
if (!match) return null;
const query = match[1];
// Column where the value begins (just after `skill::` and any spaces).
const start = cursor.ch - query.length;
return {
start: { line: cursor.line, ch: start },
end: cursor,
query,
};
}
getSuggestions(context: EditorSuggestContext): string[] {
const query = context.query.trim().toLowerCase();
const skills = this.plugin.knownSkills();
if (!query) return skills;
return skills.filter((s) => s.toLowerCase().includes(query));
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
selectSuggestion(value: string): void {
const { context } = this;
if (!context) return;
context.editor.replaceRange(value, context.start, context.end);
const end: EditorPosition = {
line: context.start.line,
ch: context.start.ch + value.length,
};
context.editor.setCursor(end);
this.plugin.recordSkills([value]);
}
}

View file

@ -4,7 +4,7 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"target": "ES2018",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
@ -13,9 +13,7 @@
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2018"
]
},
"include": [