Rebrand plugin to Glicko, with migration

This commit is contained in:
Jack Williams 2026-03-07 12:36:37 +11:00
parent 78ca27b234
commit a2fc35429f
27 changed files with 676 additions and 441 deletions

View file

@ -1,6 +1,6 @@
# Pairwise Elo ranking for your notes
# Pairwise Glicko ranking for your notes
Easily sort notes by any subjective criteria using the **Elo rating system**.
Easily sort notes by any subjective criteria using the **Glicko rating system**.
It's basically "which one wins?" instead of trying to assign absolute scores. Rather than asking "how does this note compare to every other note?", you only answer: "which of these two do I prefer?"
@ -21,8 +21,8 @@ _Other comparison arena UI options are available in Settings - shown here is **r
## Try it in two minutes
1. Install: Not yet in the Community Plugins list, so first install [BRAT](https://obsidian.md/plugins?id=obsidian42-brat)
2. Add the Pairwise Elo Ranking repo - `https://github.com/WilliamsJack/pairwise-elo-ranking` and enable the plugin
3. Run the **Pairwise Elo Ranking: Start rating session** command (or click the trophy icon)
2. Add the Pairwise Glicko Ranking repo - `https://github.com/WilliamsJack/pairwise-elo-ranking` and enable the plugin
3. Run the **Pairwise Glicko Ranking: Start rating session** command (or click the trophy icon)
4. Pick a small cohort (10 notes is perfect)
5. Do ~10-20 comparisons and you'll see an order emerge surprisingly fast
@ -41,7 +41,7 @@ _Other comparison arena UI options are available in Settings - shown here is **r
- Information-gain matchmaking that automatically picks the most useful pairs to compare
- A stability progress bar that shows how close your rankings are to converging
- Glicko-1 rating updates - uncertainty (sigma) governs step sizes automatically, so new notes converge fast and experienced notes stay stable
- Robust to renames and moves via stable per-note Elo IDs
- Robust to renames and moves via stable per-note IDs
- Cohorts are saved so you can resume ranking sessions, picking up where you left off
## Motivation
@ -62,7 +62,7 @@ That's essentially what this plugin does. Given two notes, it's much easier to c
![cohort_creator](docs/images/cohort_creator.webp)
- Click the trophy icon in the left ribbon, or run the command **"Pairwise Elo Ranking: Start rating session"**.
- Click the trophy icon in the left ribbon, or run the command **"Pairwise Glicko Ranking: Start rating session"**.
- Create a cohort in the picker:
- Vault: all notes
- From a Base (.base file + view)
@ -87,7 +87,7 @@ A toast shows the winner after each comparison (toggle in Settings).
### End the session
Press Escape or run **"Pairwise Elo Ranking: End current session"**. If you've enabled a Rank property for this cohort, the plugin recomputes ranks across the cohort and writes them to frontmatter.
Press Escape or run **"Pairwise Glicko Ranking: End current session"**. If you've enabled a Rank property for this cohort, the plugin recomputes ranks across the cohort and writes them to frontmatter.
### Configurable frontmatter output
@ -121,25 +121,25 @@ If a Base cohort's **.base file** is moved/renamed, or the **view name** no long
## Frontmatter
### Elo IDs
### Note IDs
Each note that participates in a session gets a stable Elo ID so your ratings survive file moves and renames.
Each note that participates in a session gets a stable note ID so your ratings survive file moves and renames.
- Where: by default in frontmatter as `eloId`; you can choose to store it at the end of the note as an HTML comment instead.
- Where: by default in frontmatter as `glickoId`; you can choose to store it at the end of the note as an HTML comment instead. The property name is configurable in Settings.
- When: IDs are created lazily the first time a note is shown in a session.
Example (frontmatter):
```yaml
---
eloId: 123e4567-e89b-12d3-a456-426614174000
glickoId: 123e4567-e89b-12d3-a456-426614174000
---
```
Example (anywhere in the body of a note, but appended to the end by default):
```markdown
<!-- eloId: 123e4567-e89b-12d3-a456-426614174000 -->
<!-- glickoId: 123e4567-e89b-12d3-a456-426614174000 -->
```
### Stored properties
@ -158,14 +158,15 @@ Stats are written to just the two notes involved after each match. Rank across t
## Data and integrity
- Ratings are stored per cohort in the plugin's data; no network calls.
- Notes are tracked by Elo ID, not by path, so renames and moves are fine.
- Notes are tracked by note ID, not by path, so renames and moves are fine.
- On session start, the plugin prunes any players that no longer have a corresponding note in the cohort.
- Only Markdown files are included. You need at least two notes to start.
## Settings overview
- Winner toasts
- Where to store Elo IDs: frontmatter (default) or end-of-note comment
- Where to store note IDs: frontmatter (default) or end-of-note comment
- Note ID property name (default: `glickoId`)
- Progress bar settings (stability threshold, surprise highlight)
- Default frontmatter properties (names and which to write)
- Ask for per-cohort overrides when creating a cohort (on by default)

View file

@ -46,7 +46,14 @@ export default tseslint.config(
'obsidianmd/ui/sentence-case': [
'warn',
{
brands: ['Elo', 'Markdown', 'Obsidian', 'Command palette', 'Pairwise Elo Ranking'],
brands: [
'Glicko',
'Markdown',
'Obsidian',
'Command palette',
'Pairwise Glicko Ranking',
'HTML',
],
acronyms: ['ID', 'K'],
},
],

View file

@ -1,9 +1,9 @@
{
"id": "pairwise-elo-ranking",
"name": "Pairwise Elo Ranking",
"name": "Pairwise Glicko Ranking",
"version": "1.5.0",
"minAppVersion": "1.10.0",
"description": "Easily sort notes by any subjective criteria - Rank cohorts of notes in your vault by pairwise comparisons using the Elo rating system.",
"description": "Easily sort notes by any subjective criteria - Rank cohorts of notes in your vault by pairwise comparisons using the Glicko rating system.",
"author": "Jack Williams",
"authorUrl": "https://github.com/WilliamsJack",
"fundingUrl": "https://github.com/sponsors/WilliamsJack",

View file

@ -1,7 +1,7 @@
{
"name": "pairwise-elo-ranking",
"version": "1.5.0",
"description": "Easily sort notes by any subjective criteria - Rank cohorts of notes in your vault by pairwise comparisons using the Elo rating system.",
"description": "Easily sort notes by any subjective criteria - Rank cohorts of notes in your vault by pairwise comparisons using the Glicko rating system.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",

View file

@ -2,7 +2,7 @@ import type { App, TFile } from 'obsidian';
import { Notice } from 'obsidian';
import { prettyCohortDefinition, resolveFilesForCohort } from '../domain/cohort/CohortResolver';
import type { EloSettings } from '../settings';
import type { GlickoSettings } from '../settings';
import { effectiveFrontmatterProperties } from '../settings';
import type { PluginDataStore } from '../storage/PluginDataStore';
import { ConfirmModal } from '../ui/ConfirmModal';
@ -12,23 +12,23 @@ import {
updateCohortFrontmatter,
writeFrontmatterStatsForPlayer,
} from '../utils/FrontmatterStats';
import { getEloId } from '../utils/NoteIds';
import { getNoteId } from '../utils/NoteIds';
export async function resetNoteRating(
app: App,
dataStore: PluginDataStore,
settings: EloSettings,
settings: GlickoSettings,
file: TFile,
): Promise<void> {
const eloId = await getEloId(app, file);
if (!eloId) {
new Notice('This note has no Elo ID.');
const noteId = await getNoteId(app, file, settings.idPropertyName);
if (!noteId) {
new Notice('This note has no note ID.');
return;
}
// Find cohorts containing this player
const matchingKeys = Object.keys(dataStore.store.cohorts).filter(
(key) => !!dataStore.store.cohorts[key]?.players[eloId],
(key) => !!dataStore.store.cohorts[key]?.players[noteId],
);
if (matchingKeys.length === 0) {
@ -69,7 +69,7 @@ export async function resetNoteRating(
if (!ok) return;
for (const key of keysToReset) {
dataStore.resetPlayer(key, eloId);
dataStore.resetPlayer(key, noteId);
}
await dataStore.saveStore();
@ -91,7 +91,7 @@ export async function resetNoteRating(
const rankMap = computeRankMap(cohort);
// Write the reset note's own stats (rating, matches, wins, rank)
await writeFrontmatterStatsForPlayer(app, fm, cohort, file, eloId, rankMap);
await writeFrontmatterStatsForPlayer(app, fm, cohort, file, noteId, rankMap);
// Update rank across the whole cohort since relative ordering changed
const rankCfg = fm.rank;
@ -108,6 +108,7 @@ export async function resetNoteRating(
rankCfg.property,
undefined,
'Updating ranks...',
settings.idPropertyName,
);
}
}

View file

@ -103,7 +103,7 @@ async function awaitBasesControllerReady(
await sleep(pollMs);
}
throw new Error('[Elo][Bases] Timed out waiting for Bases controller');
throw new Error('[Glicko][Bases] Timed out waiting for Bases controller');
}
/**
@ -134,7 +134,7 @@ async function waitForResultsToSettle(
if (performance.now() >= deadline) {
if (resultsOk) return { settled: false };
throw new Error('[Elo][Bases] Timed out waiting for Bases results container');
throw new Error('[Glicko][Bases] Timed out waiting for Bases results container');
}
await sleep(pollMs);
@ -166,7 +166,7 @@ export async function resolveFilesFromBaseView(
): Promise<TFile[]> {
const af = app.vault.getAbstractFileByPath(basePath);
if (!(af instanceof TFile) || af.extension.toLowerCase() !== 'base') {
throw new Error(`[Elo][Bases] Not a .base file: ${basePath}`);
throw new Error(`[Glicko][Bases] Not a .base file: ${basePath}`);
}
const baseFile = af;
@ -178,14 +178,14 @@ export async function resolveFilesFromBaseView(
try {
leaf = app.workspace.getLeaf('tab');
if (!leaf) throw new Error('[Elo][Bases] Could not create a workspace leaf');
if (!leaf) throw new Error('[Glicko][Bases] Could not create a workspace leaf');
await openBaseIntoLeaf(app, leaf, baseFile, viewName);
const basesView = getBasesView(leaf);
if (!basesView) {
const viewType = leaf.view.getViewType();
throw new Error(`[Elo][Bases] Unexpected view type: ${String(viewType)}`);
throw new Error(`[Glicko][Bases] Unexpected view type: ${String(viewType)}`);
}
const controller = await awaitBasesControllerReady(

View file

@ -1,10 +1,10 @@
import type { App, TFile } from 'obsidian';
import type { PluginDataStore } from '../../storage/PluginDataStore';
import { getEloId } from '../../utils/NoteIds';
import { getNoteId } from '../../utils/NoteIds';
/**
* Scan all notes in the cohort to collect Elo IDs (frontmatter or HTML comment),
* Scan all notes in the cohort to collect note IDs (frontmatter or HTML comment),
* then remove any players from the cohort whose IDs are not present.
*
* Returns the list of removed IDs.
@ -14,6 +14,7 @@ export async function reconcileCohortPlayersWithFiles(
dataStore: PluginDataStore,
cohortKey: string,
files: TFile[],
idPropertyName: string,
): Promise<string[]> {
const cohort = dataStore.store.cohorts[cohortKey];
if (!cohort) return [];
@ -21,7 +22,7 @@ export async function reconcileCohortPlayersWithFiles(
const foundIds = new Set<string>();
await Promise.all(
files.map(async (f) => {
const id = await getEloId(app, f);
const id = await getNoteId(app, f, idPropertyName);
if (id) foundIds.add(id);
}),
);

View file

@ -4,22 +4,22 @@ import { Notice, Plugin, TFile } from 'obsidian';
import { resetNoteRating } from './commands/ResetNoteRating';
import { reconcileCohortPlayersWithFiles } from './domain/cohort/CohortIntegrity';
import { resolveFilesForCohort } from './domain/cohort/CohortResolver';
import type { EloSettings } from './settings';
import type { GlickoSettings } from './settings';
import { effectiveFrontmatterProperties } from './settings';
import EloSettingsTab from './settings/SettingsTab';
import GlickoSettingsTab from './settings/SettingsTab';
import { PluginDataStore } from './storage/PluginDataStore';
import type { CohortDefinition } from './types';
import ArenaSession from './ui/ArenaSession';
import { CohortPicker } from './ui/CohortPicker';
import { ensureBaseCohortTarget } from './utils/EnsureBaseCohort';
import { ensureFolderCohortPath } from './utils/EnsureFolderCohort';
import { ensureUniqueEloIds } from './utils/EnsureUniqueEloIds';
import { ensureUniqueIds } from './utils/EnsureUniqueIds';
import { computeRankMap, updateCohortFrontmatter } from './utils/FrontmatterStats';
import { debugWarn, setDebugLogging } from './utils/logger';
export default class EloPlugin extends Plugin {
export default class GlickoPlugin extends Plugin {
dataStore: PluginDataStore;
settings: EloSettings;
settings: GlickoSettings;
private currentSession?: ArenaSession;
@ -29,12 +29,12 @@ export default class EloPlugin extends Plugin {
this.settings = this.dataStore.settings;
setDebugLogging(this.settings.debugLogging);
this.addRibbonIcon('trophy', 'Start Elo rating session', async () => {
this.addRibbonIcon('trophy', 'Start Glicko rating session', async () => {
await this.selectCohortAndStart();
});
this.addCommand({
id: 'elo-start-session',
id: 'glicko-start-session',
name: 'Start rating session',
callback: async () => {
await this.selectCohortAndStart();
@ -42,7 +42,7 @@ export default class EloPlugin extends Plugin {
});
this.addCommand({
id: 'elo-end-session',
id: 'glicko-end-session',
name: 'End current session',
checkCallback: (checking) => {
const has = !!this.currentSession;
@ -52,7 +52,7 @@ export default class EloPlugin extends Plugin {
});
this.addCommand({
id: 'elo-reset-note-rating',
id: 'glicko-reset-note-rating',
name: 'Reset rating for active note',
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile();
@ -78,7 +78,7 @@ export default class EloPlugin extends Plugin {
}),
);
this.addSettingTab(new EloSettingsTab(this.app, this));
this.addSettingTab(new GlickoSettingsTab(this.app, this));
}
onunload(): void {
@ -128,7 +128,7 @@ export default class EloPlugin extends Plugin {
) {
await this.endSession();
const ok = await ensureUniqueEloIds(this.app, files);
const ok = await ensureUniqueIds(this.app, files, this.settings.idPropertyName);
if (!ok) return;
this.currentSession = new ArenaSession(this.app, this, def.key, files);
@ -140,7 +140,13 @@ export default class EloPlugin extends Plugin {
void this.dataStore.saveStore();
// Run cohort integrity scan after start()
void reconcileCohortPlayersWithFiles(this.app, this.dataStore, def.key, files).catch((e) => {
void reconcileCohortPlayersWithFiles(
this.app,
this.dataStore,
def.key,
files,
this.settings.idPropertyName,
).catch((e) => {
debugWarn('Cohort integrity reconciliation failed', e);
});
}
@ -183,8 +189,9 @@ export default class EloPlugin extends Plugin {
rankCfg.property,
undefined,
'Updating ranks in frontmatter...',
this.settings.idPropertyName,
).catch((e) => {
console.error('[Elo] Failed to update ranks in frontmatter', e);
console.error('[Glicko] Failed to update ranks in frontmatter', e);
});
}
}

View file

@ -2,29 +2,30 @@ import type { App } from 'obsidian';
import { Notice, PluginSettingTab, setIcon, Setting } from 'obsidian';
import { prettyCohortDefinition, resolveFilesForCohort } from '../domain/cohort/CohortResolver';
import type EloPlugin from '../main';
import type GlickoPlugin from '../main';
import type { CohortData } from '../types';
import { CohortOptionsModal } from '../ui/CohortOptionsModal';
import { ConfirmModal } from '../ui/ConfirmModal';
import { FolderSelectModal } from '../ui/FolderPicker';
import { FM_PROP_KEYS, renderStandardFmPropertyRow } from '../ui/FrontmatterPropertyRow';
import { applyEloIdTransferPlan, planEloIdTransfer } from '../utils/EloIdTransfer';
import {
computeRankMap,
previewCohortFrontmatterPropertyUpdates,
updateCohortFrontmatter,
} from '../utils/FrontmatterStats';
import type { EloIdLocation } from './settings';
import { applyIdTransferPlan, planIdTransfer } from '../utils/IdTransfer';
import type { IdLocation } from './settings';
import type { FrontmatterPropertiesSettings, SessionLayoutMode } from './settings';
import { DEFAULT_SETTINGS, effectiveFrontmatterProperties } from './settings';
import { migrateIdPropertyName } from './SettingsTabMigration';
type PropKey = keyof FrontmatterPropertiesSettings;
export default class EloSettingsTab extends PluginSettingTab {
export default class GlickoSettingsTab extends PluginSettingTab {
icon = 'trophy';
plugin: EloPlugin;
plugin: GlickoPlugin;
constructor(app: App, plugin: EloPlugin) {
constructor(app: App, plugin: GlickoPlugin) {
super(app, plugin);
this.plugin = plugin;
}
@ -70,9 +71,9 @@ export default class EloSettingsTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName('Elo ID location')
.setName('Note ID location')
.setDesc(
`Where to store the Elo ID. When you change this setting, you can optionally move existing IDs to the new location.
`Where to store the note ID. When you change this setting, you can optionally move existing IDs to the new location.
If you choose not to move them, IDs left in the old location will continue to work.
The frontmatter ID is always used if it exists.`,
)
@ -81,25 +82,31 @@ export default class EloSettingsTab extends PluginSettingTab {
frontmatter: 'Frontmatter (YAML)',
end: 'End of note (HTML comment)',
})
.setValue(this.plugin.settings.eloIdLocation ?? 'frontmatter')
.setValue(this.plugin.settings.idLocation ?? 'frontmatter')
.onChange(async (v) => {
const oldLoc: EloIdLocation = this.plugin.settings.eloIdLocation ?? 'frontmatter';
const newLoc: EloIdLocation = v === 'end' ? 'end' : 'frontmatter';
const oldLoc: IdLocation = this.plugin.settings.idLocation ?? 'frontmatter';
const newLoc: IdLocation = v === 'end' ? 'end' : 'frontmatter';
if (newLoc === oldLoc) return;
this.plugin.settings.eloIdLocation = newLoc;
this.plugin.settings.idLocation = newLoc;
await this.plugin.saveSettings();
const files = this.app.vault.getMarkdownFiles();
if (files.length === 0) return;
const scanning = new Notice('Scanning notes for Elo IDs...', 0);
const propName = this.plugin.settings.idPropertyName;
const scanning = new Notice('Scanning notes for note IDs...', 0);
let plan;
try {
plan = await planEloIdTransfer(this.app, files, oldLoc, newLoc);
plan = await planIdTransfer(
this.app,
files,
{ propertyName: propName, location: oldLoc },
{ propertyName: propName, location: newLoc },
);
} catch (e) {
console.error('[Elo] Failed to plan Elo ID transfer', e);
new Notice('Failed to scan notes for Elo IDs.');
console.error('[Glicko] Failed to plan note ID transfer', e);
new Notice('Failed to scan notes for note IDs.');
return;
} finally {
scanning.hide();
@ -107,11 +114,11 @@ export default class EloSettingsTab extends PluginSettingTab {
if (plan.wouldUpdate === 0) return;
const locLabel = (loc: EloIdLocation) =>
const locLabel = (loc: IdLocation) =>
loc === 'frontmatter' ? 'frontmatter' : 'end-of-note HTML comment';
const msg =
`Move Elo IDs from ${locLabel(oldLoc)} to ${locLabel(newLoc)} for ${plan.wouldUpdate} note${plan.wouldUpdate === 1 ? '' : 's'}?` +
`Move note IDs from ${locLabel(oldLoc)} to ${locLabel(newLoc)} for ${plan.wouldUpdate} note${plan.wouldUpdate === 1 ? '' : 's'}?` +
(plan.mismatches > 0
? `\n\n${plan.mismatches} note${plan.mismatches === 1 ? ' has' : 's have'} differing IDs in frontmatter and the end-of-note HTML comment.
The ID in the end-of-note HTML comment will be removed, and the frontmatter ID will be ${newLoc === 'frontmatter' ? 'kept' : 'moved to the end-of-note HTML comment'}.`
@ -119,7 +126,7 @@ export default class EloSettingsTab extends PluginSettingTab {
const ok = await new ConfirmModal(
this.app,
'Move Elo IDs?',
'Move note IDs?',
msg,
'Yes, move',
'No, leave as-is',
@ -127,12 +134,12 @@ export default class EloSettingsTab extends PluginSettingTab {
if (!ok) return;
const res = await applyEloIdTransferPlan(this.app, plan, {
noticeMessage: 'Moving Elo IDs...',
const res = await applyIdTransferPlan(this.app, plan, {
noticeMessage: 'Moving note IDs...',
});
new Notice(
`Moved Elo IDs in ${res.updated} note${res.updated === 1 ? '' : 's'}` +
`Moved note IDs in ${res.updated} note${res.updated === 1 ? '' : 's'}` +
(res.mismatches > 0
? ` (${res.mismatches} mismatch${res.mismatches === 1 ? '' : 'es'} resolved).`
: '.'),
@ -140,6 +147,27 @@ export default class EloSettingsTab extends PluginSettingTab {
});
});
// Note ID property name
new Setting(containerEl)
.setName('Note ID property name')
.setDesc(
'The frontmatter property (or HTML comment tag) used to store note IDs. Changing this will offer to migrate all existing notes.',
)
.addText((t) => {
t.setValue(this.plugin.settings.idPropertyName).setPlaceholder(
DEFAULT_SETTINGS.idPropertyName,
);
// Trigger migration on blur (not on every keystroke)
t.inputEl.addEventListener('blur', async () => {
const trimmed = (t.getValue() ?? '').trim();
if (!trimmed || trimmed === this.plugin.settings.idPropertyName) return;
await migrateIdPropertyName(this.app, this.plugin, trimmed);
this.display();
});
});
// Progress bar
new Setting(containerEl).setName('Progress bar').setHeading();
@ -180,7 +208,7 @@ export default class EloSettingsTab extends PluginSettingTab {
new Setting(containerEl)
.setName('Templates folder')
.setDesc(
'Excludes your templates from cohorts. Prevents Elo IDs from appearing on templates.',
'Excludes your templates from cohorts. Prevents note IDs from appearing on templates.',
)
.addText((t) => {
t.setPlaceholder('Templates')
@ -216,8 +244,8 @@ export default class EloSettingsTab extends PluginSettingTab {
const defs = this.plugin.dataStore.listCohortDefs();
if (defs.length === 0) {
containerEl.createEl('div', {
cls: 'elo-muted',
containerEl.createDiv({
cls: 'glicko-muted',
text: 'No cohorts saved yet. Start a session to create one, or use the Command palette.',
});
} else {
@ -233,7 +261,7 @@ export default class EloSettingsTab extends PluginSettingTab {
for (const def of sorted) {
const row = list.createDiv({ cls: 'setting-item mod-toggle' });
const info = row.createDiv({ cls: 'elo-cohort-item-info' });
const info = row.createDiv({ cls: 'glicko-cohort-item-info' });
info.addEventListener('click', () => {
void this.configureCohort(def.key);
});
@ -288,10 +316,10 @@ export default class EloSettingsTab extends PluginSettingTab {
}),
);
const fmAcc = containerEl.createEl('details', { cls: 'elo-settings-accordion' });
const fmAcc = containerEl.createEl('details', { cls: 'glicko-settings-accordion' });
fmAcc.open = false;
fmAcc.createEl('summary', { text: 'Default frontmatter properties' });
const fmBody = fmAcc.createEl('div', { cls: 'elo-settings-body' });
const fmBody = fmAcc.createEl('div', { cls: 'glicko-settings-body' });
const fm = this.plugin.settings.frontmatterProperties;
@ -311,7 +339,7 @@ export default class EloSettingsTab extends PluginSettingTab {
fmBody.createEl('p', {
text:
"Choose which Elo statistics to write into a note's frontmatter and the property names to use. " +
"Choose which Glicko statistics to write into a note's frontmatter and the property names to use. " +
'These are global defaults; cohort-specific overrides can be applied during creation.',
});
@ -425,6 +453,7 @@ export default class EloSettingsTab extends PluginSettingTab {
});
if (files.length === 0) return;
const idPropName = this.plugin.settings.idPropertyName;
const cohort: CohortData | undefined = this.plugin.dataStore.store.cohorts[cohortKey];
const valuesFor = (key: PropKey): Map<string, number> => {
const map = new Map<string, number>();
@ -454,6 +483,7 @@ export default class EloSettingsTab extends PluginSettingTab {
new Map(),
'',
change.oldProp,
idPropName,
);
if (preview.wouldUpdate === 0) continue;
@ -473,6 +503,7 @@ export default class EloSettingsTab extends PluginSettingTab {
'',
change.oldProp,
`Removing "${change.oldProp}" from ${preview.wouldUpdate} notes...`,
idPropName,
);
new Notice(`Removed "${change.oldProp}" from ${res.updated} notes.`);
} else if (change.action === 'rename' && change.oldProp && change.newProp) {
@ -482,6 +513,7 @@ export default class EloSettingsTab extends PluginSettingTab {
vals,
change.newProp,
change.oldProp,
idPropName,
);
if (preview.wouldUpdate === 0) continue;
@ -501,6 +533,7 @@ export default class EloSettingsTab extends PluginSettingTab {
change.newProp,
change.oldProp,
`Renaming "${change.oldProp}" to "${change.newProp}" on ${preview.wouldUpdate} notes...`,
idPropName,
);
new Notice(`Updated ${res.updated} notes.`);
} else if (change.action === 'upsert' && change.newProp) {
@ -509,6 +542,8 @@ export default class EloSettingsTab extends PluginSettingTab {
files,
vals,
change.newProp,
undefined,
idPropName,
);
if (preview.wouldUpdate === 0) continue;
@ -528,6 +563,7 @@ export default class EloSettingsTab extends PluginSettingTab {
change.newProp,
undefined,
`Writing "${change.newProp}" to ${preview.wouldUpdate} notes...`,
idPropName,
);
new Notice(`Wrote "${change.newProp}" on ${res.updated} notes.`);
}

View file

@ -0,0 +1,65 @@
import type { App } from 'obsidian';
import { Notice } from 'obsidian';
import type GlickoPlugin from '../main';
import { ConfirmModal } from '../ui/ConfirmModal';
import { applyIdTransferPlan, planIdTransfer } from '../utils/IdTransfer';
export async function migrateIdPropertyName(
app: App,
plugin: GlickoPlugin,
newPropName: string,
): Promise<void> {
const oldPropName = plugin.settings.idPropertyName;
if (newPropName === oldPropName) return;
const files = app.vault.getMarkdownFiles();
if (files.length === 0) {
plugin.settings.idPropertyName = newPropName;
await plugin.saveSettings();
return;
}
const scanning = new Notice('Scanning notes for existing IDs...', 0);
let plan;
try {
// Auto-detect location per file: a rename writes to whichever location the ID was found in
plan = await planIdTransfer(
app,
files,
{ propertyName: oldPropName },
{ propertyName: newPropName },
);
} finally {
scanning.hide();
}
if (plan.wouldUpdate === 0) {
plugin.settings.idPropertyName = newPropName;
await plugin.saveSettings();
new Notice(`Note ID property changed to "${newPropName}".`);
return;
}
const ok = await new ConfirmModal(
app,
'Rename note ID property?',
`Rename "${oldPropName}" to "${newPropName}" in ${plan.wouldUpdate} note${plan.wouldUpdate === 1 ? '' : 's'}? This is required to keep your ratings working.`,
'Yes, rename',
'Cancel',
).openAndConfirm();
if (!ok) {
// Revert - migration is mandatory
new Notice('Note ID property change cancelled.');
return;
}
const res = await applyIdTransferPlan(app, plan, {
noticeMessage: `Renaming "${oldPropName}" to "${newPropName}"...`,
});
plugin.settings.idPropertyName = newPropName;
await plugin.saveSettings();
new Notice(`Renamed note ID property in ${res.updated} note${res.updated === 1 ? '' : 's'}.`);
}

View file

@ -1,4 +1,4 @@
export type EloIdLocation = 'frontmatter' | 'end';
export type IdLocation = 'frontmatter' | 'end';
export type SessionLayoutMode = 'reuse-active' | 'right-split' | 'new-tab' | 'new-window';
export interface FrontmatterPropertyConfig {
@ -13,9 +13,10 @@ export interface FrontmatterPropertiesSettings {
wins: FrontmatterPropertyConfig;
}
export interface EloSettings {
export interface GlickoSettings {
showToasts: boolean;
eloIdLocation: EloIdLocation;
idPropertyName: string;
idLocation: IdLocation;
sessionLayout: SessionLayoutMode;
frontmatterProperties: FrontmatterPropertiesSettings;
askForOverridesOnCohortCreation: boolean;
@ -25,15 +26,16 @@ export interface EloSettings {
debugLogging: boolean;
}
export const DEFAULT_SETTINGS: EloSettings = {
export const DEFAULT_SETTINGS: GlickoSettings = {
showToasts: true,
eloIdLocation: 'frontmatter',
idPropertyName: 'glickoId',
idLocation: 'frontmatter',
sessionLayout: 'new-tab',
frontmatterProperties: {
rating: { property: 'eloRating', enabled: false },
rank: { property: 'eloRank', enabled: false },
matches: { property: 'eloMatches', enabled: false },
wins: { property: 'eloWins', enabled: false },
rating: { property: 'glickoRating', enabled: false },
rank: { property: 'glickoRank', enabled: false },
matches: { property: 'glickoMatches', enabled: false },
wins: { property: 'glickoWins', enabled: false },
},
askForOverridesOnCohortCreation: true,

View file

@ -1,12 +1,12 @@
import type { Plugin } from 'obsidian';
import { DEFAULT_SIGMA, glickoUpdate, inflateSigma } from '../domain/rating/GlickoEngine';
import type { EloSettings, SessionLayoutMode } from '../settings';
import type { GlickoSettings, SessionLayoutMode } from '../settings';
import { DEFAULT_SETTINGS } from '../settings';
import type {
CohortData,
CohortDefinition,
EloStore,
GlickoStore,
MatchResult,
PlayerSnapshot,
UndoFrame,
@ -14,11 +14,11 @@ import type {
interface PersistedData {
version: number;
settings: EloSettings;
store: EloStore;
settings: GlickoSettings;
store: GlickoStore;
}
const DEFAULT_STORE: EloStore = {
const DEFAULT_STORE: GlickoStore = {
version: 1,
cohorts: {},
cohortDefs: {},
@ -49,12 +49,29 @@ function normaliseTemplatesFolderPath(val: unknown): string {
return p;
}
function mergeSettings(raw?: Partial<EloSettings>): EloSettings {
const base: EloSettings = { ...DEFAULT_SETTINGS };
function mergeSettings(raw?: Partial<GlickoSettings>): GlickoSettings {
const base: GlickoSettings = { ...DEFAULT_SETTINGS };
if (!raw) return base;
const out: EloSettings = { ...base, ...raw };
// Backwards compatibility: existing installations that have no idPropertyName
// should default to 'eloId' (preserving their existing note IDs).
const noIdPropertyName = !('idPropertyName' in raw);
const out: GlickoSettings = { ...base, ...raw };
if (noIdPropertyName) {
out.idPropertyName = 'eloId';
}
// Migrate old field names from pre-rebrand settings
const rawAny = raw as Record<string, unknown>;
if ('eloIdLocation' in rawAny && !('idLocation' in rawAny)) {
const loc = rawAny['eloIdLocation'];
if (loc === 'frontmatter' || loc === 'end') {
out.idLocation = loc;
}
}
// Validate session layout
out.sessionLayout = normaliseSessionLayout(raw.sessionLayout, base.sessionLayout);
@ -67,8 +84,8 @@ function mergeSettings(raw?: Partial<EloSettings>): EloSettings {
export class PluginDataStore {
private plugin: Plugin;
settings: EloSettings = { ...DEFAULT_SETTINGS };
store: EloStore = { ...DEFAULT_STORE };
settings: GlickoSettings = { ...DEFAULT_SETTINGS };
store: GlickoStore = { ...DEFAULT_STORE };
private _saveQueue: Promise<void> = Promise.resolve();
private _debounceMs = 300;
@ -106,7 +123,7 @@ export class PluginDataStore {
this._saveQueue = this._saveQueue
.then(() => this.writePersisted())
.catch((e) => {
console.error('[Elo] Failed to save data', e);
console.error('[Glicko] Failed to save data', e);
});
return this._saveQueue;
}

View file

@ -2,7 +2,7 @@ import type { FrontmatterPropertiesSettings } from './settings';
export type MatchResult = 'A' | 'B' | 'D';
export interface EloPlayer {
export interface Player {
rating: number;
matches: number;
wins: number;
@ -11,7 +11,7 @@ export interface EloPlayer {
}
export interface CohortData {
players: Record<string, EloPlayer>;
players: Record<string, Player>;
}
export type CohortKind =
@ -58,7 +58,7 @@ export type CohortDefinition = {
[K in CohortKind]: CohortDefBase<K>;
}[CohortKind];
export interface EloStore {
export interface GlickoStore {
version: number;
cohorts: Record<string, CohortData>;
cohortDefs?: Record<string, CohortDefinition>;

View file

@ -3,14 +3,14 @@ import { MarkdownView, Notice, Platform, TFile } from 'obsidian';
import { pickNextPairIndices } from '../domain/matchmaking/Matchmaker';
import { DEFAULT_SIGMA, expectedScore } from '../domain/rating/GlickoEngine';
import type EloPlugin from '../main';
import type GlickoPlugin from '../main';
import type { FrontmatterPropertiesSettings } from '../settings';
import { effectiveFrontmatterProperties } from '../settings';
import type { MatchResult, ScrollStartMode, UndoFrame } from '../types';
import { writeFrontmatterStatsForPair } from '../utils/FrontmatterStats';
import { applyInitialScroll, getPreviewEl } from '../utils/InitialScroll';
import { debugWarn } from '../utils/logger';
import { ensureEloId, getEloId } from '../utils/NoteIds';
import { ensureNoteId, getNoteId } from '../utils/NoteIds';
import { attempt, attemptAsync } from '../utils/safe';
import { installScrollSync } from '../utils/ScrollSync';
import type { ArenaLayoutHandle } from './LayoutManager';
@ -18,7 +18,7 @@ import { ArenaLayoutManager } from './LayoutManager';
export default class ArenaSession {
private app: App;
private plugin: EloPlugin;
private plugin: GlickoPlugin;
private cohortKey: string;
private files: TFile[];
@ -65,7 +65,7 @@ export default class ArenaSession {
private stabilityBarFillEl?: HTMLElement;
constructor(app: App, plugin: EloPlugin, cohortKey: string, files: TFile[]) {
constructor(app: App, plugin: GlickoPlugin, cohortKey: string, files: TFile[]) {
this.app = app;
this.plugin = plugin;
this.cohortKey = cohortKey;
@ -236,7 +236,7 @@ export default class ArenaSession {
this.clearScrollSync();
// Lazily ensure eloIds only for the notes being displayed
// Lazily ensure note IDs only for the notes being displayed
await Promise.all([this.getIdForFile(this.leftFile), this.getIdForFile(this.rightFile)]);
await Promise.all([
@ -402,12 +402,12 @@ export default class ArenaSession {
const win = btn.ownerDocument.defaultView ?? window;
btn.classList.add('elo-pressed');
btn.classList.add('glicko-pressed');
const existing = this.pressTimers.get(btn);
if (typeof existing === 'number') win.clearTimeout(existing);
const tid = win.setTimeout(() => btn.classList.remove('elo-pressed'), durationMs);
const tid = win.setTimeout(() => btn.classList.remove('glicko-pressed'), durationMs);
this.pressTimers.set(btn, tid);
}
@ -444,16 +444,16 @@ export default class ArenaSession {
}
private mountOverlay(doc: Document = document) {
const el = doc.body.createDiv({ cls: 'elo-session-bar' });
const el = doc.body.createDiv({ cls: 'glicko-session-bar' });
if (Platform.isPhone) el.classList.add('is-mobile');
el.createDiv({ cls: 'elo-side left' });
el.createDiv({ cls: 'glicko-side left' });
if (Platform.isPhone) {
this.mobileTitleEl = el.createDiv({ cls: 'elo-mobile-title' });
this.mobileTitleEl = el.createDiv({ cls: 'glicko-mobile-title' });
}
const controls = el.createDiv({ cls: 'elo-controls' });
const controls = el.createDiv({ cls: 'glicko-controls' });
if (Platform.isPhone) {
this.drawBtn = this.makeButton(doc, 'Draw', () => void this.choose('D'));
@ -474,11 +474,11 @@ export default class ArenaSession {
controls.append(this.leftBtn, this.drawBtn, this.rightBtn, this.undoBtn, this.endBtn);
}
el.createDiv({ cls: 'elo-side right' });
el.createDiv({ cls: 'glicko-side right' });
// Stability progress bar
const track = el.createDiv({ cls: 'elo-stability-track' });
this.stabilityBarFillEl = track.createDiv({ cls: 'elo-stability-fill' });
const track = el.createDiv({ cls: 'glicko-stability-track' });
this.stabilityBarFillEl = track.createDiv({ cls: 'glicko-stability-fill' });
this.overlayEl = el;
this.updateOverlay();
@ -515,8 +515,8 @@ export default class ArenaSession {
private updateOverlay() {
if (!this.overlayEl) return;
const left = this.overlayEl.querySelector('.elo-side.left') as HTMLElement;
const right = this.overlayEl.querySelector('.elo-side.right') as HTMLElement;
const left = this.overlayEl.querySelector('.glicko-side.left') as HTMLElement;
const right = this.overlayEl.querySelector('.glicko-side.right') as HTMLElement;
left.textContent = this.leftFile?.basename ?? 'Left';
right.textContent = this.rightFile?.basename ?? 'Right';
@ -550,7 +550,7 @@ export default class ArenaSession {
if (this.shortcutsPausedToastShown === true) return;
this.shortcutsPausedToastShown = true;
this.showToast('Elo keyboard shortcuts are paused while editing.');
this.showToast('Glicko keyboard shortcuts are paused while editing.');
}
private onKeydown(ev: KeyboardEvent) {
@ -673,16 +673,18 @@ export default class ArenaSession {
const cached = this.idByPath.get(file.path);
if (cached) return cached;
const existing = await getEloId(this.app, file);
const propName = this.plugin.settings.idPropertyName;
const existing = await getNoteId(this.app, file, propName);
if (existing) {
this.idByPath.set(file.path, existing);
return existing;
}
const id = await ensureEloId(
const id = await ensureNoteId(
this.app,
file,
this.plugin.settings.eloIdLocation ?? 'frontmatter',
this.plugin.settings.idLocation ?? 'frontmatter',
propName,
);
this.idByPath.set(file.path, id);
return id;

View file

@ -1,7 +1,7 @@
import type { App } from 'obsidian';
import { Setting } from 'obsidian';
import type EloPlugin from '../main';
import type GlickoPlugin from '../main';
import type { FrontmatterPropertiesSettings, FrontmatterPropertyConfig } from '../settings';
import type { ScrollStartMode } from '../types';
import { FM_PROP_KEYS, renderStandardFmPropertyRow } from './FrontmatterPropertyRow';
@ -26,7 +26,7 @@ export type CohortOptionsResult = {
};
export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | undefined> {
private plugin: EloPlugin;
private plugin: GlickoPlugin;
private mode: Mode;
private base: FrontmatterPropertiesSettings;
@ -43,7 +43,7 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
constructor(
app: App,
plugin: EloPlugin,
plugin: GlickoPlugin,
opts?: {
mode?: Mode;
initial?: Partial<FrontmatterPropertiesSettings>;
@ -113,8 +113,8 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
const desc =
this.mode === 'create'
? 'Set an optional name and configure which Elo statistics to write into frontmatter for this cohort. Global defaults are prefilled.'
: 'Rename the cohort and adjust which Elo statistics to write into frontmatter. Use Reset to revert a property to the global default.';
? 'Set an optional name and configure which Glicko statistics to write into frontmatter for this cohort. Global defaults are prefilled.'
: 'Rename the cohort and adjust which Glicko statistics to write into frontmatter. Use Reset to revert a property to the global default.';
contentEl.createEl('h3', { text: 'Cohort options' });
contentEl.createEl('p', { text: desc });
@ -169,7 +169,7 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
}),
);
const warningEl = contentEl.createDiv({ cls: 'elo-warning' });
const warningEl = contentEl.createDiv({ cls: 'glicko-warning' });
warningEl.createEl('p', {
text:
'Auto-scroll and synchronised scrolling are both enabled. These settings can conflict with each other if your notes have embedded content that loads slowly. ' +

View file

@ -7,7 +7,7 @@ import {
parseCohortKey,
prettyCohortDefinition,
} from '../domain/cohort/CohortResolver';
import type EloPlugin from '../main';
import type GlickoPlugin from '../main';
import type { FrontmatterPropertiesSettings } from '../settings';
import type { CohortDefinition } from '../types';
import type { ScrollStartMode } from '../types';
@ -23,12 +23,12 @@ type Choice =
| { kind: 'action'; action: Action; label: string };
export class CohortPicker extends FuzzySuggestModal<Choice> {
private plugin: EloPlugin;
private plugin: GlickoPlugin;
private resolver?: (def?: CohortDefinition) => void;
private resolved = false;
private awaitingChild = false;
constructor(app: App, plugin: EloPlugin) {
constructor(app: App, plugin: GlickoPlugin) {
super(app);
this.plugin = plugin;
this.setPlaceholder('Choose a cohort or create a new one...');
@ -381,18 +381,18 @@ class TagCohortModal extends BasePromiseModal<CohortDefinition | undefined> {
const tags = Array.from(this.selectedTags).sort();
if (tags.length === 0) {
el.createDiv({ cls: 'elo-muted', text: 'No tags selected.' });
el.createDiv({ cls: 'glicko-muted', text: 'No tags selected.' });
return;
}
const list = el.createDiv({ cls: 'elo-selected-tags' });
const list = el.createDiv({ cls: 'glicko-selected-tags' });
for (const tag of tags) {
const wrap = list.createSpan({ cls: 'elo-tag-wrap' });
const wrap = list.createSpan({ cls: 'glicko-tag-wrap' });
wrap.createSpan({ cls: 'tag', text: tag });
const removeBtn = wrap.createEl('button', { cls: 'elo-tag-remove', text: '×' });
const removeBtn = wrap.createEl('button', { cls: 'glicko-tag-remove', text: '×' });
removeBtn.ariaLabel = `Remove ${tag}`;
removeBtn.addEventListener('click', (e) => {
e.preventDefault();

View file

@ -9,7 +9,7 @@ type Meta = { label: string; desc: string };
const META: Record<FmPropKey, Meta> = {
rating: {
label: 'Rating',
desc: 'Write the current Elo rating to this property.',
desc: 'Write the current Glicko rating to this property.',
},
rank: {
label: 'Rank',

View file

@ -2,7 +2,7 @@ import type { App, TFile } from 'obsidian';
import { Notice, Setting } from 'obsidian';
import { debugWarn } from '../utils/logger';
import { removeEloIdEverywhere } from '../utils/NoteIds';
import { removeNoteIdEverywhere } from '../utils/NoteIds';
import { BasePromiseModal } from './PromiseModal';
function formatCreatedTime(file: TFile): string {
@ -22,16 +22,18 @@ function formatCreatedTime(file: TFile): string {
}
}
export class ResolveDuplicateEloIdsModal extends BasePromiseModal<boolean> {
private eloId: string;
export class ResolveDuplicateIdsModal extends BasePromiseModal<boolean> {
private noteId: string;
private files: TFile[];
private propertyName: string;
private working = false;
constructor(app: App, opts: { eloId: string; files: TFile[] }) {
constructor(app: App, opts: { noteId: string; files: TFile[]; propertyName: string }) {
super(app);
this.eloId = opts.eloId;
this.noteId = opts.noteId;
this.files = opts.files.slice();
this.propertyName = opts.propertyName;
}
async openAndGetResult(): Promise<boolean | undefined> {
@ -57,12 +59,12 @@ export class ResolveDuplicateEloIdsModal extends BasePromiseModal<boolean> {
private async keepIdOnFile(keep: TFile): Promise<void> {
const others = this.files.filter((f) => f.path !== keep.path);
const workingNotice = new Notice('Fixing duplicate Elo IDs...', 0);
const workingNotice = new Notice('Fixing duplicate note IDs...', 0);
let removed = 0;
try {
for (const f of others) {
const changed = await removeEloIdEverywhere(this.app, f);
const changed = await removeNoteIdEverywhere(this.app, f, this.propertyName);
if (changed) removed += 1;
}
} finally {
@ -70,7 +72,7 @@ export class ResolveDuplicateEloIdsModal extends BasePromiseModal<boolean> {
}
new Notice(
`Kept Elo ID on "${keep.basename}". Removed Elo ID from ${removed} other note${
`Kept note ID on "${keep.basename}". Removed note ID from ${removed} other note${
removed === 1 ? '' : 's'
}.`,
);
@ -82,20 +84,20 @@ export class ResolveDuplicateEloIdsModal extends BasePromiseModal<boolean> {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h3', { text: 'Duplicate Elo IDs detected' });
contentEl.createEl('h3', { text: 'Duplicate note IDs detected' });
const msg = contentEl.createEl('p');
msg.textContent = `Two or more notes in this cohort share the same Elo ID. This usually happens when a note is copied.
Pairwise Elo Ranking uses the ID to track ratings - duplicates will cause notes to share a single rating history.`;
msg.textContent = `Two or more notes in this cohort share the same note ID. This usually happens when a note is copied.
Pairwise Glicko Ranking uses the ID to track ratings - duplicates will cause notes to share a single rating history.`;
const hint = contentEl.createEl('p');
hint.textContent =
'If you are not sure which note should keep the ID, keep it on the oldest note and remove it from the newest copy.';
const idRow = new Setting(contentEl).setName('Duplicate Elo ID').setDesc(this.eloId);
idRow.settingEl.classList.add('elo-static');
const idRow = new Setting(contentEl).setName('Duplicate note ID').setDesc(this.noteId);
idRow.settingEl.classList.add('glicko-static');
contentEl.createEl('h4', { text: 'Notes using this Elo ID' });
contentEl.createEl('h4', { text: 'Notes using this note ID' });
const listWrap = contentEl.createDiv();
@ -123,7 +125,7 @@ export class ResolveDuplicateEloIdsModal extends BasePromiseModal<boolean> {
row.addButton((b) =>
b
.setCta()
.setButtonText('Keep Elo ID')
.setButtonText('Keep note ID')
.onClick(async () => {
if (this.working) return;
this.setWorking(true);

View file

@ -1,7 +1,7 @@
import type { App } from 'obsidian';
import { Setting } from 'obsidian';
import { getEloId } from '../utils/NoteIds';
import { getNoteId } from '../utils/NoteIds';
import { FolderSelectModal } from './FolderPicker';
import { BasePromiseModal } from './PromiseModal';
@ -14,6 +14,7 @@ export class ResolveMissingFolderModal extends BasePromiseModal<string | undefin
private oldPath: string;
private recursive: boolean;
private cohortIds: Set<string>;
private idPropertyName: string;
private listEl?: HTMLElement;
private progressEl?: HTMLElement;
@ -24,11 +25,15 @@ export class ResolveMissingFolderModal extends BasePromiseModal<string | undefin
private cancelled = false;
private lastRenderTs = 0;
constructor(app: App, opts: { oldPath: string; recursive: boolean; cohortIds: Set<string> }) {
constructor(
app: App,
opts: { oldPath: string; recursive: boolean; cohortIds: Set<string>; idPropertyName: string },
) {
super(app);
this.oldPath = opts.oldPath;
this.recursive = opts.recursive;
this.cohortIds = opts.cohortIds;
this.idPropertyName = opts.idPropertyName;
}
async openAndGetFolderPath(): Promise<string | undefined> {
@ -96,7 +101,7 @@ export class ResolveMissingFolderModal extends BasePromiseModal<string | undefin
if (items.length === 0) {
el.createDiv({
cls: 'elo-muted',
cls: 'glicko-muted',
text: this.scanning
? 'Building suggestions...'
: 'No suggestions. Use Browse to pick a folder.',
@ -107,14 +112,14 @@ export class ResolveMissingFolderModal extends BasePromiseModal<string | undefin
for (const s of items.slice(0, 10)) {
const row = new Setting(el)
.setName(s.path || '/')
.setDesc(`${s.count} note${s.count === 1 ? '' : 's'} with matching Elo IDs`)
.setDesc(`${s.count} note${s.count === 1 ? '' : 's'} with matching note IDs`)
.addButton((b) =>
b
.setCta()
.setButtonText('Use')
.onClick(() => this.finish(s.path)),
);
row.settingEl.classList.add('elo-static');
row.settingEl.classList.add('glicko-static');
}
}
@ -140,7 +145,7 @@ export class ResolveMissingFolderModal extends BasePromiseModal<string | undefin
if (i >= files.length) break;
const f = files[i];
const id = await getEloId(this.app, f);
const id = await getNoteId(this.app, f, this.idPropertyName);
if (id && this.cohortIds.has(id)) {
const folder = f.parent?.path ?? '';
this.suggestions.set(folder, (this.suggestions.get(folder) ?? 0) + 1);

View file

@ -1,169 +0,0 @@
import type { App, TFile } from 'obsidian';
import { Notice } from 'obsidian';
import type { EloIdLocation } from '../settings';
import { debugWarn } from './logger';
import {
extractEloIdFromHtmlComment,
getEloIdFromFrontmatterCache,
overwriteEloIdInHtmlComment,
removeEloIdFromFrontmatter,
removeEloIdHtmlComments,
setEloIdInFrontmatter,
} from './NoteIds';
const DEFAULT_POOL = 8;
export type EloIdTransferPlan = {
file: TFile;
setFrontmatterId?: string;
setHtmlCommentId?: string;
deleteFrontmatter?: boolean;
deleteHtmlComment?: boolean;
// Informational only: indicates the note had both IDs and they differed.
// Resolved by preferring frontmatter.
mismatch?: boolean;
mismatchDetails?: { frontmatterId?: string; htmlCommentId?: string };
};
export type EloIdTransferPlanResult = {
from: EloIdLocation;
to: EloIdLocation;
plans: EloIdTransferPlan[];
wouldUpdate: number;
mismatches: number;
};
async function planForFile(
app: App,
file: TFile,
from: EloIdLocation,
to: EloIdLocation,
): Promise<EloIdTransferPlan | undefined> {
const fmId = getEloIdFromFrontmatterCache(app, file);
// Frontmatter -> HTML: frontmatter is source of truth - overwrite HTML comment, then delete frontmatter.
if (from === 'frontmatter' && to === 'end') {
if (!fmId) return undefined;
return { file, deleteHtmlComment: true, setHtmlCommentId: fmId, deleteFrontmatter: true };
}
// HTML -> frontmatter: read the body to find a HTML comment id (if any).
if (from === 'end' && to === 'frontmatter') {
let htmlCommentId: string | undefined;
try {
const text = await app.vault.cachedRead(file);
htmlCommentId = extractEloIdFromHtmlComment(text);
} catch (e) {
debugWarn(`Failed to read file for eloId transfer plan: ${file.path}`, e);
htmlCommentId = undefined;
}
// If frontmatter exists, it is canonical. If a HTML comment exists, remove it.
if (fmId) {
if (!htmlCommentId) return undefined;
const mismatch = htmlCommentId !== fmId;
return mismatch
? {
file,
deleteHtmlComment: true,
mismatch: true,
mismatchDetails: { frontmatterId: fmId, htmlCommentId },
}
: { file, deleteHtmlComment: true };
}
// No frontmatter id: if we have a HTML comment id, move it into frontmatter and delete HTML comment(s).
if (!htmlCommentId) return undefined;
return { file, setFrontmatterId: htmlCommentId, deleteHtmlComment: true };
}
return undefined;
}
export async function planEloIdTransfer(
app: App,
files: TFile[],
from: EloIdLocation,
to: EloIdLocation,
opts?: { pool?: number },
): Promise<EloIdTransferPlanResult> {
const pool = Math.max(1, Math.round(opts?.pool ?? DEFAULT_POOL));
// Candidate reduction for frontmatter -> html:
// only files with frontmatter eloId need work.
const candidates =
from === 'frontmatter' && to === 'end'
? files.filter((f) => !!getEloIdFromFrontmatterCache(app, f))
: files;
const plans: EloIdTransferPlan[] = [];
let mismatches = 0;
let idx = 0;
const worker = async () => {
while (true) {
const i = idx++;
if (i >= candidates.length) break;
const f = candidates[i];
const plan = await planForFile(app, f, from, to);
if (!plan) continue;
plans.push(plan);
if (plan.mismatch) mismatches += 1;
}
};
await Promise.all(Array.from({ length: pool }, () => worker()));
plans.sort((a, b) => a.file.path.localeCompare(b.file.path));
const wouldUpdate = plans.length;
return { from, to, plans, wouldUpdate, mismatches };
}
export async function applyEloIdTransferPlan(
app: App,
plan: EloIdTransferPlanResult,
opts?: { noticeMessage?: string },
): Promise<{ updated: number; mismatches: number }> {
const working = new Notice(opts?.noticeMessage ?? 'Transferring Elo IDs...', 0);
let updated = 0;
let mismatches = 0;
try {
for (const p of plan.plans) {
if (p.mismatch) mismatches += 1;
if (p.setHtmlCommentId) {
await overwriteEloIdInHtmlComment(app, p.file, p.setHtmlCommentId);
}
if (p.setFrontmatterId) {
await setEloIdInFrontmatter(app, p.file, p.setFrontmatterId);
}
if (p.deleteFrontmatter) {
await removeEloIdFromFrontmatter(app, p.file);
}
// Delete-only HTML comment removal (typically html -> frontmatter).
if (p.deleteHtmlComment && !p.setHtmlCommentId) {
await removeEloIdHtmlComments(app, p.file);
}
updated += 1;
}
} finally {
working.hide();
}
return { updated, mismatches };
}

View file

@ -17,7 +17,7 @@ export async function ensureFolderCohortPath(
const af = app.vault.getAbstractFileByPath(path);
if (af instanceof TFolder) return def;
// Collect Elo IDs from stored cohort (if any) for suggestions.
// Collect note IDs from stored cohort (if any) for suggestions.
const cohort = dataStore.store.cohorts[def.key];
const idSet = new Set<string>(cohort ? Object.keys(cohort.players ?? {}) : []);
@ -25,6 +25,7 @@ export async function ensureFolderCohortPath(
oldPath: path,
recursive: def.kind === 'folder-recursive',
cohortIds: idSet,
idPropertyName: dataStore.settings.idPropertyName,
}).openAndGetFolderPath();
if (!picked) {

View file

@ -1,14 +1,15 @@
import type { App, TFile } from 'obsidian';
import { Notice } from 'obsidian';
import { ResolveDuplicateEloIdsModal } from '../ui/ResolveDuplicateEloIdsModal';
import { getEloId } from './NoteIds';
import { ResolveDuplicateIdsModal } from '../ui/ResolveDuplicateIdsModal';
import { getNoteId } from './NoteIds';
const DEFAULT_POOL = 8;
export async function findDuplicateEloIds(
export async function findDuplicateIds(
app: App,
files: TFile[],
propertyName: string,
opts?: { pool?: number },
): Promise<Map<string, TFile[]>> {
const pool = Math.max(1, Math.round(opts?.pool ?? DEFAULT_POOL));
@ -21,7 +22,7 @@ export async function findDuplicateEloIds(
if (i >= files.length) break;
const f = files[i];
const id = await getEloId(app, f);
const id = await getNoteId(app, f, propertyName);
if (!id) continue;
const existing = byId.get(id);
@ -41,21 +42,25 @@ export async function findDuplicateEloIds(
}
/**
* Ensures there are no duplicate Elo IDs across `files`.
* Ensures there are no duplicate note IDs across `files`.
*
* Returns:
* - true: duplicates resolved (or none found), safe to start session
* - false: user cancelled (session should not start)
*/
export async function ensureUniqueEloIds(app: App, files: TFile[]): Promise<boolean> {
export async function ensureUniqueIds(
app: App,
files: TFile[],
propertyName: string,
): Promise<boolean> {
if (files.length < 2) return true;
while (true) {
const scanning = new Notice('Scanning notes for duplicate Elo IDs...', 0);
const scanning = new Notice('Scanning notes for duplicate IDs...', 0);
let dupes: Map<string, TFile[]>;
try {
dupes = await findDuplicateEloIds(app, files);
dupes = await findDuplicateIds(app, files, propertyName);
} finally {
scanning.hide();
}
@ -67,17 +72,16 @@ export async function ensureUniqueEloIds(app: App, files: TFile[]): Promise<bool
const id = ids[0];
const dupFiles = dupes.get(id) ?? [];
const res = await new ResolveDuplicateEloIdsModal(app, {
eloId: id,
const res = await new ResolveDuplicateIdsModal(app, {
noteId: id,
files: dupFiles,
propertyName,
}).openAndGetResult();
if (res === true) {
// Loop and re-scan, because there may be more duplicates across the cohort
continue;
}
// User cancelled: stop session start
return false;
}
}

View file

@ -3,7 +3,7 @@ import { Notice } from 'obsidian';
import type { FrontmatterPropertiesSettings } from '../settings';
import type { CohortData } from '../types';
import { getEloId } from './NoteIds';
import { getNoteId } from './NoteIds';
type PlayerStats = {
rating: number;
@ -102,6 +102,7 @@ async function planFrontmatterUpdates(
valuesById: Map<string, number>,
newPropName: string,
oldPropName?: string,
idPropertyName?: string,
): Promise<{ plans: FrontmatterUpdatePlan[] }> {
const prop = (newPropName ?? '').trim();
const oldProp = (oldPropName ?? '').trim();
@ -109,7 +110,7 @@ async function planFrontmatterUpdates(
const plans: FrontmatterUpdatePlan[] = [];
for (const file of files) {
const id = await getEloId(app, file);
const id = await getNoteId(app, file, idPropertyName ?? 'glickoId');
if (!id) continue;
const fcRaw: unknown = app.metadataCache.getFileCache(file)?.frontmatter;
@ -170,8 +171,16 @@ export async function previewCohortFrontmatterPropertyUpdates(
valuesById: Map<string, number>,
newPropName: string,
oldPropName?: string,
idPropertyName?: string,
): Promise<{ wouldUpdate: number }> {
const { plans } = await planFrontmatterUpdates(app, files, valuesById, newPropName, oldPropName);
const { plans } = await planFrontmatterUpdates(
app,
files,
valuesById,
newPropName,
oldPropName,
idPropertyName,
);
return { wouldUpdate: plans.length };
}
@ -184,8 +193,16 @@ export async function updateCohortFrontmatterProperties(
valuesById: Map<string, number>,
newPropName: string,
oldPropName?: string,
idPropertyName?: string,
): Promise<{ updated: number }> {
const { plans } = await planFrontmatterUpdates(app, files, valuesById, newPropName, oldPropName);
const { plans } = await planFrontmatterUpdates(
app,
files,
valuesById,
newPropName,
oldPropName,
idPropertyName,
);
let updated = 0;
for (const p of plans) {
@ -217,6 +234,7 @@ export async function updateCohortFrontmatter(
newPropName: string,
oldPropName?: string,
noticeMessage?: string,
idPropertyName?: string,
): Promise<{ updated: number }> {
const working = new Notice(noticeMessage ?? 'Updating frontmatter...', 0);
try {
@ -226,6 +244,7 @@ export async function updateCohortFrontmatter(
valuesById,
newPropName,
oldPropName,
idPropertyName,
);
} finally {
working.hide();

201
src/utils/IdTransfer.ts Normal file
View file

@ -0,0 +1,201 @@
import type { App, TFile } from 'obsidian';
import { Notice } from 'obsidian';
import type { IdLocation } from '../settings';
import { debugWarn } from './logger';
import {
extractIdFromHtmlComment,
getNoteIdFromFrontmatterCache,
overwriteNoteIdInHtmlComment,
removeNoteIdFromFrontmatter,
removeNoteIdHtmlComments,
setNoteIdInFrontmatter,
} from './NoteIds';
const DEFAULT_POOL = 8;
/**
* Describes where an ID lives (or should live).
*
* - If `location` is set, only that location is checked / written.
* - If `location` is omitted, frontmatter is checked first, then HTML comment
* (auto-detect). On write the resolved source location is reused.
*/
export type IdEndpoint = {
propertyName: string;
location?: IdLocation;
};
export type IdTransferPlan = {
file: TFile;
write?: { location: IdLocation; propertyName: string; id: string };
remove?: { location: IdLocation; propertyName: string };
mismatch?: boolean;
};
export type IdTransferPlanResult = {
from: IdEndpoint;
to: IdEndpoint;
plans: IdTransferPlan[];
wouldUpdate: number;
mismatches: number;
};
async function readHtmlId(
app: App,
file: TFile,
propertyName: string,
): Promise<string | undefined> {
try {
const text = await app.vault.cachedRead(file);
return extractIdFromHtmlComment(text, propertyName);
} catch (e) {
debugWarn(`Failed to read file for ID transfer plan: ${file.path}`, e);
return undefined;
}
}
async function planForFile(
app: App,
file: TFile,
from: IdEndpoint,
to: IdEndpoint,
): Promise<IdTransferPlan | undefined> {
// Resolve source ID and its actual location
let sourceId: string | undefined;
let sourceLocation: IdLocation;
if (from.location) {
sourceLocation = from.location;
sourceId =
from.location === 'frontmatter'
? getNoteIdFromFrontmatterCache(app, file, from.propertyName)
: await readHtmlId(app, file, from.propertyName);
} else {
// Auto-detect: frontmatter first, then HTML comment
const fmId = getNoteIdFromFrontmatterCache(app, file, from.propertyName);
if (fmId) {
sourceId = fmId;
sourceLocation = 'frontmatter';
} else {
sourceId = await readHtmlId(app, file, from.propertyName);
sourceLocation = 'end';
}
}
if (!sourceId) return undefined;
// Resolve target location (default: same as where the source was found)
const targetLocation = to.location ?? sourceLocation;
// If source and target are the exact same endpoint, nothing to do
if (sourceLocation === targetLocation && from.propertyName === to.propertyName) {
return undefined;
}
// Read existing target ID (if the target is a different endpoint)
const targetId =
targetLocation === 'frontmatter'
? getNoteIdFromFrontmatterCache(app, file, to.propertyName)
: await readHtmlId(app, file, to.propertyName);
const removeOp = { location: sourceLocation, propertyName: from.propertyName };
if (!targetId) {
// Target empty -> write + remove source
return {
file,
write: { location: targetLocation, propertyName: to.propertyName, id: sourceId },
remove: removeOp,
};
}
if (targetId === sourceId) {
// Already correct -> just clean up source
return { file, remove: removeOp };
}
// Mismatch: target has a different ID -> keep target, remove source
return { file, remove: removeOp, mismatch: true };
}
export async function planIdTransfer(
app: App,
files: TFile[],
from: IdEndpoint,
to: IdEndpoint,
opts?: { pool?: number },
): Promise<IdTransferPlanResult> {
const pool = Math.max(1, Math.round(opts?.pool ?? DEFAULT_POOL));
// Pre-filter optimisation: if source is explicitly frontmatter,
// only files with that property in the metadata cache need work.
const candidates =
from.location === 'frontmatter'
? files.filter((f) => !!getNoteIdFromFrontmatterCache(app, f, from.propertyName))
: files;
const plans: IdTransferPlan[] = [];
let mismatches = 0;
let idx = 0;
const worker = async () => {
while (true) {
const i = idx++;
if (i >= candidates.length) break;
const plan = await planForFile(app, candidates[i], from, to);
if (!plan) continue;
plans.push(plan);
if (plan.mismatch) mismatches += 1;
}
};
await Promise.all(Array.from({ length: pool }, () => worker()));
plans.sort((a, b) => a.file.path.localeCompare(b.file.path));
return { from, to, plans, wouldUpdate: plans.length, mismatches };
}
export async function applyIdTransferPlan(
app: App,
plan: IdTransferPlanResult,
opts?: { noticeMessage?: string },
): Promise<{ updated: number; mismatches: number }> {
const working = new Notice(opts?.noticeMessage ?? 'Transferring note IDs...', 0);
let updated = 0;
let mismatches = 0;
try {
for (const p of plan.plans) {
if (p.mismatch) mismatches += 1;
// Write first so we don't lose data
if (p.write) {
if (p.write.location === 'frontmatter') {
await setNoteIdInFrontmatter(app, p.file, p.write.id, p.write.propertyName);
} else {
await overwriteNoteIdInHtmlComment(app, p.file, p.write.id, p.write.propertyName);
}
}
// Then remove the source
if (p.remove) {
if (p.remove.location === 'frontmatter') {
await removeNoteIdFromFrontmatter(app, p.file, p.remove.propertyName);
} else {
await removeNoteIdHtmlComments(app, p.file, p.remove.propertyName);
}
}
updated += 1;
}
} finally {
working.hide();
}
return { updated, mismatches };
}

View file

@ -3,55 +3,70 @@ import type { App, TFile } from 'obsidian';
import { debugWarn } from './logger';
// Matches HTML comments like: <!-- eloId: 123e4567-e89b-12d3-a456-426614174000 -->
const ELO_ID_HTML_COMMENT_BASE = /<!--\s*eloId\s*:\s*([0-9A-Za-z][0-9A-Za-z._-]*)\s*-->/;
function eloIdHtmlCommentGlobal(): RegExp {
return new RegExp(ELO_ID_HTML_COMMENT_BASE.source, 'g');
function buildIdHtmlCommentRegex(propName: string): RegExp {
const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`<!--\\s*${escaped}\\s*:\\s*([0-9A-Za-z][0-9A-Za-z._-]*)\\s*-->`, 'g');
}
export function extractEloIdFromHtmlComment(text: string): string | undefined {
export function extractIdFromHtmlComment(text: string, propertyName: string): string | undefined {
let last: string | undefined;
// Returns *last* match, the real marker is typically appended at the end of the note
for (const m of text.matchAll(eloIdHtmlCommentGlobal())) {
for (const m of text.matchAll(buildIdHtmlCommentRegex(propertyName))) {
last = m[1];
}
return last;
}
export function getEloIdFromFrontmatterCache(app: App, file: TFile): string | undefined {
export function getNoteIdFromFrontmatterCache(
app: App,
file: TFile,
propertyName: string,
): string | undefined {
const fmRaw: unknown = app.metadataCache.getFileCache(file)?.frontmatter;
const fm = fmRaw && typeof fmRaw === 'object' ? (fmRaw as Record<string, unknown>) : undefined;
const fmId = fm ? fm['eloId'] : undefined;
const fmId = fm ? fm[propertyName] : undefined;
return typeof fmId === 'string' && fmId.length > 0 ? fmId : undefined;
}
export async function getEloIdFromHtmlComment(app: App, file: TFile): Promise<string | undefined> {
export async function getNoteIdFromHtmlComment(
app: App,
file: TFile,
propertyName: string,
): Promise<string | undefined> {
try {
const text = await app.vault.cachedRead(file);
return extractEloIdFromHtmlComment(text);
return extractIdFromHtmlComment(text, propertyName);
} catch (e) {
debugWarn(`Failed to read HTML comment eloId from ${file.path}`, e);
debugWarn(`Failed to read HTML comment ${propertyName} from ${file.path}`, e);
return undefined;
}
}
export async function setEloIdInFrontmatter(app: App, file: TFile, id: string): Promise<void> {
const cur = getEloIdFromFrontmatterCache(app, file);
export async function setNoteIdInFrontmatter(
app: App,
file: TFile,
id: string,
propertyName: string,
): Promise<void> {
const cur = getNoteIdFromFrontmatterCache(app, file, propertyName);
if (cur === id) return;
await app.fileManager.processFrontMatter(file, (fmRaw) => {
const fm = fmRaw as Record<string, unknown>;
fm['eloId'] = id;
fm[propertyName] = id;
});
}
export async function removeEloIdFromFrontmatter(app: App, file: TFile): Promise<boolean> {
const cur = getEloIdFromFrontmatterCache(app, file);
export async function removeNoteIdFromFrontmatter(
app: App,
file: TFile,
propertyName: string,
): Promise<boolean> {
const cur = getNoteIdFromFrontmatterCache(app, file, propertyName);
if (!cur) return false;
await app.fileManager.processFrontMatter(file, (fmRaw) => {
const fm = fmRaw as Record<string, unknown>;
delete fm['eloId'];
delete fm[propertyName];
});
return true;
@ -77,15 +92,18 @@ function endsWithBlankLine(text: string): boolean {
return /(?:\r?\n){2}$/.test(text);
}
function getLastEloIdHtmlCommentMatch(text: string): RegExpMatchArray | undefined {
function getLastIdHtmlCommentMatch(
text: string,
propertyName: string,
): RegExpMatchArray | undefined {
let last: RegExpMatchArray | undefined;
for (const m of text.matchAll(eloIdHtmlCommentGlobal())) last = m;
for (const m of text.matchAll(buildIdHtmlCommentRegex(propertyName))) last = m;
return last;
}
function appendEloIdHtmlCommentAtEnd(text: string, id: string): string {
function appendIdHtmlCommentAtEnd(text: string, id: string, propertyName: string): string {
const eol = detectEol(text);
const marker = `<!-- eloId: ${id} -->`;
const marker = `<!-- ${propertyName}: ${id} -->`;
let out = text;
@ -99,8 +117,9 @@ function appendEloIdHtmlCommentAtEnd(text: string, id: string): string {
return out + marker + eol;
}
function removeTrailingEloIdHtmlComments(
function removeTrailingIdHtmlComments(
text: string,
propertyName: string,
ensureTrailingNewline: boolean,
): { text: string; removed: boolean } {
const eol = detectEol(text);
@ -109,7 +128,7 @@ function removeTrailingEloIdHtmlComments(
let removedBlankLineAbove = false;
while (true) {
const m = getLastEloIdHtmlCommentMatch(out);
const m = getLastIdHtmlCommentMatch(out, propertyName);
if (!m || typeof m.index !== 'number') break;
const start = m.index;
@ -146,18 +165,22 @@ function removeTrailingEloIdHtmlComments(
return { text: out, removed: removedAny };
}
function removeAllEloIdHtmlCommentsPreservingWhitespace(
function removeAllIdHtmlCommentsPreservingWhitespace(
text: string,
propertyName: string,
ensureTrailingNewlineIfTrailingRemoved: boolean,
): { text: string; changed: boolean } {
let changed = false;
// First, remove any trailing marker block in the EOF-specific way
const trailing = removeTrailingEloIdHtmlComments(text, ensureTrailingNewlineIfTrailingRemoved);
const trailing = removeTrailingIdHtmlComments(
text,
propertyName,
ensureTrailingNewlineIfTrailingRemoved,
);
changed ||= trailing.removed;
// Then remove any remaining markers elsewhere - remove just the comment text
const out = trailing.text.replace(eloIdHtmlCommentGlobal(), () => {
const out = trailing.text.replace(buildIdHtmlCommentRegex(propertyName), () => {
changed = true;
return '';
});
@ -166,31 +189,37 @@ function removeAllEloIdHtmlCommentsPreservingWhitespace(
}
/**
* Append an eloId marker at the end of the note.
* Append an ID marker at the end of the note.
*
* If an eloId marker already exists with a different ID, return the note unchanged.
* If an ID marker already exists with a different ID, return the note unchanged.
*/
export async function setEloIdInHtmlComment(app: App, file: TFile, id: string): Promise<void> {
await app.vault.process(file, (data) => {
const existing = extractEloIdFromHtmlComment(data);
if (existing === id) return data;
// If there is already a different eloId HTML comment, do not overwrite here
if (existing && existing !== id) return data;
return appendEloIdHtmlCommentAtEnd(data, id);
});
}
export async function overwriteEloIdInHtmlComment(
export async function setNoteIdInHtmlComment(
app: App,
file: TFile,
id: string,
propertyName: string,
): Promise<void> {
await app.vault.process(file, (data) => {
const matches = Array.from(data.matchAll(eloIdHtmlCommentGlobal()));
const existing = extractIdFromHtmlComment(data, propertyName);
if (existing === id) return data;
// If there is already a different note ID HTML comment, do not overwrite here
if (existing && existing !== id) return data;
return appendIdHtmlCommentAtEnd(data, id, propertyName);
});
}
export async function overwriteNoteIdInHtmlComment(
app: App,
file: TFile,
id: string,
propertyName: string,
): Promise<void> {
await app.vault.process(file, (data) => {
const regex = buildIdHtmlCommentRegex(propertyName);
const matches = Array.from(data.matchAll(regex));
// If there is exactly one marker with the desired id, and only whitespace follows it, return unchanged
if (matches.length === 1 && matches[0][1] === id) {
const m = matches[0];
const start = typeof m.index === 'number' ? m.index : -1;
@ -201,24 +230,21 @@ export async function overwriteEloIdInHtmlComment(
}
}
// Remove existing markers with minimal changes, then append the desired marker at EOF
const removed = removeAllEloIdHtmlCommentsPreservingWhitespace(
data,
false, // newline will be added by append function
);
const removed = removeAllIdHtmlCommentsPreservingWhitespace(data, propertyName, false);
return appendEloIdHtmlCommentAtEnd(removed.text, id);
return appendIdHtmlCommentAtEnd(removed.text, id, propertyName);
});
}
export async function removeEloIdHtmlComments(app: App, file: TFile): Promise<boolean> {
export async function removeNoteIdHtmlComments(
app: App,
file: TFile,
propertyName: string,
): Promise<boolean> {
let changed = false;
await app.vault.process(file, (data) => {
const removed = removeAllEloIdHtmlCommentsPreservingWhitespace(
data,
true, // if we removed a trailing marker, ensure at least one trailing newline remains
);
const removed = removeAllIdHtmlCommentsPreservingWhitespace(data, propertyName, true);
if (!removed.changed) return data;
@ -229,35 +255,42 @@ export async function removeEloIdHtmlComments(app: App, file: TFile): Promise<bo
return changed;
}
export async function removeEloIdEverywhere(app: App, file: TFile): Promise<boolean> {
const removedFrontmatter = await removeEloIdFromFrontmatter(app, file);
const removedHtml = await removeEloIdHtmlComments(app, file);
export async function removeNoteIdEverywhere(
app: App,
file: TFile,
propertyName: string,
): Promise<boolean> {
const removedFrontmatter = await removeNoteIdFromFrontmatter(app, file, propertyName);
const removedHtml = await removeNoteIdHtmlComments(app, file, propertyName);
return removedFrontmatter || removedHtml;
}
export async function getEloId(app: App, file: TFile): Promise<string | undefined> {
// Prefer frontmatter
const fmId = getEloIdFromFrontmatterCache(app, file);
export async function getNoteId(
app: App,
file: TFile,
propertyName: string,
): Promise<string | undefined> {
const fmId = getNoteIdFromFrontmatterCache(app, file, propertyName);
if (fmId) return fmId;
// Fallback: look for an HTML comment marker anywhere in the note
return await getEloIdFromHtmlComment(app, file);
return await getNoteIdFromHtmlComment(app, file, propertyName);
}
export async function ensureEloId(
export async function ensureNoteId(
app: App,
file: TFile,
preferredLocation: 'frontmatter' | 'end' = 'frontmatter',
propertyName: string,
): Promise<string> {
const existing = await getEloId(app, file);
const existing = await getNoteId(app, file, propertyName);
if (existing) return existing;
const id = crypto.randomUUID();
if (preferredLocation === 'end') {
await setEloIdInHtmlComment(app, file, id);
await setNoteIdInHtmlComment(app, file, id, propertyName);
} else {
await setEloIdInFrontmatter(app, file, id);
await setNoteIdInFrontmatter(app, file, id, propertyName);
}
return id;

View file

@ -1,4 +1,4 @@
const PREFIX = '[Elo]';
const PREFIX = '[Glicko]';
let debugEnabled = false;

View file

@ -1,5 +1,5 @@
/* Floating controls for Elo arena */
.elo-session-bar {
/* Floating controls for Glicko arena */
.glicko-session-bar {
position: fixed;
left: 50%;
bottom: var(--size-4-4); /* 16px */
@ -21,19 +21,19 @@
line-height: var(--line-height-tight);
}
.elo-session-bar .elo-side {
.glicko-session-bar .glicko-side {
min-width: 14ch;
text-align: center;
font-weight: var(--font-semibold);
}
.elo-session-bar .elo-controls {
.glicko-session-bar .glicko-controls {
display: flex;
gap: var(--size-4-2); /* 8px */
align-items: center;
}
.elo-session-bar button {
.glicko-session-bar button {
font-size: var(--font-ui-small);
line-height: var(--line-height-tight);
padding: var(--size-4-1) var(--size-4-2); /* 4px 8px */
@ -47,30 +47,30 @@
}
/* Mouse/touch press, plus programmatic press (keyboard shortcuts) */
.elo-session-bar button:active,
.elo-session-bar button.elo-pressed {
.glicko-session-bar button:active,
.glicko-session-bar button.glicko-pressed {
transform: translateY(1px);
filter: brightness(0.9);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.25);
}
.elo-cohort-item-info {
.glicko-cohort-item-info {
cursor: pointer;
}
/* Settings accordions */
.elo-settings-accordion {
.glicko-settings-accordion {
margin-top: var(--size-4-3); /* 12px */
}
.elo-settings-accordion > summary {
.glicko-settings-accordion > summary {
cursor: pointer;
font-weight: var(--font-semibold);
font-size: var(--font-ui-medium);
margin-bottom: var(--size-4-2); /* 8px */
}
.elo-settings-accordion .elo-settings-body {
.glicko-settings-accordion .glicko-settings-body {
padding-top: var(--size-4-3); /* 12px */
margin-left: var(--size-4-1); /* 4px */
padding-left: var(--size-4-3); /* 12px */
@ -78,35 +78,35 @@
}
/* Generic muted text helper */
.elo-muted {
.glicko-muted {
color: var(--text-muted);
}
/* Tag cohort picker: selected tags list */
.elo-selected-tags {
.glicko-selected-tags {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-3); /* 6px */
line-height: var(--line-height-tight);
}
.elo-tag-wrap {
.glicko-tag-wrap {
display: inline-flex;
align-items: center;
}
.elo-tag-remove {
.glicko-tag-remove {
margin-left: var(--size-2-3); /* 6px */
font-size: var(--font-ui-small);
line-height: var(--line-height-tight);
}
/* Non-interactive setting row (e.g. suggestion rows with a Use button) */
.elo-static {
.glicko-static {
cursor: default;
}
.elo-warning {
.glicko-warning {
display: block;
box-sizing: border-box;
max-height: 0;
@ -130,17 +130,17 @@
opacity 160ms ease;
}
.elo-warning.is-visible {
.glicko-warning.is-visible {
max-height: 10rem;
opacity: 1;
}
.elo-warning p {
.glicko-warning p {
margin: 0;
}
/* Convergence stability progress bar */
.elo-stability-track {
.glicko-stability-track {
position: absolute;
left: 0;
right: 0;
@ -151,7 +151,7 @@
pointer-events: none;
}
.elo-stability-fill {
.glicko-stability-fill {
height: 100%;
width: var(--stability-width, 0%);
background-color: var(--text-accent);
@ -161,30 +161,30 @@
background-color 300ms ease;
}
.elo-stability-fill.is-mid {
.glicko-stability-fill.is-mid {
background-color: var(--color-yellow);
}
.elo-stability-fill.is-high {
.glicko-stability-fill.is-high {
background-color: var(--color-green);
}
.elo-stability-fill.is-surprise {
.glicko-stability-fill.is-surprise {
background-color: var(--color-red);
}
@media (prefers-reduced-motion: reduce) {
.elo-warning {
.glicko-warning {
transition: none;
}
.elo-stability-fill {
.glicko-stability-fill {
transition: none;
}
}
/* Phone layout: stack title + controls, stretch to screen width */
.elo-session-bar.is-mobile {
.glicko-session-bar.is-mobile {
left: var(--size-4-2);
right: var(--size-4-2);
transform: none;
@ -202,7 +202,7 @@
}
/* Title line for mobile session bar */
.elo-mobile-title {
.glicko-mobile-title {
text-align: center;
font-weight: var(--font-semibold);
white-space: nowrap;
@ -211,17 +211,17 @@
}
/* Hide left/right labels on mobile */
.elo-session-bar.is-mobile .elo-side {
.glicko-session-bar.is-mobile .glicko-side {
display: none;
}
/* Mobile-friendly buttons */
.elo-session-bar.is-mobile .elo-controls {
.glicko-session-bar.is-mobile .glicko-controls {
flex-wrap: wrap;
justify-content: space-between;
}
.elo-session-bar.is-mobile button {
.glicko-session-bar.is-mobile button {
min-height: 44px;
padding: var(--size-4-2) var(--size-4-3);
font-size: var(--font-ui-medium);