···11+# sv
22+33+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
44+55+## Creating a project
66+77+If you're seeing this, you've probably already done this step. Congrats!
88+99+```sh
1010+# create a new project
1111+npx sv create my-app
1212+```
1313+1414+To recreate this project with the same configuration:
1515+1616+```sh
1717+# recreate this project
1818+pnpm dlx sv create --template minimal --types ts --add prettier tailwindcss="plugins:none" sveltekit-adapter="adapter:vercel" --install pnpm ./web
1919+```
2020+2121+## Developing
2222+2323+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
2424+2525+```sh
2626+npm run dev
2727+2828+# or start the server and open the app in a new browser tab
2929+npm run dev -- --open
3030+```
3131+3232+## Building
3333+3434+To create a production version of your app:
3535+3636+```sh
3737+npm run build
3838+```
3939+4040+You can preview the production build with `npm run preview`.
4141+4242+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
···11+/**
22+ * Browser-compatible Spotify JSON parser.
33+ * Mirrors src/lib/spotify.ts without any Node.js deps.
44+ */
55+66+import type { SpotifyRecord, PlayRecord } from '../types.js';
77+import { RECORD_TYPE, CLIENT_AGENT } from '../config.js';
88+99+export function parseSpotifyJsonContent(records: SpotifyRecord[]): SpotifyRecord[] {
1010+ return records.filter(
1111+ (r) => r.master_metadata_track_name && r.master_metadata_album_artist_name
1212+ );
1313+}
1414+1515+export async function parseSpotifyFiles(files: File[]): Promise<SpotifyRecord[]> {
1616+ let all: SpotifyRecord[] = [];
1717+ for (const file of files) {
1818+ const text = await file.text();
1919+ const parsed = JSON.parse(text) as SpotifyRecord[];
2020+ all = all.concat(parsed);
2121+ }
2222+ return parseSpotifyJsonContent(all);
2323+}
2424+2525+export function convertSpotifyToPlayRecord(r: SpotifyRecord): PlayRecord {
2626+ const artists: PlayRecord['artists'] = [];
2727+ if (r.master_metadata_album_artist_name) {
2828+ artists.push({ artistName: r.master_metadata_album_artist_name });
2929+ }
3030+3131+ const record: PlayRecord = {
3232+ $type: RECORD_TYPE,
3333+ trackName: r.master_metadata_track_name ?? 'Unknown Track',
3434+ artists,
3535+ playedTime: r.ts,
3636+ submissionClientAgent: CLIENT_AGENT,
3737+ musicServiceBaseDomain: 'spotify.com',
3838+ originUrl: ''
3939+ };
4040+4141+ if (r.master_metadata_album_album_name) record.releaseName = r.master_metadata_album_album_name;
4242+ if (r.spotify_track_uri) {
4343+ const id = r.spotify_track_uri.replace('spotify:track:', '');
4444+ record.originUrl = `https://open.spotify.com/track/${id}`;
4545+ }
4646+4747+ return record;
4848+}
+153
packages/malachite-web/src/lib/core/sync.ts
···11+/**
22+ * Browser-compatible sync helpers.
33+ * Fetches existing records from ATProto and filters for new ones.
44+ */
55+66+import type { AtpAgent } from '@atproto/api';
77+import type { PlayRecord } from '../types.js';
88+import { RECORD_TYPE } from '../config.js';
99+1010+export interface ExistingRecord {
1111+ uri: string;
1212+ cid: string;
1313+ value: PlayRecord;
1414+}
1515+1616+function recordKey(r: PlayRecord): string {
1717+ const artist = (r.artists[0]?.artistName ?? '').toLowerCase().trim();
1818+ return `${artist}|||${r.trackName.toLowerCase().trim()}|||${r.playedTime}`;
1919+}
2020+2121+/** In-session cache so repeat calls don't re-fetch. */
2222+const sessionCache = new Map<string, Map<string, ExistingRecord>>();
2323+2424+export async function fetchExistingRecords(
2525+ agent: AtpAgent,
2626+ onProgress?: (fetched: number) => void,
2727+ forceRefresh = false
2828+): Promise<Map<string, ExistingRecord>> {
2929+ const did = agent.session?.did;
3030+ if (!did) throw new Error('No authenticated session');
3131+3232+ if (!forceRefresh && sessionCache.has(did)) {
3333+ return sessionCache.get(did)!;
3434+ }
3535+3636+ const map = new Map<string, ExistingRecord>();
3737+ let cursor: string | undefined;
3838+ let total = 0;
3939+ let batchSize = 50;
4040+4141+ do {
4242+ const res = await agent.com.atproto.repo.listRecords({
4343+ repo: did,
4444+ collection: RECORD_TYPE,
4545+ limit: batchSize,
4646+ cursor
4747+ });
4848+4949+ for (const rec of res.data.records) {
5050+ const value = rec.value as unknown as PlayRecord;
5151+ const key = recordKey(value);
5252+ map.set(key, { uri: rec.uri, cid: rec.cid, value });
5353+ }
5454+5555+ total += res.data.records.length;
5656+ cursor = res.data.cursor;
5757+ onProgress?.(total);
5858+5959+ // Simple adaptive sizing
6060+ if (res.data.records.length === batchSize && batchSize < 100) {
6161+ batchSize = Math.min(100, batchSize * 2);
6262+ }
6363+ } while (cursor);
6464+6565+ sessionCache.set(did, map);
6666+ return map;
6767+}
6868+6969+export function filterNewRecords(
7070+ records: PlayRecord[],
7171+ existing: Map<string, ExistingRecord>
7272+): PlayRecord[] {
7373+ return records.filter((r) => !existing.has(recordKey(r)));
7474+}
7575+7676+export async function fetchAllRecordsForDedup(
7777+ agent: AtpAgent,
7878+ onProgress?: (fetched: number) => void
7979+): Promise<ExistingRecord[]> {
8080+ const did = agent.session?.did;
8181+ if (!did) throw new Error('No authenticated session');
8282+8383+ const all: ExistingRecord[] = [];
8484+ let cursor: string | undefined;
8585+ let batchSize = 50;
8686+8787+ do {
8888+ const res = await agent.com.atproto.repo.listRecords({
8989+ repo: did,
9090+ collection: RECORD_TYPE,
9191+ limit: batchSize,
9292+ cursor
9393+ });
9494+9595+ for (const rec of res.data.records) {
9696+ const value = rec.value as unknown as PlayRecord;
9797+ all.push({ uri: rec.uri, cid: rec.cid, value });
9898+ }
9999+100100+ cursor = res.data.cursor;
101101+ onProgress?.(all.length);
102102+103103+ if (res.data.records.length === batchSize && batchSize < 100) {
104104+ batchSize = Math.min(100, batchSize * 2);
105105+ }
106106+ } while (cursor);
107107+108108+ return all;
109109+}
110110+111111+export interface DedupGroup {
112112+ key: string;
113113+ records: ExistingRecord[];
114114+}
115115+116116+export function findDuplicateGroups(records: ExistingRecord[]): DedupGroup[] {
117117+ const groups = new Map<string, ExistingRecord[]>();
118118+ for (const rec of records) {
119119+ const key = recordKey(rec.value);
120120+ if (!groups.has(key)) groups.set(key, []);
121121+ groups.get(key)!.push(rec);
122122+ }
123123+ const result: DedupGroup[] = [];
124124+ for (const [key, recs] of groups) {
125125+ if (recs.length > 1) result.push({ key, records: recs });
126126+ }
127127+ return result;
128128+}
129129+130130+export async function removeDuplicateRecords(
131131+ agent: AtpAgent,
132132+ groups: DedupGroup[],
133133+ onProgress?: (removed: number) => void
134134+): Promise<number> {
135135+ let removed = 0;
136136+ for (const group of groups) {
137137+ for (const rec of group.records.slice(1)) {
138138+ try {
139139+ await agent.com.atproto.repo.deleteRecord({
140140+ repo: agent.session?.did ?? '',
141141+ collection: RECORD_TYPE,
142142+ rkey: rec.uri.split('/').pop()!
143143+ });
144144+ removed++;
145145+ onProgress?.(removed);
146146+ await new Promise((r) => setTimeout(r, 100));
147147+ } catch {
148148+ // continue on individual failures
149149+ }
150150+ }
151151+ }
152152+ return removed;
153153+}
+48
packages/malachite-web/src/lib/core/tid.ts
···11+/**
22+ * Browser-compatible TID (Timestamp Identifier) generation for ATProto.
33+ *
44+ * Re-implements the monotonic TID clock from src/utils/tid-clock.ts without
55+ * any Node.js dependencies (no fs, no crypto module — uses crypto.getRandomValues).
66+ *
77+ * Spec: https://atproto.com/specs/tid
88+ */
99+1010+// Base-32 alphabet used by AT Protocol (not standard base32)
1111+const S32_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
1212+1313+function s32encode(n: number): string {
1414+ if (n === 0) return '2';
1515+ let s = '';
1616+ let val = n;
1717+ while (val > 0) {
1818+ s = S32_CHARS[val % 32] + s;
1919+ val = Math.floor(val / 32);
2020+ }
2121+ return s;
2222+}
2323+2424+// In-memory monotonic state
2525+let lastTimestampUs = 0;
2626+const clockId = (() => {
2727+ const buf = new Uint8Array(1);
2828+ crypto.getRandomValues(buf);
2929+ return buf[0] % 32;
3030+})();
3131+3232+export async function generateTIDFromISO(isoString: string, _context?: string): Promise<string> {
3333+ let timestamp = new Date(isoString).getTime() * 1000; // ms → µs
3434+3535+ // Monotonicity: never go backwards
3636+ if (timestamp <= lastTimestampUs) {
3737+ timestamp = lastTimestampUs + 1;
3838+ }
3939+ lastTimestampUs = timestamp;
4040+4141+ const timestampStr = s32encode(timestamp).padStart(11, '2');
4242+ const clockIdStr = s32encode(clockId).padStart(2, '2');
4343+ return timestampStr + clockIdStr;
4444+}
4545+4646+export function resetTidClock(): void {
4747+ lastTimestampUs = 0;
4848+}
+4
packages/malachite-web/src/lib/index.ts
···11+// Public re-exports — import from '$lib' instead of deep paths.
22+export type { ImportMode, LogEntry, LogLevel, PlayRecord } from './types.js';
33+export { MODES, modeNeeds, stepLabelsFor } from './modes.js';
44+export type { ModeConfig, IconComponent } from './modes.js';
+36
packages/malachite-web/src/lib/modes.ts
···11+import { Music2, Disc3, Layers2, RefreshCw, ListFilter } from '@lucide/svelte';
22+import type { ImportMode } from '$lib/types.js';
33+import type { Component } from 'svelte';
44+55+export type IconComponent = Component<{ size?: number; strokeWidth?: number; color?: string }>;
66+77+export interface ModeConfig {
88+ id: ImportMode;
99+ icon: IconComponent;
1010+ title: string;
1111+ description: string;
1212+}
1313+1414+export const MODES: ModeConfig[] = [
1515+ { id: 'lastfm', icon: Music2 as IconComponent, title: 'Last.fm', description: 'Import scrobble history from a Last.fm CSV export' },
1616+ { id: 'spotify', icon: Disc3 as IconComponent, title: 'Spotify', description: 'Import play history from a Spotify JSON export' },
1717+ { id: 'combined', icon: Layers2 as IconComponent, title: 'Combined', description: 'Merge Last.fm + Spotify with smart deduplication' },
1818+ { id: 'sync', icon: RefreshCw as IconComponent, title: 'Sync', description: 'Only import records not already in Teal' },
1919+ { id: 'deduplicate', icon: ListFilter as IconComponent, title: 'Deduplicate', description: 'Find and remove duplicate records from Teal' },
2020+];
2121+2222+/** Which file sources does a given mode require? */
2323+export function modeNeeds(mode: ImportMode | null) {
2424+ return {
2525+ lastfm: mode === 'lastfm' || mode === 'combined' || mode === 'sync',
2626+ spotify: mode === 'spotify' || mode === 'combined',
2727+ files: mode !== 'deduplicate',
2828+ };
2929+}
3030+3131+/** Wizard step labels for a given mode. */
3232+export function stepLabelsFor(mode: ImportMode | null): string[] {
3333+ return mode === 'deduplicate'
3434+ ? ['Mode', 'Sign in', 'Options', 'Run']
3535+ : ['Mode', 'Sign in', 'Files', 'Options', 'Run'];
3636+}