Skip to content

Commit 39c465f

Browse files
committed
refactor: adopt Fancy Kit UI and Vault harnesses
1 parent de3807b commit 39c465f

12 files changed

Lines changed: 734 additions & 181 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,7 @@ If the note has the tag that is set in here, the note would be treated as there
292292
Tags that were set here would be treated as there were not.
293293

294294
##### Archive tags
295+
296+
## Development
297+
298+
Contributor setup, tests, and UI/Vault workflow architecture are documented in [the developer guide](docs/devs.md).

docs/devs.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Developer guide
2+
3+
## Setup and checks
4+
5+
Install the locked dependencies and run the repository gate:
6+
7+
```bash
8+
npm ci
9+
npm run check
10+
npm run build
11+
```
12+
13+
The App-free Vitest suite includes TagFolder tree utilities and the new-note application workflow.
14+
15+
## UI and Vault boundaries
16+
17+
The plug-in owns one `UiInteractions` capability and one `VaultTextAccess` capability for its lifetime:
18+
19+
```ts
20+
this.ui = createObsidianUi(this.app);
21+
this.vaultText = createObsidianVaultTextAccess(this.app.vault);
22+
```
23+
24+
`new-note-workflow.ts` accepts those capabilities instead of constructing an Obsidian template modal or reading and writing `TFile` instances directly. TagFolder still owns template variables, frontmatter policy, note creation, settings, and visible labels.
25+
26+
Application-flow tests use instance-scoped App-free harnesses:
27+
28+
```ts
29+
const ui = createUiTestHarness([
30+
{ kind: "pickOne", interactionId: "new-note-template", value: template },
31+
]);
32+
const vault = createVaultTextTestHarness({
33+
files: {
34+
"Templates/project.md": "# {{tagName}}",
35+
"Untitled.md": "",
36+
},
37+
});
38+
```
39+
40+
The UI transcript verifies the stable interaction ID and selected object identity. The Vault transcript verifies template reads, note writes, write ordering, and the absence of text writes when frontmatter owns the update.
41+
42+
The path-based Vault capability deliberately does not replace real Obsidian coverage for `TFile` identity, Vault events, MetadataCache propagation, or frontmatter processing.
43+
44+
## Fancy Kit preview
45+
46+
Until Fancy Kit is published to npm, its packages are pinned to immutable tarballs from one GitHub prerelease. Update the UI interactions, Obsidian plug-in kit, and test-session URLs together when a migration needs a newer preview.
47+
48+
Real-Obsidian scenarios are local-only and currently validated on Linux only.
49+
50+
```bash
51+
npm run check:e2e:obsidian
52+
npm run test:e2e:obsidian:new-note-template
53+
```
54+
55+
The real scenario uses the production UI and Vault adapters. It selects a template through Obsidian, creates a real note, and verifies the persisted content; it never installs a scripted driver into the plug-in.

main.ts

Lines changed: 44 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ import {
1414
Plugin,
1515
PluginSettingTab,
1616
Setting,
17-
SuggestModal,
1817
TFile,
1918
WorkspaceLeaf,
2019
TAbstractFile,
2120
type MarkdownFileInfo,
2221
} from "obsidian";
22+
import { createObsidianUi, type UiInteractions } from "@vrtmrz/obsidian-plugin-kit/ui";
23+
import {
24+
createObsidianVaultTextAccess,
25+
type VaultTextAccess,
26+
} from "@vrtmrz/obsidian-plugin-kit/vault";
2327

2428
import {
2529
DEFAULT_SETTINGS,
@@ -56,7 +60,11 @@ import {
5660
trimPrefix,
5761
uniqueCaseIntensive
5862
} from "./util";
59-
import { renderTagFolderTemplateVariables } from "./new-note-template";
63+
import {
64+
chooseNewNoteTemplate,
65+
populateNewNote,
66+
type NewNoteTemplateChoice,
67+
} from "./new-note-workflow";
6068
import { ScrollView } from "./ScrollView";
6169
import { TagFolderView } from "./TagFolderView";
6270
import { TagFolderList } from "./TagFolderList";
@@ -100,8 +108,6 @@ function getCompareMethodItems(settings: TagFolderSettings) {
100108
}
101109
}
102110

103-
type NewNoteTemplateChoice = TFile;
104-
105111
function getCoreTemplatesFolder(app: App): string | null {
106112
const internalPlugins = (app as App & {
107113
internalPlugins?: {
@@ -135,44 +141,6 @@ function getTemplateFiles(app: App) {
135141
return templates.sort((a, b) => compare(a.path, b.path));
136142
}
137143

138-
class NewNoteTemplateSuggestModal extends SuggestModal<NewNoteTemplateChoice> {
139-
private callback?: (template: NewNoteTemplateChoice | false) => void;
140-
private templates: TFile[];
141-
142-
constructor(app: App, templates: TFile[], callback: (template: NewNoteTemplateChoice | false) => void) {
143-
super(app);
144-
this.templates = templates;
145-
this.callback = callback;
146-
this.setPlaceholder("Type to search templates...");
147-
}
148-
149-
getSuggestions(query: string): NewNoteTemplateChoice[] {
150-
const normalizedQuery = query.toLowerCase();
151-
return this.templates.filter((file) =>
152-
file.path.toLowerCase().contains(normalizedQuery)
153-
);
154-
}
155-
156-
renderSuggestion(template: NewNoteTemplateChoice, el: HTMLElement) {
157-
el.createDiv({ text: template.basename });
158-
el.createDiv({ text: template.path, cls: "suggestion-note" });
159-
}
160-
161-
onChooseSuggestion(template: NewNoteTemplateChoice) {
162-
this.callback?.(template);
163-
this.callback = undefined;
164-
}
165-
166-
onClose(): void {
167-
window.setTimeout(() => {
168-
if (this.callback) {
169-
this.callback(false);
170-
this.callback = undefined;
171-
}
172-
}, 100);
173-
}
174-
}
175-
176144
class NewNoteTemplateInputSuggest extends AbstractInputSuggest<TFile> {
177145
private callback: (template: TFile) => void;
178146

@@ -201,20 +169,19 @@ class NewNoteTemplateInputSuggest extends AbstractInputSuggest<TFile> {
201169
}
202170
}
203171

204-
function askNewNoteTemplate(app: App): Promise<NewNoteTemplateChoice | false> {
205-
return new Promise((resolve) => {
206-
const templates = getTemplateFiles(app);
207-
if (templates.length == 0) {
208-
new Notice("No templates found");
209-
resolve(false);
210-
return;
211-
}
212-
const modal = new NewNoteTemplateSuggestModal(app, templates, resolve);
213-
modal.open();
214-
});
172+
async function askNewNoteTemplate(ui: UiInteractions, app: App): Promise<NewNoteTemplateChoice | null> {
173+
const templates = getTemplateFiles(app).map((file) => ({
174+
path: file.path,
175+
name: file.basename,
176+
}));
177+
if (templates.length == 0) {
178+
new Notice("No templates found");
179+
return null;
180+
}
181+
return await chooseNewNoteTemplate(ui, templates);
215182
}
216183

217-
function getConfiguredNewNoteTemplate(app: App, templatePath: string): TFile | null {
184+
function getConfiguredNewNoteTemplate(app: App, templatePath: string): NewNoteTemplateChoice | null {
218185
const inputPath = normalizeNewNoteTemplatePath(templatePath);
219186
if (inputPath == "") return null;
220187

@@ -240,7 +207,7 @@ function getConfiguredNewNoteTemplate(app: App, templatePath: string): TFile | n
240207
return null;
241208
}
242209

243-
return file;
210+
return { path: file.path, name: file.basename };
244211
}
245212

246213
function normalizeNewNoteTemplatePath(templatePath: string) {
@@ -260,6 +227,8 @@ function onElement<T extends HTMLElement | Document>(el: T, event: string, selec
260227

261228
export default class TagFolderPlugin extends Plugin {
262229
settings: TagFolderSettings = { ...DEFAULT_SETTINGS };
230+
ui!: UiInteractions;
231+
vaultText!: VaultTextAccess;
263232

264233
// Folder opening status.
265234
expandedFolders: string[] = ["root"];
@@ -367,6 +336,8 @@ export default class TagFolderPlugin extends Plugin {
367336

368337
async onload() {
369338
await this.loadSettings();
339+
this.ui = createObsidianUi(this.app);
340+
this.vaultText = createObsidianVaultTextAccess(this.app.vault);
370341
this.hoverPreview = this.hoverPreview.bind(this);
371342
this.modifyFile = this.modifyFile.bind(this);
372343
this.setSearchString = this.setSearchString.bind(this);
@@ -1402,31 +1373,28 @@ export default class TagFolderPlugin extends Plugin {
14021373
const selectedTemplate = configuredTemplatePath == ""
14031374
? null
14041375
: (getConfiguredNewNoteTemplate(this.app, configuredTemplatePath)
1405-
?? await askNewNoteTemplate(this.app));
1376+
?? await askNewNoteTemplate(this.ui, this.app));
14061377

14071378
//@ts-ignore
14081379
const ww = await this.app.fileManager.createAndOpenMarkdownFile();
14091380
if (!(ww instanceof TFile)) return;
1410-
if (selectedTemplate != null && selectedTemplate !== false) {
1411-
const template = await this.app.vault.read(selectedTemplate);
1412-
const renderedTemplate = renderTagFolderTemplateVariables(template, expandedTagsAll, expandedTags);
1413-
if (renderedTemplate.trim() != "") {
1414-
await this.app.vault.modify(ww, renderedTemplate);
1415-
}
1416-
return;
1417-
}
1418-
1419-
if (this.settings.useFrontmatterTagsForNewNotes) {
1420-
await this.app.fileManager.processFrontMatter(ww, (matter) => {
1421-
matter.tags = matter.tags ?? [];
1422-
matter.tags = expandedTagsAll
1423-
.filter(e => !isSpecialTag(e))
1424-
.filter(e => matter.tags.indexOf(e) < 0)
1425-
.concat(matter.tags);
1426-
});
1427-
} else {
1428-
await this.app.vault.append(ww, expandedTags);
1429-
}
1381+
await populateNewNote({
1382+
vault: this.vaultText,
1383+
notePath: ww.path,
1384+
template: selectedTemplate,
1385+
expandedTagsAll,
1386+
expandedTags,
1387+
frontmatterTags: expandedTagsAll.filter(e => !isSpecialTag(e)),
1388+
useFrontmatterTags: this.settings.useFrontmatterTagsForNewNotes,
1389+
applyFrontmatterTags: async (frontmatterTags) => {
1390+
await this.app.fileManager.processFrontMatter(ww, (matter) => {
1391+
matter.tags = matter.tags ?? [];
1392+
matter.tags = frontmatterTags
1393+
.filter(e => matter.tags.indexOf(e) < 0)
1394+
.concat(matter.tags);
1395+
});
1396+
},
1397+
});
14301398
}
14311399
}
14321400

new-note-workflow.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { UiInteractions } from "@vrtmrz/obsidian-plugin-kit/ui";
2+
import type { VaultTextAccess } from "@vrtmrz/obsidian-plugin-kit/vault";
3+
import { renderTagFolderTemplateVariables } from "./new-note-template";
4+
5+
export const NEW_NOTE_TEMPLATE_INTERACTION_ID = "new-note-template";
6+
7+
/** Template identity and labels exposed to the application workflow. */
8+
export interface NewNoteTemplateChoice {
9+
/** Vault-relative template path. */
10+
readonly path: string;
11+
/** Primary visible template name. */
12+
readonly name: string;
13+
}
14+
15+
/** Requests one template by identity, or returns `null` when dismissed or empty. */
16+
export async function chooseNewNoteTemplate(
17+
ui: UiInteractions,
18+
templates: readonly NewNoteTemplateChoice[],
19+
): Promise<NewNoteTemplateChoice | null> {
20+
if (templates.length == 0) return null;
21+
return await ui.pickOne(
22+
{
23+
items: templates,
24+
getText: (template) => template.name,
25+
getDescription: (template) => template.path,
26+
placeholder: "Type to search templates...",
27+
},
28+
NEW_NOTE_TEMPLATE_INTERACTION_ID,
29+
);
30+
}
31+
32+
/** Inputs owned by TagFolder while populating a newly created note. */
33+
export interface PopulateNewNoteOptions {
34+
/** Injectable path-based Vault text capability. */
35+
readonly vault: VaultTextAccess;
36+
/** Vault-relative path of the already created note. */
37+
readonly notePath: string;
38+
/** Selected template, or `null` to apply tags without a template. */
39+
readonly template: NewNoteTemplateChoice | null;
40+
/** Expanded tags used by template variables. */
41+
readonly expandedTagsAll: readonly string[];
42+
/** Expanded hashtag string used by template variables or body append. */
43+
readonly expandedTags: string;
44+
/** Non-special tags supplied to the frontmatter callback. */
45+
readonly frontmatterTags: readonly string[];
46+
/** Whether no-template tags should be written through frontmatter. */
47+
readonly useFrontmatterTags: boolean;
48+
/** Consumer-owned Obsidian frontmatter mutation. */
49+
readonly applyFrontmatterTags: (tags: readonly string[]) => Promise<void>;
50+
}
51+
52+
/** Populates a new note from a template, frontmatter tags, or appended hashtags. */
53+
export async function populateNewNote(options: PopulateNewNoteOptions): Promise<void> {
54+
if (options.template !== null) {
55+
const template = await options.vault.readText(options.template.path);
56+
const renderedTemplate = renderTagFolderTemplateVariables(
57+
template,
58+
[...options.expandedTagsAll],
59+
options.expandedTags,
60+
);
61+
if (renderedTemplate.trim() != "") {
62+
await options.vault.modifyText(options.notePath, renderedTemplate);
63+
}
64+
return;
65+
}
66+
67+
if (options.useFrontmatterTags) {
68+
await options.applyFrontmatterTags(options.frontmatterTags);
69+
return;
70+
}
71+
72+
await options.vault.appendText(options.notePath, options.expandedTags);
73+
}

0 commit comments

Comments
 (0)