From 9f56b145066ffbe7f60efee47dc29320e8023bc8 Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sat, 30 May 2026 08:45:41 -0700
Subject: [PATCH 01/13] Update script.json with new Patreon link and details
---
Director/script.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/Director/script.json b/Director/script.json
index 80601fb43..dae562689 100644
--- a/Director/script.json
+++ b/Director/script.json
@@ -4,6 +4,7 @@
"version": "1.0.8",
"description": "# Director\n\n**Director** is a script for supporting \"theater of the mind\"-style play in Roll20. It provides an interface for managing scenes, images, audio, and game assets — all organized within a persistent handout.\n\n---\n\n## Interface Overview\n\nThe interface appears in a Roll20 handout and consists of four main sections:\n\n- **Acts & Scenes** — scene navigation and management \n- **Images** — backdrops, highlights, and associated tracks \n- **Items** — characters, variants, macros, and token-linked objects \n- **Utility Controls** — edit mode, help toggle, settings, backup tools \n\n---\n\n## Acts & Scenes\n\n### Act Controls\n\nActs group together related scenes. Use the `+ Add Act` button to create one.\n\nIn **Edit Mode**, you can:\n- Rename or delete acts\n- Move acts up or down\n\n### Scene Controls\n\nEach scene represents a distinct moment or location. Click a scene name to set it active — this controls what images and items are shown.\n\nIn **Edit Mode**, you can:\n- Rename or delete scenes\n- Move scenes up or down (scenes moved beyond an act will join the next expanded act)\n\n---\n\n## Images\n\n### Backdrop vs. Highlight\n\n- **Backdrop**: Main background image placed on the Map Layer \n- **Highlights**: Visuals layered above the backdrop on the Object Layer (for focus or emphasis) \n\nWhen a scene is set:\n- The backdrop is placed on the map\n- All highlights appear just off the left edge of the page\n\nHighlights can be dragged manually, or previewed using `Shift+Z`.\n\n### Adding Images\n\n1. Drag a graphic to the tabletop (hold `Alt`/`Option` to preserve aspect ratio) \n2. Select the graphic and click `+ Add Image` in the interface\n\n### Image Controls\n\n- **Title**: Click to rename \n- **Bottom-right icons**: \n - `expanding arrows icon` = Set as Backdrop \n - `overlapping rectangles icon` = Set as Highlight \n - `music note icon` = Assign currently playing track. This track will auto play whenever the image becomes a backdrop image.\n- In **Edit Mode**: \n - Move an image up or down. Although the backdrop image always goes to the top\n - Recapture\n - Delete\n\n### Mute Button\n\nToggles automatic track playback. When red, backdrops will no longer auto-start audio.\n\n---\n\n## Items (Characters, Variants, Tracks, Macros, Tables)\n\nItems define what gets placed or triggered when a scene is set. Items are scoped per scene.\n\n### Adding Items\n\nClick a badge to add a new item:\n- `H` = Handout \n- `C` = Character \n- `V` = Variant \n- `T` = Track \n- `M` = Macro \n- `R` = Rollable Table \n\n### Item Behavior\n\n| Badge | Type | Behavior |\n|-------|------------|--------------------------------------------------------------------------|\n| `H` | Handout | Opens the handout |\n| `C` | Character | Opens the sheet if assigned; otherwise prompts for assignment |\n| `V` | Variant | Places token on scene set (does not open a sheet) |\n| `T` | Track | Toggles playback; assigns current track if none assigned |\n| `M` | Macro | Runs macro if assigned; otherwise prompts to choose an existing macro |\n| `R` | Table | Rolls the assigned table; result whispered to GM |\n\n> Variants are token snapshots that share a character sheet. Use them to represent alternate versions of a character or avoid issues with default token behavior.\n\n### Edit Mode Controls\n\nWhile in **Edit Mode**, each item displays:\n- `pencil icon` — Reassign\n- `trash icon` — Delete\n\nYou can also click the `magnifying glass icon` icon to filter items by type.\n\n---\n\n## Header Buttons\n\n### Set Scene as:\n\n**Scene** places the following on the tabletop:\n\n- Backdrop image (Map Layer) \n- Highlight images (Object Layer, left-aligned off page edge) \n- Character and variant tokens (Object Layer, right-aligned off page edge) \n- Starts assigned track (if set)\n\n**Grid** places the following on the tabletop:\n\n- Up to six images, arranged in a grid (Map Layer) \n- Surrounds each image with dynamic lighting barriers and turns on dynamic lighting with Daylight Mode \n- Top strip of the page is reserved (for holding player tokens) \n- Character and variant tokens (Object Layer, right-aligned off page edge)\n\n> _Only works if the current page name contains:_ `scene`, `stage`, `theater`, or `theatre`\n\n### Wipe the Scene\n\n`Wipe the Scene` removes all images and stops all audio.\n\n> Only works on valid stage pages.\n\n### Edit Mode\n\nToggles editing. When enabled:\n- Rename, delete, and move controls appear for acts, scenes, and images\n- Items display grouped by type with assign/delete icons\n\n### JB+\n\nIf Jukebox Plus is installed, this button appears and provides a chat link to launch its controls.\n\n### Help\n\nDisplays this help interface. While in help mode, this changes to \"Exit Help\".\n\n### Make Help Handout\n\nCreates a handout containing the help documentation. Use it to reference instructions while working in the main interface.\n\n---\n\n## Helpful Macros\n\nThese commands can be used in the chat or bound to macro/action buttons:\n\n`!director --set-scene`\n\n`!director --wipe-scene`\n\n`!director --new-act|Act I`\n\n`!director --new-scene|Act I|Opening Scene`\n\n`!director --capture-image`", "authors": "Keith Curtis",
"roll20userid": "162065",
+ "patreon": "https://www.patreon.com/c/KeithCurtis",
"dependencies": [],
"modifies": {
"graphic": "read, write",
From 26f7ef5902adff6420b44af36485f55a1f546785 Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sat, 30 May 2026 08:53:06 -0700
Subject: [PATCH 02/13] Added Chronicle script
---
Chronicle/1.0.0/Chronicle.js | 6544 ++++++++++++++++++++++++++++++++++
Chronicle/Chronicle.js | 6544 ++++++++++++++++++++++++++++++++++
Chronicle/readme.md | 43 +
Chronicle/script.json | 15 +
4 files changed, 13146 insertions(+)
create mode 100644 Chronicle/1.0.0/Chronicle.js
create mode 100644 Chronicle/Chronicle.js
create mode 100644 Chronicle/readme.md
create mode 100644 Chronicle/script.json
diff --git a/Chronicle/1.0.0/Chronicle.js b/Chronicle/1.0.0/Chronicle.js
new file mode 100644
index 000000000..5ac428a77
--- /dev/null
+++ b/Chronicle/1.0.0/Chronicle.js
@@ -0,0 +1,6544 @@
+// Script: Chronicle
+// By: Keith Curtis
+// Contact: https://app.roll20.net/users/162065/keithcurtis
+// Changelog
+// 0.1.0 Initial framework and data structures
+
+const Chronicle = (() => {
+ 'use strict';
+
+ // ==================================================
+ // Config
+ // ==================================================
+
+ const scriptName = 'Chronicle';
+ const version = '0.1.0';
+ const lastUpdate = Math.floor(Date.now() / 1000);
+ const schemaVersion = 0.1;
+
+ const DEBUG = true;
+ const LOGGING = false;
+
+ const HANDOUT_PREFIX = 'Chronicle';
+ const CHRONICLE_HELP_NAME = "Help: Chronicle";
+ const CHRONICLE_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147";
+ const CHRONICLE_HELP_TEXT = `
+
+
+
Chronicle Calendar System
+
+
Chronicle is a comprehensive calendar system for managing custom calendars, tracking events and notes, generating weather, and organizing campaign time.
+
+
Getting Started
+
+
Initial Setup
+
Command: !chr
+
Run this command to initialize Chronicle. This creates the main interface handout and opens the Design mode where you can configure your calendar.
+
+
Interface Modes
+
+- Calendar Mode: Default mode showing the calendar grid, featured date details, and navigation controls.
+- Design Mode: Configuration interface for setting up calendar structure, holidays, special days, moons, and weather.
+- Timeline Mode: Chronological view of all events, notes, and holidays with filtering and tagging.
+
+
+
Calendar Configuration (Design Mode)
+
+
Basic Calendar Structure
+
Click "Design" in the main interface to access configuration options.
+
+
Calendar Name
+
Chronicle ships with configuration for Gregorian and Harptos (the calendar used by the Forgotten Realms). Choose one of these, or create you own bly clicking New Calendar, and then set the name for your calendar system (e.g., "Mystara", "Exandrian"). The resto of these design instructions will assume that you are creating your own custom calendar. Otherwise, that is all you need to do.
+
+
Calendar Description
+
Add context about your calendar system that appears in Design mode. This is useful for explaining the calendar's structure, cultural significance, or special properties to players. Preset calendars (Gregorian, Absalom Reckoning, Faerun, Greyhawk, Eberron) include detailed descriptions that you can edit or replace with your own text.
+
+
Months
+
Define each month with:
+
+- Name: Month name (e.g., "Hammer", "January")
+- Days: Number of days in the month
+- Order: Position in the year (automatically managed)
+
+
Use the up/down arrows to reorder months. Delete unwanted months with the Delete button.
+
+
Weeks
+
Configure the weekly structure:
+
+- Days in Week: Number of days per week (typically 7)
+- Weekday Names: Names for each day (e.g., "Monday", "Tuesday")
+
+
+
Holidays
+
Add recurring or one-time holidays:
+
+- Name: Holiday name
+- Month/Day: Date of occurrence
+- Recurring: Whether it repeats annually
+- Description: Optional details about the holiday
+
+
Holidays appear in red text throughout the calendar. Click a holiday name to view its description privately (whisper) or announce it publicly to all players.
+
+
Special Days
+
Special days are intercalary days (like Midsummer) or leap days (like Shieldmeet) that fall outside the normal month/week structure.
+
+
Types
+
+- Fixed Special Days: Occur every year at the same position
+- Leap Special Days: Occur periodically (e.g., every 4 years)
+
+
+
Configuration
+
+- Name: Special day name
+- Position: After which month and day it occurs (e.g., "After Flamerule 30" for Midsummer)
+- Week Behavior:
+
+ - Part of week: Counts as a regular weekday
+ - Between weeks: Breaks the week cycle, appears as a separate row in the calendar grid
+
+
+- Frequency (Leap only): How often it occurs (e.g., 4 = every 4 years)
+- Offset (Leap only): Year offset for calculation (typically 0)
+- Description: Optional details about the special day
+
+
+
Moons
+
Add celestial bodies with lunar cycles that display on your calendar:
+
+- Name: Moon name (e.g., "Selûne")
+- Period: Days per complete cycle (supports decimals)
+- Full Moon Reference: A known date when the moon was full (used to calculate phases)
+- Size: Display size multiplier (0.1 to 1.0, where 1.0 is full size)
+- Color: Choose from 12 tint options: yellow, red, green, blue, cyan, orange, purple, tan, brown, white, gray, or dark
+- Display: Toggle whether moon appears on calendar grid
+
+
Moon Phases: Phases are calculated automatically based on your reference date and display on the calendar grid in the Featured Date section. Each moon shows its correct phase (new, waxing, full, waning) for the selected date.
+
Visibility: When multiple moons are visible, hover over any moon to see its name. Single-moon calendars have no tooltip to reduce clutter.
+
Sprite System: Moons use a sprite sheet system ensuring compatibility with Roll20's handout system. The system handles all 8 lunar phases with full color support.
+
+
Weather System
+
Enable procedural weather generation based on climate zones.
+
+
Setup
+
+- Click "Set Climate" in Design mode
+- The script will guide you through a series of prompts to configure your climate settings, according to a simplified Köppen climate classification.
+- Select temperature units (Fahrenheit or Celsius)
+
+
+
Generating Weather
+
Click "Generate Weather" in the Featured Date section to create weather for the current date. Generated weather persists and appears automatically when viewing that date.
+
+
Calendar Mode
+
+
Date Navigation Controls
+
+- ◀◀◀: Previous year
+- ◀◀: Previous month
+- ◀: Previous day
+- Year/Month/Day buttons: Jump to specific date via dropdown
+- ▶: Next day
+- ▶▶: Next month
+- ▶▶▶: Next year
+
+
+
Featured Date vs Today
+
+- Featured Date: The date currently displayed and selected for viewing/editing
+- Today: A saved bookmark representing the "current" campaign date
+- Go to Today: Navigate to the Today bookmark
+- Set Today: Save the current Featured Date as the new Today bookmark
+
+
+
Calendar Grid
+
Click any date in the calendar grid to set it as the Featured Date. Dates show:
+
+- Day number
+- Holidays (in red text)
+- Special days (full-width rows for "between weeks" types, or inline in red for "part of week" types)
+- Events/notes (first 40 characters displayed)
+
+
+
Events and Notes
+
+
Adding Events/Notes
+
In the Featured Date section, use the "Add Event" or "Add Note" buttons. Enter the content when prompted.
+
+
Difference Between Events and Notes
+
+- Events: Broad campaign events, usually with no specific date (war, plague, political upheaval)
+- Notes: Specific campaign events that occur on a given day. (Player actions, party actions, npc actions)
+
+
The distinction is organizational; both function identically and can be converted between types. In general, use Events for world historuical events, and Notes for campaign adventure tracking.
+
+
Managing Events/Notes
+
Each event/note has action buttons:
+
+- Edit: Modify the content
+- Delete: Remove permanently
+- ↔: Convert between event and note
+- Move: Relocate to a different date
+- +Tag: Add organizational tags
+
+
+
Tags
+
Tags are labels for organizing and filtering events/notes. Add multiple tags to categorize entries (e.g., "dungeon", "drow", "party", "Waterdeep").
+
+
Tags appear as clickable buttons in the timeline detailed view. Click a tag to remove it.
+
+- +Tag button: Opens a dropdown menu showing all existing tags in your campaign. Select a tag to instantly add it to that event/note. If no tags exist yet, you can type a new one.
+- [Untagged] filter: In Timeline mode, click [Untagged] to filter and show only items without any tags, making it easy to find and tag uncategorized items.
+
+
+
Tag Filtering: In Timeline mode, click any tag in the tag cloud to filter by that tag. Use the tag mode buttons to switch between showing items with ANY of the selected tags (OR) or ALL of the selected tags (AND).
+
+
Currently, each note or event is also appeneded with the name of the person who made it. At this moment, Chronicle is purley for GM use, but some campaigns may have multiple GMs. In that case, this feature allows you to track which GM added which information.
+
+
Send to Chat
+
+
Click "Send to Chat" to broadcast the current Featured Date information to all players. This includes:
+
+- Date and weekday
+- Moon phases
+- Weather (if generated)
+- Holidays and special days (clickable to announce descriptions)
+- Events and notes for that date
+
+
+
Timeline Mode
+
+
Accessing Timeline
+
Click "Timeline" in the main interface to view all events and notes chronologically.
+
+
Timeline Features
+
+
Date Range Selection
+
Set start and end dates to filter the timeline view. Year span determines detail level:
+
+- 1 year or less: Day-by-day view with all details
+- 1-5 years: Month-by-month summary
+- 5+ years: Yearly summary
+
+
By Default, events list at the beginning of the year they are in, and do not display a calendar date. Events are broad happenings. Notes are specific to a date, and display with their calendar date. You can also display events in ascending or descending chronological order.
+
+
Type Filters
+
+- Events: Toggle event visibility
+- Notes: Toggle note visibility
+- Holidays: Toggle holiday visibility (only shown for spans ≤1 year)
+- Weather: Toggle weather visibility (only shown for spans ≤1 year)
+- Tags: Filter by specific tags (same click behavior as main interface)
+- Untagged: Show only items with no tags attached. Works independently or combined with tag filters.
+
+
+
Show/Hide Details
+
Toggle this to display editing buttons and tag information for each entry. In detailed mode, each event and note displays:
+
+- Edit/Delete buttons: Modify or remove the item
+- +Tag button: Select from existing tags to add to this item
+- Tags: Clickable tags showing which categories apply
+- Elapsed Time: Time span from the currently viewing date to this item's date, in shorthand format (e.g., "2y.3m.15d", "-1m.2d")
+
+
+
Elapsed Time Display
+
Small buttons floating right on each item show time elapsed from your current viewing date:
+
+- Format: #y.#m.#d (e.g., "5y", "2y.3m.15d", "-18d")
+- Smart Display: Years only shown if span > 1 year, months only if span > 1 month
+- Negative Values: Minus sign indicates items before the viewing date
+- First of Year: Events on the first day of the year show only years (no months/days)
+- Clicking the button: Sets the viewing date to that item without switching modes, useful for exploring time relationships
+
+
+
Tag Mode
+
+- Any (OR): Includes any item with any of the selected tags.
+- All (AND): Includes only items with all of the selected tags.
+
+
+
Tags
+
Shows a tag cloud of all existing tags. Click tags to filter, or click [Untagged] to show items without tags.
+
+
Date Navigation
+
Clicking on a date in the timeline automatically switches to Calendar mode and displays that date. This lets you jump between timeline and calendar views easily.
+
+
+
+
Tips and Best Practices
+
+
Calendar Design
+
+- Start with basic structure (months, weeks) before adding holidays and special days
+- Test special days by navigating to their dates to ensure they appear correctly
+
+
+
Event/Note Organization
+
+- Develop a consistent tagging system early (e.g., "bruenor", "waterdeep", "adventure")
+- Use notes for tracking campaign events ("PCs Enter Lankhmar", "Frodo contract Mummy Rot")
+- Use events for historical events (battles, treaties, cataclysms)
+
+
+
Weather
+
+- Generate weather as needed rather than pre-generating for long periods
+- Weather persists once generated, so you can reference it later
+- Choose climate settings that match your campaign setting, and the generated weather should be believable
+
+
+
Timeline Usage
+
+- Use Timeline mode for campaign review and planning
+- Filter by tags to track specific storylines or NPCs
+- Start a session day by using "Send to Chat" feature for all players. This will inform them of date, weather, and holidays, if any, as well as show them where they are within the week.
+
+
+
Commands Reference
+
+
!chr - Initialize/open Chronicle interface
+
All other functions are accessed through the interactive interface buttons rather than direct commands.
+
+
Data Storage
+
+
Chronicle stores all data in Roll20 handouts:
+
+- Chronicle: [Campaign Name] - Calendar configuration (months, weeks, holidays, special days, moons, climate)
+- Chronicle Events: [Campaign Name] - Events, notes, and weather data
+- Chronicle Interface - Main interface handout
+- Help: Chronicle - This help documentation
+
+
+
These handouts are automatically created and updated. Do not manually edit their GM Notes section, as this may corrupt your calendar data.
+
+
+ `;
+
+ const INTERFACE_HANDOUT_NAME = 'Chronicle';
+
+ // ==================================================
+ // CSS (Centralized Styles)
+ // ==================================================
+
+ const cssDark = {
+ button: 'display: inline-block; padding: 4px 8px; margin: 2px; background: #5a9fd4; color: #111111; border: 1px solid #555555; border-radius: 3px; font-weight:bold; text-decoration: none; cursor: pointer; font-size: 11px;',
+ buttonSmall: 'display: inline-block; padding: 2px 5px; margin: 1px; background: #5a9fd4; color: #111111; border: 1px solid #555555; border-radius: 2px; font-weight:bold; text-decoration: none; cursor: pointer; font-size: 9px;',
+ creator: 'display: inline-block; padding: 2px 6px; margin: 0 3px; background: #3a3a3a; color: #aaaaaa; border-radius: 20px; font-size: 9px; font-weight: bold;',
+ tagButton: 'display: inline-block; padding: 2px 5px; margin: 0 1px; background: #2a2a2a; color: #cccccc; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px;font-weight: bold;',
+ tag: 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #2d2d2d; color: #bbbbbb; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px;font-weight: bold;',
+ holiday: 'color: #dd5555; font-weight: bold;',
+ container: 'background: #1a1a1a; color: #eeeeee; padding: 10px; border: 1px solid #555555; border-radius: 5px; font-family: "Helvetica Neue", Arial, sans-serif; margin: -30px;',
+ chatOutput: 'background: #4a4a4a; color: #eeeeee; padding: 8px 12px; border-left: 6px solid #6b8cae; border-top: 1px solid #6b8cae; border-right: 1px solid #6b8cae; border-bottom: 1px solid #6b8cae; border-radius: 3px; font-family: "Helvetica Neue", Arial, sans-serif; font-size: 13px; margin: 2px 0;',
+ header: 'background: #2d2d2d; color: #eeeeee; padding: 10px; margin: -10px -10px 10px -10px; border-bottom: 2px solid #555555; font-weight: bold; font-size: 16px;',
+ table: 'width: 100%; border-collapse: collapse; margin: 10px 0;',
+ tableCell: 'border: 1px solid #555555; padding: 5px; text-align: center; color: #eeeeee; vertical-align: top;',
+ calendarDay: 'width: 14.28%; height: 60px; vertical-align: top; border: 1px solid #555555; padding: 2px; position: relative; cursor: pointer; background: #2d2d2d; color: #eeeeee;',
+ calendarDayOtherMonth: 'width: 14.28%; height: 60px; vertical-align: top; border: 1px solid #555555; padding: 2px; position: relative; opacity: 0.5; cursor: pointer; background: #2d2d2d; color: #eeeeee;',
+ calendarDayToday: 'width: 14.28%; height: 60px; vertical-align: top; border: 3px solid #5a9fd4; padding: 2px; position: relative; background: #3a3a3a; cursor: pointer; font-weight: bold; color: #eeeeee;',
+ emojiCircle: 'background: #1a1a1a; border: 1px solid #555; border-radius: 50%; max-width: 32px; max-height: 32px; display: flex; align-items: center; justify-content: center; font-size: 24px; margin: 1px; float: right; line-height: 1;',
+ link: 'color: #5a9fd4; text-decoration: none;'
+ };
+
+ const lightModeOverrides = {
+ button: { background: '#4a7ac2', color: '#eeeeee', border: '1px solid #999999' },
+ buttonSmall: { background: '#4a7ac2', color: '#eeeeee', border: '1px solid #999999' },
+ creator: { background: '#e0e0e0', color: '#222222'},
+ tagButton: { background: '#cccccc', color: '#333333'},
+ tag: { background: '#cccccc', color: '#777777'},
+ holiday: { color: '#cc3333' },
+ container: { background: '#eeeeee', color: '#111111', border: '1px solid #cccccc' },
+ chatOutput: { background: '#dddddd', color: '#111111', 'border-left': '6px solid #4a7ac2', 'border-top': '1px solid #4a7ac2', 'border-right': '1px solid #4a7ac2', 'border-bottom': '1px solid #4a7ac2' },
+ header: { background: '#f5f5f5', color: '#111111', border: '2px solid #cccccc' },
+ tableCell: { border: '1px solid #cccccc', color: '#111111' },
+ calendarDay: { background: '#eeeeee', color: '#111111', border: '1px solid #cccccc' },
+ calendarDayOtherMonth: { background: '#eeeeee', color: '#111111', border: '1px solid #cccccc' },
+ calendarDayToday: { background: '#d8d8d8', color: '#111111', border: '3px solid #4a7ac2' },
+ emojiCircle: { background: '#333333', border: '1px solid #999' },
+ link: { color: '#2a5a9a' }
+ };
+
+ const fantasyModeOverrides = {
+ button: { background: '#8b4513', color: '#f4e8d0', border: '1px solid #5a3820' },
+ buttonSmall: { background: '#8b4513', color: '#f4e8d0', border: '1px solid #5a3820' },
+ creator: { background: '#d4c0a0', color: '#5a3820' },
+ tagButton: { background: '#c4b090', color: '#6b4820' },
+ tag: { background: '#cbb8a0', color: '#7b5830' },
+ holiday: { color: '#cc4444' },
+ container: { background: '#f4e8d0', color: '#2c1810', border: '1px solid #8b6f47' },
+ chatOutput: { background: '#e8d4b0', color: '#2c1810', 'border-left': '6px solid #8b4513', 'border-top': '1px solid #8b4513', 'border-right': '1px solid #8b4513', 'border-bottom': '1px solid #8b4513' },
+ header: { background: '#e8d4b0', color: '#2c1810', border: '2px solid #8b6f47' },
+ tableCell: { border: '1px solid #8b6f47', color: '#2c1810' },
+ calendarDay: { background: '#f4e8d0', color: '#2c1810', border: '1px solid #8b6f47' },
+ calendarDayOtherMonth: { background: '#f4e8d0', color: '#2c1810', border: '1px solid #8b6f47' },
+ calendarDayToday: { background: '#d4c0a0', color: '#2c1810', border: '3px solid #8b4513' },
+ emojiCircle: { background: '#5a3820', border: '1px solid #8b6f47' },
+ link: { color: '#6b3410' }
+ };
+
+ const generateThemedCSS = (baseCSS, overrides) => {
+ const result = {};
+
+ const replaceColors = (styleStr, override) => {
+ if (!override) return styleStr;
+
+ const props = styleStr.split(';').map(p => p.trim()).filter(Boolean);
+ const mapped = {};
+
+ props.forEach(p => {
+ const [key, value] = p.split(':').map(s => s.trim());
+ mapped[key] = value;
+ });
+
+ if (override.color) mapped.color = override.color;
+ if (override.background) mapped.background = override.background;
+ if (override.border) {
+ const sides = ['border', 'border-top', 'border-right', 'border-bottom', 'border-left'];
+ const borderKey = sides.find(k => Object.keys(mapped).includes(k)) || 'border';
+ mapped[borderKey] = override.border;
+ }
+
+ // Handle individual border properties
+ if (override.borderLeft) mapped['border-left'] = override.borderLeft;
+ if (override.borderTop) mapped['border-top'] = override.borderTop;
+ if (override.borderRight) mapped['border-right'] = override.borderRight;
+ if (override.borderBottom) mapped['border-bottom'] = override.borderBottom;
+
+ return Object.entries(mapped).map(([k, v]) => `${k}:${v}`).join('; ') + ';';
+ };
+
+ for (const key in baseCSS) {
+ const override = overrides[key];
+ result[key] = replaceColors(baseCSS[key], override);
+ }
+
+ return result;
+ };
+
+ const cssLight = generateThemedCSS(cssDark, lightModeOverrides);
+ const cssFantasy = generateThemedCSS(cssDark, fantasyModeOverrides);
+
+ const getCSS = () => {
+ const theme = State.config().theme;
+ if (theme === 'light') return cssLight;
+ if (theme === 'fantasy') return cssFantasy;
+ return cssDark;
+ };
+
+ // Legacy reference for backward compatibility
+ const CSS = cssDark;
+
+ // ==================================================
+ // Utilities
+ // ==================================================
+
+ const Utils = {
+
+ stripGM: (who) => {
+ // Remove " (GM)" suffix if present
+ return who.replace(/ \(GM\)$/, '');
+ },
+
+ parseTags: (tagString) => {
+ if (!tagString || tagString.trim() === '') return [];
+ return tagString.split(',')
+ .map(t => t.trim().toLowerCase())
+ .filter(t => t.length > 0);
+ }
+
+ };
+
+ // ==================================================
+ // Logger
+ // ==================================================
+
+ const Logger = {
+ log: (msg) => {
+ if (LOGGING) log(`${scriptName} | ${msg}`);
+ },
+ debug: (msg) => {
+ if (DEBUG) log(`${scriptName} [DEBUG] | ${msg}`);
+ },
+ error: (msg) => log(`${scriptName} [ERROR] | ${msg}`)
+ };
+
+ // ==================================================
+ // Data Models
+ // ==================================================
+
+ const DataModels = {
+
+ // Calendar structure
+ createCalendar: (name = 'New Calendar') => ({
+ name: name,
+ description: '', // Description of the calendar system
+ daysInYear: 365,
+ months: [],
+ interMonthDays: [], // Special days between months
+ weeks: {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ weekNames: [], // Optional week names
+ displayWeekNames: false,
+ canSpanMonths: true
+ },
+ leapYears: {
+ enabled: false,
+ cycle: 4, // Every N years
+ exceptions: [] // Years that don't follow the pattern
+ },
+ seasons: {
+ vernalEquinox: 1, // Day of year
+ // Other points calculated at even intervals
+ },
+ holidays: [], // Recurring holidays tied to specific dates
+ climate: null, // Current climate settings
+ units: 'us' // 'us' or 'metric'
+ }),
+
+ createMonth: (name, days, order) => ({
+ name: name,
+ days: days,
+ order: order // Position in year (0-indexed)
+ }),
+
+ createInterMonthDay: (name, position, breaksWeekCycle, dayType, frequency, offset, description) => ({
+ id: `special_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ position: position, // {afterMonth: X, afterDay: Y} - position in calendar
+ breaksWeekCycle: breaksWeekCycle, // true = between weeks, false = part of week
+ dayType: dayType || 'fixed', // 'fixed' or 'leap'
+ frequency: frequency || null, // for leap days (e.g., 4 = every 4 years)
+ offset: offset || 0, // for leap days (year % frequency === offset)
+ description: description || ''
+ }),
+
+ createMoon: (name, period, fullDayRef, size = 1, color = 'yellow', display = true) => ({
+ id: `moon_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ period: period, // Days per cycle (supports decimals)
+ fullDayRef: fullDayRef, // {year, month, day} when this moon was full
+ size: size, // Display size 0.1-1 (default 1)
+ color: color, // Illuminated portion color (default '#f7d79c')
+ display: display // Whether to show on calendar grid (default true)
+ }),
+
+ createHoliday: (name, dateRef, recurring, description) => ({
+ id: `holiday_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ dateRef: dateRef, // {month, day} or {month, week, weekday} for relative dates
+ recurring: recurring, // true for annual
+ type: 'absolute', // or 'relative'
+ description: description || ''
+ }),
+
+ createSpecialDay: (name, position, dayType, weekBehavior, frequency, offset, description) => ({
+ id: `special_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ position: position, // {afterMonth: X, afterDay: Y} - occurs after this date
+ dayType: dayType, // 'fixed' or 'leap'
+ weekBehavior: weekBehavior, // 'partOfWeek' or 'betweenWeeks'
+ frequency: frequency || null, // for leap days (e.g., 4 for every 4 years)
+ offset: offset || 0, // for leap days (year offset)
+ description: description || ''
+ }),
+
+ createEvent: (content, dateRef, tags, createdBy) => ({
+ id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ type: 'event',
+ content: content,
+ dateRef: dateRef, // {year, month, day} or {year, month} or {year}
+ tags: tags || [],
+ createdBy: createdBy,
+ createdAt: Date.now()
+ }),
+
+ createNote: (content, dateRef, tags, createdBy) => ({
+ id: `note_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ type: 'note',
+ content: content,
+ dateRef: dateRef, // {year, month, day} or {year, month} or {year}
+ tags: tags || [],
+ createdBy: createdBy,
+ createdAt: Date.now()
+ }),
+
+ createWeather: (dateRef, climate, temp, precipitation, wind, description) => ({
+ id: `weather_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ dateRef: dateRef, // {year, month, day}
+ climate: climate,
+ temperature: temp, // {value, unit}
+ precipitation: precipitation,
+ wind: wind,
+ description: description
+ }),
+
+ createParty: (name, members) => ({
+ id: `party_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ members: members || [] // Array of character IDs
+ }),
+
+ createClimate: (inputs) => ({
+ latitude_band: inputs.latitude_band,
+ ocean_proximity: inputs.ocean_proximity,
+ coast_type: inputs.coast_type,
+ elevation: inputs.elevation,
+ rainshadow: inputs.rainshadow,
+ koppen_code: null,
+ climate_name: null,
+ temperature_profile: null,
+ precipitation_profile: null,
+ biome_hint: null
+ })
+
+ };
+
+ // ==================================================
+ // Climate Classification System
+ // ==================================================
+
+ const ClimateClassifier = {
+
+ classify: (inputs) => {
+ const climate = DataModels.createClimate(inputs);
+
+ // Step 1: Determine base climate group
+ let baseGroup = ClimateClassifier._getBaseGroup(inputs);
+
+ // Step 2: Check for arid override
+ const aridCheck = ClimateClassifier._checkArid(inputs);
+ if (aridCheck) {
+ climate.koppen_code = aridCheck;
+ ClimateClassifier._populateDescriptions(climate);
+ return climate;
+ }
+
+ // Step 3: Determine precipitation pattern
+ const precipPattern = ClimateClassifier._getPrecipPattern(inputs, baseGroup);
+
+ // Step 4: Determine temperature subtype
+ const tempSubtype = ClimateClassifier._getTempSubtype(inputs, baseGroup);
+
+ // Assemble final code
+ climate.koppen_code = baseGroup + precipPattern + tempSubtype;
+
+ // Generate descriptions
+ ClimateClassifier._populateDescriptions(climate);
+
+ return climate;
+ },
+
+ _getBaseGroup: (inputs) => {
+ let group;
+
+ switch (inputs.latitude_band) {
+ case 'tropical': group = 'A'; break;
+ case 'subtropical': group = 'C'; break;
+ case 'temperate': group = 'C'; break;
+ case 'subarctic': group = 'D'; break;
+ case 'polar': group = 'E'; break;
+ default: group = 'C';
+ }
+
+ // Override: continental temperate becomes subarctic
+ if (inputs.ocean_proximity === 'continental' && inputs.latitude_band === 'temperate') {
+ group = 'D';
+ }
+
+ // Override: alpine elevation shifts colder
+ if (inputs.elevation === 'alpine') {
+ if (group === 'C') group = 'D';
+ else if (group === 'D') group = 'E';
+ }
+
+ return group;
+ },
+
+ _checkArid: (inputs) => {
+ // Set to "B" if arid conditions met
+ if (inputs.rainshadow === 'leeward' && inputs.ocean_proximity !== 'coastal') {
+ // Arid
+ if (inputs.latitude_band === 'tropical' || inputs.latitude_band === 'subtropical') {
+ return 'BWh'; // Hot desert
+ } else {
+ return 'BWk'; // Cold desert
+ }
+ }
+
+ if (inputs.ocean_proximity === 'continental' &&
+ (inputs.latitude_band === 'subtropical' || inputs.latitude_band === 'temperate')) {
+ // Check for steppe mitigation
+ if (inputs.ocean_proximity === 'near_coastal' || inputs.rainshadow === 'windward') {
+ return 'BSk'; // Steppe
+ } else {
+ return 'BWk'; // Cold desert
+ }
+ }
+
+ return null; // Not arid
+ },
+
+ _getPrecipPattern: (inputs, baseGroup) => {
+ if (baseGroup === 'E' || baseGroup === 'B') return '';
+
+ // West coast + subtropical/temperate = dry summer
+ if (inputs.coast_type === 'west' &&
+ (inputs.latitude_band === 'subtropical' || inputs.latitude_band === 'temperate')) {
+ return 's';
+ }
+
+ // East coast + subtropical = dry winter (monsoonal)
+ if (inputs.coast_type === 'east' && inputs.latitude_band === 'subtropical') {
+ return 'w';
+ }
+
+ // Windward = no dry season
+ if (inputs.rainshadow === 'windward') {
+ return 'f';
+ }
+
+ // Default to no dry season
+ return 'f';
+ },
+
+ _getTempSubtype: (inputs, baseGroup) => {
+ if (baseGroup !== 'C' && baseGroup !== 'D') return '';
+
+ switch (inputs.latitude_band) {
+ case 'tropical':
+ case 'subtropical':
+ return 'a'; // Hot summer
+ case 'temperate':
+ return 'b'; // Warm summer
+ case 'subarctic':
+ return 'c'; // Cool summer
+ default:
+ return 'b';
+ }
+ },
+
+ _populateDescriptions: (climate) => {
+ const descriptions = {
+ 'Af': {
+ name: 'Tropical Rainforest',
+ temp: 'Hot and humid year-round',
+ precip: 'Heavy rainfall in all seasons',
+ biome: 'Dense jungle, diverse wildlife'
+ },
+ 'Aw': {
+ name: 'Tropical Savanna',
+ temp: 'Hot year-round',
+ precip: 'Distinct wet and dry seasons',
+ biome: 'Grasslands with scattered trees'
+ },
+ 'BWh': {
+ name: 'Hot Desert',
+ temp: 'Extremely hot days, cool nights',
+ precip: 'Minimal rainfall',
+ biome: 'Sparse vegetation, dunes, arid plains'
+ },
+ 'BWk': {
+ name: 'Cold Desert',
+ temp: 'Hot summers, cold winters',
+ precip: 'Very low precipitation',
+ biome: 'Rocky terrain, hardy shrubs'
+ },
+ 'BSk': {
+ name: 'Cold Steppe',
+ temp: 'Warm summers, cold winters',
+ precip: 'Low to moderate precipitation',
+ biome: 'Short grasslands, sparse vegetation'
+ },
+ 'BSh': {
+ name: 'Hot Steppe',
+ temp: 'Hot summers, mild winters',
+ precip: 'Low precipitation',
+ biome: 'Semi-arid grasslands'
+ },
+ 'Csa': {
+ name: 'Mediterranean',
+ temp: 'Hot dry summers, mild wet winters',
+ precip: 'Summer drought, winter rain',
+ biome: 'Scrubland, drought-resistant trees'
+ },
+ 'Csb': {
+ name: 'Warm Mediterranean',
+ temp: 'Warm dry summers, mild wet winters',
+ precip: 'Summer drought, winter rain',
+ biome: 'Mixed forest, chaparral'
+ },
+ 'Cfa': {
+ name: 'Humid Subtropical',
+ temp: 'Hot summers, mild winters',
+ precip: 'High humidity, frequent storms',
+ biome: 'Mixed forests, broadleaf vegetation'
+ },
+ 'Cfb': {
+ name: 'Marine West Coast',
+ temp: 'Mild temperatures year-round',
+ precip: 'Frequent rainfall in all seasons',
+ biome: 'Temperate rainforest, dense evergreen vegetation'
+ },
+ 'Cfc': {
+ name: 'Subpolar Oceanic',
+ temp: 'Cool summers, mild winters',
+ precip: 'Consistent rainfall',
+ biome: 'Coniferous forest, mosses'
+ },
+ 'Dfa': {
+ name: 'Hot-Summer Humid Continental',
+ temp: 'Hot summers, cold snowy winters',
+ precip: 'Moderate precipitation year-round',
+ biome: 'Deciduous and mixed forests'
+ },
+ 'Dfb': {
+ name: 'Warm-Summer Humid Continental',
+ temp: 'Warm summers, cold winters',
+ precip: 'Moderate precipitation year-round',
+ biome: 'Deciduous forests, seasonal variation'
+ },
+ 'Dfc': {
+ name: 'Subarctic',
+ temp: 'Cool summers, very cold winters',
+ precip: 'Low to moderate precipitation',
+ biome: 'Boreal forest, taiga'
+ },
+ 'Dfd': {
+ name: 'Extreme Subarctic',
+ temp: 'Cool summers, extremely cold winters',
+ precip: 'Low precipitation',
+ biome: 'Sparse boreal forest'
+ },
+ 'ET': {
+ name: 'Tundra',
+ temp: 'Cold year-round',
+ precip: 'Low precipitation',
+ biome: 'Permafrost, mosses, lichens'
+ },
+ 'EF': {
+ name: 'Ice Cap',
+ temp: 'Extremely cold year-round',
+ precip: 'Minimal precipitation',
+ biome: 'Permanent ice and snow'
+ }
+ };
+
+ // Handle polar special case
+ if (climate.koppen_code.startsWith('E')) {
+ if (climate.elevation === 'alpine') {
+ climate.koppen_code = 'EF';
+ } else {
+ climate.koppen_code = 'ET';
+ }
+ }
+
+ const desc = descriptions[climate.koppen_code] || descriptions['Cfb'];
+ climate.climate_name = desc.name;
+ climate.temperature_profile = desc.temp;
+ climate.precipitation_profile = desc.precip;
+ climate.biome_hint = desc.biome;
+ }
+
+ };
+
+ // ==================================================
+ // State Management
+ // ==================================================
+
+ const State = {
+
+ initialize: () => {
+ if (!state[scriptName] || state[scriptName].version !== schemaVersion) {
+
+ Logger.log(`Initializing Schema v${schemaVersion}`);
+
+ state[scriptName] = {
+ version: schemaVersion,
+ config: {
+ currentCalendar: null, // Name of active calendar handout
+ currentEvents: null, // Name of active events handout
+ currentDate: { year: 1, month: 1, day: 1 },
+ featuredDate: { year: 1, month: 1, day: 1 }, // Saved "current campaign date"
+ displayMode: 'calendar', // 'calendar', 'design', 'timeline'
+ theme: 'light', // 'light', 'dark', 'fantasy'
+ viewingDate: { year: 1, month: 1 }, // Month being viewed
+ verboseCalendar: false // Show full notes/events in calendar cells vs just indicators
+ }
+ };
+ }
+ },
+
+ get: () => state[scriptName],
+ config: () => state[scriptName].config,
+
+ setConfig: (key, value) => {
+ state[scriptName].config[key] = value;
+ }
+ };
+
+ // ==================================================
+ // Default Calendars
+ // ==================================================
+
+ const DefaultCalendars = {
+
+ gregorian: () => {
+ const cal = DataModels.createCalendar('Gregorian');
+ cal.description = 'The modern Gregorian Calendar is the internationally dominant civil calendar used across most of Earth. It is a solar calendar consisting of 365 days divided into twelve uneven months and organized into a repeating seven-day week. To maintain alignment with the Earth\'s orbit and seasonal cycle, a leap day is added every four years, except in certain century years not evenly divisible by 400. It is used globally for civil administration, commerce, science, and international coordination, though many cultures also maintain traditional or religious calendars alongside it. Earth has a single large moon. The math for the Gregorian has been simplified here for game use. If you want historical accuracy, consult an almanac.';
+ cal.daysInYear = 365;
+ cal.months = [
+ DataModels.createMonth('January', 31, 0),
+ DataModels.createMonth('February', 28, 1),
+ DataModels.createMonth('March', 31, 2),
+ DataModels.createMonth('April', 30, 3),
+ DataModels.createMonth('May', 31, 4),
+ DataModels.createMonth('June', 30, 5),
+ DataModels.createMonth('July', 31, 6),
+ DataModels.createMonth('August', 31, 7),
+ DataModels.createMonth('September', 30, 8),
+ DataModels.createMonth('October', 31, 9),
+ DataModels.createMonth('November', 30, 10),
+ DataModels.createMonth('December', 31, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.leapYears = {
+ enabled: true,
+ cycle: 4,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 80
+ };
+ cal.holidays = [
+ DataModels.createHoliday('New Year\'s Day', { month: 1, day: 1 }, true),
+ DataModels.createHoliday('Earth Day', { month: 4, day: 22 }, true),
+ DataModels.createHoliday('Memorial Day', { month: 5, day: 26 }, true),
+ DataModels.createHoliday('Independence Day', { month: 7, day: 4 }, true),
+ DataModels.createHoliday('Halloween', { month: 10, day: 31 }, true),
+ DataModels.createHoliday('Thanksgiving', { month: 11, day: 22 }, true),
+ DataModels.createHoliday('Christmas', { month: 12, day: 25 }, true)
+ ];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Luna', 29.53059, { year: 2000, month: 1, day: 21 })
+ ];
+ return cal;
+ },
+
+ absalom: () => {
+ const cal = DataModels.createCalendar('Absalom Reckoning');
+ cal.description = 'The common calendar of Golarion is the Absalom Reckoning system, dating years from the founding of the city of Absalom. The calendar closely resembles the modern Gregorian structure familiar to players, with 365 days divided into twelve uneven months and a seven-day week. Every four years, a leap day is added to maintain seasonal alignment. The system is widely used across the Inner Sea region for commerce, governance, and scholarship, though individual cultures may maintain local calendars and observances alongside it. Golarion has a single moon that follows regular phases and exerts strong cultural and mystical influence. Lunar cycles are associated with magic, lycanthropy, tides, religion, and omens, and many traditions mark important events according to the moon\'s position or appearance in the night sky.';
+ cal.daysInYear = 365;
+ cal.months = [
+ DataModels.createMonth('Abadius', 31, 0),
+ DataModels.createMonth('Calistril', 28, 1),
+ DataModels.createMonth('Pharast', 31, 2),
+ DataModels.createMonth('Gozran', 30, 3),
+ DataModels.createMonth('Desnus', 31, 4),
+ DataModels.createMonth('Sarenith', 30, 5),
+ DataModels.createMonth('Erastus', 31, 6),
+ DataModels.createMonth('Arodus', 31, 7),
+ DataModels.createMonth('Rova', 30, 8),
+ DataModels.createMonth('Lamashan', 31, 9),
+ DataModels.createMonth('Neth', 30, 10),
+ DataModels.createMonth('Kuthona', 31, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Moonday', 'Toilday', 'Wealday', 'Oathday', 'Fireday', 'Starday', 'Sunday'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.leapYears = {
+ enabled: true,
+ cycle: 4,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 80
+ };
+ cal.holidays = [
+ DataModels.createHoliday('Foundation Day', { month: 1, day: 1 }, true),
+ DataModels.createHoliday('Ascension Day', { month: 12, day: 25 }, true)
+ ];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Somal', 29.53059, { year: 4722, month: 1, day: 6 })
+ ];
+ return cal;
+ },
+
+ faerun: () => {
+ const cal = DataModels.createCalendar('Faerun');
+ cal.description = 'The standard calendar of the Forgotten Realms is the Calendar of Harptos, a solar calendar used across much of Faerûn. The year contains 365 days divided into twelve months of thirty days each. Instead of a seven-day week, the calendar uses ten-day periods commonly called tendays, which serve the same social and commercial role as weeks in many real-world cultures. Between several months are special intercalary festival days that do not belong to any month or tenday, and every four years an additional leap day is added to keep the calendar aligned with the seasons. Faerûn is illuminated primarily by a single moon, which follows regular phases.';
+ cal.daysInYear = 360;
+ cal.months = [
+ DataModels.createMonth('Hammer', 30, 0),
+ DataModels.createMonth('Alturiak', 30, 1),
+ DataModels.createMonth('Ches', 30, 2),
+ DataModels.createMonth('Tarsakh', 30, 3),
+ DataModels.createMonth('Mirtul', 30, 4),
+ DataModels.createMonth('Kythorn', 30, 5),
+ DataModels.createMonth('Flamerule', 30, 6),
+ DataModels.createMonth('Eleasis', 30, 7),
+ DataModels.createMonth('Eleint', 30, 8),
+ DataModels.createMonth('Marpenoth', 30, 9),
+ DataModels.createMonth('Uktar', 30, 10),
+ DataModels.createMonth('Nightal', 30, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 10,
+ weekdayNames: ['Firstday', 'Secondday', 'Thirdday', 'Fourthday', 'Fifthday', 'Sixthday', 'Seventhday', 'Eighthday', 'Ninthday', 'Tenthday'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.interMonthDays = [
+ DataModels.createInterMonthDay('Midwinter', { afterMonth: 1, afterDay: 30 }, true, 'fixed', null, 0, "A hard-season revel marking survival through winter's worst. Taverns overflow, nobles host masked feasts, and common folk exchange small gifts. Priests often proclaim omens for the coming year, making it fertile ground for prophecy, intrigue, or sudden violence beneath forced merriment."),
+ DataModels.createInterMonthDay('Greengrass', { afterMonth: 4, afterDay: 30 }, true, 'fixed', null, 0, "A joyous spring festival celebrating planting, fertility, and renewal. Villages hold dances, contests, and outdoor feasts while druids and priests bless fields. Travelers find communities unusually welcoming, though ancient barrows and fey sites are said to stir with new life as well."),
+ DataModels.createInterMonthDay('Midsummer', { afterMonth: 7, afterDay: 30 }, true, 'fixed', null, 0, "A raucous holiday of bonfires, drinking, romance, and excess. Nobles sponsor tournaments and public celebrations while adventurers easily find work as guards, performers, or duelists. The festival's chaos also makes it ideal cover for thefts, assassinations, and secret cult rites."),
+ DataModels.createInterMonthDay('Highharvestide', { afterMonth: 9, afterDay: 30 }, true, 'fixed', null, 0, "A harvest celebration focused on gratitude, trade, and preparation for winter. Markets swell with food, crafts, and livestock while temples collect offerings for the needy. Rural folk tell ghost stories and leave symbolic gifts to appease local spirits before the dark season begins."),
+ DataModels.createInterMonthDay('The Feast of the Moon', { afterMonth: 11, afterDay: 30 }, true, 'fixed', null, 0, "A solemn yet warm remembrance of the dead held as winter approaches. Families honor ancestors with candlelit vigils and shared meals, while priests conduct rites for wandering souls. Undead sightings and supernatural encounters are considered more common during the festival nights."),
+ DataModels.createInterMonthDay('Shieldmeet', { afterMonth: 1, afterDay: 30 }, true, 'leap', 4, 0, "Occurring only every four years, this extra feast day is tied to truces, diplomacy, and grand gatherings. Mercenary companies negotiate contracts, rulers announce decrees, and temples pursue reconciliation rituals. Many believe ancient magic weakens or shifts during Shieldmeet, encouraging risky arcane experiments.")
+ ];
+ cal.leapYears = {
+ enabled: false,
+ cycle: 0,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 60
+ };
+ cal.holidays = [];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Selune', 91, { year: 1, month: 8, day: 4 })
+ ];
+ return cal;
+ },
+
+ greyhawk: () => {
+ const cal = DataModels.createCalendar('Greyhawk');
+ cal.description = 'The Flanaess commonly uses the Common Year calendar, a structured system consisting of 364 days divided into twelve months of twenty-eight days each. The calendar is organized around a seven-day week, with every month containing exactly four weeks. In addition to the regular months, several festival weeks occur between seasons; these intercalary periods are not part of any month and are often associated with celebrations, religious observances, tournaments, and civic events. Every four years, an additional leap festival week is inserted to preserve seasonal accuracy. The system is highly orderly and easy to track, making it popular among scholars, merchants, and rulers throughout the known world. Greyhawk\'s world possesses two moons.';
+ cal.daysInYear = 364;
+ cal.months = [
+ DataModels.createMonth('Fireseek', 28, 0),
+ DataModels.createMonth('Readying', 28, 1),
+ DataModels.createMonth('Coldeven', 28, 2),
+ DataModels.createMonth('Growfest', 7, 3),
+ DataModels.createMonth('Planting', 28, 4),
+ DataModels.createMonth('Flocktime', 28, 5),
+ DataModels.createMonth('Wealsun', 28, 6),
+ DataModels.createMonth('Richfest', 7, 7),
+ DataModels.createMonth('Reaping', 28, 8),
+ DataModels.createMonth('Goodmonth', 28, 9),
+ DataModels.createMonth('Harvester', 28, 10),
+ DataModels.createMonth('Brewfest', 7, 11),
+ DataModels.createMonth('Patchwall', 28, 12),
+ DataModels.createMonth('Ready\'reat', 28, 13),
+ DataModels.createMonth('Sunsebb', 28, 14),
+ DataModels.createMonth('Needfest', 7, 15)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Starday', 'Sunday', 'Moonday', 'Godsday', 'Waterday', 'Earthday', 'Freeday'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: false
+ };
+ cal.leapYears = {
+ enabled: false,
+ cycle: 0,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 91
+ };
+ cal.holidays = [];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Luna', 28, { year: 1, month: 8, day: 4 }, .8, 'cyan', true),
+ DataModels.createMoon('Celene', 91, { year: 1, month: 8, day: 4 }, 1, 'white', true)
+ ];
+ return cal;
+ },
+
+ eberron: () => {
+ const cal = DataModels.createCalendar('Eberron');
+ cal.description = 'The standard calendar of Eberron is the Galifar Calendar, established during the reign of the Kingdom of Galifar and still used throughout Khorvaire. The year contains 336 days divided into twelve months of exactly twenty-eight days each, creating a perfectly regular structure of four seven-day weeks per month. Because every month begins on the same weekday, dates are easy to track and schedule. The calendar contains no leap years or intercalary festival days. Eberron has an unusually complex lunar system consisting of twelve moons of varying sizes, colors, and orbital periods. The changing combinations of visible moons are a major feature of the setting\'s atmosphere and cosmology, particularly in relation to magic, prophecy, and planar influence. For simplicity, about half the moons do not display in Calendar view. This can be edited below.';
+ cal.daysInYear = 336;
+ cal.months = [
+ DataModels.createMonth('Zarantyr', 28, 0),
+ DataModels.createMonth('Olarune', 28, 1),
+ DataModels.createMonth('Therendor', 28, 2),
+ DataModels.createMonth('Eyre', 28, 3),
+ DataModels.createMonth('Dravago', 28, 4),
+ DataModels.createMonth('Nymm', 28, 5),
+ DataModels.createMonth('Lharvion', 28, 6),
+ DataModels.createMonth('Barrakas', 28, 7),
+ DataModels.createMonth('Rhaan', 28, 8),
+ DataModels.createMonth('Sypheros', 28, 9),
+ DataModels.createMonth('Aryth', 28, 10),
+ DataModels.createMonth('Vult', 28, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Sul', 'Mol', 'Zol', 'Wir', 'Zor', 'Far', 'Sar'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.leapYears = {
+ enabled: false,
+ cycle: 4,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 84
+ };
+ cal.holidays = [
+ DataModels.createHoliday('Brightblade', { month: 1, day: 12 }, true, 'A festival honoring Dol Arrah and ideals of sacrifice, courage, and honorable battle.'),
+ DataModels.createHoliday('Long Shadows', { month: 9, day: 26 }, true, 'A solemn remembrance of the dead associated with Dolurrh, funerary rites, and ancestral reflection.'),
+ DataModels.createHoliday('Wildnight', { month: 10, day: 18 }, true, 'A chaotic celebration tied to the Traveler, featuring masks, revelry, deception, and unpredictable behavior.'),
+ DataModels.createHoliday('Baker\'s Night', { month: 11, day: 9 }, true, 'A communal feast celebrated across Khorvaire with food, hospitality, storytelling, and preparation for winter.')
+ ];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Zarantyr', 0.4, { year: 1, month: 1, day: 1 }, 0.3, 'orange', false), // Tiny, hidden
+ DataModels.createMoon('Olarune', 0.8, { year: 1, month: 1, day: 2 }, 0.4, 'gray', false), // Small, hidden
+ DataModels.createMoon('Therendor', 1.8, { year: 1, month: 1, day: 3 }, 0.5, 'tan', false), // Medium-small, hidden
+ DataModels.createMoon('Eyre', 2.9, { year: 1, month: 1, day: 4 }, 0.6, 'yellow', true), // Medium, visible, default color
+ DataModels.createMoon('Dravago', 5.1, { year: 1, month: 1, day: 5 }, 0.7, 'orange', true), // Medium-large, visible, orange
+ DataModels.createMoon('Nymm', 6.7, { year: 1, month: 1, day: 6 }, 0.8, 'blue', true), // Large, visible, blue
+ DataModels.createMoon('Lharvion', 10.3, { year: 1, month: 1, day: 7 }, 0.9, 'red', true), // Very large, visible, red
+ DataModels.createMoon('Barrakas', 12.3, { year: 1, month: 1, day: 8 }, 1.0, 'orange', true), // Full size, visible, orange
+ DataModels.createMoon('Rhaan', 14.5, { year: 1, month: 1, day: 9 }, 0.5, 'purple', false), // Medium-small, hidden, purple
+ DataModels.createMoon('Sypheros', 16.9, { year: 1, month: 1, day: 10 }, 0.6, 'brown', false), // Medium, hidden, brown
+ DataModels.createMoon('Aryth', 11.9, { year: 1, month: 1, day: 11 }, 0.7, 'tan', false), // Medium-large, hidden, tan
+ DataModels.createMoon('Vult', 29.2, { year: 1, month: 1, day: 12 }, 0.8, 'yellow', true) // Large, visible, yellow
+ ];
+ return cal;
+ }
+
+ };
+
+ // ==================================================
+ // Parser
+ // ==================================================
+
+ const Parser = {
+
+ parse: (content) => {
+ const tokens = content.trim().split(/\s+/);
+ const command = tokens.shift();
+
+ const args = {};
+ let currentKey = null;
+
+ tokens.forEach(token => {
+
+ if (token.startsWith('--')) {
+ currentKey = token.replace(/^--/, '');
+ args[currentKey] = true;
+ return;
+ }
+
+ if (currentKey) {
+ // Don't split on pipe - keep the entire value intact
+ if (args[currentKey] === true) {
+ args[currentKey] = token;
+ } else {
+ args[currentKey] += ` ${token}`;
+ }
+ }
+
+ });
+
+ return { command, args };
+ }
+
+ };
+
+ // ==================================================
+ // Output
+ // ==================================================
+
+ const Output = {
+
+ send: (who, message) => {
+ const CSS_CURRENT = getCSS();
+ const cleanWho = who.split('(GM')[0].trim();
+ const cleanMessage = message.replace(/\r?\n/g, '');
+ // Use chatOutput style for whispered messages
+ const styledMessage = `${cleanMessage}
`;
+ sendChat(scriptName, `/w "${cleanWho}" ${styledMessage}`);
+ },
+
+ broadcast: (message) => {
+ const cleanMessage = message.replace(/\r?\n/g, '');
+ sendChat(scriptName, cleanMessage);
+ },
+
+ makeButton: (label, command, style) => {
+ const CSS_CURRENT = getCSS();
+ const buttonStyle = style || CSS_CURRENT.button;
+ return `${label}`;
+ }
+
+ };
+
+ // ==================================================
+ // Handout Management
+ // ==================================================
+
+ const HandoutManager = {
+
+ findHandout: (name) => {
+ return findObjs({ type: 'handout', name: name })[0];
+ },
+
+ createHandout: (name, notes, gmnotes = '', archived = true) => {
+ return createObj('handout', {
+ name: name,
+ notes: notes,
+ gmnotes: gmnotes,
+ infolderorder: '',
+ archived: archived
+ });
+ },
+
+ getHandoutNotes: (handout, callback) => {
+ handout.get('notes', callback);
+ },
+
+ getHandoutGMNotes: (handout, callback) => {
+ handout.get('gmnotes', callback);
+ },
+
+ setHandoutNotes: (handout, notes) => {
+ handout.set('notes', notes);
+ },
+
+ setHandoutGMNotes: (handout, gmnotes) => {
+ handout.set('gmnotes', gmnotes);
+ },
+
+ saveCalendar: (calendar) => {
+ const name = `${HANDOUT_PREFIX} Calendar: ${calendar.name}`;
+ let handout = HandoutManager.findHandout(name);
+
+ const data = JSON.stringify(calendar, null, 2);
+
+ if (!handout) {
+ handout = HandoutManager.createHandout(name, '');
+ Logger.log(`Created calendar handout: ${name}`);
+ }
+
+ HandoutManager.setHandoutGMNotes(handout, data);
+ Logger.log(`Updated calendar handout: ${name}`);
+
+ State.setConfig('currentCalendar', name);
+ return handout;
+ },
+
+ loadData: (callback) => {
+ // Load calendar and events data using proper async callbacks
+ // Pass loaded data to callback instead of storing in state
+ const calName = State.config().currentCalendar;
+ if (!calName) {
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ const calHandout = HandoutManager.findHandout(calName);
+ if (!calHandout) {
+ Logger.error(`Calendar handout not found: ${calName}`);
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ // Load calendar with callback
+ HandoutManager.getHandoutGMNotes(calHandout, (gmnotes) => {
+ let calendar = null;
+ let moons = [];
+
+ try {
+ calendar = JSON.parse(gmnotes || '{}');
+ moons = calendar.moons || [];
+ Logger.debug(`Loaded calendar: ${calendar.name}`);
+ } catch (e) {
+ Logger.error(`Failed to parse calendar: ${e}`);
+ }
+
+ // Load events with callback
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calName.replace(`${HANDOUT_PREFIX} Calendar: `, '')}`;
+ const eventsHandout = HandoutManager.findHandout(eventsName);
+
+ if (!eventsHandout) {
+ callback({
+ calendar: calendar,
+ events: [],
+ notes: [],
+ moons: moons,
+ weather: []
+ });
+ return;
+ }
+
+ HandoutManager.getHandoutGMNotes(eventsHandout, (eventsNotes) => {
+ let events = [];
+ let notes = [];
+ let weather = [];
+
+ try {
+ const data = JSON.parse(eventsNotes || '{}');
+ events = data.events || [];
+ notes = data.notes || [];
+ weather = data.weather || [];
+ Logger.debug(`Loaded ${events.length} events, ${notes.length} notes`);
+ } catch (e) {
+ Logger.error(`Failed to parse events: ${e}`);
+ }
+
+ // Return all loaded data
+ callback({
+ calendar: calendar,
+ events: events,
+ notes: notes,
+ moons: moons,
+ weather: weather
+ });
+ });
+ });
+ },
+
+
+
+ saveEvents: (campaignName, events, notes, weather = []) => {
+ const name = `${HANDOUT_PREFIX} Events: ${campaignName}`;
+ let handout = HandoutManager.findHandout(name);
+
+ const jsonData = JSON.stringify({ events, notes, weather }, null, 2);
+
+ if (!handout) {
+ handout = HandoutManager.createHandout(name, jsonData);
+ Logger.log(`Created events handout: ${name}`);
+ } else {
+ HandoutManager.setHandoutGMNotes(handout, jsonData);
+ Logger.log(`Updated events handout: ${name}`);
+ }
+
+ State.setConfig('currentEvents', name);
+ return handout;
+ },
+
+ loadEvents: (name, callback) => {
+ const handout = HandoutManager.findHandout(name);
+ if (!handout) {
+ Logger.error(`Events handout not found: ${name}`);
+ callback({ events: [], notes: [] });
+ return;
+ }
+
+ HandoutManager.getHandoutGMNotes(handout, (gmnotes) => {
+ try {
+ const data = JSON.parse(gmnotes);
+ Logger.debug(`Loaded ${data.events.length} events and ${data.notes.length} notes`);
+ callback(data);
+ } catch (e) {
+ Logger.error(`Failed to parse events: ${e}`);
+ callback({ events: [], notes: [] });
+ }
+ });
+ }
+
+ };
+
+ // ==================================================
+ // Date Utilities
+ // ==================================================
+
+ const DateUtils = {
+
+ // Convert {year, month, day} to absolute day number
+ toAbsoluteDay: (dateRef, calendar) => {
+ if (!calendar || !dateRef) return 0;
+
+ let dayCount = 0;
+
+ // Add full years
+ for (let y = 1; y < dateRef.year; y++) {
+ dayCount += DateUtils.getDaysInYear(y, calendar);
+ }
+
+ // Add full months in current year
+ if (dateRef.month) {
+ for (let m = 1; m < dateRef.month; m++) {
+ dayCount += DateUtils.getDaysInMonth(m, dateRef.year, calendar);
+ }
+ }
+
+ // Add days in current month
+ if (dateRef.day) {
+ dayCount += dateRef.day;
+ }
+
+ return dayCount;
+ },
+
+ // Get days in a specific year (accounting for leap years)
+ getDaysInYear: (year, calendar) => {
+ if (!calendar.leapYears.enabled) return calendar.daysInYear;
+
+ if (year % calendar.leapYears.cycle === 0 && !calendar.leapYears.exceptions.includes(year)) {
+ return calendar.daysInYear + 1; // Leap year
+ }
+
+ return calendar.daysInYear;
+ },
+
+ // Get days in a specific month
+ getDaysInMonth: (monthNum, year, calendar) => {
+ const month = calendar.months[monthNum - 1];
+ if (!month) return 0;
+
+ // Check if this is February in a leap year (for Gregorian-like calendars)
+ if (calendar.leapYears.enabled && monthNum === 2) {
+ if (year % calendar.leapYears.cycle === 0 && !calendar.leapYears.exceptions.includes(year)) {
+ return month.days + 1;
+ }
+ }
+
+ return month.days;
+ },
+
+ // Calculate distance between two dates
+ calculateDistance: (from, to, calendar) => {
+ const fromAbs = DateUtils.toAbsoluteDay(from, calendar);
+ const toAbs = DateUtils.toAbsoluteDay(to, calendar);
+ const diff = toAbs - fromAbs;
+
+ // Convert to appropriate unit
+ if (Math.abs(diff) < 365) {
+ return `${diff > 0 ? '+' : ''}${diff}d`;
+ }
+
+ const years = diff / 365;
+ if (Math.abs(years) < 10) {
+ return `${years > 0 ? '+' : ''}${years.toFixed(1)}y`;
+ }
+
+ return `${years > 0 ? '+' : ''}${Math.round(years)}y`;
+ },
+
+ // Get the weekday for a given date
+ getWeekday: (dateRef, calendar) => {
+ if (!calendar.weeks.enabled) return null;
+
+ const absDay = DateUtils.toAbsoluteDay(dateRef, calendar);
+ const weekdayIndex = (absDay - 1) % calendar.weeks.daysInWeek;
+ return calendar.weeks.weekdayNames[weekdayIndex];
+ },
+
+ // Get special days that occur in a specific year
+ getSpecialDaysForYear: (year, calendar) => {
+ if (!calendar.interMonthDays) return [];
+
+ return calendar.interMonthDays.filter(sd => {
+ if (sd.dayType === 'fixed') {
+ return true; // Fixed days always occur
+ } else if (sd.dayType === 'leap') {
+ // Check if this year qualifies for the leap day
+ return (year - sd.offset) % sd.frequency === 0;
+ }
+ return false;
+ });
+ },
+
+ // Get special days that occur after a specific date
+ getSpecialDaysAfterDate: (month, day, year, calendar) => {
+ const specialDays = DateUtils.getSpecialDaysForYear(year, calendar);
+
+ return specialDays.filter(sd => {
+ if (!sd.position) return false;
+ // Check if special day comes after this month/day
+ if (sd.position.afterMonth > month) return false;
+ if (sd.position.afterMonth === month && sd.position.afterDay > day) return false;
+ return true;
+ });
+ },
+
+ // Check if a date is a special day (occurs AFTER the position day)
+ isSpecialDay: (month, day, year, calendar) => {
+ const specialDays = DateUtils.getSpecialDaysForYear(year, calendar);
+
+ return specialDays.find(sd => {
+ if (!sd.position) return false;
+ // Special day occurs the day AFTER position.afterDay
+ if (sd.position.afterMonth !== month) return false;
+
+ // For "part of week" special days, they occur on afterDay + 1
+ // For "between weeks" they're shown separately in grid
+ if (!sd.breaksWeekCycle) {
+ return day === sd.position.afterDay + 1;
+ }
+
+ return false;
+ });
+ },
+
+ // Calculate elapsed time between two dates
+ // Returns object with {years, months, days, isNegative} for display
+ getElapsedTime: (fromDate, toDate, calendar) => {
+ const fromAbsolute = DateUtils.toAbsoluteDay(fromDate, calendar);
+ const toAbsolute = DateUtils.toAbsoluteDay(toDate, calendar);
+
+ let totalDays = toAbsolute - fromAbsolute;
+ const isNegative = totalDays < 0;
+ totalDays = Math.abs(totalDays);
+
+ // For events on first day of year, just return years
+ const isFirstOfYear = toDate.month === 1 && toDate.day === 1;
+
+ if (isFirstOfYear && totalDays >= 365) {
+ const years = Math.floor(totalDays / 365);
+ return { years: years, months: 0, days: 0, isNegative: isNegative, isFirstOfYear: true };
+ }
+
+ // Calculate years, months, days
+ let years = 0;
+ let months = 0;
+ let days = totalDays;
+
+ // Calculate years
+ const daysInYear = DateUtils.getDaysInYear(fromDate.year, calendar);
+ if (days >= daysInYear) {
+ years = Math.floor(days / daysInYear);
+ days = days % daysInYear;
+ }
+
+ // Calculate months (approximate - use average of 30 days)
+ if (days >= 30) {
+ months = Math.floor(days / 30);
+ days = days % 30;
+ }
+
+ return { years: years, months: months, days: days, isNegative: isNegative, isFirstOfYear: false };
+ }
+
+ };
+
+ // ==================================================
+ // Moon Phase Calculator
+ // ==================================================
+
+ const MoonPhaseCalculator = {
+
+ getPhase: (moon, dateRef, calendar) => {
+ const currentDay = DateUtils.toAbsoluteDay(dateRef, calendar);
+ const fullDay = DateUtils.toAbsoluteDay(moon.fullDayRef, calendar);
+
+ const daysSinceFull = currentDay - fullDay;
+ const cyclePosition = ((daysSinceFull % moon.period) + moon.period) % moon.period;
+
+ // Normalize to 0-1, where 0 is new moon, 0.5 is full moon
+ // Since fullDayRef is when the moon WAS full, we need to offset by half a cycle
+ let phase = cyclePosition / moon.period;
+ phase = (phase + 0.5) % 1; // Shift so full moon reference = 0.5
+
+ return phase;
+ },
+
+ generateMoonHTML: (phase, size, color, moonName, showTooltip) => {
+ // Set defaults
+ if (size === undefined) size = 1;
+ if (color === undefined) color = 'yellow';
+ if (moonName === undefined) moonName = '';
+ if (showTooltip === undefined) showTooltip = false;
+
+ // Sprite sheet URL
+ const spriteURL = 'https://files.d20.io/images/488065736/0YUajKyQKqwp_NAkiQZw2Q/original.webp?1779563627';
+
+ // Map color names to row indices (0-11)
+ const colorMap = {
+ 'yellow': 0, '#f7d79c': 0, // default yellow
+ 'red': 1, '#ff0000': 1, '#ff4500': 1, '#ff6347': 1,
+ 'green': 2, '#00ff00': 2, '#008000': 2,
+ 'blue': 3, '#0000ff': 3, '#87ceeb': 3,
+ 'cyan': 4, '#00ffff': 4,
+ 'orange': 5, '#ffa500': 5, '#d4af37': 5, '#ffd700': 5,
+ 'purple': 6, '#800080': 6, '#dda0dd': 6,
+ 'tan': 7, '#d2b48c': 7, '#f0e68c': 7, '#e8dcc4': 7,
+ 'brown': 8, '#8b4513': 8, '#a0522d': 8,
+ 'white': 9, '#ffffff': 9, '#f8f8ff': 9,
+ 'gray': 10, '#808080': 10, '#c0c0c0': 10,
+ 'dark': 11, '#000000': 11, '#2a2a2a': 11
+ };
+
+ // Find closest color match
+ let rowIndex = 0;
+ const lowerColor = (color || '').toLowerCase();
+ if (colorMap[lowerColor] !== undefined) {
+ rowIndex = colorMap[lowerColor];
+ } else if (colorMap[color] !== undefined) {
+ rowIndex = colorMap[color];
+ }
+
+ // Map phase (0-1) to column index (0-7)
+ // 0 = new, 0.125 = waxing crescent, 0.25 = first quarter, 0.375 = waxing gibbous
+ // 0.5 = full, 0.625 = waning gibbous, 0.75 = last quarter, 0.875 = waning crescent
+ let colIndex = Math.floor(phase * 8);
+ if (colIndex >= 8) colIndex = 7; // Cap at 7
+
+ // Sprite sheet specs
+ const sheetWidth = 512;
+ const sheetHeight = 768;
+ const cols = 8;
+ const rows = 12;
+ const cellWidth = sheetWidth / cols; // 64px
+ const cellHeight = sheetHeight / rows; // 64px
+
+ // Calculate display size
+ const baseSize = 20;
+ const actualSize = baseSize * Math.max(0.1, Math.min(1, size));
+
+ // Calculate background position (negative offsets to show the correct cell)
+ const bgX = -(colIndex * actualSize);
+ const bgY = -(rowIndex * actualSize);
+
+ // Scale the entire sprite sheet so each 64px cell becomes actualSize pixels
+ // Sheet is 8 cols × 12 rows, so scaled sheet is (8*actualSize) × (12*actualSize)
+ const scaledSheetWidth = cols * actualSize;
+ const scaledSheetHeight = rows * actualSize;
+
+ // Create HTML with background sprite
+ let html = '';
+
+ return html;
+ },
+
+ getAllPhases: (moons, dateRef, calendar) => {
+ if (!moons || moons.length === 0) {
+ return [];
+ }
+
+ const visibleMoons = moons.filter(m => m.display !== false);
+ const showTooltips = visibleMoons.length > 1;
+
+ const results = [];
+ for (let i = 0; i < visibleMoons.length; i++) {
+ const moon = visibleMoons[i];
+ try {
+ const phase = MoonPhaseCalculator.getPhase(moon, dateRef, calendar);
+ const size = moon.size || 1;
+ const color = moon.color || 'yellow';
+ const html = MoonPhaseCalculator.generateMoonHTML(phase, size, color, moon.name, showTooltips);
+
+ results.push({
+ name: moon.name,
+ phase: phase,
+ html: html
+ });
+ } catch (e) {
+ log('Error generating moon phase for ' + moon.name + ': ' + e);
+ }
+ }
+
+ return results;
+ }
+
+ };
+
+ // ==================================================
+ // Interface Renderer
+ // ==================================================
+
+
+ // ==================================================
+ // Data Loader - Loads data from handouts with callbacks
+ // ==================================================
+
+ const DataLoader = {
+ loadAll: (callback) => {
+ const calName = State.config().currentCalendar;
+
+ if (!calName) {
+ // No calendar loaded
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ const calHandout = HandoutManager.findHandout(calName);
+ if (!calHandout) {
+ Logger.error(`Calendar handout not found: ${calName}`);
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ // Load calendar with callback
+ HandoutManager.getHandoutGMNotes(calHandout, (gmnotes) => {
+ let calendar = null;
+ let moons = [];
+
+ try {
+ calendar = JSON.parse(gmnotes || '{}');
+
+ // Migration: ensure moons array exists
+ if (!calendar.moons) {
+ calendar.moons = [];
+ }
+ moons = calendar.moons;
+ } catch (e) {
+ Logger.error(`Failed to parse calendar: ${e}`);
+ }
+
+ // Load events with callback
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calName.replace(`${HANDOUT_PREFIX} Calendar: `, '')}`;
+ const eventsHandout = HandoutManager.findHandout(eventsName);
+
+ if (!eventsHandout) {
+ callback({
+ calendar: calendar,
+ events: [],
+ notes: [],
+ moons: moons,
+ weather: []
+ });
+ return;
+ }
+
+ HandoutManager.getHandoutGMNotes(eventsHandout, (eventsNotes) => {
+ let events = [];
+ let notes = [];
+ let weather = [];
+
+ try {
+ const data = JSON.parse(eventsNotes || '{}');
+ events = data.events || [];
+ notes = data.notes || [];
+ weather = data.weather || [];
+ } catch (e) {
+ Logger.error(`Failed to parse events: ${e}`);
+ }
+
+ callback({
+ calendar: calendar,
+ events: events,
+ notes: notes,
+ moons: moons,
+ weather: weather
+ });
+ });
+ });
+ }
+ };
+
+ const InterfaceRenderer = {
+
+ render: (mode, data, callback) => {
+ const CSS_CURRENT = getCSS();
+ const theme = State.config().theme;
+
+ let content = '';
+
+ // Outer wrapper for entire handout background
+ content += ``;
+
+ content += InterfaceRenderer.renderHeader(mode);
+
+ switch (mode) {
+ case 'calendar':
+ content += InterfaceRenderer.renderCalendarMode(data);
+ break;
+ case 'design':
+ content += InterfaceRenderer.renderDesignMode(data);
+ break;
+ case 'timeline':
+ content += InterfaceRenderer.renderTimelineMode(data);
+ break;
+ default:
+ content += '
Unknown mode
';
+ }
+
+ content += '
'; // Close outer wrapper
+
+ // Save to interface handout (theme is already applied via getCSS() in each component)
+ let handout = HandoutManager.findHandout(INTERFACE_HANDOUT_NAME);
+ if (!handout) {
+ handout = HandoutManager.createHandout(INTERFACE_HANDOUT_NAME, content, '', false);
+ } else {
+ HandoutManager.setHandoutNotes(handout, content);
+ }
+
+ if (callback) callback(handout);
+ },
+
+ renderHeader: (currentMode) => {
+ const CSS_CURRENT = getCSS();
+ const modes = [
+ { key: 'calendar', label: 'Calendar' },
+ { key: 'design', label: 'Design' },
+ { key: 'timeline', label: 'Timeline' }
+ ];
+
+ const themes = [
+ { key: 'light', label: '☀️' },
+ { key: 'dark', label: '🌙' },
+ { key: 'fantasy', label: '📜' }
+ ];
+
+ let html = '';
+ html += 'Chronicle';
+
+ html += '';
+
+ // Mode buttons (inline)
+ modes.forEach(m => {
+ const style = m.key === currentMode ? CSS_CURRENT.button + 'font-weight: bold;' : CSS_CURRENT.button;
+ html += Output.makeButton(m.label, `!chr --mode ${m.key}`, style);
+ });
+
+ html += '|';
+
+ // Theme buttons (inline, same size as mode buttons)
+ themes.forEach(t => {
+ html += Output.makeButton(t.label, `!chr --theme ${t.key}`, CSS_CURRENT.button);
+ });
+
+ html += '|';
+
+ // Utility buttons (inline, same size)
+ html += Output.makeButton('Help', '!chr --help', CSS_CURRENT.button);
+ html += Output.makeButton('Send to Chat', `!chr --chat ${currentMode}`, CSS_CURRENT.button);
+
+ html += ''; // Close float:right span
+ html += '
';
+ return html;
+ },
+
+ renderCalendarMode: (data) => {
+ const calendar = data.calendar;
+ if (!calendar) {
+ return 'No calendar loaded. Use Design Mode to create one.
';
+ }
+
+ const viewingDate = State.config().viewingDate;
+ const currentDate = State.config().currentDate;
+
+ let html = '';
+
+ // Month navigation
+ html += InterfaceRenderer.renderMonthNavigation(viewingDate, calendar);
+
+ // Calendar grid
+ html += InterfaceRenderer.renderCalendarGrid(viewingDate, calendar, data);
+
+ // Events and notes for current viewing date
+ html += InterfaceRenderer.renderDayDetails(currentDate, calendar, data);
+
+ html += '
';
+ return html;
+ },
+
+ renderMonthNavigation: (viewingDate, calendar) => {
+ const CSS_CURRENT = getCSS();
+ const month = calendar.months[viewingDate.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+ const currentDate = State.config().currentDate;
+
+ let html = '';
+
+ // Previous controls
+ html += Output.makeButton('◀◀◀', `!chr --prevyear`, CSS_CURRENT.button);
+ html += Output.makeButton('◀◀', `!chr --prevmonth`, CSS_CURRENT.button);
+ html += Output.makeButton('◀', `!chr --prevday`, CSS_CURRENT.button);
+
+ html += `
`;
+
+ // Day picker with direct query
+ html += `${currentDate.day}`;
+ html += ` `;
+
+ // Month picker with direct query
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+ html += `${monthName}`;
+ html += ` `;
+
+ // Year picker with direct query
+ html += `${viewingDate.year}`;
+ html += ` `;
+
+ // Next controls
+ html += Output.makeButton('▶', `!chr --nextday`, CSS_CURRENT.button);
+ html += Output.makeButton('▶▶', `!chr --nextmonth`, CSS_CURRENT.button);
+ html += Output.makeButton('▶▶▶', `!chr --nextyear`, CSS_CURRENT.button);
+
+ html += '
';
+
+ // Featured Date (currently viewing) and Today (saved campaign date) display
+ const currentMonth = calendar.months[currentDate.month - 1];
+ const currentMonthName = currentMonth ? currentMonth.name : 'Unknown';
+
+ const todayDate = State.config().featuredDate || currentDate; // "Today" is the saved date
+ const todayMonth = calendar.months[todayDate.month - 1];
+ const todayMonthName = todayMonth ? todayMonth.name : 'Unknown';
+
+ html += ``;
+ html += `Today: ${todayMonthName} ${todayDate.day}, ${todayDate.year} `;
+ html += Output.makeButton('Go to Today', `!chr --gototoday`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Define Today as Featured', `!chr --settoday`, CSS_CURRENT.buttonSmall);
+ html += `
`;
+
+ return html;
+ },
+
+ renderCalendarGrid: (viewingDate, calendar, data) => {
+ const CSS_CURRENT = getCSS();
+ const month = calendar.months[viewingDate.month - 1];
+ if (!month) return 'Invalid month
';
+
+ const daysInWeek = calendar.weeks.daysInWeek;
+ const daysInMonth = DateUtils.getDaysInMonth(viewingDate.month, viewingDate.year, calendar);
+
+ // Find what weekday the 1st falls on
+ const firstDate = { year: viewingDate.year, month: viewingDate.month, day: 1 };
+ const firstAbsDay = DateUtils.toAbsoluteDay(firstDate, calendar);
+ const firstWeekday = (firstAbsDay - 1) % daysInWeek;
+
+ let html = '';
+
+ // Weekday header
+ html += '';
+ for (let i = 0; i < daysInWeek; i++) {
+ const dayName = calendar.weeks.weekdayNames[i] || i;
+ html += `| ${dayName} | `;
+ }
+ html += '
';
+
+ // Get special days for this year that break the week cycle
+ const specialDaysThisYear = DateUtils.getSpecialDaysForYear(viewingDate.year, calendar);
+ const betweenWeeksSpecialDays = specialDaysThisYear.filter(sd =>
+ sd.breaksWeekCycle &&
+ sd.position &&
+ sd.position.afterMonth === viewingDate.month
+ );
+
+ // Special days that occur BEFORE the month (afterDay = 0)
+ const specialDaysBeforeMonth = betweenWeeksSpecialDays.filter(sd => sd.position.afterDay === 0);
+ specialDaysBeforeMonth.forEach(sd => {
+ html += '';
+ const specialDayBg = CSS_CURRENT.calendarDay.includes('2d2d2d') ? '#3d3d3d' :
+ CSS_CURRENT.calendarDay.includes('eeeeee') ? '#d8d8d8' :
+ '#e4d4c0';
+ html += `| `;
+ html += ``;
+ html += `${sd.name}`;
+ html += ``;
+ html += ` | `;
+ html += '
';
+ });
+
+ // Calendar days
+ let dayNum = 1;
+ let dayCounter = 0;
+ let finished = false;
+
+ while (!finished) {
+ html += '';
+
+ for (let weekday = 0; weekday < daysInWeek; weekday++) {
+ if (dayCounter < firstWeekday) {
+ // Days from previous month
+ const prevDate = InterfaceRenderer.getPreviousMonthDay(
+ viewingDate,
+ firstWeekday - dayCounter,
+ calendar
+ );
+ html += InterfaceRenderer.renderCalendarCell(prevDate, calendar, true, data);
+ } else if (dayNum <= daysInMonth) {
+ // Days in current month
+ const date = { year: viewingDate.year, month: viewingDate.month, day: dayNum };
+ html += InterfaceRenderer.renderCalendarCell(date, calendar, false, data);
+ dayNum++;
+ } else {
+ // Days from next month
+ const nextDate = InterfaceRenderer.getNextMonthDay(
+ viewingDate,
+ dayNum - daysInMonth,
+ calendar
+ );
+ html += InterfaceRenderer.renderCalendarCell(nextDate, calendar, true, data);
+ dayNum++;
+ }
+
+ dayCounter++;
+ }
+
+ html += '
';
+
+ // Check for special days that occur after the last day of this week
+ const lastDayRendered = dayNum - 1;
+ const specialDaysAfterThisWeek = betweenWeeksSpecialDays.filter(sd => {
+ // Find special days where afterDay is within the range of days just rendered
+ return sd.position.afterDay > (lastDayRendered - daysInWeek) &&
+ sd.position.afterDay <= lastDayRendered;
+ });
+
+ // Sort by afterDay to show in correct order
+ specialDaysAfterThisWeek.sort((a, b) => a.position.afterDay - b.position.afterDay);
+
+ // Insert special day rows
+ specialDaysAfterThisWeek.forEach(sd => {
+ html += '';
+ // Theme-aware background color (slightly lighter than calendar cells)
+ const specialDayBg = CSS_CURRENT.calendarDay.includes('2d2d2d') ? '#3d3d3d' : // dark theme
+ CSS_CURRENT.calendarDay.includes('eeeeee') ? '#d8d8d8' : // light theme
+ '#e4d4c0'; // fantasy theme
+ html += ``;
+ html += ``;
+ html += ``;
+ html += ` ${sd.name}`;
+
+ // Get events and notes for this special day
+ const specialDayDate = {
+ year: viewingDate.year,
+ month: sd.position.afterMonth,
+ day: sd.position.afterDay + 1
+ };
+ const sdEvents = data.events.filter(e =>
+ e.dateRef.year === specialDayDate.year &&
+ e.dateRef.month === specialDayDate.month &&
+ e.dateRef.day === specialDayDate.day
+ );
+ const sdNotes = data.notes.filter(n =>
+ n.dateRef.year === specialDayDate.year &&
+ n.dateRef.month === specialDayDate.month &&
+ n.dateRef.day === specialDayDate.day
+ );
+
+ // Show events/notes if any
+ if (sdEvents.length > 0 || sdNotes.length > 0) {
+ html += ' ';
+ sdEvents.forEach(e => {
+ html += `• ${e.content.substring(0, 40)}${e.content.length > 40 ? '...' : ''} `;
+ });
+ sdNotes.forEach(n => {
+ html += `• ${n.content.substring(0, 40)}${n.content.length > 40 ? '...' : ''} `;
+ });
+ html += ' ';
+ }
+
+ html += ` `;
+ html += ``;
+ html += ` | `;
+ html += '
';
+ });
+
+ if (dayNum > daysInMonth + daysInWeek) {
+ finished = true;
+ }
+ }
+
+ // Special days that occur AFTER the month ends (afterDay >= daysInMonth)
+ // But exclude ones already shown during the month
+ const shownSpecialDayIds = new Set();
+ betweenWeeksSpecialDays.forEach(sd => {
+ if (sd.position.afterDay > 0 && sd.position.afterDay <= daysInMonth) {
+ shownSpecialDayIds.add(sd.id);
+ }
+ });
+
+ const specialDaysAfterMonth = betweenWeeksSpecialDays.filter(sd =>
+ sd.position.afterDay >= daysInMonth && !shownSpecialDayIds.has(sd.id)
+ );
+ specialDaysAfterMonth.forEach(sd => {
+ html += '';
+ const specialDayBg = CSS_CURRENT.calendarDay.includes('2d2d2d') ? '#3d3d3d' :
+ CSS_CURRENT.calendarDay.includes('eeeeee') ? '#d8d8d8' :
+ '#e4d4c0';
+ html += `| `;
+ html += ``;
+ html += `${sd.name}`;
+ html += ``;
+ html += ` | `;
+ html += '
';
+ });
+
+ html += '
';
+ return html;
+ },
+
+ renderCalendarCell: (date, calendar, otherMonth, data) => {
+ const CSS_CURRENT = getCSS();
+ const currentDate = State.config().currentDate;
+ const verboseMode = State.config().verboseCalendar || false;
+ const isToday = !otherMonth &&
+ date.year === currentDate.year &&
+ date.month === currentDate.month &&
+ date.day === currentDate.day;
+
+ let style = otherMonth ? CSS_CURRENT.calendarDayOtherMonth :
+ isToday ? CSS_CURRENT.calendarDayToday :
+ CSS_CURRENT.calendarDay;
+
+ const moons = data.moons;
+ const holidays = InterfaceRenderer.getHolidaysForDate(date, calendar);
+ const weatherCache = data.weather;
+ const events = data.events.filter(e =>
+ e.dateRef.year === date.year &&
+ e.dateRef.month === date.month &&
+ e.dateRef.day === date.day
+ );
+ const notes = data.notes.filter(n =>
+ n.dateRef.year === date.year &&
+ n.dateRef.month === date.month &&
+ n.dateRef.day === date.day
+ );
+
+ // Find weather for this date
+ const weatherForDate = weatherCache.find(w =>
+ w.dateRef.year === date.year &&
+ w.dateRef.month === date.month &&
+ w.dateRef.day === date.day
+ );
+
+ let html = ``;
+ html += ``;
+
+ // Weather emoji (float right at top)
+ if (weatherForDate) {
+ const weatherEmoji = WeatherGenerator.getWeatherEmoji(weatherForDate.description);
+ html += ` ${weatherEmoji} `;
+ }
+
+ // Date number
+ html += ``;
+ html += `${date.day}`;
+ html += ` `;
+
+ // Moon phases (sprite-based)
+ if (moons && moons.length > 0) {
+ const phases = MoonPhaseCalculator.getAllPhases(moons, date, calendar);
+ html += '';
+ phases.forEach(p => {
+ html += p.html;
+ });
+ html += ' ';
+ }
+
+ // Holidays (larger font, themed color)
+ if (holidays.length > 0) {
+ html += ``;
+ html += holidays[0].name; // Show first holiday only
+ html += ' ';
+ }
+
+ // Special Days (if "part of week" type)
+ const specialDay = DateUtils.isSpecialDay(date.month, date.day, date.year, calendar);
+ if (specialDay && !specialDay.breaksWeekCycle) {
+ html += ``;
+ html += specialDay.name;
+ html += ' ';
+ }
+
+ // Notes/Events indicator or verbose display
+ const hasContent = events.length > 0 || notes.length > 0;
+ if (hasContent) {
+ if (verboseMode) {
+ // Verbose: show actual content
+ html += '';
+ if (events.length > 0) {
+ html += 'Events: ';
+ events.forEach(e => {
+ html += `• ${e.content.substring(0, 40)}${e.content.length > 40 ? '...' : ''} `;
+ });
+ }
+ if (notes.length > 0) {
+ notes.forEach(n => {
+ html += `• ${n.content.substring(0, 40)}${n.content.length > 40 ? '...' : ''} `;
+ });
+ }
+ html += ' ';
+ } else {
+ // Indicator only
+ html += '';
+ if (events.length > 0) html += `📅 `;
+ if (notes.length > 0) html += `📝`;
+ html += ' ';
+ }
+ }
+
+ html += ''; // Close clickable link
+ html += ' | ';
+ return html;
+ },
+
+ getPreviousMonthDay: (viewingDate, daysBack, calendar) => {
+ let month = viewingDate.month - 1;
+ let year = viewingDate.year;
+
+ if (month < 1) {
+ month = calendar.months.length;
+ year--;
+ }
+
+ const daysInPrevMonth = DateUtils.getDaysInMonth(month, year, calendar);
+ const day = daysInPrevMonth - daysBack + 1;
+
+ return { year, month, day };
+ },
+
+ getNextMonthDay: (viewingDate, daysForward, calendar) => {
+ let month = viewingDate.month + 1;
+ let year = viewingDate.year;
+
+ if (month > calendar.months.length) {
+ month = 1;
+ year++;
+ }
+
+ return { year, month, day: daysForward };
+ },
+
+ getHolidaysForDate: (date, calendar) => {
+ return calendar.holidays.filter(h => {
+ if (h.type === 'absolute') {
+ return h.dateRef.month === date.month && h.dateRef.day === date.day;
+ }
+ // TODO: Handle relative dates
+ return false;
+ });
+ },
+
+ renderDayDetails: (date, calendar, data) => {
+ const CSS_CURRENT = getCSS();
+ const verbose = State.config().verboseCalendar || false;
+
+ const events = data.events.filter(e =>
+ e.dateRef.year === date.year &&
+ e.dateRef.month === date.month &&
+ e.dateRef.day === date.day
+ );
+
+ const notes = data.notes.filter(n =>
+ n.dateRef.year === date.year &&
+ n.dateRef.month === date.month &&
+ n.dateRef.day === date.day
+ );
+
+ const weather = data.weather.find(w =>
+ w.dateRef.year === date.year &&
+ w.dateRef.month === date.month &&
+ w.dateRef.day === date.day
+ );
+
+ const month = calendar.months[date.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ // Calculate day of year (1-based, counting from month 1 day 1)
+ let dayOfYear = 0;
+ for (let m = 1; m < date.month; m++) {
+ dayOfYear += DateUtils.getDaysInMonth(m, date.year, calendar);
+ }
+ dayOfYear += date.day;
+
+ const daysInYear = DateUtils.getDaysInYear(date.year, calendar);
+ const vernal = calendar.seasons.vernalEquinox || 80; // Default to day 80 if not set
+ const seasonOffset = Math.floor(daysInYear / 12); // 1/12 of year before equinox/solstice
+
+ // Calculate season boundaries (starting 1/12 year before each equinox/solstice)
+ const springStart = vernal - seasonOffset;
+ const summerStart = vernal + Math.floor(daysInYear / 4) - seasonOffset;
+ const autumnStart = vernal + Math.floor(daysInYear / 2) - seasonOffset;
+ const winterStart = vernal + Math.floor(3 * daysInYear / 4) - seasonOffset;
+
+ let season = 'winter';
+ if (dayOfYear >= springStart && dayOfYear < summerStart) {
+ season = 'spring';
+ } else if (dayOfYear >= summerStart && dayOfYear < autumnStart) {
+ season = 'summer';
+ } else if (dayOfYear >= autumnStart && dayOfYear < winterStart) {
+ season = 'autumn';
+ } else {
+ season = 'winter';
+ }
+
+ let html = '';
+
+
+ // Check if this date has a special day reference
+ if (date.specialDayId) {
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === date.specialDayId);
+ if (specialDay) {
+ html += `
Featured Date: `;
+ html += `${specialDay.name}`;
+ html += `, ${date.year}`;
+ } else {
+ // Special day not found, fall back to regular display
+ html += `
Featured Date: ${monthName} ${date.day}, ${date.year}`;
+ }
+ } else {
+ // Regular date display
+ html += `
Featured Date: ${monthName} ${date.day}, ${date.year}`;
+ }
+
+ // Control buttons with Roll20 queries
+ const verboseMode = State.config().verboseCalendar || false;
+ html += '
';
+ html += Output.makeButton(verboseMode ? '▼ Hide Details' : '▶ Show Details', `!chr --toggleverbose`, CSS_CURRENT.button);
+ html += Output.makeButton('Add Note', `!chr --savenote ?{Note text}`, CSS_CURRENT.button);
+ html += Output.makeButton('Add Event', `!chr --saveevent ?{Event text}`, CSS_CURRENT.button);
+ html += Output.makeButton('Generate Weather', `!chr --genweather`, CSS_CURRENT.button);
+ html += '
';
+
+
+ html += `
Season: ${season.charAt(0).toUpperCase() + season.slice(1)} (Day ${dayOfYear} of ${daysInYear})
`;
+
+ // Holidays
+ const holidays = (calendar.holidays || []).filter(h =>
+ h.dateRef.month === date.month &&
+ h.dateRef.day === date.day
+ );
+ if (holidays.length > 0) {
+ html += '
Holidays: ';
+ holidays.forEach((h, idx) => {
+ html += `
${h.name}`;
+ if (idx < holidays.length - 1) html += ', ';
+ });
+ html += '
';
+ }
+
+ // Special Days
+ const specialDay = DateUtils.isSpecialDay(date.month, date.day, date.year, calendar);
+ if (specialDay) {
+ html += '
';
+ }
+
+ // Weather
+ if (weather) {
+ html += '
Weather: ';
+ html += weather.description;
+ html += ` (${weather.temperature.value}°${weather.temperature.unit})`;
+ html += Output.makeButton('Regenerate', `!chr --regenweather`, CSS_CURRENT.buttonSmall + `margin-left:10px;`);
+ html += Output.makeButton('Clear Weather', `!chr --clearweather`, CSS_CURRENT.buttonSmall + `margin-left:5px;`);
+ html += '
';
+ }
+
+ // Events
+ if (events.length > 0) {
+ html += '
Events:';
+ events.forEach((e, idx) => {
+ html += `- `;
+
+ // Action buttons (always visible) with prepopulated content
+ const escapedContent = e.content.replace(/\|/g, '|').replace(/\}/g, '}');
+ html += Output.makeButton('Edit', `!chr --editevent ${e.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deleteevent ${e.id}`, CSS_CURRENT.buttonSmall);
+ html += `↔`;
+ html += `Move`;
+
+ // Content
+ html += ` ${e.content} `;
+
+ // Verbose mode: show creator, tag management, and tags
+ if (verbose) {
+ // Creator badge
+ html += `${e.createdBy} `;
+
+ // Tag management buttons
+ html += `+`;
+
+ // Build tag list for the Ⲷ button
+ const allTags = TagSystem.getAllTags(data);
+ if (allTags.length > 0) {
+ const tagList = allTags.join('|');
+ html += `Ⲷ`;
+ } else {
+ html += `Ⲷ`;
+ }
+
+ // Display existing tags
+ if (e.tags && e.tags.length > 0) {
+ e.tags.forEach(tag => {
+ html += `${tag}`;
+ });
+ }
+ }
+
+ html += '
';
+ });
+ html += '
';
+ }
+
+ // Notes
+ if (notes.length > 0) {
+ html += '
Notes:';
+ notes.forEach((n, idx) => {
+ html += `- `;
+
+ // Action buttons (always visible) with prepopulated content
+ const escapedContent = n.content.replace(/\|/g, '|').replace(/\}/g, '}');
+ html += Output.makeButton('Edit', `!chr --editnote ${n.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deletenote ${n.id}`, CSS_CURRENT.buttonSmall);
+ html += `↔`;
+ html += `Move`;
+
+ // Content
+ html += ` ${n.content} `;
+
+ // Verbose mode: show creator, tag management, and tags
+ if (verbose) {
+ // Creator badge
+ html += `${n.createdBy} `;
+
+ // Tag management buttons
+ html += `+`;
+
+ // Build tag list for the Ⲷ button
+ const allTags = TagSystem.getAllTags(data);
+ if (allTags.length > 0) {
+ const tagList = allTags.join('|');
+ html += `Ⲷ`;
+ } else {
+ html += `Ⲷ`;
+ }
+
+ // Display existing tags
+ if (n.tags && n.tags.length > 0) {
+ n.tags.forEach(tag => {
+ html += `${tag}`;
+ });
+ }
+ }
+
+ html += '
';
+ });
+ html += '
';
+ }
+
+ html += '
';
+ return html;
+ },
+
+ renderDesignMode: (data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar || DataModels.createCalendar('New Calendar');
+
+ let html = '';
+ html += '
Calendar Design
';
+
+ // Calendar selection
+ html += '
';
+ html += '
Active Calendar: ' + (calendar.name || 'None');
+ html += '
';
+
+ // Built-in calendars
+ html += Output.makeButton('Load Gregorian', `!chr --loadcal gregorian`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Absalom', `!chr --loadcal absalom`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Faerun', `!chr --loadcal faerun`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Greyhawk', `!chr --loadcal greyhawk`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Eberron', `!chr --loadcal eberron`, CSS_CURRENT.button);
+
+ // Find all custom calendar handouts
+ const allHandouts = findObjs({ type: 'handout' });
+ const presetCalendarNames = [
+ HANDOUT_PREFIX + ' Calendar: Gregorian',
+ HANDOUT_PREFIX + ' Calendar: Absalom Reckoning',
+ HANDOUT_PREFIX + ' Calendar: Faerun',
+ HANDOUT_PREFIX + ' Calendar: Greyhawk',
+ HANDOUT_PREFIX + ' Calendar: Eberron'
+ ];
+
+ const customCalendars = allHandouts.filter(h => {
+ const name = h.get('name');
+ return name.startsWith(HANDOUT_PREFIX + ' Calendar:') &&
+ !presetCalendarNames.includes(name);
+ });
+
+ // Add button for each custom calendar
+ customCalendars.forEach(h => {
+ const fullName = h.get('name');
+ const calName = fullName.replace(HANDOUT_PREFIX + ' Calendar: ', '');
+ html += Output.makeButton('Load ' + calName, '!chr --loadcal ' + calName, CSS_CURRENT.button);
+ });
+
+ html += '
New Calendar';
+ html += '
';
+ html += '
';
+
+ // Calendar Description
+ if (calendar.description) {
+ html += '
';
+ html += '
' + calendar.description + '
';
+ html += Output.makeButton('Edit Description', '!chr --savedescription ?{Calendar Description|' + calendar.description.replace(/'/g, ''').replace(/"/g, '"') + '}', CSS_CURRENT.buttonSmall);
+ html += '
';
+ } else {
+ html += '
';
+ html += '
No description set.
';
+ html += Output.makeButton('Add Description', '!chr --savedescription ?{Calendar Description}', CSS_CURRENT.buttonSmall);
+ html += '
';
+ }
+
+ // Basic settings
+ html += '
';
+ html += '
Basic Settings
';
+ html += `
Calendar Name: ${calendar.name} `;
+ html += Output.makeButton('Edit', `!chr --savename ?{Calendar Name|${calendar.name}}`, CSS_CURRENT.buttonSmall);
+ html += '
';
+ html += `
Days in Year: ${calendar.daysInYear} `;
+ html += Output.makeButton('Edit', `!chr --savedaysinyear ?{Days in Year|${calendar.daysInYear}}`, CSS_CURRENT.buttonSmall);
+ html += ` Note: Do not include intercalery days, (those which do not receive a week day)`;
+ html += '
';
+ html += `
Days in Week: ${calendar.weeks.daysInWeek} `;
+ html += Output.makeButton('Edit', `!chr --savedaysinweek ?{Days in Week|${calendar.weeks.daysInWeek}}`, CSS_CURRENT.buttonSmall);
+ html += '
';
+ html += '
';
+
+ // Months
+ html += '
';
+ html += '
Months
';
+ if (calendar.months.length === 0) {
+ html += '
No months defined
';
+ } else {
+ html += '
';
+ html += '| Order | Name | Days | Actions |
';
+ calendar.months.forEach((m, idx) => {
+ html += '';
+ html += `| ${idx + 1} | `;
+ html += `${m.name} | `;
+ html += `${m.days} | `;
+ html += ``;
+
+ // Up arrow (disabled for first item)
+ if (idx > 0) {
+ html += Output.makeButton('↑', `!chr --movemonth ${idx}|up`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↑`;
+ }
+
+ // Down arrow (disabled for last item)
+ if (idx < calendar.months.length - 1) {
+ html += Output.makeButton('↓', `!chr --movemonth ${idx}|down`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↓`;
+ }
+
+ html += Output.makeButton('Edit', `!chr --updatemonth ${idx}|?{Month Name|${m.name}}|?{Days|${m.days}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --delmonth ${idx}`, CSS_CURRENT.buttonSmall);
+ html += ' | ';
+ html += '
';
+ });
+ html += '
';
+ }
+ html += '
';
+ html += Output.makeButton('Add Month', `!chr --savemonth ?{Month Name}|?{Days in Month}`, CSS_CURRENT.button);
+ html += '
';
+ html += '
';
+
+ // Weekday names
+ html += '
';
+ html += '
Weekday Names
';
+ html += '
' + calendar.weeks.weekdayNames.join(', ') + '
';
+ const weekdayStr = calendar.weeks.weekdayNames.join(',');
+ html += Output.makeButton('Edit Weekdays', `!chr --saveweekdays ?{Weekday Names (comma-separated)|${weekdayStr}}`, CSS_CURRENT.button);
+ html += '
';
+
+ // Holidays
+ html += '
';
+ html += '
Holidays
';
+ if (!calendar.holidays || calendar.holidays.length === 0) {
+ html += '
No holidays defined
';
+ } else {
+ html += '
';
+ html += '| Name | Date | Description | Recurring | Actions |
';
+ calendar.holidays.forEach((h, idx) => {
+ html += '';
+ html += `| ${h.name} | `;
+ html += ``;
+ if (h.type === 'absolute') {
+ html += `${h.dateRef.month}/${h.dateRef.day}`;
+ } else {
+ html += `Relative`;
+ }
+ html += ` | `;
+ html += `${h.description || 'None'} | `;
+ html += `${h.recurring ? 'Yes' : 'No'} | `;
+ html += ``;
+
+ // Edit button - edit all fields
+ const escapedName = h.name.replace(/\|/g, '|').replace(/\}/g, '}');
+ const escapedDesc = (h.description || '').replace(/\|/g, '|').replace(/\}/g, '}');
+ const recurringDefault = h.recurring ? 'Yes' : 'No';
+ html += Output.makeButton('Edit',
+ `!chr --editholiday ${idx}|?{Holiday Name|${escapedName}}|?{Month (1-12)|${h.dateRef.month}}|?{Day|${h.dateRef.day}}|?{Recurring?|${recurringDefault}|Yes|No}|?{Description|${escapedDesc}}`,
+ CSS_CURRENT.buttonSmall);
+
+ // Up/Down arrows
+ if (idx > 0) {
+ html += Output.makeButton('↑', `!chr --moveholiday ${idx}|up`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↑`;
+ }
+ if (idx < calendar.holidays.length - 1) {
+ html += Output.makeButton('↓', `!chr --moveholiday ${idx}|down`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↓`;
+ }
+
+ html += Output.makeButton('Delete', `!chr --deleteholiday ${idx}`, CSS_CURRENT.buttonSmall);
+ html += ' | ';
+ html += '
';
+ });
+ html += '
';
+ }
+ html += '
';
+ html += Output.makeButton('Add Holiday',
+ `!chr --addholiday ?{Holiday Name}|?{Month (1-12)}|?{Day}|?{Recurring?|Yes|No}|?{Description (optional)||}`,
+ CSS_CURRENT.button);
+ html += '
';
+ html += '
';
+
+ // Special Days
+ html += '
';
+ html += '
Special Days
';
+ html += '
Intercalary days (like Midsummer, leap days) that occur outside normal month/week structure
';
+ const specialDays = calendar.interMonthDays || [];
+ if (specialDays.length === 0) {
+ html += '
No special days defined
';
+ } else {
+ html += '
';
+ html += '| Name | Position | Type | Week Behavior | Description | Actions |
';
+ specialDays.forEach((sd, idx) => {
+ html += '';
+ html += `| ${sd.name} | `;
+ html += ``;
+ if (sd.position && sd.position.afterMonth) {
+ const monthName = calendar.months[sd.position.afterMonth - 1]?.name || '?';
+ html += `After ${monthName} ${sd.position.afterDay || ''}`;
+ } else {
+ html += 'Not set';
+ }
+ html += ` | `;
+ html += ``;
+ if (sd.dayType === 'leap') {
+ html += `Leap (every ${sd.frequency} yrs)`;
+ } else {
+ html += 'Fixed';
+ }
+ html += ` | `;
+ html += `${sd.breaksWeekCycle ? 'Between weeks' : 'Part of week'} | `;
+ html += `${sd.description || 'None'} | `;
+ html += ``;
+
+ // Edit button - direct href
+ const escapedName = sd.name.replace(/\|/g, '|').replace(/\}/g, '}');
+ const escapedDesc = (sd.description || '').replace(/\|/g, '|').replace(/\}/g, '}');
+
+ const currentMonth = calendar.months[sd.position.afterMonth - 1];
+ const monthList = calendar.months.map((m, idx) => {
+ const num = idx + 1;
+ return `${m.name},${num}`;
+ }).join('|');
+ const monthDefault = `${currentMonth.name},${sd.position.afterMonth}`;
+
+ const weekBehaviorDefault = sd.breaksWeekCycle ? 'Between weeks,betweenWeeks' : 'Part of week,partOfWeek';
+
+ let editQuery = `!chr --updatespecialday ${idx}|${sd.dayType}|?{Name|${escapedName}}|?{After Which Month?|${monthDefault}|${monthList}}|?{After Which Day?|${sd.position.afterDay}}|?{Week Behavior|${weekBehaviorDefault}|Part of week,partOfWeek|Between weeks,betweenWeeks}`;
+
+ if (sd.dayType === 'leap') {
+ editQuery += `|?{Frequency|${sd.frequency}}|?{Offset|${sd.offset}}`;
+ }
+
+ editQuery += `|?{Description|${escapedDesc}}`;
+
+ html += `Edit`;
+ html += Output.makeButton('Delete', `!chr --deletespecialday ${idx}`, CSS_CURRENT.buttonSmall);
+ html += ' | ';
+ html += '
';
+ });
+ html += '
';
+ }
+ html += '
';
+
+ // Build month list for special day queries
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+
+ // Fixed special day query
+ const fixedQuery = `!chr --savespecialday fixed|?{Name}|?{After Which Month?|${monthList}}|?{After Which Day? (0=before month)}|?{Week Behavior|Part of week,partOfWeek|Between weeks,betweenWeeks}|?{Description (optional)|}`;
+ html += `
Add Fixed Special Day`;
+
+ // Leap special day query
+ const leapQuery = `!chr --savespecialday leap|?{Name}|?{After Which Month?|${monthList}}|?{After Which Day? (0=before month)}|?{Week Behavior|Part of week,partOfWeek|Between weeks,betweenWeeks}|?{Every N years (frequency)|4}|?{Year offset|0}|?{Description (optional)|}`;
+ html += `
Add Leap Special Day`;
+
+ html += '
';
+ html += '
';
+
+ // Moons
+ html += '
';
+ html += '
Moons
';
+ const moons = data.moons;
+ if (!moons || moons.length === 0) {
+ html += '
No moons defined
';
+ } else {
+ html += '
';
+ html += '';
+ html += '| Name | ';
+ html += 'Period | ';
+ html += 'Size | ';
+ html += 'Color | ';
+ html += 'Visible | ';
+ html += 'Actions | ';
+ html += '
';
+
+ moons.forEach((m, idx) => {
+ const size = m.size || 1;
+ const color = m.color || 'yellow';
+ const display = m.display !== false ? 'Yes' : 'No';
+
+ html += '';
+ html += '| ' + m.name + ' | ';
+ html += '' + m.period + 'd | ';
+ html += '' + size + ' | ';
+ html += '' + color + ' | ';
+ html += '' + display + ' | ';
+ html += '';
+
+ // Up/Down arrows
+ if (idx > 0) {
+ html += Output.makeButton('↑', '!chr --movemoon ' + idx + '|up', CSS_CURRENT.buttonSmall);
+ }
+ if (idx < moons.length - 1) {
+ html += Output.makeButton('↓', '!chr --movemoon ' + idx + '|down', CSS_CURRENT.buttonSmall);
+ }
+
+ html += Output.makeButton('Edit',
+ '!chr --updatemoon ' + idx + '|?{Moon Name|' + m.name + '}|?{Period|' + m.period + '}|?{Full Year|' + m.fullDayRef.year + '}|?{Full Month|' + m.fullDayRef.month + '}|?{Full Day|' + m.fullDayRef.day + '}|?{Size (0.1-1.0)|' + size + '}|?{Color|' + color + ',yellow|red,red|green,green|blue,blue|cyan,cyan|orange,orange|purple,purple|tan,tan|brown,brown|white,white|gray,gray|dark,dark}|?{Display on grid?|' + (m.display !== false ? 'true' : 'false') + ',true|false,false}',
+ CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', '!chr --delmoon ' + idx + '', CSS_CURRENT.buttonSmall);
+
+ html += ' | ';
+ html += '
';
+ });
+
+ html += '
';
+ }
+ html += Output.makeButton('Add Moon',
+ '!chr --savemoon ?{Moon Name}|?{Period in Days (decimals OK)|28}|?{Year when full|1}|?{Month when full|1}|?{Day when full|1}|?{Size (0.1-1.0)|1}|?{Color|yellow,yellow|red,red|green,green|blue,blue|cyan,cyan|orange,orange|purple,purple|tan,tan|brown,brown|white,white|gray,gray|dark,dark}|?{Display on grid?|true,true|false,false}',
+ CSS_CURRENT.button);
+ html += '
';
+
+ // Climate
+ html += '
';
+ html += '
Climate
';
+ if (calendar.climate) {
+ html += `
${calendar.climate.climate_name} (${calendar.climate.koppen_code})
`;
+ html += `
${calendar.climate.biome_hint}
`;
+ } else {
+ html += '
No climate set
';
+ }
+ html += Output.makeButton('Set Climate',
+ `!chr --saveclimate ?{Latitude|tropical|subtropical|temperate|subarctic|polar}|?{Ocean Proximity|coastal|near_coastal|inland|continental}|?{Coast Type|west|east|none}|?{Elevation|lowland|highland|alpine}|?{Rainshadow|windward|leeward|neutral}`,
+ CSS_CURRENT.button);
+
+ // Temperature units toggle
+ const currentUnits = calendar.units || 'us';
+ const unitsLabel = currentUnits === 'us' ? 'F' : 'C';
+ html += Output.makeButton(`Units: ${unitsLabel}`, `!chr --toggleunits`, CSS_CURRENT.buttonSmall);
+
+ html += '
';
+
+ // Seasons
+ html += '
';
+ html += '
Seasons & Equinoxes
';
+ html += `
Vernal Equinox: Day ${calendar.seasons.vernalEquinox} of ${calendar.daysInYear} `;
+ html += Output.makeButton('Edit',
+ `!chr --setvernalequinox ?{Day of Year for Vernal Equinox|${calendar.seasons.vernalEquinox}}`,
+ CSS_CURRENT.buttonSmall);
+ html += '
';
+
+ // Calculate and display the other seasonal points
+ const vernal = calendar.seasons.vernalEquinox;
+ const daysInYear = calendar.daysInYear;
+ const summer = vernal + Math.floor(daysInYear / 4);
+ const autumnal = vernal + Math.floor(daysInYear / 2);
+ const winter = vernal + Math.floor(3 * daysInYear / 4);
+
+ html += `
Based on this setting:
`;
+ html += `
`;
+ html += `- Spring Equinox (Vernal): Day ${vernal}
`;
+ html += `- Summer Solstice: Day ${summer}
`;
+ html += `- Autumn Equinox: Day ${autumnal}
`;
+ html += `- Winter Solstice: Day ${winter}
`;
+ html += `
`;
+ html += '
These points divide the year into four equal seasons for weather generation.
';
+ html += '
';
+
+ // Leap Years
+ html += '
';
+ html += '
Leap Years
';
+ html += `
Enabled: ${calendar.leapYears.enabled ? 'Yes' : 'No'} `;
+ html += Output.makeButton('Toggle', `!chr --toggleleap`, CSS_CURRENT.buttonSmall);
+ html += '
';
+
+ if (calendar.leapYears.enabled) {
+ html += `
Cycle: Every ${calendar.leapYears.cycle} years `;
+ html += Output.makeButton('Edit',
+ `!chr --setleapcycle ?{Leap Year Cycle|${calendar.leapYears.cycle}}`,
+ CSS_CURRENT.buttonSmall);
+ html += '
';
+
+ html += '
Exception Years: ';
+ if (calendar.leapYears.exceptions && calendar.leapYears.exceptions.length > 0) {
+ calendar.leapYears.exceptions.forEach((year, idx) => {
+ html += `${year} `;
+ html += Output.makeButton('✖',
+ `!chr --removeleapexception ${idx}`,
+ CSS_CURRENT.buttonSmall);
+ html += ' ';
+ });
+ } else {
+ html += 'None';
+ }
+ html += '
';
+ html += '
';
+ html += Output.makeButton('Add Exception Year',
+ `!chr --addleapexception ?{Year to Exclude from Leap Years}`,
+ CSS_CURRENT.button);
+ html += '
';
+
+ html += `
When enabled, adds 1 day to the year every ${calendar.leapYears.cycle} years (except exception years). February typically receives the extra day in Gregorian-style calendars.
`;
+ }
+ html += '
';
+
+ html += '
';
+ return html;
+ },
+
+ renderTimelineMode: (data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ const events = data.events;
+ const notes = data.notes;
+ const holidays = calendar.holidays || [];
+
+ // Get timeline state from State config (create if doesn't exist)
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR', // 'OR' or 'AND'
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ showWeather: false,
+ showDetails: false,
+ showUntagged: false, // Show items with no tags
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ // Get all unique tags
+ const allTags = TagSystem.getAllTags(data);
+
+ // Build pipe-separated tag list for queries
+ const tagQueryString = Array.from(allTags).sort().join('|');
+
+ let html = '';
+
+ // ===== LEFT SIDEBAR =====
+ html += '| ';
+
+ // Type toggles
+ html += ' ';
+ html += 'Type: ';
+ html += Output.makeButton(
+ timelineState.showEvents ? '✓ Events' : 'Events',
+ `!chr --tl-toggle event`,
+ timelineState.showEvents ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += Output.makeButton(
+ timelineState.showNotes ? '✓ Notes' : 'Notes',
+ `!chr --tl-toggle note`,
+ timelineState.showNotes ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += Output.makeButton(
+ timelineState.showHolidays ? '✓ Holidays' : 'Holidays',
+ `!chr --tl-toggle holiday`,
+ timelineState.showHolidays ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += Output.makeButton(
+ timelineState.showWeather ? '✓ Weather' : 'Weather',
+ `!chr --tl-toggle weather`,
+ timelineState.showWeather ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += ' ';
+
+ // Show Details toggle
+ html += '';
+ html += Output.makeButton(
+ timelineState.showDetails ? '▼ Hide Details' : '▶ Show Details',
+ `!chr --tl-toggle details`,
+ timelineState.showDetails ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += ' ';
+
+ // Date range controls
+ html += '';
+ html += ' Date Range: ';
+
+ const startYearText = timelineState.startYear || '---';
+ html += ` `;
+ html += startYearText;
+ html += ``;
+
+ html += Output.makeButton('All', `!chr --tl-clearrange`, CSS_CURRENT.buttonSmall);
+
+ const endYearText = timelineState.endYear || '---';
+ html += ` `;
+ html += endYearText;
+ html += ``;
+
+ // Sort toggle
+ const sortIcon = timelineState.sortAscending ? '↓' : '↑';
+ html += Output.makeButton(sortIcon, `!chr --tl-togglesort`, CSS_CURRENT.buttonSmall);
+
+ html += ' ';
+
+ // Tag mode toggle
+ html += '';
+ html += 'Tag Mode: ';
+ html += Output.makeButton(
+ timelineState.tagMode === 'OR' ? 'ANY (OR)' : 'ALL (AND)',
+ `!chr --tl-togglemode`,
+ CSS_CURRENT.buttonSmall
+ );
+ html += ' ';
+
+ // Select All / Deselect All buttons
+ if (allTags.length > 0) {
+ html += '';
+ if (timelineState.selectedTags.length === allTags.length) {
+ html += Output.makeButton('Deselect All', `!chr --tl-deselectall`, CSS_CURRENT.buttonSmall);
+ } else if (timelineState.selectedTags.length === 0) {
+ html += Output.makeButton('Select All', `!chr --tl-selectall`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += Output.makeButton('Select All', `!chr --tl-selectall`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Deselect All', `!chr --tl-deselectall`, CSS_CURRENT.buttonSmall);
+ }
+ html += ' ';
+ }
+
+ // Tag list
+ html += 'Tags: ';
+ html += '';
+
+ if (allTags.length === 0) {
+ html += ' No tags yet ';
+ } else {
+ allTags.forEach(tag => {
+ const isSelected = timelineState.selectedTags.includes(tag);
+ let tagStyle = CSS_CURRENT.tag;
+
+ if (isSelected) {
+ // Different color based on AND/OR mode
+ if (timelineState.tagMode === 'OR') {
+ // OR mode - blue/highlighted
+ tagStyle = 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #4a7ac2; color: #ffffff; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px; font-weight: bold;';
+ } else {
+ // AND mode - green/highlighted
+ tagStyle = 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #5a9f5a; color: #ffffff; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px; font-weight: bold;';
+ }
+ }
+
+ html += ' ' + tag + ' ';
+ });
+ }
+
+ // Add "Untagged" filter
+ html += ' ';
+ const showUntagged = timelineState.showUntagged || false;
+ const untaggedStyle = showUntagged ?
+ 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #888888; color: #ffffff; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px; font-weight: bold;' :
+ CSS_CURRENT.tag;
+ html += ' [Untagged]';
+ html += ' ';
+
+ html += ' ';
+ html += ' | '; // End left sidebar
+
+ // ===== RIGHT CONTENT AREA =====
+ html += '';
+
+ if (timelineState.selectedTags.length === 0 && !timelineState.showUntagged) {
+ html += ' ';
+ html += 'Select one or more tags to view timeline';
+ html += ' ';
+ } else {
+ // Filter items based on selected tags, tag mode, and untagged filter
+ let filteredItems = [];
+
+ // Add events if toggled on
+ if (timelineState.showEvents) {
+ events.forEach(e => {
+ let shouldInclude = false;
+
+ // Check if item has tags
+ const hasTags = e.tags && e.tags.length > 0;
+
+ // Show item if ANY of these conditions are true:
+ // 1. showUntagged is ON and item has NO tags
+ if (timelineState.showUntagged && !hasTags) {
+ shouldInclude = true;
+ }
+ // 2. tags are selected and item matches them
+ if (timelineState.selectedTags.length > 0 && hasTags) {
+ const matches = timelineState.tagMode === 'OR'
+ ? e.tags.some(t => timelineState.selectedTags.includes(t))
+ : timelineState.selectedTags.every(t => e.tags.includes(t));
+ if (matches) shouldInclude = true;
+ }
+ // 3. NO filters active - show all items
+ if (timelineState.selectedTags.length === 0 && !timelineState.showUntagged) {
+ shouldInclude = true;
+ }
+
+ if (shouldInclude) {
+ filteredItems.push({
+ type: 'event',
+ date: e.dateRef,
+ content: e.content,
+ item: e
+ });
+ }
+ });
+ }
+
+ // Add notes if toggled on
+ if (timelineState.showNotes) {
+ notes.forEach(n => {
+ let shouldInclude = false;
+
+ // Check if item has tags
+ const hasTags = n.tags && n.tags.length > 0;
+
+ // Show item if ANY of these conditions are true:
+ // 1. showUntagged is ON and item has NO tags
+ if (timelineState.showUntagged && !hasTags) {
+ shouldInclude = true;
+ }
+ // 2. tags are selected and item matches them
+ if (timelineState.selectedTags.length > 0 && hasTags) {
+ const matches = timelineState.tagMode === 'OR'
+ ? n.tags.some(t => timelineState.selectedTags.includes(t))
+ : timelineState.selectedTags.every(t => n.tags.includes(t));
+ if (matches) shouldInclude = true;
+ }
+ // 3. NO filters active - show all items
+ if (timelineState.selectedTags.length === 0 && !timelineState.showUntagged) {
+ shouldInclude = true;
+ }
+
+ if (shouldInclude) {
+ filteredItems.push({
+ type: 'note',
+ date: n.dateRef,
+ content: n.content,
+ item: n // Store full object
+ });
+ }
+ });
+ }
+
+ // Find date range of filtered items
+ if (filteredItems.length > 0) {
+ const sortedItems = [...filteredItems].sort((a, b) => {
+ const aAbs = DateUtils.toAbsoluteDay(a.date, calendar);
+ const bAbs = DateUtils.toAbsoluteDay(b.date, calendar);
+ return aAbs - bAbs;
+ });
+
+ const earliestDate = sortedItems[0].date;
+ const latestDate = sortedItems[sortedItems.length - 1].date;
+
+ // Calculate year span
+ const yearSpan = latestDate.year - earliestDate.year;
+
+ // Add holidays if toggled on, within range, AND span is one year or less
+ if (timelineState.showHolidays && yearSpan <= 1) {
+ holidays.forEach(h => {
+ // Check if holiday falls within the date range of filtered items
+ const holidayDate = { year: earliestDate.year, month: h.dateRef.month, day: h.dateRef.day };
+
+ // Check each year in range
+ for (let year = earliestDate.year; year <= latestDate.year; year++) {
+ const hDate = { year: year, month: h.dateRef.month, day: h.dateRef.day };
+ const hAbs = DateUtils.toAbsoluteDay(hDate, calendar);
+ const earlyAbs = DateUtils.toAbsoluteDay(earliestDate, calendar);
+ const lateAbs = DateUtils.toAbsoluteDay(latestDate, calendar);
+
+ if (hAbs >= earlyAbs && hAbs <= lateAbs) {
+ filteredItems.push({
+ type: 'holiday',
+ date: hDate,
+ content: h.name
+ });
+ }
+ }
+ });
+
+ // Add special days if toggled on
+ const specialDays = calendar.interMonthDays || [];
+ specialDays.forEach(sd => {
+ // Check each year in range
+ for (let year = earliestDate.year; year <= latestDate.year; year++) {
+ // Check if this special day occurs in this year (leap day logic)
+ const specialDaysForYear = DateUtils.getSpecialDaysForYear(year, calendar);
+ if (specialDaysForYear.find(s => s.id === sd.id)) {
+ const sdDate = { year: year, month: sd.position.afterMonth, day: sd.position.afterDay + 1 };
+ const sdAbs = DateUtils.toAbsoluteDay(sdDate, calendar);
+ const earlyAbs = DateUtils.toAbsoluteDay(earliestDate, calendar);
+ const lateAbs = DateUtils.toAbsoluteDay(latestDate, calendar);
+
+ if (sdAbs >= earlyAbs && sdAbs <= lateAbs) {
+ filteredItems.push({
+ type: 'specialday',
+ date: sdDate,
+ content: sd.name,
+ specialDayId: sd.id
+ });
+ }
+ }
+ }
+ });
+ }
+ }
+
+ // Add weather if toggled on
+ if (timelineState.showWeather) {
+ const weather = data.weather || [];
+ weather.forEach(w => {
+ filteredItems.push({
+ type: 'weather',
+ date: w.dateRef,
+ content: `${w.description} (${w.temperature.value}°${w.temperature.unit})`,
+ item: w
+ });
+ });
+ }
+
+ // Apply year range filter
+ if (timelineState.startYear) {
+ filteredItems = filteredItems.filter(item => item.date.year >= timelineState.startYear);
+ }
+ if (timelineState.endYear) {
+ filteredItems = filteredItems.filter(item => item.date.year <= timelineState.endYear);
+ }
+
+ // Sort by date
+ filteredItems.sort((a, b) => {
+ const aAbs = DateUtils.toAbsoluteDay(a.date, calendar);
+ const bAbs = DateUtils.toAbsoluteDay(b.date, calendar);
+ return timelineState.sortAscending ? aAbs - bAbs : bAbs - aAbs;
+ });
+
+ if (filteredItems.length === 0) {
+ html += '';
+ html += 'No items match the selected filters';
+ html += ' ';
+ } else {
+ // Group items by date
+ const itemsByDate = {};
+ filteredItems.forEach(item => {
+ const key = `${item.date.year}-${item.date.month}-${item.date.day}`;
+ if (!itemsByDate[key]) {
+ itemsByDate[key] = {
+ date: item.date,
+ items: []
+ };
+ }
+ itemsByDate[key].items.push(item);
+ });
+
+ // Render timeline table
+ html += '';
+
+ let lastYear = null;
+ let lastMonth = null;
+
+ Object.keys(itemsByDate).forEach(key => {
+ const entry = itemsByDate[key];
+ const d = entry.date;
+ const month = calendar.months[d.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ // Calculate weekday
+ const absDay = DateUtils.toAbsoluteDay(d, calendar);
+ const weekdayIndex = (absDay - 1) % calendar.weeks.daysInWeek;
+ const weekdayName = calendar.weeks.weekdayNames[weekdayIndex] || 'Day';
+
+ // Check if only events (no notes or holidays)
+ const hasOnlyEvents = entry.items.every(item => item.type === 'event');
+
+ html += '';
+
+ // Date column - theme-aware colors, clickable
+ html += ``;
+ html += ``;
+
+ if (d.year !== lastYear) {
+ html += `${d.year} `;
+ lastYear = d.year;
+ lastMonth = null; // Reset month when year changes
+ }
+
+ // Only show month/day if not just events
+ if (!hasOnlyEvents) {
+ if (d.month !== lastMonth) {
+ html += `${monthName} `;
+ lastMonth = d.month;
+ }
+
+ html += `${weekdayName} ${d.day}`;
+ }
+ html += '';
+ html += ' | ';
+
+ // Content column
+ html += '';
+
+ entry.items.forEach(item => {
+ if (item.type === 'holiday') {
+ html += ` Holiday: ${item.content} `;
+ } else if (item.type === 'specialday') {
+ html += ``;
+ } else if (item.type === 'weather') {
+ html += `Weather: ${item.content} `;
+ } else if (item.type === 'event' && timelineState.showDetails && item.item) {
+ // Show event with action buttons and tags
+ const e = item.item;
+ const escapedContent = e.content.replace(/\|/g, '|').replace(/\}/g, '}');
+
+ // Calculate elapsed time from the viewing date (currentDate)
+ const viewingDate = State.config().currentDate || { year: 1, month: 1, day: 1 };
+ const elapsed = DateUtils.getElapsedTime(viewingDate, e.dateRef, calendar);
+ let elapsedText = '';
+ if (elapsed.isFirstOfYear) {
+ elapsedText = (elapsed.isNegative ? '-' : '') + elapsed.years + 'y';
+ } else {
+ if (elapsed.years > 0) elapsedText += elapsed.years + 'y.';
+ if (elapsed.months > 0 || elapsed.years > 0) elapsedText += elapsed.months + 'm.';
+ elapsedText += elapsed.days + 'd';
+ if (elapsed.isNegative) elapsedText = '-' + elapsedText;
+ }
+
+ html += ``;
+ html += ' ';
+ html += e.content;
+ // Elapsed time button floating right
+ html += ` ${elapsedText}`;
+ html += ' ';
+
+ // Buttons and tags on same line
+ html += ' ';
+ html += Output.makeButton('Edit', `!chr --editevent ${e.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deleteevent ${e.id}`, CSS_CURRENT.buttonSmall);
+ html += ` ↔`;
+ html += ` Move`;
+ html += ` +Tag`;
+ if (e.tags && e.tags.length > 0) {
+ html += ' ';
+ e.tags.forEach(tag => {
+ const tagState = timelineState.selectedTags.includes(tag) ? 'active' : 'inactive';
+ html += Output.makeButton(tag, `!chr --tl-tag ${tag}`, CSS_CURRENT.tag);
+ });
+ }
+ html += ' ';
+ html += ' ';
+ } else if (item.type === 'note' && timelineState.showDetails && item.item) {
+ // Show note with action buttons and tags
+ const n = item.item;
+ const escapedContent = n.content.replace(/\|/g, '|').replace(/\}/g, '}');
+
+ // Calculate elapsed time from the viewing date (currentDate)
+ const viewingDate = State.config().currentDate || { year: 1, month: 1, day: 1 };
+ const elapsed = DateUtils.getElapsedTime(viewingDate, n.dateRef, calendar);
+ let elapsedText = '';
+ if (elapsed.isFirstOfYear) {
+ elapsedText = (elapsed.isNegative ? '-' : '') + elapsed.years + 'y';
+ } else {
+ if (elapsed.years > 0) elapsedText += elapsed.years + 'y.';
+ if (elapsed.months > 0 || elapsed.years > 0) elapsedText += elapsed.months + 'm.';
+ elapsedText += elapsed.days + 'd';
+ if (elapsed.isNegative) elapsedText = '-' + elapsedText;
+ }
+
+ html += ``;
+ html += ' ';
+ html += n.content;
+ // Elapsed time button floating right
+ html += ` ${elapsedText}`;
+ html += ' ';
+
+ // Buttons and tags on same line
+ html += ' ';
+ html += Output.makeButton('Edit', `!chr --editnote ${n.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deletenote ${n.id}`, CSS_CURRENT.buttonSmall);
+ html += ` ↔`;
+ html += ` Move`;
+ html += ` +Tag`;
+ if (n.tags && n.tags.length > 0) {
+ html += ' ';
+ n.tags.forEach(tag => {
+ const tagState = timelineState.selectedTags.includes(tag) ? 'active' : 'inactive';
+ html += Output.makeButton(tag, `!chr --tl-tag ${tag}`, CSS_CURRENT.tag);
+ });
+ }
+ html += ' ';
+ html += ' ';
+ } else {
+ // Simple display - just content with elapsed time button
+ const viewingDate = State.config().currentDate || { year: 1, month: 1, day: 1 };
+ const elapsed = DateUtils.getElapsedTime(viewingDate, item.date, calendar);
+ let elapsedText = '';
+ if (elapsed.isFirstOfYear) {
+ elapsedText = (elapsed.isNegative ? '-' : '') + elapsed.years + 'y';
+ } else {
+ if (elapsed.years > 0) elapsedText += elapsed.years + 'y.';
+ if (elapsed.months > 0 || elapsed.years > 0) elapsedText += elapsed.months + 'm.';
+ elapsedText += elapsed.days + 'd';
+ if (elapsed.isNegative) elapsedText = '-' + elapsedText;
+ }
+
+ html += '';
+ }
+ });
+
+ html += ' | ';
+ html += ' ';
+ });
+
+ html += ' ';
+ }
+ }
+
+ html += ' | '; // End content area
+ html += '
'; // End outer table
+
+ return html;
+ }
+
+ };
+
+ // ==================================================
+ // Commands (Single Root)
+ // ==================================================
+
+ const Commands = {
+
+ root: (msg, parsed) => {
+ const { args } = parsed;
+
+ if (args.help) {
+ return Commands.help(msg);
+ }
+
+ if (args.init) {
+ return Commands.initialize(msg, args);
+ }
+
+ if (args.mode) {
+ return Commands.setMode(msg, args.mode);
+ }
+
+ if (args.theme) {
+ return Commands.setTheme(msg, args.theme);
+ }
+
+ if (args.loadcal) {
+ return Commands.loadCalendar(msg, args.loadcal);
+ }
+
+ if (args.prevmonth) {
+ return Commands.previousMonth(msg);
+ }
+
+ if (args.nextmonth) {
+ return Commands.nextMonth(msg);
+ }
+
+ if (args.prevday) {
+ return Commands.previousDay(msg);
+ }
+
+ if (args.nextday) {
+ return Commands.nextDay(msg);
+ }
+
+ if (args.prevyear) {
+ return Commands.previousYear(msg);
+ }
+
+ if (args.nextyear) {
+ return Commands.nextYear(msg);
+ }
+
+ if (args.gototoday) {
+ return Commands.goToToday(msg);
+ }
+
+ if (args.settoday) {
+ return Commands.setToday(msg);
+ }
+
+ if (args.toggleverbose) {
+ return Commands.toggleVerbose(msg);
+ }
+
+ if (args.editevent) {
+ return Commands.editEvent(msg, args.editevent);
+ }
+
+ if (args.deleteevent) {
+ return Commands.deleteEvent(msg, args.deleteevent);
+ }
+
+ if (args.editnote) {
+ return Commands.editNote(msg, args.editnote);
+ }
+
+ if (args.deletenote) {
+ return Commands.deleteNote(msg, args.deletenote);
+ }
+
+ if (args.convert) {
+ return Commands.convertItem(msg, args.convert);
+ }
+
+ if (args.moveevent) {
+ return Commands.moveEvent(msg, args.moveevent);
+ }
+
+ if (args.movenote) {
+ return Commands.moveNote(msg, args.movenote);
+ }
+
+ if (args.pickmonth) {
+ return Commands.pickMonth(msg);
+ }
+
+ if (args.pickyear) {
+ return Commands.pickYear(msg);
+ }
+
+ if (args.jumptomonth) {
+ return Commands.jumpToMonth(msg, args.jumptomonth);
+ }
+
+ if (args.jumptoyear) {
+ return Commands.jumpToYear(msg, args.jumptoyear);
+ }
+
+ if (args.jumptoday) {
+ return Commands.jumpToDay(msg, args.jumptoday);
+ }
+
+ if (args.newcal) {
+ return Commands.newCalendar(msg);
+ }
+
+ if (args.createnewcal) {
+ return Commands.createNewCalendar(msg, args.createnewcal);
+ }
+
+ if (args.viewdate) {
+ return Commands.viewDate(msg, args.viewdate);
+ }
+
+ if (args.setfeatureddate) {
+ return Commands.setFeaturedDate(msg, args.setfeatureddate);
+ }
+
+ if (args.addnote) {
+ return Commands.addNote(msg);
+ }
+
+ if (args.addevent) {
+ return Commands.addEvent(msg);
+ }
+
+ if (args.genweather) {
+ return Commands.generateWeather(msg);
+ }
+
+ if (args.regenweather) {
+ return Commands.regenerateWeather(msg);
+ }
+
+ if (args.clearweather) {
+ return Commands.clearWeather(msg);
+ }
+
+ if (args.addmonth) {
+ return Commands.addMonth(msg);
+ }
+
+ if (args.addmoon) {
+ return Commands.addMoon(msg);
+ }
+
+ if (args.setclimate) {
+ return Commands.setClimate(msg);
+ }
+
+ if (args.savenote) {
+ return Commands.saveNote(msg, args.savenote);
+ }
+
+ if (args.saveevent) {
+ return Commands.saveEvent(msg, args.saveevent);
+ }
+
+ if (args.savemonth) {
+ return Commands.saveMonth(msg, args.savemonth);
+ }
+
+ if (args.savemoon) {
+ return Commands.saveMoon(msg, args.savemoon);
+ }
+
+ if (args.saveclimate) {
+ return Commands.saveClimate(msg, args.saveclimate);
+ }
+
+ if (args.toggleunits) {
+ return Commands.toggleUnits(msg);
+ }
+
+ if (args.setvernalequinox) {
+ return Commands.setVernalEquinox(msg, args.setvernalequinox);
+ }
+
+ if (args.toggleleap) {
+ return Commands.toggleLeapYear(msg);
+ }
+
+ if (args.setleapcycle) {
+ return Commands.setLeapCycle(msg, args.setleapcycle);
+ }
+
+ if (args.addleapexception) {
+ return Commands.addLeapException(msg, args.addleapexception);
+ }
+
+ if (args.removeleapexception !== undefined) {
+ return Commands.removeLeapException(msg, args.removeleapexception);
+ }
+
+ if (args.addholiday) {
+ return Commands.addHoliday(msg, args.addholiday);
+ }
+
+ if (args.editholiday) {
+ return Commands.editHoliday(msg, args.editholiday);
+ }
+
+ if (args.holidaywhisper) {
+ return Commands.holidayWhisper(msg, args.holidaywhisper);
+ }
+
+ if (args.holidayannounce) {
+ return Commands.holidayAnnounce(msg, args.holidayannounce);
+ }
+
+ if (args.addspecialday) {
+ return Commands.addSpecialDay(msg, args.addspecialday);
+ }
+
+ if (args.savespecialday) {
+ return Commands.saveSpecialDay(msg, args.savespecialday);
+ }
+
+ if (args.editspecialday) {
+ return Commands.editSpecialDay(msg, args.editspecialday);
+ }
+
+ if (args.updatespecialday) {
+ return Commands.updateSpecialDay(msg, args.updatespecialday);
+ }
+
+ if (args.deletespecialday) {
+ return Commands.deleteSpecialDay(msg, args.deletespecialday);
+ }
+
+ if (args.specialdaywhisper) {
+ return Commands.specialDayWhisper(msg, args.specialdaywhisper);
+ }
+
+ if (args.specialdayannounce) {
+ return Commands.specialDayAnnounce(msg, args.specialdayannounce);
+ }
+
+ if (args.setspecialday) {
+ return Commands.setSpecialDay(msg, args.setspecialday);
+ }
+
+ if (args.deleteholiday !== undefined) {
+ return Commands.deleteHoliday(msg, args.deleteholiday);
+ }
+
+ if (args.movemonth) {
+ return Commands.moveMonth(msg, args.movemonth);
+ }
+
+ if (args.movemoon) {
+ return Commands.moveMoon(msg, args.movemoon);
+ }
+
+ if (args.moveholiday) {
+ return Commands.moveHoliday(msg, args.moveholiday);
+ }
+
+ if (args.editmonth !== undefined) {
+ return Commands.editMonth(msg, args.editmonth);
+ }
+
+ if (args.delmonth !== undefined) {
+ return Commands.deleteMonth(msg, args.delmonth);
+ }
+
+ if (args.editmoon !== undefined) {
+ return Commands.editMoon(msg, args.editmoon);
+ }
+
+ if (args.delmoon !== undefined) {
+ return Commands.deleteMoon(msg, args.delmoon);
+ }
+
+ if (args.editweekdays) {
+ return Commands.editWeekdays(msg);
+ }
+
+ if (args.saveweekdays) {
+ return Commands.saveWeekdays(msg, args.saveweekdays);
+ }
+
+ if (args.editname) {
+ return Commands.editCalendarName(msg);
+ }
+
+ if (args.savename) {
+ return Commands.saveCalendarName(msg, args.savename);
+ }
+
+ if (args.savedescription) {
+ return Commands.saveDescription(msg, args.savedescription);
+ }
+
+ if (args.editdaysinyear) {
+ return Commands.editDaysInYear(msg);
+ }
+
+ if (args.savedaysinyear) {
+ return Commands.saveDaysInYear(msg, args.savedaysinyear);
+ }
+
+ if (args.editdaysinweek) {
+ return Commands.editDaysInWeek(msg);
+ }
+
+ if (args.savedaysinweek) {
+ return Commands.saveDaysInWeek(msg, args.savedaysinweek);
+ }
+
+ if (args.updatemonth) {
+ return Commands.updateMonth(msg, args.updatemonth);
+ }
+
+ if (args.updatemoon) {
+ return Commands.updateMoon(msg, args.updatemoon);
+ }
+
+ if (args.addtag) {
+ return Commands.addTag(msg, args.addtag);
+ }
+
+ if (args.edittag) {
+ return Commands.editTag(msg, args.edittag);
+ }
+
+ if (args.addtagfromlist) {
+ return Commands.addTagFromList(msg, args.addtagfromlist);
+ }
+
+ // Timeline commands
+ if (args['tl-toggle']) {
+ return Commands.timelineToggle(msg, args['tl-toggle']);
+ }
+
+ if (args['tl-startyear']) {
+ return Commands.timelineStartYear(msg, args['tl-startyear']);
+ }
+
+ if (args['tl-endyear']) {
+ return Commands.timelineEndYear(msg, args['tl-endyear']);
+ }
+
+ if (args['tl-clearrange']) {
+ return Commands.timelineClearRange(msg);
+ }
+
+ if (args['tl-togglesort']) {
+ return Commands.timelineToggleSort(msg);
+ }
+
+ if (args['tl-togglemode']) {
+ return Commands.timelineToggleMode(msg);
+ }
+
+ if (args['tl-deselectall']) {
+ return Commands.timelineDeselectAll(msg);
+ }
+
+ if (args['tl-selectall']) {
+ return Commands.timelineSelectAll(msg);
+ }
+
+ if (args['tl-toggletag']) {
+ return Commands.timelineToggleTag(msg, args['tl-toggletag']);
+ }
+
+ if (args['tl-toggleuntagged']) {
+ return Commands.timelineToggleUntagged(msg);
+ }
+
+ if (args.pickitemtag) {
+ return Commands.pickItemTag(msg, args.pickitemtag);
+ }
+
+ if (args.addtag) {
+ return Commands.addTag(msg, args.addtag);
+ }
+
+ if (args.chat) {
+ if (args.chat === 'calendar') {
+ return Commands.sendCalendarToChat(msg);
+ } else if (args.chat === 'design') {
+ return Commands.sendDesignToChat(msg);
+ }
+ }
+
+ // Default: show handout link
+ Commands.showHandout(msg);
+ },
+
+ help: (msg) => {
+ // Find or create the help handout
+ let helpHandout = findObjs({
+ _type: 'handout',
+ name: CHRONICLE_HELP_NAME
+ })[0];
+
+ if (!helpHandout) {
+ // Create new help handout
+ helpHandout = createObj('handout', {
+ name: CHRONICLE_HELP_NAME,
+ inplayerjournals: 'all',
+ archived: false,
+ avatar: CHRONICLE_HELP_AVATAR
+ });
+
+ helpHandout.set('notes', CHRONICLE_HELP_TEXT);
+
+ log('Chronicle: Created help handout');
+ } else {
+ // Update existing help handout
+ helpHandout.set('notes', CHRONICLE_HELP_TEXT);
+ helpHandout.set('avatar', CHRONICLE_HELP_AVATAR);
+ log('Chronicle: Updated help handout');
+ }
+
+ // Send clickable button
+ const CSS_CURRENT = getCSS();
+ const handoutId = helpHandout.get('_id');
+ const button = `Open Chronicle Help Documentation`;
+
+ Output.send(msg.who, button);
+ },
+
+ initialize: (msg, args) => {
+ const CSS_CURRENT = getCSS();
+ // Create default calendar
+ const calendar = DefaultCalendars.gregorian();
+ HandoutManager.saveCalendar(calendar);
+ State.setConfig('currentCalendar', `${HANDOUT_PREFIX} Calendar: ${calendar.name}`);
+
+ // Create empty events handout
+ HandoutManager.saveEvents('My Campaign', [], []);
+
+ // Set initial viewing date
+ State.setConfig('viewingDate', { year: 1, month: 1 });
+ State.setConfig('currentDate', { year: 1, month: 1, day: 1 });
+
+ Output.send(msg.who, `Chronicle initialized with Gregorian calendar!
`);
+
+ Commands.renderInterface(msg);
+ },
+
+ renderInterface: (msg) => {
+ // Load data with callbacks, then render with loaded data
+ HandoutManager.loadData((data) => {
+ const mode = State.config().displayMode;
+ InterfaceRenderer.render(mode, data, (handout) => {
+ // Silently update - no confirmation needed
+ });
+ });
+ },
+
+ showHandout: (msg) => {
+ let handout = HandoutManager.findHandout(INTERFACE_HANDOUT_NAME);
+ if (!handout) {
+ // Create and render if it doesn't exist
+ Commands.renderInterface(msg);
+ handout = HandoutManager.findHandout(INTERFACE_HANDOUT_NAME);
+ }
+
+ if (handout) {
+ // Send button link using Output system
+ const who = Utils.stripGM(msg.who);
+ const CSS_CURRENT = getCSS();
+ const button = `Open Chronicle Interface`;
+ Output.send(who, button);
+ }
+ },
+
+ setMode: (msg, mode) => {
+ State.setConfig('displayMode', mode);
+ Commands.renderInterface(msg);
+ },
+
+ setTheme: (msg, theme) => {
+ State.setConfig('theme', theme);
+ Commands.renderInterface(msg);
+ },
+
+ loadCalendar: (msg, calType) => {
+ let calendar;
+
+ // Check if this is a request to list existing calendars
+ if (calType === 'list') {
+ const handouts = findObjs({ type: 'handout' });
+ const presetNames = ['Gregorian', 'Absalom Reckoning', 'Faerun', 'Greyhawk', 'Eberron'];
+ const calendarHandouts = handouts.filter(h => {
+ const name = h.get('name');
+ if (!name.startsWith(HANDOUT_PREFIX + ' Calendar:')) return false;
+
+ const calName = name.replace(HANDOUT_PREFIX + ' Calendar: ', '');
+ // Exclude preset calendars from the list since they have dedicated Load buttons
+ return !presetNames.includes(calName);
+ });
+
+ if (calendarHandouts.length === 0) {
+ Output.send(msg.who, 'No custom calendars found.');
+ return;
+ }
+
+ let output = 'Custom Calendars:';
+ calendarHandouts.forEach(h => {
+ const fullName = h.get('name');
+ const calName = fullName.replace(HANDOUT_PREFIX + ' Calendar: ', '');
+ output += '• ' + calName + ' -
Load';
+ });
+ output += '
';
+ Output.send(msg.who, output);
+ return;
+ }
+
+ // Check if loading a default calendar type
+ if (calType === 'gregorian' || calType === 'absalom' || calType === 'faerun' || calType === 'greyhawk' || calType === 'eberron') {
+ // Get the default calendar
+ if (calType === 'gregorian') {
+ calendar = DefaultCalendars.gregorian();
+ } else if (calType === 'absalom') {
+ calendar = DefaultCalendars.absalom();
+ } else if (calType === 'faerun') {
+ calendar = DefaultCalendars.faerun();
+ } else if (calType === 'greyhawk') {
+ calendar = DefaultCalendars.greyhawk();
+ } else if (calType === 'eberron') {
+ calendar = DefaultCalendars.eberron();
+ }
+
+ // Check if handouts already exist - if so, just load them instead of overwriting
+ const handoutName = `${HANDOUT_PREFIX} Calendar: ${calendar.name}`;
+ const existingHandout = HandoutManager.findHandout(handoutName);
+
+ if (existingHandout) {
+ // Handout already exists - just switch to it, don't overwrite
+ State.setConfig('currentCalendar', handoutName);
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calendar.name}`;
+ State.setConfig('currentEvents', eventsName);
+ Commands.renderInterface(msg);
+ return;
+ }
+
+ // Handout doesn't exist - create it
+ HandoutManager.saveCalendar(calendar);
+ State.setConfig('currentCalendar', handoutName);
+ Commands.renderInterface(msg);
+ return;
+ }
+
+ // Not a preset - try to find existing calendar handout with this name
+ const handoutName = `${HANDOUT_PREFIX} Calendar: ${calType}`;
+ const handout = HandoutManager.findHandout(handoutName);
+
+ if (!handout) {
+ Output.send(msg.who, `Calendar "${calType}" not found. Use !chr --loadcal list to see existing calendars, or !chr --loadcal gregorian / !chr --loadcal absalom / !chr --loadcal faerun / !chr --loadcal greyhawk / !chr --loadcal eberron to create a new one.`);
+ return;
+ }
+
+ // Load the existing calendar
+ State.setConfig('currentCalendar', handoutName);
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calType}`;
+ State.setConfig('currentEvents', eventsName);
+
+ Commands.renderInterface(msg);
+ },
+
+ previousDay: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let day = currentDate.day - 1;
+ let month = currentDate.month;
+ let year = currentDate.year;
+
+ if (day < 1) {
+ month--;
+ if (month < 1) {
+ month = calendar.months.length;
+ year--;
+ }
+ day = DateUtils.getDaysInMonth(month, year, calendar);
+ }
+
+ const newDate = { year, month, day };
+ State.setConfig('currentDate', newDate);
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ nextDay: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let day = currentDate.day + 1;
+ let month = currentDate.month;
+ let year = currentDate.year;
+
+ const daysInMonth = DateUtils.getDaysInMonth(month, year, calendar);
+
+ if (day > daysInMonth) {
+ day = 1;
+ month++;
+ if (month > calendar.months.length) {
+ month = 1;
+ year++;
+ }
+ }
+
+ const newDate = { year, month, day };
+ State.setConfig('currentDate', newDate);
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ previousMonth: (msg) => {
+ DataLoader.loadAll((data) => {
+ const viewingDate = State.config().viewingDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let month = viewingDate.month - 1;
+ let year = viewingDate.year;
+
+ if (month < 1) {
+ month = calendar.months.length;
+ year--;
+ }
+
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ nextMonth: (msg) => {
+ DataLoader.loadAll((data) => {
+ const viewingDate = State.config().viewingDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let month = viewingDate.month + 1;
+ let year = viewingDate.year;
+
+ if (month > calendar.months.length) {
+ month = 1;
+ year++;
+ }
+
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ previousYear: (msg) => {
+ const viewingDate = State.config().viewingDate;
+
+ State.setConfig('viewingDate', {
+ year: viewingDate.year - 1,
+ month: viewingDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ nextYear: (msg) => {
+ const viewingDate = State.config().viewingDate;
+
+ State.setConfig('viewingDate', {
+ year: viewingDate.year + 1,
+ month: viewingDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ goToToday: (msg) => {
+ // Navigate to the saved "Today" date
+ const todayDate = State.config().featuredDate || State.config().currentDate;
+ State.setConfig('currentDate', todayDate);
+ State.setConfig('viewingDate', {
+ year: todayDate.year,
+ month: todayDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ setToday: (msg) => {
+ // Save the current Featured Date as the new "Today"
+ const currentDate = State.config().currentDate;
+ State.setConfig('featuredDate', {
+ year: currentDate.year,
+ month: currentDate.month,
+ day: currentDate.day
+ });
+ Commands.renderInterface(msg);
+ },
+
+ toggleVerbose: (msg) => {
+ const current = State.config().verboseCalendar || false;
+ State.setConfig('verboseCalendar', !current);
+ Commands.renderInterface(msg);
+ },
+
+ // Timeline Mode Commands
+ timelineToggle: (msg, type) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ if (type === 'event') {
+ timelineState.showEvents = !timelineState.showEvents;
+ } else if (type === 'note') {
+ timelineState.showNotes = !timelineState.showNotes;
+ } else if (type === 'holiday') {
+ timelineState.showHolidays = !timelineState.showHolidays;
+ } else if (type === 'weather') {
+ timelineState.showWeather = !timelineState.showWeather;
+ } else if (type === 'details') {
+ timelineState.showDetails = !timelineState.showDetails;
+ }
+
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineStartYear: (msg, year) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ const yearNum = parseInt(year);
+ if (!isNaN(yearNum)) {
+ timelineState.startYear = yearNum;
+ State.setConfig('timelineState', timelineState);
+ }
+
+ Commands.renderInterface(msg);
+ },
+
+ timelineEndYear: (msg, year) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ const yearNum = parseInt(year);
+ if (!isNaN(yearNum)) {
+ timelineState.endYear = yearNum;
+ State.setConfig('timelineState', timelineState);
+ }
+
+ Commands.renderInterface(msg);
+ },
+
+ timelineClearRange: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.startYear = null;
+ timelineState.endYear = null;
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineToggleSort: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.sortAscending = !timelineState.sortAscending;
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineToggleMode: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.tagMode = timelineState.tagMode === 'OR' ? 'AND' : 'OR';
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineDeselectAll: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.selectedTags = [];
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineSelectAll: (msg) => {
+ DataLoader.loadAll((data) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ // Get all tags and select them
+ const allTags = TagSystem.getAllTags(data);
+ timelineState.selectedTags = [...allTags];
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ timelineToggleTag: (msg, tag) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ showUntagged: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ const index = timelineState.selectedTags.indexOf(tag);
+ if (index > -1) {
+ // Tag is selected, remove it
+ timelineState.selectedTags.splice(index, 1);
+ } else {
+ // Tag is not selected, add it
+ timelineState.selectedTags.push(tag);
+ }
+
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineToggleUntagged: (msg, tag) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ showUntagged: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.showUntagged = !timelineState.showUntagged;
+
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ pickItemTag: (msg, itemData) => {
+ const parts = itemData.split('|');
+ const itemId = parts[0];
+ const itemType = parts[1]; // 'event' or 'note'
+
+ DataLoader.loadAll((data) => {
+ // Find the item
+ let item = null;
+ if (itemType === 'event') {
+ item = data.events.find(e => e.id === itemId);
+ } else if (itemType === 'note') {
+ item = data.notes.find(n => n.id === itemId);
+ }
+
+ if (!item) {
+ Output.send(msg.who, 'Item not found');
+ return;
+ }
+
+ // Collect all existing tags
+ const allTags = new Set();
+ data.events.forEach(e => {
+ if (e.tags) e.tags.forEach(t => allTags.add(t));
+ });
+ data.notes.forEach(n => {
+ if (n.tags) n.tags.forEach(t => allTags.add(t));
+ });
+
+ const tagList = Array.from(allTags).sort().join('|');
+
+ // Build button with direct query - use pipe-separated tags
+ let output = '';
+ if (tagList) {
+ output += '
Pick a tag to add';
+ } else {
+ output += '
No existing tags to choose from. Type a new tag:';
+ output += '
Add new tag';
+ }
+ output += '
';
+
+ Output.send(msg.who, output);
+ });
+ },
+
+ addTag: (msg, tagData) => {
+ const parts = tagData.split('|');
+ const itemId = parts[0];
+ const itemType = parts[1];
+ const tag = parts.slice(2).join('|').trim(); // Rejoin in case tag contains |
+
+ if (!tag) {
+ Output.send(msg.who, 'No tag selected');
+ return;
+ }
+
+ DataLoader.loadAll((data) => {
+ let item = null;
+ if (itemType === 'event') {
+ item = data.events.find(e => e.id === itemId);
+ } else if (itemType === 'note') {
+ item = data.notes.find(n => n.id === itemId);
+ }
+
+ if (!item) {
+ Output.send(msg.who, 'Item not found');
+ return;
+ }
+
+ // Add tag if not already present
+ if (!item.tags) item.tags = [];
+ if (!item.tags.includes(tag)) {
+ item.tags.push(tag);
+ }
+
+ // Save
+ HandoutManager.saveEvents(data.events);
+ HandoutManager.saveNotes(data.notes);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editEvent: (msg, eventData) => {
+ const parts = eventData.split('|');
+ const eventId = parts[0];
+ const newContent = parts[1];
+
+ DataLoader.loadAll((data) => {
+ const events = data.events;
+ const event = events.find(e => e.id === eventId);
+
+ if (!event) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ event.content = newContent;
+ const calendar = data.calendar;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteEvent: (msg, eventId) => {
+ DataLoader.loadAll((data) => {
+ let events = data.events;
+ const originalLength = events.length;
+ events = events.filter(e => e.id !== eventId);
+
+ if (events.length === originalLength) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ const calendar = data.calendar;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editNote: (msg, noteData) => {
+ const parts = noteData.split('|');
+ const noteId = parts[0];
+ const newContent = parts[1];
+
+ DataLoader.loadAll((data) => {
+ const notes = data.notes;
+ const note = notes.find(n => n.id === noteId);
+
+ if (!note) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ note.content = newContent;
+ const calendar = data.calendar;
+ const events = data.events;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteNote: (msg, noteId) => {
+ DataLoader.loadAll((data) => {
+ let notes = data.notes;
+ const originalLength = notes.length;
+ notes = notes.filter(n => n.id !== noteId);
+
+ if (notes.length === originalLength) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ const calendar = data.calendar;
+ const events = data.events;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ convertItem: (msg, itemData) => {
+ DataLoader.loadAll((data) => {
+ const parts = itemData.split('|');
+ const itemId = parts[0];
+ const fromType = parts[1]; // 'event' or 'note'
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ if (fromType === 'event') {
+ // Convert event to note
+ const eventIndex = events.findIndex(e => e.id === itemId);
+ if (eventIndex === -1) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ const event = events[eventIndex];
+ const newNote = DataModels.createNote(event.content, event.dateRef, event.tags || [], event.createdBy);
+ notes.push(newNote);
+ events.splice(eventIndex, 1);
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ } else if (fromType === 'note') {
+ // Convert note to event
+ const noteIndex = notes.findIndex(n => n.id === itemId);
+ if (noteIndex === -1) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ const note = notes[noteIndex];
+ const newEvent = DataModels.createEvent(note.content, note.dateRef, note.tags || [], note.createdBy);
+ events.push(newEvent);
+ notes.splice(noteIndex, 1);
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ moveEvent: (msg, eventData) => {
+ DataLoader.loadAll((data) => {
+ const parts = eventData.split('|');
+ const eventId = parts[0];
+ const newYear = parseInt(parts[1]);
+ const newMonth = parseInt(parts[2]);
+ const newDay = parseInt(parts[3]);
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ const eventIndex = events.findIndex(e => e.id === eventId);
+ if (eventIndex === -1) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ // Update the event's dateRef
+ events[eventIndex].dateRef = {
+ year: newYear,
+ month: newMonth,
+ day: newDay
+ };
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ moveNote: (msg, noteData) => {
+ DataLoader.loadAll((data) => {
+ const parts = noteData.split('|');
+ const noteId = parts[0];
+ const newYear = parseInt(parts[1]);
+ const newMonth = parseInt(parts[2]);
+ const newDay = parseInt(parts[3]);
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ const noteIndex = notes.findIndex(n => n.id === noteId);
+ if (noteIndex === -1) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ // Update the note's dateRef
+ notes[noteIndex].dateRef = {
+ year: newYear,
+ month: newMonth,
+ day: newDay
+ };
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addTag: (msg, tagData) => {
+ DataLoader.loadAll((data) => {
+ const parts = tagData.split('|');
+ if (parts.length < 3) {
+ Output.send(msg.who, 'Invalid tag data format');
+ return;
+ }
+
+ const itemId = parts[0];
+ const itemType = parts[1]; // 'event' or 'note'
+ const newTagsStr = parts[2];
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ // Parse new tags
+ const newTags = Utils.parseTags(newTagsStr);
+
+ if (newTags.length === 0) {
+ Output.send(msg.who, 'No valid tags provided');
+ return;
+ }
+
+ // Find the item and add tags
+ let found = false;
+ if (itemType === 'event') {
+ const event = events.find(e => e.id === itemId);
+ if (event) {
+ event.tags = event.tags || [];
+ newTags.forEach(tag => {
+ if (!event.tags.includes(tag)) {
+ event.tags.push(tag);
+ }
+ });
+ found = true;
+ }
+ } else if (itemType === 'note') {
+ const note = notes.find(n => n.id === itemId);
+ if (note) {
+ note.tags = note.tags || [];
+ newTags.forEach(tag => {
+ if (!note.tags.includes(tag)) {
+ note.tags.push(tag);
+ }
+ });
+ found = true;
+ }
+ }
+
+ if (!found) {
+ Output.send(msg.who, 'Item not found');
+ return;
+ }
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editTag: (msg, tagData) => {
+ DataLoader.loadAll((data) => {
+ const parts = tagData.split('|');
+ if (parts.length < 4) {
+ Output.send(msg.who, 'Invalid tag edit format');
+ return;
+ }
+
+ const itemId = parts[0];
+ const itemType = parts[1]; // 'event' or 'note'
+ const oldTag = parts[2].toLowerCase().trim();
+ const newTag = parts[3].toLowerCase().trim();
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ // Find the item
+ let item = null;
+ if (itemType === 'event') {
+ item = events.find(e => e.id === itemId);
+ } else if (itemType === 'note') {
+ item = notes.find(n => n.id === itemId);
+ }
+
+ if (!item || !item.tags) {
+ Output.send(msg.who, 'Item or tag not found');
+ return;
+ }
+
+ const tagIndex = item.tags.indexOf(oldTag);
+ if (tagIndex === -1) {
+ return;
+ }
+
+ if (newTag === '') {
+ // Delete tag
+ item.tags.splice(tagIndex, 1);
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ } else {
+ // Update tag
+ item.tags[tagIndex] = newTag;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addTagFromList: (msg, itemData) => {
+ DataLoader.loadAll((data) => {
+ const parts = itemData.split('|');
+ if (parts.length < 2) {
+ Output.send(msg.who, 'Invalid format');
+ return;
+ }
+
+ const itemId = parts[0];
+ const itemType = parts[1];
+
+ // Get all existing tags
+ const allTags = TagSystem.getAllTags(data);
+
+ if (allTags.length === 0) {
+ Output.send(msg.who, 'No existing tags found. Use the + button to create tags first.');
+ return;
+ }
+
+ // Build the tag list for the query dropdown and output the command directly
+ const tagList = allTags.join('|');
+
+ // This sends nothing to chat - Roll20 will process the command directly from the button
+ // The button's href already contains the full command, so we just need to trigger it
+ sendChat('Chronicle', `!chr --addtag ${itemId}|${itemType}|?{Choose tag to add|${tagList}}`);
+ });
+ },
+
+ pickMonth: (msg) => {
+ DataLoader.loadAll((data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ // Build list of months for query
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+ Output.send(msg.who, `Click to jump to month:
${Output.makeButton('Select Month', `!chr --jumptomonth ?{Which month?|${monthList}}`, CSS_CURRENT.button)}`);
+ });
+ },
+
+ jumpToMonth: (msg, monthNum) => {
+ const month = parseInt(monthNum);
+ const viewingDate = State.config().viewingDate;
+
+ if (isNaN(month)) {
+ Output.send(msg.who, 'Invalid month');
+ return;
+ }
+
+ State.setConfig('viewingDate', {
+ year: viewingDate.year,
+ month: month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ pickYear: (msg) => {
+ const CSS_CURRENT = getCSS();
+ const viewingDate = State.config().viewingDate;
+ Output.send(msg.who, `Current year: ${viewingDate.year}
${Output.makeButton('Jump to Year', `!chr --jumptoyear ?{Enter year|${viewingDate.year}}`, CSS_CURRENT.button)}`);
+ },
+
+ jumpToYear: (msg, yearNum) => {
+ const year = parseInt(yearNum);
+ const viewingDate = State.config().viewingDate;
+
+ if (isNaN(year)) {
+ Output.send(msg.who, 'Invalid year');
+ return;
+ }
+
+ State.setConfig('viewingDate', {
+ year: year,
+ month: viewingDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ jumpToDay: (msg, dayNum) => {
+ DataLoader.loadAll((data) => {
+ const day = parseInt(dayNum);
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+
+ if (isNaN(day)) {
+ Output.send(msg.who, 'Invalid day');
+ return;
+ }
+
+ const month = calendar.months[currentDate.month - 1];
+ if (!month || day < 1 || day > month.days) {
+ Output.send(msg.who, `Invalid day. Must be between 1 and ${month ? month.days : '?'}`);
+ return;
+ }
+
+ State.setConfig('currentDate', {
+ year: currentDate.year,
+ month: currentDate.month,
+ day: day
+ });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ newCalendar: (msg) => {
+ const CSS_CURRENT = getCSS();
+ Output.send(msg.who, `${Output.makeButton('Create New Calendar', `!chr --createnewcal ?{Calendar Name|New Calendar}`, CSS_CURRENT.button)}`);
+ },
+
+ createNewCalendar: (msg, calName) => {
+ const calendar = DataModels.createCalendar(calName);
+
+ // Start with basic structure - user will configure in Design Mode
+ calendar.months = [];
+ calendar.weeks.weekdayNames = ['Day1', 'Day2', 'Day3', 'Day4', 'Day5', 'Day6', 'Day7'];
+
+ HandoutManager.saveCalendar(calendar);
+ State.setConfig('currentCalendar', `${HANDOUT_PREFIX} Calendar: ${calName}`);
+
+ Output.send(msg.who, `New calendar "${calName}" created. Use Design Mode to add months.`);
+ State.setConfig('displayMode', 'design');
+ Commands.renderInterface(msg);
+ },
+
+ viewDate: (msg, dateStr) => {
+ const parts = dateStr.split('|');
+ const date = {
+ year: parseInt(parts[0]),
+ month: parseInt(parts[1]),
+ day: parseInt(parts[2])
+ };
+
+ State.setConfig('currentDate', date);
+ State.setConfig('viewingDate', { year: date.year, month: date.month });
+ State.setConfig('displayMode', 'calendar'); // Switch to calendar view
+ Commands.renderInterface(msg);
+ },
+
+ setFeaturedDate: (msg, dateStr) => {
+ const parts = dateStr.split('|');
+ const date = {
+ year: parseInt(parts[0]),
+ month: parseInt(parts[1]),
+ day: parseInt(parts[2])
+ };
+
+ State.setConfig('currentDate', date);
+ State.setConfig('viewingDate', { year: date.year, month: date.month });
+ // Don't change mode - stay in current view (timeline)
+ Commands.renderInterface(msg);
+ },
+
+ addNote: (msg) => {
+ // Kept for backward compatibility, but query is now in the button
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ const month = calendar.months[currentDate.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ Output.send(msg.who, `To add a note for ${monthName} ${currentDate.day}, ${currentDate.year}, use the Add Note button in the handout.`);
+ });
+ },
+
+ saveNote: (msg, noteText) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const notes = data.notes;
+ let eventsName = State.config().currentEvents;
+ const currentCalendar = State.config().currentCalendar;
+ const who = Utils.stripGM(msg.who);
+
+ // If currentEvents isn't set, derive it from calendar name
+ if (!eventsName && currentCalendar) {
+ const calName = currentCalendar.replace(`${HANDOUT_PREFIX} Calendar: `, '');
+ eventsName = `${HANDOUT_PREFIX} Events: ${calName}`;
+ State.setConfig('currentEvents', eventsName);
+ }
+
+ const note = DataModels.createNote(noteText, currentDate, [], who);
+ notes.push(note);
+
+ // Save to handout
+ const events = data.events;
+ if (eventsName) {
+ const calName = eventsName.replace(`${HANDOUT_PREFIX} Events: `, '');
+ HandoutManager.saveEvents(calName, events, notes, data.weather || []);
+ } else {
+ Output.send(msg.who, 'Error: No calendar loaded. Please load or create a calendar first.');
+ return;
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addEvent: (msg) => {
+ // Kept for backward compatibility, but query is now in the button
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ const month = calendar.months[currentDate.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ Output.send(msg.who, `To add an event for ${monthName} ${currentDate.day}, ${currentDate.year}, use the Add Event button in the handout.`);
+ });
+ },
+
+ saveEvent: (msg, eventText) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const events = data.events;
+ let eventsName = State.config().currentEvents;
+ const currentCalendar = State.config().currentCalendar;
+ const who = Utils.stripGM(msg.who);
+
+ // If currentEvents isn't set, derive it from calendar name
+ if (!eventsName && currentCalendar) {
+ const calName = currentCalendar.replace(`${HANDOUT_PREFIX} Calendar: `, '');
+ eventsName = `${HANDOUT_PREFIX} Events: ${calName}`;
+ State.setConfig('currentEvents', eventsName);
+ }
+
+ const event = DataModels.createEvent(eventText, currentDate, [], who);
+ events.push(event);
+
+ // Save to handout
+ const notes = data.notes;
+ if (eventsName) {
+ const calName = eventsName.replace(`${HANDOUT_PREFIX} Events: `, '');
+ HandoutManager.saveEvents(calName, events, notes, data.weather || []);
+ } else {
+ Output.send(msg.who, 'Error: No calendar loaded. Please load or create a calendar first.');
+ return;
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ generateWeather: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ const weather = data.weather;
+
+ // Check if weather already exists for this date
+ const existing = weather.findIndex(w =>
+ w.dateRef.year === currentDate.year &&
+ w.dateRef.month === currentDate.month &&
+ w.dateRef.day === currentDate.day
+ );
+
+ const newWeather = WeatherGenerator.generate(currentDate, calendar);
+
+ if (!newWeather) {
+ Output.send(msg.who, 'No climate set. Use Design Mode to set a climate first.');
+ return;
+ }
+
+ if (existing >= 0) {
+ weather[existing] = newWeather;
+ } else {
+ weather.push(newWeather);
+ }
+
+ // Save weather to handout
+ const events = data.events;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, weather);
+
+ // Weather generated, interface will re-render to show it
+ Commands.renderInterface(msg);
+ });
+ },
+
+ regenerateWeather: (msg) => {
+ // Just call generateWeather again, which will overwrite existing
+ Commands.generateWeather(msg);
+ },
+
+ clearWeather: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ let weather = data.weather || [];
+
+ // Remove weather for current date
+ weather = weather.filter(w =>
+ !(w.dateRef.year === currentDate.year &&
+ w.dateRef.month === currentDate.month &&
+ w.dateRef.day === currentDate.day)
+ );
+
+ // Save back
+ const events = data.events;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, weather);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addMonth: (msg) => {
+ // Kept for backward compatibility - query is now in the button
+ Output.send(msg.who, `Use the Add Month button in Design Mode to add a month.`);
+ },
+
+ saveMonth: (msg, monthData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ Logger.debug(`saveMonth received: "${monthData}"`);
+
+ const parts = monthData.split('|');
+
+ if (parts.length < 2) {
+ Output.send(msg.who, `Invalid format. Received ${parts.length} parts. Expected format: Name|Days. Got: "${monthData}"`);
+ return;
+ }
+
+ const name = parts[0].trim();
+ const days = parseInt(parts[1]);
+
+ if (isNaN(days) || days < 1) {
+ Output.send(msg.who, `Invalid number of days. Received: "${parts[1]}"`);
+ return;
+ }
+
+ const newMonth = DataModels.createMonth(name, days, calendar.months.length);
+ calendar.months.push(newMonth);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addMoon: (msg) => {
+ // Kept for backward compatibility - query is now in the button
+ Output.send(msg.who, `Use the Add Moon button in Design Mode to add a moon.`);
+ },
+
+
+ saveMoon: (msg, moonData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ if (!calendar) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ const moons = calendar.moons || [];
+
+ Logger.debug(`saveMoon received: "${moonData}"`);
+
+ const parts = moonData.split('|');
+
+ if (parts.length < 5) {
+ Output.send(msg.who, `Invalid format. Expected at least 5 parts: Name|Period|FullYear|FullMonth|FullDay. Got: "${moonData}"`);
+ return;
+ }
+
+ const name = parts[0].trim();
+ const period = parseFloat(parts[1]); // Changed to parseFloat for decimal support
+ const fullYear = parseInt(parts[2]);
+ const fullMonth = parseInt(parts[3]);
+ const fullDay = parseInt(parts[4]);
+ const size = parts.length > 5 ? parseFloat(parts[5]) : 1;
+ const color = parts.length > 6 ? parts[6].trim() : 'yellow';
+ const display = parts.length > 7 ? parts[7].trim() === 'true' : true;
+
+ if (isNaN(period) || isNaN(fullYear) || isNaN(fullMonth) || isNaN(fullDay)) {
+ Output.send(msg.who, `Invalid numbers in moon data. Period=${parts[1]}, Year=${parts[2]}, Month=${parts[3]}, Day=${parts[4]}`);
+ return;
+ }
+
+ const fullDayRef = { year: fullYear, month: fullMonth, day: fullDay };
+ const newMoon = DataModels.createMoon(name, period, fullDayRef, size, color, display);
+ moons.push(newMoon);
+
+ calendar.moons = moons;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ setClimate: (msg) => {
+ // Kept for backward compatibility - query is now in the button
+ Output.send(msg.who, `Use the Set Climate button in Design Mode to configure climate.`);
+ },
+
+
+ saveClimate: (msg, climateData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = climateData.split('|');
+
+ if (parts.length < 5) {
+ Output.send(msg.who, 'Invalid format. See help for proper format.');
+ return;
+ }
+
+ const inputs = {
+ latitude_band: parts[0].trim(),
+ ocean_proximity: parts[1].trim(),
+ coast_type: parts[2].trim(),
+ elevation: parts[3].trim(),
+ rainshadow: parts[4].trim()
+ };
+
+ const climate = ClimateClassifier.classify(inputs);
+ calendar.climate = climate;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ toggleUnits: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ // Toggle between 'us' and 'metric'
+ calendar.units = (calendar.units === 'us') ? 'metric' : 'us';
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ setVernalEquinox: (msg, dayStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const day = parseInt(dayStr);
+
+ if (isNaN(day) || day < 1 || day > calendar.daysInYear) {
+ Output.send(msg.who, `Invalid day. Must be between 1 and ${calendar.daysInYear}`);
+ return;
+ }
+
+ calendar.seasons.vernalEquinox = day;
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Vernal Equinox set to day ${day}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ toggleLeapYear: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ calendar.leapYears.enabled = !calendar.leapYears.enabled;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Leap years ${calendar.leapYears.enabled ? 'enabled' : 'disabled'}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ setLeapCycle: (msg, cycleStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const cycle = parseInt(cycleStr);
+
+ if (isNaN(cycle) || cycle < 1) {
+ Output.send(msg.who, 'Invalid cycle. Must be a positive number.');
+ return;
+ }
+
+ calendar.leapYears.cycle = cycle;
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Leap year cycle set to every ${cycle} years`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addLeapException: (msg, yearStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const year = parseInt(yearStr);
+
+ if (isNaN(year)) {
+ Output.send(msg.who, 'Invalid year');
+ return;
+ }
+
+ if (!calendar.leapYears.exceptions) {
+ calendar.leapYears.exceptions = [];
+ }
+
+ if (calendar.leapYears.exceptions.includes(year)) {
+ Output.send(msg.who, `Year ${year} is already an exception`);
+ return;
+ }
+
+ calendar.leapYears.exceptions.push(year);
+ calendar.leapYears.exceptions.sort((a, b) => a - b);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ removeLeapException: (msg, idxStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const idx = parseInt(idxStr);
+
+ if (!calendar.leapYears.exceptions || idx < 0 || idx >= calendar.leapYears.exceptions.length) {
+ Output.send(msg.who, 'Invalid exception index');
+ return;
+ }
+
+ const year = calendar.leapYears.exceptions[idx];
+ calendar.leapYears.exceptions.splice(idx, 1);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ addHoliday: (msg, holidayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = holidayData.split('|');
+
+ Logger.debug(`addHoliday received: "${holidayData}"`);
+
+ if (parts.length < 4) {
+ Output.send(msg.who, `Invalid format. Expected: Name|Month|Day|Recurring|Description. Got: "${holidayData}"`);
+ return;
+ }
+
+ const name = parts[0].trim();
+ const month = parseInt(parts[1]);
+ const day = parseInt(parts[2]);
+ const recurring = parts[3].trim() === 'Yes';
+ const description = parts[4] ? parts[4].trim() : '';
+
+ if (isNaN(month) || isNaN(day)) {
+ Output.send(msg.who, `Invalid month or day. Month=${parts[1]}, Day=${parts[2]}`);
+ return;
+ }
+
+ if (month < 1 || month > calendar.months.length) {
+ Output.send(msg.who, `Invalid month. Must be between 1 and ${calendar.months.length}`);
+ return;
+ }
+
+ const holiday = DataModels.createHoliday(name, {month, day}, recurring, description);
+
+ if (!calendar.holidays) {
+ calendar.holidays = [];
+ }
+ calendar.holidays.push(holiday);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editHoliday: (msg, holidayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = holidayData.split('|');
+
+ if (parts.length < 6) {
+ Output.send(msg.who, `Invalid format. Expected: Index|Name|Month|Day|Recurring|Description`);
+ return;
+ }
+
+ const idx = parseInt(parts[0]);
+ const name = parts[1].trim();
+ const month = parseInt(parts[2]);
+ const day = parseInt(parts[3]);
+ const recurring = parts[4].trim() === 'Yes';
+ const description = parts[5].trim();
+
+ if (isNaN(idx) || idx < 0 || idx >= calendar.holidays.length) {
+ Output.send(msg.who, `Invalid holiday index: ${idx}`);
+ return;
+ }
+
+ if (isNaN(month) || isNaN(day)) {
+ Output.send(msg.who, `Invalid month or day`);
+ return;
+ }
+
+ if (month < 1 || month > calendar.months.length) {
+ Output.send(msg.who, `Invalid month. Must be between 1 and ${calendar.months.length}`);
+ return;
+ }
+
+ // Update all fields
+ calendar.holidays[idx].name = name;
+ calendar.holidays[idx].dateRef = { month, day };
+ calendar.holidays[idx].recurring = recurring;
+ calendar.holidays[idx].description = description;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ holidayWhisper: (msg, holidayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const holiday = calendar.holidays.find(h => h.id === holidayId);
+
+ if (!holiday) {
+ Output.send(msg.who, `Holiday not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = `${holiday.name}`;
+ if (holiday.description) {
+ output += `
${holiday.description}`;
+ }
+ output += `
`;
+ output += `Announce publicly`;
+
+ Output.send(msg.who, output);
+ });
+ },
+
+ holidayAnnounce: (msg, holidayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const holiday = calendar.holidays.find(h => h.id === holidayId);
+
+ if (!holiday) {
+ Output.send(msg.who, `Holiday not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = ``;
+ output += `${holiday.name}`;
+ if (holiday.description) {
+ output += `
${holiday.description}`;
+ }
+ output += `
`;
+
+ Output.broadcast(output);
+ });
+ },
+
+ addSpecialDay: (msg, dayType) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ // Build month list for query
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+
+ // Create direct query based on type - this will be embedded in a button href
+ let query = `!chr --savespecialday ${dayType}|?{Name}|?{After Which Month?|${monthList}}|?{After Which Day? (0=before month)}|?{Week Behavior|Part of week,partOfWeek|Between weeks,betweenWeeks}`;
+
+ if (dayType === 'leap') {
+ query += `|?{Every N years (frequency)|4}|?{Year offset|0}`;
+ }
+
+ query += `|?{Description (optional)|}`;
+
+ // This command should not be called from Design mode buttons anymore
+ // But keep for backwards compatibility
+ const CSS_CURRENT = getCSS();
+ Output.send(msg.who, `Configure Special Day`);
+ });
+ },
+
+ saveSpecialDay: (msg, specialDayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = specialDayData.split('|');
+
+ const dayType = parts[0]; // 'fixed' or 'leap'
+ const name = parts[1].trim();
+ const monthParts = parts[2].split(','); // "MonthName,MonthNumber"
+ const afterMonth = parseInt(monthParts[1] || monthParts[0]); // Use number part or fallback
+ const afterDay = parseInt(parts[3]);
+ const weekBehaviorParts = parts[4].split(',');
+ const weekBehavior = weekBehaviorParts[1] || weekBehaviorParts[0]; // Get second part or fallback
+
+ let frequency = null;
+ let offset = 0;
+ let description = '';
+
+ if (dayType === 'leap') {
+ frequency = parseInt(parts[5]);
+ offset = parseInt(parts[6]);
+ description = parts[7] ? parts[7].trim() : '';
+ } else {
+ description = parts[5] ? parts[5].trim() : '';
+ }
+
+ if (!name || isNaN(afterMonth) || isNaN(afterDay)) {
+ Output.send(msg.who, `Invalid input. Name: "${name}", Month: ${afterMonth}, Day: ${afterDay}`);
+ return;
+ }
+
+ const specialDay = DataModels.createInterMonthDay(
+ name,
+ { afterMonth, afterDay },
+ weekBehavior === 'betweenWeeks',
+ dayType,
+ frequency,
+ offset,
+ description
+ );
+
+ if (!calendar.interMonthDays) {
+ calendar.interMonthDays = [];
+ }
+ calendar.interMonthDays.push(specialDay);
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editSpecialDay: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const CSS_CURRENT = getCSS();
+ const index = parseInt(idx);
+
+ if (!calendar.interMonthDays || index < 0 || index >= calendar.interMonthDays.length) {
+ Output.send(msg.who, 'Special day not found');
+ return;
+ }
+
+ const sd = calendar.interMonthDays[index];
+ const escapedName = sd.name.replace(/\|/g, '|').replace(/\}/g, '}');
+ const escapedDesc = (sd.description || '').replace(/\|/g, '|').replace(/\}/g, '}');
+
+ // Build month list with current selection first
+ const currentMonth = calendar.months[sd.position.afterMonth - 1];
+ const monthList = calendar.months.map((m, idx) => {
+ const num = idx + 1;
+ return num === sd.position.afterMonth ? `${m.name},${num}` : `${m.name},${num}`;
+ }).join('|');
+ const monthDefault = `${currentMonth.name},${sd.position.afterMonth}`;
+
+ // Week behavior with current as default
+ const weekBehaviorDefault = sd.breaksWeekCycle ? 'Between weeks,betweenWeeks' : 'Part of week,partOfWeek';
+
+ let query = `!chr --updatespecialday ${index}|${sd.dayType}|?{Name|${escapedName}}|?{After Which Month?|${monthDefault}|${monthList}}|?{After Which Day?|${sd.position.afterDay}}|?{Week Behavior|${weekBehaviorDefault}|Part of week,partOfWeek|Between weeks,betweenWeeks}`;
+
+ if (sd.dayType === 'leap') {
+ query += `|?{Frequency|${sd.frequency}}|?{Offset|${sd.offset}}`;
+ }
+
+ query += `|?{Description|${escapedDesc}}`;
+
+ Output.send(msg.who, Output.makeButton('Update Special Day', query, CSS_CURRENT.button));
+ });
+ },
+
+ updateSpecialDay: (msg, specialDayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = specialDayData.split('|');
+
+ const index = parseInt(parts[0]);
+ const dayType = parts[1];
+ const name = parts[2].trim();
+ const monthParts = parts[3].split(',');
+ const afterMonth = parseInt(monthParts[1] || monthParts[0]);
+ const afterDay = parseInt(parts[4]);
+ const weekBehaviorParts = parts[5].split(',');
+ const weekBehavior = weekBehaviorParts[1] || weekBehaviorParts[0];
+
+ let frequency = null;
+ let offset = 0;
+ let description = '';
+
+ if (dayType === 'leap') {
+ frequency = parseInt(parts[6]);
+ offset = parseInt(parts[7]);
+ description = parts[8] ? parts[8].trim() : '';
+ } else {
+ description = parts[6] ? parts[6].trim() : '';
+ }
+
+ if (!calendar.interMonthDays || index < 0 || index >= calendar.interMonthDays.length) {
+ Output.send(msg.who, 'Special day not found');
+ return;
+ }
+
+ calendar.interMonthDays[index].name = name;
+ calendar.interMonthDays[index].position = { afterMonth, afterDay };
+ calendar.interMonthDays[index].breaksWeekCycle = (weekBehavior === 'betweenWeeks');
+ calendar.interMonthDays[index].dayType = dayType;
+ calendar.interMonthDays[index].frequency = frequency;
+ calendar.interMonthDays[index].offset = offset;
+ calendar.interMonthDays[index].description = description;
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteSpecialDay: (msg, idxStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const idx = parseInt(idxStr);
+
+ if (!calendar.interMonthDays || idx < 0 || idx >= calendar.interMonthDays.length) {
+ Output.send(msg.who, 'Invalid special day index');
+ return;
+ }
+
+ const sd = calendar.interMonthDays[idx];
+ calendar.interMonthDays.splice(idx, 1);
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ specialDayWhisper: (msg, specialDayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === specialDayId);
+
+ if (!specialDay) {
+ Output.send(msg.who, `Special day not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = `${specialDay.name}`;
+ if (specialDay.description) {
+ output += `
${specialDay.description}`;
+ }
+ output += `
`;
+ output += `Announce publicly`;
+
+ Output.send(msg.who, output);
+ });
+ },
+
+ specialDayAnnounce: (msg, specialDayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === specialDayId);
+
+ if (!specialDay) {
+ Output.send(msg.who, `Special day not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = ``;
+ output += `${specialDay.name}`;
+ if (specialDay.description) {
+ output += `
${specialDay.description}`;
+ }
+ output += `
`;
+
+ Output.broadcast(output);
+ });
+ },
+
+ setSpecialDay: (msg, specialDayData) => {
+ DataLoader.loadAll((data) => {
+ const parts = specialDayData.split('|');
+ const year = parseInt(parts[0]);
+ const specialDayId = parts[1];
+
+ const calendar = data.calendar;
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === specialDayId);
+
+ if (!specialDay) {
+ Output.send(msg.who, `Special day not found`);
+ return;
+ }
+
+ // Calculate unique day number for this special day
+ // Count how many special days come before this one with the same afterMonth and afterDay
+ const specialDaysThisYear = DateUtils.getSpecialDaysForYear(year, calendar);
+ const sameDaySpecialDays = specialDaysThisYear.filter(sd =>
+ sd.position.afterMonth === specialDay.position.afterMonth &&
+ sd.position.afterDay === specialDay.position.afterDay
+ );
+
+ // Find this special day's index among same-day special days
+ const index = sameDaySpecialDays.findIndex(sd => sd.id === specialDayId);
+
+ // Set currentDate with special day reference and unique fractional day
+ State.setConfig('currentDate', {
+ year: year,
+ month: specialDay.position.afterMonth,
+ day: specialDay.position.afterDay + 1 + (index * 0.01), // Unique fractional offset
+ specialDayId: specialDayId
+ });
+
+ // Set viewing month
+ State.setConfig('viewingDate', {
+ year: year,
+ month: specialDay.position.afterMonth
+ });
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ deleteHoliday: (msg, idxStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const idx = parseInt(idxStr);
+
+ if (!calendar.holidays || idx < 0 || idx >= calendar.holidays.length) {
+ Output.send(msg.who, 'Invalid holiday index');
+ return;
+ }
+
+ const holiday = calendar.holidays[idx];
+ calendar.holidays.splice(idx, 1);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ moveMonth: (msg, moveData) => {
+ const calendar = data.calendar;
+ const parts = moveData.split('|');
+ const idx = parseInt(parts[0]);
+ const direction = parts[1];
+
+ if (isNaN(idx) || idx < 0 || idx >= calendar.months.length) {
+ Output.send(msg.who, 'Invalid month index');
+ return;
+ }
+
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1;
+
+ if (newIdx < 0 || newIdx >= calendar.months.length) {
+ return; // Can't move beyond boundaries
+ }
+
+ // Swap
+ const temp = calendar.months[idx];
+ calendar.months[idx] = calendar.months[newIdx];
+ calendar.months[newIdx] = temp;
+
+ // Update order property
+ calendar.months.forEach((m, i) => {
+ m.order = i;
+ });
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ },
+
+
+ moveMoon: (msg, moveData) => {
+ const moons = data.moons;
+ const parts = moveData.split('|');
+ const idx = parseInt(parts[0]);
+ const direction = parts[1];
+
+ if (isNaN(idx) || idx < 0 || idx >= moons.length) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1;
+
+ if (newIdx < 0 || newIdx >= moons.length) {
+ return; // Can't move beyond boundaries
+ }
+
+ // Swap
+ const temp = moons[idx];
+ moons[idx] = moons[newIdx];
+ moons[newIdx] = temp;
+
+
+ Commands.renderInterface(msg);
+ },
+
+
+ moveHoliday: (msg, moveData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = moveData.split('|');
+ const idx = parseInt(parts[0]);
+ const direction = parts[1];
+
+ if (!calendar.holidays || isNaN(idx) || idx < 0 || idx >= calendar.holidays.length) {
+ Output.send(msg.who, 'Invalid holiday index');
+ return;
+ }
+
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1;
+
+ if (newIdx < 0 || newIdx >= calendar.holidays.length) {
+ return; // Can't move beyond boundaries
+ }
+
+ // Swap
+ const temp = calendar.holidays[idx];
+ calendar.holidays[idx] = calendar.holidays[newIdx];
+ calendar.holidays[newIdx] = temp;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ editMonth: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const monthIndex = parseInt(idx);
+ const month = calendar.months[monthIndex];
+
+ if (!month) {
+ Output.send(msg.who, 'Invalid month index');
+ return;
+ }
+
+ Output.send(msg.who, `To edit "${month.name}", type: !chr --updatemonth ${idx}|?{New Month Name|${month.name}}|?{New Days|${month.days}}`);
+ });
+ },
+
+ updateMonth: (msg, monthData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ Logger.debug(`updateMonth received: "${monthData}"`);
+
+ const parts = monthData.split('|');
+
+ if (parts.length < 3) {
+ Output.send(msg.who, `Invalid format. Received ${parts.length} parts. Expected: Index|Name|Days. Got: "${monthData}"`);
+ return;
+ }
+
+ const idx = parseInt(parts[0]);
+ const name = parts[1].trim();
+ const days = parseInt(parts[2]);
+
+ if (isNaN(idx) || isNaN(days) || !calendar.months[idx]) {
+ Output.send(msg.who, `Invalid month data. Index=${parts[0]}, Name=${parts[1]}, Days=${parts[2]}`);
+ return;
+ }
+
+ calendar.months[idx].name = name;
+ calendar.months[idx].days = days;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteMonth: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const monthIndex = parseInt(idx);
+ const month = calendar.months[monthIndex];
+
+ if (!month) {
+ Output.send(msg.who, 'Invalid month index');
+ return;
+ }
+
+ calendar.months.splice(monthIndex, 1);
+
+ // Re-index remaining months
+ calendar.months.forEach((m, i) => {
+ m.order = i;
+ });
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editMoon: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const moons = data.moons;
+ const moonIndex = parseInt(idx);
+ const moon = moons[moonIndex];
+
+ if (!moon) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ Output.send(msg.who, `To edit "${moon.name}", use: !chr --updatemoon ${idx}|?{Moon Name|${moon.name}}|?{Period|${moon.period}}|?{Full Year|${moon.fullDayRef.year}}|?{Full Month|${moon.fullDayRef.month}}|?{Full Day|${moon.fullDayRef.day}}`);
+ });
+ },
+
+ updateMoon: (msg, moonData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const moons = calendar.moons || [];
+ const parts = moonData.split('|');
+
+ if (parts.length < 6) {
+ Output.send(msg.who, 'Invalid format');
+ return;
+ }
+
+ const idx = parseInt(parts[0]);
+ const name = parts[1].trim();
+ const period = parseFloat(parts[2]); // Changed to parseFloat for decimal support
+ const fullYear = parseInt(parts[3]);
+ const fullMonth = parseInt(parts[4]);
+ const fullDay = parseInt(parts[5]);
+ const size = parts.length > 6 ? parseFloat(parts[6]) : (moons[idx].size || 1);
+ const color = parts.length > 7 ? parts[7].trim() : (moons[idx].color || 'yellow');
+ const display = parts.length > 8 ? parts[8].trim() === 'true' : (moons[idx].display !== false);
+
+ if (isNaN(idx) || !moons[idx]) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ moons[idx].name = name;
+ moons[idx].period = period;
+ moons[idx].fullDayRef = { year: fullYear, month: fullMonth, day: fullDay };
+ moons[idx].size = size;
+ moons[idx].color = color;
+ moons[idx].display = display;
+
+ calendar.moons = moons;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteMoon: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const moons = calendar.moons || [];
+ const moonIndex = parseInt(idx);
+ const moon = moons[moonIndex];
+
+ if (!moon) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ moons.splice(moonIndex, 1);
+
+ calendar.moons = moons;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editWeekdays: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const current = calendar.weeks.weekdayNames.join(',');
+
+ Output.send(msg.who, `Current weekdays: ${current}
To change, type: !chr --saveweekdays ?{Weekday Names (comma-separated)|${current}}`);
+ });
+ },
+
+ saveWeekdays: (msg, weekdayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const weekdays = weekdayData.split(',').map(w => w.trim());
+
+ if (weekdays.length !== calendar.weeks.daysInWeek) {
+ Output.send(msg.who, `Error: You must provide exactly ${calendar.weeks.daysInWeek} weekday names`);
+ return;
+ }
+
+ calendar.weeks.weekdayNames = weekdays;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editCalendarName: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ Output.send(msg.who, `Current name: ${calendar.name}
To change, type: !chr --savename ?{Calendar Name|${calendar.name}}`);
+ });
+ },
+
+ saveCalendarName: (msg, newName) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const oldName = calendar.name;
+ calendar.name = newName;
+
+ // Save with new name
+ const newHandoutName = HANDOUT_PREFIX + ' Calendar: ' + newName;
+ State.setConfig('currentCalendar', newHandoutName);
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, 'Calendar renamed from "' + oldName + '" to "' + newName + '"');
+ Commands.renderInterface(msg);
+ });
+ },
+
+ saveDescription: (msg, description) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ calendar.description = description || '';
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editDaysInYear: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ Output.send(msg.who, `Current days in year: ${calendar.daysInYear}
To change, type: !chr --savedaysinyear ?{Days in Year|${calendar.daysInYear}}`);
+ });
+ },
+
+ saveDaysInYear: (msg, days) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const numDays = parseInt(days);
+
+ if (isNaN(numDays) || numDays < 1) {
+ Output.send(msg.who, 'Invalid number of days');
+ return;
+ }
+
+ calendar.daysInYear = numDays;
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Days in year set to ${numDays}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editDaysInWeek: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ Output.send(msg.who, `Current days in week: ${calendar.weeks.daysInWeek}
To change, type: !chr --savedaysinweek ?{Days in Week|${calendar.weeks.daysInWeek}}`);
+ });
+ },
+
+ saveDaysInWeek: (msg, days) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const numDays = parseInt(days);
+
+ if (isNaN(numDays) || numDays < 1) {
+ Output.send(msg.who, 'Invalid number of days');
+ return;
+ }
+
+ calendar.weeks.daysInWeek = numDays;
+
+ // Adjust weekday names if needed
+ while (calendar.weeks.weekdayNames.length < numDays) {
+ calendar.weeks.weekdayNames.push(`Day${calendar.weeks.weekdayNames.length + 1}`);
+ }
+ while (calendar.weeks.weekdayNames.length > numDays) {
+ calendar.weeks.weekdayNames.pop();
+ }
+
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Days in week set to ${numDays}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ sendCalendarToChat: (msg) => {
+ DataLoader.loadAll((data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ const viewingDate = State.config().viewingDate;
+ const currentDate = State.config().currentDate;
+ const month = calendar.months[currentDate.month - 1];
+ const moons = data.moons;
+ const events = data.events;
+ const notes = data.notes;
+ const weather = data.weather;
+
+ if (!month) {
+ Output.send(msg.who, 'Invalid month');
+ return;
+ }
+
+ // Calculate day of year (1-based, counting from month 1 day 1)
+ let dayOfYear = 0;
+ for (let m = 1; m < currentDate.month; m++) {
+ dayOfYear += DateUtils.getDaysInMonth(m, currentDate.year, calendar);
+ }
+ dayOfYear += currentDate.day;
+
+ const daysInYear = DateUtils.getDaysInYear(currentDate.year, calendar);
+ const vernal = calendar.seasons.vernalEquinox || 80;
+ const seasonOffset = Math.floor(daysInYear / 12);
+
+ const springStart = vernal - seasonOffset;
+ const summerStart = vernal + Math.floor(daysInYear / 4) - seasonOffset;
+ const autumnStart = vernal + Math.floor(daysInYear / 2) - seasonOffset;
+ const winterStart = vernal + Math.floor(3 * daysInYear / 4) - seasonOffset;
+
+ let season = 'Winter';
+ if (dayOfYear >= springStart && dayOfYear < summerStart) {
+ season = 'Spring';
+ } else if (dayOfYear >= summerStart && dayOfYear < autumnStart) {
+ season = 'Summer';
+ } else if (dayOfYear >= autumnStart && dayOfYear < winterStart) {
+ season = 'Autumn';
+ } else {
+ season = 'Winter';
+ }
+
+ let output = ``;
+ output += `
${month.name} ${currentDate.day}, ${currentDate.year}
`;
+ output += `
Season: ${season} (Day ${dayOfYear} of ${daysInYear})
`;
+
+ // Current day's weather
+ const todayWeather = weather.find(w =>
+ w.dateRef.year === currentDate.year &&
+ w.dateRef.month === currentDate.month &&
+ w.dateRef.day === currentDate.day
+ );
+ if (todayWeather) {
+ output += `
Weather: ${todayWeather.description} (${todayWeather.temperature.value}°${todayWeather.temperature.unit})
`;
+ }
+
+ // Current day's holidays
+ const todayHolidays = (calendar.holidays || []).filter(h =>
+ h.dateRef.month === currentDate.month &&
+ h.dateRef.day === currentDate.day
+ );
+ if (todayHolidays.length > 0) {
+ output += '
Holidays: ';
+ output += todayHolidays.map(h =>
+ `
${h.name}`
+ ).join(', ');
+ output += '
';
+ }
+
+ // Current day's special days
+ const todaySpecialDay = DateUtils.isSpecialDay(currentDate.month, currentDate.day, currentDate.year, calendar);
+ if (todaySpecialDay) {
+ output += '
';
+ }
+
+ // Current day's events (exclude gm tagged)
+ const todayEvents = events.filter(e =>
+ e.dateRef.year === currentDate.year &&
+ e.dateRef.month === currentDate.month &&
+ e.dateRef.day === currentDate.day &&
+ !(e.tags && e.tags.includes('gm'))
+ );
+ if (todayEvents.length > 0) {
+ output += '
Events:
';
+ todayEvents.forEach(e => output += `- ${e.content}
`);
+ output += '
';
+ }
+
+ // Current day's notes (exclude gm tagged)
+ const todayNotes = notes.filter(n =>
+ n.dateRef.year === currentDate.year &&
+ n.dateRef.month === currentDate.month &&
+ n.dateRef.day === currentDate.day &&
+ !(n.tags && n.tags.includes('gm'))
+ );
+ if (todayNotes.length > 0) {
+ output += '
Notes:
';
+ todayNotes.forEach(n => output += `- ${n.content}
`);
+ output += '
';
+ }
+
+ // Week context (simplified calendar)
+ const daysInWeek = calendar.weeks.daysInWeek;
+ const daysInMonth = DateUtils.getDaysInMonth(currentDate.month, currentDate.year, calendar);
+ const currentAbsDay = DateUtils.toAbsoluteDay(currentDate, calendar);
+ const currentWeekday = (currentAbsDay - 1) % daysInWeek;
+ const weekStart = currentDate.day - currentWeekday;
+
+ output += '
This Week:
';
+ output += '
';
+
+ // Weekday headers
+ output += '';
+ for (let i = 0; i < Math.min(daysInWeek, 7); i++) {
+ const dayName = calendar.weeks.weekdayNames[i] || i;
+ output += `| ${dayName.substr(0, 2)} | `;
+ }
+ output += '
';
+
+ output += '';
+ for (let i = 0; i < Math.min(daysInWeek, 7); i++) {
+ const day = weekStart + i;
+ if (day < 1 || day > daysInMonth) {
+ output += '| - | ';
+ } else {
+ const isToday = day === currentDate.day;
+ const style = isToday ?
+ 'border:2px solid #6b8cae;padding:1px;font-weight:bold;vertical-align:top;background:#5a5a5a;' :
+ 'border:1px solid #666;padding:2px;vertical-align:top;';
+
+ output += `${day} `;
+
+ // Moon phases (SVG, visible moons only)
+ if (moons && moons.length > 0) {
+ const date = {year: currentDate.year, month: currentDate.month, day: day};
+ const phases = MoonPhaseCalculator.getAllPhases(moons, date, calendar);
+ if (phases.length > 0) {
+ output += ``;
+ phases.forEach(p => output += p.html);
+ output += ` `;
+ }
+ }
+
+ // Weather emoji
+ const w = weather.find(ww => ww.dateRef.year === currentDate.year && ww.dateRef.month === currentDate.month && ww.dateRef.day === day);
+ if (w) output += `${WeatherGenerator.getWeatherEmoji(w.description)} `;
+
+ output += ' | ';
+ }
+ }
+ output += '
';
+
+ output += '
';
+
+ Output.broadcast(output);
+ });
+ },
+
+ sendDesignToChat: (msg) => {
+ DataLoader.loadAll((data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ const moons = data.moons;
+
+ let output = ``;
+ output += `
${calendar.name} - Calendar Structure
`;
+
+ output += `
`;
+ output += `Days in Year: ${calendar.daysInYear} | `;
+ output += `Days in Week: ${calendar.weeks.daysInWeek}`;
+ output += `
`;
+
+ output += `
`;
+ output += `Months: ${calendar.months.map(m => `${m.name} (${m.days})`).join(', ')}`;
+ output += `
`;
+
+ output += `
`;
+ output += `Weekdays: ${calendar.weeks.weekdayNames.join(', ')}`;
+ output += `
`;
+
+ if (moons && moons.length > 0) {
+ output += `
`;
+ output += `Moons: ${moons.map(m => `${m.name} (${m.period}d)`).join(', ')}`;
+ output += `
`;
+ }
+
+ if (calendar.climate) {
+ output += `
`;
+ output += `Climate: ${calendar.climate.climate_name} (${calendar.climate.koppen_code})`;
+ output += `
`;
+ }
+
+ if (calendar.leapYears.enabled) {
+ output += `
`;
+ output += `Leap Years: Every ${calendar.leapYears.cycle} years`;
+ output += `
`;
+ }
+
+ output += '
';
+
+ Output.broadcast(output);
+ });
+ }
+
+ };
+
+ // ==================================================
+ // Weather Generator
+ // ==================================================
+
+ const WeatherGenerator = {
+
+ getWeatherEmoji: (description) => {
+ if (!description) return '';
+
+ const desc = description.toLowerCase();
+
+ // Check for specific weather types
+ if (desc.includes('snow')) {
+ if (desc.includes('heavy')) return '❄️';
+ if (desc.includes('light')) return '🌨️';
+ return '❄️';
+ }
+ if (desc.includes('thunderstorm')) return '⛈️';
+ if (desc.includes('rain')) {
+ if (desc.includes('heavy')) return '🌧️';
+ return '🌧️';
+ }
+ if (desc.includes('cloudy') || desc.includes('overcast')) return '☁️';
+ if (desc.includes('partly')) return '⛅';
+ if (desc.includes('clear')) {
+ if (desc.includes('cold')) return '🌬️';
+ return '☀️';
+ }
+ if (desc.includes('fog') || desc.includes('mist')) return '🌫️';
+
+ // Default
+ return '🌤️';
+ },
+
+ generate: (date, calendar) => {
+ if (!calendar.climate) {
+ return null;
+ }
+
+ const climate = calendar.climate;
+ const dayOfYear = DateUtils.toAbsoluteDay(date, calendar) % DateUtils.getDaysInYear(date.year, calendar);
+
+ // Determine season
+ const season = WeatherGenerator._getSeason(dayOfYear, calendar);
+
+ // Generate based on climate and season
+ const temp = WeatherGenerator._generateTemperature(climate, season, calendar.units);
+ const precip = WeatherGenerator._generatePrecipitation(climate, season);
+ const wind = WeatherGenerator._generateWind(climate, season);
+ const description = WeatherGenerator._generateDescription(climate, season, temp, precip, wind);
+
+ return DataModels.createWeather(date, climate.koppen_code, temp, precip, wind, description);
+ },
+
+ _getSeason: (dayOfYear, calendar) => {
+ const vernal = calendar.seasons.vernalEquinox;
+ const daysInYear = calendar.daysInYear;
+
+ // Calculate other equinoxes/solstices at even intervals
+ const summer = vernal + Math.floor(daysInYear / 4);
+ const autumnal = vernal + Math.floor(daysInYear / 2);
+ const winter = vernal + Math.floor(3 * daysInYear / 4);
+
+ if (dayOfYear >= vernal && dayOfYear < summer) return 'spring';
+ if (dayOfYear >= summer && dayOfYear < autumnal) return 'summer';
+ if (dayOfYear >= autumnal && dayOfYear < winter) return 'autumn';
+ return 'winter';
+ },
+
+ _generateTemperature: (climate, season, units) => {
+ const code = climate.koppen_code;
+ let baseTemp = 60; // Default Fahrenheit
+ let seasonalSwing = 15; // Default seasonal temperature variation
+
+ // Adjust by climate group
+ if (code.startsWith('A')) {
+ baseTemp = 85; // Tropical
+ seasonalSwing = 5; // Minimal seasonal variation in tropics
+ } else if (code.startsWith('B')) {
+ baseTemp = code.includes('h') ? 90 : 70; // Hot/Cold Desert
+ seasonalSwing = code.includes('h') ? 20 : 30; // Large daily and seasonal swings in deserts
+ } else if (code.startsWith('C')) {
+ baseTemp = 65; // Temperate
+ seasonalSwing = 20; // Moderate seasonal variation
+ } else if (code.startsWith('D')) {
+ baseTemp = 45; // Continental
+ seasonalSwing = 35; // Large seasonal variation
+ } else if (code.startsWith('E')) {
+ baseTemp = 20; // Polar
+ seasonalSwing = 25; // Moderate variation (always cold)
+ }
+
+ // Adjust by season with climate-appropriate swings
+ const seasonMod = {
+ 'spring': 0,
+ 'summer': seasonalSwing,
+ 'autumn': -seasonalSwing * 0.3,
+ 'winter': -seasonalSwing * 1.3
+ };
+ baseTemp += seasonMod[season] || 0;
+
+ // Add random daily variation (larger in continental climates, smaller in maritime)
+ let dailyVariation = 10;
+ if (code.includes('f')) dailyVariation = 7; // Maritime climates more stable
+ if (code.startsWith('D')) dailyVariation = 15; // Continental more variable
+ if (code.startsWith('B')) dailyVariation = 20; // Deserts highly variable
+
+ const variation = Math.floor(Math.random() * (dailyVariation * 2)) - dailyVariation;
+ let temp = baseTemp + variation;
+
+ const unit = units === 'metric' ? 'C' : 'F';
+
+ if (units === 'metric') {
+ temp = Math.round((temp - 32) * 5 / 9);
+ }
+
+ return { value: temp, unit: unit };
+ },
+
+ _generatePrecipitation: (climate, season) => {
+ const code = climate.koppen_code;
+ const rand = Math.random();
+
+ // Dry climates (B) - very little precipitation year-round
+ if (code.startsWith('B')) {
+ if (rand < 0.9) return 'Clear';
+ return 'Scattered clouds';
+ }
+
+ // Rainforest (Af) - heavy rain year-round
+ if (code === 'Af') {
+ if (rand < 0.6) return 'Rain';
+ if (rand < 0.9) return 'Heavy rain';
+ return 'Partly cloudy';
+ }
+
+ // Monsoon/Tropical Savanna (Aw) - wet summer, dry winter
+ if (code === 'Aw') {
+ if (season === 'summer') {
+ if (rand < 0.7) return 'Heavy rain';
+ return 'Thunderstorms';
+ } else if (season === 'winter') {
+ if (rand < 0.8) return 'Clear';
+ return 'Partly cloudy';
+ } else {
+ if (rand < 0.5) return 'Rain';
+ return 'Cloudy';
+ }
+ }
+
+ // Mediterranean (Cs) - dry summer, wet winter
+ if (code.startsWith('Cs')) {
+ if (season === 'summer') {
+ if (rand < 0.8) return 'Clear';
+ return 'Partly cloudy';
+ } else if (season === 'winter') {
+ if (rand < 0.6) return 'Rain';
+ return 'Cloudy';
+ } else {
+ if (rand < 0.5) return 'Partly cloudy';
+ return 'Rain';
+ }
+ }
+
+ // Monsoon temperate (Cw) - dry winter
+ if (code.startsWith('Cw')) {
+ if (season === 'winter') {
+ if (rand < 0.7) return 'Clear';
+ return 'Partly cloudy';
+ } else {
+ if (rand < 0.5) return 'Rain';
+ return 'Cloudy';
+ }
+ }
+
+ // Marine/Humid climates (Cf, Df) - precipitation year-round but varies by season
+ if (code.includes('f')) {
+ // Winter tends to have more precipitation in continental climates
+ if (code.startsWith('D') && season === 'winter') {
+ if (rand < 0.3) return 'Snow';
+ if (rand < 0.6) return 'Heavy snow';
+ if (rand < 0.8) return 'Cloudy';
+ return 'Light snow';
+ }
+
+ // Summer has more thunderstorms
+ if (season === 'summer') {
+ if (rand < 0.3) return 'Clear';
+ if (rand < 0.5) return 'Partly cloudy';
+ if (rand < 0.7) return 'Cloudy';
+ if (rand < 0.85) return 'Rain';
+ return 'Thunderstorms';
+ }
+
+ // Spring/Autumn moderate
+ if (rand < 0.3) return 'Clear';
+ if (rand < 0.6) return 'Partly cloudy';
+ if (rand < 0.8) return 'Cloudy';
+ return 'Rain';
+ }
+
+ // Polar (E) - very little precipitation, mostly snow
+ if (code.startsWith('E')) {
+ if (season === 'summer') {
+ if (rand < 0.6) return 'Overcast';
+ if (rand < 0.9) return 'Light snow';
+ return 'Snow';
+ } else {
+ if (rand < 0.5) return 'Clear and cold';
+ if (rand < 0.8) return 'Light snow';
+ return 'Heavy snow';
+ }
+ }
+
+ // Default fallback
+ if (rand < 0.3) return 'Clear';
+ if (rand < 0.6) return 'Partly cloudy';
+ if (rand < 0.8) return 'Cloudy';
+ if (rand < 0.95) return 'Rain';
+ return 'Thunderstorms';
+ },
+
+ _generateWind: (climate, season) => {
+ const rand = Math.random();
+
+ if (rand < 0.4) return 'Calm';
+ if (rand < 0.7) return 'Light breeze';
+ if (rand < 0.9) return 'Moderate wind';
+ if (rand < 0.97) return 'Strong wind';
+ return 'Very strong wind';
+ },
+
+ _generateDescription: (climate, season, temp, precip, wind) => {
+ let desc = precip;
+
+ if (precip !== 'Clear' && wind !== 'Calm') {
+ desc += `, ${wind.toLowerCase()}`;
+ }
+
+ return desc;
+ }
+
+ };
+
+ // ==================================================
+ // Tag System
+ // ==================================================
+
+ const TagSystem = {
+
+ expandPartyTags: (tags) => {
+ // Party management removed - tags are just passed through for now
+ return [...tags];
+ },
+
+ getAllTags: (data) => {
+ const events = data.events;
+ const notes = data.notes;
+
+ const allTags = new Set();
+
+ [...events, ...notes].forEach(item => {
+ if (item.tags) {
+ item.tags.forEach(tag => allTags.add(tag));
+ }
+ });
+
+ return Array.from(allTags).sort();
+ },
+
+ filterByTags: (items, tags) => {
+ if (!tags || tags.length === 0) return items;
+
+ return items.filter(item => {
+ if (!item.tags) return false;
+ return tags.some(tag => item.tags.includes(tag));
+ });
+ }
+
+ };
+
+ // ==================================================
+ // Input Handler
+ // ==================================================
+
+ const handleInput = (msg) => {
+ if (msg.type !== 'api') return;
+
+ const parsed = Parser.parse(msg.content);
+
+ if (parsed.command !== '!chronicle' && parsed.command !== '!chr') return;
+
+ Commands.root(msg, parsed);
+ };
+
+ // ==================================================
+ // Event Registration
+ // ==================================================
+
+ const registerEventHandlers = () => {
+ on('chat:message', handleInput);
+ };
+
+ // ==================================================
+ // Initialization
+ // ==================================================
+
+ const checkInstall = () => {
+ Logger.log(`v${version} [${new Date(lastUpdate * 1000)}]`);
+ State.initialize();
+ return true;
+ };
+
+ on('ready', () => {
+ if (checkInstall()) {
+ registerEventHandlers();
+ }
+ });
+
+ // ==================================================
+ // Public Interface
+ // ==================================================
+
+ return {
+ version: version
+ };
+})();
\ No newline at end of file
diff --git a/Chronicle/Chronicle.js b/Chronicle/Chronicle.js
new file mode 100644
index 000000000..5ac428a77
--- /dev/null
+++ b/Chronicle/Chronicle.js
@@ -0,0 +1,6544 @@
+// Script: Chronicle
+// By: Keith Curtis
+// Contact: https://app.roll20.net/users/162065/keithcurtis
+// Changelog
+// 0.1.0 Initial framework and data structures
+
+const Chronicle = (() => {
+ 'use strict';
+
+ // ==================================================
+ // Config
+ // ==================================================
+
+ const scriptName = 'Chronicle';
+ const version = '0.1.0';
+ const lastUpdate = Math.floor(Date.now() / 1000);
+ const schemaVersion = 0.1;
+
+ const DEBUG = true;
+ const LOGGING = false;
+
+ const HANDOUT_PREFIX = 'Chronicle';
+ const CHRONICLE_HELP_NAME = "Help: Chronicle";
+ const CHRONICLE_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147";
+ const CHRONICLE_HELP_TEXT = `
+
+
+
Chronicle Calendar System
+
+
Chronicle is a comprehensive calendar system for managing custom calendars, tracking events and notes, generating weather, and organizing campaign time.
+
+
Getting Started
+
+
Initial Setup
+
Command: !chr
+
Run this command to initialize Chronicle. This creates the main interface handout and opens the Design mode where you can configure your calendar.
+
+
Interface Modes
+
+- Calendar Mode: Default mode showing the calendar grid, featured date details, and navigation controls.
+- Design Mode: Configuration interface for setting up calendar structure, holidays, special days, moons, and weather.
+- Timeline Mode: Chronological view of all events, notes, and holidays with filtering and tagging.
+
+
+
Calendar Configuration (Design Mode)
+
+
Basic Calendar Structure
+
Click "Design" in the main interface to access configuration options.
+
+
Calendar Name
+
Chronicle ships with configuration for Gregorian and Harptos (the calendar used by the Forgotten Realms). Choose one of these, or create you own bly clicking New Calendar, and then set the name for your calendar system (e.g., "Mystara", "Exandrian"). The resto of these design instructions will assume that you are creating your own custom calendar. Otherwise, that is all you need to do.
+
+
Calendar Description
+
Add context about your calendar system that appears in Design mode. This is useful for explaining the calendar's structure, cultural significance, or special properties to players. Preset calendars (Gregorian, Absalom Reckoning, Faerun, Greyhawk, Eberron) include detailed descriptions that you can edit or replace with your own text.
+
+
Months
+
Define each month with:
+
+- Name: Month name (e.g., "Hammer", "January")
+- Days: Number of days in the month
+- Order: Position in the year (automatically managed)
+
+
Use the up/down arrows to reorder months. Delete unwanted months with the Delete button.
+
+
Weeks
+
Configure the weekly structure:
+
+- Days in Week: Number of days per week (typically 7)
+- Weekday Names: Names for each day (e.g., "Monday", "Tuesday")
+
+
+
Holidays
+
Add recurring or one-time holidays:
+
+- Name: Holiday name
+- Month/Day: Date of occurrence
+- Recurring: Whether it repeats annually
+- Description: Optional details about the holiday
+
+
Holidays appear in red text throughout the calendar. Click a holiday name to view its description privately (whisper) or announce it publicly to all players.
+
+
Special Days
+
Special days are intercalary days (like Midsummer) or leap days (like Shieldmeet) that fall outside the normal month/week structure.
+
+
Types
+
+- Fixed Special Days: Occur every year at the same position
+- Leap Special Days: Occur periodically (e.g., every 4 years)
+
+
+
Configuration
+
+- Name: Special day name
+- Position: After which month and day it occurs (e.g., "After Flamerule 30" for Midsummer)
+- Week Behavior:
+
+ - Part of week: Counts as a regular weekday
+ - Between weeks: Breaks the week cycle, appears as a separate row in the calendar grid
+
+
+- Frequency (Leap only): How often it occurs (e.g., 4 = every 4 years)
+- Offset (Leap only): Year offset for calculation (typically 0)
+- Description: Optional details about the special day
+
+
+
Moons
+
Add celestial bodies with lunar cycles that display on your calendar:
+
+- Name: Moon name (e.g., "Selûne")
+- Period: Days per complete cycle (supports decimals)
+- Full Moon Reference: A known date when the moon was full (used to calculate phases)
+- Size: Display size multiplier (0.1 to 1.0, where 1.0 is full size)
+- Color: Choose from 12 tint options: yellow, red, green, blue, cyan, orange, purple, tan, brown, white, gray, or dark
+- Display: Toggle whether moon appears on calendar grid
+
+
Moon Phases: Phases are calculated automatically based on your reference date and display on the calendar grid in the Featured Date section. Each moon shows its correct phase (new, waxing, full, waning) for the selected date.
+
Visibility: When multiple moons are visible, hover over any moon to see its name. Single-moon calendars have no tooltip to reduce clutter.
+
Sprite System: Moons use a sprite sheet system ensuring compatibility with Roll20's handout system. The system handles all 8 lunar phases with full color support.
+
+
Weather System
+
Enable procedural weather generation based on climate zones.
+
+
Setup
+
+- Click "Set Climate" in Design mode
+- The script will guide you through a series of prompts to configure your climate settings, according to a simplified Köppen climate classification.
+- Select temperature units (Fahrenheit or Celsius)
+
+
+
Generating Weather
+
Click "Generate Weather" in the Featured Date section to create weather for the current date. Generated weather persists and appears automatically when viewing that date.
+
+
Calendar Mode
+
+
Date Navigation Controls
+
+- ◀◀◀: Previous year
+- ◀◀: Previous month
+- ◀: Previous day
+- Year/Month/Day buttons: Jump to specific date via dropdown
+- ▶: Next day
+- ▶▶: Next month
+- ▶▶▶: Next year
+
+
+
Featured Date vs Today
+
+- Featured Date: The date currently displayed and selected for viewing/editing
+- Today: A saved bookmark representing the "current" campaign date
+- Go to Today: Navigate to the Today bookmark
+- Set Today: Save the current Featured Date as the new Today bookmark
+
+
+
Calendar Grid
+
Click any date in the calendar grid to set it as the Featured Date. Dates show:
+
+- Day number
+- Holidays (in red text)
+- Special days (full-width rows for "between weeks" types, or inline in red for "part of week" types)
+- Events/notes (first 40 characters displayed)
+
+
+
Events and Notes
+
+
Adding Events/Notes
+
In the Featured Date section, use the "Add Event" or "Add Note" buttons. Enter the content when prompted.
+
+
Difference Between Events and Notes
+
+- Events: Broad campaign events, usually with no specific date (war, plague, political upheaval)
+- Notes: Specific campaign events that occur on a given day. (Player actions, party actions, npc actions)
+
+
The distinction is organizational; both function identically and can be converted between types. In general, use Events for world historuical events, and Notes for campaign adventure tracking.
+
+
Managing Events/Notes
+
Each event/note has action buttons:
+
+- Edit: Modify the content
+- Delete: Remove permanently
+- ↔: Convert between event and note
+- Move: Relocate to a different date
+- +Tag: Add organizational tags
+
+
+
Tags
+
Tags are labels for organizing and filtering events/notes. Add multiple tags to categorize entries (e.g., "dungeon", "drow", "party", "Waterdeep").
+
+
Tags appear as clickable buttons in the timeline detailed view. Click a tag to remove it.
+
+- +Tag button: Opens a dropdown menu showing all existing tags in your campaign. Select a tag to instantly add it to that event/note. If no tags exist yet, you can type a new one.
+- [Untagged] filter: In Timeline mode, click [Untagged] to filter and show only items without any tags, making it easy to find and tag uncategorized items.
+
+
+
Tag Filtering: In Timeline mode, click any tag in the tag cloud to filter by that tag. Use the tag mode buttons to switch between showing items with ANY of the selected tags (OR) or ALL of the selected tags (AND).
+
+
Currently, each note or event is also appeneded with the name of the person who made it. At this moment, Chronicle is purley for GM use, but some campaigns may have multiple GMs. In that case, this feature allows you to track which GM added which information.
+
+
Send to Chat
+
+
Click "Send to Chat" to broadcast the current Featured Date information to all players. This includes:
+
+- Date and weekday
+- Moon phases
+- Weather (if generated)
+- Holidays and special days (clickable to announce descriptions)
+- Events and notes for that date
+
+
+
Timeline Mode
+
+
Accessing Timeline
+
Click "Timeline" in the main interface to view all events and notes chronologically.
+
+
Timeline Features
+
+
Date Range Selection
+
Set start and end dates to filter the timeline view. Year span determines detail level:
+
+- 1 year or less: Day-by-day view with all details
+- 1-5 years: Month-by-month summary
+- 5+ years: Yearly summary
+
+
By Default, events list at the beginning of the year they are in, and do not display a calendar date. Events are broad happenings. Notes are specific to a date, and display with their calendar date. You can also display events in ascending or descending chronological order.
+
+
Type Filters
+
+- Events: Toggle event visibility
+- Notes: Toggle note visibility
+- Holidays: Toggle holiday visibility (only shown for spans ≤1 year)
+- Weather: Toggle weather visibility (only shown for spans ≤1 year)
+- Tags: Filter by specific tags (same click behavior as main interface)
+- Untagged: Show only items with no tags attached. Works independently or combined with tag filters.
+
+
+
Show/Hide Details
+
Toggle this to display editing buttons and tag information for each entry. In detailed mode, each event and note displays:
+
+- Edit/Delete buttons: Modify or remove the item
+- +Tag button: Select from existing tags to add to this item
+- Tags: Clickable tags showing which categories apply
+- Elapsed Time: Time span from the currently viewing date to this item's date, in shorthand format (e.g., "2y.3m.15d", "-1m.2d")
+
+
+
Elapsed Time Display
+
Small buttons floating right on each item show time elapsed from your current viewing date:
+
+- Format: #y.#m.#d (e.g., "5y", "2y.3m.15d", "-18d")
+- Smart Display: Years only shown if span > 1 year, months only if span > 1 month
+- Negative Values: Minus sign indicates items before the viewing date
+- First of Year: Events on the first day of the year show only years (no months/days)
+- Clicking the button: Sets the viewing date to that item without switching modes, useful for exploring time relationships
+
+
+
Tag Mode
+
+- Any (OR): Includes any item with any of the selected tags.
+- All (AND): Includes only items with all of the selected tags.
+
+
+
Tags
+
Shows a tag cloud of all existing tags. Click tags to filter, or click [Untagged] to show items without tags.
+
+
Date Navigation
+
Clicking on a date in the timeline automatically switches to Calendar mode and displays that date. This lets you jump between timeline and calendar views easily.
+
+
+
+
Tips and Best Practices
+
+
Calendar Design
+
+- Start with basic structure (months, weeks) before adding holidays and special days
+- Test special days by navigating to their dates to ensure they appear correctly
+
+
+
Event/Note Organization
+
+- Develop a consistent tagging system early (e.g., "bruenor", "waterdeep", "adventure")
+- Use notes for tracking campaign events ("PCs Enter Lankhmar", "Frodo contract Mummy Rot")
+- Use events for historical events (battles, treaties, cataclysms)
+
+
+
Weather
+
+- Generate weather as needed rather than pre-generating for long periods
+- Weather persists once generated, so you can reference it later
+- Choose climate settings that match your campaign setting, and the generated weather should be believable
+
+
+
Timeline Usage
+
+- Use Timeline mode for campaign review and planning
+- Filter by tags to track specific storylines or NPCs
+- Start a session day by using "Send to Chat" feature for all players. This will inform them of date, weather, and holidays, if any, as well as show them where they are within the week.
+
+
+
Commands Reference
+
+
!chr - Initialize/open Chronicle interface
+
All other functions are accessed through the interactive interface buttons rather than direct commands.
+
+
Data Storage
+
+
Chronicle stores all data in Roll20 handouts:
+
+- Chronicle: [Campaign Name] - Calendar configuration (months, weeks, holidays, special days, moons, climate)
+- Chronicle Events: [Campaign Name] - Events, notes, and weather data
+- Chronicle Interface - Main interface handout
+- Help: Chronicle - This help documentation
+
+
+
These handouts are automatically created and updated. Do not manually edit their GM Notes section, as this may corrupt your calendar data.
+
+
+ `;
+
+ const INTERFACE_HANDOUT_NAME = 'Chronicle';
+
+ // ==================================================
+ // CSS (Centralized Styles)
+ // ==================================================
+
+ const cssDark = {
+ button: 'display: inline-block; padding: 4px 8px; margin: 2px; background: #5a9fd4; color: #111111; border: 1px solid #555555; border-radius: 3px; font-weight:bold; text-decoration: none; cursor: pointer; font-size: 11px;',
+ buttonSmall: 'display: inline-block; padding: 2px 5px; margin: 1px; background: #5a9fd4; color: #111111; border: 1px solid #555555; border-radius: 2px; font-weight:bold; text-decoration: none; cursor: pointer; font-size: 9px;',
+ creator: 'display: inline-block; padding: 2px 6px; margin: 0 3px; background: #3a3a3a; color: #aaaaaa; border-radius: 20px; font-size: 9px; font-weight: bold;',
+ tagButton: 'display: inline-block; padding: 2px 5px; margin: 0 1px; background: #2a2a2a; color: #cccccc; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px;font-weight: bold;',
+ tag: 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #2d2d2d; color: #bbbbbb; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px;font-weight: bold;',
+ holiday: 'color: #dd5555; font-weight: bold;',
+ container: 'background: #1a1a1a; color: #eeeeee; padding: 10px; border: 1px solid #555555; border-radius: 5px; font-family: "Helvetica Neue", Arial, sans-serif; margin: -30px;',
+ chatOutput: 'background: #4a4a4a; color: #eeeeee; padding: 8px 12px; border-left: 6px solid #6b8cae; border-top: 1px solid #6b8cae; border-right: 1px solid #6b8cae; border-bottom: 1px solid #6b8cae; border-radius: 3px; font-family: "Helvetica Neue", Arial, sans-serif; font-size: 13px; margin: 2px 0;',
+ header: 'background: #2d2d2d; color: #eeeeee; padding: 10px; margin: -10px -10px 10px -10px; border-bottom: 2px solid #555555; font-weight: bold; font-size: 16px;',
+ table: 'width: 100%; border-collapse: collapse; margin: 10px 0;',
+ tableCell: 'border: 1px solid #555555; padding: 5px; text-align: center; color: #eeeeee; vertical-align: top;',
+ calendarDay: 'width: 14.28%; height: 60px; vertical-align: top; border: 1px solid #555555; padding: 2px; position: relative; cursor: pointer; background: #2d2d2d; color: #eeeeee;',
+ calendarDayOtherMonth: 'width: 14.28%; height: 60px; vertical-align: top; border: 1px solid #555555; padding: 2px; position: relative; opacity: 0.5; cursor: pointer; background: #2d2d2d; color: #eeeeee;',
+ calendarDayToday: 'width: 14.28%; height: 60px; vertical-align: top; border: 3px solid #5a9fd4; padding: 2px; position: relative; background: #3a3a3a; cursor: pointer; font-weight: bold; color: #eeeeee;',
+ emojiCircle: 'background: #1a1a1a; border: 1px solid #555; border-radius: 50%; max-width: 32px; max-height: 32px; display: flex; align-items: center; justify-content: center; font-size: 24px; margin: 1px; float: right; line-height: 1;',
+ link: 'color: #5a9fd4; text-decoration: none;'
+ };
+
+ const lightModeOverrides = {
+ button: { background: '#4a7ac2', color: '#eeeeee', border: '1px solid #999999' },
+ buttonSmall: { background: '#4a7ac2', color: '#eeeeee', border: '1px solid #999999' },
+ creator: { background: '#e0e0e0', color: '#222222'},
+ tagButton: { background: '#cccccc', color: '#333333'},
+ tag: { background: '#cccccc', color: '#777777'},
+ holiday: { color: '#cc3333' },
+ container: { background: '#eeeeee', color: '#111111', border: '1px solid #cccccc' },
+ chatOutput: { background: '#dddddd', color: '#111111', 'border-left': '6px solid #4a7ac2', 'border-top': '1px solid #4a7ac2', 'border-right': '1px solid #4a7ac2', 'border-bottom': '1px solid #4a7ac2' },
+ header: { background: '#f5f5f5', color: '#111111', border: '2px solid #cccccc' },
+ tableCell: { border: '1px solid #cccccc', color: '#111111' },
+ calendarDay: { background: '#eeeeee', color: '#111111', border: '1px solid #cccccc' },
+ calendarDayOtherMonth: { background: '#eeeeee', color: '#111111', border: '1px solid #cccccc' },
+ calendarDayToday: { background: '#d8d8d8', color: '#111111', border: '3px solid #4a7ac2' },
+ emojiCircle: { background: '#333333', border: '1px solid #999' },
+ link: { color: '#2a5a9a' }
+ };
+
+ const fantasyModeOverrides = {
+ button: { background: '#8b4513', color: '#f4e8d0', border: '1px solid #5a3820' },
+ buttonSmall: { background: '#8b4513', color: '#f4e8d0', border: '1px solid #5a3820' },
+ creator: { background: '#d4c0a0', color: '#5a3820' },
+ tagButton: { background: '#c4b090', color: '#6b4820' },
+ tag: { background: '#cbb8a0', color: '#7b5830' },
+ holiday: { color: '#cc4444' },
+ container: { background: '#f4e8d0', color: '#2c1810', border: '1px solid #8b6f47' },
+ chatOutput: { background: '#e8d4b0', color: '#2c1810', 'border-left': '6px solid #8b4513', 'border-top': '1px solid #8b4513', 'border-right': '1px solid #8b4513', 'border-bottom': '1px solid #8b4513' },
+ header: { background: '#e8d4b0', color: '#2c1810', border: '2px solid #8b6f47' },
+ tableCell: { border: '1px solid #8b6f47', color: '#2c1810' },
+ calendarDay: { background: '#f4e8d0', color: '#2c1810', border: '1px solid #8b6f47' },
+ calendarDayOtherMonth: { background: '#f4e8d0', color: '#2c1810', border: '1px solid #8b6f47' },
+ calendarDayToday: { background: '#d4c0a0', color: '#2c1810', border: '3px solid #8b4513' },
+ emojiCircle: { background: '#5a3820', border: '1px solid #8b6f47' },
+ link: { color: '#6b3410' }
+ };
+
+ const generateThemedCSS = (baseCSS, overrides) => {
+ const result = {};
+
+ const replaceColors = (styleStr, override) => {
+ if (!override) return styleStr;
+
+ const props = styleStr.split(';').map(p => p.trim()).filter(Boolean);
+ const mapped = {};
+
+ props.forEach(p => {
+ const [key, value] = p.split(':').map(s => s.trim());
+ mapped[key] = value;
+ });
+
+ if (override.color) mapped.color = override.color;
+ if (override.background) mapped.background = override.background;
+ if (override.border) {
+ const sides = ['border', 'border-top', 'border-right', 'border-bottom', 'border-left'];
+ const borderKey = sides.find(k => Object.keys(mapped).includes(k)) || 'border';
+ mapped[borderKey] = override.border;
+ }
+
+ // Handle individual border properties
+ if (override.borderLeft) mapped['border-left'] = override.borderLeft;
+ if (override.borderTop) mapped['border-top'] = override.borderTop;
+ if (override.borderRight) mapped['border-right'] = override.borderRight;
+ if (override.borderBottom) mapped['border-bottom'] = override.borderBottom;
+
+ return Object.entries(mapped).map(([k, v]) => `${k}:${v}`).join('; ') + ';';
+ };
+
+ for (const key in baseCSS) {
+ const override = overrides[key];
+ result[key] = replaceColors(baseCSS[key], override);
+ }
+
+ return result;
+ };
+
+ const cssLight = generateThemedCSS(cssDark, lightModeOverrides);
+ const cssFantasy = generateThemedCSS(cssDark, fantasyModeOverrides);
+
+ const getCSS = () => {
+ const theme = State.config().theme;
+ if (theme === 'light') return cssLight;
+ if (theme === 'fantasy') return cssFantasy;
+ return cssDark;
+ };
+
+ // Legacy reference for backward compatibility
+ const CSS = cssDark;
+
+ // ==================================================
+ // Utilities
+ // ==================================================
+
+ const Utils = {
+
+ stripGM: (who) => {
+ // Remove " (GM)" suffix if present
+ return who.replace(/ \(GM\)$/, '');
+ },
+
+ parseTags: (tagString) => {
+ if (!tagString || tagString.trim() === '') return [];
+ return tagString.split(',')
+ .map(t => t.trim().toLowerCase())
+ .filter(t => t.length > 0);
+ }
+
+ };
+
+ // ==================================================
+ // Logger
+ // ==================================================
+
+ const Logger = {
+ log: (msg) => {
+ if (LOGGING) log(`${scriptName} | ${msg}`);
+ },
+ debug: (msg) => {
+ if (DEBUG) log(`${scriptName} [DEBUG] | ${msg}`);
+ },
+ error: (msg) => log(`${scriptName} [ERROR] | ${msg}`)
+ };
+
+ // ==================================================
+ // Data Models
+ // ==================================================
+
+ const DataModels = {
+
+ // Calendar structure
+ createCalendar: (name = 'New Calendar') => ({
+ name: name,
+ description: '', // Description of the calendar system
+ daysInYear: 365,
+ months: [],
+ interMonthDays: [], // Special days between months
+ weeks: {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ weekNames: [], // Optional week names
+ displayWeekNames: false,
+ canSpanMonths: true
+ },
+ leapYears: {
+ enabled: false,
+ cycle: 4, // Every N years
+ exceptions: [] // Years that don't follow the pattern
+ },
+ seasons: {
+ vernalEquinox: 1, // Day of year
+ // Other points calculated at even intervals
+ },
+ holidays: [], // Recurring holidays tied to specific dates
+ climate: null, // Current climate settings
+ units: 'us' // 'us' or 'metric'
+ }),
+
+ createMonth: (name, days, order) => ({
+ name: name,
+ days: days,
+ order: order // Position in year (0-indexed)
+ }),
+
+ createInterMonthDay: (name, position, breaksWeekCycle, dayType, frequency, offset, description) => ({
+ id: `special_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ position: position, // {afterMonth: X, afterDay: Y} - position in calendar
+ breaksWeekCycle: breaksWeekCycle, // true = between weeks, false = part of week
+ dayType: dayType || 'fixed', // 'fixed' or 'leap'
+ frequency: frequency || null, // for leap days (e.g., 4 = every 4 years)
+ offset: offset || 0, // for leap days (year % frequency === offset)
+ description: description || ''
+ }),
+
+ createMoon: (name, period, fullDayRef, size = 1, color = 'yellow', display = true) => ({
+ id: `moon_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ period: period, // Days per cycle (supports decimals)
+ fullDayRef: fullDayRef, // {year, month, day} when this moon was full
+ size: size, // Display size 0.1-1 (default 1)
+ color: color, // Illuminated portion color (default '#f7d79c')
+ display: display // Whether to show on calendar grid (default true)
+ }),
+
+ createHoliday: (name, dateRef, recurring, description) => ({
+ id: `holiday_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ dateRef: dateRef, // {month, day} or {month, week, weekday} for relative dates
+ recurring: recurring, // true for annual
+ type: 'absolute', // or 'relative'
+ description: description || ''
+ }),
+
+ createSpecialDay: (name, position, dayType, weekBehavior, frequency, offset, description) => ({
+ id: `special_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ position: position, // {afterMonth: X, afterDay: Y} - occurs after this date
+ dayType: dayType, // 'fixed' or 'leap'
+ weekBehavior: weekBehavior, // 'partOfWeek' or 'betweenWeeks'
+ frequency: frequency || null, // for leap days (e.g., 4 for every 4 years)
+ offset: offset || 0, // for leap days (year offset)
+ description: description || ''
+ }),
+
+ createEvent: (content, dateRef, tags, createdBy) => ({
+ id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ type: 'event',
+ content: content,
+ dateRef: dateRef, // {year, month, day} or {year, month} or {year}
+ tags: tags || [],
+ createdBy: createdBy,
+ createdAt: Date.now()
+ }),
+
+ createNote: (content, dateRef, tags, createdBy) => ({
+ id: `note_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ type: 'note',
+ content: content,
+ dateRef: dateRef, // {year, month, day} or {year, month} or {year}
+ tags: tags || [],
+ createdBy: createdBy,
+ createdAt: Date.now()
+ }),
+
+ createWeather: (dateRef, climate, temp, precipitation, wind, description) => ({
+ id: `weather_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ dateRef: dateRef, // {year, month, day}
+ climate: climate,
+ temperature: temp, // {value, unit}
+ precipitation: precipitation,
+ wind: wind,
+ description: description
+ }),
+
+ createParty: (name, members) => ({
+ id: `party_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ name: name,
+ members: members || [] // Array of character IDs
+ }),
+
+ createClimate: (inputs) => ({
+ latitude_band: inputs.latitude_band,
+ ocean_proximity: inputs.ocean_proximity,
+ coast_type: inputs.coast_type,
+ elevation: inputs.elevation,
+ rainshadow: inputs.rainshadow,
+ koppen_code: null,
+ climate_name: null,
+ temperature_profile: null,
+ precipitation_profile: null,
+ biome_hint: null
+ })
+
+ };
+
+ // ==================================================
+ // Climate Classification System
+ // ==================================================
+
+ const ClimateClassifier = {
+
+ classify: (inputs) => {
+ const climate = DataModels.createClimate(inputs);
+
+ // Step 1: Determine base climate group
+ let baseGroup = ClimateClassifier._getBaseGroup(inputs);
+
+ // Step 2: Check for arid override
+ const aridCheck = ClimateClassifier._checkArid(inputs);
+ if (aridCheck) {
+ climate.koppen_code = aridCheck;
+ ClimateClassifier._populateDescriptions(climate);
+ return climate;
+ }
+
+ // Step 3: Determine precipitation pattern
+ const precipPattern = ClimateClassifier._getPrecipPattern(inputs, baseGroup);
+
+ // Step 4: Determine temperature subtype
+ const tempSubtype = ClimateClassifier._getTempSubtype(inputs, baseGroup);
+
+ // Assemble final code
+ climate.koppen_code = baseGroup + precipPattern + tempSubtype;
+
+ // Generate descriptions
+ ClimateClassifier._populateDescriptions(climate);
+
+ return climate;
+ },
+
+ _getBaseGroup: (inputs) => {
+ let group;
+
+ switch (inputs.latitude_band) {
+ case 'tropical': group = 'A'; break;
+ case 'subtropical': group = 'C'; break;
+ case 'temperate': group = 'C'; break;
+ case 'subarctic': group = 'D'; break;
+ case 'polar': group = 'E'; break;
+ default: group = 'C';
+ }
+
+ // Override: continental temperate becomes subarctic
+ if (inputs.ocean_proximity === 'continental' && inputs.latitude_band === 'temperate') {
+ group = 'D';
+ }
+
+ // Override: alpine elevation shifts colder
+ if (inputs.elevation === 'alpine') {
+ if (group === 'C') group = 'D';
+ else if (group === 'D') group = 'E';
+ }
+
+ return group;
+ },
+
+ _checkArid: (inputs) => {
+ // Set to "B" if arid conditions met
+ if (inputs.rainshadow === 'leeward' && inputs.ocean_proximity !== 'coastal') {
+ // Arid
+ if (inputs.latitude_band === 'tropical' || inputs.latitude_band === 'subtropical') {
+ return 'BWh'; // Hot desert
+ } else {
+ return 'BWk'; // Cold desert
+ }
+ }
+
+ if (inputs.ocean_proximity === 'continental' &&
+ (inputs.latitude_band === 'subtropical' || inputs.latitude_band === 'temperate')) {
+ // Check for steppe mitigation
+ if (inputs.ocean_proximity === 'near_coastal' || inputs.rainshadow === 'windward') {
+ return 'BSk'; // Steppe
+ } else {
+ return 'BWk'; // Cold desert
+ }
+ }
+
+ return null; // Not arid
+ },
+
+ _getPrecipPattern: (inputs, baseGroup) => {
+ if (baseGroup === 'E' || baseGroup === 'B') return '';
+
+ // West coast + subtropical/temperate = dry summer
+ if (inputs.coast_type === 'west' &&
+ (inputs.latitude_band === 'subtropical' || inputs.latitude_band === 'temperate')) {
+ return 's';
+ }
+
+ // East coast + subtropical = dry winter (monsoonal)
+ if (inputs.coast_type === 'east' && inputs.latitude_band === 'subtropical') {
+ return 'w';
+ }
+
+ // Windward = no dry season
+ if (inputs.rainshadow === 'windward') {
+ return 'f';
+ }
+
+ // Default to no dry season
+ return 'f';
+ },
+
+ _getTempSubtype: (inputs, baseGroup) => {
+ if (baseGroup !== 'C' && baseGroup !== 'D') return '';
+
+ switch (inputs.latitude_band) {
+ case 'tropical':
+ case 'subtropical':
+ return 'a'; // Hot summer
+ case 'temperate':
+ return 'b'; // Warm summer
+ case 'subarctic':
+ return 'c'; // Cool summer
+ default:
+ return 'b';
+ }
+ },
+
+ _populateDescriptions: (climate) => {
+ const descriptions = {
+ 'Af': {
+ name: 'Tropical Rainforest',
+ temp: 'Hot and humid year-round',
+ precip: 'Heavy rainfall in all seasons',
+ biome: 'Dense jungle, diverse wildlife'
+ },
+ 'Aw': {
+ name: 'Tropical Savanna',
+ temp: 'Hot year-round',
+ precip: 'Distinct wet and dry seasons',
+ biome: 'Grasslands with scattered trees'
+ },
+ 'BWh': {
+ name: 'Hot Desert',
+ temp: 'Extremely hot days, cool nights',
+ precip: 'Minimal rainfall',
+ biome: 'Sparse vegetation, dunes, arid plains'
+ },
+ 'BWk': {
+ name: 'Cold Desert',
+ temp: 'Hot summers, cold winters',
+ precip: 'Very low precipitation',
+ biome: 'Rocky terrain, hardy shrubs'
+ },
+ 'BSk': {
+ name: 'Cold Steppe',
+ temp: 'Warm summers, cold winters',
+ precip: 'Low to moderate precipitation',
+ biome: 'Short grasslands, sparse vegetation'
+ },
+ 'BSh': {
+ name: 'Hot Steppe',
+ temp: 'Hot summers, mild winters',
+ precip: 'Low precipitation',
+ biome: 'Semi-arid grasslands'
+ },
+ 'Csa': {
+ name: 'Mediterranean',
+ temp: 'Hot dry summers, mild wet winters',
+ precip: 'Summer drought, winter rain',
+ biome: 'Scrubland, drought-resistant trees'
+ },
+ 'Csb': {
+ name: 'Warm Mediterranean',
+ temp: 'Warm dry summers, mild wet winters',
+ precip: 'Summer drought, winter rain',
+ biome: 'Mixed forest, chaparral'
+ },
+ 'Cfa': {
+ name: 'Humid Subtropical',
+ temp: 'Hot summers, mild winters',
+ precip: 'High humidity, frequent storms',
+ biome: 'Mixed forests, broadleaf vegetation'
+ },
+ 'Cfb': {
+ name: 'Marine West Coast',
+ temp: 'Mild temperatures year-round',
+ precip: 'Frequent rainfall in all seasons',
+ biome: 'Temperate rainforest, dense evergreen vegetation'
+ },
+ 'Cfc': {
+ name: 'Subpolar Oceanic',
+ temp: 'Cool summers, mild winters',
+ precip: 'Consistent rainfall',
+ biome: 'Coniferous forest, mosses'
+ },
+ 'Dfa': {
+ name: 'Hot-Summer Humid Continental',
+ temp: 'Hot summers, cold snowy winters',
+ precip: 'Moderate precipitation year-round',
+ biome: 'Deciduous and mixed forests'
+ },
+ 'Dfb': {
+ name: 'Warm-Summer Humid Continental',
+ temp: 'Warm summers, cold winters',
+ precip: 'Moderate precipitation year-round',
+ biome: 'Deciduous forests, seasonal variation'
+ },
+ 'Dfc': {
+ name: 'Subarctic',
+ temp: 'Cool summers, very cold winters',
+ precip: 'Low to moderate precipitation',
+ biome: 'Boreal forest, taiga'
+ },
+ 'Dfd': {
+ name: 'Extreme Subarctic',
+ temp: 'Cool summers, extremely cold winters',
+ precip: 'Low precipitation',
+ biome: 'Sparse boreal forest'
+ },
+ 'ET': {
+ name: 'Tundra',
+ temp: 'Cold year-round',
+ precip: 'Low precipitation',
+ biome: 'Permafrost, mosses, lichens'
+ },
+ 'EF': {
+ name: 'Ice Cap',
+ temp: 'Extremely cold year-round',
+ precip: 'Minimal precipitation',
+ biome: 'Permanent ice and snow'
+ }
+ };
+
+ // Handle polar special case
+ if (climate.koppen_code.startsWith('E')) {
+ if (climate.elevation === 'alpine') {
+ climate.koppen_code = 'EF';
+ } else {
+ climate.koppen_code = 'ET';
+ }
+ }
+
+ const desc = descriptions[climate.koppen_code] || descriptions['Cfb'];
+ climate.climate_name = desc.name;
+ climate.temperature_profile = desc.temp;
+ climate.precipitation_profile = desc.precip;
+ climate.biome_hint = desc.biome;
+ }
+
+ };
+
+ // ==================================================
+ // State Management
+ // ==================================================
+
+ const State = {
+
+ initialize: () => {
+ if (!state[scriptName] || state[scriptName].version !== schemaVersion) {
+
+ Logger.log(`Initializing Schema v${schemaVersion}`);
+
+ state[scriptName] = {
+ version: schemaVersion,
+ config: {
+ currentCalendar: null, // Name of active calendar handout
+ currentEvents: null, // Name of active events handout
+ currentDate: { year: 1, month: 1, day: 1 },
+ featuredDate: { year: 1, month: 1, day: 1 }, // Saved "current campaign date"
+ displayMode: 'calendar', // 'calendar', 'design', 'timeline'
+ theme: 'light', // 'light', 'dark', 'fantasy'
+ viewingDate: { year: 1, month: 1 }, // Month being viewed
+ verboseCalendar: false // Show full notes/events in calendar cells vs just indicators
+ }
+ };
+ }
+ },
+
+ get: () => state[scriptName],
+ config: () => state[scriptName].config,
+
+ setConfig: (key, value) => {
+ state[scriptName].config[key] = value;
+ }
+ };
+
+ // ==================================================
+ // Default Calendars
+ // ==================================================
+
+ const DefaultCalendars = {
+
+ gregorian: () => {
+ const cal = DataModels.createCalendar('Gregorian');
+ cal.description = 'The modern Gregorian Calendar is the internationally dominant civil calendar used across most of Earth. It is a solar calendar consisting of 365 days divided into twelve uneven months and organized into a repeating seven-day week. To maintain alignment with the Earth\'s orbit and seasonal cycle, a leap day is added every four years, except in certain century years not evenly divisible by 400. It is used globally for civil administration, commerce, science, and international coordination, though many cultures also maintain traditional or religious calendars alongside it. Earth has a single large moon. The math for the Gregorian has been simplified here for game use. If you want historical accuracy, consult an almanac.';
+ cal.daysInYear = 365;
+ cal.months = [
+ DataModels.createMonth('January', 31, 0),
+ DataModels.createMonth('February', 28, 1),
+ DataModels.createMonth('March', 31, 2),
+ DataModels.createMonth('April', 30, 3),
+ DataModels.createMonth('May', 31, 4),
+ DataModels.createMonth('June', 30, 5),
+ DataModels.createMonth('July', 31, 6),
+ DataModels.createMonth('August', 31, 7),
+ DataModels.createMonth('September', 30, 8),
+ DataModels.createMonth('October', 31, 9),
+ DataModels.createMonth('November', 30, 10),
+ DataModels.createMonth('December', 31, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.leapYears = {
+ enabled: true,
+ cycle: 4,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 80
+ };
+ cal.holidays = [
+ DataModels.createHoliday('New Year\'s Day', { month: 1, day: 1 }, true),
+ DataModels.createHoliday('Earth Day', { month: 4, day: 22 }, true),
+ DataModels.createHoliday('Memorial Day', { month: 5, day: 26 }, true),
+ DataModels.createHoliday('Independence Day', { month: 7, day: 4 }, true),
+ DataModels.createHoliday('Halloween', { month: 10, day: 31 }, true),
+ DataModels.createHoliday('Thanksgiving', { month: 11, day: 22 }, true),
+ DataModels.createHoliday('Christmas', { month: 12, day: 25 }, true)
+ ];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Luna', 29.53059, { year: 2000, month: 1, day: 21 })
+ ];
+ return cal;
+ },
+
+ absalom: () => {
+ const cal = DataModels.createCalendar('Absalom Reckoning');
+ cal.description = 'The common calendar of Golarion is the Absalom Reckoning system, dating years from the founding of the city of Absalom. The calendar closely resembles the modern Gregorian structure familiar to players, with 365 days divided into twelve uneven months and a seven-day week. Every four years, a leap day is added to maintain seasonal alignment. The system is widely used across the Inner Sea region for commerce, governance, and scholarship, though individual cultures may maintain local calendars and observances alongside it. Golarion has a single moon that follows regular phases and exerts strong cultural and mystical influence. Lunar cycles are associated with magic, lycanthropy, tides, religion, and omens, and many traditions mark important events according to the moon\'s position or appearance in the night sky.';
+ cal.daysInYear = 365;
+ cal.months = [
+ DataModels.createMonth('Abadius', 31, 0),
+ DataModels.createMonth('Calistril', 28, 1),
+ DataModels.createMonth('Pharast', 31, 2),
+ DataModels.createMonth('Gozran', 30, 3),
+ DataModels.createMonth('Desnus', 31, 4),
+ DataModels.createMonth('Sarenith', 30, 5),
+ DataModels.createMonth('Erastus', 31, 6),
+ DataModels.createMonth('Arodus', 31, 7),
+ DataModels.createMonth('Rova', 30, 8),
+ DataModels.createMonth('Lamashan', 31, 9),
+ DataModels.createMonth('Neth', 30, 10),
+ DataModels.createMonth('Kuthona', 31, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Moonday', 'Toilday', 'Wealday', 'Oathday', 'Fireday', 'Starday', 'Sunday'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.leapYears = {
+ enabled: true,
+ cycle: 4,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 80
+ };
+ cal.holidays = [
+ DataModels.createHoliday('Foundation Day', { month: 1, day: 1 }, true),
+ DataModels.createHoliday('Ascension Day', { month: 12, day: 25 }, true)
+ ];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Somal', 29.53059, { year: 4722, month: 1, day: 6 })
+ ];
+ return cal;
+ },
+
+ faerun: () => {
+ const cal = DataModels.createCalendar('Faerun');
+ cal.description = 'The standard calendar of the Forgotten Realms is the Calendar of Harptos, a solar calendar used across much of Faerûn. The year contains 365 days divided into twelve months of thirty days each. Instead of a seven-day week, the calendar uses ten-day periods commonly called tendays, which serve the same social and commercial role as weeks in many real-world cultures. Between several months are special intercalary festival days that do not belong to any month or tenday, and every four years an additional leap day is added to keep the calendar aligned with the seasons. Faerûn is illuminated primarily by a single moon, which follows regular phases.';
+ cal.daysInYear = 360;
+ cal.months = [
+ DataModels.createMonth('Hammer', 30, 0),
+ DataModels.createMonth('Alturiak', 30, 1),
+ DataModels.createMonth('Ches', 30, 2),
+ DataModels.createMonth('Tarsakh', 30, 3),
+ DataModels.createMonth('Mirtul', 30, 4),
+ DataModels.createMonth('Kythorn', 30, 5),
+ DataModels.createMonth('Flamerule', 30, 6),
+ DataModels.createMonth('Eleasis', 30, 7),
+ DataModels.createMonth('Eleint', 30, 8),
+ DataModels.createMonth('Marpenoth', 30, 9),
+ DataModels.createMonth('Uktar', 30, 10),
+ DataModels.createMonth('Nightal', 30, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 10,
+ weekdayNames: ['Firstday', 'Secondday', 'Thirdday', 'Fourthday', 'Fifthday', 'Sixthday', 'Seventhday', 'Eighthday', 'Ninthday', 'Tenthday'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.interMonthDays = [
+ DataModels.createInterMonthDay('Midwinter', { afterMonth: 1, afterDay: 30 }, true, 'fixed', null, 0, "A hard-season revel marking survival through winter's worst. Taverns overflow, nobles host masked feasts, and common folk exchange small gifts. Priests often proclaim omens for the coming year, making it fertile ground for prophecy, intrigue, or sudden violence beneath forced merriment."),
+ DataModels.createInterMonthDay('Greengrass', { afterMonth: 4, afterDay: 30 }, true, 'fixed', null, 0, "A joyous spring festival celebrating planting, fertility, and renewal. Villages hold dances, contests, and outdoor feasts while druids and priests bless fields. Travelers find communities unusually welcoming, though ancient barrows and fey sites are said to stir with new life as well."),
+ DataModels.createInterMonthDay('Midsummer', { afterMonth: 7, afterDay: 30 }, true, 'fixed', null, 0, "A raucous holiday of bonfires, drinking, romance, and excess. Nobles sponsor tournaments and public celebrations while adventurers easily find work as guards, performers, or duelists. The festival's chaos also makes it ideal cover for thefts, assassinations, and secret cult rites."),
+ DataModels.createInterMonthDay('Highharvestide', { afterMonth: 9, afterDay: 30 }, true, 'fixed', null, 0, "A harvest celebration focused on gratitude, trade, and preparation for winter. Markets swell with food, crafts, and livestock while temples collect offerings for the needy. Rural folk tell ghost stories and leave symbolic gifts to appease local spirits before the dark season begins."),
+ DataModels.createInterMonthDay('The Feast of the Moon', { afterMonth: 11, afterDay: 30 }, true, 'fixed', null, 0, "A solemn yet warm remembrance of the dead held as winter approaches. Families honor ancestors with candlelit vigils and shared meals, while priests conduct rites for wandering souls. Undead sightings and supernatural encounters are considered more common during the festival nights."),
+ DataModels.createInterMonthDay('Shieldmeet', { afterMonth: 1, afterDay: 30 }, true, 'leap', 4, 0, "Occurring only every four years, this extra feast day is tied to truces, diplomacy, and grand gatherings. Mercenary companies negotiate contracts, rulers announce decrees, and temples pursue reconciliation rituals. Many believe ancient magic weakens or shifts during Shieldmeet, encouraging risky arcane experiments.")
+ ];
+ cal.leapYears = {
+ enabled: false,
+ cycle: 0,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 60
+ };
+ cal.holidays = [];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Selune', 91, { year: 1, month: 8, day: 4 })
+ ];
+ return cal;
+ },
+
+ greyhawk: () => {
+ const cal = DataModels.createCalendar('Greyhawk');
+ cal.description = 'The Flanaess commonly uses the Common Year calendar, a structured system consisting of 364 days divided into twelve months of twenty-eight days each. The calendar is organized around a seven-day week, with every month containing exactly four weeks. In addition to the regular months, several festival weeks occur between seasons; these intercalary periods are not part of any month and are often associated with celebrations, religious observances, tournaments, and civic events. Every four years, an additional leap festival week is inserted to preserve seasonal accuracy. The system is highly orderly and easy to track, making it popular among scholars, merchants, and rulers throughout the known world. Greyhawk\'s world possesses two moons.';
+ cal.daysInYear = 364;
+ cal.months = [
+ DataModels.createMonth('Fireseek', 28, 0),
+ DataModels.createMonth('Readying', 28, 1),
+ DataModels.createMonth('Coldeven', 28, 2),
+ DataModels.createMonth('Growfest', 7, 3),
+ DataModels.createMonth('Planting', 28, 4),
+ DataModels.createMonth('Flocktime', 28, 5),
+ DataModels.createMonth('Wealsun', 28, 6),
+ DataModels.createMonth('Richfest', 7, 7),
+ DataModels.createMonth('Reaping', 28, 8),
+ DataModels.createMonth('Goodmonth', 28, 9),
+ DataModels.createMonth('Harvester', 28, 10),
+ DataModels.createMonth('Brewfest', 7, 11),
+ DataModels.createMonth('Patchwall', 28, 12),
+ DataModels.createMonth('Ready\'reat', 28, 13),
+ DataModels.createMonth('Sunsebb', 28, 14),
+ DataModels.createMonth('Needfest', 7, 15)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Starday', 'Sunday', 'Moonday', 'Godsday', 'Waterday', 'Earthday', 'Freeday'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: false
+ };
+ cal.leapYears = {
+ enabled: false,
+ cycle: 0,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 91
+ };
+ cal.holidays = [];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Luna', 28, { year: 1, month: 8, day: 4 }, .8, 'cyan', true),
+ DataModels.createMoon('Celene', 91, { year: 1, month: 8, day: 4 }, 1, 'white', true)
+ ];
+ return cal;
+ },
+
+ eberron: () => {
+ const cal = DataModels.createCalendar('Eberron');
+ cal.description = 'The standard calendar of Eberron is the Galifar Calendar, established during the reign of the Kingdom of Galifar and still used throughout Khorvaire. The year contains 336 days divided into twelve months of exactly twenty-eight days each, creating a perfectly regular structure of four seven-day weeks per month. Because every month begins on the same weekday, dates are easy to track and schedule. The calendar contains no leap years or intercalary festival days. Eberron has an unusually complex lunar system consisting of twelve moons of varying sizes, colors, and orbital periods. The changing combinations of visible moons are a major feature of the setting\'s atmosphere and cosmology, particularly in relation to magic, prophecy, and planar influence. For simplicity, about half the moons do not display in Calendar view. This can be edited below.';
+ cal.daysInYear = 336;
+ cal.months = [
+ DataModels.createMonth('Zarantyr', 28, 0),
+ DataModels.createMonth('Olarune', 28, 1),
+ DataModels.createMonth('Therendor', 28, 2),
+ DataModels.createMonth('Eyre', 28, 3),
+ DataModels.createMonth('Dravago', 28, 4),
+ DataModels.createMonth('Nymm', 28, 5),
+ DataModels.createMonth('Lharvion', 28, 6),
+ DataModels.createMonth('Barrakas', 28, 7),
+ DataModels.createMonth('Rhaan', 28, 8),
+ DataModels.createMonth('Sypheros', 28, 9),
+ DataModels.createMonth('Aryth', 28, 10),
+ DataModels.createMonth('Vult', 28, 11)
+ ];
+ cal.weeks = {
+ enabled: true,
+ daysInWeek: 7,
+ weekdayNames: ['Sul', 'Mol', 'Zol', 'Wir', 'Zor', 'Far', 'Sar'],
+ weekNames: [],
+ displayWeekNames: false,
+ canSpanMonths: true
+ };
+ cal.leapYears = {
+ enabled: false,
+ cycle: 4,
+ exceptions: []
+ };
+ cal.seasons = {
+ vernalEquinox: 84
+ };
+ cal.holidays = [
+ DataModels.createHoliday('Brightblade', { month: 1, day: 12 }, true, 'A festival honoring Dol Arrah and ideals of sacrifice, courage, and honorable battle.'),
+ DataModels.createHoliday('Long Shadows', { month: 9, day: 26 }, true, 'A solemn remembrance of the dead associated with Dolurrh, funerary rites, and ancestral reflection.'),
+ DataModels.createHoliday('Wildnight', { month: 10, day: 18 }, true, 'A chaotic celebration tied to the Traveler, featuring masks, revelry, deception, and unpredictable behavior.'),
+ DataModels.createHoliday('Baker\'s Night', { month: 11, day: 9 }, true, 'A communal feast celebrated across Khorvaire with food, hospitality, storytelling, and preparation for winter.')
+ ];
+ cal.climate = null;
+ cal.units = 'us';
+ cal.moons = [
+ DataModels.createMoon('Zarantyr', 0.4, { year: 1, month: 1, day: 1 }, 0.3, 'orange', false), // Tiny, hidden
+ DataModels.createMoon('Olarune', 0.8, { year: 1, month: 1, day: 2 }, 0.4, 'gray', false), // Small, hidden
+ DataModels.createMoon('Therendor', 1.8, { year: 1, month: 1, day: 3 }, 0.5, 'tan', false), // Medium-small, hidden
+ DataModels.createMoon('Eyre', 2.9, { year: 1, month: 1, day: 4 }, 0.6, 'yellow', true), // Medium, visible, default color
+ DataModels.createMoon('Dravago', 5.1, { year: 1, month: 1, day: 5 }, 0.7, 'orange', true), // Medium-large, visible, orange
+ DataModels.createMoon('Nymm', 6.7, { year: 1, month: 1, day: 6 }, 0.8, 'blue', true), // Large, visible, blue
+ DataModels.createMoon('Lharvion', 10.3, { year: 1, month: 1, day: 7 }, 0.9, 'red', true), // Very large, visible, red
+ DataModels.createMoon('Barrakas', 12.3, { year: 1, month: 1, day: 8 }, 1.0, 'orange', true), // Full size, visible, orange
+ DataModels.createMoon('Rhaan', 14.5, { year: 1, month: 1, day: 9 }, 0.5, 'purple', false), // Medium-small, hidden, purple
+ DataModels.createMoon('Sypheros', 16.9, { year: 1, month: 1, day: 10 }, 0.6, 'brown', false), // Medium, hidden, brown
+ DataModels.createMoon('Aryth', 11.9, { year: 1, month: 1, day: 11 }, 0.7, 'tan', false), // Medium-large, hidden, tan
+ DataModels.createMoon('Vult', 29.2, { year: 1, month: 1, day: 12 }, 0.8, 'yellow', true) // Large, visible, yellow
+ ];
+ return cal;
+ }
+
+ };
+
+ // ==================================================
+ // Parser
+ // ==================================================
+
+ const Parser = {
+
+ parse: (content) => {
+ const tokens = content.trim().split(/\s+/);
+ const command = tokens.shift();
+
+ const args = {};
+ let currentKey = null;
+
+ tokens.forEach(token => {
+
+ if (token.startsWith('--')) {
+ currentKey = token.replace(/^--/, '');
+ args[currentKey] = true;
+ return;
+ }
+
+ if (currentKey) {
+ // Don't split on pipe - keep the entire value intact
+ if (args[currentKey] === true) {
+ args[currentKey] = token;
+ } else {
+ args[currentKey] += ` ${token}`;
+ }
+ }
+
+ });
+
+ return { command, args };
+ }
+
+ };
+
+ // ==================================================
+ // Output
+ // ==================================================
+
+ const Output = {
+
+ send: (who, message) => {
+ const CSS_CURRENT = getCSS();
+ const cleanWho = who.split('(GM')[0].trim();
+ const cleanMessage = message.replace(/\r?\n/g, '');
+ // Use chatOutput style for whispered messages
+ const styledMessage = `${cleanMessage}
`;
+ sendChat(scriptName, `/w "${cleanWho}" ${styledMessage}`);
+ },
+
+ broadcast: (message) => {
+ const cleanMessage = message.replace(/\r?\n/g, '');
+ sendChat(scriptName, cleanMessage);
+ },
+
+ makeButton: (label, command, style) => {
+ const CSS_CURRENT = getCSS();
+ const buttonStyle = style || CSS_CURRENT.button;
+ return `${label}`;
+ }
+
+ };
+
+ // ==================================================
+ // Handout Management
+ // ==================================================
+
+ const HandoutManager = {
+
+ findHandout: (name) => {
+ return findObjs({ type: 'handout', name: name })[0];
+ },
+
+ createHandout: (name, notes, gmnotes = '', archived = true) => {
+ return createObj('handout', {
+ name: name,
+ notes: notes,
+ gmnotes: gmnotes,
+ infolderorder: '',
+ archived: archived
+ });
+ },
+
+ getHandoutNotes: (handout, callback) => {
+ handout.get('notes', callback);
+ },
+
+ getHandoutGMNotes: (handout, callback) => {
+ handout.get('gmnotes', callback);
+ },
+
+ setHandoutNotes: (handout, notes) => {
+ handout.set('notes', notes);
+ },
+
+ setHandoutGMNotes: (handout, gmnotes) => {
+ handout.set('gmnotes', gmnotes);
+ },
+
+ saveCalendar: (calendar) => {
+ const name = `${HANDOUT_PREFIX} Calendar: ${calendar.name}`;
+ let handout = HandoutManager.findHandout(name);
+
+ const data = JSON.stringify(calendar, null, 2);
+
+ if (!handout) {
+ handout = HandoutManager.createHandout(name, '');
+ Logger.log(`Created calendar handout: ${name}`);
+ }
+
+ HandoutManager.setHandoutGMNotes(handout, data);
+ Logger.log(`Updated calendar handout: ${name}`);
+
+ State.setConfig('currentCalendar', name);
+ return handout;
+ },
+
+ loadData: (callback) => {
+ // Load calendar and events data using proper async callbacks
+ // Pass loaded data to callback instead of storing in state
+ const calName = State.config().currentCalendar;
+ if (!calName) {
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ const calHandout = HandoutManager.findHandout(calName);
+ if (!calHandout) {
+ Logger.error(`Calendar handout not found: ${calName}`);
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ // Load calendar with callback
+ HandoutManager.getHandoutGMNotes(calHandout, (gmnotes) => {
+ let calendar = null;
+ let moons = [];
+
+ try {
+ calendar = JSON.parse(gmnotes || '{}');
+ moons = calendar.moons || [];
+ Logger.debug(`Loaded calendar: ${calendar.name}`);
+ } catch (e) {
+ Logger.error(`Failed to parse calendar: ${e}`);
+ }
+
+ // Load events with callback
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calName.replace(`${HANDOUT_PREFIX} Calendar: `, '')}`;
+ const eventsHandout = HandoutManager.findHandout(eventsName);
+
+ if (!eventsHandout) {
+ callback({
+ calendar: calendar,
+ events: [],
+ notes: [],
+ moons: moons,
+ weather: []
+ });
+ return;
+ }
+
+ HandoutManager.getHandoutGMNotes(eventsHandout, (eventsNotes) => {
+ let events = [];
+ let notes = [];
+ let weather = [];
+
+ try {
+ const data = JSON.parse(eventsNotes || '{}');
+ events = data.events || [];
+ notes = data.notes || [];
+ weather = data.weather || [];
+ Logger.debug(`Loaded ${events.length} events, ${notes.length} notes`);
+ } catch (e) {
+ Logger.error(`Failed to parse events: ${e}`);
+ }
+
+ // Return all loaded data
+ callback({
+ calendar: calendar,
+ events: events,
+ notes: notes,
+ moons: moons,
+ weather: weather
+ });
+ });
+ });
+ },
+
+
+
+ saveEvents: (campaignName, events, notes, weather = []) => {
+ const name = `${HANDOUT_PREFIX} Events: ${campaignName}`;
+ let handout = HandoutManager.findHandout(name);
+
+ const jsonData = JSON.stringify({ events, notes, weather }, null, 2);
+
+ if (!handout) {
+ handout = HandoutManager.createHandout(name, jsonData);
+ Logger.log(`Created events handout: ${name}`);
+ } else {
+ HandoutManager.setHandoutGMNotes(handout, jsonData);
+ Logger.log(`Updated events handout: ${name}`);
+ }
+
+ State.setConfig('currentEvents', name);
+ return handout;
+ },
+
+ loadEvents: (name, callback) => {
+ const handout = HandoutManager.findHandout(name);
+ if (!handout) {
+ Logger.error(`Events handout not found: ${name}`);
+ callback({ events: [], notes: [] });
+ return;
+ }
+
+ HandoutManager.getHandoutGMNotes(handout, (gmnotes) => {
+ try {
+ const data = JSON.parse(gmnotes);
+ Logger.debug(`Loaded ${data.events.length} events and ${data.notes.length} notes`);
+ callback(data);
+ } catch (e) {
+ Logger.error(`Failed to parse events: ${e}`);
+ callback({ events: [], notes: [] });
+ }
+ });
+ }
+
+ };
+
+ // ==================================================
+ // Date Utilities
+ // ==================================================
+
+ const DateUtils = {
+
+ // Convert {year, month, day} to absolute day number
+ toAbsoluteDay: (dateRef, calendar) => {
+ if (!calendar || !dateRef) return 0;
+
+ let dayCount = 0;
+
+ // Add full years
+ for (let y = 1; y < dateRef.year; y++) {
+ dayCount += DateUtils.getDaysInYear(y, calendar);
+ }
+
+ // Add full months in current year
+ if (dateRef.month) {
+ for (let m = 1; m < dateRef.month; m++) {
+ dayCount += DateUtils.getDaysInMonth(m, dateRef.year, calendar);
+ }
+ }
+
+ // Add days in current month
+ if (dateRef.day) {
+ dayCount += dateRef.day;
+ }
+
+ return dayCount;
+ },
+
+ // Get days in a specific year (accounting for leap years)
+ getDaysInYear: (year, calendar) => {
+ if (!calendar.leapYears.enabled) return calendar.daysInYear;
+
+ if (year % calendar.leapYears.cycle === 0 && !calendar.leapYears.exceptions.includes(year)) {
+ return calendar.daysInYear + 1; // Leap year
+ }
+
+ return calendar.daysInYear;
+ },
+
+ // Get days in a specific month
+ getDaysInMonth: (monthNum, year, calendar) => {
+ const month = calendar.months[monthNum - 1];
+ if (!month) return 0;
+
+ // Check if this is February in a leap year (for Gregorian-like calendars)
+ if (calendar.leapYears.enabled && monthNum === 2) {
+ if (year % calendar.leapYears.cycle === 0 && !calendar.leapYears.exceptions.includes(year)) {
+ return month.days + 1;
+ }
+ }
+
+ return month.days;
+ },
+
+ // Calculate distance between two dates
+ calculateDistance: (from, to, calendar) => {
+ const fromAbs = DateUtils.toAbsoluteDay(from, calendar);
+ const toAbs = DateUtils.toAbsoluteDay(to, calendar);
+ const diff = toAbs - fromAbs;
+
+ // Convert to appropriate unit
+ if (Math.abs(diff) < 365) {
+ return `${diff > 0 ? '+' : ''}${diff}d`;
+ }
+
+ const years = diff / 365;
+ if (Math.abs(years) < 10) {
+ return `${years > 0 ? '+' : ''}${years.toFixed(1)}y`;
+ }
+
+ return `${years > 0 ? '+' : ''}${Math.round(years)}y`;
+ },
+
+ // Get the weekday for a given date
+ getWeekday: (dateRef, calendar) => {
+ if (!calendar.weeks.enabled) return null;
+
+ const absDay = DateUtils.toAbsoluteDay(dateRef, calendar);
+ const weekdayIndex = (absDay - 1) % calendar.weeks.daysInWeek;
+ return calendar.weeks.weekdayNames[weekdayIndex];
+ },
+
+ // Get special days that occur in a specific year
+ getSpecialDaysForYear: (year, calendar) => {
+ if (!calendar.interMonthDays) return [];
+
+ return calendar.interMonthDays.filter(sd => {
+ if (sd.dayType === 'fixed') {
+ return true; // Fixed days always occur
+ } else if (sd.dayType === 'leap') {
+ // Check if this year qualifies for the leap day
+ return (year - sd.offset) % sd.frequency === 0;
+ }
+ return false;
+ });
+ },
+
+ // Get special days that occur after a specific date
+ getSpecialDaysAfterDate: (month, day, year, calendar) => {
+ const specialDays = DateUtils.getSpecialDaysForYear(year, calendar);
+
+ return specialDays.filter(sd => {
+ if (!sd.position) return false;
+ // Check if special day comes after this month/day
+ if (sd.position.afterMonth > month) return false;
+ if (sd.position.afterMonth === month && sd.position.afterDay > day) return false;
+ return true;
+ });
+ },
+
+ // Check if a date is a special day (occurs AFTER the position day)
+ isSpecialDay: (month, day, year, calendar) => {
+ const specialDays = DateUtils.getSpecialDaysForYear(year, calendar);
+
+ return specialDays.find(sd => {
+ if (!sd.position) return false;
+ // Special day occurs the day AFTER position.afterDay
+ if (sd.position.afterMonth !== month) return false;
+
+ // For "part of week" special days, they occur on afterDay + 1
+ // For "between weeks" they're shown separately in grid
+ if (!sd.breaksWeekCycle) {
+ return day === sd.position.afterDay + 1;
+ }
+
+ return false;
+ });
+ },
+
+ // Calculate elapsed time between two dates
+ // Returns object with {years, months, days, isNegative} for display
+ getElapsedTime: (fromDate, toDate, calendar) => {
+ const fromAbsolute = DateUtils.toAbsoluteDay(fromDate, calendar);
+ const toAbsolute = DateUtils.toAbsoluteDay(toDate, calendar);
+
+ let totalDays = toAbsolute - fromAbsolute;
+ const isNegative = totalDays < 0;
+ totalDays = Math.abs(totalDays);
+
+ // For events on first day of year, just return years
+ const isFirstOfYear = toDate.month === 1 && toDate.day === 1;
+
+ if (isFirstOfYear && totalDays >= 365) {
+ const years = Math.floor(totalDays / 365);
+ return { years: years, months: 0, days: 0, isNegative: isNegative, isFirstOfYear: true };
+ }
+
+ // Calculate years, months, days
+ let years = 0;
+ let months = 0;
+ let days = totalDays;
+
+ // Calculate years
+ const daysInYear = DateUtils.getDaysInYear(fromDate.year, calendar);
+ if (days >= daysInYear) {
+ years = Math.floor(days / daysInYear);
+ days = days % daysInYear;
+ }
+
+ // Calculate months (approximate - use average of 30 days)
+ if (days >= 30) {
+ months = Math.floor(days / 30);
+ days = days % 30;
+ }
+
+ return { years: years, months: months, days: days, isNegative: isNegative, isFirstOfYear: false };
+ }
+
+ };
+
+ // ==================================================
+ // Moon Phase Calculator
+ // ==================================================
+
+ const MoonPhaseCalculator = {
+
+ getPhase: (moon, dateRef, calendar) => {
+ const currentDay = DateUtils.toAbsoluteDay(dateRef, calendar);
+ const fullDay = DateUtils.toAbsoluteDay(moon.fullDayRef, calendar);
+
+ const daysSinceFull = currentDay - fullDay;
+ const cyclePosition = ((daysSinceFull % moon.period) + moon.period) % moon.period;
+
+ // Normalize to 0-1, where 0 is new moon, 0.5 is full moon
+ // Since fullDayRef is when the moon WAS full, we need to offset by half a cycle
+ let phase = cyclePosition / moon.period;
+ phase = (phase + 0.5) % 1; // Shift so full moon reference = 0.5
+
+ return phase;
+ },
+
+ generateMoonHTML: (phase, size, color, moonName, showTooltip) => {
+ // Set defaults
+ if (size === undefined) size = 1;
+ if (color === undefined) color = 'yellow';
+ if (moonName === undefined) moonName = '';
+ if (showTooltip === undefined) showTooltip = false;
+
+ // Sprite sheet URL
+ const spriteURL = 'https://files.d20.io/images/488065736/0YUajKyQKqwp_NAkiQZw2Q/original.webp?1779563627';
+
+ // Map color names to row indices (0-11)
+ const colorMap = {
+ 'yellow': 0, '#f7d79c': 0, // default yellow
+ 'red': 1, '#ff0000': 1, '#ff4500': 1, '#ff6347': 1,
+ 'green': 2, '#00ff00': 2, '#008000': 2,
+ 'blue': 3, '#0000ff': 3, '#87ceeb': 3,
+ 'cyan': 4, '#00ffff': 4,
+ 'orange': 5, '#ffa500': 5, '#d4af37': 5, '#ffd700': 5,
+ 'purple': 6, '#800080': 6, '#dda0dd': 6,
+ 'tan': 7, '#d2b48c': 7, '#f0e68c': 7, '#e8dcc4': 7,
+ 'brown': 8, '#8b4513': 8, '#a0522d': 8,
+ 'white': 9, '#ffffff': 9, '#f8f8ff': 9,
+ 'gray': 10, '#808080': 10, '#c0c0c0': 10,
+ 'dark': 11, '#000000': 11, '#2a2a2a': 11
+ };
+
+ // Find closest color match
+ let rowIndex = 0;
+ const lowerColor = (color || '').toLowerCase();
+ if (colorMap[lowerColor] !== undefined) {
+ rowIndex = colorMap[lowerColor];
+ } else if (colorMap[color] !== undefined) {
+ rowIndex = colorMap[color];
+ }
+
+ // Map phase (0-1) to column index (0-7)
+ // 0 = new, 0.125 = waxing crescent, 0.25 = first quarter, 0.375 = waxing gibbous
+ // 0.5 = full, 0.625 = waning gibbous, 0.75 = last quarter, 0.875 = waning crescent
+ let colIndex = Math.floor(phase * 8);
+ if (colIndex >= 8) colIndex = 7; // Cap at 7
+
+ // Sprite sheet specs
+ const sheetWidth = 512;
+ const sheetHeight = 768;
+ const cols = 8;
+ const rows = 12;
+ const cellWidth = sheetWidth / cols; // 64px
+ const cellHeight = sheetHeight / rows; // 64px
+
+ // Calculate display size
+ const baseSize = 20;
+ const actualSize = baseSize * Math.max(0.1, Math.min(1, size));
+
+ // Calculate background position (negative offsets to show the correct cell)
+ const bgX = -(colIndex * actualSize);
+ const bgY = -(rowIndex * actualSize);
+
+ // Scale the entire sprite sheet so each 64px cell becomes actualSize pixels
+ // Sheet is 8 cols × 12 rows, so scaled sheet is (8*actualSize) × (12*actualSize)
+ const scaledSheetWidth = cols * actualSize;
+ const scaledSheetHeight = rows * actualSize;
+
+ // Create HTML with background sprite
+ let html = '';
+
+ return html;
+ },
+
+ getAllPhases: (moons, dateRef, calendar) => {
+ if (!moons || moons.length === 0) {
+ return [];
+ }
+
+ const visibleMoons = moons.filter(m => m.display !== false);
+ const showTooltips = visibleMoons.length > 1;
+
+ const results = [];
+ for (let i = 0; i < visibleMoons.length; i++) {
+ const moon = visibleMoons[i];
+ try {
+ const phase = MoonPhaseCalculator.getPhase(moon, dateRef, calendar);
+ const size = moon.size || 1;
+ const color = moon.color || 'yellow';
+ const html = MoonPhaseCalculator.generateMoonHTML(phase, size, color, moon.name, showTooltips);
+
+ results.push({
+ name: moon.name,
+ phase: phase,
+ html: html
+ });
+ } catch (e) {
+ log('Error generating moon phase for ' + moon.name + ': ' + e);
+ }
+ }
+
+ return results;
+ }
+
+ };
+
+ // ==================================================
+ // Interface Renderer
+ // ==================================================
+
+
+ // ==================================================
+ // Data Loader - Loads data from handouts with callbacks
+ // ==================================================
+
+ const DataLoader = {
+ loadAll: (callback) => {
+ const calName = State.config().currentCalendar;
+
+ if (!calName) {
+ // No calendar loaded
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ const calHandout = HandoutManager.findHandout(calName);
+ if (!calHandout) {
+ Logger.error(`Calendar handout not found: ${calName}`);
+ callback({
+ calendar: null,
+ events: [],
+ notes: [],
+ moons: [],
+ weather: []
+ });
+ return;
+ }
+
+ // Load calendar with callback
+ HandoutManager.getHandoutGMNotes(calHandout, (gmnotes) => {
+ let calendar = null;
+ let moons = [];
+
+ try {
+ calendar = JSON.parse(gmnotes || '{}');
+
+ // Migration: ensure moons array exists
+ if (!calendar.moons) {
+ calendar.moons = [];
+ }
+ moons = calendar.moons;
+ } catch (e) {
+ Logger.error(`Failed to parse calendar: ${e}`);
+ }
+
+ // Load events with callback
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calName.replace(`${HANDOUT_PREFIX} Calendar: `, '')}`;
+ const eventsHandout = HandoutManager.findHandout(eventsName);
+
+ if (!eventsHandout) {
+ callback({
+ calendar: calendar,
+ events: [],
+ notes: [],
+ moons: moons,
+ weather: []
+ });
+ return;
+ }
+
+ HandoutManager.getHandoutGMNotes(eventsHandout, (eventsNotes) => {
+ let events = [];
+ let notes = [];
+ let weather = [];
+
+ try {
+ const data = JSON.parse(eventsNotes || '{}');
+ events = data.events || [];
+ notes = data.notes || [];
+ weather = data.weather || [];
+ } catch (e) {
+ Logger.error(`Failed to parse events: ${e}`);
+ }
+
+ callback({
+ calendar: calendar,
+ events: events,
+ notes: notes,
+ moons: moons,
+ weather: weather
+ });
+ });
+ });
+ }
+ };
+
+ const InterfaceRenderer = {
+
+ render: (mode, data, callback) => {
+ const CSS_CURRENT = getCSS();
+ const theme = State.config().theme;
+
+ let content = '';
+
+ // Outer wrapper for entire handout background
+ content += ``;
+
+ content += InterfaceRenderer.renderHeader(mode);
+
+ switch (mode) {
+ case 'calendar':
+ content += InterfaceRenderer.renderCalendarMode(data);
+ break;
+ case 'design':
+ content += InterfaceRenderer.renderDesignMode(data);
+ break;
+ case 'timeline':
+ content += InterfaceRenderer.renderTimelineMode(data);
+ break;
+ default:
+ content += '
Unknown mode
';
+ }
+
+ content += '
'; // Close outer wrapper
+
+ // Save to interface handout (theme is already applied via getCSS() in each component)
+ let handout = HandoutManager.findHandout(INTERFACE_HANDOUT_NAME);
+ if (!handout) {
+ handout = HandoutManager.createHandout(INTERFACE_HANDOUT_NAME, content, '', false);
+ } else {
+ HandoutManager.setHandoutNotes(handout, content);
+ }
+
+ if (callback) callback(handout);
+ },
+
+ renderHeader: (currentMode) => {
+ const CSS_CURRENT = getCSS();
+ const modes = [
+ { key: 'calendar', label: 'Calendar' },
+ { key: 'design', label: 'Design' },
+ { key: 'timeline', label: 'Timeline' }
+ ];
+
+ const themes = [
+ { key: 'light', label: '☀️' },
+ { key: 'dark', label: '🌙' },
+ { key: 'fantasy', label: '📜' }
+ ];
+
+ let html = '';
+ html += 'Chronicle';
+
+ html += '';
+
+ // Mode buttons (inline)
+ modes.forEach(m => {
+ const style = m.key === currentMode ? CSS_CURRENT.button + 'font-weight: bold;' : CSS_CURRENT.button;
+ html += Output.makeButton(m.label, `!chr --mode ${m.key}`, style);
+ });
+
+ html += '|';
+
+ // Theme buttons (inline, same size as mode buttons)
+ themes.forEach(t => {
+ html += Output.makeButton(t.label, `!chr --theme ${t.key}`, CSS_CURRENT.button);
+ });
+
+ html += '|';
+
+ // Utility buttons (inline, same size)
+ html += Output.makeButton('Help', '!chr --help', CSS_CURRENT.button);
+ html += Output.makeButton('Send to Chat', `!chr --chat ${currentMode}`, CSS_CURRENT.button);
+
+ html += ''; // Close float:right span
+ html += '
';
+ return html;
+ },
+
+ renderCalendarMode: (data) => {
+ const calendar = data.calendar;
+ if (!calendar) {
+ return 'No calendar loaded. Use Design Mode to create one.
';
+ }
+
+ const viewingDate = State.config().viewingDate;
+ const currentDate = State.config().currentDate;
+
+ let html = '';
+
+ // Month navigation
+ html += InterfaceRenderer.renderMonthNavigation(viewingDate, calendar);
+
+ // Calendar grid
+ html += InterfaceRenderer.renderCalendarGrid(viewingDate, calendar, data);
+
+ // Events and notes for current viewing date
+ html += InterfaceRenderer.renderDayDetails(currentDate, calendar, data);
+
+ html += '
';
+ return html;
+ },
+
+ renderMonthNavigation: (viewingDate, calendar) => {
+ const CSS_CURRENT = getCSS();
+ const month = calendar.months[viewingDate.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+ const currentDate = State.config().currentDate;
+
+ let html = '';
+
+ // Previous controls
+ html += Output.makeButton('◀◀◀', `!chr --prevyear`, CSS_CURRENT.button);
+ html += Output.makeButton('◀◀', `!chr --prevmonth`, CSS_CURRENT.button);
+ html += Output.makeButton('◀', `!chr --prevday`, CSS_CURRENT.button);
+
+ html += `
`;
+
+ // Day picker with direct query
+ html += `${currentDate.day}`;
+ html += ` `;
+
+ // Month picker with direct query
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+ html += `${monthName}`;
+ html += ` `;
+
+ // Year picker with direct query
+ html += `${viewingDate.year}`;
+ html += ` `;
+
+ // Next controls
+ html += Output.makeButton('▶', `!chr --nextday`, CSS_CURRENT.button);
+ html += Output.makeButton('▶▶', `!chr --nextmonth`, CSS_CURRENT.button);
+ html += Output.makeButton('▶▶▶', `!chr --nextyear`, CSS_CURRENT.button);
+
+ html += '
';
+
+ // Featured Date (currently viewing) and Today (saved campaign date) display
+ const currentMonth = calendar.months[currentDate.month - 1];
+ const currentMonthName = currentMonth ? currentMonth.name : 'Unknown';
+
+ const todayDate = State.config().featuredDate || currentDate; // "Today" is the saved date
+ const todayMonth = calendar.months[todayDate.month - 1];
+ const todayMonthName = todayMonth ? todayMonth.name : 'Unknown';
+
+ html += ``;
+ html += `Today: ${todayMonthName} ${todayDate.day}, ${todayDate.year} `;
+ html += Output.makeButton('Go to Today', `!chr --gototoday`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Define Today as Featured', `!chr --settoday`, CSS_CURRENT.buttonSmall);
+ html += `
`;
+
+ return html;
+ },
+
+ renderCalendarGrid: (viewingDate, calendar, data) => {
+ const CSS_CURRENT = getCSS();
+ const month = calendar.months[viewingDate.month - 1];
+ if (!month) return 'Invalid month
';
+
+ const daysInWeek = calendar.weeks.daysInWeek;
+ const daysInMonth = DateUtils.getDaysInMonth(viewingDate.month, viewingDate.year, calendar);
+
+ // Find what weekday the 1st falls on
+ const firstDate = { year: viewingDate.year, month: viewingDate.month, day: 1 };
+ const firstAbsDay = DateUtils.toAbsoluteDay(firstDate, calendar);
+ const firstWeekday = (firstAbsDay - 1) % daysInWeek;
+
+ let html = '';
+
+ // Weekday header
+ html += '';
+ for (let i = 0; i < daysInWeek; i++) {
+ const dayName = calendar.weeks.weekdayNames[i] || i;
+ html += `| ${dayName} | `;
+ }
+ html += '
';
+
+ // Get special days for this year that break the week cycle
+ const specialDaysThisYear = DateUtils.getSpecialDaysForYear(viewingDate.year, calendar);
+ const betweenWeeksSpecialDays = specialDaysThisYear.filter(sd =>
+ sd.breaksWeekCycle &&
+ sd.position &&
+ sd.position.afterMonth === viewingDate.month
+ );
+
+ // Special days that occur BEFORE the month (afterDay = 0)
+ const specialDaysBeforeMonth = betweenWeeksSpecialDays.filter(sd => sd.position.afterDay === 0);
+ specialDaysBeforeMonth.forEach(sd => {
+ html += '';
+ const specialDayBg = CSS_CURRENT.calendarDay.includes('2d2d2d') ? '#3d3d3d' :
+ CSS_CURRENT.calendarDay.includes('eeeeee') ? '#d8d8d8' :
+ '#e4d4c0';
+ html += `| `;
+ html += ``;
+ html += `${sd.name}`;
+ html += ``;
+ html += ` | `;
+ html += '
';
+ });
+
+ // Calendar days
+ let dayNum = 1;
+ let dayCounter = 0;
+ let finished = false;
+
+ while (!finished) {
+ html += '';
+
+ for (let weekday = 0; weekday < daysInWeek; weekday++) {
+ if (dayCounter < firstWeekday) {
+ // Days from previous month
+ const prevDate = InterfaceRenderer.getPreviousMonthDay(
+ viewingDate,
+ firstWeekday - dayCounter,
+ calendar
+ );
+ html += InterfaceRenderer.renderCalendarCell(prevDate, calendar, true, data);
+ } else if (dayNum <= daysInMonth) {
+ // Days in current month
+ const date = { year: viewingDate.year, month: viewingDate.month, day: dayNum };
+ html += InterfaceRenderer.renderCalendarCell(date, calendar, false, data);
+ dayNum++;
+ } else {
+ // Days from next month
+ const nextDate = InterfaceRenderer.getNextMonthDay(
+ viewingDate,
+ dayNum - daysInMonth,
+ calendar
+ );
+ html += InterfaceRenderer.renderCalendarCell(nextDate, calendar, true, data);
+ dayNum++;
+ }
+
+ dayCounter++;
+ }
+
+ html += '
';
+
+ // Check for special days that occur after the last day of this week
+ const lastDayRendered = dayNum - 1;
+ const specialDaysAfterThisWeek = betweenWeeksSpecialDays.filter(sd => {
+ // Find special days where afterDay is within the range of days just rendered
+ return sd.position.afterDay > (lastDayRendered - daysInWeek) &&
+ sd.position.afterDay <= lastDayRendered;
+ });
+
+ // Sort by afterDay to show in correct order
+ specialDaysAfterThisWeek.sort((a, b) => a.position.afterDay - b.position.afterDay);
+
+ // Insert special day rows
+ specialDaysAfterThisWeek.forEach(sd => {
+ html += '';
+ // Theme-aware background color (slightly lighter than calendar cells)
+ const specialDayBg = CSS_CURRENT.calendarDay.includes('2d2d2d') ? '#3d3d3d' : // dark theme
+ CSS_CURRENT.calendarDay.includes('eeeeee') ? '#d8d8d8' : // light theme
+ '#e4d4c0'; // fantasy theme
+ html += ``;
+ html += ``;
+ html += ``;
+ html += ` ${sd.name}`;
+
+ // Get events and notes for this special day
+ const specialDayDate = {
+ year: viewingDate.year,
+ month: sd.position.afterMonth,
+ day: sd.position.afterDay + 1
+ };
+ const sdEvents = data.events.filter(e =>
+ e.dateRef.year === specialDayDate.year &&
+ e.dateRef.month === specialDayDate.month &&
+ e.dateRef.day === specialDayDate.day
+ );
+ const sdNotes = data.notes.filter(n =>
+ n.dateRef.year === specialDayDate.year &&
+ n.dateRef.month === specialDayDate.month &&
+ n.dateRef.day === specialDayDate.day
+ );
+
+ // Show events/notes if any
+ if (sdEvents.length > 0 || sdNotes.length > 0) {
+ html += ' ';
+ sdEvents.forEach(e => {
+ html += `• ${e.content.substring(0, 40)}${e.content.length > 40 ? '...' : ''} `;
+ });
+ sdNotes.forEach(n => {
+ html += `• ${n.content.substring(0, 40)}${n.content.length > 40 ? '...' : ''} `;
+ });
+ html += ' ';
+ }
+
+ html += ` `;
+ html += ``;
+ html += ` | `;
+ html += '
';
+ });
+
+ if (dayNum > daysInMonth + daysInWeek) {
+ finished = true;
+ }
+ }
+
+ // Special days that occur AFTER the month ends (afterDay >= daysInMonth)
+ // But exclude ones already shown during the month
+ const shownSpecialDayIds = new Set();
+ betweenWeeksSpecialDays.forEach(sd => {
+ if (sd.position.afterDay > 0 && sd.position.afterDay <= daysInMonth) {
+ shownSpecialDayIds.add(sd.id);
+ }
+ });
+
+ const specialDaysAfterMonth = betweenWeeksSpecialDays.filter(sd =>
+ sd.position.afterDay >= daysInMonth && !shownSpecialDayIds.has(sd.id)
+ );
+ specialDaysAfterMonth.forEach(sd => {
+ html += '';
+ const specialDayBg = CSS_CURRENT.calendarDay.includes('2d2d2d') ? '#3d3d3d' :
+ CSS_CURRENT.calendarDay.includes('eeeeee') ? '#d8d8d8' :
+ '#e4d4c0';
+ html += `| `;
+ html += ``;
+ html += `${sd.name}`;
+ html += ``;
+ html += ` | `;
+ html += '
';
+ });
+
+ html += '
';
+ return html;
+ },
+
+ renderCalendarCell: (date, calendar, otherMonth, data) => {
+ const CSS_CURRENT = getCSS();
+ const currentDate = State.config().currentDate;
+ const verboseMode = State.config().verboseCalendar || false;
+ const isToday = !otherMonth &&
+ date.year === currentDate.year &&
+ date.month === currentDate.month &&
+ date.day === currentDate.day;
+
+ let style = otherMonth ? CSS_CURRENT.calendarDayOtherMonth :
+ isToday ? CSS_CURRENT.calendarDayToday :
+ CSS_CURRENT.calendarDay;
+
+ const moons = data.moons;
+ const holidays = InterfaceRenderer.getHolidaysForDate(date, calendar);
+ const weatherCache = data.weather;
+ const events = data.events.filter(e =>
+ e.dateRef.year === date.year &&
+ e.dateRef.month === date.month &&
+ e.dateRef.day === date.day
+ );
+ const notes = data.notes.filter(n =>
+ n.dateRef.year === date.year &&
+ n.dateRef.month === date.month &&
+ n.dateRef.day === date.day
+ );
+
+ // Find weather for this date
+ const weatherForDate = weatherCache.find(w =>
+ w.dateRef.year === date.year &&
+ w.dateRef.month === date.month &&
+ w.dateRef.day === date.day
+ );
+
+ let html = ``;
+ html += ``;
+
+ // Weather emoji (float right at top)
+ if (weatherForDate) {
+ const weatherEmoji = WeatherGenerator.getWeatherEmoji(weatherForDate.description);
+ html += ` ${weatherEmoji} `;
+ }
+
+ // Date number
+ html += ``;
+ html += `${date.day}`;
+ html += ` `;
+
+ // Moon phases (sprite-based)
+ if (moons && moons.length > 0) {
+ const phases = MoonPhaseCalculator.getAllPhases(moons, date, calendar);
+ html += '';
+ phases.forEach(p => {
+ html += p.html;
+ });
+ html += ' ';
+ }
+
+ // Holidays (larger font, themed color)
+ if (holidays.length > 0) {
+ html += ``;
+ html += holidays[0].name; // Show first holiday only
+ html += ' ';
+ }
+
+ // Special Days (if "part of week" type)
+ const specialDay = DateUtils.isSpecialDay(date.month, date.day, date.year, calendar);
+ if (specialDay && !specialDay.breaksWeekCycle) {
+ html += ``;
+ html += specialDay.name;
+ html += ' ';
+ }
+
+ // Notes/Events indicator or verbose display
+ const hasContent = events.length > 0 || notes.length > 0;
+ if (hasContent) {
+ if (verboseMode) {
+ // Verbose: show actual content
+ html += '';
+ if (events.length > 0) {
+ html += 'Events: ';
+ events.forEach(e => {
+ html += `• ${e.content.substring(0, 40)}${e.content.length > 40 ? '...' : ''} `;
+ });
+ }
+ if (notes.length > 0) {
+ notes.forEach(n => {
+ html += `• ${n.content.substring(0, 40)}${n.content.length > 40 ? '...' : ''} `;
+ });
+ }
+ html += ' ';
+ } else {
+ // Indicator only
+ html += '';
+ if (events.length > 0) html += `📅 `;
+ if (notes.length > 0) html += `📝`;
+ html += ' ';
+ }
+ }
+
+ html += ''; // Close clickable link
+ html += ' | ';
+ return html;
+ },
+
+ getPreviousMonthDay: (viewingDate, daysBack, calendar) => {
+ let month = viewingDate.month - 1;
+ let year = viewingDate.year;
+
+ if (month < 1) {
+ month = calendar.months.length;
+ year--;
+ }
+
+ const daysInPrevMonth = DateUtils.getDaysInMonth(month, year, calendar);
+ const day = daysInPrevMonth - daysBack + 1;
+
+ return { year, month, day };
+ },
+
+ getNextMonthDay: (viewingDate, daysForward, calendar) => {
+ let month = viewingDate.month + 1;
+ let year = viewingDate.year;
+
+ if (month > calendar.months.length) {
+ month = 1;
+ year++;
+ }
+
+ return { year, month, day: daysForward };
+ },
+
+ getHolidaysForDate: (date, calendar) => {
+ return calendar.holidays.filter(h => {
+ if (h.type === 'absolute') {
+ return h.dateRef.month === date.month && h.dateRef.day === date.day;
+ }
+ // TODO: Handle relative dates
+ return false;
+ });
+ },
+
+ renderDayDetails: (date, calendar, data) => {
+ const CSS_CURRENT = getCSS();
+ const verbose = State.config().verboseCalendar || false;
+
+ const events = data.events.filter(e =>
+ e.dateRef.year === date.year &&
+ e.dateRef.month === date.month &&
+ e.dateRef.day === date.day
+ );
+
+ const notes = data.notes.filter(n =>
+ n.dateRef.year === date.year &&
+ n.dateRef.month === date.month &&
+ n.dateRef.day === date.day
+ );
+
+ const weather = data.weather.find(w =>
+ w.dateRef.year === date.year &&
+ w.dateRef.month === date.month &&
+ w.dateRef.day === date.day
+ );
+
+ const month = calendar.months[date.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ // Calculate day of year (1-based, counting from month 1 day 1)
+ let dayOfYear = 0;
+ for (let m = 1; m < date.month; m++) {
+ dayOfYear += DateUtils.getDaysInMonth(m, date.year, calendar);
+ }
+ dayOfYear += date.day;
+
+ const daysInYear = DateUtils.getDaysInYear(date.year, calendar);
+ const vernal = calendar.seasons.vernalEquinox || 80; // Default to day 80 if not set
+ const seasonOffset = Math.floor(daysInYear / 12); // 1/12 of year before equinox/solstice
+
+ // Calculate season boundaries (starting 1/12 year before each equinox/solstice)
+ const springStart = vernal - seasonOffset;
+ const summerStart = vernal + Math.floor(daysInYear / 4) - seasonOffset;
+ const autumnStart = vernal + Math.floor(daysInYear / 2) - seasonOffset;
+ const winterStart = vernal + Math.floor(3 * daysInYear / 4) - seasonOffset;
+
+ let season = 'winter';
+ if (dayOfYear >= springStart && dayOfYear < summerStart) {
+ season = 'spring';
+ } else if (dayOfYear >= summerStart && dayOfYear < autumnStart) {
+ season = 'summer';
+ } else if (dayOfYear >= autumnStart && dayOfYear < winterStart) {
+ season = 'autumn';
+ } else {
+ season = 'winter';
+ }
+
+ let html = '';
+
+
+ // Check if this date has a special day reference
+ if (date.specialDayId) {
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === date.specialDayId);
+ if (specialDay) {
+ html += `
Featured Date: `;
+ html += `${specialDay.name}`;
+ html += `, ${date.year}`;
+ } else {
+ // Special day not found, fall back to regular display
+ html += `
Featured Date: ${monthName} ${date.day}, ${date.year}`;
+ }
+ } else {
+ // Regular date display
+ html += `
Featured Date: ${monthName} ${date.day}, ${date.year}`;
+ }
+
+ // Control buttons with Roll20 queries
+ const verboseMode = State.config().verboseCalendar || false;
+ html += '
';
+ html += Output.makeButton(verboseMode ? '▼ Hide Details' : '▶ Show Details', `!chr --toggleverbose`, CSS_CURRENT.button);
+ html += Output.makeButton('Add Note', `!chr --savenote ?{Note text}`, CSS_CURRENT.button);
+ html += Output.makeButton('Add Event', `!chr --saveevent ?{Event text}`, CSS_CURRENT.button);
+ html += Output.makeButton('Generate Weather', `!chr --genweather`, CSS_CURRENT.button);
+ html += '
';
+
+
+ html += `
Season: ${season.charAt(0).toUpperCase() + season.slice(1)} (Day ${dayOfYear} of ${daysInYear})
`;
+
+ // Holidays
+ const holidays = (calendar.holidays || []).filter(h =>
+ h.dateRef.month === date.month &&
+ h.dateRef.day === date.day
+ );
+ if (holidays.length > 0) {
+ html += '
Holidays: ';
+ holidays.forEach((h, idx) => {
+ html += `
${h.name}`;
+ if (idx < holidays.length - 1) html += ', ';
+ });
+ html += '
';
+ }
+
+ // Special Days
+ const specialDay = DateUtils.isSpecialDay(date.month, date.day, date.year, calendar);
+ if (specialDay) {
+ html += '
';
+ }
+
+ // Weather
+ if (weather) {
+ html += '
Weather: ';
+ html += weather.description;
+ html += ` (${weather.temperature.value}°${weather.temperature.unit})`;
+ html += Output.makeButton('Regenerate', `!chr --regenweather`, CSS_CURRENT.buttonSmall + `margin-left:10px;`);
+ html += Output.makeButton('Clear Weather', `!chr --clearweather`, CSS_CURRENT.buttonSmall + `margin-left:5px;`);
+ html += '
';
+ }
+
+ // Events
+ if (events.length > 0) {
+ html += '
Events:';
+ events.forEach((e, idx) => {
+ html += `- `;
+
+ // Action buttons (always visible) with prepopulated content
+ const escapedContent = e.content.replace(/\|/g, '|').replace(/\}/g, '}');
+ html += Output.makeButton('Edit', `!chr --editevent ${e.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deleteevent ${e.id}`, CSS_CURRENT.buttonSmall);
+ html += `↔`;
+ html += `Move`;
+
+ // Content
+ html += ` ${e.content} `;
+
+ // Verbose mode: show creator, tag management, and tags
+ if (verbose) {
+ // Creator badge
+ html += `${e.createdBy} `;
+
+ // Tag management buttons
+ html += `+`;
+
+ // Build tag list for the Ⲷ button
+ const allTags = TagSystem.getAllTags(data);
+ if (allTags.length > 0) {
+ const tagList = allTags.join('|');
+ html += `Ⲷ`;
+ } else {
+ html += `Ⲷ`;
+ }
+
+ // Display existing tags
+ if (e.tags && e.tags.length > 0) {
+ e.tags.forEach(tag => {
+ html += `${tag}`;
+ });
+ }
+ }
+
+ html += '
';
+ });
+ html += '
';
+ }
+
+ // Notes
+ if (notes.length > 0) {
+ html += '
Notes:';
+ notes.forEach((n, idx) => {
+ html += `- `;
+
+ // Action buttons (always visible) with prepopulated content
+ const escapedContent = n.content.replace(/\|/g, '|').replace(/\}/g, '}');
+ html += Output.makeButton('Edit', `!chr --editnote ${n.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deletenote ${n.id}`, CSS_CURRENT.buttonSmall);
+ html += `↔`;
+ html += `Move`;
+
+ // Content
+ html += ` ${n.content} `;
+
+ // Verbose mode: show creator, tag management, and tags
+ if (verbose) {
+ // Creator badge
+ html += `${n.createdBy} `;
+
+ // Tag management buttons
+ html += `+`;
+
+ // Build tag list for the Ⲷ button
+ const allTags = TagSystem.getAllTags(data);
+ if (allTags.length > 0) {
+ const tagList = allTags.join('|');
+ html += `Ⲷ`;
+ } else {
+ html += `Ⲷ`;
+ }
+
+ // Display existing tags
+ if (n.tags && n.tags.length > 0) {
+ n.tags.forEach(tag => {
+ html += `${tag}`;
+ });
+ }
+ }
+
+ html += '
';
+ });
+ html += '
';
+ }
+
+ html += '
';
+ return html;
+ },
+
+ renderDesignMode: (data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar || DataModels.createCalendar('New Calendar');
+
+ let html = '';
+ html += '
Calendar Design
';
+
+ // Calendar selection
+ html += '
';
+ html += '
Active Calendar: ' + (calendar.name || 'None');
+ html += '
';
+
+ // Built-in calendars
+ html += Output.makeButton('Load Gregorian', `!chr --loadcal gregorian`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Absalom', `!chr --loadcal absalom`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Faerun', `!chr --loadcal faerun`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Greyhawk', `!chr --loadcal greyhawk`, CSS_CURRENT.button);
+ html += Output.makeButton('Load Eberron', `!chr --loadcal eberron`, CSS_CURRENT.button);
+
+ // Find all custom calendar handouts
+ const allHandouts = findObjs({ type: 'handout' });
+ const presetCalendarNames = [
+ HANDOUT_PREFIX + ' Calendar: Gregorian',
+ HANDOUT_PREFIX + ' Calendar: Absalom Reckoning',
+ HANDOUT_PREFIX + ' Calendar: Faerun',
+ HANDOUT_PREFIX + ' Calendar: Greyhawk',
+ HANDOUT_PREFIX + ' Calendar: Eberron'
+ ];
+
+ const customCalendars = allHandouts.filter(h => {
+ const name = h.get('name');
+ return name.startsWith(HANDOUT_PREFIX + ' Calendar:') &&
+ !presetCalendarNames.includes(name);
+ });
+
+ // Add button for each custom calendar
+ customCalendars.forEach(h => {
+ const fullName = h.get('name');
+ const calName = fullName.replace(HANDOUT_PREFIX + ' Calendar: ', '');
+ html += Output.makeButton('Load ' + calName, '!chr --loadcal ' + calName, CSS_CURRENT.button);
+ });
+
+ html += '
New Calendar';
+ html += '
';
+ html += '
';
+
+ // Calendar Description
+ if (calendar.description) {
+ html += '
';
+ html += '
' + calendar.description + '
';
+ html += Output.makeButton('Edit Description', '!chr --savedescription ?{Calendar Description|' + calendar.description.replace(/'/g, ''').replace(/"/g, '"') + '}', CSS_CURRENT.buttonSmall);
+ html += '
';
+ } else {
+ html += '
';
+ html += '
No description set.
';
+ html += Output.makeButton('Add Description', '!chr --savedescription ?{Calendar Description}', CSS_CURRENT.buttonSmall);
+ html += '
';
+ }
+
+ // Basic settings
+ html += '
';
+ html += '
Basic Settings
';
+ html += `
Calendar Name: ${calendar.name} `;
+ html += Output.makeButton('Edit', `!chr --savename ?{Calendar Name|${calendar.name}}`, CSS_CURRENT.buttonSmall);
+ html += '
';
+ html += `
Days in Year: ${calendar.daysInYear} `;
+ html += Output.makeButton('Edit', `!chr --savedaysinyear ?{Days in Year|${calendar.daysInYear}}`, CSS_CURRENT.buttonSmall);
+ html += ` Note: Do not include intercalery days, (those which do not receive a week day)`;
+ html += '
';
+ html += `
Days in Week: ${calendar.weeks.daysInWeek} `;
+ html += Output.makeButton('Edit', `!chr --savedaysinweek ?{Days in Week|${calendar.weeks.daysInWeek}}`, CSS_CURRENT.buttonSmall);
+ html += '
';
+ html += '
';
+
+ // Months
+ html += '
';
+ html += '
Months
';
+ if (calendar.months.length === 0) {
+ html += '
No months defined
';
+ } else {
+ html += '
';
+ html += '| Order | Name | Days | Actions |
';
+ calendar.months.forEach((m, idx) => {
+ html += '';
+ html += `| ${idx + 1} | `;
+ html += `${m.name} | `;
+ html += `${m.days} | `;
+ html += ``;
+
+ // Up arrow (disabled for first item)
+ if (idx > 0) {
+ html += Output.makeButton('↑', `!chr --movemonth ${idx}|up`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↑`;
+ }
+
+ // Down arrow (disabled for last item)
+ if (idx < calendar.months.length - 1) {
+ html += Output.makeButton('↓', `!chr --movemonth ${idx}|down`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↓`;
+ }
+
+ html += Output.makeButton('Edit', `!chr --updatemonth ${idx}|?{Month Name|${m.name}}|?{Days|${m.days}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --delmonth ${idx}`, CSS_CURRENT.buttonSmall);
+ html += ' | ';
+ html += '
';
+ });
+ html += '
';
+ }
+ html += '
';
+ html += Output.makeButton('Add Month', `!chr --savemonth ?{Month Name}|?{Days in Month}`, CSS_CURRENT.button);
+ html += '
';
+ html += '
';
+
+ // Weekday names
+ html += '
';
+ html += '
Weekday Names
';
+ html += '
' + calendar.weeks.weekdayNames.join(', ') + '
';
+ const weekdayStr = calendar.weeks.weekdayNames.join(',');
+ html += Output.makeButton('Edit Weekdays', `!chr --saveweekdays ?{Weekday Names (comma-separated)|${weekdayStr}}`, CSS_CURRENT.button);
+ html += '
';
+
+ // Holidays
+ html += '
';
+ html += '
Holidays
';
+ if (!calendar.holidays || calendar.holidays.length === 0) {
+ html += '
No holidays defined
';
+ } else {
+ html += '
';
+ html += '| Name | Date | Description | Recurring | Actions |
';
+ calendar.holidays.forEach((h, idx) => {
+ html += '';
+ html += `| ${h.name} | `;
+ html += ``;
+ if (h.type === 'absolute') {
+ html += `${h.dateRef.month}/${h.dateRef.day}`;
+ } else {
+ html += `Relative`;
+ }
+ html += ` | `;
+ html += `${h.description || 'None'} | `;
+ html += `${h.recurring ? 'Yes' : 'No'} | `;
+ html += ``;
+
+ // Edit button - edit all fields
+ const escapedName = h.name.replace(/\|/g, '|').replace(/\}/g, '}');
+ const escapedDesc = (h.description || '').replace(/\|/g, '|').replace(/\}/g, '}');
+ const recurringDefault = h.recurring ? 'Yes' : 'No';
+ html += Output.makeButton('Edit',
+ `!chr --editholiday ${idx}|?{Holiday Name|${escapedName}}|?{Month (1-12)|${h.dateRef.month}}|?{Day|${h.dateRef.day}}|?{Recurring?|${recurringDefault}|Yes|No}|?{Description|${escapedDesc}}`,
+ CSS_CURRENT.buttonSmall);
+
+ // Up/Down arrows
+ if (idx > 0) {
+ html += Output.makeButton('↑', `!chr --moveholiday ${idx}|up`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↑`;
+ }
+ if (idx < calendar.holidays.length - 1) {
+ html += Output.makeButton('↓', `!chr --moveholiday ${idx}|down`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += `↓`;
+ }
+
+ html += Output.makeButton('Delete', `!chr --deleteholiday ${idx}`, CSS_CURRENT.buttonSmall);
+ html += ' | ';
+ html += '
';
+ });
+ html += '
';
+ }
+ html += '
';
+ html += Output.makeButton('Add Holiday',
+ `!chr --addholiday ?{Holiday Name}|?{Month (1-12)}|?{Day}|?{Recurring?|Yes|No}|?{Description (optional)||}`,
+ CSS_CURRENT.button);
+ html += '
';
+ html += '
';
+
+ // Special Days
+ html += '
';
+ html += '
Special Days
';
+ html += '
Intercalary days (like Midsummer, leap days) that occur outside normal month/week structure
';
+ const specialDays = calendar.interMonthDays || [];
+ if (specialDays.length === 0) {
+ html += '
No special days defined
';
+ } else {
+ html += '
';
+ html += '| Name | Position | Type | Week Behavior | Description | Actions |
';
+ specialDays.forEach((sd, idx) => {
+ html += '';
+ html += `| ${sd.name} | `;
+ html += ``;
+ if (sd.position && sd.position.afterMonth) {
+ const monthName = calendar.months[sd.position.afterMonth - 1]?.name || '?';
+ html += `After ${monthName} ${sd.position.afterDay || ''}`;
+ } else {
+ html += 'Not set';
+ }
+ html += ` | `;
+ html += ``;
+ if (sd.dayType === 'leap') {
+ html += `Leap (every ${sd.frequency} yrs)`;
+ } else {
+ html += 'Fixed';
+ }
+ html += ` | `;
+ html += `${sd.breaksWeekCycle ? 'Between weeks' : 'Part of week'} | `;
+ html += `${sd.description || 'None'} | `;
+ html += ``;
+
+ // Edit button - direct href
+ const escapedName = sd.name.replace(/\|/g, '|').replace(/\}/g, '}');
+ const escapedDesc = (sd.description || '').replace(/\|/g, '|').replace(/\}/g, '}');
+
+ const currentMonth = calendar.months[sd.position.afterMonth - 1];
+ const monthList = calendar.months.map((m, idx) => {
+ const num = idx + 1;
+ return `${m.name},${num}`;
+ }).join('|');
+ const monthDefault = `${currentMonth.name},${sd.position.afterMonth}`;
+
+ const weekBehaviorDefault = sd.breaksWeekCycle ? 'Between weeks,betweenWeeks' : 'Part of week,partOfWeek';
+
+ let editQuery = `!chr --updatespecialday ${idx}|${sd.dayType}|?{Name|${escapedName}}|?{After Which Month?|${monthDefault}|${monthList}}|?{After Which Day?|${sd.position.afterDay}}|?{Week Behavior|${weekBehaviorDefault}|Part of week,partOfWeek|Between weeks,betweenWeeks}`;
+
+ if (sd.dayType === 'leap') {
+ editQuery += `|?{Frequency|${sd.frequency}}|?{Offset|${sd.offset}}`;
+ }
+
+ editQuery += `|?{Description|${escapedDesc}}`;
+
+ html += `Edit`;
+ html += Output.makeButton('Delete', `!chr --deletespecialday ${idx}`, CSS_CURRENT.buttonSmall);
+ html += ' | ';
+ html += '
';
+ });
+ html += '
';
+ }
+ html += '
';
+
+ // Build month list for special day queries
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+
+ // Fixed special day query
+ const fixedQuery = `!chr --savespecialday fixed|?{Name}|?{After Which Month?|${monthList}}|?{After Which Day? (0=before month)}|?{Week Behavior|Part of week,partOfWeek|Between weeks,betweenWeeks}|?{Description (optional)|}`;
+ html += `
Add Fixed Special Day`;
+
+ // Leap special day query
+ const leapQuery = `!chr --savespecialday leap|?{Name}|?{After Which Month?|${monthList}}|?{After Which Day? (0=before month)}|?{Week Behavior|Part of week,partOfWeek|Between weeks,betweenWeeks}|?{Every N years (frequency)|4}|?{Year offset|0}|?{Description (optional)|}`;
+ html += `
Add Leap Special Day`;
+
+ html += '
';
+ html += '
';
+
+ // Moons
+ html += '
';
+ html += '
Moons
';
+ const moons = data.moons;
+ if (!moons || moons.length === 0) {
+ html += '
No moons defined
';
+ } else {
+ html += '
';
+ html += '';
+ html += '| Name | ';
+ html += 'Period | ';
+ html += 'Size | ';
+ html += 'Color | ';
+ html += 'Visible | ';
+ html += 'Actions | ';
+ html += '
';
+
+ moons.forEach((m, idx) => {
+ const size = m.size || 1;
+ const color = m.color || 'yellow';
+ const display = m.display !== false ? 'Yes' : 'No';
+
+ html += '';
+ html += '| ' + m.name + ' | ';
+ html += '' + m.period + 'd | ';
+ html += '' + size + ' | ';
+ html += '' + color + ' | ';
+ html += '' + display + ' | ';
+ html += '';
+
+ // Up/Down arrows
+ if (idx > 0) {
+ html += Output.makeButton('↑', '!chr --movemoon ' + idx + '|up', CSS_CURRENT.buttonSmall);
+ }
+ if (idx < moons.length - 1) {
+ html += Output.makeButton('↓', '!chr --movemoon ' + idx + '|down', CSS_CURRENT.buttonSmall);
+ }
+
+ html += Output.makeButton('Edit',
+ '!chr --updatemoon ' + idx + '|?{Moon Name|' + m.name + '}|?{Period|' + m.period + '}|?{Full Year|' + m.fullDayRef.year + '}|?{Full Month|' + m.fullDayRef.month + '}|?{Full Day|' + m.fullDayRef.day + '}|?{Size (0.1-1.0)|' + size + '}|?{Color|' + color + ',yellow|red,red|green,green|blue,blue|cyan,cyan|orange,orange|purple,purple|tan,tan|brown,brown|white,white|gray,gray|dark,dark}|?{Display on grid?|' + (m.display !== false ? 'true' : 'false') + ',true|false,false}',
+ CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', '!chr --delmoon ' + idx + '', CSS_CURRENT.buttonSmall);
+
+ html += ' | ';
+ html += '
';
+ });
+
+ html += '
';
+ }
+ html += Output.makeButton('Add Moon',
+ '!chr --savemoon ?{Moon Name}|?{Period in Days (decimals OK)|28}|?{Year when full|1}|?{Month when full|1}|?{Day when full|1}|?{Size (0.1-1.0)|1}|?{Color|yellow,yellow|red,red|green,green|blue,blue|cyan,cyan|orange,orange|purple,purple|tan,tan|brown,brown|white,white|gray,gray|dark,dark}|?{Display on grid?|true,true|false,false}',
+ CSS_CURRENT.button);
+ html += '
';
+
+ // Climate
+ html += '
';
+ html += '
Climate
';
+ if (calendar.climate) {
+ html += `
${calendar.climate.climate_name} (${calendar.climate.koppen_code})
`;
+ html += `
${calendar.climate.biome_hint}
`;
+ } else {
+ html += '
No climate set
';
+ }
+ html += Output.makeButton('Set Climate',
+ `!chr --saveclimate ?{Latitude|tropical|subtropical|temperate|subarctic|polar}|?{Ocean Proximity|coastal|near_coastal|inland|continental}|?{Coast Type|west|east|none}|?{Elevation|lowland|highland|alpine}|?{Rainshadow|windward|leeward|neutral}`,
+ CSS_CURRENT.button);
+
+ // Temperature units toggle
+ const currentUnits = calendar.units || 'us';
+ const unitsLabel = currentUnits === 'us' ? 'F' : 'C';
+ html += Output.makeButton(`Units: ${unitsLabel}`, `!chr --toggleunits`, CSS_CURRENT.buttonSmall);
+
+ html += '
';
+
+ // Seasons
+ html += '
';
+ html += '
Seasons & Equinoxes
';
+ html += `
Vernal Equinox: Day ${calendar.seasons.vernalEquinox} of ${calendar.daysInYear} `;
+ html += Output.makeButton('Edit',
+ `!chr --setvernalequinox ?{Day of Year for Vernal Equinox|${calendar.seasons.vernalEquinox}}`,
+ CSS_CURRENT.buttonSmall);
+ html += '
';
+
+ // Calculate and display the other seasonal points
+ const vernal = calendar.seasons.vernalEquinox;
+ const daysInYear = calendar.daysInYear;
+ const summer = vernal + Math.floor(daysInYear / 4);
+ const autumnal = vernal + Math.floor(daysInYear / 2);
+ const winter = vernal + Math.floor(3 * daysInYear / 4);
+
+ html += `
Based on this setting:
`;
+ html += `
`;
+ html += `- Spring Equinox (Vernal): Day ${vernal}
`;
+ html += `- Summer Solstice: Day ${summer}
`;
+ html += `- Autumn Equinox: Day ${autumnal}
`;
+ html += `- Winter Solstice: Day ${winter}
`;
+ html += `
`;
+ html += '
These points divide the year into four equal seasons for weather generation.
';
+ html += '
';
+
+ // Leap Years
+ html += '
';
+ html += '
Leap Years
';
+ html += `
Enabled: ${calendar.leapYears.enabled ? 'Yes' : 'No'} `;
+ html += Output.makeButton('Toggle', `!chr --toggleleap`, CSS_CURRENT.buttonSmall);
+ html += '
';
+
+ if (calendar.leapYears.enabled) {
+ html += `
Cycle: Every ${calendar.leapYears.cycle} years `;
+ html += Output.makeButton('Edit',
+ `!chr --setleapcycle ?{Leap Year Cycle|${calendar.leapYears.cycle}}`,
+ CSS_CURRENT.buttonSmall);
+ html += '
';
+
+ html += '
Exception Years: ';
+ if (calendar.leapYears.exceptions && calendar.leapYears.exceptions.length > 0) {
+ calendar.leapYears.exceptions.forEach((year, idx) => {
+ html += `${year} `;
+ html += Output.makeButton('✖',
+ `!chr --removeleapexception ${idx}`,
+ CSS_CURRENT.buttonSmall);
+ html += ' ';
+ });
+ } else {
+ html += 'None';
+ }
+ html += '
';
+ html += '
';
+ html += Output.makeButton('Add Exception Year',
+ `!chr --addleapexception ?{Year to Exclude from Leap Years}`,
+ CSS_CURRENT.button);
+ html += '
';
+
+ html += `
When enabled, adds 1 day to the year every ${calendar.leapYears.cycle} years (except exception years). February typically receives the extra day in Gregorian-style calendars.
`;
+ }
+ html += '
';
+
+ html += '
';
+ return html;
+ },
+
+ renderTimelineMode: (data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ const events = data.events;
+ const notes = data.notes;
+ const holidays = calendar.holidays || [];
+
+ // Get timeline state from State config (create if doesn't exist)
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR', // 'OR' or 'AND'
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ showWeather: false,
+ showDetails: false,
+ showUntagged: false, // Show items with no tags
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ // Get all unique tags
+ const allTags = TagSystem.getAllTags(data);
+
+ // Build pipe-separated tag list for queries
+ const tagQueryString = Array.from(allTags).sort().join('|');
+
+ let html = '';
+
+ // ===== LEFT SIDEBAR =====
+ html += '| ';
+
+ // Type toggles
+ html += ' ';
+ html += 'Type: ';
+ html += Output.makeButton(
+ timelineState.showEvents ? '✓ Events' : 'Events',
+ `!chr --tl-toggle event`,
+ timelineState.showEvents ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += Output.makeButton(
+ timelineState.showNotes ? '✓ Notes' : 'Notes',
+ `!chr --tl-toggle note`,
+ timelineState.showNotes ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += Output.makeButton(
+ timelineState.showHolidays ? '✓ Holidays' : 'Holidays',
+ `!chr --tl-toggle holiday`,
+ timelineState.showHolidays ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += Output.makeButton(
+ timelineState.showWeather ? '✓ Weather' : 'Weather',
+ `!chr --tl-toggle weather`,
+ timelineState.showWeather ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += ' ';
+
+ // Show Details toggle
+ html += '';
+ html += Output.makeButton(
+ timelineState.showDetails ? '▼ Hide Details' : '▶ Show Details',
+ `!chr --tl-toggle details`,
+ timelineState.showDetails ? CSS_CURRENT.button : CSS_CURRENT.buttonSmall
+ );
+ html += ' ';
+
+ // Date range controls
+ html += '';
+ html += ' Date Range: ';
+
+ const startYearText = timelineState.startYear || '---';
+ html += ` `;
+ html += startYearText;
+ html += ``;
+
+ html += Output.makeButton('All', `!chr --tl-clearrange`, CSS_CURRENT.buttonSmall);
+
+ const endYearText = timelineState.endYear || '---';
+ html += ` `;
+ html += endYearText;
+ html += ``;
+
+ // Sort toggle
+ const sortIcon = timelineState.sortAscending ? '↓' : '↑';
+ html += Output.makeButton(sortIcon, `!chr --tl-togglesort`, CSS_CURRENT.buttonSmall);
+
+ html += ' ';
+
+ // Tag mode toggle
+ html += '';
+ html += 'Tag Mode: ';
+ html += Output.makeButton(
+ timelineState.tagMode === 'OR' ? 'ANY (OR)' : 'ALL (AND)',
+ `!chr --tl-togglemode`,
+ CSS_CURRENT.buttonSmall
+ );
+ html += ' ';
+
+ // Select All / Deselect All buttons
+ if (allTags.length > 0) {
+ html += '';
+ if (timelineState.selectedTags.length === allTags.length) {
+ html += Output.makeButton('Deselect All', `!chr --tl-deselectall`, CSS_CURRENT.buttonSmall);
+ } else if (timelineState.selectedTags.length === 0) {
+ html += Output.makeButton('Select All', `!chr --tl-selectall`, CSS_CURRENT.buttonSmall);
+ } else {
+ html += Output.makeButton('Select All', `!chr --tl-selectall`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Deselect All', `!chr --tl-deselectall`, CSS_CURRENT.buttonSmall);
+ }
+ html += ' ';
+ }
+
+ // Tag list
+ html += 'Tags: ';
+ html += '';
+
+ if (allTags.length === 0) {
+ html += ' No tags yet ';
+ } else {
+ allTags.forEach(tag => {
+ const isSelected = timelineState.selectedTags.includes(tag);
+ let tagStyle = CSS_CURRENT.tag;
+
+ if (isSelected) {
+ // Different color based on AND/OR mode
+ if (timelineState.tagMode === 'OR') {
+ // OR mode - blue/highlighted
+ tagStyle = 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #4a7ac2; color: #ffffff; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px; font-weight: bold;';
+ } else {
+ // AND mode - green/highlighted
+ tagStyle = 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #5a9f5a; color: #ffffff; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px; font-weight: bold;';
+ }
+ }
+
+ html += ' ' + tag + ' ';
+ });
+ }
+
+ // Add "Untagged" filter
+ html += ' ';
+ const showUntagged = timelineState.showUntagged || false;
+ const untaggedStyle = showUntagged ?
+ 'display: inline-block; padding: 2px 5px; margin: 0 2px; background: #888888; color: #ffffff; border-radius: 20px; text-decoration: none; cursor: pointer; font-size: 9px; font-weight: bold;' :
+ CSS_CURRENT.tag;
+ html += ' [Untagged]';
+ html += ' ';
+
+ html += ' ';
+ html += ' | '; // End left sidebar
+
+ // ===== RIGHT CONTENT AREA =====
+ html += '';
+
+ if (timelineState.selectedTags.length === 0 && !timelineState.showUntagged) {
+ html += ' ';
+ html += 'Select one or more tags to view timeline';
+ html += ' ';
+ } else {
+ // Filter items based on selected tags, tag mode, and untagged filter
+ let filteredItems = [];
+
+ // Add events if toggled on
+ if (timelineState.showEvents) {
+ events.forEach(e => {
+ let shouldInclude = false;
+
+ // Check if item has tags
+ const hasTags = e.tags && e.tags.length > 0;
+
+ // Show item if ANY of these conditions are true:
+ // 1. showUntagged is ON and item has NO tags
+ if (timelineState.showUntagged && !hasTags) {
+ shouldInclude = true;
+ }
+ // 2. tags are selected and item matches them
+ if (timelineState.selectedTags.length > 0 && hasTags) {
+ const matches = timelineState.tagMode === 'OR'
+ ? e.tags.some(t => timelineState.selectedTags.includes(t))
+ : timelineState.selectedTags.every(t => e.tags.includes(t));
+ if (matches) shouldInclude = true;
+ }
+ // 3. NO filters active - show all items
+ if (timelineState.selectedTags.length === 0 && !timelineState.showUntagged) {
+ shouldInclude = true;
+ }
+
+ if (shouldInclude) {
+ filteredItems.push({
+ type: 'event',
+ date: e.dateRef,
+ content: e.content,
+ item: e
+ });
+ }
+ });
+ }
+
+ // Add notes if toggled on
+ if (timelineState.showNotes) {
+ notes.forEach(n => {
+ let shouldInclude = false;
+
+ // Check if item has tags
+ const hasTags = n.tags && n.tags.length > 0;
+
+ // Show item if ANY of these conditions are true:
+ // 1. showUntagged is ON and item has NO tags
+ if (timelineState.showUntagged && !hasTags) {
+ shouldInclude = true;
+ }
+ // 2. tags are selected and item matches them
+ if (timelineState.selectedTags.length > 0 && hasTags) {
+ const matches = timelineState.tagMode === 'OR'
+ ? n.tags.some(t => timelineState.selectedTags.includes(t))
+ : timelineState.selectedTags.every(t => n.tags.includes(t));
+ if (matches) shouldInclude = true;
+ }
+ // 3. NO filters active - show all items
+ if (timelineState.selectedTags.length === 0 && !timelineState.showUntagged) {
+ shouldInclude = true;
+ }
+
+ if (shouldInclude) {
+ filteredItems.push({
+ type: 'note',
+ date: n.dateRef,
+ content: n.content,
+ item: n // Store full object
+ });
+ }
+ });
+ }
+
+ // Find date range of filtered items
+ if (filteredItems.length > 0) {
+ const sortedItems = [...filteredItems].sort((a, b) => {
+ const aAbs = DateUtils.toAbsoluteDay(a.date, calendar);
+ const bAbs = DateUtils.toAbsoluteDay(b.date, calendar);
+ return aAbs - bAbs;
+ });
+
+ const earliestDate = sortedItems[0].date;
+ const latestDate = sortedItems[sortedItems.length - 1].date;
+
+ // Calculate year span
+ const yearSpan = latestDate.year - earliestDate.year;
+
+ // Add holidays if toggled on, within range, AND span is one year or less
+ if (timelineState.showHolidays && yearSpan <= 1) {
+ holidays.forEach(h => {
+ // Check if holiday falls within the date range of filtered items
+ const holidayDate = { year: earliestDate.year, month: h.dateRef.month, day: h.dateRef.day };
+
+ // Check each year in range
+ for (let year = earliestDate.year; year <= latestDate.year; year++) {
+ const hDate = { year: year, month: h.dateRef.month, day: h.dateRef.day };
+ const hAbs = DateUtils.toAbsoluteDay(hDate, calendar);
+ const earlyAbs = DateUtils.toAbsoluteDay(earliestDate, calendar);
+ const lateAbs = DateUtils.toAbsoluteDay(latestDate, calendar);
+
+ if (hAbs >= earlyAbs && hAbs <= lateAbs) {
+ filteredItems.push({
+ type: 'holiday',
+ date: hDate,
+ content: h.name
+ });
+ }
+ }
+ });
+
+ // Add special days if toggled on
+ const specialDays = calendar.interMonthDays || [];
+ specialDays.forEach(sd => {
+ // Check each year in range
+ for (let year = earliestDate.year; year <= latestDate.year; year++) {
+ // Check if this special day occurs in this year (leap day logic)
+ const specialDaysForYear = DateUtils.getSpecialDaysForYear(year, calendar);
+ if (specialDaysForYear.find(s => s.id === sd.id)) {
+ const sdDate = { year: year, month: sd.position.afterMonth, day: sd.position.afterDay + 1 };
+ const sdAbs = DateUtils.toAbsoluteDay(sdDate, calendar);
+ const earlyAbs = DateUtils.toAbsoluteDay(earliestDate, calendar);
+ const lateAbs = DateUtils.toAbsoluteDay(latestDate, calendar);
+
+ if (sdAbs >= earlyAbs && sdAbs <= lateAbs) {
+ filteredItems.push({
+ type: 'specialday',
+ date: sdDate,
+ content: sd.name,
+ specialDayId: sd.id
+ });
+ }
+ }
+ }
+ });
+ }
+ }
+
+ // Add weather if toggled on
+ if (timelineState.showWeather) {
+ const weather = data.weather || [];
+ weather.forEach(w => {
+ filteredItems.push({
+ type: 'weather',
+ date: w.dateRef,
+ content: `${w.description} (${w.temperature.value}°${w.temperature.unit})`,
+ item: w
+ });
+ });
+ }
+
+ // Apply year range filter
+ if (timelineState.startYear) {
+ filteredItems = filteredItems.filter(item => item.date.year >= timelineState.startYear);
+ }
+ if (timelineState.endYear) {
+ filteredItems = filteredItems.filter(item => item.date.year <= timelineState.endYear);
+ }
+
+ // Sort by date
+ filteredItems.sort((a, b) => {
+ const aAbs = DateUtils.toAbsoluteDay(a.date, calendar);
+ const bAbs = DateUtils.toAbsoluteDay(b.date, calendar);
+ return timelineState.sortAscending ? aAbs - bAbs : bAbs - aAbs;
+ });
+
+ if (filteredItems.length === 0) {
+ html += '';
+ html += 'No items match the selected filters';
+ html += ' ';
+ } else {
+ // Group items by date
+ const itemsByDate = {};
+ filteredItems.forEach(item => {
+ const key = `${item.date.year}-${item.date.month}-${item.date.day}`;
+ if (!itemsByDate[key]) {
+ itemsByDate[key] = {
+ date: item.date,
+ items: []
+ };
+ }
+ itemsByDate[key].items.push(item);
+ });
+
+ // Render timeline table
+ html += '';
+
+ let lastYear = null;
+ let lastMonth = null;
+
+ Object.keys(itemsByDate).forEach(key => {
+ const entry = itemsByDate[key];
+ const d = entry.date;
+ const month = calendar.months[d.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ // Calculate weekday
+ const absDay = DateUtils.toAbsoluteDay(d, calendar);
+ const weekdayIndex = (absDay - 1) % calendar.weeks.daysInWeek;
+ const weekdayName = calendar.weeks.weekdayNames[weekdayIndex] || 'Day';
+
+ // Check if only events (no notes or holidays)
+ const hasOnlyEvents = entry.items.every(item => item.type === 'event');
+
+ html += '';
+
+ // Date column - theme-aware colors, clickable
+ html += ``;
+ html += ``;
+
+ if (d.year !== lastYear) {
+ html += `${d.year} `;
+ lastYear = d.year;
+ lastMonth = null; // Reset month when year changes
+ }
+
+ // Only show month/day if not just events
+ if (!hasOnlyEvents) {
+ if (d.month !== lastMonth) {
+ html += `${monthName} `;
+ lastMonth = d.month;
+ }
+
+ html += `${weekdayName} ${d.day}`;
+ }
+ html += '';
+ html += ' | ';
+
+ // Content column
+ html += '';
+
+ entry.items.forEach(item => {
+ if (item.type === 'holiday') {
+ html += ` Holiday: ${item.content} `;
+ } else if (item.type === 'specialday') {
+ html += ``;
+ } else if (item.type === 'weather') {
+ html += `Weather: ${item.content} `;
+ } else if (item.type === 'event' && timelineState.showDetails && item.item) {
+ // Show event with action buttons and tags
+ const e = item.item;
+ const escapedContent = e.content.replace(/\|/g, '|').replace(/\}/g, '}');
+
+ // Calculate elapsed time from the viewing date (currentDate)
+ const viewingDate = State.config().currentDate || { year: 1, month: 1, day: 1 };
+ const elapsed = DateUtils.getElapsedTime(viewingDate, e.dateRef, calendar);
+ let elapsedText = '';
+ if (elapsed.isFirstOfYear) {
+ elapsedText = (elapsed.isNegative ? '-' : '') + elapsed.years + 'y';
+ } else {
+ if (elapsed.years > 0) elapsedText += elapsed.years + 'y.';
+ if (elapsed.months > 0 || elapsed.years > 0) elapsedText += elapsed.months + 'm.';
+ elapsedText += elapsed.days + 'd';
+ if (elapsed.isNegative) elapsedText = '-' + elapsedText;
+ }
+
+ html += ``;
+ html += ' ';
+ html += e.content;
+ // Elapsed time button floating right
+ html += ` ${elapsedText}`;
+ html += ' ';
+
+ // Buttons and tags on same line
+ html += ' ';
+ html += Output.makeButton('Edit', `!chr --editevent ${e.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deleteevent ${e.id}`, CSS_CURRENT.buttonSmall);
+ html += ` ↔`;
+ html += ` Move`;
+ html += ` +Tag`;
+ if (e.tags && e.tags.length > 0) {
+ html += ' ';
+ e.tags.forEach(tag => {
+ const tagState = timelineState.selectedTags.includes(tag) ? 'active' : 'inactive';
+ html += Output.makeButton(tag, `!chr --tl-tag ${tag}`, CSS_CURRENT.tag);
+ });
+ }
+ html += ' ';
+ html += ' ';
+ } else if (item.type === 'note' && timelineState.showDetails && item.item) {
+ // Show note with action buttons and tags
+ const n = item.item;
+ const escapedContent = n.content.replace(/\|/g, '|').replace(/\}/g, '}');
+
+ // Calculate elapsed time from the viewing date (currentDate)
+ const viewingDate = State.config().currentDate || { year: 1, month: 1, day: 1 };
+ const elapsed = DateUtils.getElapsedTime(viewingDate, n.dateRef, calendar);
+ let elapsedText = '';
+ if (elapsed.isFirstOfYear) {
+ elapsedText = (elapsed.isNegative ? '-' : '') + elapsed.years + 'y';
+ } else {
+ if (elapsed.years > 0) elapsedText += elapsed.years + 'y.';
+ if (elapsed.months > 0 || elapsed.years > 0) elapsedText += elapsed.months + 'm.';
+ elapsedText += elapsed.days + 'd';
+ if (elapsed.isNegative) elapsedText = '-' + elapsedText;
+ }
+
+ html += ``;
+ html += ' ';
+ html += n.content;
+ // Elapsed time button floating right
+ html += ` ${elapsedText}`;
+ html += ' ';
+
+ // Buttons and tags on same line
+ html += ' ';
+ html += Output.makeButton('Edit', `!chr --editnote ${n.id}|?{New content|${escapedContent}}`, CSS_CURRENT.buttonSmall);
+ html += Output.makeButton('Delete', `!chr --deletenote ${n.id}`, CSS_CURRENT.buttonSmall);
+ html += ` ↔`;
+ html += ` Move`;
+ html += ` +Tag`;
+ if (n.tags && n.tags.length > 0) {
+ html += ' ';
+ n.tags.forEach(tag => {
+ const tagState = timelineState.selectedTags.includes(tag) ? 'active' : 'inactive';
+ html += Output.makeButton(tag, `!chr --tl-tag ${tag}`, CSS_CURRENT.tag);
+ });
+ }
+ html += ' ';
+ html += ' ';
+ } else {
+ // Simple display - just content with elapsed time button
+ const viewingDate = State.config().currentDate || { year: 1, month: 1, day: 1 };
+ const elapsed = DateUtils.getElapsedTime(viewingDate, item.date, calendar);
+ let elapsedText = '';
+ if (elapsed.isFirstOfYear) {
+ elapsedText = (elapsed.isNegative ? '-' : '') + elapsed.years + 'y';
+ } else {
+ if (elapsed.years > 0) elapsedText += elapsed.years + 'y.';
+ if (elapsed.months > 0 || elapsed.years > 0) elapsedText += elapsed.months + 'm.';
+ elapsedText += elapsed.days + 'd';
+ if (elapsed.isNegative) elapsedText = '-' + elapsedText;
+ }
+
+ html += '';
+ }
+ });
+
+ html += ' | ';
+ html += ' ';
+ });
+
+ html += ' ';
+ }
+ }
+
+ html += ' | '; // End content area
+ html += '
'; // End outer table
+
+ return html;
+ }
+
+ };
+
+ // ==================================================
+ // Commands (Single Root)
+ // ==================================================
+
+ const Commands = {
+
+ root: (msg, parsed) => {
+ const { args } = parsed;
+
+ if (args.help) {
+ return Commands.help(msg);
+ }
+
+ if (args.init) {
+ return Commands.initialize(msg, args);
+ }
+
+ if (args.mode) {
+ return Commands.setMode(msg, args.mode);
+ }
+
+ if (args.theme) {
+ return Commands.setTheme(msg, args.theme);
+ }
+
+ if (args.loadcal) {
+ return Commands.loadCalendar(msg, args.loadcal);
+ }
+
+ if (args.prevmonth) {
+ return Commands.previousMonth(msg);
+ }
+
+ if (args.nextmonth) {
+ return Commands.nextMonth(msg);
+ }
+
+ if (args.prevday) {
+ return Commands.previousDay(msg);
+ }
+
+ if (args.nextday) {
+ return Commands.nextDay(msg);
+ }
+
+ if (args.prevyear) {
+ return Commands.previousYear(msg);
+ }
+
+ if (args.nextyear) {
+ return Commands.nextYear(msg);
+ }
+
+ if (args.gototoday) {
+ return Commands.goToToday(msg);
+ }
+
+ if (args.settoday) {
+ return Commands.setToday(msg);
+ }
+
+ if (args.toggleverbose) {
+ return Commands.toggleVerbose(msg);
+ }
+
+ if (args.editevent) {
+ return Commands.editEvent(msg, args.editevent);
+ }
+
+ if (args.deleteevent) {
+ return Commands.deleteEvent(msg, args.deleteevent);
+ }
+
+ if (args.editnote) {
+ return Commands.editNote(msg, args.editnote);
+ }
+
+ if (args.deletenote) {
+ return Commands.deleteNote(msg, args.deletenote);
+ }
+
+ if (args.convert) {
+ return Commands.convertItem(msg, args.convert);
+ }
+
+ if (args.moveevent) {
+ return Commands.moveEvent(msg, args.moveevent);
+ }
+
+ if (args.movenote) {
+ return Commands.moveNote(msg, args.movenote);
+ }
+
+ if (args.pickmonth) {
+ return Commands.pickMonth(msg);
+ }
+
+ if (args.pickyear) {
+ return Commands.pickYear(msg);
+ }
+
+ if (args.jumptomonth) {
+ return Commands.jumpToMonth(msg, args.jumptomonth);
+ }
+
+ if (args.jumptoyear) {
+ return Commands.jumpToYear(msg, args.jumptoyear);
+ }
+
+ if (args.jumptoday) {
+ return Commands.jumpToDay(msg, args.jumptoday);
+ }
+
+ if (args.newcal) {
+ return Commands.newCalendar(msg);
+ }
+
+ if (args.createnewcal) {
+ return Commands.createNewCalendar(msg, args.createnewcal);
+ }
+
+ if (args.viewdate) {
+ return Commands.viewDate(msg, args.viewdate);
+ }
+
+ if (args.setfeatureddate) {
+ return Commands.setFeaturedDate(msg, args.setfeatureddate);
+ }
+
+ if (args.addnote) {
+ return Commands.addNote(msg);
+ }
+
+ if (args.addevent) {
+ return Commands.addEvent(msg);
+ }
+
+ if (args.genweather) {
+ return Commands.generateWeather(msg);
+ }
+
+ if (args.regenweather) {
+ return Commands.regenerateWeather(msg);
+ }
+
+ if (args.clearweather) {
+ return Commands.clearWeather(msg);
+ }
+
+ if (args.addmonth) {
+ return Commands.addMonth(msg);
+ }
+
+ if (args.addmoon) {
+ return Commands.addMoon(msg);
+ }
+
+ if (args.setclimate) {
+ return Commands.setClimate(msg);
+ }
+
+ if (args.savenote) {
+ return Commands.saveNote(msg, args.savenote);
+ }
+
+ if (args.saveevent) {
+ return Commands.saveEvent(msg, args.saveevent);
+ }
+
+ if (args.savemonth) {
+ return Commands.saveMonth(msg, args.savemonth);
+ }
+
+ if (args.savemoon) {
+ return Commands.saveMoon(msg, args.savemoon);
+ }
+
+ if (args.saveclimate) {
+ return Commands.saveClimate(msg, args.saveclimate);
+ }
+
+ if (args.toggleunits) {
+ return Commands.toggleUnits(msg);
+ }
+
+ if (args.setvernalequinox) {
+ return Commands.setVernalEquinox(msg, args.setvernalequinox);
+ }
+
+ if (args.toggleleap) {
+ return Commands.toggleLeapYear(msg);
+ }
+
+ if (args.setleapcycle) {
+ return Commands.setLeapCycle(msg, args.setleapcycle);
+ }
+
+ if (args.addleapexception) {
+ return Commands.addLeapException(msg, args.addleapexception);
+ }
+
+ if (args.removeleapexception !== undefined) {
+ return Commands.removeLeapException(msg, args.removeleapexception);
+ }
+
+ if (args.addholiday) {
+ return Commands.addHoliday(msg, args.addholiday);
+ }
+
+ if (args.editholiday) {
+ return Commands.editHoliday(msg, args.editholiday);
+ }
+
+ if (args.holidaywhisper) {
+ return Commands.holidayWhisper(msg, args.holidaywhisper);
+ }
+
+ if (args.holidayannounce) {
+ return Commands.holidayAnnounce(msg, args.holidayannounce);
+ }
+
+ if (args.addspecialday) {
+ return Commands.addSpecialDay(msg, args.addspecialday);
+ }
+
+ if (args.savespecialday) {
+ return Commands.saveSpecialDay(msg, args.savespecialday);
+ }
+
+ if (args.editspecialday) {
+ return Commands.editSpecialDay(msg, args.editspecialday);
+ }
+
+ if (args.updatespecialday) {
+ return Commands.updateSpecialDay(msg, args.updatespecialday);
+ }
+
+ if (args.deletespecialday) {
+ return Commands.deleteSpecialDay(msg, args.deletespecialday);
+ }
+
+ if (args.specialdaywhisper) {
+ return Commands.specialDayWhisper(msg, args.specialdaywhisper);
+ }
+
+ if (args.specialdayannounce) {
+ return Commands.specialDayAnnounce(msg, args.specialdayannounce);
+ }
+
+ if (args.setspecialday) {
+ return Commands.setSpecialDay(msg, args.setspecialday);
+ }
+
+ if (args.deleteholiday !== undefined) {
+ return Commands.deleteHoliday(msg, args.deleteholiday);
+ }
+
+ if (args.movemonth) {
+ return Commands.moveMonth(msg, args.movemonth);
+ }
+
+ if (args.movemoon) {
+ return Commands.moveMoon(msg, args.movemoon);
+ }
+
+ if (args.moveholiday) {
+ return Commands.moveHoliday(msg, args.moveholiday);
+ }
+
+ if (args.editmonth !== undefined) {
+ return Commands.editMonth(msg, args.editmonth);
+ }
+
+ if (args.delmonth !== undefined) {
+ return Commands.deleteMonth(msg, args.delmonth);
+ }
+
+ if (args.editmoon !== undefined) {
+ return Commands.editMoon(msg, args.editmoon);
+ }
+
+ if (args.delmoon !== undefined) {
+ return Commands.deleteMoon(msg, args.delmoon);
+ }
+
+ if (args.editweekdays) {
+ return Commands.editWeekdays(msg);
+ }
+
+ if (args.saveweekdays) {
+ return Commands.saveWeekdays(msg, args.saveweekdays);
+ }
+
+ if (args.editname) {
+ return Commands.editCalendarName(msg);
+ }
+
+ if (args.savename) {
+ return Commands.saveCalendarName(msg, args.savename);
+ }
+
+ if (args.savedescription) {
+ return Commands.saveDescription(msg, args.savedescription);
+ }
+
+ if (args.editdaysinyear) {
+ return Commands.editDaysInYear(msg);
+ }
+
+ if (args.savedaysinyear) {
+ return Commands.saveDaysInYear(msg, args.savedaysinyear);
+ }
+
+ if (args.editdaysinweek) {
+ return Commands.editDaysInWeek(msg);
+ }
+
+ if (args.savedaysinweek) {
+ return Commands.saveDaysInWeek(msg, args.savedaysinweek);
+ }
+
+ if (args.updatemonth) {
+ return Commands.updateMonth(msg, args.updatemonth);
+ }
+
+ if (args.updatemoon) {
+ return Commands.updateMoon(msg, args.updatemoon);
+ }
+
+ if (args.addtag) {
+ return Commands.addTag(msg, args.addtag);
+ }
+
+ if (args.edittag) {
+ return Commands.editTag(msg, args.edittag);
+ }
+
+ if (args.addtagfromlist) {
+ return Commands.addTagFromList(msg, args.addtagfromlist);
+ }
+
+ // Timeline commands
+ if (args['tl-toggle']) {
+ return Commands.timelineToggle(msg, args['tl-toggle']);
+ }
+
+ if (args['tl-startyear']) {
+ return Commands.timelineStartYear(msg, args['tl-startyear']);
+ }
+
+ if (args['tl-endyear']) {
+ return Commands.timelineEndYear(msg, args['tl-endyear']);
+ }
+
+ if (args['tl-clearrange']) {
+ return Commands.timelineClearRange(msg);
+ }
+
+ if (args['tl-togglesort']) {
+ return Commands.timelineToggleSort(msg);
+ }
+
+ if (args['tl-togglemode']) {
+ return Commands.timelineToggleMode(msg);
+ }
+
+ if (args['tl-deselectall']) {
+ return Commands.timelineDeselectAll(msg);
+ }
+
+ if (args['tl-selectall']) {
+ return Commands.timelineSelectAll(msg);
+ }
+
+ if (args['tl-toggletag']) {
+ return Commands.timelineToggleTag(msg, args['tl-toggletag']);
+ }
+
+ if (args['tl-toggleuntagged']) {
+ return Commands.timelineToggleUntagged(msg);
+ }
+
+ if (args.pickitemtag) {
+ return Commands.pickItemTag(msg, args.pickitemtag);
+ }
+
+ if (args.addtag) {
+ return Commands.addTag(msg, args.addtag);
+ }
+
+ if (args.chat) {
+ if (args.chat === 'calendar') {
+ return Commands.sendCalendarToChat(msg);
+ } else if (args.chat === 'design') {
+ return Commands.sendDesignToChat(msg);
+ }
+ }
+
+ // Default: show handout link
+ Commands.showHandout(msg);
+ },
+
+ help: (msg) => {
+ // Find or create the help handout
+ let helpHandout = findObjs({
+ _type: 'handout',
+ name: CHRONICLE_HELP_NAME
+ })[0];
+
+ if (!helpHandout) {
+ // Create new help handout
+ helpHandout = createObj('handout', {
+ name: CHRONICLE_HELP_NAME,
+ inplayerjournals: 'all',
+ archived: false,
+ avatar: CHRONICLE_HELP_AVATAR
+ });
+
+ helpHandout.set('notes', CHRONICLE_HELP_TEXT);
+
+ log('Chronicle: Created help handout');
+ } else {
+ // Update existing help handout
+ helpHandout.set('notes', CHRONICLE_HELP_TEXT);
+ helpHandout.set('avatar', CHRONICLE_HELP_AVATAR);
+ log('Chronicle: Updated help handout');
+ }
+
+ // Send clickable button
+ const CSS_CURRENT = getCSS();
+ const handoutId = helpHandout.get('_id');
+ const button = `Open Chronicle Help Documentation`;
+
+ Output.send(msg.who, button);
+ },
+
+ initialize: (msg, args) => {
+ const CSS_CURRENT = getCSS();
+ // Create default calendar
+ const calendar = DefaultCalendars.gregorian();
+ HandoutManager.saveCalendar(calendar);
+ State.setConfig('currentCalendar', `${HANDOUT_PREFIX} Calendar: ${calendar.name}`);
+
+ // Create empty events handout
+ HandoutManager.saveEvents('My Campaign', [], []);
+
+ // Set initial viewing date
+ State.setConfig('viewingDate', { year: 1, month: 1 });
+ State.setConfig('currentDate', { year: 1, month: 1, day: 1 });
+
+ Output.send(msg.who, `Chronicle initialized with Gregorian calendar!
`);
+
+ Commands.renderInterface(msg);
+ },
+
+ renderInterface: (msg) => {
+ // Load data with callbacks, then render with loaded data
+ HandoutManager.loadData((data) => {
+ const mode = State.config().displayMode;
+ InterfaceRenderer.render(mode, data, (handout) => {
+ // Silently update - no confirmation needed
+ });
+ });
+ },
+
+ showHandout: (msg) => {
+ let handout = HandoutManager.findHandout(INTERFACE_HANDOUT_NAME);
+ if (!handout) {
+ // Create and render if it doesn't exist
+ Commands.renderInterface(msg);
+ handout = HandoutManager.findHandout(INTERFACE_HANDOUT_NAME);
+ }
+
+ if (handout) {
+ // Send button link using Output system
+ const who = Utils.stripGM(msg.who);
+ const CSS_CURRENT = getCSS();
+ const button = `Open Chronicle Interface`;
+ Output.send(who, button);
+ }
+ },
+
+ setMode: (msg, mode) => {
+ State.setConfig('displayMode', mode);
+ Commands.renderInterface(msg);
+ },
+
+ setTheme: (msg, theme) => {
+ State.setConfig('theme', theme);
+ Commands.renderInterface(msg);
+ },
+
+ loadCalendar: (msg, calType) => {
+ let calendar;
+
+ // Check if this is a request to list existing calendars
+ if (calType === 'list') {
+ const handouts = findObjs({ type: 'handout' });
+ const presetNames = ['Gregorian', 'Absalom Reckoning', 'Faerun', 'Greyhawk', 'Eberron'];
+ const calendarHandouts = handouts.filter(h => {
+ const name = h.get('name');
+ if (!name.startsWith(HANDOUT_PREFIX + ' Calendar:')) return false;
+
+ const calName = name.replace(HANDOUT_PREFIX + ' Calendar: ', '');
+ // Exclude preset calendars from the list since they have dedicated Load buttons
+ return !presetNames.includes(calName);
+ });
+
+ if (calendarHandouts.length === 0) {
+ Output.send(msg.who, 'No custom calendars found.');
+ return;
+ }
+
+ let output = 'Custom Calendars:';
+ calendarHandouts.forEach(h => {
+ const fullName = h.get('name');
+ const calName = fullName.replace(HANDOUT_PREFIX + ' Calendar: ', '');
+ output += '• ' + calName + ' -
Load';
+ });
+ output += '
';
+ Output.send(msg.who, output);
+ return;
+ }
+
+ // Check if loading a default calendar type
+ if (calType === 'gregorian' || calType === 'absalom' || calType === 'faerun' || calType === 'greyhawk' || calType === 'eberron') {
+ // Get the default calendar
+ if (calType === 'gregorian') {
+ calendar = DefaultCalendars.gregorian();
+ } else if (calType === 'absalom') {
+ calendar = DefaultCalendars.absalom();
+ } else if (calType === 'faerun') {
+ calendar = DefaultCalendars.faerun();
+ } else if (calType === 'greyhawk') {
+ calendar = DefaultCalendars.greyhawk();
+ } else if (calType === 'eberron') {
+ calendar = DefaultCalendars.eberron();
+ }
+
+ // Check if handouts already exist - if so, just load them instead of overwriting
+ const handoutName = `${HANDOUT_PREFIX} Calendar: ${calendar.name}`;
+ const existingHandout = HandoutManager.findHandout(handoutName);
+
+ if (existingHandout) {
+ // Handout already exists - just switch to it, don't overwrite
+ State.setConfig('currentCalendar', handoutName);
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calendar.name}`;
+ State.setConfig('currentEvents', eventsName);
+ Commands.renderInterface(msg);
+ return;
+ }
+
+ // Handout doesn't exist - create it
+ HandoutManager.saveCalendar(calendar);
+ State.setConfig('currentCalendar', handoutName);
+ Commands.renderInterface(msg);
+ return;
+ }
+
+ // Not a preset - try to find existing calendar handout with this name
+ const handoutName = `${HANDOUT_PREFIX} Calendar: ${calType}`;
+ const handout = HandoutManager.findHandout(handoutName);
+
+ if (!handout) {
+ Output.send(msg.who, `Calendar "${calType}" not found. Use !chr --loadcal list to see existing calendars, or !chr --loadcal gregorian / !chr --loadcal absalom / !chr --loadcal faerun / !chr --loadcal greyhawk / !chr --loadcal eberron to create a new one.`);
+ return;
+ }
+
+ // Load the existing calendar
+ State.setConfig('currentCalendar', handoutName);
+ const eventsName = `${HANDOUT_PREFIX} Events: ${calType}`;
+ State.setConfig('currentEvents', eventsName);
+
+ Commands.renderInterface(msg);
+ },
+
+ previousDay: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let day = currentDate.day - 1;
+ let month = currentDate.month;
+ let year = currentDate.year;
+
+ if (day < 1) {
+ month--;
+ if (month < 1) {
+ month = calendar.months.length;
+ year--;
+ }
+ day = DateUtils.getDaysInMonth(month, year, calendar);
+ }
+
+ const newDate = { year, month, day };
+ State.setConfig('currentDate', newDate);
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ nextDay: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let day = currentDate.day + 1;
+ let month = currentDate.month;
+ let year = currentDate.year;
+
+ const daysInMonth = DateUtils.getDaysInMonth(month, year, calendar);
+
+ if (day > daysInMonth) {
+ day = 1;
+ month++;
+ if (month > calendar.months.length) {
+ month = 1;
+ year++;
+ }
+ }
+
+ const newDate = { year, month, day };
+ State.setConfig('currentDate', newDate);
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ previousMonth: (msg) => {
+ DataLoader.loadAll((data) => {
+ const viewingDate = State.config().viewingDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let month = viewingDate.month - 1;
+ let year = viewingDate.year;
+
+ if (month < 1) {
+ month = calendar.months.length;
+ year--;
+ }
+
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ nextMonth: (msg) => {
+ DataLoader.loadAll((data) => {
+ const viewingDate = State.config().viewingDate;
+ const calendar = data.calendar;
+
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ let month = viewingDate.month + 1;
+ let year = viewingDate.year;
+
+ if (month > calendar.months.length) {
+ month = 1;
+ year++;
+ }
+
+ State.setConfig('viewingDate', { year, month });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ previousYear: (msg) => {
+ const viewingDate = State.config().viewingDate;
+
+ State.setConfig('viewingDate', {
+ year: viewingDate.year - 1,
+ month: viewingDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ nextYear: (msg) => {
+ const viewingDate = State.config().viewingDate;
+
+ State.setConfig('viewingDate', {
+ year: viewingDate.year + 1,
+ month: viewingDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ goToToday: (msg) => {
+ // Navigate to the saved "Today" date
+ const todayDate = State.config().featuredDate || State.config().currentDate;
+ State.setConfig('currentDate', todayDate);
+ State.setConfig('viewingDate', {
+ year: todayDate.year,
+ month: todayDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ setToday: (msg) => {
+ // Save the current Featured Date as the new "Today"
+ const currentDate = State.config().currentDate;
+ State.setConfig('featuredDate', {
+ year: currentDate.year,
+ month: currentDate.month,
+ day: currentDate.day
+ });
+ Commands.renderInterface(msg);
+ },
+
+ toggleVerbose: (msg) => {
+ const current = State.config().verboseCalendar || false;
+ State.setConfig('verboseCalendar', !current);
+ Commands.renderInterface(msg);
+ },
+
+ // Timeline Mode Commands
+ timelineToggle: (msg, type) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ if (type === 'event') {
+ timelineState.showEvents = !timelineState.showEvents;
+ } else if (type === 'note') {
+ timelineState.showNotes = !timelineState.showNotes;
+ } else if (type === 'holiday') {
+ timelineState.showHolidays = !timelineState.showHolidays;
+ } else if (type === 'weather') {
+ timelineState.showWeather = !timelineState.showWeather;
+ } else if (type === 'details') {
+ timelineState.showDetails = !timelineState.showDetails;
+ }
+
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineStartYear: (msg, year) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ const yearNum = parseInt(year);
+ if (!isNaN(yearNum)) {
+ timelineState.startYear = yearNum;
+ State.setConfig('timelineState', timelineState);
+ }
+
+ Commands.renderInterface(msg);
+ },
+
+ timelineEndYear: (msg, year) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ const yearNum = parseInt(year);
+ if (!isNaN(yearNum)) {
+ timelineState.endYear = yearNum;
+ State.setConfig('timelineState', timelineState);
+ }
+
+ Commands.renderInterface(msg);
+ },
+
+ timelineClearRange: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.startYear = null;
+ timelineState.endYear = null;
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineToggleSort: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.sortAscending = !timelineState.sortAscending;
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineToggleMode: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.tagMode = timelineState.tagMode === 'OR' ? 'AND' : 'OR';
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineDeselectAll: (msg) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.selectedTags = [];
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineSelectAll: (msg) => {
+ DataLoader.loadAll((data) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ // Get all tags and select them
+ const allTags = TagSystem.getAllTags(data);
+ timelineState.selectedTags = [...allTags];
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ timelineToggleTag: (msg, tag) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ showUntagged: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ const index = timelineState.selectedTags.indexOf(tag);
+ if (index > -1) {
+ // Tag is selected, remove it
+ timelineState.selectedTags.splice(index, 1);
+ } else {
+ // Tag is not selected, add it
+ timelineState.selectedTags.push(tag);
+ }
+
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ timelineToggleUntagged: (msg, tag) => {
+ const timelineState = State.config().timelineState || {
+ selectedTags: [],
+ tagMode: 'OR',
+ showEvents: true,
+ showNotes: true,
+ showHolidays: false,
+ showUntagged: false,
+ startYear: null,
+ endYear: null,
+ sortAscending: true
+ };
+
+ timelineState.showUntagged = !timelineState.showUntagged;
+
+ State.setConfig('timelineState', timelineState);
+ Commands.renderInterface(msg);
+ },
+
+ pickItemTag: (msg, itemData) => {
+ const parts = itemData.split('|');
+ const itemId = parts[0];
+ const itemType = parts[1]; // 'event' or 'note'
+
+ DataLoader.loadAll((data) => {
+ // Find the item
+ let item = null;
+ if (itemType === 'event') {
+ item = data.events.find(e => e.id === itemId);
+ } else if (itemType === 'note') {
+ item = data.notes.find(n => n.id === itemId);
+ }
+
+ if (!item) {
+ Output.send(msg.who, 'Item not found');
+ return;
+ }
+
+ // Collect all existing tags
+ const allTags = new Set();
+ data.events.forEach(e => {
+ if (e.tags) e.tags.forEach(t => allTags.add(t));
+ });
+ data.notes.forEach(n => {
+ if (n.tags) n.tags.forEach(t => allTags.add(t));
+ });
+
+ const tagList = Array.from(allTags).sort().join('|');
+
+ // Build button with direct query - use pipe-separated tags
+ let output = '';
+ if (tagList) {
+ output += '
Pick a tag to add';
+ } else {
+ output += '
No existing tags to choose from. Type a new tag:';
+ output += '
Add new tag';
+ }
+ output += '
';
+
+ Output.send(msg.who, output);
+ });
+ },
+
+ addTag: (msg, tagData) => {
+ const parts = tagData.split('|');
+ const itemId = parts[0];
+ const itemType = parts[1];
+ const tag = parts.slice(2).join('|').trim(); // Rejoin in case tag contains |
+
+ if (!tag) {
+ Output.send(msg.who, 'No tag selected');
+ return;
+ }
+
+ DataLoader.loadAll((data) => {
+ let item = null;
+ if (itemType === 'event') {
+ item = data.events.find(e => e.id === itemId);
+ } else if (itemType === 'note') {
+ item = data.notes.find(n => n.id === itemId);
+ }
+
+ if (!item) {
+ Output.send(msg.who, 'Item not found');
+ return;
+ }
+
+ // Add tag if not already present
+ if (!item.tags) item.tags = [];
+ if (!item.tags.includes(tag)) {
+ item.tags.push(tag);
+ }
+
+ // Save
+ HandoutManager.saveEvents(data.events);
+ HandoutManager.saveNotes(data.notes);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editEvent: (msg, eventData) => {
+ const parts = eventData.split('|');
+ const eventId = parts[0];
+ const newContent = parts[1];
+
+ DataLoader.loadAll((data) => {
+ const events = data.events;
+ const event = events.find(e => e.id === eventId);
+
+ if (!event) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ event.content = newContent;
+ const calendar = data.calendar;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteEvent: (msg, eventId) => {
+ DataLoader.loadAll((data) => {
+ let events = data.events;
+ const originalLength = events.length;
+ events = events.filter(e => e.id !== eventId);
+
+ if (events.length === originalLength) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ const calendar = data.calendar;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editNote: (msg, noteData) => {
+ const parts = noteData.split('|');
+ const noteId = parts[0];
+ const newContent = parts[1];
+
+ DataLoader.loadAll((data) => {
+ const notes = data.notes;
+ const note = notes.find(n => n.id === noteId);
+
+ if (!note) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ note.content = newContent;
+ const calendar = data.calendar;
+ const events = data.events;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteNote: (msg, noteId) => {
+ DataLoader.loadAll((data) => {
+ let notes = data.notes;
+ const originalLength = notes.length;
+ notes = notes.filter(n => n.id !== noteId);
+
+ if (notes.length === originalLength) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ const calendar = data.calendar;
+ const events = data.events;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ convertItem: (msg, itemData) => {
+ DataLoader.loadAll((data) => {
+ const parts = itemData.split('|');
+ const itemId = parts[0];
+ const fromType = parts[1]; // 'event' or 'note'
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ if (fromType === 'event') {
+ // Convert event to note
+ const eventIndex = events.findIndex(e => e.id === itemId);
+ if (eventIndex === -1) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ const event = events[eventIndex];
+ const newNote = DataModels.createNote(event.content, event.dateRef, event.tags || [], event.createdBy);
+ notes.push(newNote);
+ events.splice(eventIndex, 1);
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ } else if (fromType === 'note') {
+ // Convert note to event
+ const noteIndex = notes.findIndex(n => n.id === itemId);
+ if (noteIndex === -1) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ const note = notes[noteIndex];
+ const newEvent = DataModels.createEvent(note.content, note.dateRef, note.tags || [], note.createdBy);
+ events.push(newEvent);
+ notes.splice(noteIndex, 1);
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ moveEvent: (msg, eventData) => {
+ DataLoader.loadAll((data) => {
+ const parts = eventData.split('|');
+ const eventId = parts[0];
+ const newYear = parseInt(parts[1]);
+ const newMonth = parseInt(parts[2]);
+ const newDay = parseInt(parts[3]);
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ const eventIndex = events.findIndex(e => e.id === eventId);
+ if (eventIndex === -1) {
+ Output.send(msg.who, 'Event not found');
+ return;
+ }
+
+ // Update the event's dateRef
+ events[eventIndex].dateRef = {
+ year: newYear,
+ month: newMonth,
+ day: newDay
+ };
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ moveNote: (msg, noteData) => {
+ DataLoader.loadAll((data) => {
+ const parts = noteData.split('|');
+ const noteId = parts[0];
+ const newYear = parseInt(parts[1]);
+ const newMonth = parseInt(parts[2]);
+ const newDay = parseInt(parts[3]);
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ const noteIndex = notes.findIndex(n => n.id === noteId);
+ if (noteIndex === -1) {
+ Output.send(msg.who, 'Note not found');
+ return;
+ }
+
+ // Update the note's dateRef
+ notes[noteIndex].dateRef = {
+ year: newYear,
+ month: newMonth,
+ day: newDay
+ };
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addTag: (msg, tagData) => {
+ DataLoader.loadAll((data) => {
+ const parts = tagData.split('|');
+ if (parts.length < 3) {
+ Output.send(msg.who, 'Invalid tag data format');
+ return;
+ }
+
+ const itemId = parts[0];
+ const itemType = parts[1]; // 'event' or 'note'
+ const newTagsStr = parts[2];
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ // Parse new tags
+ const newTags = Utils.parseTags(newTagsStr);
+
+ if (newTags.length === 0) {
+ Output.send(msg.who, 'No valid tags provided');
+ return;
+ }
+
+ // Find the item and add tags
+ let found = false;
+ if (itemType === 'event') {
+ const event = events.find(e => e.id === itemId);
+ if (event) {
+ event.tags = event.tags || [];
+ newTags.forEach(tag => {
+ if (!event.tags.includes(tag)) {
+ event.tags.push(tag);
+ }
+ });
+ found = true;
+ }
+ } else if (itemType === 'note') {
+ const note = notes.find(n => n.id === itemId);
+ if (note) {
+ note.tags = note.tags || [];
+ newTags.forEach(tag => {
+ if (!note.tags.includes(tag)) {
+ note.tags.push(tag);
+ }
+ });
+ found = true;
+ }
+ }
+
+ if (!found) {
+ Output.send(msg.who, 'Item not found');
+ return;
+ }
+
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editTag: (msg, tagData) => {
+ DataLoader.loadAll((data) => {
+ const parts = tagData.split('|');
+ if (parts.length < 4) {
+ Output.send(msg.who, 'Invalid tag edit format');
+ return;
+ }
+
+ const itemId = parts[0];
+ const itemType = parts[1]; // 'event' or 'note'
+ const oldTag = parts[2].toLowerCase().trim();
+ const newTag = parts[3].toLowerCase().trim();
+
+ const calendar = data.calendar;
+ let events = data.events;
+ let notes = data.notes;
+
+ // Find the item
+ let item = null;
+ if (itemType === 'event') {
+ item = events.find(e => e.id === itemId);
+ } else if (itemType === 'note') {
+ item = notes.find(n => n.id === itemId);
+ }
+
+ if (!item || !item.tags) {
+ Output.send(msg.who, 'Item or tag not found');
+ return;
+ }
+
+ const tagIndex = item.tags.indexOf(oldTag);
+ if (tagIndex === -1) {
+ return;
+ }
+
+ if (newTag === '') {
+ // Delete tag
+ item.tags.splice(tagIndex, 1);
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ } else {
+ // Update tag
+ item.tags[tagIndex] = newTag;
+ HandoutManager.saveEvents(calendar.name, events, notes, data.weather || []);
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addTagFromList: (msg, itemData) => {
+ DataLoader.loadAll((data) => {
+ const parts = itemData.split('|');
+ if (parts.length < 2) {
+ Output.send(msg.who, 'Invalid format');
+ return;
+ }
+
+ const itemId = parts[0];
+ const itemType = parts[1];
+
+ // Get all existing tags
+ const allTags = TagSystem.getAllTags(data);
+
+ if (allTags.length === 0) {
+ Output.send(msg.who, 'No existing tags found. Use the + button to create tags first.');
+ return;
+ }
+
+ // Build the tag list for the query dropdown and output the command directly
+ const tagList = allTags.join('|');
+
+ // This sends nothing to chat - Roll20 will process the command directly from the button
+ // The button's href already contains the full command, so we just need to trigger it
+ sendChat('Chronicle', `!chr --addtag ${itemId}|${itemType}|?{Choose tag to add|${tagList}}`);
+ });
+ },
+
+ pickMonth: (msg) => {
+ DataLoader.loadAll((data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ if (!calendar || !calendar.months || calendar.months.length === 0) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ // Build list of months for query
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+ Output.send(msg.who, `Click to jump to month:
${Output.makeButton('Select Month', `!chr --jumptomonth ?{Which month?|${monthList}}`, CSS_CURRENT.button)}`);
+ });
+ },
+
+ jumpToMonth: (msg, monthNum) => {
+ const month = parseInt(monthNum);
+ const viewingDate = State.config().viewingDate;
+
+ if (isNaN(month)) {
+ Output.send(msg.who, 'Invalid month');
+ return;
+ }
+
+ State.setConfig('viewingDate', {
+ year: viewingDate.year,
+ month: month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ pickYear: (msg) => {
+ const CSS_CURRENT = getCSS();
+ const viewingDate = State.config().viewingDate;
+ Output.send(msg.who, `Current year: ${viewingDate.year}
${Output.makeButton('Jump to Year', `!chr --jumptoyear ?{Enter year|${viewingDate.year}}`, CSS_CURRENT.button)}`);
+ },
+
+ jumpToYear: (msg, yearNum) => {
+ const year = parseInt(yearNum);
+ const viewingDate = State.config().viewingDate;
+
+ if (isNaN(year)) {
+ Output.send(msg.who, 'Invalid year');
+ return;
+ }
+
+ State.setConfig('viewingDate', {
+ year: year,
+ month: viewingDate.month
+ });
+ Commands.renderInterface(msg);
+ },
+
+ jumpToDay: (msg, dayNum) => {
+ DataLoader.loadAll((data) => {
+ const day = parseInt(dayNum);
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+
+ if (isNaN(day)) {
+ Output.send(msg.who, 'Invalid day');
+ return;
+ }
+
+ const month = calendar.months[currentDate.month - 1];
+ if (!month || day < 1 || day > month.days) {
+ Output.send(msg.who, `Invalid day. Must be between 1 and ${month ? month.days : '?'}`);
+ return;
+ }
+
+ State.setConfig('currentDate', {
+ year: currentDate.year,
+ month: currentDate.month,
+ day: day
+ });
+ Commands.renderInterface(msg);
+ });
+ },
+
+ newCalendar: (msg) => {
+ const CSS_CURRENT = getCSS();
+ Output.send(msg.who, `${Output.makeButton('Create New Calendar', `!chr --createnewcal ?{Calendar Name|New Calendar}`, CSS_CURRENT.button)}`);
+ },
+
+ createNewCalendar: (msg, calName) => {
+ const calendar = DataModels.createCalendar(calName);
+
+ // Start with basic structure - user will configure in Design Mode
+ calendar.months = [];
+ calendar.weeks.weekdayNames = ['Day1', 'Day2', 'Day3', 'Day4', 'Day5', 'Day6', 'Day7'];
+
+ HandoutManager.saveCalendar(calendar);
+ State.setConfig('currentCalendar', `${HANDOUT_PREFIX} Calendar: ${calName}`);
+
+ Output.send(msg.who, `New calendar "${calName}" created. Use Design Mode to add months.`);
+ State.setConfig('displayMode', 'design');
+ Commands.renderInterface(msg);
+ },
+
+ viewDate: (msg, dateStr) => {
+ const parts = dateStr.split('|');
+ const date = {
+ year: parseInt(parts[0]),
+ month: parseInt(parts[1]),
+ day: parseInt(parts[2])
+ };
+
+ State.setConfig('currentDate', date);
+ State.setConfig('viewingDate', { year: date.year, month: date.month });
+ State.setConfig('displayMode', 'calendar'); // Switch to calendar view
+ Commands.renderInterface(msg);
+ },
+
+ setFeaturedDate: (msg, dateStr) => {
+ const parts = dateStr.split('|');
+ const date = {
+ year: parseInt(parts[0]),
+ month: parseInt(parts[1]),
+ day: parseInt(parts[2])
+ };
+
+ State.setConfig('currentDate', date);
+ State.setConfig('viewingDate', { year: date.year, month: date.month });
+ // Don't change mode - stay in current view (timeline)
+ Commands.renderInterface(msg);
+ },
+
+ addNote: (msg) => {
+ // Kept for backward compatibility, but query is now in the button
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ const month = calendar.months[currentDate.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ Output.send(msg.who, `To add a note for ${monthName} ${currentDate.day}, ${currentDate.year}, use the Add Note button in the handout.`);
+ });
+ },
+
+ saveNote: (msg, noteText) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const notes = data.notes;
+ let eventsName = State.config().currentEvents;
+ const currentCalendar = State.config().currentCalendar;
+ const who = Utils.stripGM(msg.who);
+
+ // If currentEvents isn't set, derive it from calendar name
+ if (!eventsName && currentCalendar) {
+ const calName = currentCalendar.replace(`${HANDOUT_PREFIX} Calendar: `, '');
+ eventsName = `${HANDOUT_PREFIX} Events: ${calName}`;
+ State.setConfig('currentEvents', eventsName);
+ }
+
+ const note = DataModels.createNote(noteText, currentDate, [], who);
+ notes.push(note);
+
+ // Save to handout
+ const events = data.events;
+ if (eventsName) {
+ const calName = eventsName.replace(`${HANDOUT_PREFIX} Events: `, '');
+ HandoutManager.saveEvents(calName, events, notes, data.weather || []);
+ } else {
+ Output.send(msg.who, 'Error: No calendar loaded. Please load or create a calendar first.');
+ return;
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addEvent: (msg) => {
+ // Kept for backward compatibility, but query is now in the button
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ const month = calendar.months[currentDate.month - 1];
+ const monthName = month ? month.name : 'Unknown';
+
+ Output.send(msg.who, `To add an event for ${monthName} ${currentDate.day}, ${currentDate.year}, use the Add Event button in the handout.`);
+ });
+ },
+
+ saveEvent: (msg, eventText) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const events = data.events;
+ let eventsName = State.config().currentEvents;
+ const currentCalendar = State.config().currentCalendar;
+ const who = Utils.stripGM(msg.who);
+
+ // If currentEvents isn't set, derive it from calendar name
+ if (!eventsName && currentCalendar) {
+ const calName = currentCalendar.replace(`${HANDOUT_PREFIX} Calendar: `, '');
+ eventsName = `${HANDOUT_PREFIX} Events: ${calName}`;
+ State.setConfig('currentEvents', eventsName);
+ }
+
+ const event = DataModels.createEvent(eventText, currentDate, [], who);
+ events.push(event);
+
+ // Save to handout
+ const notes = data.notes;
+ if (eventsName) {
+ const calName = eventsName.replace(`${HANDOUT_PREFIX} Events: `, '');
+ HandoutManager.saveEvents(calName, events, notes, data.weather || []);
+ } else {
+ Output.send(msg.who, 'Error: No calendar loaded. Please load or create a calendar first.');
+ return;
+ }
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ generateWeather: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ const weather = data.weather;
+
+ // Check if weather already exists for this date
+ const existing = weather.findIndex(w =>
+ w.dateRef.year === currentDate.year &&
+ w.dateRef.month === currentDate.month &&
+ w.dateRef.day === currentDate.day
+ );
+
+ const newWeather = WeatherGenerator.generate(currentDate, calendar);
+
+ if (!newWeather) {
+ Output.send(msg.who, 'No climate set. Use Design Mode to set a climate first.');
+ return;
+ }
+
+ if (existing >= 0) {
+ weather[existing] = newWeather;
+ } else {
+ weather.push(newWeather);
+ }
+
+ // Save weather to handout
+ const events = data.events;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, weather);
+
+ // Weather generated, interface will re-render to show it
+ Commands.renderInterface(msg);
+ });
+ },
+
+ regenerateWeather: (msg) => {
+ // Just call generateWeather again, which will overwrite existing
+ Commands.generateWeather(msg);
+ },
+
+ clearWeather: (msg) => {
+ DataLoader.loadAll((data) => {
+ const currentDate = State.config().currentDate;
+ const calendar = data.calendar;
+ let weather = data.weather || [];
+
+ // Remove weather for current date
+ weather = weather.filter(w =>
+ !(w.dateRef.year === currentDate.year &&
+ w.dateRef.month === currentDate.month &&
+ w.dateRef.day === currentDate.day)
+ );
+
+ // Save back
+ const events = data.events;
+ const notes = data.notes;
+ HandoutManager.saveEvents(calendar.name, events, notes, weather);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addMonth: (msg) => {
+ // Kept for backward compatibility - query is now in the button
+ Output.send(msg.who, `Use the Add Month button in Design Mode to add a month.`);
+ },
+
+ saveMonth: (msg, monthData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ Logger.debug(`saveMonth received: "${monthData}"`);
+
+ const parts = monthData.split('|');
+
+ if (parts.length < 2) {
+ Output.send(msg.who, `Invalid format. Received ${parts.length} parts. Expected format: Name|Days. Got: "${monthData}"`);
+ return;
+ }
+
+ const name = parts[0].trim();
+ const days = parseInt(parts[1]);
+
+ if (isNaN(days) || days < 1) {
+ Output.send(msg.who, `Invalid number of days. Received: "${parts[1]}"`);
+ return;
+ }
+
+ const newMonth = DataModels.createMonth(name, days, calendar.months.length);
+ calendar.months.push(newMonth);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addMoon: (msg) => {
+ // Kept for backward compatibility - query is now in the button
+ Output.send(msg.who, `Use the Add Moon button in Design Mode to add a moon.`);
+ },
+
+
+ saveMoon: (msg, moonData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ if (!calendar) {
+ Output.send(msg.who, 'No calendar loaded');
+ return;
+ }
+
+ const moons = calendar.moons || [];
+
+ Logger.debug(`saveMoon received: "${moonData}"`);
+
+ const parts = moonData.split('|');
+
+ if (parts.length < 5) {
+ Output.send(msg.who, `Invalid format. Expected at least 5 parts: Name|Period|FullYear|FullMonth|FullDay. Got: "${moonData}"`);
+ return;
+ }
+
+ const name = parts[0].trim();
+ const period = parseFloat(parts[1]); // Changed to parseFloat for decimal support
+ const fullYear = parseInt(parts[2]);
+ const fullMonth = parseInt(parts[3]);
+ const fullDay = parseInt(parts[4]);
+ const size = parts.length > 5 ? parseFloat(parts[5]) : 1;
+ const color = parts.length > 6 ? parts[6].trim() : 'yellow';
+ const display = parts.length > 7 ? parts[7].trim() === 'true' : true;
+
+ if (isNaN(period) || isNaN(fullYear) || isNaN(fullMonth) || isNaN(fullDay)) {
+ Output.send(msg.who, `Invalid numbers in moon data. Period=${parts[1]}, Year=${parts[2]}, Month=${parts[3]}, Day=${parts[4]}`);
+ return;
+ }
+
+ const fullDayRef = { year: fullYear, month: fullMonth, day: fullDay };
+ const newMoon = DataModels.createMoon(name, period, fullDayRef, size, color, display);
+ moons.push(newMoon);
+
+ calendar.moons = moons;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ setClimate: (msg) => {
+ // Kept for backward compatibility - query is now in the button
+ Output.send(msg.who, `Use the Set Climate button in Design Mode to configure climate.`);
+ },
+
+
+ saveClimate: (msg, climateData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = climateData.split('|');
+
+ if (parts.length < 5) {
+ Output.send(msg.who, 'Invalid format. See help for proper format.');
+ return;
+ }
+
+ const inputs = {
+ latitude_band: parts[0].trim(),
+ ocean_proximity: parts[1].trim(),
+ coast_type: parts[2].trim(),
+ elevation: parts[3].trim(),
+ rainshadow: parts[4].trim()
+ };
+
+ const climate = ClimateClassifier.classify(inputs);
+ calendar.climate = climate;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ toggleUnits: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ // Toggle between 'us' and 'metric'
+ calendar.units = (calendar.units === 'us') ? 'metric' : 'us';
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ setVernalEquinox: (msg, dayStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const day = parseInt(dayStr);
+
+ if (isNaN(day) || day < 1 || day > calendar.daysInYear) {
+ Output.send(msg.who, `Invalid day. Must be between 1 and ${calendar.daysInYear}`);
+ return;
+ }
+
+ calendar.seasons.vernalEquinox = day;
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Vernal Equinox set to day ${day}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ toggleLeapYear: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ calendar.leapYears.enabled = !calendar.leapYears.enabled;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Leap years ${calendar.leapYears.enabled ? 'enabled' : 'disabled'}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ setLeapCycle: (msg, cycleStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const cycle = parseInt(cycleStr);
+
+ if (isNaN(cycle) || cycle < 1) {
+ Output.send(msg.who, 'Invalid cycle. Must be a positive number.');
+ return;
+ }
+
+ calendar.leapYears.cycle = cycle;
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Leap year cycle set to every ${cycle} years`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ addLeapException: (msg, yearStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const year = parseInt(yearStr);
+
+ if (isNaN(year)) {
+ Output.send(msg.who, 'Invalid year');
+ return;
+ }
+
+ if (!calendar.leapYears.exceptions) {
+ calendar.leapYears.exceptions = [];
+ }
+
+ if (calendar.leapYears.exceptions.includes(year)) {
+ Output.send(msg.who, `Year ${year} is already an exception`);
+ return;
+ }
+
+ calendar.leapYears.exceptions.push(year);
+ calendar.leapYears.exceptions.sort((a, b) => a - b);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ removeLeapException: (msg, idxStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const idx = parseInt(idxStr);
+
+ if (!calendar.leapYears.exceptions || idx < 0 || idx >= calendar.leapYears.exceptions.length) {
+ Output.send(msg.who, 'Invalid exception index');
+ return;
+ }
+
+ const year = calendar.leapYears.exceptions[idx];
+ calendar.leapYears.exceptions.splice(idx, 1);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ addHoliday: (msg, holidayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = holidayData.split('|');
+
+ Logger.debug(`addHoliday received: "${holidayData}"`);
+
+ if (parts.length < 4) {
+ Output.send(msg.who, `Invalid format. Expected: Name|Month|Day|Recurring|Description. Got: "${holidayData}"`);
+ return;
+ }
+
+ const name = parts[0].trim();
+ const month = parseInt(parts[1]);
+ const day = parseInt(parts[2]);
+ const recurring = parts[3].trim() === 'Yes';
+ const description = parts[4] ? parts[4].trim() : '';
+
+ if (isNaN(month) || isNaN(day)) {
+ Output.send(msg.who, `Invalid month or day. Month=${parts[1]}, Day=${parts[2]}`);
+ return;
+ }
+
+ if (month < 1 || month > calendar.months.length) {
+ Output.send(msg.who, `Invalid month. Must be between 1 and ${calendar.months.length}`);
+ return;
+ }
+
+ const holiday = DataModels.createHoliday(name, {month, day}, recurring, description);
+
+ if (!calendar.holidays) {
+ calendar.holidays = [];
+ }
+ calendar.holidays.push(holiday);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editHoliday: (msg, holidayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = holidayData.split('|');
+
+ if (parts.length < 6) {
+ Output.send(msg.who, `Invalid format. Expected: Index|Name|Month|Day|Recurring|Description`);
+ return;
+ }
+
+ const idx = parseInt(parts[0]);
+ const name = parts[1].trim();
+ const month = parseInt(parts[2]);
+ const day = parseInt(parts[3]);
+ const recurring = parts[4].trim() === 'Yes';
+ const description = parts[5].trim();
+
+ if (isNaN(idx) || idx < 0 || idx >= calendar.holidays.length) {
+ Output.send(msg.who, `Invalid holiday index: ${idx}`);
+ return;
+ }
+
+ if (isNaN(month) || isNaN(day)) {
+ Output.send(msg.who, `Invalid month or day`);
+ return;
+ }
+
+ if (month < 1 || month > calendar.months.length) {
+ Output.send(msg.who, `Invalid month. Must be between 1 and ${calendar.months.length}`);
+ return;
+ }
+
+ // Update all fields
+ calendar.holidays[idx].name = name;
+ calendar.holidays[idx].dateRef = { month, day };
+ calendar.holidays[idx].recurring = recurring;
+ calendar.holidays[idx].description = description;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ holidayWhisper: (msg, holidayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const holiday = calendar.holidays.find(h => h.id === holidayId);
+
+ if (!holiday) {
+ Output.send(msg.who, `Holiday not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = `${holiday.name}`;
+ if (holiday.description) {
+ output += `
${holiday.description}`;
+ }
+ output += `
`;
+ output += `Announce publicly`;
+
+ Output.send(msg.who, output);
+ });
+ },
+
+ holidayAnnounce: (msg, holidayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const holiday = calendar.holidays.find(h => h.id === holidayId);
+
+ if (!holiday) {
+ Output.send(msg.who, `Holiday not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = ``;
+ output += `${holiday.name}`;
+ if (holiday.description) {
+ output += `
${holiday.description}`;
+ }
+ output += `
`;
+
+ Output.broadcast(output);
+ });
+ },
+
+ addSpecialDay: (msg, dayType) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ // Build month list for query
+ const monthList = calendar.months.map((m, idx) => `${m.name},${idx + 1}`).join('|');
+
+ // Create direct query based on type - this will be embedded in a button href
+ let query = `!chr --savespecialday ${dayType}|?{Name}|?{After Which Month?|${monthList}}|?{After Which Day? (0=before month)}|?{Week Behavior|Part of week,partOfWeek|Between weeks,betweenWeeks}`;
+
+ if (dayType === 'leap') {
+ query += `|?{Every N years (frequency)|4}|?{Year offset|0}`;
+ }
+
+ query += `|?{Description (optional)|}`;
+
+ // This command should not be called from Design mode buttons anymore
+ // But keep for backwards compatibility
+ const CSS_CURRENT = getCSS();
+ Output.send(msg.who, `Configure Special Day`);
+ });
+ },
+
+ saveSpecialDay: (msg, specialDayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = specialDayData.split('|');
+
+ const dayType = parts[0]; // 'fixed' or 'leap'
+ const name = parts[1].trim();
+ const monthParts = parts[2].split(','); // "MonthName,MonthNumber"
+ const afterMonth = parseInt(monthParts[1] || monthParts[0]); // Use number part or fallback
+ const afterDay = parseInt(parts[3]);
+ const weekBehaviorParts = parts[4].split(',');
+ const weekBehavior = weekBehaviorParts[1] || weekBehaviorParts[0]; // Get second part or fallback
+
+ let frequency = null;
+ let offset = 0;
+ let description = '';
+
+ if (dayType === 'leap') {
+ frequency = parseInt(parts[5]);
+ offset = parseInt(parts[6]);
+ description = parts[7] ? parts[7].trim() : '';
+ } else {
+ description = parts[5] ? parts[5].trim() : '';
+ }
+
+ if (!name || isNaN(afterMonth) || isNaN(afterDay)) {
+ Output.send(msg.who, `Invalid input. Name: "${name}", Month: ${afterMonth}, Day: ${afterDay}`);
+ return;
+ }
+
+ const specialDay = DataModels.createInterMonthDay(
+ name,
+ { afterMonth, afterDay },
+ weekBehavior === 'betweenWeeks',
+ dayType,
+ frequency,
+ offset,
+ description
+ );
+
+ if (!calendar.interMonthDays) {
+ calendar.interMonthDays = [];
+ }
+ calendar.interMonthDays.push(specialDay);
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editSpecialDay: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const CSS_CURRENT = getCSS();
+ const index = parseInt(idx);
+
+ if (!calendar.interMonthDays || index < 0 || index >= calendar.interMonthDays.length) {
+ Output.send(msg.who, 'Special day not found');
+ return;
+ }
+
+ const sd = calendar.interMonthDays[index];
+ const escapedName = sd.name.replace(/\|/g, '|').replace(/\}/g, '}');
+ const escapedDesc = (sd.description || '').replace(/\|/g, '|').replace(/\}/g, '}');
+
+ // Build month list with current selection first
+ const currentMonth = calendar.months[sd.position.afterMonth - 1];
+ const monthList = calendar.months.map((m, idx) => {
+ const num = idx + 1;
+ return num === sd.position.afterMonth ? `${m.name},${num}` : `${m.name},${num}`;
+ }).join('|');
+ const monthDefault = `${currentMonth.name},${sd.position.afterMonth}`;
+
+ // Week behavior with current as default
+ const weekBehaviorDefault = sd.breaksWeekCycle ? 'Between weeks,betweenWeeks' : 'Part of week,partOfWeek';
+
+ let query = `!chr --updatespecialday ${index}|${sd.dayType}|?{Name|${escapedName}}|?{After Which Month?|${monthDefault}|${monthList}}|?{After Which Day?|${sd.position.afterDay}}|?{Week Behavior|${weekBehaviorDefault}|Part of week,partOfWeek|Between weeks,betweenWeeks}`;
+
+ if (sd.dayType === 'leap') {
+ query += `|?{Frequency|${sd.frequency}}|?{Offset|${sd.offset}}`;
+ }
+
+ query += `|?{Description|${escapedDesc}}`;
+
+ Output.send(msg.who, Output.makeButton('Update Special Day', query, CSS_CURRENT.button));
+ });
+ },
+
+ updateSpecialDay: (msg, specialDayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = specialDayData.split('|');
+
+ const index = parseInt(parts[0]);
+ const dayType = parts[1];
+ const name = parts[2].trim();
+ const monthParts = parts[3].split(',');
+ const afterMonth = parseInt(monthParts[1] || monthParts[0]);
+ const afterDay = parseInt(parts[4]);
+ const weekBehaviorParts = parts[5].split(',');
+ const weekBehavior = weekBehaviorParts[1] || weekBehaviorParts[0];
+
+ let frequency = null;
+ let offset = 0;
+ let description = '';
+
+ if (dayType === 'leap') {
+ frequency = parseInt(parts[6]);
+ offset = parseInt(parts[7]);
+ description = parts[8] ? parts[8].trim() : '';
+ } else {
+ description = parts[6] ? parts[6].trim() : '';
+ }
+
+ if (!calendar.interMonthDays || index < 0 || index >= calendar.interMonthDays.length) {
+ Output.send(msg.who, 'Special day not found');
+ return;
+ }
+
+ calendar.interMonthDays[index].name = name;
+ calendar.interMonthDays[index].position = { afterMonth, afterDay };
+ calendar.interMonthDays[index].breaksWeekCycle = (weekBehavior === 'betweenWeeks');
+ calendar.interMonthDays[index].dayType = dayType;
+ calendar.interMonthDays[index].frequency = frequency;
+ calendar.interMonthDays[index].offset = offset;
+ calendar.interMonthDays[index].description = description;
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteSpecialDay: (msg, idxStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const idx = parseInt(idxStr);
+
+ if (!calendar.interMonthDays || idx < 0 || idx >= calendar.interMonthDays.length) {
+ Output.send(msg.who, 'Invalid special day index');
+ return;
+ }
+
+ const sd = calendar.interMonthDays[idx];
+ calendar.interMonthDays.splice(idx, 1);
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ specialDayWhisper: (msg, specialDayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === specialDayId);
+
+ if (!specialDay) {
+ Output.send(msg.who, `Special day not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = `${specialDay.name}`;
+ if (specialDay.description) {
+ output += `
${specialDay.description}`;
+ }
+ output += `
`;
+ output += `Announce publicly`;
+
+ Output.send(msg.who, output);
+ });
+ },
+
+ specialDayAnnounce: (msg, specialDayId) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === specialDayId);
+
+ if (!specialDay) {
+ Output.send(msg.who, `Special day not found`);
+ return;
+ }
+
+ const CSS_CURRENT = getCSS();
+ let output = ``;
+ output += `${specialDay.name}`;
+ if (specialDay.description) {
+ output += `
${specialDay.description}`;
+ }
+ output += `
`;
+
+ Output.broadcast(output);
+ });
+ },
+
+ setSpecialDay: (msg, specialDayData) => {
+ DataLoader.loadAll((data) => {
+ const parts = specialDayData.split('|');
+ const year = parseInt(parts[0]);
+ const specialDayId = parts[1];
+
+ const calendar = data.calendar;
+ const specialDay = (calendar.interMonthDays || []).find(sd => sd.id === specialDayId);
+
+ if (!specialDay) {
+ Output.send(msg.who, `Special day not found`);
+ return;
+ }
+
+ // Calculate unique day number for this special day
+ // Count how many special days come before this one with the same afterMonth and afterDay
+ const specialDaysThisYear = DateUtils.getSpecialDaysForYear(year, calendar);
+ const sameDaySpecialDays = specialDaysThisYear.filter(sd =>
+ sd.position.afterMonth === specialDay.position.afterMonth &&
+ sd.position.afterDay === specialDay.position.afterDay
+ );
+
+ // Find this special day's index among same-day special days
+ const index = sameDaySpecialDays.findIndex(sd => sd.id === specialDayId);
+
+ // Set currentDate with special day reference and unique fractional day
+ State.setConfig('currentDate', {
+ year: year,
+ month: specialDay.position.afterMonth,
+ day: specialDay.position.afterDay + 1 + (index * 0.01), // Unique fractional offset
+ specialDayId: specialDayId
+ });
+
+ // Set viewing month
+ State.setConfig('viewingDate', {
+ year: year,
+ month: specialDay.position.afterMonth
+ });
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ deleteHoliday: (msg, idxStr) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const idx = parseInt(idxStr);
+
+ if (!calendar.holidays || idx < 0 || idx >= calendar.holidays.length) {
+ Output.send(msg.who, 'Invalid holiday index');
+ return;
+ }
+
+ const holiday = calendar.holidays[idx];
+ calendar.holidays.splice(idx, 1);
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ moveMonth: (msg, moveData) => {
+ const calendar = data.calendar;
+ const parts = moveData.split('|');
+ const idx = parseInt(parts[0]);
+ const direction = parts[1];
+
+ if (isNaN(idx) || idx < 0 || idx >= calendar.months.length) {
+ Output.send(msg.who, 'Invalid month index');
+ return;
+ }
+
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1;
+
+ if (newIdx < 0 || newIdx >= calendar.months.length) {
+ return; // Can't move beyond boundaries
+ }
+
+ // Swap
+ const temp = calendar.months[idx];
+ calendar.months[idx] = calendar.months[newIdx];
+ calendar.months[newIdx] = temp;
+
+ // Update order property
+ calendar.months.forEach((m, i) => {
+ m.order = i;
+ });
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ },
+
+
+ moveMoon: (msg, moveData) => {
+ const moons = data.moons;
+ const parts = moveData.split('|');
+ const idx = parseInt(parts[0]);
+ const direction = parts[1];
+
+ if (isNaN(idx) || idx < 0 || idx >= moons.length) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1;
+
+ if (newIdx < 0 || newIdx >= moons.length) {
+ return; // Can't move beyond boundaries
+ }
+
+ // Swap
+ const temp = moons[idx];
+ moons[idx] = moons[newIdx];
+ moons[newIdx] = temp;
+
+
+ Commands.renderInterface(msg);
+ },
+
+
+ moveHoliday: (msg, moveData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const parts = moveData.split('|');
+ const idx = parseInt(parts[0]);
+ const direction = parts[1];
+
+ if (!calendar.holidays || isNaN(idx) || idx < 0 || idx >= calendar.holidays.length) {
+ Output.send(msg.who, 'Invalid holiday index');
+ return;
+ }
+
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1;
+
+ if (newIdx < 0 || newIdx >= calendar.holidays.length) {
+ return; // Can't move beyond boundaries
+ }
+
+ // Swap
+ const temp = calendar.holidays[idx];
+ calendar.holidays[idx] = calendar.holidays[newIdx];
+ calendar.holidays[newIdx] = temp;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+
+ editMonth: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const monthIndex = parseInt(idx);
+ const month = calendar.months[monthIndex];
+
+ if (!month) {
+ Output.send(msg.who, 'Invalid month index');
+ return;
+ }
+
+ Output.send(msg.who, `To edit "${month.name}", type: !chr --updatemonth ${idx}|?{New Month Name|${month.name}}|?{New Days|${month.days}}`);
+ });
+ },
+
+ updateMonth: (msg, monthData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+
+ Logger.debug(`updateMonth received: "${monthData}"`);
+
+ const parts = monthData.split('|');
+
+ if (parts.length < 3) {
+ Output.send(msg.who, `Invalid format. Received ${parts.length} parts. Expected: Index|Name|Days. Got: "${monthData}"`);
+ return;
+ }
+
+ const idx = parseInt(parts[0]);
+ const name = parts[1].trim();
+ const days = parseInt(parts[2]);
+
+ if (isNaN(idx) || isNaN(days) || !calendar.months[idx]) {
+ Output.send(msg.who, `Invalid month data. Index=${parts[0]}, Name=${parts[1]}, Days=${parts[2]}`);
+ return;
+ }
+
+ calendar.months[idx].name = name;
+ calendar.months[idx].days = days;
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteMonth: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const monthIndex = parseInt(idx);
+ const month = calendar.months[monthIndex];
+
+ if (!month) {
+ Output.send(msg.who, 'Invalid month index');
+ return;
+ }
+
+ calendar.months.splice(monthIndex, 1);
+
+ // Re-index remaining months
+ calendar.months.forEach((m, i) => {
+ m.order = i;
+ });
+
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editMoon: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const moons = data.moons;
+ const moonIndex = parseInt(idx);
+ const moon = moons[moonIndex];
+
+ if (!moon) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ Output.send(msg.who, `To edit "${moon.name}", use: !chr --updatemoon ${idx}|?{Moon Name|${moon.name}}|?{Period|${moon.period}}|?{Full Year|${moon.fullDayRef.year}}|?{Full Month|${moon.fullDayRef.month}}|?{Full Day|${moon.fullDayRef.day}}`);
+ });
+ },
+
+ updateMoon: (msg, moonData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const moons = calendar.moons || [];
+ const parts = moonData.split('|');
+
+ if (parts.length < 6) {
+ Output.send(msg.who, 'Invalid format');
+ return;
+ }
+
+ const idx = parseInt(parts[0]);
+ const name = parts[1].trim();
+ const period = parseFloat(parts[2]); // Changed to parseFloat for decimal support
+ const fullYear = parseInt(parts[3]);
+ const fullMonth = parseInt(parts[4]);
+ const fullDay = parseInt(parts[5]);
+ const size = parts.length > 6 ? parseFloat(parts[6]) : (moons[idx].size || 1);
+ const color = parts.length > 7 ? parts[7].trim() : (moons[idx].color || 'yellow');
+ const display = parts.length > 8 ? parts[8].trim() === 'true' : (moons[idx].display !== false);
+
+ if (isNaN(idx) || !moons[idx]) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ moons[idx].name = name;
+ moons[idx].period = period;
+ moons[idx].fullDayRef = { year: fullYear, month: fullMonth, day: fullDay };
+ moons[idx].size = size;
+ moons[idx].color = color;
+ moons[idx].display = display;
+
+ calendar.moons = moons;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ deleteMoon: (msg, idx) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const moons = calendar.moons || [];
+ const moonIndex = parseInt(idx);
+ const moon = moons[moonIndex];
+
+ if (!moon) {
+ Output.send(msg.who, 'Invalid moon index');
+ return;
+ }
+
+ moons.splice(moonIndex, 1);
+
+ calendar.moons = moons;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editWeekdays: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const current = calendar.weeks.weekdayNames.join(',');
+
+ Output.send(msg.who, `Current weekdays: ${current}
To change, type: !chr --saveweekdays ?{Weekday Names (comma-separated)|${current}}`);
+ });
+ },
+
+ saveWeekdays: (msg, weekdayData) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const weekdays = weekdayData.split(',').map(w => w.trim());
+
+ if (weekdays.length !== calendar.weeks.daysInWeek) {
+ Output.send(msg.who, `Error: You must provide exactly ${calendar.weeks.daysInWeek} weekday names`);
+ return;
+ }
+
+ calendar.weeks.weekdayNames = weekdays;
+ HandoutManager.saveCalendar(calendar);
+
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editCalendarName: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ Output.send(msg.who, `Current name: ${calendar.name}
To change, type: !chr --savename ?{Calendar Name|${calendar.name}}`);
+ });
+ },
+
+ saveCalendarName: (msg, newName) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const oldName = calendar.name;
+ calendar.name = newName;
+
+ // Save with new name
+ const newHandoutName = HANDOUT_PREFIX + ' Calendar: ' + newName;
+ State.setConfig('currentCalendar', newHandoutName);
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, 'Calendar renamed from "' + oldName + '" to "' + newName + '"');
+ Commands.renderInterface(msg);
+ });
+ },
+
+ saveDescription: (msg, description) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ calendar.description = description || '';
+
+ HandoutManager.saveCalendar(calendar);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editDaysInYear: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ Output.send(msg.who, `Current days in year: ${calendar.daysInYear}
To change, type: !chr --savedaysinyear ?{Days in Year|${calendar.daysInYear}}`);
+ });
+ },
+
+ saveDaysInYear: (msg, days) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const numDays = parseInt(days);
+
+ if (isNaN(numDays) || numDays < 1) {
+ Output.send(msg.who, 'Invalid number of days');
+ return;
+ }
+
+ calendar.daysInYear = numDays;
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Days in year set to ${numDays}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ editDaysInWeek: (msg) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ Output.send(msg.who, `Current days in week: ${calendar.weeks.daysInWeek}
To change, type: !chr --savedaysinweek ?{Days in Week|${calendar.weeks.daysInWeek}}`);
+ });
+ },
+
+ saveDaysInWeek: (msg, days) => {
+ DataLoader.loadAll((data) => {
+ const calendar = data.calendar;
+ const numDays = parseInt(days);
+
+ if (isNaN(numDays) || numDays < 1) {
+ Output.send(msg.who, 'Invalid number of days');
+ return;
+ }
+
+ calendar.weeks.daysInWeek = numDays;
+
+ // Adjust weekday names if needed
+ while (calendar.weeks.weekdayNames.length < numDays) {
+ calendar.weeks.weekdayNames.push(`Day${calendar.weeks.weekdayNames.length + 1}`);
+ }
+ while (calendar.weeks.weekdayNames.length > numDays) {
+ calendar.weeks.weekdayNames.pop();
+ }
+
+ HandoutManager.saveCalendar(calendar);
+
+ Output.send(msg.who, `Days in week set to ${numDays}`);
+ Commands.renderInterface(msg);
+ });
+ },
+
+ sendCalendarToChat: (msg) => {
+ DataLoader.loadAll((data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ const viewingDate = State.config().viewingDate;
+ const currentDate = State.config().currentDate;
+ const month = calendar.months[currentDate.month - 1];
+ const moons = data.moons;
+ const events = data.events;
+ const notes = data.notes;
+ const weather = data.weather;
+
+ if (!month) {
+ Output.send(msg.who, 'Invalid month');
+ return;
+ }
+
+ // Calculate day of year (1-based, counting from month 1 day 1)
+ let dayOfYear = 0;
+ for (let m = 1; m < currentDate.month; m++) {
+ dayOfYear += DateUtils.getDaysInMonth(m, currentDate.year, calendar);
+ }
+ dayOfYear += currentDate.day;
+
+ const daysInYear = DateUtils.getDaysInYear(currentDate.year, calendar);
+ const vernal = calendar.seasons.vernalEquinox || 80;
+ const seasonOffset = Math.floor(daysInYear / 12);
+
+ const springStart = vernal - seasonOffset;
+ const summerStart = vernal + Math.floor(daysInYear / 4) - seasonOffset;
+ const autumnStart = vernal + Math.floor(daysInYear / 2) - seasonOffset;
+ const winterStart = vernal + Math.floor(3 * daysInYear / 4) - seasonOffset;
+
+ let season = 'Winter';
+ if (dayOfYear >= springStart && dayOfYear < summerStart) {
+ season = 'Spring';
+ } else if (dayOfYear >= summerStart && dayOfYear < autumnStart) {
+ season = 'Summer';
+ } else if (dayOfYear >= autumnStart && dayOfYear < winterStart) {
+ season = 'Autumn';
+ } else {
+ season = 'Winter';
+ }
+
+ let output = ``;
+ output += `
${month.name} ${currentDate.day}, ${currentDate.year}
`;
+ output += `
Season: ${season} (Day ${dayOfYear} of ${daysInYear})
`;
+
+ // Current day's weather
+ const todayWeather = weather.find(w =>
+ w.dateRef.year === currentDate.year &&
+ w.dateRef.month === currentDate.month &&
+ w.dateRef.day === currentDate.day
+ );
+ if (todayWeather) {
+ output += `
Weather: ${todayWeather.description} (${todayWeather.temperature.value}°${todayWeather.temperature.unit})
`;
+ }
+
+ // Current day's holidays
+ const todayHolidays = (calendar.holidays || []).filter(h =>
+ h.dateRef.month === currentDate.month &&
+ h.dateRef.day === currentDate.day
+ );
+ if (todayHolidays.length > 0) {
+ output += '
Holidays: ';
+ output += todayHolidays.map(h =>
+ `
${h.name}`
+ ).join(', ');
+ output += '
';
+ }
+
+ // Current day's special days
+ const todaySpecialDay = DateUtils.isSpecialDay(currentDate.month, currentDate.day, currentDate.year, calendar);
+ if (todaySpecialDay) {
+ output += '
';
+ }
+
+ // Current day's events (exclude gm tagged)
+ const todayEvents = events.filter(e =>
+ e.dateRef.year === currentDate.year &&
+ e.dateRef.month === currentDate.month &&
+ e.dateRef.day === currentDate.day &&
+ !(e.tags && e.tags.includes('gm'))
+ );
+ if (todayEvents.length > 0) {
+ output += '
Events:
';
+ todayEvents.forEach(e => output += `- ${e.content}
`);
+ output += '
';
+ }
+
+ // Current day's notes (exclude gm tagged)
+ const todayNotes = notes.filter(n =>
+ n.dateRef.year === currentDate.year &&
+ n.dateRef.month === currentDate.month &&
+ n.dateRef.day === currentDate.day &&
+ !(n.tags && n.tags.includes('gm'))
+ );
+ if (todayNotes.length > 0) {
+ output += '
Notes:
';
+ todayNotes.forEach(n => output += `- ${n.content}
`);
+ output += '
';
+ }
+
+ // Week context (simplified calendar)
+ const daysInWeek = calendar.weeks.daysInWeek;
+ const daysInMonth = DateUtils.getDaysInMonth(currentDate.month, currentDate.year, calendar);
+ const currentAbsDay = DateUtils.toAbsoluteDay(currentDate, calendar);
+ const currentWeekday = (currentAbsDay - 1) % daysInWeek;
+ const weekStart = currentDate.day - currentWeekday;
+
+ output += '
This Week:
';
+ output += '
';
+
+ // Weekday headers
+ output += '';
+ for (let i = 0; i < Math.min(daysInWeek, 7); i++) {
+ const dayName = calendar.weeks.weekdayNames[i] || i;
+ output += `| ${dayName.substr(0, 2)} | `;
+ }
+ output += '
';
+
+ output += '';
+ for (let i = 0; i < Math.min(daysInWeek, 7); i++) {
+ const day = weekStart + i;
+ if (day < 1 || day > daysInMonth) {
+ output += '| - | ';
+ } else {
+ const isToday = day === currentDate.day;
+ const style = isToday ?
+ 'border:2px solid #6b8cae;padding:1px;font-weight:bold;vertical-align:top;background:#5a5a5a;' :
+ 'border:1px solid #666;padding:2px;vertical-align:top;';
+
+ output += `${day} `;
+
+ // Moon phases (SVG, visible moons only)
+ if (moons && moons.length > 0) {
+ const date = {year: currentDate.year, month: currentDate.month, day: day};
+ const phases = MoonPhaseCalculator.getAllPhases(moons, date, calendar);
+ if (phases.length > 0) {
+ output += ``;
+ phases.forEach(p => output += p.html);
+ output += ` `;
+ }
+ }
+
+ // Weather emoji
+ const w = weather.find(ww => ww.dateRef.year === currentDate.year && ww.dateRef.month === currentDate.month && ww.dateRef.day === day);
+ if (w) output += `${WeatherGenerator.getWeatherEmoji(w.description)} `;
+
+ output += ' | ';
+ }
+ }
+ output += '
';
+
+ output += '
';
+
+ Output.broadcast(output);
+ });
+ },
+
+ sendDesignToChat: (msg) => {
+ DataLoader.loadAll((data) => {
+ const CSS_CURRENT = getCSS();
+ const calendar = data.calendar;
+ const moons = data.moons;
+
+ let output = ``;
+ output += `
${calendar.name} - Calendar Structure
`;
+
+ output += `
`;
+ output += `Days in Year: ${calendar.daysInYear} | `;
+ output += `Days in Week: ${calendar.weeks.daysInWeek}`;
+ output += `
`;
+
+ output += `
`;
+ output += `Months: ${calendar.months.map(m => `${m.name} (${m.days})`).join(', ')}`;
+ output += `
`;
+
+ output += `
`;
+ output += `Weekdays: ${calendar.weeks.weekdayNames.join(', ')}`;
+ output += `
`;
+
+ if (moons && moons.length > 0) {
+ output += `
`;
+ output += `Moons: ${moons.map(m => `${m.name} (${m.period}d)`).join(', ')}`;
+ output += `
`;
+ }
+
+ if (calendar.climate) {
+ output += `
`;
+ output += `Climate: ${calendar.climate.climate_name} (${calendar.climate.koppen_code})`;
+ output += `
`;
+ }
+
+ if (calendar.leapYears.enabled) {
+ output += `
`;
+ output += `Leap Years: Every ${calendar.leapYears.cycle} years`;
+ output += `
`;
+ }
+
+ output += '
';
+
+ Output.broadcast(output);
+ });
+ }
+
+ };
+
+ // ==================================================
+ // Weather Generator
+ // ==================================================
+
+ const WeatherGenerator = {
+
+ getWeatherEmoji: (description) => {
+ if (!description) return '';
+
+ const desc = description.toLowerCase();
+
+ // Check for specific weather types
+ if (desc.includes('snow')) {
+ if (desc.includes('heavy')) return '❄️';
+ if (desc.includes('light')) return '🌨️';
+ return '❄️';
+ }
+ if (desc.includes('thunderstorm')) return '⛈️';
+ if (desc.includes('rain')) {
+ if (desc.includes('heavy')) return '🌧️';
+ return '🌧️';
+ }
+ if (desc.includes('cloudy') || desc.includes('overcast')) return '☁️';
+ if (desc.includes('partly')) return '⛅';
+ if (desc.includes('clear')) {
+ if (desc.includes('cold')) return '🌬️';
+ return '☀️';
+ }
+ if (desc.includes('fog') || desc.includes('mist')) return '🌫️';
+
+ // Default
+ return '🌤️';
+ },
+
+ generate: (date, calendar) => {
+ if (!calendar.climate) {
+ return null;
+ }
+
+ const climate = calendar.climate;
+ const dayOfYear = DateUtils.toAbsoluteDay(date, calendar) % DateUtils.getDaysInYear(date.year, calendar);
+
+ // Determine season
+ const season = WeatherGenerator._getSeason(dayOfYear, calendar);
+
+ // Generate based on climate and season
+ const temp = WeatherGenerator._generateTemperature(climate, season, calendar.units);
+ const precip = WeatherGenerator._generatePrecipitation(climate, season);
+ const wind = WeatherGenerator._generateWind(climate, season);
+ const description = WeatherGenerator._generateDescription(climate, season, temp, precip, wind);
+
+ return DataModels.createWeather(date, climate.koppen_code, temp, precip, wind, description);
+ },
+
+ _getSeason: (dayOfYear, calendar) => {
+ const vernal = calendar.seasons.vernalEquinox;
+ const daysInYear = calendar.daysInYear;
+
+ // Calculate other equinoxes/solstices at even intervals
+ const summer = vernal + Math.floor(daysInYear / 4);
+ const autumnal = vernal + Math.floor(daysInYear / 2);
+ const winter = vernal + Math.floor(3 * daysInYear / 4);
+
+ if (dayOfYear >= vernal && dayOfYear < summer) return 'spring';
+ if (dayOfYear >= summer && dayOfYear < autumnal) return 'summer';
+ if (dayOfYear >= autumnal && dayOfYear < winter) return 'autumn';
+ return 'winter';
+ },
+
+ _generateTemperature: (climate, season, units) => {
+ const code = climate.koppen_code;
+ let baseTemp = 60; // Default Fahrenheit
+ let seasonalSwing = 15; // Default seasonal temperature variation
+
+ // Adjust by climate group
+ if (code.startsWith('A')) {
+ baseTemp = 85; // Tropical
+ seasonalSwing = 5; // Minimal seasonal variation in tropics
+ } else if (code.startsWith('B')) {
+ baseTemp = code.includes('h') ? 90 : 70; // Hot/Cold Desert
+ seasonalSwing = code.includes('h') ? 20 : 30; // Large daily and seasonal swings in deserts
+ } else if (code.startsWith('C')) {
+ baseTemp = 65; // Temperate
+ seasonalSwing = 20; // Moderate seasonal variation
+ } else if (code.startsWith('D')) {
+ baseTemp = 45; // Continental
+ seasonalSwing = 35; // Large seasonal variation
+ } else if (code.startsWith('E')) {
+ baseTemp = 20; // Polar
+ seasonalSwing = 25; // Moderate variation (always cold)
+ }
+
+ // Adjust by season with climate-appropriate swings
+ const seasonMod = {
+ 'spring': 0,
+ 'summer': seasonalSwing,
+ 'autumn': -seasonalSwing * 0.3,
+ 'winter': -seasonalSwing * 1.3
+ };
+ baseTemp += seasonMod[season] || 0;
+
+ // Add random daily variation (larger in continental climates, smaller in maritime)
+ let dailyVariation = 10;
+ if (code.includes('f')) dailyVariation = 7; // Maritime climates more stable
+ if (code.startsWith('D')) dailyVariation = 15; // Continental more variable
+ if (code.startsWith('B')) dailyVariation = 20; // Deserts highly variable
+
+ const variation = Math.floor(Math.random() * (dailyVariation * 2)) - dailyVariation;
+ let temp = baseTemp + variation;
+
+ const unit = units === 'metric' ? 'C' : 'F';
+
+ if (units === 'metric') {
+ temp = Math.round((temp - 32) * 5 / 9);
+ }
+
+ return { value: temp, unit: unit };
+ },
+
+ _generatePrecipitation: (climate, season) => {
+ const code = climate.koppen_code;
+ const rand = Math.random();
+
+ // Dry climates (B) - very little precipitation year-round
+ if (code.startsWith('B')) {
+ if (rand < 0.9) return 'Clear';
+ return 'Scattered clouds';
+ }
+
+ // Rainforest (Af) - heavy rain year-round
+ if (code === 'Af') {
+ if (rand < 0.6) return 'Rain';
+ if (rand < 0.9) return 'Heavy rain';
+ return 'Partly cloudy';
+ }
+
+ // Monsoon/Tropical Savanna (Aw) - wet summer, dry winter
+ if (code === 'Aw') {
+ if (season === 'summer') {
+ if (rand < 0.7) return 'Heavy rain';
+ return 'Thunderstorms';
+ } else if (season === 'winter') {
+ if (rand < 0.8) return 'Clear';
+ return 'Partly cloudy';
+ } else {
+ if (rand < 0.5) return 'Rain';
+ return 'Cloudy';
+ }
+ }
+
+ // Mediterranean (Cs) - dry summer, wet winter
+ if (code.startsWith('Cs')) {
+ if (season === 'summer') {
+ if (rand < 0.8) return 'Clear';
+ return 'Partly cloudy';
+ } else if (season === 'winter') {
+ if (rand < 0.6) return 'Rain';
+ return 'Cloudy';
+ } else {
+ if (rand < 0.5) return 'Partly cloudy';
+ return 'Rain';
+ }
+ }
+
+ // Monsoon temperate (Cw) - dry winter
+ if (code.startsWith('Cw')) {
+ if (season === 'winter') {
+ if (rand < 0.7) return 'Clear';
+ return 'Partly cloudy';
+ } else {
+ if (rand < 0.5) return 'Rain';
+ return 'Cloudy';
+ }
+ }
+
+ // Marine/Humid climates (Cf, Df) - precipitation year-round but varies by season
+ if (code.includes('f')) {
+ // Winter tends to have more precipitation in continental climates
+ if (code.startsWith('D') && season === 'winter') {
+ if (rand < 0.3) return 'Snow';
+ if (rand < 0.6) return 'Heavy snow';
+ if (rand < 0.8) return 'Cloudy';
+ return 'Light snow';
+ }
+
+ // Summer has more thunderstorms
+ if (season === 'summer') {
+ if (rand < 0.3) return 'Clear';
+ if (rand < 0.5) return 'Partly cloudy';
+ if (rand < 0.7) return 'Cloudy';
+ if (rand < 0.85) return 'Rain';
+ return 'Thunderstorms';
+ }
+
+ // Spring/Autumn moderate
+ if (rand < 0.3) return 'Clear';
+ if (rand < 0.6) return 'Partly cloudy';
+ if (rand < 0.8) return 'Cloudy';
+ return 'Rain';
+ }
+
+ // Polar (E) - very little precipitation, mostly snow
+ if (code.startsWith('E')) {
+ if (season === 'summer') {
+ if (rand < 0.6) return 'Overcast';
+ if (rand < 0.9) return 'Light snow';
+ return 'Snow';
+ } else {
+ if (rand < 0.5) return 'Clear and cold';
+ if (rand < 0.8) return 'Light snow';
+ return 'Heavy snow';
+ }
+ }
+
+ // Default fallback
+ if (rand < 0.3) return 'Clear';
+ if (rand < 0.6) return 'Partly cloudy';
+ if (rand < 0.8) return 'Cloudy';
+ if (rand < 0.95) return 'Rain';
+ return 'Thunderstorms';
+ },
+
+ _generateWind: (climate, season) => {
+ const rand = Math.random();
+
+ if (rand < 0.4) return 'Calm';
+ if (rand < 0.7) return 'Light breeze';
+ if (rand < 0.9) return 'Moderate wind';
+ if (rand < 0.97) return 'Strong wind';
+ return 'Very strong wind';
+ },
+
+ _generateDescription: (climate, season, temp, precip, wind) => {
+ let desc = precip;
+
+ if (precip !== 'Clear' && wind !== 'Calm') {
+ desc += `, ${wind.toLowerCase()}`;
+ }
+
+ return desc;
+ }
+
+ };
+
+ // ==================================================
+ // Tag System
+ // ==================================================
+
+ const TagSystem = {
+
+ expandPartyTags: (tags) => {
+ // Party management removed - tags are just passed through for now
+ return [...tags];
+ },
+
+ getAllTags: (data) => {
+ const events = data.events;
+ const notes = data.notes;
+
+ const allTags = new Set();
+
+ [...events, ...notes].forEach(item => {
+ if (item.tags) {
+ item.tags.forEach(tag => allTags.add(tag));
+ }
+ });
+
+ return Array.from(allTags).sort();
+ },
+
+ filterByTags: (items, tags) => {
+ if (!tags || tags.length === 0) return items;
+
+ return items.filter(item => {
+ if (!item.tags) return false;
+ return tags.some(tag => item.tags.includes(tag));
+ });
+ }
+
+ };
+
+ // ==================================================
+ // Input Handler
+ // ==================================================
+
+ const handleInput = (msg) => {
+ if (msg.type !== 'api') return;
+
+ const parsed = Parser.parse(msg.content);
+
+ if (parsed.command !== '!chronicle' && parsed.command !== '!chr') return;
+
+ Commands.root(msg, parsed);
+ };
+
+ // ==================================================
+ // Event Registration
+ // ==================================================
+
+ const registerEventHandlers = () => {
+ on('chat:message', handleInput);
+ };
+
+ // ==================================================
+ // Initialization
+ // ==================================================
+
+ const checkInstall = () => {
+ Logger.log(`v${version} [${new Date(lastUpdate * 1000)}]`);
+ State.initialize();
+ return true;
+ };
+
+ on('ready', () => {
+ if (checkInstall()) {
+ registerEventHandlers();
+ }
+ });
+
+ // ==================================================
+ // Public Interface
+ // ==================================================
+
+ return {
+ version: version
+ };
+})();
\ No newline at end of file
diff --git a/Chronicle/readme.md b/Chronicle/readme.md
new file mode 100644
index 000000000..d6f75cda0
--- /dev/null
+++ b/Chronicle/readme.md
@@ -0,0 +1,43 @@
+# Chronicle
+
+Chronicle is a comprehensive calendar and timeline management system for Roll20 campaigns.
+
+## Key Features
+
+- **Custom or Preset Calendars** — Create your own or load from five included presets (Gregorian, Forgotten Realms, Golarion, Greyhawk, Eberron)
+- **Event and Note Tracking** — Powerful tagging system for organizing and filtering campaign events
+- **Calendar View** — Interactive grid with date-by-date navigation and exploration
+- **Timeline Mode** — Chronological story review with tag and date-range filtering
+- **Weather Generation** — Automatic realistic weather based on Köppen climate zones
+- **Moon Phases** — Multiple moons with automatic phase calculation for any date
+- **Send to Chat** — Announce dates and events directly to players
+- **GM-Only Content** — Tag items 'gm' to hide them from chat output
+- **Special Days** — Support for holidays, intercalary days, leap days, and custom events
+
+## Getting Started
+
+1. **Install the script** in your campaign
+2. **Run `!chr`** to initialize (creates your main interface handout)
+3. **Choose your path:**
+ - Load a preset calendar (30 seconds)
+ - Create a custom calendar from scratch (30 minutes)
+4. **Start tracking** events, notes, and campaign time
+5. **Run `!chr --help`** anytime for detailed documentation
+
+## Design Mode
+
+Configure your calendar with custom months, weeks, climate zones, holidays, and moons. The interface handles all calculations automatically—no spreadsheets or complex setup required.
+
+## Perfect For
+
+Long-term campaigns where tracking in-game time enhances storytelling. Works with any system—D&D, Pathfinder, Vampire: The Masquerade, and more. No coding knowledge required.
+
+## Installation
+
+1. Copy the Chronicle script to your Roll20 campaign's Scripts section
+2. Run `!chr` to begin
+3. Load a preset calendar or create your own in Design mode
+
+## Support
+
+For detailed help and documentation, run `!chr --help` in your campaign to access the full help system built into the script.
\ No newline at end of file
diff --git a/Chronicle/script.json b/Chronicle/script.json
new file mode 100644
index 000000000..3ad6b852a
--- /dev/null
+++ b/Chronicle/script.json
@@ -0,0 +1,15 @@
+{
+ "name": "Chronicle",
+ "script": "Chronicle",
+ "version": "1.0.0",
+ "description": "Chronicle is a comprehensive calendar and timeline management system for Roll20 campaigns.\n\nKEY FEATURES:\n• Create custom calendars or load presets (Gregorian, Forgotten Realms, Golarion, Greyhawk, Eberron)\n• Track events and notes with a powerful tagging system for organizing and filtering\n• Calendar view with interactive grid navigation and date-by-date exploration\n• Timeline mode for chronological story review with tag and date-range filtering\n• Automatic weather generation based on Köppen climate zones\n• Multiple moons with automatic phase calculation for any date\n• Send to Chat feature to announce dates and events to players\n• GM-only content filtering (tag items 'gm' to hide from chat output)\n• Special days, holidays, leap days, and intercalary days support\n\nGETTING STARTED:\n1. Install the script in your campaign\n2. Run !chr to initialize (creates your main interface handout)\n3. Choose: Load a preset calendar (30 seconds) or create a custom one (30 minutes)\n4. Start adding events, notes, and tracking campaign time\nRun !chr --help anytime for detailed documentation.\n\nDESIGN MODE:\nConfigure your calendar with custom months, weeks, climate zones, holidays, and moons. The interface handles all calculations automatically—no spreadsheets or complex setup required.\n\nPERFECT FOR:\nLong-term campaigns where tracking in-game time enhances storytelling. Works with any system—D&D, Pathfinder, Vampire, etc. No coding knowledge required.",
+ "authors": "Keith Curtis",
+ "roll20userid": "162065",
+ "modifies": {
+ "handout": "read,write"
+ },
+ "patreon": "https://www.patreon.com/c/KeithCurtis",
+ "dependencies": [],
+ "conflicts": [],
+ "previousversions": ["1.0.0"]
+}
\ No newline at end of file
From ff99996e2a3c71089697681dbabbc791bea45d1c Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sat, 30 May 2026 09:02:06 -0700
Subject: [PATCH 03/13] Chronicle Script readme and json edits
---
Chronicle/readme.md | 6 +++++-
Chronicle/script.json | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/Chronicle/readme.md b/Chronicle/readme.md
index d6f75cda0..1949eaed9 100644
--- a/Chronicle/readme.md
+++ b/Chronicle/readme.md
@@ -2,12 +2,16 @@
Chronicle is a comprehensive calendar and timeline management system for Roll20 campaigns.
+## Quick Demo
+
+Watch a walkthrough of Chronicle's features: https://youtu.be/Zb4M21NtP3Y
+
## Key Features
- **Custom or Preset Calendars** — Create your own or load from five included presets (Gregorian, Forgotten Realms, Golarion, Greyhawk, Eberron)
- **Event and Note Tracking** — Powerful tagging system for organizing and filtering campaign events
- **Calendar View** — Interactive grid with date-by-date navigation and exploration
-- **Timeline Mode** — Chronological story review with tag and date-range filtering
+- **Timeline Mode** — Chronological campaign review with tag and date-range filtering
- **Weather Generation** — Automatic realistic weather based on Köppen climate zones
- **Moon Phases** — Multiple moons with automatic phase calculation for any date
- **Send to Chat** — Announce dates and events directly to players
diff --git a/Chronicle/script.json b/Chronicle/script.json
index 3ad6b852a..b0d3a955b 100644
--- a/Chronicle/script.json
+++ b/Chronicle/script.json
@@ -2,7 +2,7 @@
"name": "Chronicle",
"script": "Chronicle",
"version": "1.0.0",
- "description": "Chronicle is a comprehensive calendar and timeline management system for Roll20 campaigns.\n\nKEY FEATURES:\n• Create custom calendars or load presets (Gregorian, Forgotten Realms, Golarion, Greyhawk, Eberron)\n• Track events and notes with a powerful tagging system for organizing and filtering\n• Calendar view with interactive grid navigation and date-by-date exploration\n• Timeline mode for chronological story review with tag and date-range filtering\n• Automatic weather generation based on Köppen climate zones\n• Multiple moons with automatic phase calculation for any date\n• Send to Chat feature to announce dates and events to players\n• GM-only content filtering (tag items 'gm' to hide from chat output)\n• Special days, holidays, leap days, and intercalary days support\n\nGETTING STARTED:\n1. Install the script in your campaign\n2. Run !chr to initialize (creates your main interface handout)\n3. Choose: Load a preset calendar (30 seconds) or create a custom one (30 minutes)\n4. Start adding events, notes, and tracking campaign time\nRun !chr --help anytime for detailed documentation.\n\nDESIGN MODE:\nConfigure your calendar with custom months, weeks, climate zones, holidays, and moons. The interface handles all calculations automatically—no spreadsheets or complex setup required.\n\nPERFECT FOR:\nLong-term campaigns where tracking in-game time enhances storytelling. Works with any system—D&D, Pathfinder, Vampire, etc. No coding knowledge required.",
+ "description": "Chronicle is a comprehensive calendar and timeline management system for Roll20 campaigns.\n\nWATCH A DEMO:\nhttps://youtu.be/Zb4M21NtP3Y\n\nKEY FEATURES:\n• Create custom calendars or load presets (Gregorian, Forgotten Realms, Golarion, Greyhawk, Eberron)\n• Track events and notes with a powerful tagging system for organizing and filtering\n• Calendar view with interactive grid navigation and date-by-date exploration\n• Timeline mode for chronological story review with tag and date-range filtering\n• Automatic weather generation based on Köppen climate zones\n• Multiple moons with automatic phase calculation for any date\n• Send to Chat feature to announce dates and events to players\n• GM-only content filtering (tag items 'gm' to hide from chat output)\n• Special days, holidays, leap days, and intercalary days support\n\nGETTING STARTED:\n1. Install the script in your campaign\n2. Run !chr to initialize (creates your main interface handout)\n3. Choose: Load a preset calendar (30 seconds) or create a custom one (30 minutes)\n4. Start adding events, notes, and tracking campaign time\nRun !chr --help anytime for detailed documentation.\n\nDESIGN MODE:\nConfigure your calendar with custom months, weeks, climate zones, holidays, and moons. The interface handles all calculations automatically—no spreadsheets or complex setup required.\n\nPERFECT FOR:\nLong-term campaigns where tracking in-game time enhances storytelling. Works with any system—D&D, Pathfinder, Vampire, etc. No coding knowledge required.",
"authors": "Keith Curtis",
"roll20userid": "162065",
"modifies": {
From fd223c75562b4026093d9cab8918a3267f6818ce Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sat, 30 May 2026 09:09:17 -0700
Subject: [PATCH 04/13] Update script reference to Chronicle.js
---
Chronicle/script.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Chronicle/script.json b/Chronicle/script.json
index b0d3a955b..70a930e14 100644
--- a/Chronicle/script.json
+++ b/Chronicle/script.json
@@ -1,6 +1,6 @@
{
"name": "Chronicle",
- "script": "Chronicle",
+ "script": "Chronicle.js",
"version": "1.0.0",
"description": "Chronicle is a comprehensive calendar and timeline management system for Roll20 campaigns.\n\nWATCH A DEMO:\nhttps://youtu.be/Zb4M21NtP3Y\n\nKEY FEATURES:\n• Create custom calendars or load presets (Gregorian, Forgotten Realms, Golarion, Greyhawk, Eberron)\n• Track events and notes with a powerful tagging system for organizing and filtering\n• Calendar view with interactive grid navigation and date-by-date exploration\n• Timeline mode for chronological story review with tag and date-range filtering\n• Automatic weather generation based on Köppen climate zones\n• Multiple moons with automatic phase calculation for any date\n• Send to Chat feature to announce dates and events to players\n• GM-only content filtering (tag items 'gm' to hide from chat output)\n• Special days, holidays, leap days, and intercalary days support\n\nGETTING STARTED:\n1. Install the script in your campaign\n2. Run !chr to initialize (creates your main interface handout)\n3. Choose: Load a preset calendar (30 seconds) or create a custom one (30 minutes)\n4. Start adding events, notes, and tracking campaign time\nRun !chr --help anytime for detailed documentation.\n\nDESIGN MODE:\nConfigure your calendar with custom months, weeks, climate zones, holidays, and moons. The interface handles all calculations automatically—no spreadsheets or complex setup required.\n\nPERFECT FOR:\nLong-term campaigns where tracking in-game time enhances storytelling. Works with any system—D&D, Pathfinder, Vampire, etc. No coding knowledge required.",
"authors": "Keith Curtis",
@@ -12,4 +12,4 @@
"dependencies": [],
"conflicts": [],
"previousversions": ["1.0.0"]
-}
\ No newline at end of file
+}
From 344b5fb4016f1110a61e84760477c22c0a03b629 Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sun, 31 May 2026 11:28:04 -0700
Subject: [PATCH 05/13] Fix Elapsed Time Calculation
---
Chronicle/Chronicle.js | 116 +++++++++++++++++++++++++----------------
1 file changed, 71 insertions(+), 45 deletions(-)
diff --git a/Chronicle/Chronicle.js b/Chronicle/Chronicle.js
index 5ac428a77..9e57b5c82 100644
--- a/Chronicle/Chronicle.js
+++ b/Chronicle/Chronicle.js
@@ -16,7 +16,7 @@ const Chronicle = (() => {
const lastUpdate = Math.floor(Date.now() / 1000);
const schemaVersion = 0.1;
- const DEBUG = true;
+ const DEBUG = false;
const LOGGING = false;
const HANDOUT_PREFIX = 'Chronicle';
@@ -397,11 +397,11 @@ const Chronicle = (() => {
mapped[borderKey] = override.border;
}
- // Handle individual border properties
- if (override.borderLeft) mapped['border-left'] = override.borderLeft;
- if (override.borderTop) mapped['border-top'] = override.borderTop;
- if (override.borderRight) mapped['border-right'] = override.borderRight;
- if (override.borderBottom) mapped['border-bottom'] = override.borderBottom;
+ // Handle individual border properties (kebab-case)
+ if (override['border-left']) mapped['border-left'] = override['border-left'];
+ if (override['border-top']) mapped['border-top'] = override['border-top'];
+ if (override['border-right']) mapped['border-right'] = override['border-right'];
+ if (override['border-bottom']) mapped['border-bottom'] = override['border-bottom'];
return Object.entries(mapped).map(([k, v]) => `${k}:${v}`).join('; ') + ';';
};
@@ -881,6 +881,10 @@ const Chronicle = (() => {
// Default Calendars
// ==================================================
+ // ==================================================
+ // Default Calendars
+ // ==================================================
+
const DefaultCalendars = {
gregorian: () => {
@@ -1139,6 +1143,7 @@ const Chronicle = (() => {
}
};
+
// ==================================================
// Parser
@@ -1396,19 +1401,27 @@ const Chronicle = (() => {
let dayCount = 0;
- // Add full years
- for (let y = 1; y < dateRef.year; y++) {
- dayCount += DateUtils.getDaysInYear(y, calendar);
+ // Count all complete years before the current year
+ if (dateRef.year > 0) {
+ // Positive years: count from year 1 to year-1
+ for (let y = 1; y < dateRef.year; y++) {
+ dayCount += DateUtils.getDaysInYear(y, calendar);
+ }
+ } else if (dateRef.year < 0) {
+ // Negative years: count backwards from year -1
+ for (let y = -1; y >= dateRef.year; y--) {
+ dayCount -= DateUtils.getDaysInYear(y, calendar);
+ }
}
+ // Year 0 is treated as day 0, no offset needed
- // Add full months in current year
- if (dateRef.month) {
- for (let m = 1; m < dateRef.month; m++) {
- dayCount += DateUtils.getDaysInMonth(m, dateRef.year, calendar);
- }
+ // Add complete months in current year (always add, regardless of year sign)
+ for (let m = 1; m < dateRef.month; m++) {
+ const daysInMonth = DateUtils.getDaysInMonth(m, dateRef.year, calendar);
+ dayCount += daysInMonth;
}
- // Add days in current month
+ // Add days in current month (always add, regardless of year sign)
if (dateRef.day) {
dayCount += dateRef.day;
}
@@ -1520,40 +1533,54 @@ const Chronicle = (() => {
// Calculate elapsed time between two dates
// Returns object with {years, months, days, isNegative} for display
getElapsedTime: (fromDate, toDate, calendar) => {
- const fromAbsolute = DateUtils.toAbsoluteDay(fromDate, calendar);
- const toAbsolute = DateUtils.toAbsoluteDay(toDate, calendar);
-
- let totalDays = toAbsolute - fromAbsolute;
- const isNegative = totalDays < 0;
- totalDays = Math.abs(totalDays);
-
- // For events on first day of year, just return years
- const isFirstOfYear = toDate.month === 1 && toDate.day === 1;
-
- if (isFirstOfYear && totalDays >= 365) {
- const years = Math.floor(totalDays / 365);
- return { years: years, months: 0, days: 0, isNegative: isNegative, isFirstOfYear: true };
+ if (!calendar || !fromDate || !toDate) return { years: 0, months: 0, days: 0, isNegative: false };
+
+ // Determine direction (which date is earlier)
+ let isNegative = false;
+ let start = fromDate;
+ let end = toDate;
+
+ if (toDate.year < fromDate.year ||
+ (toDate.year === fromDate.year && toDate.month < fromDate.month) ||
+ (toDate.year === fromDate.year && toDate.month === fromDate.month && toDate.day < fromDate.day)) {
+ isNegative = true;
+ start = toDate;
+ end = fromDate;
+ }
+
+ // Calculate the difference
+ let years = end.year - start.year;
+ let months = end.month - start.month;
+ let days = end.day - start.day;
+
+ // Adjust if days went negative
+ if (days < 0) {
+ months--;
+ if (start.month > 0 && start.month <= calendar.months.length) {
+ days += DateUtils.getDaysInMonth(start.month, start.year, calendar);
+ } else {
+ days += 30; // fallback
+ }
}
- // Calculate years, months, days
- let years = 0;
- let months = 0;
- let days = totalDays;
-
- // Calculate years
- const daysInYear = DateUtils.getDaysInYear(fromDate.year, calendar);
- if (days >= daysInYear) {
- years = Math.floor(days / daysInYear);
- days = days % daysInYear;
+ // Adjust if months went negative
+ if (months < 0) {
+ years--;
+ months += calendar.months.length;
}
- // Calculate months (approximate - use average of 30 days)
- if (days >= 30) {
- months = Math.floor(days / 30);
- days = days % 30;
+ // Special case: if both dates are first day of their respective years, show only years
+ if (start.day === 1 && start.month === 1 && end.day === 1 && end.month === 1) {
+ return { years: years, months: 0, days: 0, isNegative: isNegative, isFirstOfYear: true };
}
- return { years: years, months: months, days: days, isNegative: isNegative, isFirstOfYear: false };
+ return {
+ years: years,
+ months: months,
+ days: days,
+ isNegative: isNegative,
+ isFirstOfYear: false
+ };
}
};
@@ -2546,7 +2573,6 @@ const Chronicle = (() => {
html += '
';
html += `Days in Year: ${calendar.daysInYear} `;
html += Output.makeButton('Edit', `!chr --savedaysinyear ?{Days in Year|${calendar.daysInYear}}`, CSS_CURRENT.buttonSmall);
- html += ` Note: Do not include intercalery days, (those which do not receive a week day)`;
html += '
';
html += `Days in Week: ${calendar.weeks.daysInWeek} `;
html += Output.makeButton('Edit', `!chr --savedaysinweek ?{Days in Week|${calendar.weeks.daysInWeek}}`, CSS_CURRENT.buttonSmall);
@@ -6541,4 +6567,4 @@ const Chronicle = (() => {
return {
version: version
};
-})();
\ No newline at end of file
+})();
From b2f010793afa3055be192f0f6008ea2d618e2fb5 Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sun, 31 May 2026 11:28:55 -0700
Subject: [PATCH 06/13] Disable debug mode and refactor date calculations
---
Chronicle/1.0.0/Chronicle.js | 116 +++++++++++++++++++++--------------
1 file changed, 71 insertions(+), 45 deletions(-)
diff --git a/Chronicle/1.0.0/Chronicle.js b/Chronicle/1.0.0/Chronicle.js
index 5ac428a77..9e57b5c82 100644
--- a/Chronicle/1.0.0/Chronicle.js
+++ b/Chronicle/1.0.0/Chronicle.js
@@ -16,7 +16,7 @@ const Chronicle = (() => {
const lastUpdate = Math.floor(Date.now() / 1000);
const schemaVersion = 0.1;
- const DEBUG = true;
+ const DEBUG = false;
const LOGGING = false;
const HANDOUT_PREFIX = 'Chronicle';
@@ -397,11 +397,11 @@ const Chronicle = (() => {
mapped[borderKey] = override.border;
}
- // Handle individual border properties
- if (override.borderLeft) mapped['border-left'] = override.borderLeft;
- if (override.borderTop) mapped['border-top'] = override.borderTop;
- if (override.borderRight) mapped['border-right'] = override.borderRight;
- if (override.borderBottom) mapped['border-bottom'] = override.borderBottom;
+ // Handle individual border properties (kebab-case)
+ if (override['border-left']) mapped['border-left'] = override['border-left'];
+ if (override['border-top']) mapped['border-top'] = override['border-top'];
+ if (override['border-right']) mapped['border-right'] = override['border-right'];
+ if (override['border-bottom']) mapped['border-bottom'] = override['border-bottom'];
return Object.entries(mapped).map(([k, v]) => `${k}:${v}`).join('; ') + ';';
};
@@ -881,6 +881,10 @@ const Chronicle = (() => {
// Default Calendars
// ==================================================
+ // ==================================================
+ // Default Calendars
+ // ==================================================
+
const DefaultCalendars = {
gregorian: () => {
@@ -1139,6 +1143,7 @@ const Chronicle = (() => {
}
};
+
// ==================================================
// Parser
@@ -1396,19 +1401,27 @@ const Chronicle = (() => {
let dayCount = 0;
- // Add full years
- for (let y = 1; y < dateRef.year; y++) {
- dayCount += DateUtils.getDaysInYear(y, calendar);
+ // Count all complete years before the current year
+ if (dateRef.year > 0) {
+ // Positive years: count from year 1 to year-1
+ for (let y = 1; y < dateRef.year; y++) {
+ dayCount += DateUtils.getDaysInYear(y, calendar);
+ }
+ } else if (dateRef.year < 0) {
+ // Negative years: count backwards from year -1
+ for (let y = -1; y >= dateRef.year; y--) {
+ dayCount -= DateUtils.getDaysInYear(y, calendar);
+ }
}
+ // Year 0 is treated as day 0, no offset needed
- // Add full months in current year
- if (dateRef.month) {
- for (let m = 1; m < dateRef.month; m++) {
- dayCount += DateUtils.getDaysInMonth(m, dateRef.year, calendar);
- }
+ // Add complete months in current year (always add, regardless of year sign)
+ for (let m = 1; m < dateRef.month; m++) {
+ const daysInMonth = DateUtils.getDaysInMonth(m, dateRef.year, calendar);
+ dayCount += daysInMonth;
}
- // Add days in current month
+ // Add days in current month (always add, regardless of year sign)
if (dateRef.day) {
dayCount += dateRef.day;
}
@@ -1520,40 +1533,54 @@ const Chronicle = (() => {
// Calculate elapsed time between two dates
// Returns object with {years, months, days, isNegative} for display
getElapsedTime: (fromDate, toDate, calendar) => {
- const fromAbsolute = DateUtils.toAbsoluteDay(fromDate, calendar);
- const toAbsolute = DateUtils.toAbsoluteDay(toDate, calendar);
-
- let totalDays = toAbsolute - fromAbsolute;
- const isNegative = totalDays < 0;
- totalDays = Math.abs(totalDays);
-
- // For events on first day of year, just return years
- const isFirstOfYear = toDate.month === 1 && toDate.day === 1;
-
- if (isFirstOfYear && totalDays >= 365) {
- const years = Math.floor(totalDays / 365);
- return { years: years, months: 0, days: 0, isNegative: isNegative, isFirstOfYear: true };
+ if (!calendar || !fromDate || !toDate) return { years: 0, months: 0, days: 0, isNegative: false };
+
+ // Determine direction (which date is earlier)
+ let isNegative = false;
+ let start = fromDate;
+ let end = toDate;
+
+ if (toDate.year < fromDate.year ||
+ (toDate.year === fromDate.year && toDate.month < fromDate.month) ||
+ (toDate.year === fromDate.year && toDate.month === fromDate.month && toDate.day < fromDate.day)) {
+ isNegative = true;
+ start = toDate;
+ end = fromDate;
+ }
+
+ // Calculate the difference
+ let years = end.year - start.year;
+ let months = end.month - start.month;
+ let days = end.day - start.day;
+
+ // Adjust if days went negative
+ if (days < 0) {
+ months--;
+ if (start.month > 0 && start.month <= calendar.months.length) {
+ days += DateUtils.getDaysInMonth(start.month, start.year, calendar);
+ } else {
+ days += 30; // fallback
+ }
}
- // Calculate years, months, days
- let years = 0;
- let months = 0;
- let days = totalDays;
-
- // Calculate years
- const daysInYear = DateUtils.getDaysInYear(fromDate.year, calendar);
- if (days >= daysInYear) {
- years = Math.floor(days / daysInYear);
- days = days % daysInYear;
+ // Adjust if months went negative
+ if (months < 0) {
+ years--;
+ months += calendar.months.length;
}
- // Calculate months (approximate - use average of 30 days)
- if (days >= 30) {
- months = Math.floor(days / 30);
- days = days % 30;
+ // Special case: if both dates are first day of their respective years, show only years
+ if (start.day === 1 && start.month === 1 && end.day === 1 && end.month === 1) {
+ return { years: years, months: 0, days: 0, isNegative: isNegative, isFirstOfYear: true };
}
- return { years: years, months: months, days: days, isNegative: isNegative, isFirstOfYear: false };
+ return {
+ years: years,
+ months: months,
+ days: days,
+ isNegative: isNegative,
+ isFirstOfYear: false
+ };
}
};
@@ -2546,7 +2573,6 @@ const Chronicle = (() => {
html += '
';
html += `Days in Year: ${calendar.daysInYear} `;
html += Output.makeButton('Edit', `!chr --savedaysinyear ?{Days in Year|${calendar.daysInYear}}`, CSS_CURRENT.buttonSmall);
- html += ` Note: Do not include intercalery days, (those which do not receive a week day)`;
html += '
';
html += `Days in Week: ${calendar.weeks.daysInWeek} `;
html += Output.makeButton('Edit', `!chr --savedaysinweek ?{Days in Week|${calendar.weeks.daysInWeek}}`, CSS_CURRENT.buttonSmall);
@@ -6541,4 +6567,4 @@ const Chronicle = (() => {
return {
version: version
};
-})();
\ No newline at end of file
+})();
From 71a3118f52a7069d4c81dc131b6887cd4509772f Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sun, 31 May 2026 15:31:57 -0700
Subject: [PATCH 07/13] Bump version to 0.2.8 and add webp support
Updated version to 0.2.8 and added webp support.
---
Supernotes/Supernotes.js | 17 ++++++-----------
1 file changed, 6 insertions(+), 11 deletions(-)
diff --git a/Supernotes/Supernotes.js b/Supernotes/Supernotes.js
index 5fe3c5100..b02bb5696 100644
--- a/Supernotes/Supernotes.js
+++ b/Supernotes/Supernotes.js
@@ -1,7 +1,3 @@
-var API_Meta = API_Meta||{};
-API_Meta.Supernotes={offset:Number.MAX_SAFE_INTEGER,lineCount:-1};
-{try{throw new Error('');}catch(e){API_Meta.Supernotes.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-3);}}
-
// Supernotes_Templates can be called by other scripts. At this point ScriptCards is the only One Click script that does this.
let Supernotes_Templates = {
@@ -740,7 +736,7 @@ Re-Run Configuration
}
function cleanText(text,buttonStyle){
- text = ((undefined !== text) ? text.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1").replace(//gm, "").replace(/<\/p>/gm, "
").replace("padding:5px'>
", "padding:5px'>") : "");
+ text = ((undefined !== text) ? text.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1").replace(/
/gm, "").replace(/<\/p>/gm, "
").replace("padding:5px'>
", "padding:5px'>") : "");
text = text.replace('
@@ -758,9 +754,10 @@ return text;
const decodeUnicode = (str) => str.replace(/%u[0-9a-fA-F]{2,4}/g, (m) => String.fromCharCode(parseInt(m.slice(2), 16)));
- const version = '0.2.7';
+ const version = '0.2.8';
log('Supernotes v' + version + ' is ready! --offset ' + API_Meta.Supernotes.offset + 'To set the template of choice or to toggle the send to players option, Use the command !gmnote --config');
-//Changelong
+//Changelog
+// 0.2.8 Added webp support
// 0.2.7 Added Templates for 2024 sheet, Dark and Light
// 0.2.6 Reworked and updated Help system to use handout. Fixed logic issue Card output.
// 0.2.5 fixed trailing space problem in command line, fixed linebreak issue.
@@ -990,8 +987,8 @@ whisper= whisper.replace(/<\/span>
/i,"")
} else {
- playerButton = ((undefined !== playerButton) ? playerButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1") : "");
- handoutButton = ((undefined !== handoutButton) ? handoutButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1") : "");
+ playerButton = ((undefined !== playerButton) ? playerButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1") : "");
+ handoutButton = ((undefined !== handoutButton) ? handoutButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1") : "");
whisper = ((whisper.length>0) ? "
" + whisper + "
" : "");
//log ("whisper = " + whisper);
return sendChat(whom, messagePrefix + '&{template:' + template + '}{{' + title + '=' + whom + '}} {{' + theText + '=' + message + whisper + playerButton + handoutButton + '}}');
@@ -1542,5 +1539,3 @@ if (option === 'card') {
}
});
});
-
-{ try { throw new Error(''); } catch (e) { API_Meta.Supernotes.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Supernotes.offset); } }
From df5dbae70ab96d90f101ab409494e3dc5c646f9c Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sun, 31 May 2026 15:34:51 -0700
Subject: [PATCH 08/13] Fix log message formatting for Supernotes version
Removed space in log message for version readiness.
---
Supernotes/Supernotes.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Supernotes/Supernotes.js b/Supernotes/Supernotes.js
index b02bb5696..bbc4273fa 100644
--- a/Supernotes/Supernotes.js
+++ b/Supernotes/Supernotes.js
@@ -755,7 +755,7 @@ return text;
const decodeUnicode = (str) => str.replace(/%u[0-9a-fA-F]{2,4}/g, (m) => String.fromCharCode(parseInt(m.slice(2), 16)));
const version = '0.2.8';
- log('Supernotes v' + version + ' is ready! --offset ' + API_Meta.Supernotes.offset + 'To set the template of choice or to toggle the send to players option, Use the command !gmnote --config');
+ log('Supernotes v' + version + ' is ready!To set the template of choice or to toggle the send to players option, Use the command !gmnote --config');
//Changelog
// 0.2.8 Added webp support
// 0.2.7 Added Templates for 2024 sheet, Dark and Light
From 44c0ee724261f0de2693273ee45c6b447108f343 Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sun, 31 May 2026 15:35:33 -0700
Subject: [PATCH 09/13] New version
---
Supernotes/0.2.8/Supernotes.js | 1541 ++++++++++++++++++++++++++++++++
1 file changed, 1541 insertions(+)
create mode 100644 Supernotes/0.2.8/Supernotes.js
diff --git a/Supernotes/0.2.8/Supernotes.js b/Supernotes/0.2.8/Supernotes.js
new file mode 100644
index 000000000..bbc4273fa
--- /dev/null
+++ b/Supernotes/0.2.8/Supernotes.js
@@ -0,0 +1,1541 @@
+
+// Supernotes_Templates can be called by other scripts. At this point ScriptCards is the only One Click script that does this.
+let Supernotes_Templates = {
+ generic: {
+ boxcode: ``,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#ce0f69 !important; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#ce0f69; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: ' | ',
+ handoutbuttonstyle: `style='display:inline-block; color:#ce0f69; background-color: transparent;padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ dark: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#a980bd; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#a980bd; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: ' | ',
+ handoutbuttonstyle: `style='display:inline-block; color:#a980bd; background-color: transparent;padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ dark55: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#e16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`,
+ buttondivider: ' | ',
+ handoutbuttonstyle: `style='display:inline-block; color:#e16363; font-weight:bolder; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`,
+ whisperStyle: `'background-color:#none; color:#ccc; display:block; padding:5px; margin-top:20px; border-top: 1px solid #d72f2f; font-weight:normal;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#ccc; font-weight:bold; background-color: transparent;padding: 0px; border: none;`,
+ footer: ""
+ },
+
+ light55: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`,
+ buttondivider: ' | ',
+ handoutbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif;'`,
+ whisperStyle: `'background-color:#F1ECE6; color:#292218; display:block; padding:5px; margin-top:20px; border-top: 1px solid #8E5620; font-weight:normal;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#E16363; font-weight:bold; background-color: transparent;padding: 0px; border: none;`,
+ footer: ""
+ },
+
+ roll20dark: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#a980bd; font-weight:bold; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif; ;'`,
+ buttondivider: '',
+ handoutbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none;font-family:"nunito black", nunito;'`,
+ whisperStyle: `'background-color:#f9cce7; color:#111; display:block; padding:5px; margin-top:20px;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#702c91; font-weight:bold; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ roll20light: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#702c91; font-weight:bold; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"proxima nova", sans-serif; ;'`,
+ buttondivider: '',
+ handoutbuttonstyle: `style='display:inline-block; color:#fff; font-weight:bolder; background-color: #e7339d;border-radius: 4px; margin:4px; padding: 2px 6px 2px 6px; border: none; font-family:"Nunito Black", nunito;'`,
+ whisperStyle: `'background-color:#f9cce7; color:#111; display:block; padding:5px; margin-top:20px;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#702c91; font-weight:bold; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+
+ lcars: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#cc6060; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; border:none; color:black; background-color: #cc6060; border-radius: 10px 0px 0px 10px; padding: 2px 4px 2px 4px;margin-top: 12px; font-size: 10px; font-family: Tahoma, sans-serif; font-stretch: condensed !important; text-transform: uppercase;'`,
+ buttondivider: '',
+ handoutbuttonstyle: `style='display:inline-block; border:none; color:black; background-color: #cc6060; border-radius: 0px 10px 10px 0px; padding: 2px 4px 2px 4px;margin-top: 12px; margin-left:4px; font-size: 10px; font-family: Tahoma, sans-serif; font-stretch: condensed !important; text-transform: uppercase;'`,
+ whisperStyle: `'border-radius: 10px 0px 0px 10px; color:#ffae21; border-color: #ffae21; display:block; border-width: 0px 0px 5px 15px; border-style: solid; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#cc6060; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ faraway: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#13f2fc; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#13f2fc; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: `
• `,
+ handoutbuttonstyle: `style='display:inline-block; color:#13f2fc; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:transparent; color:#feda4a; display:block; border-width: 8px; border-style: solid; border-radius:5px; border-color:#feda4a; padding:15px; margin-top:10px;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#13f2fc; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ strange: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#ff1515; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#ff1515; font-family: "Goblin One"; font-weight:normal; font-size: 10px; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: `
• `,
+ handoutbuttonstyle: `style='display:inline-block; color:#ff1515; font-family: "Goblin One"; font-weight:normal; font-size: 10px; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:##4f0606; color:#ff1515; display:block; border: 1px solid #000; box-shadow: 0 0 5px #ff1515; padding:5px; margin-top:10px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ gothic: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `

`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#ccc; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#ccc; font-size:12px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: `

`,
+ handoutbuttonstyle: `style='display:inline-block; color:#ccc; font-size:12px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:#2b2130; color:#ddd; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#aaa; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ western: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `

`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#000; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: `

`,
+ handoutbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-color:#382d1d; color:#ebcfa9; font-style: italic; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px; margin-top:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#fabe69; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ dragon: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#0e3365; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color: #0e3365; font-size:14px; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: " • ", //`

`,
+ handoutbuttonstyle: `style='display:inline-block; color: #0e3365; font-size:14px; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'display:block; border-width: 5px 0px 5px 0px; border-style: solid; border-color:#58170D; padding:5px; margin-top:9px;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#0e3365; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+
+
+ wizard: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color: #000; font-size:12px; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: " • ", //`

`,
+ handoutbuttonstyle: `style='display:inline-block; color: #000; font-size:12px; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-color:#E0E5C1; color:#000; display:block; border-width: 1px; border-width: 1px 0px 1px 0px; border-style: solid; border-color:#58170D; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+path: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#5e0000; font-weight:bold; background-color: transparent; padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color: #eee; font-size:12px; background-color: #5e0000; padding: 0px 4px 0px 4px; border-style:solid; border-width: 2px 4px 2px 4px; border-color: #d9c484; text-transformation: all-caps; font-family: "gin", impact, "Arial Bold Condensed", sans-serif;'`,
+ buttondivider: " ", //`

`,
+ handoutbuttonstyle: `style='display:inline-block; color: #eee; font-size:12px; background-color: #5e0000; padding: 0px 4px 0px 4px; border-style:solid; border-width: 2px 4px 2px 4px; border-color: #d9c484; text-transformation: all-caps; font-family: "gin", impact, "Arial Bold Condensed", sans-serif;'`,
+ whisperStyle: `'background-color:#dbd1bc; color:#000; display:block; border-width: 1px; margin-top:15px; padding:5px; font-size: 15px; font-family: "Good OT", arial, sans-serif;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent; font-weight:bold; padding: 0px; border: none'`,
+ footer: ""
+},
+
+apoc: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#555; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#000; font-size:14px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: " / ",
+ handoutbuttonstyle: `style='display:inline-block; color:#000; font-size:14px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:#403f3d; color:#ddd; display:block; padding:5px !important; margin:5px; font-family: "Shadows Into Light", Monaco,"Courier New", monospace !important; '`,
+ whisperbuttonstyle: `style='display:inline-block; color:#bbb; background-color: transparent;padding: 0px; border: none'`,
+ footer: `

`
+ },
+
+ roman: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#7c6f39; font-weight: bold; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#000; font-size:12px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: " | ",
+ handoutbuttonstyle: `style='display:inline-block; color:#000; font-size:12px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'background-image: url(https://files.d20.io/images/459209597/cdZeKGAy2_NKcU1Wjkjeew/original.jpg); background-repeat: no-repeat; background-size: 100% 100%; background-color:#403f3d; color:#ddd; display:block; padding:8px !important; margin:5px 0px; text-shadow: none; line-height:16px;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#bbaa55; font-weight: bolder !important; background-color: transparent;padding: 0px; border: none'`,
+ footer: `

`
+ },
+
+ notebook: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color: red; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:red; font-size:10px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: `
/`,
+ handoutbuttonstyle: `style='display:inline-block; color:red; font-size:10px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'color:red; display:block; padding-top:7px; font-family: "Patrick Hand", Monaco,"Courier New", monospace; line-height: 16px;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#333; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ steam: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#056b20; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#056b20; font-size:12px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: `

`,
+ handoutbuttonstyle: `style='display:inline-block; color:#056b20; font-size:12px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#fff; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ treasure: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#8a4100; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#634401; font-size:14px; font-weight:normal; background-color: transparent;padding: 0px; border: none;'`,
+ buttondivider: `

`,
+ handoutbuttonstyle: `style='display:inline-block; color:#401e00; font-size:14px; font-weight:normal; background-color: transparent; padding: 0px; border: none;'`,
+ whisperStyle: `'background-color:#401e00; color:#eee; font-family: Tahoma, serif; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; margin-top:10px;padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#e3b76f; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+choices: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#eee; hover: yellow; background-color: transparent;padding: 0px; border: none; '`,
+ playerbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: " ◼ ", //`

`,
+ handoutbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-image: linear-gradient(to bottom,#4b443d,#3f3732,#4b443d); background-color: transparent; color:#f8e8a6; display:block; border-width: 1px; border: 1px solid #4f4841; margin: 20px, -12px, 15px, -12px; padding:10px, 10px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#eee; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+},
+gate3: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#eada8d; background-color: transparent;padding: 0px; border: none; '`,
+ playerbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: " ◼ ", //`

`,
+ handoutbuttonstyle: `style='display:inline-block; color: #eee; font-size:16px; font-family: "Minion", "Minion Pro", serif; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-image: linear-gradient(to bottom,#4b443d,#3f3732,#4b443d); background-color: transparent; color:#f8e8a6; display:block; border-width: 1px; border: 1px solid #4f4841; margin: 20px, -12px, 15px, -12px; padding:10px, 10px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#eee; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+},
+
+
+ crt: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: "
",
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#fff; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block;font-weight:bold; color:white; background-color: transparent;padding: 0px; border: none;font-size: 12px'`,
+ buttondivider: '|',
+ handoutbuttonstyle: `style='display:inline-block;font-weight:bold; color:white; background-color: transparent;padding: 0px; border: none;font-size: 12px'`,
+ whisperStyle: `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#fff; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ news: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#222; text-decoration:underline; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block;float:right; margin-top:5px; font-weight:bold; color:#444; background-color: transparent;padding: 0px; border: none;font-size: 12px'`,
+ buttondivider: ' ',
+ handoutbuttonstyle: `style='display:inline-block;float:left; margin-top:5px; font-weight:bold; color:#444; background-color: transparent;padding: 0px; border: none;font-size: 12px'`,
+ whisperStyle: `'background-color: rgba(0, 0, 0, 0.1); color:#444; font-size: 14px;font-family: arial, helvetica, sans-serif; padding:8px; display:block; border: 1px solid #444;'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#444; text-decoration:underline; background-color: transparent; padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ scroll: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: ' | ',
+ handoutbuttonstyle: `style='display:inline-block; color:#7e2d40; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-color:#58170d; color:#d9bf93; display:block; padding:5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#fce5bb; background-color: transparent;padding: 0px; border: none'`,
+ footer: ""
+ },
+
+ scroll2: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#58170D; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#58170D; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: ' | ',
+ handoutbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#58170D; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-color:#241605; color:#eee; box-shadow: 0px 0px 5px 5px #241605; display:block; border-radius:15px; padding:5px; margin: 15px 5px 10px 5px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#fcdd6d; background-color: transparent;padding: 0px; border: none'`,
+ footer: `

`
+ },
+
+ vault: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block; color:#111; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; font-size: 15px !important; color:#fef265; text-shadow: 2px 2px 2px #111; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: `

`,
+ handoutbuttonstyle: `style='display:inline-block; font-size: 15px !important; color:#fef265; text-shadow: 2px 2px 2px #111;background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-color: #transparent; background-image: url(https://files.d20.io/images/459209469/UA2E7Vyf-kncA8k1jUuyAg/original.png; color:#111; display:block; text-shadow: none; text-align:center; font-family: "Contrail One"; border-radius:3px; padding:5px; margin: 15px -20px 10px -20px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#284a73; background-color: transparent;padding: 0px; border: none'`,
+ footer: ``
+ },
+
+ osrblue: {
+ boxcode: `
`,
+ titlecode: `
`,
+ textcode: `
`,
+ buttonwrapper: `
`,
+ buttonstyle: `style='display:inline-block !important; color:#333; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`,
+ playerbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#333; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`,
+ buttondivider: `|`,
+ handoutbuttonstyle: `style='display:inline-block; font-size: 14px !important; color:#333; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`,
+ whisperStyle: `'background-color: #729aa5; color:#eee; display:block; text-align:center; font-family: "Arial"; padding:5px; margin: 15px -20px 10px -20px'`,
+ whisperbuttonstyle: `style='display:inline-block; color:#eee; text-decoration: underline; background-color: transparent;padding: 0px; border: none'`,
+ footer: ``
+ }
+
+};
+
+on('ready', function() {
+ if (!_.has(state, 'Supernotes')) {
+ state.Supernotes = {
+ sheet: 'Default',
+ template: 'default',
+ title: 'name',
+ theText: '',
+ sendToPlayers: true,
+ makeHandout: true,
+ darkMode: false
+ };
+ message = 'Welcome to Supernotes! If this is your first time running it, the script is set to use the Default Roll Template. You can choose a different sheet template below, as well as decide whether you want the script to display a "Send to Players" footer at the end of every GM message. It is currently set to true.
[Default Template - any sheet](!gmnote --config|default)
[D&D 5th Edition by Roll20](!gmnote --config|dnd5e)
[DnD 5e Shaped](!gmnote --config|5eshaped)
[Pathfinder by Roll20](!gmnote --config|pfofficial)
[Pathfinder Community](!gmnote --config|pfcommunity)
[Pathfinder 2e by Roll20](!gmnote --config|pf2e)
[Starfinder by Roll20](!gmnote --config|starfinder)
[Call of Cthulhu 7th Edition by Roll20](!gmnote --config|callofcthulhu)
[Toggle Send to Players](!gmnote --config|sendtoPlayers)';
+ sendChat('Supernotes', '/w gm &{template:' + state.Supernotes.template + '}{{' + state.Supernotes.title + '=' + 'Config' + '}} {{' + state.Supernotes.theText + '=' + message + '}}');
+ }
+});
+
+on('ready', () => {
+
+
+ /* =========================================================
+ * Supernotes Help Handout Builder
+ * ========================================================= */
+
+const buildSupernotesHelp = () => {
+
+ const HANDOUT_NAME = "Help: Supernotes";
+ const HANDOUT_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; // change if desired
+
+const helpHtml = `
+
Supernotes
+
Documentation for v.${version}
+
+
+
+
Overview
+
+
+Supernotes pulls content from a token’s GM Notes field and from other character fields not normally accessible to macros.
+If a token represents a character, you may retrieve:
+
+
+
+- Character GM Notes
+- Character Bio
+- Character Avatar
+- Bio images (single, indexed, or all)
+- Token tooltip
+- Token image
+
+
+
+Notes may be whispered to the GM, sent to all players, whispered to the sender, or written directly to a named handout.
+A footer button may optionally appear on GM whispers, allowing the note to be forwarded to players.
+
+
+
+Images, API command buttons, links, markdown image syntax [x](imageURL), and most special characters pass through correctly in both chat and handouts.
+
+
+
+
Special Control Character for Inline GMnotes
+
-----
+
Five dashes placed in the gmnotes of a token indicate that any following content is trested as gm-only text when sent to chat.
+
+
+
+
+
Commands
+
+
!gmnote
+Whispers note to GM.
+
+
!pcnote
+Sends note to all players.
+
+
!selfnote
+Whispers note to the command sender.
+
+
+
+
Parameters
+
+
Sources
+
+- --token
+Pull from selected token GM Notes (default). Token does not require a character.
+
+- --charnote
+Pull from represented character GM Notes.
+
+- --bio
+Pull from character Bio field.
+
+- --avatar
+Return character Avatar image.
+
+- --image
+Return first Bio image.
+
+- --images
+Return all Bio images.
+
+- --image[number]
+Return indexed Bio image (e.g. --image1, --image2).
+
+- --tooltip
+Return selected token tooltip.
+
+- --tokenimage
+Return selected token image.
+
+- --card
+Return token image and gmnotes in one report.
+
+
+
+
Options
+
+
+- --notitle
+Suppress title in chat output. May be added to any command in any order.
+
+- --idTOKENID
+Read notes from specific token ID. No space after --id. Example:
!gmnote --id-1234567890abcdef
+
+- --handout|Handout Name|
+Send output to named handout instead of chat.
+Creates the handout if it does not exist.
+Content above the automatic horizontal rule remains persistent.
+
+
+- --help
+Displays help.
+
+- --config
+Opens configuration dialog.
+
+
+
+
+
Examples
+
+
!pcnote --bio
+
Sends selected character Bio to all players.
+
+
!gmnote --charnote
+
Whispers character GM Notes to GM.
+
+
!pcnote --image --notitle
+
Sends first image without revealing title.
+
+
+
+
Templates
+
+
+Add a template using:
+
+
+
--template|templatename
+
+
+Example:
+
+
+
!gmnote --template|crt
+!pcnote --template|notebook --bio
+!pcnote --template|faraway --tokenimage
+
+
+All templates include inline buttons and support Send to Players and Make Handout.
+Handouts use Roll20’s native styling for cross-platform reliability.
+
+
+
+
+
Available Templates
+
+
+
+| generic Just the facts, Ma'am. Nothing fancy here.  |
+dark As previous, but in reverse.  |
+crt Retro greenscreen for hacking and cyberpunk. Or for reports on that xenomorph hiding on your ship.  |
+
+
+
+| notebook You know, for kids. Who like to ride bikes. Maybe they attend a school and solve mysteries.  |
+gothic Classic noire horror for contending with Universal monsters or maybe contending with elder gods.  |
+apoc Messages scrawled on a wall. Crumbling and ancient, like the world that was.  |
+
+
+
+| scroll High fantasy. Or low fantasy—we don't judge.  |
+scroll2 An alternative to scroll, thats even scrollier.  |
+lcars For opening hailing frequencies and to boldly split infinitives that no one has split before!  |
+
+
+
+| faraway No animated title crawl, but still has that space wizard feel.  |
+steam Gears and brass have changed my life.  |
+western Return with us now to those thrilling days of yesteryear.  |
+
+
+
+| dragon Three-fivey style  |
+wizard A fifth edition of templates.  |
+strange Other kids who ride bikes and play D&D.  |
+
+
+
+| gate3 For folks who like the GOTY based on D&D.  |
+choices A second gate-y style, suitable for for the same crowd.  |
+roll20light for when you want your notes to have the feeling of authority  |
+
+
+
+| roll20dark As before, but.... dark  |
+news Extra! Extra! Read all about it! The ink bleeds through from the other side of the newsprint.  |
+treasure For listing all that loot.  |
+
+
+
+| vault A comforting style for sheltered people.  |
+path A style that works well with PF2 Adventure Paths  |
+osrblue Gygax-approved. Maybe. The graph paper even has yellowed edges  |
+
+
+
+| roman Hail Caesar!  |
+dark55 A style to complement the D&D 5.5e (2024) Sheet dark mode  |
+light55 A style to complement the D&D 5.5e (2024) Sheet light mode  |
+
+
+
+
+
+
Configuration
+
+
+On installation, Supernotes defaults to the Default roll template.
+The configuration dialog allows you to:
+
+
+
+- Select a sheet roll template
+- Toggle the “Send to Players” footer button
+
+
+
+Supported sheet templates include:
+
+
+
+- Default Template
+- D&D 5th Edition by Roll20
+- 5e Shaped
+- Pathfinder by Roll20
+- Pathfinder Community
+- Pathfinder 2e by Roll20
+- Starfinder
+
+
+
+
+
Troubleshooting
+
+
+If you experience template issues or configuration problems, you may use the buttons below to restore default behavior or re-open the configuration dialog.
+
+
+
+
+
+Restore Default Template resets Supernotes to the Default roll template.
+Re-Run Configuration opens the configuration dialog to select a sheet template and toggle footer options.
+
+
+`;
+
+
+ // Find existing handout
+ let handout = findObjs({
+ _type: "handout",
+ name: HANDOUT_NAME
+ })[0];
+
+ // Create if missing
+ if (!handout) {
+ handout = createObj("handout", {
+ name: HANDOUT_NAME,
+ archived: false
+ });
+ }
+
+ // Always overwrite content + avatar
+ handout.set({
+ notes: helpHtml,
+ avatar: HANDOUT_AVATAR
+ });
+
+ const link = `http://journal.roll20.net/handout/${handout.get("_id")}`;
+
+ const box =
+ `
`;
+
+ sendChat("Supernotes", `/w gm ${box}`, null, { noarchive: true });
+};
+
+
+ function parseMarkdown(markdownText) {
+ const htmlText = markdownText
+ .replace(/^### (.*$)/gim, '
$1
')
+ .replace(/^## (.*$)/gim, '
$1
')
+ .replace(/^# (.*$)/gim, '
$1
')
+ .replace(/^\> (.*$)/gim, '
$1
')
+ .replace(/\*\*(.*)\*\*/gim, '
$1')
+ .replace(/\*(.*)\*/gim, '
$1')
+ .replace(/!\[(.*?)\]\((.*?)\)/gim, "

")
+ .replace(/\[(.*?)\]\((.*?)\)/gim, "
$1")
+ .replace(/\n$/gim, '
')
+
+ return htmlText.trim()
+ }
+
+function cleanText(text,buttonStyle){
+ text = ((undefined !== text) ? text.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1").replace(/
/gm, "").replace(/<\/p>/gm, "
").replace("padding:5px'>
", "padding:5px'>") : "");
+ text = text.replace('
+ .replace(/\r?\n+/g, "
")
+ // Normalize mixed
,
,
variations to
+ .replace(/<\s*br\s*\/?\s*>/gi, "
")
+ // Remove accidental duplicate
etc
+ .replace(/(
\s*){2,}/g, "
")
+ .trim();
+
+return text;
+}
+
+
+
+ const decodeUnicode = (str) => str.replace(/%u[0-9a-fA-F]{2,4}/g, (m) => String.fromCharCode(parseInt(m.slice(2), 16)));
+
+ const version = '0.2.8';
+ log('Supernotes v' + version + ' is ready!To set the template of choice or to toggle the send to players option, Use the command !gmnote --config');
+//Changelog
+// 0.2.8 Added webp support
+// 0.2.7 Added Templates for 2024 sheet, Dark and Light
+// 0.2.6 Reworked and updated Help system to use handout. Fixed logic issue Card output.
+// 0.2.5 fixed trailing space problem in command line, fixed linebreak issue.
+
+
+
+
+ on('chat:message', function(msg) {
+ if ('api' === msg.type && msg.content.match(/^!(gm|pc|self)note\b/)) {
+ let match = msg.content.match(/^!gmnote-(.*)$/);
+let selectedObject = msg.selected;
+
+//################## EXPERIMENTAL TO GET TOKEN ID FROM SUPPLIED VALUE
+if(msg.content.includes("--token|")){
+ virtualTokenID = msg.content.split(/--token\|/)[1].split(/\s/)[0];
+sendChat ("notes","success. Virtual token id is " + virtualTokenID);
+ if (virtualTokenID.length !== 20 && virtualTokenID.charAt(0) !== "-"){
+ sendChat ("notes","this is not a token id :" + virtualTokenID);
+ sendChat ("notes","player page id :" + Campaign().get("playerpageid"));
+
+ selectedObject = findObjs({
+ _type: "graphic",
+ _id: virtualTokenID,
+ });
+ log ("selectedObject is " + selectedObject);
+ // selectedObject = theToken[0];
+ }
+ if (selectedObject){
+ sendChat ("notes", "number of 'selected' objects is " +selectedObject.length);
+ } else{
+ sendChat ("notes", "no passed value");
+ }
+//sendChat ("notes","virtual ID is " + selectedObject[0].get("_id"));
+}
+//################## EXPERIMENTAL TO GET TOKEN ID FROM SUPPLIED VALUE
+
+
+
+
+
+
+
+ //define command
+ let command = msg.content.split(/\s+--/)[0];
+ let sender = msg.who;
+ let senderID = msg.playerid;
+
+ let isGM = playerIsGM(senderID);
+ let messagePrefix = '/w gm ';
+ if (command === '!pcnote') {
+ messagePrefix = '';
+ }
+
+ if (command === '!selfnote') {
+ messagePrefix = '/w ' + sender + ' ';
+ }
+
+ let secondOption = '';
+ let args = msg.content.trim().split(/\s+--/);
+
+ let customTemplate = '';
+ let option = '';
+ let notitle = false;
+ let id = '';
+ let tokenImage = '';
+ let tooltip = '';
+ let tokenName = '';
+ let trueToken = [];
+ let tokenID = '';
+ let handoutTitle = '';
+ let whisper = '';
+
+ let templates = Supernotes_Templates;
+
+
+
+
+ function sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton) {
+ handoutButton = ((handoutButton) ? handoutButton.replace(/NamePlaceholder/, whom) : handoutButton);
+
+ if (message === "" && option.match(/^(bio|charnote|token|tooltip)/)) {
+ message = `The information does not exist for the ${option} option`
+ }
+
+ if (handoutTitle === '') {
+ //Crops out GM info on player messages
+ if (isGM) {
+ //message = (message.includes("-----") ? message.split('-----')[0] + "" + message.split('-----')[1] + "
" : message);
+ whisper = (message.includes("-----") ? message.split('-----')[1] : "");
+ message = (message.includes("-----") ? message.split('-----')[0] : message);
+
+ }
+
+ if (customTemplate.length > 0) {
+ let chosenTemplate = templates.generic;
+ switch (customTemplate) {
+ case "crt":
+ chosenTemplate = templates.crt;
+ break;
+ case "dark":
+ chosenTemplate = templates.dark;
+ break;
+ case "roll20light":
+ chosenTemplate = templates.roll20light;
+ break;
+ case "roll20dark":
+ chosenTemplate = templates.roll20dark;
+ break;
+ case "scroll":
+ chosenTemplate = templates.scroll;
+ break;
+ case "scroll2":
+ chosenTemplate = templates.scroll2;
+ break;
+ case "vault":
+ chosenTemplate = templates.vault;
+ break;
+ case "osrblue":
+ chosenTemplate = templates.osrblue;
+ break;
+ case "lcars":
+ chosenTemplate = templates.lcars;
+ break;
+ case "faraway":
+ chosenTemplate = templates.faraway;
+ break;
+ case "strange":
+ chosenTemplate = templates.strange;
+ break;
+ case "gothic":
+ chosenTemplate = templates.gothic;
+ break;
+ case "western":
+ chosenTemplate = templates.western;
+ break;
+ case "dragon":
+ chosenTemplate = templates.dragon;
+ break;
+ case "wizard":
+ chosenTemplate = templates.wizard;
+ break;
+ case "path":
+ chosenTemplate = templates.path;
+ break;
+ case "treasure":
+ chosenTemplate = templates.treasure;
+ break;
+ case "steam":
+ chosenTemplate = templates.steam;
+ break;
+ case "gate3":
+ chosenTemplate = templates.gate3;
+ break;
+ case "choices":
+ chosenTemplate = templates.choices;
+ break;
+ case "apoc":
+ chosenTemplate = templates.apoc;
+ break;
+ case "news":
+ chosenTemplate = templates.news;
+ break;
+ case "roman":
+ chosenTemplate = templates.roman;
+ break;
+ case "notebook":
+ chosenTemplate = templates.notebook;
+ break;
+ case "dark55":
+ chosenTemplate = templates.dark55;
+ break;
+ case "light55":
+ chosenTemplate = templates.light55;
+ break;
+ case "bob":
+ break;
+ default:
+ chosenTemplate = templates.generic;
+ // code block
+ }
+
+
+
+
+ playerButton = playerButton.split('\n')[1];
+
+ playerButton = ((undefined !== playerButton) ? playerButton.replace(/\[(.*?)\]\((.*?)\)/gim, "$1") : "");
+ handoutButton = ((undefined !== handoutButton) ? handoutButton.replace(/\[(.*?)\]\((.*?)\)/gim, "
$1").replace(" |
0) ? "" + whisper + "
" : "");
+
+
+message = cleanText(message,chosenTemplate.buttonstyle);
+//the following lines attempt to account for numerous Roll20 CSS and HTML oddities.
+whisper = cleanText(whisper,chosenTemplate.whisperbuttonstyle);
+whisper= whisper.replace(/<\/span>
/i,"")
+.replace(/
/i,'')
+.replace(/
/i,'
')
+.replace(/(
|<\/p>)/,'')
+.replace(/>
/i,'>');
+
+
+
+
+
+
+// message = ((undefined !== message) ? message.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1
").replace(/
/gm, "").replace(/<\/p>/gm, "
").replace("padding:5px'>
' + chosenTemplate.footer + '
');
+ }
+
+
+
+ } else {
+ playerButton = ((undefined !== playerButton) ? playerButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1") : "");
+ handoutButton = ((undefined !== handoutButton) ? handoutButton.replace(/\[([^\]]*?)\]\(([^\)]*?)\)(?$1") : "");
+whisper = ((whisper.length>0) ? "
" + whisper + "
" : "");
+//log ("whisper = " + whisper);
+ return sendChat(whom, messagePrefix + '&{template:' + template + '}{{' + title + '=' + whom + '}} {{' + theText + '=' + message + whisper + playerButton + handoutButton + '}}');
+ }
+
+ } else {
+ let noteHandout = findObjs({
+ type: 'handout',
+ name: handoutTitle
+ });
+ noteHandout = noteHandout ? noteHandout[0] : undefined;
+
+ if (!noteHandout) {
+ noteHandout = createObj('handout', {
+ name: handoutTitle,
+ archived: false,
+ inplayerjournals: "",
+ controlledby: ""
+ });
+ let noteHandoutid = noteHandout.get("_id");
+ sendChat('Supernotes', `/w gm Supernotes has created a handout named
${handoutTitle}.
Click
here to open.`, null, {
+ noarchive: true
+ });
+ }
+ if (noteHandout) {
+
+ playerButton = '
Send to Players in Chat';
+ if (makeHandout) {
+ handoutButton = ((playerButton) ? ' | ' : '
') + '
Make Handout';
+ }
+ message = message.replace(/\[.*?\]\((.*?\.(jpg|jpeg|png|gif))\)/g, `

`);
+ message = message.replace(/\[(.*?)\]\((.*?)\)/g, '
$1');
+ message = message.replace(/
![]()
/g, `
![]()
\(\d*\)/)) {
+ let reportCount= notes.match(/(?<=
\()\d+/);;
+//log ("reportCount = " + reportCount);
+
+let newHeight = reportCount * 20;
+if (newHeight > 500){newHeight = 500};
+if (newHeight < 200){newHeight = 200};
+//log ("newHeight = " + newHeight);
+message = message.replace(/201px/,newHeight+'px');
+
+ }
+//##############TEST FOR VARIABLE IMAGE HEIGHT BASED ON HEIGHT OF REPORT###################################################
+
+
+ if (notes.includes('')) {
+ if (notes.includes('!report')) {
+ notes = notes.split('')[0] + '';
+ } else {
+ notes = notes.split(/
/i)[0] + '';
+ }
+ } else {
+ playerButton = '';
+ handoutButton = '';
+ notes = ''; //';
+ }
+ /*if (notes.includes(' ')) {
+ notes = notes.split(' ')[0] + ' '
+ } else {
+ notes = ' '
+ }*/
+ //message = '' + message +'
';
+
+ noteHandout.set("gmnotes", gmnote);
+ noteHandout.set("notes", notes + "" + whom + "
" + message + playerButton + handoutButton)
+ //THIS NEEDS A TOGGLE
+ //if(!tokenImage.includes("marketplace")){noteHandout.set("avatar", tokenImage+"?12345678")}
+ })
+ } else {
+ sendChat('Supernotes', whom + `No handout named ${handoutTitle} was found.`, null, {
+ noarchive: true
+ }, )
+ }
+
+ }
+
+ }
+
+ let theToken = selectedObject;
+
+ args.forEach(a => {
+ if (a === 'notitle') {
+ notitle = true
+ }
+ if (a.includes('id-')) {
+ id = a.split(/id/)[1]
+ }
+ if (a.match(/handout\|.*?\|/)) {
+ handoutTitle = a.match(/handout\|.*?\|/).toString().split('|')[1]
+ }
+ if (a !== command && !(a.includes('id-')) && !(a.includes('handout|')) && a !== 'notitle') {
+ option = a
+ }
+ if (a.includes('template|')) {
+ customTemplate = a.split(/\|/)[1]
+ }
+
+ });
+
+ ((id) ? theToken = [{
+ "_id": id,
+ "type": "graphic"
+ }] : theToken = selectedObject);
+
+
+ if (undefined !== theToken) {
+ trueToken = getObj('graphic', theToken[0]._id);
+ tokenImage = trueToken.get('imgsrc');
+ tokenTooltip = trueToken.get('tooltip');
+ tokenName = trueToken.get('name');
+ tokenID = trueToken.get('_id');
+ }
+
+
+
+ const template = state.Supernotes.template;
+ const title = state.Supernotes.title;
+ const theText = state.Supernotes.theText;
+ const sendToPlayers = state.Supernotes.sendToPlayers;
+ const makeHandout = state.Supernotes.makeHandout || false;
+ const darkMode = state.Supernotes.darkMode || false;
+ const whisperStyle = ((darkMode) ? `'background-color:#2b2130; color:#fbfcf0; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'` : `'background-color:#fff; color:#000; display:block; border-width: 1px; border-style: solid; border-color:#a3a681; padding:5px'`);
+
+ const whisperColor = ((darkMode) ? "#2b2130" : "#fbfcf0");
+ const whisperTextColor = ((darkMode) ? "#fff" : "#000");
+ const buttonstyle = ((darkMode) ? `style='display:inline-block; color:#a980bd; font-size: 0.9em; background-color: transparent;padding: 0px; border: none'` : `style='display:inline-block; color:#ce0f69; font-size: 0.9em; background-color: transparent;padding: 0px; border: none'`);
+
+
+
+
+ if (option !== undefined && option.includes('config')) {
+ let templateChoice = option.split('|')[1]
+
+ if (templateChoice === undefined) {
+ message = 'Current sheet template:
' + state.Supernotes.sheet + '
Send to Players:
' + state.Supernotes.sendToPlayers + '
Choose a template for Supernotes to use.
[Default Template - any sheet](!gmnote --config|default)
[D&D 5th Edition by Roll20](!gmnote --config|dnd5e)
[DnD 5e Shaped](!gmnote --config|5eshaped)
[Pathfinder Community](!gmnote --config|pfcommunity)
[Pathfinder by Roll20](!gmnote --config|pfofficial)
[Pathfinder 2e by Roll20](!gmnote --config|pf2e)
[Starfinder by Roll20](!gmnote --config|starfinder)
[Call of Cthulhu 7th Edition by Roll20](!gmnote --config|callofcthulhu)
[Toggle Send to Players](!gmnote --config|sendtoPlayers)
[Toggle Make Handout button](!gmnote --config|makeHandout)
[Toggle Darkmode](!gmnote --config|darkMode)'
+ sendChat('Supernotes', messagePrefix + '&{template:' + template + '}{{' + title + '=' + 'Config' + '}} {{' + theText + '=' + message + '}}');
+ }
+
+
+ switch (templateChoice) {
+ case 'default':
+ state.Supernotes.sheet = 'Default';
+ state.Supernotes.template = 'default';
+ state.Supernotes.title = 'name';
+ state.Supernotes.theText = '';
+ sendChat('Supernotes', '/w gm Supernotes set to Default roll template');
+ break;
+ case 'dnd5e':
+ state.Supernotes.sheet = 'D&D 5th Edition by Roll20';
+ state.Supernotes.template = 'npcaction';
+ state.Supernotes.title = 'rname';
+ state.Supernotes.theText = 'description';
+ sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet);
+ break;
+ case '5eshaped':
+ state.Supernotes.sheet = 'DnD 5e Shaped';
+ state.Supernotes.template = '5e-shaped';
+ state.Supernotes.title = 'title';
+ state.Supernotes.theText = 'text_big';
+ sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet);
+ break;
+ case 'pfcommunity':
+ state.Supernotes.sheet = 'Pathfinder Community';
+ state.Supernotes.template = 'pf_generic';
+ state.Supernotes.title = 'name';
+ state.Supernotes.theText = 'description';
+ sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet);
+ break;
+ case 'pfofficial':
+ state.Supernotes.sheet = 'Pathfinder by Roll20';
+ state.Supernotes.template = 'npc';
+ state.Supernotes.title = 'name';
+ state.Supernotes.theText = 'descflag=1}} {{desc';
+ sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet);
+ break;
+ case 'pf2e':
+ state.Supernotes.sheet = 'Pathefinder 2e';
+ state.Supernotes.template = 'rolls';
+ state.Supernotes.title = 'header';
+ state.Supernotes.theText = 'notes_show=[[1]]}} {{notes';
+ sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet);
+ break;
+ case 'starfinder':
+ state.Supernotes.sheet = 'Starfinder';
+ state.Supernotes.template = 'sf_generic';
+ state.Supernotes.title = 'title';
+ state.Supernotes.theText = 'buttons0';
+ sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet);
+ break;
+ case 'callofcthulhu':
+ state.Supernotes.sheet = 'Call of Cthulhu 7th Edition by Roll20';
+ state.Supernotes.template = 'callofcthulhu';
+ state.Supernotes.title = 'title';
+ state.Supernotes.theText = 'roll_bonus';
+ sendChat('Supernotes', '/w gm Supernotes set to ' + state.Supernotes.sheet);
+ break;
+ case 'sendtoPlayers':
+ if (state.Supernotes.sendToPlayers) {
+ state.Supernotes.sendToPlayers = false
+ } else {
+ state.Supernotes.sendToPlayers = true
+ };
+ sendChat('Supernotes', '/w gm Send to Players set to ' + state.Supernotes.sendToPlayers);
+ break;
+ case 'makeHandout':
+ if (state.Supernotes.makeHandout) {
+ state.Supernotes.makeHandout = false
+ } else {
+ state.Supernotes.makeHandout = true
+ };
+ sendChat('Supernotes', '/w gm Make Handout button set to ' + state.Supernotes.makeHandout);
+ break;
+ case 'darkMode':
+ if (state.Supernotes.darkMode) {
+ state.Supernotes.darkMode = false
+ } else {
+ state.Supernotes.darkMode = true
+ };
+ sendChat('Supernotes', '/w gm darkMode set to ' + state.Supernotes.darkMode);
+ break;
+ }
+ } else {
+ if (option !== undefined && option.includes('help')) {
+ buildSupernotesHelp();
+ return;
+ } else {
+ if (!(option + '').match(/^(card|bio|charnote|tokenimage|tooltip|avatar|imag(e|es|e[1-9]))/)) {
+ option = 'token';
+ }
+
+ let playerButton = '';
+ if (sendToPlayers && (command === '!gmnote' || command === '!selfnote')) {
+
+
+
+
+
+ playerButton = '\n[Send to Players](' + msg.content.replace(/!(gm|self)/, "!pc") + ' --id' + tokenID + ')';
+ }
+
+ let handoutButton = '';
+ if (makeHandout && (command.includes('gmnote') || command.includes('selfnote'))) {
+ handoutButton = ((playerButton) ? ' | ' : '
') + '[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") + ' --id' + tokenID + ' --handout|NamePlaceholder|)';
+ } else {
+ //handoutButton = '\n[Make Handout](' + msg.content.replace(/!(pc|self)/, "!gm") +')';
+
+ }
+
+ let regex;
+ if (match && match[1]) {
+ regex = new RegExp(`^${match[1]}`, 'i');
+ }
+
+ let message = '';
+ let whom = '';
+
+
+
+if (option === 'card') {
+
+ (theToken || []).forEach(sel => {
+
+ const o = getObj('graphic', sel._id);
+ if (!o) return;
+
+ const tokenID = o.id;
+ const tokenName = o.get('name') || '';
+ const rawGM = o.get('gmnotes') || '';
+
+ // Always assign whom deterministically
+ whom = tokenName;
+
+ // Decode GM notes safely
+ let decodedGM = rawGM ? unescape(decodeUnicode(rawGM)) : '';
+
+ // Apply regex filtering if present
+ if (decodedGM && regex) {
+ decodedGM = _.filter(
+ decodedGM.split(/(?:[\n\r]+|
)/),
+ l => regex.test(l)
+ ).join('\r');
+ }
+
+ message = decodedGM || '';
+
+ // Crop GM-only content for player/self notes
+ if (command === '!pcnote' || command === '!selfnote') {
+ if (message.includes("-----")) {
+ message = message.split('-----')[0];
+ }
+ }
+
+ // Apply notitle
+ if (notitle) {
+ whom = '';
+ }
+
+ // Inject token image if message isn't an image URL
+ if (!/\.(png|jpg|jpeg|gif)/i.test(message)) {
+
+ let styledTokenImage = `
`;
+
+ if (!message) {
+ message = `
`;
+ }
+
+ message = styledTokenImage + message;
+ }
+
+ sendMessage(
+ whom,
+ messagePrefix,
+ template,
+ title,
+ theText,
+ message,
+ tokenID,
+ playerButton,
+ handoutButton
+ );
+
+ });
+
+ } else {
+ if (option === 'tooltip') {
+ (theToken || [])
+ .map(o => getObj('graphic', o._id))
+ .filter(g => undefined !== g)
+ .map(t => getObj('character', t.get('represents')))
+ .filter(c => undefined !== c)
+ .forEach(c => {
+ message = tokenTooltip;
+ whom = tokenName;
+ if (notitle) {
+ whom = '';
+ }
+ sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton);
+ });
+ } else {
+ if (option === 'tokenimage') {
+ (theToken || [])
+ .map(o => getObj('graphic', o._id))
+ .filter(g => undefined !== g)
+ /* .map(t => getObj('character', t.get('represents')))*/
+ .filter(c => undefined !== c)
+ .forEach(c => {
+ message = "
";
+ whom = tokenName;
+ if (notitle) {
+ whom = '';
+ }
+ sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton);
+ });
+ } else {
+ if (option === 'avatar') {
+ (theToken || [])
+ .map(o => getObj('graphic', o._id))
+ .filter(g => undefined !== g)
+ .map(t => getObj('character', t.get('represents')))
+ .filter(c => undefined !== c)
+ .forEach(c => {
+ message = "
";
+ whom = c.get('name');
+ if (notitle) {
+ whom = '';
+ }
+ sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton);
+ });
+ } else {
+
+ if (option.match(/^imag(e|es|e[1-9])/)) {
+
+
+ (theToken || [])
+ .map(o => getObj('graphic', o._id))
+ .filter(g => undefined !== g)
+ .map(t => getObj('character', t.get('represents')))
+ .filter(c => undefined !== c)
+ .forEach(c => c.get('bio', (val) => {
+ if (null !== val && 'null' !== val && val.length > 0) {
+ if (regex) {
+ message = _.filter(
+ decodeUnicode(val).split(/(?:[\n\r]+|
)/),
+ (l) => regex.test(l.replace(/<[^>]*>/g, ''))
+ ).join('\r');
+ message = message.replace("
/g);
+ if (artwork === null) {
+ artwork = 'No artwork exists for this character. Consider specifiying avatar.'
+ };
+
+ } else {
+ artwork = message.match(/\<.* src.*?\>/g);
+ artwork = String(artwork);
+ if (artwork === null) {
+ artwork = 'No artwork exists for this character. Consider specifiying avatar.'
+ };
+
+
+ imageIndex = option.match(/\d+/g);
+
+
+ if (isNaN(imageIndex) || !imageIndex) {
+ imageIndex = 1
+ }
+
+ if (imageIndex > (artwork.split(",")).length) {
+ imageIndex = 1
+ }
+
+ imageIndex = imageIndex - 1; //corrects from human readable
+
+ artwork = artwork.split(",")[imageIndex];
+
+ }
+ if (('' + artwork).length > 3) {
+ message = artwork;
+ } else {
+ message = 'No artwork exists for this character.';
+ }
+ if (artwork === "null" || message === "null") {
+ message = 'No artwork exists for this character. Consider specifiying avatar.'
+ };
+
+ whom = c.get('name');
+
+ //Sends the final message
+ if (notitle) {
+ whom = '';
+ }
+ sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton);
+
+ }
+ }));
+ } else {
+
+
+
+ if ((option === 'bio') || (option === 'charnote')) {
+ let suboption = (option === 'charnote') ? 'gmnotes' : 'bio';
+
+ (theToken || [])
+ .map(o => getObj('graphic', o._id))
+ .filter(g => undefined !== g)
+ .map(t => getObj('character', t.get('represents')))
+ .filter(c => undefined !== c)
+ .forEach(c => c.get(suboption, (val) => {
+ if (null !== val && 'null' !== val && val.length > 0) {
+ if (regex) {
+ message = _.filter(
+ decodeUnicode(val).split(/(?:[\n\r]+|
)/),
+ (l) => regex.test(l.replace(/<[^>]*>/g, ''))
+ ).join('\r');
+ } else {
+ message = decodeUnicode(val);
+ }
+ whom = c.get('name');
+ //Crops out GM info on player messages
+ if (command === '!pcnote' || command === '!selfnote') {
+ message = (message.includes("-----") ? message.split('-----')[0] : message);
+ }
+ //Sends the final message
+ if (notitle) {
+ whom = '';
+ }
+ sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton);
+
+ } else {
+ if (notitle) {
+ whom = ''
+ }
+ message = `The information does not exist for the ${option} option`;
+ sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton);
+
+ }
+ }));
+ } else {
+ (theToken || [])
+ .map(o => getObj('graphic', o._id))
+ .filter(g => undefined !== g)
+ .filter((o) => {
+ const gm = (o && o.get) ? o.get('gmnotes') : '';
+ return !!(gm && gm.length > 0);
+})
+ .forEach(o => {
+ if (regex) {
+ message = _.filter(unescape(decodeUnicode(o.get('gmnotes'))).split(/(?:[\n\r]+|
)/), (l) => regex.test(l)).join('\r');
+ } else {
+ message = unescape(decodeUnicode(o.get('gmnotes')));
+ }
+ whom = o.get('name');
+
+ });
+
+ //Crops out GM info on player messages
+ if (command === '!pcnote' || command === '!selfnote') {
+ message = (message.includes("-----") ? message.split('-----')[0] : message);
+ }
+
+ //Sends the final message
+ if (notitle) {
+ whom = '';
+ }
+ sendMessage(whom, messagePrefix, template, title, theText, message, tokenID, playerButton, handoutButton);
+
+ }
+
+ /* Log Block. Turn on for debugging
+ [
+ `### REPORT###`,
+ `THE MESSAGE =${message}`,
+ `command = ${command}`,
+ // `option = ${option}`,
+ `secondOption = ${secondOption}`,
+ `messagePrefix = ${messagePrefix}`,
+ `whom = ${whom}`,
+ `message =${message}`
+ ].forEach(m => log(m));
+ */
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+});
From cbcb1fdc72cf887f9d584c04402d75fa9e6b5b18 Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Sun, 31 May 2026 15:36:15 -0700
Subject: [PATCH 10/13] Add version 0.2.8 to previous versions list
---
Supernotes/script.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Supernotes/script.json b/Supernotes/script.json
index 79ddd453a..d47c9e296 100644
--- a/Supernotes/script.json
+++ b/Supernotes/script.json
@@ -13,5 +13,5 @@
"character.represents": "read"
},
"conflicts": [],
- "previousversions": ["0.0.4","0.0.5","0.0.6","0.0.7","0.0.8","0.0.9","0.0.91","0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.2.6","0.2.7"]
+ "previousversions": ["0.0.4","0.0.5","0.0.6","0.0.7","0.0.8","0.0.9","0.0.91","0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.2.6","0.2.7","0.2.8"]
}
From 9a84c10812a20e786743635786cc61095382188b Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Tue, 2 Jun 2026 10:33:31 -0700
Subject: [PATCH 11/13] Update climate setting button options
---
Chronicle/Chronicle.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Chronicle/Chronicle.js b/Chronicle/Chronicle.js
index 9e57b5c82..1bf83f07a 100644
--- a/Chronicle/Chronicle.js
+++ b/Chronicle/Chronicle.js
@@ -2823,7 +2823,7 @@ const Chronicle = (() => {
html += 'No climate set
';
}
html += Output.makeButton('Set Climate',
- `!chr --saveclimate ?{Latitude|tropical|subtropical|temperate|subarctic|polar}|?{Ocean Proximity|coastal|near_coastal|inland|continental}|?{Coast Type|west|east|none}|?{Elevation|lowland|highland|alpine}|?{Rainshadow|windward|leeward|neutral}`,
+ `!chr --saveclimate ?{Latitude|tropical|subtropical|temperate|subarctic|polar}|?{Ocean Proximity|coastal|near_coastal|inland|continental}|?{Coast Type|west|east|none}|?{Elevation|lowland|highland|alpine}|?{Rainfall Pattern - If nearby mountains affect rainfall choose windward for the wetter side and leeward for the drier side otherwise choose neutral|windward|leeward|neutral}`,
CSS_CURRENT.button);
// Temperature units toggle
From c691a420dbe648626581e1bdd78bb7440bf484ec Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Tue, 2 Jun 2026 10:33:51 -0700
Subject: [PATCH 12/13] Update climate setting command with rainfall pattern
option
---
Chronicle/1.0.0/Chronicle.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Chronicle/1.0.0/Chronicle.js b/Chronicle/1.0.0/Chronicle.js
index 9e57b5c82..1bf83f07a 100644
--- a/Chronicle/1.0.0/Chronicle.js
+++ b/Chronicle/1.0.0/Chronicle.js
@@ -2823,7 +2823,7 @@ const Chronicle = (() => {
html += 'No climate set
';
}
html += Output.makeButton('Set Climate',
- `!chr --saveclimate ?{Latitude|tropical|subtropical|temperate|subarctic|polar}|?{Ocean Proximity|coastal|near_coastal|inland|continental}|?{Coast Type|west|east|none}|?{Elevation|lowland|highland|alpine}|?{Rainshadow|windward|leeward|neutral}`,
+ `!chr --saveclimate ?{Latitude|tropical|subtropical|temperate|subarctic|polar}|?{Ocean Proximity|coastal|near_coastal|inland|continental}|?{Coast Type|west|east|none}|?{Elevation|lowland|highland|alpine}|?{Rainfall Pattern - If nearby mountains affect rainfall choose windward for the wetter side and leeward for the drier side otherwise choose neutral|windward|leeward|neutral}`,
CSS_CURRENT.button);
// Temperature units toggle
From 18c48995046d3057d1a96a3deb1db84b8473b150 Mon Sep 17 00:00:00 2001
From: keithcurtis1
Date: Wed, 3 Jun 2026 13:09:57 -0700
Subject: [PATCH 13/13] Update version from 0.2.7 to 0.2.8
---
Supernotes/script.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Supernotes/script.json b/Supernotes/script.json
index d47c9e296..e3b94f090 100644
--- a/Supernotes/script.json
+++ b/Supernotes/script.json
@@ -1,7 +1,7 @@
{
"name": "Supernotes",
"script": "Supernotes.js",
- "version": "0.2.7",
+ "version": "0.2.8",
"description": "# Supernotes\r*by keithcurtis, expanded from code written by the Aaron.*\r\rThis script pulls the contents from a token's GM Notes field and sends them to chat, based on a user-selectable roll template. If the token represents a character, you can optionally pull in the Bio or GM notes from the character. The user can decide whether to whisper the notes to the GM or broadcast them to all players. Finally, there is the option to add a footer to notes whispered to the GM. This footer creates a chat button to give the option of sending the notes on to the players.\r\rThis script as written is optimized for the D&D 5th Edition by Roll20 sheet, but can be adapted easily suing the Configuration section below.\r\r* [SuperNotes forum thread](https://app.roll20.net/forum/post/8293909/script-supernotes)\r\r\r## Commands:\r\r**!gmnote** whispers the note to the GM\r\r**!pcnote** sends the note to all players\r\r**!selfnote** whispers the note to to the sender\r\r\r## Paramaters\r\r*--token* Pulls notes from the selected token's gm notes field. This is optional. If it is missing, the script assumes --token\r\r*--charnote* Pulls notes from the gm notes field of the character assigned to a token.\r\r*--bio* Pulls notes from the bio field of the character assigned to a token.\r\r*--avatar* Pulls the image from the avatar field of the character assigned to a token.\r\r--image Pulls first image from the bio field of the character assigned to a token, if any exists. Otherwise returns notice that no artwork is available\r\r*--images* Pulls all images from the bio field of the character assigned to a token, if any exist. Otherwise returns notice that no artwork is available\r\r*--image[number]* Pulls indexed image from the bio field of the character assigned to a token, if any exist. *--image1* will pull the first image, *--image2* the second and so on. Otherwise returns first image if available. If no images are available, returns notice that no artwork is available.\r\r*--notitle* This option suppresses the title in the chat output. It is useful for times when the GM might wish to show an image or note to the player without clueing them in wha the note is about. For instance, they may wish to reveal an image of a monster without revealing its name.\r\r*--id* supply this with a token id, and the script will attempt to read the notes associated with a specific token, or the character associate with that token. There is no space between --id and the token id. Only one token id may be passed.\r\r*--handout|Handoutname|* If this is present in the arguments, the note will be sent to a handout instead of chat. This can allow a note to remain usable without scrolling through the chat. It can also be used as a sort of floating palette. Notes in handouts can be updated. Running the macro again will regenerate the note. The string in between pipes will be used as the name of the note handout. If no handout by that name exists, Supernotes will create one and post a link in chat to open it. The title must be placed between two pipes. handout|My Handout| will work. handout|My Handout will break.\rA note handout automatically creates a horizontal rule at the top of the handout. Anything typed manually above that rule will be persistent. Supernotes will not overwrite this portion. You can use this area to create Journal Command Buttons to generate new notes or to give some context to the existing note. All updates are live.\r\r--template[templatename] Instead of using the configured sheet roll template, you can choose from between more than 10 custom templates that cover most common genres. Add the template command directly after the main prompt, followed by any of the regular parameters above. The current choices are:\r**template|generic.** Just the facts, ma'am. Nothing fancy here.\r**template|dark.** As above, but in reverse.\r**template|crt.** Retro greenscreen for hacking and cyberpunk. Or for reports on that xenomorph hiding on your ship.\r**template|notebook.** You know, for kids. Who like to ride bikes. Maybe they attend a school and fight vampires or rescue lost extraterrestrials\r**template|gothic.** Classic noire horror for contending with Universal monsters or maybe contending with elder gods.\r**template|apoc.** Messages scrawled on a wall. Crumbling and ancient, like the world that was.\r**template|scroll.** High fantasy. Or low fantasy—we don't judge.\r**template|lcars.** For opening hailing frequencies and to boldly split infinitives that no one has split before!\r**template|faraway.** No animated title crawl, but still has that space wizard feel.\r**template|steam.** Gears and brass have changed my life.\r**template|western.** Return with us now to those thrilling days of yesteryear!\r**template|wizard.** Like those ones that live on the coast\r**template|dragon.** Third Edition goodness!\r\r*--help* Displays help.\r\r*--config* Returns a configuration dialog box that allows you to set which sheet's roll template to use, and to toggle the '\r Players' footer.\r\r\r## Configuration\r\rWhen first installed, Supernotes is configured for the default roll template. It will display a config dialog box at startup that will allow you to choose a roll template based on your character sheet of choice, as well as the option to toggle whether you want the '\r Players' footer button to appear.\r\rYou will need to edit the code of the script if you wish to create a custom configuration, or contact keithcurtis on the Roll20 forum and request an addition. The pre-installed sheets are:\r\rDefault Template, D&D 5th Edition by Roll20, 5e Shaped, Pathfinder by Roll20, Pathfinder Community, Pathfinder 2e by Roll20, Starfinder, Starfinder, Call of Cthulhu 7th Edition by Roll20",
"authors": "Keith Curtis",
"roll20userid": "162065",