diff --git a/backend/app.go b/backend/app.go
index f1c00db..172a03c 100644
--- a/backend/app.go
+++ b/backend/app.go
@@ -18,6 +18,7 @@ import (
"yellowjacket/backend/frontendutil"
"yellowjacket/backend/library"
"yellowjacket/backend/player"
+ "yellowjacket/backend/playlist"
"yellowjacket/backend/queue"
)
@@ -31,6 +32,7 @@ type YellowJacketApp struct {
database *database.DB
library *library.Library
player *player.Player
+ playlist *playlist.Service
queue *queue.Queue
appContext context.Context
appConfig *config.Config
@@ -93,9 +95,13 @@ func NewYellowJacketApp(
yjApp.assetHandler.RegisterHandler("/covers/", coverHandler)
+ // create playlist service
+ yjApp.playlist = playlist.NewService(yjApp.logger, yjApp.database)
+
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
yjApp.library,
+ yjApp.playlist,
}
return yjApp, nil
@@ -113,6 +119,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.appConfig.SetContext(ctx)
yj.FrontendUtil.SetContext(ctx)
yj.library.SetContext(ctx)
+ yj.playlist.SetContext(ctx)
var err error
// create player
diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql
index 36156cc..599d588 100644
--- a/backend/database/sql/queries/playlists.sql
+++ b/backend/database/sql/queries/playlists.sql
@@ -30,3 +30,7 @@ DELETE FROM playlist_tracks WHERE id = ?;
-- name: ClearPlaylistTracks :exec
DELETE FROM playlist_tracks WHERE playlist_id = ?;
+
+-- name: GetNextPlaylistTrackPosition :one
+SELECT COALESCE(MAX(position), -1) + 1 AS next_position
+FROM playlist_tracks WHERE playlist_id = ?;
diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go
index 45f5d34..fe4e944 100644
--- a/backend/database/sql/sqlcgen/playlists.sql.go
+++ b/backend/database/sql/sqlcgen/playlists.sql.go
@@ -99,6 +99,18 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
return items, nil
}
+const getNextPlaylistTrackPosition = `-- name: GetNextPlaylistTrackPosition :one
+SELECT COALESCE(MAX(position), -1) + 1 AS next_position
+FROM playlist_tracks WHERE playlist_id = ?
+`
+
+func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID int64) (int64, error) {
+ row := q.db.QueryRowContext(ctx, getNextPlaylistTrackPosition, playlistID)
+ var next_position int64
+ err := row.Scan(&next_position)
+ return next_position, err
+}
+
const getPlaylist = `-- name: GetPlaylist :one
SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
`
diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go
new file mode 100644
index 0000000..6bb9136
--- /dev/null
+++ b/backend/playlist/playlist.go
@@ -0,0 +1,190 @@
+// Package playlist provides playlist management functionality.
+package playlist
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "yellowjacket/backend/database"
+ "yellowjacket/backend/database/sql/sqlcgen"
+)
+
+var (
+ errEmptyName = errors.New("playlist name cannot be empty")
+ errEmptyFilePath = errors.New("file path cannot be empty")
+ errNoFilePaths = errors.New("no file paths provided")
+)
+
+// Summary is a lightweight representation of a playlist for the picker UI.
+type Summary struct {
+ ID int64 `json:"ID"`
+ Name string `json:"Name"`
+}
+
+// Service manages playlist operations.
+type Service struct {
+ ctx context.Context
+ logger *slog.Logger
+ db *database.DB
+}
+
+// NewService creates a new playlist service.
+func NewService(
+ logger *slog.Logger,
+ db *database.DB,
+) *Service {
+ return &Service{
+ logger: logger.WithGroup("playlist"),
+ db: db,
+ }
+}
+
+// SetContext sets the Wails runtime context.
+func (s *Service) SetContext(ctx context.Context) {
+ s.ctx = ctx
+}
+
+// GetAllPlaylists returns all playlists ordered by most recently updated.
+func (s *Service) GetAllPlaylists() ([]Summary, error) {
+ playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx)
+ if err != nil {
+ s.logger.Error("Failed to get playlists", "err", err)
+
+ return nil, fmt.Errorf("failed to get playlists: %w", err)
+ }
+
+ summaries := make([]Summary, 0, len(playlists))
+ for _, p := range playlists {
+ summaries = append(summaries, Summary{
+ ID: p.ID,
+ Name: p.Name,
+ })
+ }
+
+ return summaries, nil
+}
+
+// CreatePlaylist creates a new empty playlist with the given name.
+func (s *Service) CreatePlaylist(name string) (Summary, error) {
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ return Summary{}, errEmptyName
+ }
+
+ created, err := s.db.Queries.CreatePlaylist(s.db.Ctx, trimmed)
+ if err != nil {
+ s.logger.Error("Failed to create playlist", "name", trimmed, "err", err)
+
+ return Summary{}, fmt.Errorf("failed to create playlist: %w", err)
+ }
+
+ s.logger.Info("Playlist created", "id", created.ID, "name", created.Name)
+
+ return Summary{ID: created.ID, Name: created.Name}, nil
+}
+
+// AddTracksToPlaylist adds one or more tracks to an existing playlist.
+func (s *Service) AddTracksToPlaylist(
+ playlistID int64,
+ filePaths []string,
+) error {
+ if len(filePaths) == 0 {
+ return errNoFilePaths
+ }
+
+ nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
+ s.db.Ctx,
+ playlistID,
+ )
+ if err != nil {
+ s.logger.Error(
+ "Failed to get next position",
+ "playlistId", playlistID,
+ "err", err,
+ )
+
+ return fmt.Errorf("failed to get next track position: %w", err)
+ }
+
+ for i, fp := range filePaths {
+ if err := s.addSingleTrack(playlistID, fp, nextPos+int64(i)); err != nil {
+ return err
+ }
+ }
+
+ s.logger.Info(
+ "Tracks added to playlist",
+ "playlistId", playlistID,
+ "count", len(filePaths),
+ )
+
+ return nil
+}
+
+// CreatePlaylistWithTracks creates a new playlist and populates it with tracks.
+func (s *Service) CreatePlaylistWithTracks(
+ name string,
+ filePaths []string,
+) (Summary, error) {
+ summary, err := s.CreatePlaylist(name)
+ if err != nil {
+ return Summary{}, err
+ }
+
+ if len(filePaths) > 0 {
+ if err := s.AddTracksToPlaylist(summary.ID, filePaths); err != nil {
+ return Summary{}, fmt.Errorf(
+ "playlist created but failed to add tracks: %w",
+ err,
+ )
+ }
+ }
+
+ return summary, nil
+}
+
+// addSingleTrack looks up the audio file by path and inserts it into the playlist.
+func (s *Service) addSingleTrack(
+ playlistID int64,
+ filePath string,
+ position int64,
+) error {
+ if strings.TrimSpace(filePath) == "" {
+ return errEmptyFilePath
+ }
+
+ audioFile, err := s.db.Queries.GetAudioFileByPath(s.db.Ctx, filePath)
+ if err != nil {
+ s.logger.Error(
+ "Failed to find audio file",
+ "filePath", filePath,
+ "err", err,
+ )
+
+ return fmt.Errorf("failed to find audio file %q: %w", filePath, err)
+ }
+
+ _, err = s.db.Queries.AddPlaylistTrack(
+ s.db.Ctx,
+ sqlcgen.AddPlaylistTrackParams{
+ PlaylistID: playlistID,
+ AudioFileID: audioFile.ID,
+ Position: position,
+ },
+ )
+ if err != nil {
+ s.logger.Error(
+ "Failed to add track to playlist",
+ "playlistId", playlistID,
+ "audioFileId", audioFile.ID,
+ "err", err,
+ )
+
+ return fmt.Errorf("failed to add track to playlist: %w", err)
+ }
+
+ return nil
+}
diff --git a/frontend/index.ts b/frontend/index.ts
index ee38a47..0f1d5de 100644
--- a/frontend/index.ts
+++ b/frontend/index.ts
@@ -4,6 +4,7 @@ import '@components/cover-grid/cover-grid.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/queue-panel/queue-panel.ts';
+import '@components/playlist-view/playlist-view.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
@@ -24,6 +25,9 @@ document.addEventListener('navigate', (e: Event) => {
case 'tracks':
mainContent.innerHTML = '';
break;
+ case 'playlists':
+ mainContent.innerHTML = '';
+ break;
default:
mainContent.innerHTML = `
Coming soon: ${view}
@@ -46,8 +50,4 @@ if (queueButton && queuePanel) {
}
});
- // Close panel when the component dispatches a close event
- queuePanel.addEventListener('queue-panel-close', () => {
- queuePanel.removeAttribute('open');
- });
}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index b7e89aa..5746819 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-74e25cdcdccb20fc50b40dc29ec5f6f9
\ No newline at end of file
+cf76bbfd46ad4447fbfbaa4a1c6845ca
\ No newline at end of file
diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts
index e45fb14..4b0b3ab 100644
--- a/frontend/src/components/cover-grid/cover-grid.ts
+++ b/frontend/src/components/cover-grid/cover-grid.ts
@@ -9,6 +9,8 @@ import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
+import '@components/playlist-picker/playlist-picker.js';
+import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
@customElement('cover-grid')
export class CoverGrid extends LitElement {
@@ -143,6 +145,20 @@ export class CoverGrid extends LitElement {
.context-menu-panel wa-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
+
+ .submenu-item {
+ position: relative;
+ }
+
+ .submenu-arrow {
+ font-size: 10px;
+ margin-left: auto;
+ padding-left: 12px;
+ }
+
+ #playlist-submenu {
+ z-index: 210;
+ }
`;
@state()
@@ -157,9 +173,18 @@ export class CoverGrid extends LitElement {
@state()
private contextMenuAlbum: library.Album | null = null;
+ @state()
+ private playlistSubmenuOpen = false;
+
+ @state()
+ private playlistFilePaths: string[] = [];
+
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
+ @query('#playlist-submenu')
+ private playlistSubmenuPopup!: HTMLElement;
+
override connectedCallback() {
super.connectedCallback();
this.loadAlbums();
@@ -253,8 +278,10 @@ export class CoverGrid extends LitElement {
private closeContextMenu() {
if (!this.contextMenuOpen) return;
+ this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.contextMenuAlbum = null;
+ this.playlistFilePaths = [];
const popup = this.contextMenuPopup;
@@ -263,6 +290,52 @@ export class CoverGrid extends LitElement {
}
}
+ private async showPlaylistSubmenu() {
+ if (this.playlistSubmenuOpen) return;
+
+ if (this.contextMenuAlbum) {
+ this.playlistFilePaths = await this.getAlbumFilePaths(
+ this.contextMenuAlbum,
+ );
+ }
+
+ this.playlistSubmenuOpen = true;
+
+ await this.updateComplete;
+
+ const submenu = this.playlistSubmenuPopup;
+ const trigger = this.shadowRoot?.querySelector(
+ '.submenu-item',
+ );
+
+ if (submenu && trigger) {
+ (submenu as any).anchor = trigger;
+ (submenu as any).active = true;
+ }
+
+ const picker = this.shadowRoot?.querySelector(
+ 'playlist-picker',
+ ) as PlaylistPicker | null;
+
+ picker?.reset();
+ }
+
+ private closePlaylistSubmenu() {
+ if (!this.playlistSubmenuOpen) return;
+
+ this.playlistSubmenuOpen = false;
+
+ const submenu = this.playlistSubmenuPopup;
+
+ if (submenu) {
+ (submenu as any).active = false;
+ }
+ }
+
+ private onPlaylistActionComplete = () => {
+ this.closeContextMenu();
+ };
+
private renderAlbumCard = (album: library.Album): unknown => {
return html`
Play Next
+
`
: nothing}
+
+
`;
}
}
diff --git a/frontend/src/components/playlist-picker/playlist-picker.ts b/frontend/src/components/playlist-picker/playlist-picker.ts
new file mode 100644
index 0000000..fde4416
--- /dev/null
+++ b/frontend/src/components/playlist-picker/playlist-picker.ts
@@ -0,0 +1,299 @@
+import { LitElement, html, css, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+
+import '@awesome.me/webawesome/dist/components/icon/icon.js';
+import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
+
+import {
+ GetAllPlaylists,
+ AddTracksToPlaylist,
+ CreatePlaylistWithTracks,
+} from '@go/playlist/Service';
+import type { playlist } from '@go/models';
+
+/**
+ * A reusable playlist picker that displays existing playlists
+ * and allows creating new ones. Accepts file paths and handles
+ * adding tracks to the selected/created playlist.
+ *
+ * @fires playlist-action-complete - When tracks have been added successfully.
+ */
+@customElement('playlist-picker')
+export class PlaylistPicker extends LitElement {
+ /** File paths to add when a playlist is selected or created. */
+ @property({ type: Array }) filePaths: string[] = [];
+
+ @state() private mode: 'list' | 'create' = 'list';
+ @state() private playlists: playlist.Summary[] = [];
+ @state() private newPlaylistName = '';
+ @state() private loading = false;
+
+ static override styles = css`
+ :host {
+ display: block;
+ }
+
+ .picker-panel {
+ background-color: #343a40;
+ border: 1px solid #444;
+ border-radius: 6px;
+ padding: 4px 0;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ min-width: 180px;
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
+ .picker-panel wa-dropdown-item {
+ cursor: pointer;
+ --wa-color-text-normal: #fff;
+ font-size: 13px;
+ }
+
+ .picker-panel wa-dropdown-item:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+
+ .separator {
+ height: 1px;
+ background: #555;
+ margin: 4px 0;
+ }
+
+ .create-form {
+ padding: 8px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .create-form input {
+ background: #2a2d30;
+ border: 1px solid #555;
+ border-radius: 4px;
+ color: #fff;
+ padding: 6px 8px;
+ font-size: 13px;
+ outline: none;
+ font-family: inherit;
+ }
+
+ .create-form input:focus {
+ border-color: #ffd43b;
+ }
+
+ .create-form input::placeholder {
+ color: #888;
+ }
+
+ .button-row {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+ }
+
+ .button-row button {
+ background: #495057;
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ padding: 4px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ font-family: inherit;
+ }
+
+ .button-row button:hover {
+ background: #5a6268;
+ }
+
+ .button-row button.primary {
+ background: #ffd43b;
+ color: #000;
+ }
+
+ .button-row button.primary:hover {
+ background: #ffe066;
+ }
+
+ .button-row button.primary:disabled {
+ background: #665a1e;
+ color: #888;
+ cursor: not-allowed;
+ }
+
+ .empty-message {
+ padding: 8px 12px;
+ color: #888;
+ font-size: 13px;
+ }
+ `;
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.loadPlaylists();
+ }
+
+ private async loadPlaylists() {
+ try {
+ this.playlists = await GetAllPlaylists();
+ } catch (err) {
+ console.error('Failed to load playlists:', err);
+ this.playlists = [];
+ }
+ }
+
+ private handleSelectPlaylist = async (playlistId: number) => {
+ if (this.loading || this.filePaths.length === 0) return;
+
+ this.loading = true;
+
+ try {
+ await AddTracksToPlaylist(playlistId, this.filePaths);
+ this.dispatchComplete();
+ } catch (err) {
+ console.error('Failed to add tracks to playlist:', err);
+ } finally {
+ this.loading = false;
+ }
+ };
+
+ private handleShowCreate = () => {
+ this.mode = 'create';
+ this.newPlaylistName = '';
+
+ void this.updateComplete.then(() => {
+ const input =
+ this.shadowRoot?.querySelector
(
+ '.create-form input',
+ );
+
+ input?.focus();
+ });
+ };
+
+ private handleCancelCreate = () => {
+ this.mode = 'list';
+ this.newPlaylistName = '';
+ };
+
+ private handleCreatePlaylist = async () => {
+ const name = this.newPlaylistName.trim();
+ if (!name || this.loading) return;
+
+ this.loading = true;
+
+ try {
+ await CreatePlaylistWithTracks(name, this.filePaths);
+ this.dispatchComplete();
+ } catch (err) {
+ console.error('Failed to create playlist:', err);
+ } finally {
+ this.loading = false;
+ }
+ };
+
+ private handleInputChange = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ this.newPlaylistName = input.value;
+ };
+
+ private handleInputKeydown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ void this.handleCreatePlaylist();
+ } else if (e.key === 'Escape') {
+ this.handleCancelCreate();
+ }
+
+ // Stop propagation so parent context menu handlers don't interfere.
+ e.stopPropagation();
+ };
+
+ private dispatchComplete() {
+ this.dispatchEvent(
+ new CustomEvent('playlist-action-complete', {
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ /** Resets the picker to its initial list state. */
+ reset() {
+ this.mode = 'list';
+ this.newPlaylistName = '';
+ this.loading = false;
+ this.loadPlaylists();
+ }
+
+ override render() {
+ if (this.mode === 'create') {
+ return this.renderCreateForm();
+ }
+
+ return this.renderPlaylistList();
+ }
+
+ private renderPlaylistList() {
+ return html`
+
+ ${this.playlists.length > 0
+ ? html`
+ ${this.playlists.map(
+ (p) => html`
+
+ this.handleSelectPlaylist(p.ID)}
+ >
+ ${p.Name}
+
+ `,
+ )}
+
+ `
+ : nothing}
+
+
+ New Playlist
+
+
+ `;
+ }
+
+ private renderCreateForm() {
+ const canCreate = this.newPlaylistName.trim().length > 0;
+
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'playlist-picker': PlaylistPicker;
+ }
+}
diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts
new file mode 100644
index 0000000..fd1042d
--- /dev/null
+++ b/frontend/src/components/playlist-view/playlist-view.ts
@@ -0,0 +1,325 @@
+import { LitElement, html, css } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+
+import '@awesome.me/webawesome/dist/components/icon/icon.js';
+
+import {
+ GetAllPlaylists,
+ CreatePlaylist,
+} from '@go/playlist/Service';
+import type { playlist } from '@go/models';
+
+@customElement('playlist-view')
+export class PlaylistView extends LitElement {
+ @state() private playlists: playlist.Summary[] = [];
+ @state() private loading = true;
+ @state() private creating = false;
+ @state() private newPlaylistName = '';
+
+ static override styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px;
+ flex-shrink: 0;
+ border-bottom: 1px solid #333;
+ }
+
+ .header h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #fff;
+ }
+
+ .new-playlist-button {
+ background: none;
+ border: 1px solid #555;
+ border-radius: 4px;
+ color: #fff;
+ padding: 6px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: inherit;
+ }
+
+ .new-playlist-button:hover {
+ border-color: #ffd43b;
+ color: #ffd43b;
+ }
+
+ .create-form {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-bottom: 1px solid #333;
+ flex-shrink: 0;
+ }
+
+ .create-form input {
+ flex: 1;
+ background: #2a2d30;
+ border: 1px solid #555;
+ border-radius: 4px;
+ color: #fff;
+ padding: 6px 10px;
+ font-size: 13px;
+ outline: none;
+ font-family: inherit;
+ }
+
+ .create-form input:focus {
+ border-color: #ffd43b;
+ }
+
+ .create-form input::placeholder {
+ color: #888;
+ }
+
+ .create-form button {
+ background: #495057;
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ padding: 6px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ font-family: inherit;
+ }
+
+ .create-form button:hover {
+ background: #5a6268;
+ }
+
+ .create-form button.primary {
+ background: #ffd43b;
+ color: #000;
+ }
+
+ .create-form button.primary:hover {
+ background: #ffe066;
+ }
+
+ .create-form button.primary:disabled {
+ background: #665a1e;
+ color: #888;
+ cursor: not-allowed;
+ }
+
+ .playlist-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+
+ .playlist-item {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ gap: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ }
+
+ .playlist-item:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ .playlist-icon {
+ font-size: 18px;
+ color: #888;
+ flex-shrink: 0;
+ }
+
+ .playlist-name {
+ font-size: 14px;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 32px;
+ color: #b3b3b3;
+ }
+
+ .empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 20px;
+ color: #b3b3b3;
+ text-align: center;
+ gap: 8px;
+ }
+
+ .empty-state wa-icon {
+ font-size: 32px;
+ }
+
+ .empty-state p {
+ margin: 4px 0;
+ }
+ `;
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.loadPlaylists();
+ }
+
+ private async loadPlaylists() {
+ try {
+ this.loading = true;
+ const result = await GetAllPlaylists();
+ this.playlists = result ?? [];
+ } catch (err) {
+ console.error('Failed to load playlists:', err);
+ this.playlists = [];
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private handleNewPlaylistClick = () => {
+ this.creating = true;
+ this.newPlaylistName = '';
+
+ void this.updateComplete.then(() => {
+ const input =
+ this.shadowRoot?.querySelector(
+ '.create-form input',
+ );
+
+ input?.focus();
+ });
+ };
+
+ private handleCancelCreate = () => {
+ this.creating = false;
+ this.newPlaylistName = '';
+ };
+
+ private handleCreatePlaylist = async () => {
+ const name = this.newPlaylistName.trim();
+ if (!name) return;
+
+ try {
+ await CreatePlaylist(name);
+ this.creating = false;
+ this.newPlaylistName = '';
+ await this.loadPlaylists();
+ } catch (err) {
+ console.error('Failed to create playlist:', err);
+ }
+ };
+
+ private handleInputChange = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ this.newPlaylistName = input.value;
+ };
+
+ private handleInputKeydown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ void this.handleCreatePlaylist();
+ } else if (e.key === 'Escape') {
+ this.handleCancelCreate();
+ }
+ };
+
+ override render() {
+ return html`
+
+
+ ${this.creating ? this.renderCreateForm() : ''}
+ ${this.loading
+ ? html`Loading playlists...
`
+ : this.renderPlaylistList()}
+ `;
+ }
+
+ private renderCreateForm() {
+ const canCreate = this.newPlaylistName.trim().length > 0;
+
+ return html`
+
+
+
+
+
+ `;
+ }
+
+ private renderPlaylistList() {
+ if (this.playlists.length === 0) {
+ return html`
+
+
+
No playlists yet
+
+ Create a playlist to get started.
+
+
+ `;
+ }
+
+ return html`
+
+ ${this.playlists.map(
+ (p) => html`
+ -
+
+ ${p.Name}
+
+ `,
+ )}
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'playlist-view': PlaylistView;
+ }
+}
diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts
index 0e000d7..43b4948 100644
--- a/frontend/src/components/queue-panel/queue-panel.ts
+++ b/frontend/src/components/queue-panel/queue-panel.ts
@@ -1,7 +1,10 @@
-import { LitElement, html, css, unsafeCSS } from 'lit';
-import { customElement, property, state } from 'lit/decorators.js';
+import { LitElement, html, css, nothing, unsafeCSS } from 'lit';
+import { customElement, property, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
+import '@awesome.me/webawesome/dist/components/popup/popup.js';
import { QueueController } from '@store/controllers/queue-controller';
+import '@components/playlist-picker/playlist-picker.js';
+import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
const MIN_WIDTH = 200;
const MAX_WIDTH = 500;
@@ -17,6 +20,22 @@ export class QueuePanel extends LitElement {
@state()
private isDragging = false;
+ @state()
+ private playlistPickerOpen = false;
+
+ @query('#save-playlist-popup')
+ private savePlaylistPopup!: HTMLElement;
+
+ private closePickerHandler = (e: MouseEvent) => {
+ const path = e.composedPath();
+ const popup = this.savePlaylistPopup;
+ const btn = this.shadowRoot?.querySelector('.save-playlist-button');
+
+ if (popup && !path.includes(popup) && (!btn || !path.includes(btn))) {
+ this.closePlaylistPicker();
+ }
+ };
+
private panelWidth = DEFAULT_WIDTH;
static override styles = css`
@@ -75,7 +94,7 @@ export class QueuePanel extends LitElement {
font-weight: 600;
}
- .close-button {
+ .save-playlist-button {
background: none;
border: none;
color: inherit;
@@ -85,10 +104,19 @@ export class QueuePanel extends LitElement {
align-items: center;
}
- .close-button:hover {
+ .save-playlist-button:hover {
color: #ffd43b;
}
+ .save-playlist-button:disabled {
+ color: #555;
+ cursor: not-allowed;
+ }
+
+ #save-playlist-popup {
+ z-index: 210;
+ }
+
.track-list {
flex: 1;
overflow-y: auto;
@@ -193,24 +221,56 @@ export class QueuePanel extends LitElement {
this.style.setProperty('--queue-width', `${this.panelWidth}px`);
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
+ document.addEventListener('click', this.closePickerHandler);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
+ document.removeEventListener('click', this.closePickerHandler);
}
- private handleClose() {
- this.open = false;
- this.dispatchEvent(
- new CustomEvent('queue-panel-close', {
- bubbles: true,
- composed: true,
- }),
- );
+ private async handleSaveAsPlaylist() {
+ if (this.queue.tracks.length === 0) return;
+
+ this.playlistPickerOpen = !this.playlistPickerOpen;
+
+ await this.updateComplete;
+
+ const popup = this.savePlaylistPopup;
+ const btn = this.shadowRoot?.querySelector('.save-playlist-button');
+
+ if (popup && btn) {
+ (popup as any).anchor = btn;
+ (popup as any).active = this.playlistPickerOpen;
+ }
+
+ if (this.playlistPickerOpen) {
+ const picker = this.shadowRoot?.querySelector(
+ 'playlist-picker',
+ ) as PlaylistPicker | null;
+
+ picker?.reset();
+ }
}
+ private closePlaylistPicker() {
+ if (!this.playlistPickerOpen) return;
+
+ this.playlistPickerOpen = false;
+
+ const popup = this.savePlaylistPopup;
+
+ if (popup) {
+ (popup as any).active = false;
+ }
+ }
+
+ private onPlaylistActionComplete = () => {
+ this.closePlaylistPicker();
+ };
+
private handleRemoveTrack(e: Event, position: number) {
e.stopPropagation();
this.queue.removeFromQueue(position);
@@ -275,11 +335,32 @@ export class QueuePanel extends LitElement {
>
+
+
${tracks.length === 0
? html`
diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts
index b2d0e47..1b7a52a 100644
--- a/frontend/src/components/track-list/track-list.ts
+++ b/frontend/src/components/track-list/track-list.ts
@@ -11,6 +11,8 @@ import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
+import '@components/playlist-picker/playlist-picker.js';
+import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
@customElement('track-list')
export class TrackList extends LitElement {
@@ -26,9 +28,15 @@ export class TrackList extends LitElement {
@state()
private contextMenuTrack: library.Track | null = null;
+ @state()
+ private playlistSubmenuOpen = false;
+
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
+ @query('#playlist-submenu')
+ private playlistSubmenuPopup!: HTMLElement;
+
private closeHandler = () => this.closeContextMenu();
static override styles = css`
@@ -128,6 +136,20 @@ export class TrackList extends LitElement {
.context-menu-panel wa-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
+
+ .submenu-item {
+ position: relative;
+ }
+
+ .submenu-arrow {
+ font-size: 10px;
+ margin-left: auto;
+ padding-left: 12px;
+ }
+
+ #playlist-submenu {
+ z-index: 210;
+ }
`;
override connectedCallback() {
@@ -214,6 +236,7 @@ export class TrackList extends LitElement {
private closeContextMenu() {
if (!this.contextMenuOpen) return;
+ this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.contextMenuTrack = null;
@@ -224,6 +247,44 @@ export class TrackList extends LitElement {
}
}
+ private async showPlaylistSubmenu() {
+ if (this.playlistSubmenuOpen) return;
+
+ this.playlistSubmenuOpen = true;
+
+ await this.updateComplete;
+
+ const submenu = this.playlistSubmenuPopup;
+ const trigger = this.shadowRoot?.querySelector('.submenu-item');
+
+ if (submenu && trigger) {
+ (submenu as any).anchor = trigger;
+ (submenu as any).active = true;
+ }
+
+ const picker = this.shadowRoot?.querySelector(
+ 'playlist-picker',
+ ) as PlaylistPicker | null;
+
+ picker?.reset();
+ }
+
+ private closePlaylistSubmenu() {
+ if (!this.playlistSubmenuOpen) return;
+
+ this.playlistSubmenuOpen = false;
+
+ const submenu = this.playlistSubmenuPopup;
+
+ if (submenu) {
+ (submenu as any).active = false;
+ }
+ }
+
+ private onPlaylistActionComplete = () => {
+ this.closeContextMenu();
+ };
+
private isActiveTrack(track: library.Track): boolean {
const currentTrack = this.player.currentTrack;
@@ -298,10 +359,38 @@ export class TrackList extends LitElement {
Play Next
+
`
: nothing}
+
+
`;
}
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 267b08b..bad5357 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -43,3 +43,22 @@ export namespace library {
}
+export namespace playlist {
+
+ export class Summary {
+ ID: number;
+ Name: string;
+
+ static createFrom(source: any = {}) {
+ return new Summary(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.ID = source["ID"];
+ this.Name = source["Name"];
+ }
+ }
+
+}
+
diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts
new file mode 100755
index 0000000..5a6a21d
--- /dev/null
+++ b/frontend/wailsjs/go/playlist/Service.d.ts
@@ -0,0 +1,14 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+import {playlist} from '../models';
+import {context} from '../models';
+
+export function AddTracksToPlaylist(arg1:number,arg2:Array):Promise;
+
+export function CreatePlaylist(arg1:string):Promise;
+
+export function CreatePlaylistWithTracks(arg1:string,arg2:Array):Promise;
+
+export function GetAllPlaylists():Promise>;
+
+export function SetContext(arg1:context.Context):Promise;
diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js
new file mode 100755
index 0000000..e260d5a
--- /dev/null
+++ b/frontend/wailsjs/go/playlist/Service.js
@@ -0,0 +1,23 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export function AddTracksToPlaylist(arg1, arg2) {
+ return window['go']['playlist']['Service']['AddTracksToPlaylist'](arg1, arg2);
+}
+
+export function CreatePlaylist(arg1) {
+ return window['go']['playlist']['Service']['CreatePlaylist'](arg1);
+}
+
+export function CreatePlaylistWithTracks(arg1, arg2) {
+ return window['go']['playlist']['Service']['CreatePlaylistWithTracks'](arg1, arg2);
+}
+
+export function GetAllPlaylists() {
+ return window['go']['playlist']['Service']['GetAllPlaylists']();
+}
+
+export function SetContext(arg1) {
+ return window['go']['playlist']['Service']['SetContext'](arg1);
+}
diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json
old mode 100644
new mode 100755
diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts
old mode 100644
new mode 100755
diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js
old mode 100644
new mode 100755
diff --git a/go.mod b/go.mod
index 50fa355..bff8311 100644
--- a/go.mod
+++ b/go.mod
@@ -85,6 +85,8 @@ require (
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
+ github.com/cloudflare/circl v1.3.7 // indirect
+ github.com/containerd/console v1.0.3 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/cubicdaiya/gonp v1.0.4 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
@@ -96,7 +98,9 @@ require (
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/oto/v3 v3.3.3 // indirect
- github.com/ebitengine/purego v0.8.4 // indirect
+ github.com/ebitengine/purego v0.9.1 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/ettle/strcase v0.2.0 // indirect
github.com/evilmartians/lefthook v1.13.6 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -341,7 +345,6 @@ require (
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/exp/typeparams v0.0.0-20251125195548-87e1e737ad39 // indirect
- golang.org/x/image v0.12.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
@@ -377,6 +380,7 @@ tool (
github.com/sqlc-dev/sqlc/cmd/sqlc
github.com/wailsapp/wails/v2/cmd/wails
golang.org/x/vuln/cmd/govulncheck
+ yellowjacket
)
// replace github.com/TheCodeOfCaleb/beep/v2 => /mnt/vault/dev/golang/beep/
diff --git a/go.sum b/go.sum
index 5661295..ad1b980 100644
--- a/go.sum
+++ b/go.sum
@@ -216,8 +216,14 @@ github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7Lsp
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
@@ -274,8 +280,27 @@ github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
+github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
+github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
+github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
+github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
+github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0=
+github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -516,6 +541,10 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jszwec/csvutil v1.5.1/go.mod h1:Rpu7Uu9giO9subDyMCIQfHVDuLrcaC36UA4YcJjGBkg=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
+github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/kaptinlin/go-i18n v0.2.2 h1:kebVCZme/BrCTqonh/J+VYCl1+Of5C18bvyn3DRPl5M=
github.com/kaptinlin/go-i18n v0.2.2/go.mod h1:MiwkeHryBopAhC/M3zEwIM/2IN8TvTqJQswPw6kceqM=
github.com/kaptinlin/jsonpointer v0.4.8 h1:HocHcXrOBfP/nUJw0YYjed/TlQvuCAY6uRs3Qok7F6g=
@@ -625,6 +654,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=
@@ -778,6 +809,14 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
+github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
+github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw=
+github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
+github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
+github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
@@ -971,8 +1010,21 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
@@ -985,6 +1037,12 @@ golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+o
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -1002,8 +1060,15 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
-golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -1037,8 +1102,21 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1048,6 +1126,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1105,15 +1185,24 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
+golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
-golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@@ -1121,9 +1210,13 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1174,8 +1267,16 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
-golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
+golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=
+golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=