15 커밋 7f18c6c57b ... d7ae8eac4b

작성자 SHA1 메시지 날짜
  Brandon Wong d7ae8eac4b old filtering script 1 개월 전
  Brandon Wong e5e1c81500 added readme with packaging instructions 1 개월 전
  Brandon Wong baaa4f01d8 adjusted claude's instructions to include LLM prompts 1 개월 전
  Brandon Wong 343b6893d9 adjusted placement and styling of buttons 1 개월 전
  Brandon Wong c3ee2cba59 instructed claude to write report files 1 개월 전
  Brandon Wong d6776e442e added claude "verify-csp" skill 1 개월 전
  Brandon Wong af31006d34 added claude file 1 개월 전
  Brandon Wong 71b280f380 given the format of the json file generated by the "export" function, give me a `jq` command to find all the playback history entries that have "tags". 4 달 전
  Brandon Wong 5cbb85e326 feat: add playlist and history export scripts 7 달 전
  Brandon Wong c504312d6e feat: add JSON merge script for playlists and history 7 달 전
  Brandon Wong a11f2914cd feat: add move to top/bottom options in video menu 7 달 전
  Brandon Wong 4b39ec2da7 feat: modify playlist import to append and filter 7 달 전
  Brandon Wong 9febb23e56 refactor: create separate import playlist screen 7 달 전
  Brandon Wong 1e52f50cc1 feat: replace file selector with textarea for playlist import 7 달 전
  Brandon Wong cbb48f6d26 feat: add wiki/insp button with wikitext formatting 1 년 전

+ 27 - 0
.claude/skills/verify-csp/SKILL.md

@@ -0,0 +1,27 @@
+---
+name: verify-csp
+description: Scan popup HTML and JS for Alpine.js CSP violations after editing popup code
+---
+
+Scan `popup/popup.html` and `popup/popup.js` for Alpine.js CSP compliance violations.
+
+The extension runs under a Content Security Policy that forbids JavaScript evaluation, so Alpine.js attributes must never contain inline expressions.
+
+**Check for these violation patterns in HTML attributes (`x-show`, `x-if`, `x-text`, `x-bind:*`, `:attr`, `@event`, etc.):**
+- Equality/comparison operators: `===`, `!==`, `==`, `!=`, `>`, `<`
+- Method or function calls: `foo()`, `this.bar()`
+- Ternary expressions: `condition ? a : b`
+- Logical operators used to compute values: `a && b`, `a || b`
+- String concatenation or arithmetic
+
+**What is allowed:**
+- Simple property access: `x-show="isOpen"`, `:class="activeClass"`
+- `$el`, `$refs`, `$store` access without calling methods
+- Negation of a boolean property: `x-show="!isOpen"` (acceptable)
+
+For each violation found, report:
+1. File and line number
+2. The offending attribute and its current value
+3. The compliant fix — typically moving the logic into `Alpine.data()` as a computed property or method, then binding the property name in the template
+
+If no violations are found, confirm the popup is CSP-compliant.

+ 33 - 0
2025-02-24-codemcp-jq-filter-tags.md

@@ -0,0 +1,33 @@
+# 2025-02-24 - jq filter tags
+
+## User Request
+given the format of the json file generated by the "export" function, give me a `jq` command to find all the playback history entries that have "tags".
+
+generate an additional command that will do the same thing (as the third example), but only filtering the entries that have the "plus one" tag
+
+## Export JSON Structure
+From `popup/popup.js` lines 768-774:
+```javascript
+const exportData = {
+  playlists,
+  playbackHistory,
+  openTabs,
+  exportDate: new Date().toISOString(),
+};
+```
+
+The `playbackHistory` object has:
+- Keys: video IDs
+- Values: objects containing `history` array, `tags` array, `title`, etc.
+
+## jq Commands Provided
+
+### All entries with any tags:
+```bash
+jq '.playbackHistory | to_entries | map(select(.value.tags | length > 0)) | map({videoId: .key, tags: .value.tags, title: .value.title})' export.json
+```
+
+### Only entries with "plus one" tag:
+```bash
+jq '.playbackHistory | to_entries | map(select(.value.tags | index("plus one"))) | map({videoId: .key, tags: .value.tags, title: .value.title})' export.json
+```

+ 138 - 0
2025-06-21-codemcp-feat-add-wiki-insp-button-with-wikitext-formatting.md

@@ -0,0 +1,138 @@
+# 2025-06-21 CodeMCP: Add Wiki/Insp Button with Wikitext Formatting
+
+## User Request
+The popup menu has a button to "save channel", which opens a simple dialogue view, and copies some text to the system clipboard. Add a new button called "wiki/insp", which opens a similar dialogue view. This time, it copies some wikitext-formatted text to the clipboard, in this format:
+
+```
+!!! <today's date>
+* [[<page title>|<page url]] (yt)
+** <timestamp of video>
+```
+
+where:
+- "<today's date>" is formatted as "YYYY-MM-DD"
+- "<page title>" has all pipes ("|") replaced with hyphens ("-")
+- the final line (with "<timestamp of video>") is only added by default if the video has not ended
+
+## Follow-up Requests
+1. Make the textarea fill the full width of the view, and put the copy button below it.
+2. Add a checkbox control between the textarea and the copy button to toggle the presence of the timestamp line.
+3. The checked version should include the current timestamp of the video, not 0:00 (unless the video is at the beginning).
+4. Add the appropriate styling to `popup/popup.css`.
+5. Let the timestamp account for hours (as in "1:36:11" rather than "96:11"), but only if necessary (ie: the video is longer than an hour).
+
+## Implementation
+
+### Files Modified
+1. **popup/popup.js**:
+   - Added new properties for wiki/insp functionality:
+     - `wikitextCommand`: Generated wikitext
+     - `isCurrentVideoYoutube`: Whether current video is YouTube
+     - `hasVideoEnded`: Whether video has ended
+     - `currentVideoTimestamp`: Current video timestamp (real time from video)
+     - `includeTimestamp`: Whether to include timestamp line (default: true)
+   - Added `showWikiInsp()` method to switch to wiki/insp view with timestamp refresh
+   - Added `updateWikitextCommand()` method to generate the wikitext format
+   - Added `copyWikitextCommand()` method to copy text to clipboard
+   - Added `toggleTimestamp()` method to toggle timestamp inclusion
+   - Added `getVideoTimestamp()` method to get real video position via script execution
+   - Added `refreshVideoTimestamp()` method to manually update timestamp
+   - Updated `getCurrentTab()` to detect YouTube video pages and get initial timestamp
+   - **Enhanced timestamp formatting**: Now handles hours when video duration > 1 hour
+   - Added event handlers and CSP-compliant bindings:
+     - `wikiInspButton`: Click handler for wiki/insp button
+     - `wikiInspHeader`: Show/hide wiki/insp header
+     - `wikiInspContainer`: Show/hide wiki/insp container
+     - `copyWikitextButton`: Copy button with disabled state
+     - `timestampCheckbox`: Checkbox for toggling timestamp inclusion
+     - `refreshTimestampButton`: Button to refresh video timestamp
+     - `wikiInspNotice`: Show notice when not on YouTube video
+
+2. **popup/popup.html**:
+   - Added new header for wiki/insp view with back button
+   - Added wiki/insp container with:
+     - Full-width readonly textarea showing generated wikitext
+     - Controls section with checkbox and refresh button side by side
+     - Copy button positioned below the controls
+     - Notice text for non-YouTube pages
+   - Added "Wiki/Insp" button to the export container
+
+3. **popup/popup.css**:
+   - **Wiki/Insp Container**: White background, rounded corners, shadow, consistent with other views
+   - **Textarea**: Full-width, monospace font, light gray background, minimum 120px height
+   - **Timestamp Controls**: Flexbox layout with space-between for checkbox on left, refresh button on right
+   - **Checkbox**: Custom styling with blue accent color, proper cursor and user-select behavior
+   - **Refresh Button**: Green background with hover effects, subtle animation on click
+   - **Copy Button**: Full-width blue button with hover animations and elevation effects
+   - **Notice**: Yellow warning background for non-YouTube pages
+   - **Wiki/Insp Button**: Purple background to distinguish from other action buttons
+   - **Disabled States**: Proper styling for disabled buttons with gray colors and no interactions
+
+### Features
+- **Smart Timestamp Formatting**:
+  - **Short videos** (< 1 hour): `MM:SS` format (e.g., "12:34")
+  - **Long videos** (≥ 1 hour): `H:MM:SS` format (e.g., "1:36:11")
+  - **Detection**: Automatically determines format based on video duration
+- **Real Video Timestamps**: Gets actual current playback position from YouTube video element
+- **Wikitext Format**: Generates properly formatted wikitext with today's date in YYYY-MM-DD format
+- **Title Processing**: Replaces pipe characters ("|") with hyphens ("-") to avoid wiki formatting conflicts
+- **Timestamp Toggle**: User can control whether to include the timestamp line via checkbox (checked by default)
+- **Manual Refresh**: Refresh button (🔄) to update timestamp without reopening the view
+- **Video End Detection**: Detects when video has ended to conditionally include timestamp
+- **YouTube Detection**: Only works on YouTube video pages (/watch)
+- **CSP Compliance**: All Alpine.js code follows CSP requirements with no JavaScript expressions in HTML attributes
+- **Clipboard Integration**: Copies generated wikitext to system clipboard with fallback support
+- **Script Execution**: Uses browser.tabs.executeScript to get real video data from YouTube page
+- **Professional Styling**: Consistent visual design with hover effects, transitions, and proper spacing
+
+### Timestamp Format Logic
+```javascript
+const formatTime = (seconds) => {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  const remainingSeconds = Math.floor(seconds % 60);
+
+  if (hours > 0) {
+    return hours + ':' + minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0');
+  } else {
+    return minutes + ':' + remainingSeconds.toString().padStart(2, '0');
+  }
+};
+```
+
+### Example Outputs
+**Short video** (42 minutes):
+```
+!!! 2025-06-21
+* [[Some YouTube Video Title|https://www.youtube.com/watch?v=abc123]] (yt)
+** 12:34
+```
+
+**Long video** (2 hours):
+```
+!!! 2025-06-21
+* [[Long YouTube Video Title|https://www.youtube.com/watch?v=def456]] (yt)
+** 1:36:11
+```
+
+### Technical Implementation
+- **Script Execution**: Injects code into YouTube page to access video element
+- **Smart Time Formatting**: Conditionally includes hours based on video duration
+- **Real-time Data**: Gets current playback position, video duration, and ended status
+- **Error Handling**: Graceful fallback to "0:00" if video data unavailable
+- **Permissions**: Uses existing `activeTab`, `tabs`, and `<all_urls>` permissions
+
+### Current Features
+- ✅ Smart hour-aware timestamp formatting implemented
+- ✅ Real video timestamp detection implemented
+- ✅ Video ended status detection implemented
+- ✅ Manual timestamp refresh functionality
+- ✅ Full user control over timestamp inclusion
+- ✅ Professional styling and visual design
+- ✅ Consistent with existing UI patterns
+
+### Future Enhancements
+- Add visual feedback when text is copied to clipboard
+- Persist checkbox state across sessions
+- Add keyboard shortcuts for common actions
+- Show video duration alongside current timestamp

+ 63 - 0
2025-11-03-codemcp-import-playlist-view.md

@@ -0,0 +1,63 @@
+# Import Playlist View Refactor - 2025-11-03
+
+## User Request
+make the "import playlist" view a separate screen, just like the "history" view or the "save channel" view. this will require updating `popup.js` to hook up the html components with the code functionality (and maybe the corresponding styles in `popup.css`). remove the existing file selector input, and have the current "Import Playlists" button navigate to the screen.
+
+## Changes Made
+
+### 1. HTML Changes (popup.html)
+- **Removed**: Hidden file input element (`<input type="file" id="import-file-input" ...>`)
+- **Kept**: Import view container structure with textarea and submit button (already existed in HTML)
+- **Kept**: Import header with back button (already existed in HTML)
+
+### 2. JavaScript Changes (popup.js)
+
+#### Added State Properties
+```javascript
+// Import properties
+importText: "", // Text area content for import
+```
+
+#### Added Methods
+- `showImport()`: Navigates to the import view by setting `currentView` to "import"
+- `handleImport()`: Processes the JSON text from textarea, validates and imports playlists
+
+#### Modified Methods
+- `importButton` binding: Changed from triggering file input click to calling `showImport()`
+- Removed `importFileInput` binding (no longer needed)
+- Removed `importFile(event)` method (replaced by `handleImport()`)
+
+#### Added Bindings
+- `importHeader`: Controls visibility of import view header (shows only when `currentView === "import"`)
+- `importContainer`: Controls visibility of import view container
+- `importTextarea`: Two-way binding for textarea value
+- `importSubmitButton`: Click handler for import button
+
+### 3. CSS Changes (popup.css)
+
+#### Added Styles
+- `.import-container`: Container for import view (matches other view containers)
+- `.import-content`: Content wrapper with flex layout
+- `.import-section`: Section wrapper for textarea and button
+- `.import-textarea`: Large monospace textarea with focus styles
+- `.import-submit-btn`: Green submit button with hover/active states
+- `.import-btn`: Green button style for main navigation button
+- `.export-btn`: Blue button style (added for consistency)
+
+## Architecture Pattern
+The import view now follows the same pattern as other views (history, saveChannel, wikiInsp):
+1. Header with back button (controlled by `importHeader` binding)
+2. Container div with x-bind for visibility control
+3. Navigation button on main screen
+4. View-specific Alpine.js bindings for interactivity
+5. Dedicated CSS styles for the view
+
+## CSP Compliance
+All Alpine.js code remains CSP-compliant:
+- No JavaScript expressions in HTML attributes
+- All bindings use Alpine.data properties
+- Pre-computed display data in Alpine state
+- Event handlers defined in binding objects
+
+## Bug Fix
+- Added missing `importHeader` binding to properly hide the import view header when not in that view

+ 52 - 0
2025-11-07-codemcp-add-move-to-top-bottom.md

@@ -0,0 +1,52 @@
+# Add Move to Top/Bottom Options in Video Menu
+
+**Date**: 2025-11-07
+**Feature**: Add "Move to Top" and "Move to Bottom" options to the three-dot menu for videos
+
+## User Request
+
+In the main view of the popup menu, in the three-dot menu next to each video, add an option to "move to top", which will move that video in its playlist to the "top", which (as a reminder) is defined as "after the last video marked as 'done' continuously from the beginning of the playlist". Also add an option to "move to bottom", which will move the video to the end of its playlist. Obviously, disable these options if the video in question is already at the top or the bottom respectively.
+
+## Implementation
+
+### Changes Made
+
+1. **popup/popup.js** - Added three new methods:
+   - `moveVideoToTop(playlistName, index)`: Moves a video to the top position (after the last continuously done videos)
+   - `moveVideoToBottom(playlistName, index)`: Moves a video to the bottom of the playlist
+   - `findTopPosition(playlistName)`: Helper method that finds the "top" position by finding the first non-done video
+
+2. **popup/popup.js** - Added two new button bindings:
+   - `moveToTopButton`: Binding with click handler and disabled state (disabled when video is already at top)
+   - `moveToBottomButton`: Binding with click handler and disabled state (disabled when video is already at bottom)
+
+3. **popup/popup.html** - Added buttons to both views:
+   - Main playlists view: Added "Move to Top" and "Move to Bottom" buttons in the more-menu
+   - Individual playlist view: Added the same buttons in the more-menu
+
+### Key Implementation Details
+
+- The "top" position is determined by finding the first video that doesn't have `status === "done"`
+- If all videos are marked as done, the top position is at the end of the playlist
+- Both operations use `splice` to remove the video from its current position and insert it at the new position
+- The buttons are disabled when:
+  - "Move to Top" is disabled when the video is already at the top position
+  - "Move to Bottom" is disabled when the video is already at the last position
+- Both operations close the menu after execution (via `closeAllMenus()`)
+- Alpine.js CSP compliance is maintained - no JavaScript expressions in HTML attributes
+
+### Testing Notes
+
+To test this feature:
+1. Create a playlist with multiple videos
+2. Mark some videos as "done" from the beginning
+3. Open the three-dot menu for a video that's not at the top
+4. Click "Move to Top" - it should move after the last done video
+5. Open the menu for a video in the middle
+6. Click "Move to Bottom" - it should move to the end
+7. Verify that the buttons are disabled appropriately when videos are already at top/bottom
+
+## Files Modified
+
+- `/Users/yellowdig/personal/projects/playlist/popup/popup.js`
+- `/Users/yellowdig/personal/projects/playlist/popup/popup.html`

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 54 - 0
2025-11-07-codemcp-feat-add-json-merge-script-for-playlists-and-histo.md


+ 42 - 0
2025-11-07-codemcp-modify-playlist-import-to-append-and-filter.md

@@ -0,0 +1,42 @@
+# 2025-11-07 CodeMCP: Modify Playlist Import to Append and Filter
+
+## User Request
+
+regarding the playlist import functionality, rather than parsing the json content and replacing the in-memory playlist data, I want to: filter out any playlist items that are already marked as "done", add them to the end of the playlists without overriding the items that are already there, and not add any that are already currently in a playlist (that is already the current behaviour for adding an individual video).
+
+## Changes Made
+
+### Modified `validateAndImportPlaylists()` in popup/popup.js
+
+1. Added filtering logic to exclude videos marked as "done":
+   ```javascript
+   if (video.status === "done") {
+     return false;
+   }
+   ```
+
+2. Changed the final call from `this.updatePlaylists(validPlaylists)` to `this.appendToPlaylists(validPlaylists)`
+
+### Replaced `updatePlaylists()` with `appendToPlaylists()` in popup/popup.js
+
+Created a new method that:
+1. Creates a copy of current playlists
+2. Iterates through imported playlists and videos
+3. Uses `PlaylistUtils.findPlaylist()` to check if each video already exists in any playlist
+4. Only adds videos that don't already exist (deduplication)
+5. Appends videos to the end of their respective playlists
+6. Shows user feedback with counts of added and skipped videos
+
+Key implementation details:
+- Uses the existing `PlaylistUtils.findPlaylist()` utility which checks videos by their YouTube video ID
+- Maintains the same duplicate-checking behavior as adding individual videos
+- Creates new playlists if they don't exist yet
+- Provides informative user feedback: "Import complete! Added X video(s), skipped Y duplicate(s)."
+
+## Behavior Summary
+
+The import functionality now:
+1. ✅ Filters out videos marked as "done"
+2. ✅ Appends videos to end of playlists (doesn't replace existing items)
+3. ✅ Skips duplicates using the same logic as adding individual videos
+4. ✅ Provides clear feedback about what was added/skipped

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 60 - 0
2025-11-08-codemcp-feat-add-playlist-and-history-export-scripts.md


+ 22 - 0
2026-05-25-claude-move-action-buttons-to-top.md

@@ -0,0 +1,22 @@
+# Move Action Buttons to Top
+
+**Date:** 2026-05-25
+
+## Requests
+
+> on the popup window, there are buttons at the bottom (after the playlists). move them to the top (so the user doesn't need to scroll to access the actions).
+
+> put a small space between the buttons and the first playlist
+
+> when the buttons wrap onto another line (row), add a (smaller) space between the rows of buttons
+
+> also add a very small (horizontal) space between the buttons
+
+## Changes
+
+- **`popup/popup.html`**: Moved the `export-container` div (containing Export Playlists, Import Playlists, History, Save Channel, Wiki/Insp buttons) from after all view containers to just before the `playlists-container`. The buttons now appear immediately below the header on the main playlists view.
+- **`popup/popup.css`**: Added `.export-container` rule with:
+  - `display: flex; flex-wrap: wrap` — enables wrapping layout
+  - `row-gap: 6px` — small space between wrapped rows of buttons
+  - `column-gap: 4px` — very small horizontal space between buttons
+  - `margin-bottom: 12px` — gap between the button bar and the first playlist

+ 51 - 0
CLAUDE.md

@@ -0,0 +1,51 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Firefox browser extension that manages video playlists and tracks playback history. All data is persisted via the browser's `storage` API — no backend, no IndexedDB. Uses Manifest V2 and vanilla JavaScript with no build step; load via `about:debugging` → "Load Temporary Add-on" → select `manifest.json`.
+
+## Code Layout
+
+- `popup/` — Extension popup UI (Alpine.js, single-page, multiple views)
+- `background.js` — Service worker: context menus, storage init, autoplay chaining, history tracking
+- `content_scripts/content.js` — Injected into YouTube pages; forwards `play`/`pause`/`ended` events to background
+- `shared/playlist-utils.js` — Shared helpers used by both popup and background (video ID extraction, deduplication, search)
+
+## Alpine.js CSP Compliance (Critical)
+
+The popup runs under a Content Security Policy that forbids inline JavaScript evaluation. All Alpine.js code must be CSP-compliant:
+
+- **No JavaScript expressions in HTML attributes** — `x-show="count === 0"`, `x-text="label()"`, ternaries, method calls are all forbidden
+- Use `Alpine.data()` binding objects and pre-compute all display values as properties
+- Bind with `x-bind` or `:attr`; never inline logic in templates
+
+Violation examples to avoid: `x-show="items.length > 0"`, `x-text="getName(id)"`, `x-if="a === b"`.
+
+## Popup Views
+
+The popup is a single-page app; `currentView` controls which view is active:
+
+| View | Purpose |
+|------|---------|
+| `playlists` | Main view: all playlists and their unviewed videos |
+| `playlist` | Single-playlist detail with full video list including viewed |
+| `history` | Playback history with per-event tags |
+| `saveChannel` | Save a YouTube channel with categories |
+| `wikiInsp` | Copy video info as wikitext |
+| `import` | Import playlists from JSON |
+
+## Session Reports
+
+After completing any code changes, write a session report summarizing what was requested and what was done (changes made, reasoning, files touched). Save it to the project root with the filename `<YYYY-MM-DD>-claude-<subject, max 50 chars>.md` (e.g., `2026-05-25-claude-add-remove-video-button.md`). These files are committed to git.
+
+Include the user's original request(s) verbatim in the report (copy exact wording from the conversation).
+
+If the user makes follow-up requests in the same session on the same subject, append to the existing report rather than creating a new one. If a new session starts with a different subject, create a new file.
+
+## Key Constraints
+
+- A video can exist in **at most one playlist** — `addVideoToPlaylist()` enforces global deduplication
+- The 6 default playlists (`listening-long`, `listening-short`, `listening-misc`, `watching-short`, `watching-long`, `slow-tv`) are hardcoded in both `background.js` and the context menu; adding a new playlist requires changes in both places
+- History is keyed by YouTube video ID; rewatching appends new interaction events rather than overwriting

+ 34 - 0
README.md

@@ -0,0 +1,34 @@
+# Playlist Extension
+
+Firefox browser extension that manages video playlists and tracks playback history.
+
+## Loading for Development
+
+Load via `about:debugging` → "Load Temporary Add-on" → select `manifest.json`.
+
+## Packaging
+
+Create an `.xpi` file (the extension package format — just a ZIP with a different extension). Make sure `manifest.json` and other top-level files are at the root of the archive, not inside a subdirectory:
+
+```bash
+zip -r playlist.xpi manifest.json background.js content_scripts/ icons/ popup/ shared/
+```
+
+## Installing Permanently (without the store)
+
+### Option A: AMO Unlisted Signing (works on any standard Firefox)
+
+Mozilla will sign your extension without publishing it publicly:
+
+1. Create a developer account at addons.mozilla.org
+2. Submit the `.xpi` as **"unlisted"** (self-distribution, not public)
+3. Mozilla reviews it (usually auto-approved for unlisted) and returns a signed `.xpi`
+4. Install the signed `.xpi` permanently on any standard Firefox — no dev mode needed
+
+The extension never appears in the public store.
+
+### Option B: Unsigned `.xpi` on Firefox Developer Edition or Nightly
+
+Standard Firefox release enforces signature checks and ignores the bypass preference. **Firefox Developer Edition** and **Nightly** have `xpinstall.signatures.required` set to `false` by default, so they accept unsigned `.xpi` files as permanent installs.
+
+Drag-and-drop the `.xpi` into the browser, or go to `about:addons` → gear icon → "Install Add-on From File".

+ 5 - 0
filter-history.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+
+# take all playlist export files, retrieve videos marked as "to save"
+jq -s 'map(.playbackHistory | to_entries | map(select(.value.tags | index("to save"))) | map({videoId: .key, title: .value.title})) | add | unique_by(.videoId)' playlists-export-*
+

+ 51 - 0
format-history.sh

@@ -0,0 +1,51 @@
+#!/bin/bash
+
+if [ $# -eq 0 ]; then
+    echo "Usage: $0 <export-file.json> [tag1] [tag2] ..." >&2
+    exit 1
+fi
+
+export_file="$1"
+shift
+tags=("$@")
+
+if [ ! -f "$export_file" ]; then
+    echo "Error: File '$export_file' not found" >&2
+    exit 1
+fi
+
+if [ ${#tags[@]} -eq 0 ]; then
+    jq -r '
+    .playbackHistory | to_entries |
+    map({
+      url: .value.url,
+      title: .value.title,
+      lastInteraction: ([.value.history[].timestamp] | max)
+    }) |
+    sort_by(.lastInteraction) | reverse |
+    .[] |
+    "\((.lastInteraction / 1000 | strftime("%Y-%m-%d %H:%M:%S")))\t\(.url)\t\(.title)"
+    ' "$export_file"
+else
+    # Build jq filter for tag matching
+    tag_filters=""
+    for tag in "${tags[@]}"; do
+        if [ -n "$tag_filters" ]; then
+            tag_filters="$tag_filters and "
+        fi
+        tag_filters="${tag_filters}(.value.tags // [] | contains([\"$tag\"]))"
+    done
+
+    jq -r "
+    .playbackHistory | to_entries |
+    map(select($tag_filters)) |
+    map({
+      url: .value.url,
+      title: .value.title,
+      lastInteraction: ([.value.history[].timestamp] | max)
+    }) |
+    sort_by(.lastInteraction) | reverse |
+    .[] |
+    \"\((.lastInteraction / 1000 | strftime(\"%Y-%m-%d %H:%M:%S\")))\\t\(.url)\\t\(.title)\"
+    " "$export_file"
+fi

+ 37 - 0
format-playlists.sh

@@ -0,0 +1,37 @@
+#!/bin/bash
+
+if [ $# -eq 0 ]; then
+    echo "Usage: $0 <export-file.json>" >&2
+    exit 1
+fi
+
+export_file="$1"
+
+if [ ! -f "$export_file" ]; then
+    echo "Error: File '$export_file' not found" >&2
+    exit 1
+fi
+
+jq -r '
+.playlists | to_entries | .[] |
+"# \(.key)",
+(
+  [.value[] |
+    select(.status != "done") |
+    "\(.url)\t\(.title)"
+  ] |
+  if length > 0 then .[] else empty end
+),
+(
+  if ([.value[] | select(.status == "done")] | length) > 0 then
+    "## Done",
+    ([.value[] |
+      select(.status == "done") |
+      "\(.url)\t\(.title)"
+    ] | .[])
+  else
+    empty
+  end
+),
+""
+' "$export_file"

+ 92 - 0
merge-exports.sh

@@ -0,0 +1,92 @@
+#!/bin/bash
+
+set -euo pipefail
+
+if [ $# -eq 0 ]; then
+    echo "Usage: $0 <export-file1.json> [export-file2.json] ..." >&2
+    exit 1
+fi
+
+jq -n --slurpfile files <(
+    for file in "$@"; do
+        basename=$(basename "$file")
+        date_prefix=$(echo "$basename" | grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2}' || echo "0000-00-00")
+
+        jq --arg file "$file" --arg date "$date_prefix" '. + {sourceFile: $file, fileDate: $date}' "$file"
+    done | jq -s '.'
+) '
+# Sort files by date (later dates have priority for tie-breaking)
+($files[0] | sort_by(.fileDate)) as $sorted_files |
+
+# Merge playlists
+($sorted_files | map(.playlists // {} | to_entries) | flatten | group_by(.key) | map({
+    key: .[0].key,
+    value: (
+        # Get the playlist name
+        .[0].key as $playlist_name |
+
+        # Collect all videos from all versions with metadata
+        (map(.value) | flatten | unique_by(.url)) as $all_videos |
+
+        # For each video, find its earliest position
+        $all_videos | map(. as $video |
+            # Find earliest position across all files
+            ([$sorted_files[] |
+                select(.playlists[$playlist_name]) |
+                .playlists[$playlist_name] |
+                to_entries[] |
+                select(.value.url == $video.url) |
+                {index: .key, date: .}
+            ] |
+            if length > 0 then
+                min_by(.date)
+            else
+                {index: 999999, date: {fileDate: "9999-99-99"}}
+            end) as $earliest |
+
+            # Get latest video data
+            ([$sorted_files[] |
+                select(.playlists[$playlist_name]) |
+                .playlists[$playlist_name][] |
+                select(.url == $video.url)
+            ] | last) as $latest_video |
+
+            $latest_video + {
+                _sort_date: ($sorted_files | map(select(.playlists[$playlist_name] and (.playlists[$playlist_name] | map(.url) | contains([$video.url])))) | .[0].fileDate),
+                _sort_index: $earliest.index
+            }
+        ) |
+        # Sort by earliest appearance
+        sort_by([._sort_date, ._sort_index]) |
+        # Remove sort keys
+        map(del(._sort_date, ._sort_index))
+    )
+}) | from_entries) as $merged_playlists |
+
+# Merge playback history
+($sorted_files | map(.playbackHistory // {} | to_entries) | flatten | group_by(.key) | map({
+    key: .[0].key,
+    value: (
+        # For each video in history, merge all events and metadata
+        map(.value) |
+        {
+            url: (map(.url) | last),
+            title: (map(.title) | last),
+            tags: (map(.tags // []) | flatten | unique),
+            history: (map(.history // []) | flatten | unique_by(.timestamp) | sort_by(.timestamp))
+        }
+    )
+}) | from_entries) as $merged_history |
+
+# Collect all open tabs (unique by URL, keep latest title)
+($sorted_files | map(.openTabs // []) | flatten | group_by(.url) | map(last)) as $merged_tabs |
+
+# Output merged result
+{
+    playlists: $merged_playlists,
+    playbackHistory: $merged_history,
+    openTabs: $merged_tabs,
+    exportDate: (now | todate),
+    mergedFrom: ($sorted_files | map({file: .sourceFile, date: .fileDate}))
+}
+'

+ 270 - 0
popup/popup.css

@@ -630,3 +630,273 @@ button:hover {
 .save-channel-btn:hover {
   background-color: #e55a00;
 }
+
+/* Wiki/Insp view styles */
+.wiki-insp-container {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  min-height: 300px;
+}
+
+.wiki-insp-content {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.wikitext-command-section {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.wikitext-command-field {
+  width: 100%;
+  min-height: 120px;
+  padding: 12px;
+  border: 1px solid #ddd;
+  border-radius: 6px;
+  font-family: monospace;
+  font-size: 12px;
+  background-color: #f8f9fa;
+  resize: vertical;
+  line-height: 1.4;
+}
+
+.timestamp-controls {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 8px 0;
+}
+
+.timestamp-checkbox-container {
+  display: flex;
+  align-items: center;
+}
+
+.timestamp-checkbox-label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 14px;
+  color: #333;
+  cursor: pointer;
+  user-select: none;
+}
+
+.timestamp-checkbox {
+  width: 16px;
+  height: 16px;
+  cursor: pointer;
+  accent-color: #4285f4;
+}
+
+.refresh-timestamp-btn {
+  background-color: #34a853;
+  color: white;
+  padding: 6px 12px;
+  border-radius: 4px;
+  font-size: 12px;
+  font-weight: 500;
+  transition: all 0.2s ease;
+  white-space: nowrap;
+}
+
+.refresh-timestamp-btn:hover:not(:disabled) {
+  background-color: #2d7d3a;
+  transform: translateY(-1px);
+  box-shadow: 0 2px 6px rgba(52, 168, 83, 0.3);
+}
+
+.refresh-timestamp-btn:active {
+  background-color: #1e5f2a;
+  transform: translateY(0);
+  box-shadow: 0 1px 3px rgba(52, 168, 83, 0.4);
+  transition: all 0.1s ease;
+}
+
+.refresh-timestamp-btn:disabled {
+  background-color: #ccc;
+  color: #888;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.refresh-timestamp-btn:disabled:hover {
+  background-color: #ccc;
+  transform: none;
+  box-shadow: none;
+}
+
+.copy-wikitext-btn {
+  width: 100%;
+  background-color: #4285f4;
+  color: white;
+  padding: 10px 16px;
+  border-radius: 6px;
+  font-size: 14px;
+  font-weight: 500;
+  transition: all 0.2s ease;
+}
+
+.copy-wikitext-btn:hover:not(:disabled) {
+  background-color: #3367d6;
+  transform: translateY(-1px);
+  box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3);
+}
+
+.copy-wikitext-btn:active {
+  background-color: #2d5aa0;
+  transform: translateY(0);
+  box-shadow: 0 1px 3px rgba(66, 133, 244, 0.4);
+  transition: all 0.1s ease;
+}
+
+.copy-wikitext-btn:disabled {
+  background-color: #ccc;
+  color: #888;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.copy-wikitext-btn:disabled:hover {
+  background-color: #ccc;
+  transform: none;
+  box-shadow: none;
+}
+
+.wiki-insp-notice {
+  background-color: #fff3cd;
+  border: 1px solid #ffeaa7;
+  border-radius: 6px;
+  padding: 12px;
+  color: #856404;
+  font-size: 14px;
+}
+
+.wiki-insp-notice p {
+  margin: 0;
+}
+
+/* Wiki/Insp button styles */
+.wiki-insp-btn {
+  background-color: #9c27b0;
+  color: white;
+  padding: 8px 12px;
+  border-radius: 4px;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.wiki-insp-btn:hover {
+  background-color: #7b1fa2;
+}
+
+/* Import view styles */
+.import-container {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  min-height: 300px;
+}
+
+.import-content {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.import-section {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.import-textarea {
+  width: 100%;
+  min-height: 450px;
+  max-height: 500px;
+  padding: 12px;
+  border: 1px solid #ddd;
+  border-radius: 6px;
+  font-family: monospace;
+  font-size: 12px;
+  background-color: #f8f9fa;
+  resize: vertical;
+  line-height: 1.4;
+}
+
+.import-textarea:focus {
+  outline: none;
+  border-color: #4285f4;
+  box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
+}
+
+.import-submit-btn {
+  width: 100%;
+  background-color: #34a853;
+  color: white;
+  padding: 10px 16px;
+  border-radius: 6px;
+  font-size: 14px;
+  font-weight: 500;
+  transition: all 0.2s ease;
+}
+
+.import-submit-btn:hover {
+  background-color: #2d7d3a;
+  transform: translateY(-1px);
+  box-shadow: 0 2px 8px rgba(52, 168, 83, 0.3);
+}
+
+.import-submit-btn:active {
+  background-color: #1e5f2a;
+  transform: translateY(0);
+  box-shadow: 0 1px 3px rgba(52, 168, 83, 0.4);
+  transition: all 0.1s ease;
+}
+
+/* Import button styles */
+.import-btn {
+  background-color: #34a853;
+  color: white;
+  padding: 8px 12px;
+  border-radius: 4px;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.import-btn:hover {
+  background-color: #2d7d3a;
+}
+
+/* Export button styles */
+.export-btn {
+  background-color: #4285f4;
+  color: white;
+  padding: 8px 12px;
+  border-radius: 4px;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.export-btn:hover {
+  background-color: #3367d6;
+}
+
+.export-container {
+  display: flex;
+  flex-wrap: wrap;
+  row-gap: 6px;
+  column-gap: 4px;
+  margin-bottom: 12px;
+}

+ 122 - 22
popup/popup.html

@@ -30,6 +30,35 @@
         <h1>Save Channel</h1>
       </header>
 
+      <header x-bind="wikiInspHeader">
+        <button class="back-btn-arrow" x-bind="backButton">←</button>
+        <h1>Wiki/Insp</h1>
+      </header>
+
+      <header x-bind="importHeader">
+        <button class="back-btn-arrow" x-bind="backButton">←</button>
+        <h1>Import Playlists</h1>
+      </header>
+
+      <!-- Export/Import/History buttons -->
+      <div class="export-container" x-bind="exportContainer">
+        <button class="export-btn" x-bind="exportButton">
+          Export Playlists
+        </button>
+        <button class="import-btn" x-bind="importButton">
+          Import Playlists
+        </button>
+        <button class="history-btn" x-bind="historyButton">
+          History
+        </button>
+        <button class="save-channel-btn" x-bind="saveChannelButton">
+          Save Channel
+        </button>
+        <button class="wiki-insp-btn" x-bind="wikiInspButton">
+          Wiki/Insp
+        </button>
+      </div>
+
       <div class="playlists-container" x-bind="playlistsContainer">
         <template
           x-for="playlistData in playlistsForDisplay"
@@ -100,6 +129,22 @@
                         :data-playlist-index="video.originalIndex"
                         x-bind="moreMenu"
                       >
+                        <button
+                          :data-playlist-name="playlistData.name"
+                          :data-playlist-index="video.originalIndex"
+                          x-bind="moveToTopButton"
+                          class="menu-item move-to-top-item"
+                        >
+                          Move to Top
+                        </button>
+                        <button
+                          :data-playlist-name="playlistData.name"
+                          :data-playlist-index="video.originalIndex"
+                          x-bind="moveToBottomButton"
+                          class="menu-item move-to-bottom-item"
+                        >
+                          Move to Bottom
+                        </button>
                         <button
                           :data-playlist-name="playlistData.name"
                           :data-playlist-index="video.originalIndex"
@@ -245,6 +290,67 @@
         </div>
       </div>
 
+      <!-- Wiki/Insp view -->
+      <div class="wiki-insp-container" x-bind="wikiInspContainer">
+        <div class="wiki-insp-content">
+          <div class="wikitext-command-section">
+            <textarea
+              class="wikitext-command-field"
+              x-text="wikitextCommand"
+              readonly
+            ></textarea>
+            <div class="timestamp-controls">
+              <div class="timestamp-checkbox-container">
+                <label class="timestamp-checkbox-label">
+                  <input
+                    type="checkbox"
+                    class="timestamp-checkbox"
+                    x-bind="timestampCheckbox"
+                  />
+                  Include timestamp line
+                </label>
+              </div>
+              <button
+                class="refresh-timestamp-btn"
+                x-bind="refreshTimestampButton"
+                title="Refresh current video timestamp"
+              >
+                🔄 Refresh
+              </button>
+            </div>
+            <button
+              class="copy-wikitext-btn"
+              x-bind="copyWikitextButton"
+              title="Copy to clipboard"
+            >
+              Copy
+            </button>
+          </div>
+          <div class="wiki-insp-notice" x-bind="wikiInspNotice">
+            <p>This feature is only available on YouTube video pages (/watch).</p>
+          </div>
+        </div>
+      </div>
+
+      <!-- Import view -->
+      <div class="import-container" x-bind="importContainer">
+        <div class="import-content">
+          <div class="import-section">
+            <textarea
+              class="import-textarea"
+              placeholder="Paste your JSON export here..."
+              x-bind="importTextarea"
+            ></textarea>
+            <button
+              class="import-submit-btn"
+              x-bind="importSubmitButton"
+            >
+              Import
+            </button>
+          </div>
+        </div>
+      </div>
+
       <!-- Individual playlist view -->
       <div class="playlist-view-container" x-bind="playlistViewContainer">
         <div class="playlist-full-view">
@@ -297,6 +403,22 @@
                     :data-playlist-index="index"
                     x-bind="moreMenu"
                   >
+                    <button
+                      :data-playlist-name="currentPlaylistName"
+                      :data-playlist-index="index"
+                      x-bind="moveToTopButton"
+                      class="menu-item move-to-top-item"
+                    >
+                      Move to Top
+                    </button>
+                    <button
+                      :data-playlist-name="currentPlaylistName"
+                      :data-playlist-index="index"
+                      x-bind="moveToBottomButton"
+                      class="menu-item move-to-bottom-item"
+                    >
+                      Move to Bottom
+                    </button>
                     <button
                       :data-playlist-name="currentPlaylistName"
                       :data-playlist-index="index"
@@ -332,28 +454,6 @@
         </div>
       </div>
 
-      <!-- Export/Import/History buttons -->
-      <div class="export-container" x-bind="exportContainer">
-        <button class="export-btn" x-bind="exportButton">
-          Export Playlists
-        </button>
-        <button class="import-btn" x-bind="importButton">
-          Import Playlists
-        </button>
-        <button class="history-btn" x-bind="historyButton">
-          History
-        </button>
-        <button class="save-channel-btn" x-bind="saveChannelButton">
-          Save Channel
-        </button>
-        <input
-          type="file"
-          id="import-file-input"
-          accept=".json"
-          style="display: none"
-          x-bind="importFileInput"
-        />
-      </div>
     </div>
   </body>
 </html>

+ 373 - 35
popup/popup.js

@@ -9,21 +9,23 @@ document.addEventListener("alpine:init", () => {
   // X view playback history (?)
   // X include currently open tabs in export
   // - support playlist management (add, remove, rename playlists)
+  //   - support dynamic (on-demand) playlists
   // - support input text box for adding to playlist (how to handle title?)
   //   - or raw json editing
   // X option (probably a button) to add current page (url, title) to playlist
   //   X align with addLinkToPlaylist in background.js (no repeated videos)
-  // - button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version)
+  // X button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version)
   // - long-term: replace youtube page? rss feeds? need server?
   // X add personal rating feature ("enjoyed", "this was important", etc)
   // - option to move video (including status) to another playlist
+  // X button to copy current video to clipboard in wikitext format (for personal wiki list)
   Alpine.data("playlistManager", () => ({
     playlists: {},
     currentIndices: {},
     history: {},
     sortedHistory: [],
     openMenus: new Set(), // Track which menus are open
-    currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', or 'saveChannel'
+    currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', 'saveChannel', or 'wikiInsp'
     currentPlaylistName: "", // Track which playlist is being viewed
     playlistsForDisplay: [], // Computed array for display
     currentPlaylistVideos: [], // Videos for current playlist view
@@ -52,6 +54,16 @@ document.addEventListener("alpine:init", () => {
     curlCommand: "", // Generated curl command
     checkInterval: 14, // Check interval in days
 
+    // Wiki/insp properties
+    wikitextCommand: "", // Generated wikitext
+    isCurrentVideoYoutube: false, // Whether current video is YouTube
+    hasVideoEnded: false, // Whether video has ended
+    currentVideoTimestamp: "", // Current video timestamp
+    includeTimestamp: true, // Whether to include timestamp line
+
+    // Import properties
+    importText: "", // Text area content for import
+
     init() {
       this.loadPlaylists();
       this.loadHistory();
@@ -172,16 +184,79 @@ document.addEventListener("alpine:init", () => {
           this.isCurrentTabYoutube = url.hostname === "www.youtube.com";
           this.isCurrentTabChannelPage =
             this.isCurrentTabYoutube && url.pathname.includes("/videos");
+          this.isCurrentVideoYoutube =
+            this.isCurrentTabYoutube && url.pathname.includes("/watch");
+
+          // Get video timestamp if on YouTube video page
+          if (this.isCurrentVideoYoutube) {
+            await this.getVideoTimestamp();
+          }
+
           this.updateAddCurrentPageButtonText();
           this.updateCurlCommand();
+          this.updateWikitextCommand();
         }
       } catch (error) {
         console.error("Error getting current tab:", error);
         this.currentTab = null;
         this.isCurrentTabYoutube = false;
         this.isCurrentTabChannelPage = false;
+        this.isCurrentVideoYoutube = false;
         this.updateAddCurrentPageButtonText();
         this.updateCurlCommand();
+        this.updateWikitextCommand();
+      }
+    },
+
+    async getVideoTimestamp() {
+      try {
+        // Execute script in the current tab to get video timestamp
+        const results = await browser.tabs.executeScript(this.currentTab.id, {
+          code: `
+            (function() {
+              const video = document.querySelector('video');
+              if (video) {
+                const currentTime = video.currentTime;
+                const duration = video.duration;
+                const ended = video.ended;
+
+                const formatTime = (seconds) => {
+                  const hours = Math.floor(seconds / 3600);
+                  const minutes = Math.floor((seconds % 3600) / 60);
+                  const remainingSeconds = Math.floor(seconds % 60);
+
+                  if (hours > 0) {
+                    return hours + ':' + minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0');
+                  } else {
+                    return minutes + ':' + remainingSeconds.toString().padStart(2, '0');
+                  }
+                };
+
+                return {
+                  currentTime: currentTime,
+                  formattedTime: formatTime(currentTime),
+                  duration: duration,
+                  ended: ended,
+                  isLongVideo: duration > 3600
+                };
+              }
+              return null;
+            })();
+          `
+        });
+
+        if (results && results[0]) {
+          const videoData = results[0];
+          this.currentVideoTimestamp = videoData.formattedTime;
+          this.hasVideoEnded = videoData.ended;
+        } else {
+          this.currentVideoTimestamp = "0:00";
+          this.hasVideoEnded = false;
+        }
+      } catch (error) {
+        console.error("Error getting video timestamp:", error);
+        this.currentVideoTimestamp = "0:00";
+        this.hasVideoEnded = false;
       }
     },
 
@@ -250,6 +325,79 @@ document.addEventListener("alpine:init", () => {
       this.updateCurlCommand();
     },
 
+    showWikiInsp() {
+      this.currentView = "wikiInsp";
+      // Refresh timestamp when opening the view
+      if (this.isCurrentVideoYoutube) {
+        this.getVideoTimestamp().then(() => {
+          this.updateWikitextCommand();
+        });
+      } else {
+        this.updateWikitextCommand();
+      }
+    },
+
+    showImport() {
+      this.currentView = "import";
+    },
+
+    async refreshVideoTimestamp() {
+      if (this.isCurrentVideoYoutube) {
+        await this.getVideoTimestamp();
+        this.updateWikitextCommand();
+      }
+    },
+
+    updateWikitextCommand() {
+      if (!this.currentTab || !this.isCurrentVideoYoutube) {
+        this.wikitextCommand = "This feature is only available on YouTube videos.";
+        return;
+      }
+
+      const today = new Date();
+      const dateString = today.toISOString().split('T')[0]; // YYYY-MM-DD format
+
+      const title = this.currentTab.title.replace(" - YouTube", "").replace(/\|/g, "-");
+      const url = this.currentTab.url;
+
+      let wikitext = `!!! ${dateString}\n* [[${title}|${url}]] (yt)`;
+
+      // Add timestamp if user has enabled it and video hasn't ended
+      if (this.includeTimestamp && !this.hasVideoEnded) {
+        wikitext += `\n** ${this.currentVideoTimestamp || "0:00"} - `;
+      }
+
+      this.wikitextCommand = wikitext;
+    },
+
+    async copyWikitextCommand() {
+      try {
+        await navigator.clipboard.writeText(this.wikitextCommand);
+        console.log("Wikitext copied to clipboard");
+        // Could add visual feedback here
+      } catch (error) {
+        console.error("Failed to copy to clipboard:", error);
+        // Fallback for older browsers
+        try {
+          const textArea = document.createElement("textarea");
+          textArea.value = this.wikitextCommand;
+          document.body.appendChild(textArea);
+          textArea.focus();
+          textArea.select();
+          document.execCommand("copy");
+          document.body.removeChild(textArea);
+          console.log("Wikitext copied to clipboard (fallback)");
+        } catch (fallbackError) {
+          console.error("Fallback copy failed:", fallbackError);
+        }
+      }
+    },
+
+    toggleTimestamp() {
+      this.includeTimestamp = !this.includeTimestamp;
+      this.updateWikitextCommand();
+    },
+
     sortHistoryByRecentInteraction() {
       // Convert history object to array with video ID and sort by most recent interaction
       this.sortedHistory = Object.entries(this.history)
@@ -459,6 +607,69 @@ document.addEventListener("alpine:init", () => {
       }
     },
 
+    async moveVideoToTop(playlistName, index) {
+      const playlists = JSON.parse(JSON.stringify(this.playlists));
+      const playlist = [...playlists[playlistName]];
+
+      const topPosition = this.findTopPosition(playlistName);
+
+      if (index === topPosition) return;
+
+      const video = playlist.splice(index, 1)[0];
+      playlist.splice(topPosition, 0, video);
+
+      const updatedPlaylists = {
+        ...playlists,
+        [playlistName]: playlist,
+      };
+
+      try {
+        await browser.storage.local.set({ playlists: updatedPlaylists });
+        this.playlists = updatedPlaylists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
+      } catch (error) {
+        console.error("Error moving video to top:", error);
+      }
+    },
+
+    async moveVideoToBottom(playlistName, index) {
+      const playlists = JSON.parse(JSON.stringify(this.playlists));
+      const playlist = [...playlists[playlistName]];
+
+      if (index === playlist.length - 1) return;
+
+      const video = playlist.splice(index, 1)[0];
+      playlist.push(video);
+
+      const updatedPlaylists = {
+        ...playlists,
+        [playlistName]: playlist,
+      };
+
+      try {
+        await browser.storage.local.set({ playlists: updatedPlaylists });
+        this.playlists = updatedPlaylists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
+      } catch (error) {
+        console.error("Error moving video to bottom:", error);
+      }
+    },
+
+    findTopPosition(playlistName) {
+      const playlist = this.playlists[playlistName];
+      if (!playlist) return 0;
+
+      for (let i = 0; i < playlist.length; i++) {
+        if (playlist[i].status !== "done") {
+          return i;
+        }
+      }
+
+      return playlist.length;
+    },
+
     async toggleVideoDoneStatus(playlistName, index) {
       const playlists = JSON.parse(JSON.stringify(this.playlists));
       const playlist = [...playlists[playlistName]];
@@ -699,45 +910,66 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
-    exportButton: {
+    moveToTopButton: {
       ["@click"]() {
-        this.exportPlaylists();
+        this.moveVideoToTop(
+          this.$el.dataset.playlistName,
+          parseInt(this.$el.dataset.playlistIndex),
+        );
+        this.closeAllMenus();
+      },
+      [":disabled"]() {
+        const index = parseInt(this.$el.dataset.playlistIndex);
+        const playlistName = this.$el.dataset.playlistName;
+        const topPosition = this.findTopPosition(playlistName);
+        return index === topPosition;
       },
     },
 
-    importButton: {
+    moveToBottomButton: {
       ["@click"]() {
-        document.getElementById("import-file-input").click();
+        this.moveVideoToBottom(
+          this.$el.dataset.playlistName,
+          parseInt(this.$el.dataset.playlistIndex),
+        );
+        this.closeAllMenus();
+      },
+      [":disabled"]() {
+        return (
+          parseInt(this.$el.dataset.playlistIndex) ===
+          this.playlists[this.$el.dataset.playlistName].length - 1
+        );
       },
     },
 
-    importFileInput: {
-      ["@change"]() {
-        this.importFile(this.$event);
+    exportButton: {
+      ["@click"]() {
+        this.exportPlaylists();
       },
     },
 
-    importFile(event) {
-      const file = event.target.files[0];
-      if (!file) return;
+    importButton: {
+      ["@click"]() {
+        this.showImport();
+      },
+    },
 
-      const reader = new FileReader();
-      reader.onload = (e) => {
-        try {
-          const importedData = JSON.parse(e.target.result);
-          this.validateAndImportPlaylists(importedData);
-        } catch (error) {
-          console.error("Error parsing JSON file:", error);
-          alert(
-            "Invalid JSON file. Please select a valid playlist export file.",
-          );
-        } finally {
-          // Reset the file input so the same file can be selected again
-          event.target.value = "";
-        }
-      };
+    async handleImport() {
+      if (!this.importText.trim()) {
+        alert("Please paste your JSON export data");
+        return;
+      }
 
-      reader.readAsText(file);
+      try {
+        const importedData = JSON.parse(this.importText);
+        this.validateAndImportPlaylists(importedData);
+        this.importText = "";
+      } catch (error) {
+        console.error("Error parsing JSON:", error);
+        alert(
+          "Invalid JSON format. Please paste valid playlist export data.",
+        );
+      }
     },
 
     validateAndImportPlaylists(data) {
@@ -761,7 +993,7 @@ document.addEventListener("alpine:init", () => {
           continue;
         }
 
-        // Validate each video in the playlist
+        // Validate each video in the playlist and filter out done videos
         const validVideos = videos.filter((video) => {
           if (!video || typeof video !== "object") {
             console.error(`Invalid video object in '${playlistName}'`);
@@ -780,6 +1012,10 @@ document.addEventListener("alpine:init", () => {
             return false;
           }
 
+          if (video.status === "done") {
+            return false;
+          }
+
           return true;
         });
 
@@ -802,18 +1038,40 @@ document.addEventListener("alpine:init", () => {
       }
 
       // Update storage and state
-      this.updatePlaylists(validPlaylists);
+      this.appendToPlaylists(validPlaylists);
     },
 
-    async updatePlaylists(playlists) {
+    async appendToPlaylists(importedPlaylists) {
       try {
-        await browser.storage.local.set({ playlists });
-        this.playlists = playlists;
+        const currentPlaylists = JSON.parse(JSON.stringify(this.playlists));
+        let addedCount = 0;
+        let skippedCount = 0;
+
+        for (const [playlistName, importedVideos] of Object.entries(importedPlaylists)) {
+          if (!currentPlaylists[playlistName]) {
+            currentPlaylists[playlistName] = [];
+          }
+
+          for (const video of importedVideos) {
+            const alreadyExists = PlaylistUtils.findPlaylist(currentPlaylists, video.url);
+
+            if (!alreadyExists) {
+              currentPlaylists[playlistName].push(video);
+              addedCount++;
+            } else {
+              skippedCount++;
+            }
+          }
+        }
+
+        await browser.storage.local.set({ playlists: currentPlaylists });
+        this.playlists = currentPlaylists;
         this.updatePlaylistsForDisplay();
         this.updateCurrentPlaylistVideos();
-        alert("Playlists imported successfully!");
+
+        alert(`Import complete! Added ${addedCount} video(s), skipped ${skippedCount} duplicate(s).`);
       } catch (error) {
-        console.error("Error updating playlists:", error);
+        console.error("Error appending playlists:", error);
         alert("Error importing playlists: " + error.message);
       }
     },
@@ -919,6 +1177,12 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    wikiInspButton: {
+      ["@click"]() {
+        this.showWikiInsp();
+      },
+    },
+
     backButton: {
       ["@click"]() {
         this.showPlaylists();
@@ -986,6 +1250,18 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    wikiInspHeader: {
+      ["x-show"]() {
+        return this.currentView === "wikiInsp";
+      },
+    },
+
+    importHeader: {
+      ["x-show"]() {
+        return this.currentView === "import";
+      },
+    },
+
     playlistsContainer: {
       ["x-show"]() {
         return this.currentView === "playlists";
@@ -1010,6 +1286,18 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    wikiInspContainer: {
+      ["x-show"]() {
+        return this.currentView === "wikiInsp";
+      },
+    },
+
+    importContainer: {
+      ["x-show"]() {
+        return this.currentView === "import";
+      },
+    },
+
     exportContainer: {
       ["x-show"]() {
         return this.currentView === "playlists";
@@ -1056,6 +1344,34 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    // Wiki/insp specific bindings
+    copyWikitextButton: {
+      ["@click"]() {
+        this.copyWikitextCommand();
+      },
+      [":disabled"]() {
+        return !this.isCurrentVideoYoutube;
+      },
+    },
+
+    timestampCheckbox: {
+      ["@change"]() {
+        this.toggleTimestamp();
+      },
+      [":checked"]() {
+        return this.includeTimestamp;
+      },
+    },
+
+    refreshTimestampButton: {
+      ["@click"]() {
+        this.refreshVideoTimestamp();
+      },
+      [":disabled"]() {
+        return !this.isCurrentVideoYoutube;
+      },
+    },
+
     categoryButton: {
       ["@click"]() {
         const category = this.$el.dataset.category;
@@ -1075,6 +1391,12 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    wikiInspNotice: {
+      ["x-show"]() {
+        return !this.isCurrentVideoYoutube;
+      },
+    },
+
     intervalInput: {
       [":value"]() {
         return this.checkInterval;
@@ -1105,5 +1427,21 @@ document.addEventListener("alpine:init", () => {
         };
       },
     },
+
+    // Import view bindings
+    importTextarea: {
+      [":value"]() {
+        return this.importText;
+      },
+      ["@input"]() {
+        this.importText = this.$el.value;
+      },
+    },
+
+    importSubmitButton: {
+      ["@click"]() {
+        this.handleImport();
+      },
+    },
   }));
 });