mirror of
https://github.com/williamsjack/pairwise-glicko-ranking.git
synced 2026-07-22 06:44:09 +00:00
Rebrand plugin to Glicko, with migration
This commit is contained in:
parent
78ca27b234
commit
a2fc35429f
27 changed files with 676 additions and 441 deletions
29
README.md
29
README.md
|
|
@ -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
|
|||
|
||||

|
||||
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
33
src/main.ts
33
src/main.ts
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
|
|
|||
65
src/settings/SettingsTabMigration.ts
Normal file
65
src/settings/SettingsTabMigration.ts
Normal 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'}.`);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. ' +
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
201
src/utils/IdTransfer.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const PREFIX = '[Elo]';
|
||||
const PREFIX = '[Glicko]';
|
||||
|
||||
let debugEnabled = false;
|
||||
|
||||
|
|
|
|||
62
styles.css
62
styles.css
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue