wip on autotagging
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
// Shared helpers for grouping a tracklist by disc number — used by
|
||||
// both the explore album view and the autotag review UI so the
|
||||
// rendering rules stay consistent (single-disc albums skip the
|
||||
// "Disc 1" separator, multi-disc albums show one per disc).
|
||||
|
||||
export interface Disced {
|
||||
discNumber?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when any track has a discNumber > 1. A list with
|
||||
* only disc 1 (or no discNumber set) renders without "Disc N"
|
||||
* headers.
|
||||
*/
|
||||
export function isMultiDisc<T extends Disced>(tracks: T[]): boolean {
|
||||
return tracks.some((t) => (t.discNumber ?? 1) > 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tracks by disc number, sorting tracks within each disc
|
||||
* by position. Discs with no number default to disc 1, which
|
||||
* matches MusicBrainz behaviour for releases that omit the field.
|
||||
*/
|
||||
export function groupByDisc<T extends Disced>(tracks: T[]): Map<number, T[]> {
|
||||
const discMap = new Map<number, T[]>();
|
||||
|
||||
for (const track of tracks) {
|
||||
const disc = track.discNumber ?? 1;
|
||||
const bucket = discMap.get(disc);
|
||||
if (bucket) {
|
||||
bucket.push(track);
|
||||
} else {
|
||||
discMap.set(disc, [track]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of discMap.values()) {
|
||||
bucket.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
}
|
||||
|
||||
return discMap;
|
||||
}
|
||||
|
||||
/** Returns disc numbers in ascending order from a grouped map. */
|
||||
export function discNumbers<T>(discMap: Map<number, T[]>): number[] {
|
||||
return [...discMap.keys()].sort((a, b) => a - b);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Inline text diff for the autotag review UI. Splits both sides
|
||||
// into tokens (word runs, whitespace runs, individual punctuation
|
||||
// chars), runs LCS, and emits a flat segment list the renderer
|
||||
// drops into spans. Punctuation is its own token so an apostrophe
|
||||
// type swap (' vs ') shows just the apostrophe as changed instead
|
||||
// of the whole word — that's the case the visible-but-identical
|
||||
// titles in the autotag view were tripping on.
|
||||
|
||||
export type SegmentType = 'equal' | 'remove' | 'add';
|
||||
|
||||
export interface DiffSegment {
|
||||
type: SegmentType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const tokenRe = /(\w+|\s+|[^\w\s])/g;
|
||||
|
||||
function tokenize(s: string): string[] {
|
||||
return s.match(tokenRe) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an inline word/punct-level diff between `a` (old) and
|
||||
* `b` (new), returning a list of segments suitable for inline
|
||||
* rendering: equal segments come from both sides, remove segments
|
||||
* come from `a` only, add segments come from `b` only. Adjacent
|
||||
* segments of the same type are coalesced. Both sides empty
|
||||
* returns a single empty equal segment.
|
||||
*/
|
||||
export function inlineDiff(a: string, b: string): DiffSegment[] {
|
||||
if (a === b) {
|
||||
return [{ type: 'equal', text: a }];
|
||||
}
|
||||
if (a === '') {
|
||||
return [{ type: 'add', text: b }];
|
||||
}
|
||||
if (b === '') {
|
||||
return [{ type: 'remove', text: a }];
|
||||
}
|
||||
|
||||
const ta = tokenize(a);
|
||||
const tb = tokenize(b);
|
||||
const m = ta.length;
|
||||
const n = tb.length;
|
||||
|
||||
// LCS table — O(m*n) memory. Track titles cap out at ~100
|
||||
// tokens so this stays trivially small.
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array<number>(n + 1).fill(0),
|
||||
);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (ta[i - 1] === tb[j - 1]) {
|
||||
dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
||||
} else {
|
||||
dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack to build the op list (reversed).
|
||||
const ops: DiffSegment[] = [];
|
||||
let i = m;
|
||||
let j = n;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && ta[i - 1] === tb[j - 1]) {
|
||||
ops.push({ type: 'equal', text: ta[i - 1]! });
|
||||
i--;
|
||||
j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
|
||||
ops.push({ type: 'add', text: tb[j - 1]! });
|
||||
j--;
|
||||
} else {
|
||||
ops.push({ type: 'remove', text: ta[i - 1]! });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
ops.reverse();
|
||||
|
||||
// Coalesce adjacent same-type segments so the renderer outputs
|
||||
// one span per visual run instead of per token.
|
||||
const merged: DiffSegment[] = [];
|
||||
for (const seg of ops) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.type === seg.type) {
|
||||
last.text += seg.text;
|
||||
} else {
|
||||
merged.push({ type: seg.type, text: seg.text });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
Reference in New Issue
Block a user