@audiofool/moondog 0.1.0 → 0.2.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
2
|
+
import { intlLocale, screenTranslator } from "../../i18n/index.mjs";
|
|
2
3
|
|
|
3
4
|
const MAX_ITEMS_PER_SOURCE = 10;
|
|
4
5
|
|
|
@@ -27,9 +28,9 @@ function count(value) {
|
|
|
27
28
|
: undefined;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
function amount(value) {
|
|
31
|
+
function amount(value, locale = "en") {
|
|
31
32
|
return Number.isFinite(value) && value >= 0
|
|
32
|
-
? value.toLocaleString(
|
|
33
|
+
? value.toLocaleString(intlLocale(locale), { maximumFractionDigits: 1 })
|
|
33
34
|
: undefined;
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -57,59 +58,56 @@ function subjectKey(target) {
|
|
|
57
58
|
]);
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
function
|
|
61
|
-
return `${count(value)} ${value === 1 ? one : many}`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function itemMetrics(item) {
|
|
61
|
+
function itemMetrics(item, tr) {
|
|
65
62
|
const values = [];
|
|
66
63
|
const known = (field) => count(item?.[field]) !== undefined;
|
|
67
|
-
if (known("play_count")) values.push(
|
|
64
|
+
if (known("play_count")) values.push(tr.n(item.play_count, "{count} play", "{count} plays"));
|
|
68
65
|
if (known("engaged_play_count") && item.engaged_play_count !== item.play_count) {
|
|
69
|
-
values.push(
|
|
66
|
+
values.push(tr("{count} heard properly", { count: tr.number(item.engaged_play_count) }));
|
|
70
67
|
}
|
|
71
|
-
if (known("distinct_tracks")) values.push(
|
|
72
|
-
if (known("explicit_skips") && item.explicit_skips > 0) values.push(
|
|
73
|
-
if (known("playlist_count")) values.push(
|
|
74
|
-
if (known("quiet_days")) values.push(
|
|
75
|
-
if (known("return_count")) values.push(
|
|
76
|
-
if (known("longest_gap_days")) values.push(
|
|
77
|
-
if (known("maximum_consecutive_plays")) values.push(
|
|
78
|
-
if (known("burst_count") && item.burst_count > 1) values.push(
|
|
79
|
-
const minutes = amount(item?.listening_minutes);
|
|
80
|
-
if (minutes !== undefined) values.push(
|
|
68
|
+
if (known("distinct_tracks")) values.push(tr.n(item.distinct_tracks, "{count} track", "{count} tracks"));
|
|
69
|
+
if (known("explicit_skips") && item.explicit_skips > 0) values.push(tr.n(item.explicit_skips, "skipped {count} time", "skipped {count} times"));
|
|
70
|
+
if (known("playlist_count")) values.push(tr.n(item.playlist_count, "on {count} playlist", "on {count} playlists"));
|
|
71
|
+
if (known("quiet_days")) values.push(tr.n(item.quiet_days, "quiet for {count} day", "quiet for {count} days"));
|
|
72
|
+
if (known("return_count")) values.push(item.return_count === 1 ? tr("came back once") : tr.n(item.return_count, "came back {count} time", "came back {count} times"));
|
|
73
|
+
if (known("longest_gap_days")) values.push(tr.n(item.longest_gap_days, "longest time away {count} day", "longest time away {count} days"));
|
|
74
|
+
if (known("maximum_consecutive_plays")) values.push(tr("up to {count} in a row", { count: tr.number(item.maximum_consecutive_plays) }));
|
|
75
|
+
if (known("burst_count") && item.burst_count > 1) values.push(tr("{count} separate runs", { count: tr.number(item.burst_count) }));
|
|
76
|
+
const minutes = amount(item?.listening_minutes, tr.locale);
|
|
77
|
+
if (minutes !== undefined) values.push(tr("{minutes} min", { minutes }));
|
|
81
78
|
const lastPlayed = date(item?.last_played_at);
|
|
82
|
-
if (lastPlayed) values.push(
|
|
79
|
+
if (lastPlayed) values.push(tr("last played {date}", { date: lastPlayed }));
|
|
83
80
|
if (Number.isFinite(item?.confidence) && item.confidence >= 0 && item.confidence <= 1) {
|
|
84
|
-
values.push(
|
|
81
|
+
values.push(tr("{percent}% sure", { percent: Math.round(item.confidence * 100) }));
|
|
85
82
|
}
|
|
86
83
|
return values;
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
function overview(profile) {
|
|
86
|
+
function overview(profile, tr) {
|
|
90
87
|
const coverage = profile?.coverage ?? {};
|
|
91
88
|
const lines = [];
|
|
92
89
|
const durationUnavailable = coverage.effective_listening_events > 0 && coverage.events_with_played_duration === 0;
|
|
90
|
+
const hours = durationUnavailable ? undefined : amount(coverage.listening_hours, tr.locale);
|
|
93
91
|
const listening = [
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
]
|
|
98
|
-
if (durationUnavailable) listening.push("listening time unknown");
|
|
92
|
+
...(count(coverage.effective_listening_events) !== undefined ? [tr.n(coverage.effective_listening_events, "{count} play", "{count} plays")] : []),
|
|
93
|
+
...(hours !== undefined ? [tr("{hours} h", { hours })] : []),
|
|
94
|
+
...(count(coverage.listening_tracks) !== undefined ? [tr.n(coverage.listening_tracks, "{count} track", "{count} tracks")] : []),
|
|
95
|
+
];
|
|
96
|
+
if (durationUnavailable) listening.push(tr("listening time unknown"));
|
|
99
97
|
if (listening.length && !(coverage.effective_listening_events === 0 && (coverage.tracks_observed > 0 || coverage.collection_tracks > 0))) {
|
|
100
98
|
lines.push(listening.join(" · "));
|
|
101
99
|
}
|
|
102
100
|
if (Number.isSafeInteger(coverage.tracks_observed) && coverage.tracks_observed > 0) {
|
|
103
101
|
const loved = count(coverage.loved_or_favorited);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
const tracks = tr.n(coverage.tracks_observed, "{count} track", "{count} tracks");
|
|
103
|
+
lines.push(loved === undefined ? tr("Apple Music library: {tracks}", { tracks })
|
|
104
|
+
: tr("Apple Music library: {tracks} · {loved} loved", { tracks, loved: tr.number(coverage.loved_or_favorited) }));
|
|
107
105
|
}
|
|
108
106
|
const source = profile?.listening_source ?? {};
|
|
109
107
|
const collections = items(coverage.collection_sources).filter((collection) => collection.tracks > 0);
|
|
110
|
-
if (collections.length > 1) lines.push(
|
|
108
|
+
if (collections.length > 1) lines.push(tr("{songs} across {count} services", { songs: tr.n(coverage.collection_tracks, "{count} song", "{count} songs"), count: collections.length }));
|
|
111
109
|
for (const collection of collections) {
|
|
112
|
-
if (collection.tracks > 0) lines.push(`${inline(collection.label)}: ${
|
|
110
|
+
if (collection.tracks > 0) lines.push(`${inline(collection.label)}: ${tr.n(collection.tracks, "{count} song", "{count} songs")}`);
|
|
113
111
|
}
|
|
114
112
|
const providers = items(source.providers).map(inline).filter(Boolean).map((provider) => ({
|
|
115
113
|
spotify: "Spotify",
|
|
@@ -120,21 +118,26 @@ function overview(profile) {
|
|
|
120
118
|
})[provider] ?? provider);
|
|
121
119
|
const earliest = date(source.listening_range?.earliest);
|
|
122
120
|
const latest = date(source.listening_range?.latest);
|
|
123
|
-
const range = earliest && latest ?
|
|
121
|
+
const range = earliest && latest ? tr("{first} to {last}", { first: earliest, last: latest }) : earliest || latest;
|
|
124
122
|
if (providers.length || range) {
|
|
125
|
-
lines.push([providers.length ?
|
|
123
|
+
lines.push([providers.length ? tr("{services} history", { services: providers.join(", ") }) : tr("History"), range]
|
|
126
124
|
.filter(Boolean).join(" · "));
|
|
127
125
|
}
|
|
128
126
|
const captured = date(profile?.source?.captured_at);
|
|
129
|
-
if (captured) lines.push(
|
|
130
|
-
|
|
131
|
-
if (
|
|
127
|
+
if (captured) lines.push(tr("Library as of {date}", { date: captured }));
|
|
128
|
+
if (coverage.active_listener_assertions > 0) lines.push(tr.n(coverage.active_listener_assertions, "{count} choice you've made", "{count} choices you've made"));
|
|
129
|
+
if (profile?.listener_model) {
|
|
130
|
+
const saved = profile.listener_model;
|
|
131
|
+
lines.splice(1, 0, saved.state === "missing" ? tr("Build a saved reading: /profile build")
|
|
132
|
+
: saved.state === "stale" ? tr("Saved reading v{version} needs update: /profile build", { version: saved.sequence })
|
|
133
|
+
: tr("Saved reading v{version}: /profile saved", { version: saved.sequence }));
|
|
134
|
+
}
|
|
132
135
|
return lines;
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
const sourceNames = {
|
|
136
|
-
"Apple Music library preference": "In your Apple Music library",
|
|
137
|
-
"Apple Music aggregate play count": "Apple Music play count",
|
|
139
|
+
"Apple Music library preference": tr => tr("In your Apple Music library"),
|
|
140
|
+
"Apple Music aggregate play count": tr => tr("Apple Music play count"),
|
|
138
141
|
};
|
|
139
142
|
|
|
140
143
|
function explicitKind(item) {
|
|
@@ -145,9 +148,10 @@ function explicitKind(item) {
|
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
/** Build a private, bounded review view from structured profile evidence. */
|
|
148
|
-
export function buildTasteProfileModel(profile = {}) {
|
|
151
|
+
export function buildTasteProfileModel(profile = {}, tr = screenTranslator("en")) {
|
|
149
152
|
const metrics = (item) => itemMetrics(profile.coverage?.events_with_played_duration === 0
|
|
150
|
-
? { ...item, listening_minutes: undefined } : item);
|
|
153
|
+
? { ...item, listening_minutes: undefined } : item, tr);
|
|
154
|
+
const withDetails = (source, details) => details.length ? tr("{source}: {details}", { source, details: details.join(" · ") }) : source;
|
|
151
155
|
const subjects = new Map();
|
|
152
156
|
const seenDetails = new Map();
|
|
153
157
|
const assertions = profile?.listener_assertions ?? {};
|
|
@@ -163,7 +167,7 @@ export function buildTasteProfileModel(profile = {}) {
|
|
|
163
167
|
subject = {
|
|
164
168
|
key,
|
|
165
169
|
label: inline(target.label),
|
|
166
|
-
subtitle: kind === "track" ? inline(target.artistCredit) : "Artist",
|
|
170
|
+
subtitle: kind === "track" ? inline(target.artistCredit) : tr("Artist"),
|
|
167
171
|
kind,
|
|
168
172
|
detailLines: [],
|
|
169
173
|
target,
|
|
@@ -204,26 +208,22 @@ export function buildTasteProfileModel(profile = {}) {
|
|
|
204
208
|
seenAssertions.add(key);
|
|
205
209
|
const when = date(item.asserted_at);
|
|
206
210
|
const note = inline(item.note);
|
|
207
|
-
add(item, item.entity_type, "Your choice",
|
|
208
|
-
|
|
209
|
-
}${when ? ` · ${when}` : ""}${note ? ` · "${note}"` : ""}`, { assertion: true });
|
|
211
|
+
add(item, item.entity_type, tr("Your choice"), [item.stance === "like" ? tr("You said: I like this") : tr("You said: keep this out"),
|
|
212
|
+
...(when ? [when] : []), ...(note ? [`"${note}"`] : [])].join(" · "), { assertion: true });
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
const recentDays = count(behavior.context?.recent_window_days);
|
|
213
|
-
const recentSource = recentDays ?
|
|
216
|
+
const recentSource = recentDays ? tr("Lately (last {count} days)", { count: recentDays }) : tr("Lately");
|
|
214
217
|
for (const [field, kind, source] of [
|
|
215
|
-
["repeat_tracks", "track", "Most played"],
|
|
216
|
-
["enduring_artists", "artist", "Across the years"],
|
|
218
|
+
["repeat_tracks", "track", tr("Most played")],
|
|
219
|
+
["enduring_artists", "artist", tr("Across the years")],
|
|
217
220
|
["recent_tracks", "track", recentSource],
|
|
218
221
|
["recent_artists", "artist", recentSource],
|
|
219
|
-
["rediscovery_tracks", "track", "Gone quiet"],
|
|
220
|
-
["historical_return_tracks", "track", "Came back"],
|
|
221
|
-
["back_to_back_tracks", "track", "Played in a row"],
|
|
222
|
+
["rediscovery_tracks", "track", tr("Gone quiet")],
|
|
223
|
+
["historical_return_tracks", "track", tr("Came back")],
|
|
224
|
+
["back_to_back_tracks", "track", tr("Played in a row")],
|
|
222
225
|
]) {
|
|
223
|
-
for (const item of items(behavior[field]))
|
|
224
|
-
const details = metrics(item);
|
|
225
|
-
add(item, kind, source, `${source}${details.length ? `: ${details.join(" · ")}` : ""}`);
|
|
226
|
-
}
|
|
226
|
+
for (const item of items(behavior[field])) add(item, kind, source, withDetails(source, metrics(item)));
|
|
227
227
|
}
|
|
228
228
|
for (const item of items(behavior.time_capsule_tracks)) {
|
|
229
229
|
const year = count(item?.capsule_year);
|
|
@@ -233,35 +233,32 @@ export function buildTasteProfileModel(profile = {}) {
|
|
|
233
233
|
listening_minutes: item?.year_listening_minutes,
|
|
234
234
|
explicit_skips: item?.year_explicit_skips,
|
|
235
235
|
});
|
|
236
|
-
add(item, "track", "Song of the year",
|
|
237
|
-
details.length ? `: ${details.join(" · ")}` : ""
|
|
238
|
-
}`);
|
|
236
|
+
add(item, "track", tr("Song of the year"), withDetails(year ? tr("Your song of {year}", { year: item.capsule_year }) : tr("Song of the year"), details));
|
|
239
237
|
}
|
|
240
238
|
for (const [field, kind, source] of [
|
|
241
|
-
["saved_tracks", "track", "Saved in your
|
|
242
|
-
["playlist_anchors", "track", "On your
|
|
243
|
-
["followed_artists", "artist", "Followed on
|
|
239
|
+
["saved_tracks", "track", service => tr("Saved in your {service} library", { service })],
|
|
240
|
+
["playlist_anchors", "track", service => tr("On your {service} playlists", { service })],
|
|
241
|
+
["followed_artists", "artist", service => tr("Followed on {service}", { service })],
|
|
244
242
|
]) {
|
|
245
243
|
for (const item of items(curated[field])) {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
add(item, kind, label, `${label}${details.length ? `: ${details.join(" · ")}` : ""}`);
|
|
244
|
+
const label = source(inline(item.source_label) || "Spotify");
|
|
245
|
+
add(item, kind, label, withDetails(label, metrics(item)));
|
|
249
246
|
}
|
|
250
247
|
}
|
|
251
248
|
for (const item of items(curated.avoids)) {
|
|
252
249
|
const target = targetFor(item, item?.entity_type);
|
|
253
250
|
if (target && !seenAssertions.has(subjectKey(target))) {
|
|
254
|
-
add(item, item.entity_type, "Disliked in your music service", "You disliked this in your music service. You can still change it here.");
|
|
251
|
+
add(item, item.entity_type, tr("Disliked in your music service"), tr("You disliked this in your music service. You can still change it here."));
|
|
255
252
|
}
|
|
256
253
|
}
|
|
257
254
|
for (const [field, kind] of [["artists", "artist"], ["tracks", "track"]]) {
|
|
258
255
|
for (const item of items(profile?.provider_signals?.[field])) {
|
|
259
256
|
const rank = count(item?.best_rank);
|
|
260
|
-
const details = [...(rank ? [
|
|
261
|
-
add(item, kind, "Service top list",
|
|
257
|
+
const details = [...(rank ? [tr("best at #{rank}", { rank })] : []), ...metrics(item)];
|
|
258
|
+
add(item, kind, tr("Service top list"), withDetails(tr("In your service's top list"), details));
|
|
262
259
|
}
|
|
263
260
|
}
|
|
264
|
-
for (const [field, fallback] of [["strong_preferences", "From your library"], ["familiarity", "Familiar from your library"]]) {
|
|
261
|
+
for (const [field, fallback] of [["strong_preferences", tr("From your library")], ["familiarity", tr("Familiar from your library")]]) {
|
|
265
262
|
for (const item of items(profile?.[field])) {
|
|
266
263
|
const kind = explicitKind(item);
|
|
267
264
|
if (!kind) continue;
|
|
@@ -269,10 +266,10 @@ export function buildTasteProfileModel(profile = {}) {
|
|
|
269
266
|
if (!target) continue;
|
|
270
267
|
const existing = subjects.get(subjectKey(target));
|
|
271
268
|
if (item.evidence_id && existing?.evidence.some((evidence) => evidence.id === item.evidence_id)) continue;
|
|
272
|
-
const source = sourceNames[inline(item.source)] ?? (inline(item.source) || fallback);
|
|
269
|
+
const source = sourceNames[inline(item.source)]?.(tr) ?? (inline(item.source) || fallback);
|
|
273
270
|
const observed = date(item.observed_at);
|
|
274
|
-
const details = [inline(item.signal).replace(/,? non-computed rating/u, ", rated"), ...metrics(item), ...(observed ? [
|
|
275
|
-
add(item, kind, source,
|
|
271
|
+
const details = [inline(item.signal).replace(/,? non-computed rating/u, ", rated"), ...metrics(item), ...(observed ? [tr("as of {date}", { date: observed })] : [])].filter(Boolean);
|
|
272
|
+
add(item, kind, source, withDetails(source, details));
|
|
276
273
|
}
|
|
277
274
|
}
|
|
278
275
|
for (const item of items(profile?.artist_facets)) {
|
|
@@ -280,27 +277,29 @@ export function buildTasteProfileModel(profile = {}) {
|
|
|
280
277
|
if (!target) continue;
|
|
281
278
|
const existing = subjects.get(subjectKey(target));
|
|
282
279
|
if (item.evidence_id && existing?.evidence.some((evidence) => evidence.id === item.evidence_id)) continue;
|
|
283
|
-
add(item, "artist", "
|
|
280
|
+
add(item, "artist", tr("Library evidence"), Number.isSafeInteger(item.library_tracks)
|
|
281
|
+
? tr("Across your Apple library: {tracks} · {preferred} with positive preferences.", { tracks: tr.n(item.library_tracks, "{count} track", "{count} tracks"), preferred: tr.number(item.preferred_tracks) })
|
|
282
|
+
: tr("Shows up through one of their tracks."));
|
|
284
283
|
}
|
|
285
284
|
|
|
286
285
|
for (const subject of subjects.values()) {
|
|
287
286
|
if (subject.kind !== "track") continue;
|
|
288
287
|
const artist = subjects.get(subjectKey({ entityType: "artist", label: subject.target.artistCredit }));
|
|
289
288
|
if (artist?.stance !== "avoid") continue;
|
|
290
|
-
subject.detailLines.unshift(
|
|
291
|
-
|
|
292
|
-
|
|
289
|
+
subject.detailLines.unshift(subject.stance === "like"
|
|
290
|
+
? tr("You asked me to keep {artist} out. That still applies, even though you like this track.", { artist: artist.label })
|
|
291
|
+
: tr("You asked me to keep {artist} out.", { artist: artist.label }));
|
|
293
292
|
if (artist.evidenceId && !subject.evidence.some((evidence) => evidence.id === artist.evidenceId)) {
|
|
294
|
-
subject.evidence.push({ id: artist.evidenceId, source: "Your choice for the artist" });
|
|
293
|
+
subject.evidence.push({ id: artist.evidenceId, source: tr("Your choice for the artist") });
|
|
295
294
|
}
|
|
296
295
|
}
|
|
297
296
|
|
|
298
297
|
return {
|
|
299
|
-
summaryLines: overview(profile),
|
|
298
|
+
summaryLines: overview(profile, tr),
|
|
300
299
|
subjects: [...subjects.values()],
|
|
301
300
|
emptyLines: [
|
|
302
|
-
"Nothing to look at yet.",
|
|
303
|
-
"Import your history or library and it will show up here.",
|
|
301
|
+
tr("Nothing to look at yet."),
|
|
302
|
+
tr("Import your history or library and it will show up here."),
|
|
304
303
|
],
|
|
305
304
|
};
|
|
306
305
|
}
|
|
@@ -9,12 +9,13 @@ import {
|
|
|
9
9
|
} from "@earendil-works/pi-tui";
|
|
10
10
|
|
|
11
11
|
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
12
|
+
import { N_, screenTranslator } from "../../i18n/index.mjs";
|
|
12
13
|
|
|
13
14
|
const categories = [
|
|
14
|
-
{ id: "all", label: "All" },
|
|
15
|
-
{ id: "track", label: "Tracks" },
|
|
16
|
-
{ id: "artist", label: "Artists" },
|
|
17
|
-
{ id: "choices", label: "Your choices" },
|
|
15
|
+
{ id: "all", label: N_("All") },
|
|
16
|
+
{ id: "track", label: N_("Tracks") },
|
|
17
|
+
{ id: "artist", label: N_("Artists") },
|
|
18
|
+
{ id: "choices", label: N_("Your choices") },
|
|
18
19
|
];
|
|
19
20
|
|
|
20
21
|
function clean(value) {
|
|
@@ -41,14 +42,15 @@ function ends(left, right, width) {
|
|
|
41
42
|
return head + " ".repeat(width - visibleWidth(head) - tailWidth) + right;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
function stanceLabel(subject) {
|
|
45
|
-
return subject?.stance === "like" ? "You like this" : subject?.stance === "avoid" ? "You asked me to keep this out" : "";
|
|
45
|
+
function stanceLabel(subject, tr) {
|
|
46
|
+
return subject?.stance === "like" ? tr("You like this") : subject?.stance === "avoid" ? tr("You asked me to keep this out") : "";
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/** A terminal-native, searchable profile with a live reading beside its subjects. */
|
|
49
50
|
export class TasteProfileView {
|
|
50
|
-
constructor({ model, getTheme, getRows }) {
|
|
51
|
+
constructor({ model, getTheme, getRows, getText = () => screenTranslator("en") }) {
|
|
51
52
|
this.getTheme = getTheme;
|
|
53
|
+
this.getText = getText;
|
|
52
54
|
this.getRows = getRows;
|
|
53
55
|
this.input = new Input();
|
|
54
56
|
this.category = "all";
|
|
@@ -76,7 +78,7 @@ export class TasteProfileView {
|
|
|
76
78
|
const subjects = Array.isArray(this.model.subjects) ? this.model.subjects : [];
|
|
77
79
|
this.entries = subjects.filter((subject) => subject && typeof subject === "object").map((subject) => ({
|
|
78
80
|
subject,
|
|
79
|
-
search: [subject.label, subject.subtitle, subject.kind, stanceLabel(subject), ...(subject.detailLines ?? [])]
|
|
81
|
+
search: [subject.label, subject.subtitle, subject.kind, stanceLabel(subject, this.getText()), ...(subject.detailLines ?? [])]
|
|
80
82
|
.map(inline).join(" ").toLowerCase(),
|
|
81
83
|
}));
|
|
82
84
|
this.filterSubjects();
|
|
@@ -150,13 +152,14 @@ export class TasteProfileView {
|
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
renderCategories(width, theme) {
|
|
155
|
+
const tr = this.getText();
|
|
153
156
|
if (width < 34) {
|
|
154
|
-
const label = categories.find((category) => category.id === this.category)?.label ?? "All";
|
|
155
|
-
return theme.bold(`‹ ${label} ›`) + (width >= 24 ? theme.faint("
|
|
157
|
+
const label = tr.marked(categories.find((category) => category.id === this.category)?.label ?? "All");
|
|
158
|
+
return theme.bold(`‹ ${label} ›`) + (width >= 24 ? theme.faint(` ${tr("tab change")}`) : "");
|
|
156
159
|
}
|
|
157
160
|
return categories.map(({ id, label }) => {
|
|
158
|
-
const text = width < 38 && id === "choices" ? "Choices" : label;
|
|
159
|
-
return id === this.category ? theme.
|
|
161
|
+
const text = width < 38 && id === "choices" ? tr("Choices") : tr.marked(label);
|
|
162
|
+
return id === this.category ? theme.underline(theme.selected(text)) : theme.muted(text);
|
|
160
163
|
}).join(width >= 34 ? " " : " ");
|
|
161
164
|
}
|
|
162
165
|
|
|
@@ -167,14 +170,15 @@ export class TasteProfileView {
|
|
|
167
170
|
const selectedIndex = this.filtered.findIndex((subject) => subject.key === this.selectedKey);
|
|
168
171
|
const start = Math.max(0, Math.min(selectedIndex - Math.floor(capacity / 2), this.filtered.length - capacity));
|
|
169
172
|
const lines = [];
|
|
173
|
+
const tr = this.getText();
|
|
170
174
|
for (const subject of this.filtered.slice(start, start + capacity)) {
|
|
171
175
|
const selected = subject.key === this.selectedKey;
|
|
172
|
-
const marker = selected ? "
|
|
173
|
-
const label = inline(subject.label) || (subject.kind === "artist" ? "Artist" : "Track");
|
|
176
|
+
const marker = selected ? "◉ " : " ";
|
|
177
|
+
const label = inline(subject.label) || (subject.kind === "artist" ? tr("Artist") : tr("Track"));
|
|
174
178
|
const stance = subject.stance === "like" ? "+" : subject.stance === "avoid" ? "−" : "";
|
|
175
179
|
const main = ends(marker + label, stance, width);
|
|
176
|
-
lines.push(selected ? theme.
|
|
177
|
-
if (!compact && lines.length < height) lines.push(theme.muted(` ${inline(subject.subtitle) || (subject.kind === "artist" ? "Artist" : "Track")}`));
|
|
180
|
+
lines.push(selected ? theme.selected(main) : theme.text(main));
|
|
181
|
+
if (!compact && lines.length < height) lines.push(theme.muted(` ${inline(subject.subtitle) || (subject.kind === "artist" ? tr("Artist") : tr("Track"))}`));
|
|
178
182
|
}
|
|
179
183
|
return Array.from({ length: height }, (_, index) => lines[index] ?? "");
|
|
180
184
|
}
|
|
@@ -183,16 +187,17 @@ export class TasteProfileView {
|
|
|
183
187
|
if (height <= 0) return [];
|
|
184
188
|
const subject = this.getSelectedItem();
|
|
185
189
|
if (!subject) return Array(height).fill("");
|
|
186
|
-
const
|
|
190
|
+
const tr = this.getText();
|
|
191
|
+
const heading = theme.bold(inline(subject.label) || tr("Selected"));
|
|
187
192
|
const subtitle = inline(subject.subtitle);
|
|
188
|
-
const stance = stanceLabel(subject);
|
|
193
|
+
const stance = stanceLabel(subject, tr);
|
|
189
194
|
const prefix = [heading];
|
|
190
195
|
if (subtitle && height >= 4) prefix.push(theme.muted(subtitle));
|
|
191
196
|
if (stance && height >= 6) prefix.push(theme.accent(stance));
|
|
192
197
|
if (height >= 8) prefix.push("");
|
|
193
198
|
const sourceLines = Array.isArray(subject.detailLines) && subject.detailLines.length
|
|
194
199
|
? subject.detailLines
|
|
195
|
-
: ["Nothing more to show for this one yet."];
|
|
200
|
+
: [tr("Nothing more to show for this one yet.")];
|
|
196
201
|
const detail = sourceLines.flatMap((line) => wrapTextWithAnsi(clean(line), Math.max(1, width)));
|
|
197
202
|
const available = Math.max(0, height - prefix.length);
|
|
198
203
|
const scrolls = detail.length > available;
|
|
@@ -204,7 +209,7 @@ export class TasteProfileView {
|
|
|
204
209
|
const lines = [...prefix, ...visible];
|
|
205
210
|
if (scrolls && available >= 2) {
|
|
206
211
|
const range = `${this.detailOffset + 1}–${Math.min(detail.length, this.detailOffset + contentHeight)}/${detail.length}`;
|
|
207
|
-
lines.push(theme.faint(ends("PgUp/PgDn to scroll", range, width)));
|
|
212
|
+
lines.push(theme.faint(ends(tr("PgUp/PgDn to scroll"), range, width)));
|
|
208
213
|
}
|
|
209
214
|
return Array.from({ length: height }, (_, index) => lines[index] ?? "");
|
|
210
215
|
}
|
|
@@ -213,11 +218,12 @@ export class TasteProfileView {
|
|
|
213
218
|
this.detailOffset = 0;
|
|
214
219
|
this.detailMaxOffset = 0;
|
|
215
220
|
const filtered = this.entries.length > 0;
|
|
221
|
+
const tr = this.getText();
|
|
216
222
|
const lines = filtered
|
|
217
|
-
? ["Nothing matches that.", "", "Try fewer words, or press Tab for another list."]
|
|
223
|
+
? [tr("Nothing matches that."), "", tr("Try fewer words, or press Tab for another list.")]
|
|
218
224
|
: (Array.isArray(this.model.emptyLines) && this.model.emptyLines.length
|
|
219
225
|
? this.model.emptyLines
|
|
220
|
-
: ["Nothing to look at yet.", "", "Import your history or library and it will show up here."]);
|
|
226
|
+
: [tr("Nothing to look at yet."), "", tr("Import your history or library and it will show up here.")]);
|
|
221
227
|
const wrapped = lines.flatMap((line) => wrapTextWithAnsi(clean(line), width));
|
|
222
228
|
return Array.from({ length: height }, (_, index) => index === 0
|
|
223
229
|
? theme.bold(wrapped[index] ?? "") : theme.muted(wrapped[index] ?? ""));
|
|
@@ -233,18 +239,19 @@ export class TasteProfileView {
|
|
|
233
239
|
const inner = Math.max(1, columns - inset * 2);
|
|
234
240
|
const position = this.filtered.findIndex((subject) => subject.key === this.selectedKey) + 1;
|
|
235
241
|
const count = `${Math.max(0, position)}/${this.filtered.length}`;
|
|
236
|
-
const
|
|
242
|
+
const tr = this.getText();
|
|
243
|
+
const heading = theme.bold(inner >= 24 + count.length ? tr("Your listening profile") : tr("Your profile"));
|
|
237
244
|
const lines = [ends(heading, theme.muted(count), inner)];
|
|
238
245
|
if (height >= 10) {
|
|
239
246
|
const summaries = Array.isArray(this.model.summaryLines) ? this.model.summaryLines : [];
|
|
240
|
-
const maxSummary = height >= 22 ?
|
|
247
|
+
const maxSummary = height >= 22 ? 3 : 1;
|
|
241
248
|
lines.push(...summaries.slice(0, maxSummary).map((line) => theme.muted(inline(line))));
|
|
242
249
|
}
|
|
243
250
|
if (height >= 5) lines.push(inner >= 76
|
|
244
|
-
? ends(this.renderCategories(inner, theme), theme.faint("Ctrl+R refresh · Ctrl+O report"), inner)
|
|
251
|
+
? ends(this.renderCategories(inner, theme), theme.faint(tr("Ctrl+R refresh · Ctrl+O report")), inner)
|
|
245
252
|
: this.renderCategories(inner, theme));
|
|
246
253
|
if (height >= 2) {
|
|
247
|
-
const searchLabel = inner >= 26 ? theme.faint("Search
|
|
254
|
+
const searchLabel = inner >= 26 ? theme.faint(`${tr("Search")} `) : "";
|
|
248
255
|
lines.push(searchLabel + this.input.render(Math.max(1, inner - visibleWidth(searchLabel)))[0]);
|
|
249
256
|
}
|
|
250
257
|
if (height >= 7) lines.push(theme.faint("─".repeat(inner)));
|