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'

{post["title"]}

', + 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)