diff --git a/tests/test_future_posts.py b/tests/test_future_posts.py
new file mode 100644
index 00000000..ee728892
--- /dev/null
+++ b/tests/test_future_posts.py
@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import os
+import sys
+import json
+import shutil
+import pytest
+from pytest import fixture
+
+from nikola import nikola
+from nikola.utils import _reload
+
+
+try:
+ from freezegun import freeze_time
+
+ _freeze_time = True
+except ImportError:
+ _freeze_time = False
+ freeze_time = lambda x: lambda y: y
+
+# Define the path to our V8 plugins
+from tests import V8_PLUGIN_PATH
+
+
+class TestCommandFuturePostsBase:
+ """Base class for testing the future_posts command."""
+
+ @fixture(autouse=True)
+ def _copy_plugin_to_site(self, _init_site):
+ """Copy the future_posts plugin to the test site's plugins directory."""
+ if not os.path.exists("plugins"):
+ os.makedirs("plugins")
+ shutil.copytree(
+ str(V8_PLUGIN_PATH / "future_posts"),
+ os.path.join("plugins", "future_posts"),
+ )
+
+ def _add_test_post(self, title):
+ """Helper to add a test post with specified date."""
+ self._run_command(["new_post", "-f", "markdown", "-t", title, "-s"])
+
+ def _force_scan(self):
+ """Force a rescan of posts."""
+ self._site._scanned = False
+ self._site.scan_posts(True)
+
+ @fixture(autouse=True)
+ def _init_site(self, monkeypatch, tmp_path):
+ """Initialize a demo site for testing."""
+ from nikola.plugins.command.init import CommandInit
+
+ # Create demo site
+ monkeypatch.chdir(tmp_path)
+ command_init = CommandInit()
+ command_init.execute(options={"demo": True, "quiet": True}, args=["demo"])
+
+ # Setup paths and load config
+ sys.path.insert(0, "")
+ monkeypatch.chdir(tmp_path / "demo")
+ with open("conf.py", "a") as f:
+ f.write(
+ "SCHEDULE_RULE = 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=7;BYMINUTE=0;BYSECOND=0'\n"
+ )
+ import conf
+
+ _reload(conf)
+ sys.path.pop(0)
+
+ # Initialize Nikola site
+ self._site = nikola.Nikola(**conf.__dict__)
+ # Initialize plugins (our plugin will be discovered from the plugins directory)
+ self._site.init_plugins()
+
+ def _run_command(self, args=[]):
+ """Run a Nikola command."""
+ from nikola.__main__ import main
+
+ return main(args)
+
+ def _get_command_output(self, args):
+ """Run command and capture its output."""
+ import subprocess
+
+ try:
+ output = subprocess.check_output(args)
+ return output.decode("utf-8")
+ except (OSError, subprocess.CalledProcessError):
+ return ""
+
+
+@pytest.mark.skipif(not _freeze_time, reason="freezegun package not installed.")
+class TestCommandFuturePosts(TestCommandFuturePostsBase):
+ """Test the future_posts command functionality."""
+
+ @fixture(autouse=True)
+ def setUp(self):
+ """Set up test posts."""
+ # Add some test posts with future dates
+ for i in range(3):
+ self._add_test_post(f"Future Post {i+1}")
+ self._force_scan()
+
+ def test_json_output(self):
+ """Test JSON output format."""
+ output = self._get_command_output(["nikola", "future_posts", "--json"])
+ posts = json.loads(output)
+ assert isinstance(posts, list)
+ assert len(posts) >= 3
+ assert all(isinstance(post, dict) for post in posts)
+ assert all("title" in post and "date" in post for post in posts)
+
+ def test_calendar_output(self):
+ """Test calendar output format."""
+ output = self._get_command_output(["nikola", "future_posts", "--calendar"])
+ print(output)
+ assert "## indicates days with scheduled posts" in output
+ assert any(month in output for month in ["January", "February", "March"])
+
+ def test_details_output(self):
+ """Test detailed list output format."""
+ output = self._get_command_output(["nikola", "future_posts", "--details"])
+ assert "Found" in output
+ assert "Future Post" in output
+ assert "Title:" in output
+ assert "Date:" in output
+
+ def test_html_output(self):
+ """Test HTML output format."""
+ output = self._get_command_output(["nikola", "future_posts", "--html"])
+ assert "" in output
+ assert "
Future Posts" in output
+ assert "Future Post" in output
+
+ def test_no_future_posts(self):
+ """Test behavior when no future posts exist."""
+ # Clear existing posts
+ for post in os.listdir("posts"):
+ os.remove(os.path.join("posts", post))
+ self._force_scan()
+ output = self._get_command_output(["nikola", "future_posts", "--details"])
+ assert "No future posts scheduled." in output
+
+ @pytest.mark.skipif(not _freeze_time, reason="freezegun package not installed.")
+ @freeze_time("2025-01-01")
+ def test_months_parameter(self):
+ """Test the --months parameter."""
+ output = self._get_command_output(
+ ["nikola", "future_posts", "--calendar", "--months", "2"]
+ )
+ month_headers = [
+ line
+ for line in output.split("\n")
+ if any(
+ month in line
+ for month in [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+ ]
+ )
+ ]
+ assert len(month_headers) == 2
diff --git a/v8/future_posts/LICENSE b/v8/future_posts/LICENSE
new file mode 100644
index 00000000..62bb2f14
--- /dev/null
+++ b/v8/future_posts/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Jesper Dramsch
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/v8/future_posts/README.md b/v8/future_posts/README.md
new file mode 100644
index 00000000..c40cf463
--- /dev/null
+++ b/v8/future_posts/README.md
@@ -0,0 +1,157 @@
+# Future Posts Plugin for Nikola
+
+A command plugin for [Nikola](https://getnikola.com) that helps you manage and visualize your scheduled future blog posts. Get an overview of upcoming content through ASCII calendars, detailed lists, JSON data, or HTML output.
+
+## Overview
+
+This plugin adds a `future_posts` command to Nikola that displays information about posts scheduled for future dates. It supports multiple output formats that can be used individually or combined.
+
+## Features
+
+- 📅 ASCII calendar view showing scheduled post dates
+- 📋 Detailed list view of upcoming posts
+- 🔄 JSON output for integration with other tools
+- 🌐 HTML output for web viewing
+- 📦 Modular design - mix and match output formats
+- 💾 Save output to file or display in terminal
+
+## Installation
+
+You can install this plugin using Nikola's plugin manager:
+
+```bash
+nikola plugin -i future_posts
+```
+
+Or manually:
+
+1. Create a `future_posts` directory in your Nikola site's plugins directory:
+
+ ```bash
+ mkdir -p plugins/future_posts
+ ```
+
+2. Copy `future_posts.plugin` and `future_posts.py` into the new directory
+
+3. Add "future_posts" to your `COMMANDS` list in `conf.py`:
+ ```python
+ COMMANDS.append('future_posts')
+ ```
+
+## Usage
+
+Basic usage:
+
+```bash
+nikola future_posts
+```
+
+### Output Formats
+
+You can combine any of these options:
+
+- `--calendar` (`-c`): Show ASCII calendar with marked post dates
+- `--details` (`-d`): Show detailed list of posts
+- `--json` (`-j`): Output in JSON format
+- `--html` (`-h`): Generate HTML output
+
+### Additional Options
+
+- `--months N` (`-m N`): Show N months in calendar view (defaults to all scheduled months + 1)
+- `--output FILE` (`-o FILE`): Save output to file instead of stdout
+
+### Examples
+
+Show calendar for scheduled posts:
+
+```bash
+nikola future_posts --calendar
+```
+
+Show both calendar and detailed list:
+
+```bash
+nikola future_posts --calendar --details
+```
+
+Generate JSON output:
+
+```bash
+nikola future_posts --json
+```
+
+Save HTML view to file:
+
+```bash
+nikola future_posts --html --output future-posts.html
+```
+
+Show calendar for next 6 months:
+
+```bash
+nikola future_posts --calendar --months 6
+```
+
+## Configuration
+
+Optional configuration settings can be added to your site's `conf.py`:
+
+```python
+# Default number of months to show in calendar view
+FUTURE_POSTS_DEFAULT_MONTHS = 3
+
+# Default output format
+FUTURE_POSTS_DEFAULT_FORMAT = "details"
+
+# Default output file path
+FUTURE_POSTS_DEFAULT_OUTPUT = None
+```
+
+## Sample Output
+
+### Calendar View
+
+```
+ February 2025
+Mo Tu We Th Fr Sa Su
+ 1 2 3 4
+ 5 6 7 8 9 ## 11
+12 13 14 15 16 17 18
+19 20 21 22 23 24 ##
+26 27 28
+
+## indicates days with scheduled posts
+```
+
+### Detailed View
+
+```
+Found 2 future posts:
+----------------------------------------
+Title: My First Future Post
+Date: 2025-02-10
+Source: posts/first-post.rst
+Permalink: /blog/2025/02/first-post/
+----------------------------------------
+```
+
+## Contributing
+
+Contributions are welcome! Please feel free to:
+
+1. 🐛 Report bugs
+2. ✨ Suggest new features
+3. 📝 Improve documentation
+4. 🔧 Submit pull requests
+
+## License
+
+This project is licensed under the MIT License - see the LICENSE file for details.
+
+## Author
+
+Jesper Dramsch
+
+## Support
+
+If you find any issues or have questions, please file an issue on the [Nikola Plugins repository](https://github.com/getnikola/plugins).
diff --git a/v8/future_posts/conf.py.sample b/v8/future_posts/conf.py.sample
new file mode 100644
index 00000000..fc9ae010
--- /dev/null
+++ b/v8/future_posts/conf.py.sample
@@ -0,0 +1,11 @@
+# Options for the future_posts plugin
+# No configuration is required by default, but you can customize these settings
+
+# Default number of months to show in calendar view
+# FUTURE_POSTS_DEFAULT_MONTHS = 3
+
+# Default output format (calendar, details, json, or html)
+# FUTURE_POSTS_DEFAULT_FORMAT = "details"
+
+# Default output file path (if not specified, outputs to stdout)
+# FUTURE_POSTS_DEFAULT_OUTPUT = None
diff --git a/v8/future_posts/future_posts.plugin b/v8/future_posts/future_posts.plugin
new file mode 100644
index 00000000..109fcacf
--- /dev/null
+++ b/v8/future_posts/future_posts.plugin
@@ -0,0 +1,13 @@
+[Core]
+Name = future_posts
+Module = future_posts
+
+[Nikola]
+MinVersion = 8.0.0
+PluginCategory = Command
+
+[Documentation]
+Author = Jesper Dramsch
+Version = 1.0.0
+Website = https://plugins.getnikola.com/#future_posts
+Description = A command to visualize and manage scheduled future blog posts
diff --git a/v8/future_posts/future_posts.py b/v8/future_posts/future_posts.py
new file mode 100644
index 00000000..04f7a3d2
--- /dev/null
+++ b/v8/future_posts/future_posts.py
@@ -0,0 +1,235 @@
+from datetime import datetime, timedelta
+import calendar
+from nikola.plugin_categories import Command
+import json
+
+
+class CommandFuturePosts(Command):
+ """List all future scheduled posts with configurable output formats."""
+
+ name = "future_posts"
+ doc_usage = (
+ "[--json] [--calendar] [--details] [--html] [--months N] [--output FILE]"
+ )
+ doc_purpose = "List scheduled future posts in various formats"
+ cmd_options = [
+ {
+ "name": "json",
+ "short": "j",
+ "long": "json",
+ "type": bool,
+ "default": False,
+ "help": "Output in JSON format",
+ },
+ {
+ "name": "calendar",
+ "short": "c",
+ "long": "calendar",
+ "type": bool,
+ "default": False,
+ "help": "Show ASCII calendar view",
+ },
+ {
+ "name": "details",
+ "short": "d",
+ "long": "details",
+ "type": bool,
+ "default": False,
+ "help": "Show detailed list view",
+ },
+ {
+ "name": "html",
+ "short": "h",
+ "long": "html",
+ "type": bool,
+ "default": False,
+ "help": "Output as HTML",
+ },
+ {
+ "name": "months",
+ "short": "m",
+ "long": "months",
+ "type": int,
+ "default": None,
+ "help": "Number of months to show in calendar (default: from config or all scheduled months)",
+ },
+ {
+ "name": "output",
+ "short": "o",
+ "long": "output",
+ "type": str,
+ "default": None,
+ "help": "Output file (default: from config or stdout)",
+ },
+ ]
+
+ def _get_future_posts(self):
+ """Get all future posts sorted by date."""
+ current_date = datetime.now()
+ future_posts = []
+
+ for post in self.site.timeline:
+ post_date = datetime.fromtimestamp(post.date.timestamp())
+ if post_date > current_date:
+ post_info = {
+ "title": post.title(),
+ "date": post_date.strftime("%Y-%m-%d"),
+ "permalink": post.permalink(),
+ "source_path": post.source_path,
+ }
+ future_posts.append(post_info)
+
+ return sorted(future_posts, key=lambda x: x["date"])
+
+ def _format_calendar(self, future_posts, months_ahead=None):
+ """Generate ASCII calendar with marked posts."""
+ output = []
+ start_date = datetime.now().date()
+ post_dates = {
+ datetime.strptime(post["date"], "%Y-%m-%d").date() for post in future_posts
+ }
+
+ last_post_date = datetime.strptime(future_posts[-1]["date"], "%Y-%m-%d")
+ if months_ahead is None:
+ months_ahead = (last_post_date.year - start_date.year) * 12 + last_post_date.month - start_date.month + 2
+
+ for month_offset in range(months_ahead):
+ target_date = start_date + timedelta(days=32 * month_offset)
+ year = target_date.year
+ month = target_date.month
+
+ cal = calendar.monthcalendar(year, month)
+ month_name = calendar.month_name[month]
+
+ output.append(f"\n{month_name} {year}".center(20))
+ output.append("Mo Tu We Th Fr Sa Su")
+
+ for week in cal:
+ week_str = ""
+ for day in week:
+ if day == 0:
+ week_str += " "
+ else:
+ date = datetime(year, month, day).date()
+ if date in post_dates:
+ week_str += "## "
+ else:
+ week_str += f"{day:2d} "
+ output.append(week_str)
+
+ output.append("\n## indicates days with scheduled posts")
+ return "\n".join(output)
+
+ def _format_details(self, future_posts):
+ """Generate detailed text listing."""
+ output = [f"\nFound {len(future_posts)} future posts:", "-" * 40]
+
+ for post in future_posts:
+ output.extend(
+ [
+ f"Title: {post['title']}",
+ f"Date: {post['date']}",
+ f"Source: {post['source_path']}",
+ f"Permalink: {post['permalink']}",
+ "-" * 40,
+ ]
+ )
+
+ return "\n".join(output)
+
+ def _format_html(self, future_posts, include_calendar=True):
+ """Generate HTML output."""
+ html = [
+ "",
+ "",
+ "",
+ "Future Posts",
+ "",
+ "",
+ "",
+ f"Future Posts ({len(future_posts)})
",
+ ]
+
+ if include_calendar:
+ html.append('')
+ html.append(self._format_calendar(future_posts).replace("\n", "
"))
+ html.append("
")
+
+ html.append('')
+ for post in future_posts:
+ html.extend(
+ [
+ '
',
+ f'
',
+ f'
Date: {post["date"]}
',
+ f'
Source: {post["source_path"]}
',
+ "
",
+ ]
+ )
+ html.extend(["
", "", ""])
+
+ return "\n".join(html)
+
+ def _execute(self, options, args):
+ """Execute the future posts command."""
+ self.site.scan_posts()
+
+ # Get configuration values
+ config_months = self.site.config.get("FUTURE_POSTS_DEFAULT_MONTHS", None)
+ config_format = self.site.config.get("FUTURE_POSTS_DEFAULT_FORMAT", "details")
+ config_output = self.site.config.get("FUTURE_POSTS_DEFAULT_OUTPUT", None)
+
+ # Apply config values if command-line options aren't set
+ if options["months"] is None:
+ options["months"] = config_months
+
+ if options["output"] is None:
+ options["output"] = config_output
+
+ if not any(
+ [options["json"], options["calendar"], options["details"], options["html"]]
+ ):
+ # Use config format if no format specified
+ if config_format == "json":
+ options["json"] = True
+ elif config_format == "calendar":
+ options["calendar"] = True
+ elif config_format == "html":
+ options["html"] = True
+ else: # default to details
+ options["details"] = True
+
+ future_posts = self._get_future_posts()
+
+ if not future_posts:
+ print("No future posts scheduled.")
+ return
+
+ # Prepare output
+ output = []
+
+ # Generate requested formats
+ if options["json"]:
+ output.append(json.dumps(future_posts, indent=2))
+
+ if options["calendar"]:
+ output.append(self._format_calendar(future_posts, options["months"]))
+
+ if options["details"]:
+ output.append(self._format_details(future_posts))
+
+ if options["html"]:
+ output = [self._format_html(future_posts, options["calendar"])]
+
+ # Output to file or stdout
+ result = "\n\n".join(output)
+ if options["output"]:
+ with open(options["output"], "w") as f:
+ f.write(result)
+ else:
+ print(result)