forked from GitHubMirrors/silverbullet-icalendar
Compare commits
11 Commits
9b54e2d8a8
...
b2b109b923
| Author | SHA1 | Date | |
|---|---|---|---|
| b2b109b923 | |||
| bc0afad261 | |||
| 9af3e436aa | |||
| 533c240c07 | |||
| 780e90b1f0 | |||
| ffaef28332 | |||
| 4128c046d0 | |||
| 10a6db5893 | |||
| 85691d1df5 | |||
| b8bf269de8 | |||
| b94ebd30a2 |
23
conductor/code_styleguides/general.md
Normal file
23
conductor/code_styleguides/general.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# General Code Style Principles
|
||||
|
||||
This document outlines general coding principles that apply across all languages and frameworks used in this project.
|
||||
|
||||
## Readability
|
||||
- Code should be easy to read and understand by humans.
|
||||
- Avoid overly clever or obscure constructs.
|
||||
|
||||
## Consistency
|
||||
- Follow existing patterns in the codebase.
|
||||
- Maintain consistent formatting, naming, and structure.
|
||||
|
||||
## Simplicity
|
||||
- Prefer simple solutions over complex ones.
|
||||
- Break down complex problems into smaller, manageable parts.
|
||||
|
||||
## Maintainability
|
||||
- Write code that is easy to modify and extend.
|
||||
- Minimize dependencies and coupling.
|
||||
|
||||
## Documentation
|
||||
- Document *why* something is done, not just *what*.
|
||||
- Keep documentation up-to-date with code changes.
|
||||
43
conductor/code_styleguides/typescript.md
Normal file
43
conductor/code_styleguides/typescript.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Google TypeScript Style Guide Summary
|
||||
|
||||
This document summarizes key rules and best practices from the Google TypeScript Style Guide, which is enforced by the `gts` tool.
|
||||
|
||||
## 1. Language Features
|
||||
- **Variable Declarations:** Always use `const` or `let`. **`var` is forbidden.** Use `const` by default.
|
||||
- **Modules:** Use ES6 modules (`import`/`export`). **Do not use `namespace`.**
|
||||
- **Exports:** Use named exports (`export {MyClass};`). **Do not use default exports.**
|
||||
- **Classes:**
|
||||
- **Do not use `#private` fields.** Use TypeScript's `private` visibility modifier.
|
||||
- Mark properties never reassigned outside the constructor with `readonly`.
|
||||
- **Never use the `public` modifier** (it's the default). Restrict visibility with `private` or `protected` where possible.
|
||||
- **Functions:** Prefer function declarations for named functions. Use arrow functions for anonymous functions/callbacks.
|
||||
- **String Literals:** Use single quotes (`'`). Use template literals (`` ` ``) for interpolation and multi-line strings.
|
||||
- **Equality Checks:** Always use triple equals (`===`) and not equals (`!==`).
|
||||
- **Type Assertions:** **Avoid type assertions (`x as SomeType`) and non-nullability assertions (`y!`)**. If you must use them, provide a clear justification.
|
||||
|
||||
## 2. Disallowed Features
|
||||
- **`any` Type:** **Avoid `any`**. Prefer `unknown` or a more specific type.
|
||||
- **Wrapper Objects:** Do not instantiate `String`, `Boolean`, or `Number` wrapper classes.
|
||||
- **Automatic Semicolon Insertion (ASI):** Do not rely on it. **Explicitly end all statements with a semicolon.**
|
||||
- **`const enum`:** Do not use `const enum`. Use plain `enum` instead.
|
||||
- **`eval()` and `Function(...string)`:** Forbidden.
|
||||
|
||||
## 3. Naming
|
||||
- **`UpperCamelCase`:** For classes, interfaces, types, enums, and decorators.
|
||||
- **`lowerCamelCase`:** For variables, parameters, functions, methods, and properties.
|
||||
- **`CONSTANT_CASE`:** For global constant values, including enum values.
|
||||
- **`_` Prefix/Suffix:** **Do not use `_` as a prefix or suffix** for identifiers, including for private properties.
|
||||
|
||||
## 4. Type System
|
||||
- **Type Inference:** Rely on type inference for simple, obvious types. Be explicit for complex types.
|
||||
- **`undefined` and `null`:** Both are supported. Be consistent within your project.
|
||||
- **Optional vs. `|undefined`:** Prefer optional parameters and fields (`?`) over adding `|undefined` to the type.
|
||||
- **`Array<T>` Type:** Use `T[]` for simple types. Use `Array<T>` for more complex union types (e.g., `Array<string | number>`).
|
||||
- **`{}` Type:** **Do not use `{}`**. Prefer `unknown`, `Record<string, unknown>`, or `object`.
|
||||
|
||||
## 5. Comments and Documentation
|
||||
- **JSDoc:** Use `/** JSDoc */` for documentation, `//` for implementation comments.
|
||||
- **Redundancy:** **Do not declare types in `@param` or `@return` blocks** (e.g., `/** @param {string} user */`). This is redundant in TypeScript.
|
||||
- **Add Information:** Comments must add information, not just restate the code.
|
||||
|
||||
*Source: [Google TypeScript Style Guide](https://google.github.io/styleguide/tsguide.html)*
|
||||
14
conductor/index.md
Normal file
14
conductor/index.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Project Context
|
||||
|
||||
## Definition
|
||||
- [Product Definition](./product.md)
|
||||
- [Product Guidelines](./product-guidelines.md)
|
||||
- [Tech Stack](./tech-stack.md)
|
||||
|
||||
## Workflow
|
||||
- [Workflow](./workflow.md)
|
||||
- [Code Style Guides](./code_styleguides/)
|
||||
|
||||
## Management
|
||||
- [Tracks Registry](./tracks.md)
|
||||
- [Tracks Directory](./tracks/)
|
||||
17
conductor/product-guidelines.md
Normal file
17
conductor/product-guidelines.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Product Guidelines - SilverBullet iCalendar Plug
|
||||
|
||||
## Documentation and Communication Style
|
||||
- **Technical and Concise:** All documentation, configuration examples, and user-facing messages should be accurate, brief, and focused on providing high value to the user. Avoid unnecessary fluff or conversational filler.
|
||||
- **Example-Driven:** Prioritize clear, copy-pasteable configuration snippets and query examples to help users get started quickly.
|
||||
|
||||
## Visual Identity and User Interface
|
||||
- **Native SilverBullet Integration:** The plug should feel like a core part of the SilverBullet experience. Commands, notifications, and any future UI elements must strictly adhere to SilverBullet's design patterns and aesthetic.
|
||||
- **Informative and Actionable Feedback:**
|
||||
- Notifications should provide immediate clarity on the outcome of actions (e.g., "Synced 194 events", "Sync failed: HTTP 404").
|
||||
- Error messages should be descriptive enough to aid in troubleshooting (e.g., specifying which source failed).
|
||||
- **Subtle Consistency:** Use consistent naming conventions for commands (`iCalendar: Sync`, `iCalendar: Force Sync`, etc.) to maintain a professional and organized command palette.
|
||||
|
||||
## Code and Maintenance Guidelines (Inferred)
|
||||
- **Robust Error Handling:** Always catch and log errors during fetch and parse operations to prevent the entire sync process from crashing.
|
||||
- **Performance First:** Efficiently process large `.ics` files and avoid redundant indexing operations.
|
||||
- **Version Alignment:** Ensure the version number is synchronized across `deno.json`, `icalendar.plug.yaml`, `PLUG.md`, and the TypeScript source code.
|
||||
23
conductor/product.md
Normal file
23
conductor/product.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Initial Concept
|
||||
`silverbullet-icalendar` is a Plug for SilverBullet that reads external iCalendar data (.ics format) and integrates it into the SilverBullet environment.
|
||||
|
||||
# Product Definition - SilverBullet iCalendar Plug
|
||||
|
||||
## Vision
|
||||
A reliable and seamless bridge between external iCalendar services and the SilverBullet knowledge management environment, enabling users to consolidate their scheduling data within their personal workspace.
|
||||
|
||||
## Target Audience
|
||||
- SilverBullet users who need to integrate external calendars (Google, Nextcloud, Outlook, etc.) directly into their notes and queries.
|
||||
|
||||
## Core Goals & Features
|
||||
- **Reliable Multi-Source Synchronization:** Support for fetching and parsing `.ics` data from various providers like Google Calendar and Nextcloud.
|
||||
- **SilverBullet Index Integration:** Seamlessly index calendar events using the `ical-event` tag, making them instantly queryable using SilverBullet's Lua Integrated Query (LIQ).
|
||||
- **Robust Timezone Handling:** Accurate conversion and shifting of event times to ensure consistency regardless of the source provider's configuration.
|
||||
- **Cache Management:** Efficient local caching of calendar data with user-configurable durations and force-sync capabilities.
|
||||
- **Clean Indexing:** Sanitization of complex iCalendar objects into flat, query-friendly metadata.
|
||||
|
||||
## Technology Stack (Inferred)
|
||||
- **Language:** TypeScript
|
||||
- **Runtime:** Deno
|
||||
- **Platform:** SilverBullet Plug API
|
||||
- **Parsing Library:** `ts-ics`
|
||||
1
conductor/setup_state.json
Normal file
1
conductor/setup_state.json
Normal file
@@ -0,0 +1 @@
|
||||
{"last_successful_step": "2.5_workflow"}
|
||||
19
conductor/tech-stack.md
Normal file
19
conductor/tech-stack.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Technology Stack - SilverBullet iCalendar Plug
|
||||
|
||||
## Core Runtime & Language
|
||||
- **Language:** [TypeScript](https://www.typescriptlang.org/) - Provides type safety and modern JavaScript features for robust plug development.
|
||||
- **Runtime:** [Deno](https://deno.com/) - A secure-by-default runtime for JavaScript and TypeScript, used for building and running the plug's development tasks.
|
||||
|
||||
## Platform & API
|
||||
- **Platform:** [SilverBullet Plug API](https://silverbullet.md/Plugs) - The official API for extending SilverBullet functionality.
|
||||
- **Dependency Management:** [JSR](https://jsr.io/) and [ESM.sh](https://esm.sh/) - Used for importing the SilverBullet syscalls and external libraries like `ts-ics`.
|
||||
|
||||
## Libraries
|
||||
- **iCalendar Parsing:** [`ts-ics`](https://www.npmjs.com/package/ts-ics) (v2.4.0) - A library for parsing iCalendar data into structured JavaScript objects.
|
||||
- **Recurrence Expansion:** [`rrule`](https://www.npmjs.com/package/rrule) (v2.8.1) - A library for expanding recurring event rules (RRULE) into individual occurrences.
|
||||
|
||||
## Build & Development Tools
|
||||
- **Task Orchestration:** Deno Tasks (defined in `deno.json`) - Handles version synchronization and plug compilation.
|
||||
- **Compiler:** `plug-compile.js` - The standard SilverBullet utility for bundling the TypeScript source and manifest into a `.plug.js` file.
|
||||
- **Version Control:** Git - For source code management and integration with Gitea Actions.
|
||||
- **CI/CD:** Gitea Actions - Automates the build and deployment process upon pushes to the repository.
|
||||
8
conductor/tracks.md
Normal file
8
conductor/tracks.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Project Tracks
|
||||
|
||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Upgrade the SilverBullet iCalendar plug to use DST-aware timezone resolution and add recurring event support using rrule.**
|
||||
*Link: [./tracks/timezone_rrule_20260218/](./tracks/timezone_rrule_20260218/)*
|
||||
5
conductor/tracks/timezone_rrule_20260218/index.md
Normal file
5
conductor/tracks/timezone_rrule_20260218/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Track timezone_rrule_20260218 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
8
conductor/tracks/timezone_rrule_20260218/metadata.json
Normal file
8
conductor/tracks/timezone_rrule_20260218/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "timezone_rrule_20260218",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2026-02-18T11:20:00Z",
|
||||
"updated_at": "2026-02-18T11:20:00Z",
|
||||
"description": "Upgrade the SilverBullet iCalendar plug to use DST-aware timezone resolution and add recurring event support using rrule."
|
||||
}
|
||||
40
conductor/tracks/timezone_rrule_20260218/plan.md
Normal file
40
conductor/tracks/timezone_rrule_20260218/plan.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Implementation Plan: Proper Timezone Handling & Recurring Events
|
||||
|
||||
## Phase 1: Foundation - Timezone Mapping & Resolver [checkpoint: b8bf269]
|
||||
- [x] Task: Setup Timezone Map (WINDOWS_TO_IANA)
|
||||
- [x] Write failing tests for `resolveIanaName`
|
||||
- [x] Implement `WINDOWS_TO_IANA` mapping and `resolveIanaName` in `timezones.ts`
|
||||
- [x] Task: Implement UTC Offset Resolver using Intl
|
||||
- [x] Write failing tests for `getUtcOffsetMs`
|
||||
- [x] Implement `getUtcOffsetMs` in `timezones.ts`
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Core Logic - Extraction & Shifting [checkpoint: 10a6db5]
|
||||
- [x] Task: Fix Wall-Clock Extraction logic
|
||||
- [x] Write failing tests for `resolveEventStart` (mocking `Intl` if necessary)
|
||||
- [x] Implement `resolveEventStart` in `icalendar.ts` to handle local time ground truth
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2: Core Logic' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Features - Recurring Events & Filtering [checkpoint: ffaef28]
|
||||
- [x] Task: Integrate `rrule` library
|
||||
- [x] Add `rrule` to `deno.json` imports
|
||||
- [x] Verify import works in a simple script
|
||||
- [x] Task: Implement Recurring Event Expansion
|
||||
- [x] Write failing tests for `expandRecurrences`
|
||||
- [x] Implement `expandRecurrences` in `icalendar.ts`
|
||||
- [x] Task: Implement EXDATE support
|
||||
- [x] Write failing tests for EXDATE exclusion
|
||||
- [x] Update `expandRecurrences` to handle `EXDATE`
|
||||
- [x] Task: Implement Status Filtering
|
||||
- [x] Write failing tests for filtering "CANCELLED" events
|
||||
- [x] Update sync logic to filter based on iCalendar status
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 3: Features' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Cleanup & Configuration [checkpoint: 533c240]
|
||||
- [x] Task: Remove obsolete configuration
|
||||
- [x] Write failing tests verifying `tzShift` is ignored/deprecated
|
||||
- [x] Remove `tzShift` and `hourShift` from `getSources` and `fetchAndParseCalendar`
|
||||
- [x] Task: Add `syncWindowDays` configuration
|
||||
- [x] Write failing tests for configurable expansion window
|
||||
- [x] Implement `syncWindowDays` in config and sync logic
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Cleanup & Configuration' (Protocol in workflow.md)
|
||||
36
conductor/tracks/timezone_rrule_20260218/spec.md
Normal file
36
conductor/tracks/timezone_rrule_20260218/spec.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification: Proper Timezone Handling & Recurring Events
|
||||
|
||||
## Overview
|
||||
Upgrade the SilverBullet iCalendar plug to provide accurate, DST-aware timezone resolution and full support for recurring events (RRULE expansion). This replaces manual hour shifting with an automated, reliable system using IANA timezone standards and the `Intl` API.
|
||||
|
||||
## Functional Requirements
|
||||
- **IANA Timezone Mapping:** Implement a comprehensive mapping of 139 Windows timezone names to IANA identifiers using Unicode CLDR data.
|
||||
- **DST-Aware Offsets:** Calculate UTC offsets at runtime for specific event dates using the built-in `Intl.DateTimeFormat` API, ensuring accuracy during Daylight Saving Time transitions.
|
||||
- **Robust Date Extraction:** Correct the wall-clock extraction logic to prevent "double-shifting" of event times.
|
||||
- **Recurring Event Expansion:**
|
||||
- Integrate the `rrule` library to expand recurring events into individual occurrences.
|
||||
- Support `EXDATE` for excluding specific instances of a recurring series.
|
||||
- Implement a configurable `syncWindowDays` (default: 365) to limit the expansion range.
|
||||
- **Advanced Filtering:**
|
||||
- Filter out "CANCELLED" events based on the iCalendar status field.
|
||||
- (Optional) Add `includeTransparent` and `includeDeclined` per-source flags.
|
||||
- **Error Handling & Fallbacks:**
|
||||
- If a timezone is unrecognized, fallback to UTC and append a warning to the event's description.
|
||||
- **Configuration Cleanup:**
|
||||
- Remove the redundant `tzShift` / `hourShift` parameters.
|
||||
- Add `syncWindowDays` global config.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Self-Contained:** Maintain the plug as a Deno-compatible project using `esm.sh` or `deno.json` imports.
|
||||
- **Performance:** Ensure efficient expansion of recurrences, even for busy calendars.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Events from various providers (Google, O365, Nextcloud) appear at the correct local time in SilverBullet, regardless of DST.
|
||||
2. All occurrences of a weekly recurring event within the sync window are indexed.
|
||||
3. Excluded dates (`EXDATE`) are correctly omitted from the index.
|
||||
4. Cancelled events are not indexed.
|
||||
5. The manual `tzShift` configuration is no longer required for correct time display.
|
||||
|
||||
## Out of Scope
|
||||
- Full CalDAV synchronization (this remains a read-only `.ics` fetcher).
|
||||
- UI for managing individual recurring instances (handled via SilverBullet queries).
|
||||
333
conductor/workflow.md
Normal file
333
conductor/workflow.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Project Workflow
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
|
||||
2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
|
||||
3. **Test-Driven Development:** Write unit tests before implementing functionality
|
||||
4. **High Code Coverage:** Aim for >80% code coverage for all modules
|
||||
5. **User Experience First:** Every decision should prioritize user experience
|
||||
6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
|
||||
|
||||
## Task Workflow
|
||||
|
||||
All tasks follow a strict lifecycle:
|
||||
|
||||
### Standard Task Workflow
|
||||
|
||||
1. **Select Task:** Choose the next available task from `plan.md` in sequential order
|
||||
|
||||
2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
|
||||
|
||||
3. **Write Failing Tests (Red Phase):**
|
||||
- Create a new test file for the feature or bug fix.
|
||||
- Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
|
||||
- **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
|
||||
|
||||
4. **Implement to Pass Tests (Green Phase):**
|
||||
- Write the minimum amount of application code necessary to make the failing tests pass.
|
||||
- Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
|
||||
|
||||
5. **Refactor (Optional but Recommended):**
|
||||
- With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
|
||||
- Rerun tests to ensure they still pass after refactoring.
|
||||
|
||||
6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like:
|
||||
```bash
|
||||
pytest --cov=app --cov-report=html
|
||||
```
|
||||
Target: >80% coverage for new code. The specific tools and commands will vary by language and framework.
|
||||
|
||||
7. **Document Deviations:** If implementation differs from tech stack:
|
||||
- **STOP** implementation
|
||||
- Update `tech-stack.md` with new design
|
||||
- Add dated note explaining the change
|
||||
- Resume implementation
|
||||
|
||||
8. **Commit Code Changes:**
|
||||
- Stage all code changes related to the task.
|
||||
- Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`.
|
||||
- Perform the commit.
|
||||
|
||||
9. **Attach Task Summary with Git Notes:**
|
||||
- **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`).
|
||||
- **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change.
|
||||
- **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit.
|
||||
```bash
|
||||
# The note content from the previous step is passed via the -m flag.
|
||||
git notes add -m "<note content>" <commit_hash>
|
||||
```
|
||||
|
||||
10. **Get and Record Task Commit SHA:**
|
||||
- **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash.
|
||||
- **Step 10.2: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
11. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`).
|
||||
|
||||
### Phase Completion Verification and Checkpointing Protocol
|
||||
|
||||
**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
|
||||
|
||||
1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
|
||||
|
||||
2. **Ensure Test Coverage for Phase Changes:**
|
||||
- **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit.
|
||||
- **Step 2.2: List Changed Files:** Execute `git diff --name-only <previous_checkpoint_sha> HEAD` to get a precise list of all files modified during this phase.
|
||||
- **Step 2.3: Verify and Create Tests:** For each file in the list:
|
||||
- **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`).
|
||||
- For each remaining code file, verify a corresponding test file exists.
|
||||
- If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`).
|
||||
|
||||
3. **Execute Automated Tests with Proactive Debugging:**
|
||||
- Before execution, you **must** announce the exact shell command you will use to run the tests.
|
||||
- **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`"
|
||||
- Execute the announced command.
|
||||
- If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance.
|
||||
|
||||
4. **Propose a Detailed, Actionable Manual Verification Plan:**
|
||||
- **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase.
|
||||
- You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes.
|
||||
- The plan you present to the user **must** follow this format:
|
||||
|
||||
**For a Frontend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Start the development server with the command:** `npm run dev`
|
||||
2. **Open your browser to:** `http://localhost:3000`
|
||||
3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly.
|
||||
```
|
||||
|
||||
**For a Backend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Ensure the server is running.**
|
||||
2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'`
|
||||
3. **Confirm that you receive:** A JSON response with a status of `201 Created`.
|
||||
```
|
||||
|
||||
5. **Await Explicit User Feedback:**
|
||||
- After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**"
|
||||
- **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation.
|
||||
|
||||
6. **Create Checkpoint Commit:**
|
||||
- Stage all changes. If no changes occurred in this step, proceed with an empty commit.
|
||||
- Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`).
|
||||
|
||||
7. **Attach Auditable Verification Report using Git Notes:**
|
||||
- **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation.
|
||||
- **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit.
|
||||
|
||||
8. **Get and Record Phase Checkpoint SHA:**
|
||||
- **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`).
|
||||
- **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: <sha>]`.
|
||||
- **Step 8.3: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
9. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '<PHASE NAME>' as complete`.
|
||||
|
||||
10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
Before marking any task complete, verify:
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] Code coverage meets requirements (>80%)
|
||||
- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`)
|
||||
- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc)
|
||||
- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types)
|
||||
- [ ] No linting or static analysis errors (using the project's configured tools)
|
||||
- [ ] Works correctly on mobile (if applicable)
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] No security vulnerabilities introduced
|
||||
|
||||
## Development Commands
|
||||
|
||||
**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.**
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Example: Commands to set up the development environment (e.g., install dependencies, configure database)
|
||||
# e.g., for a Node.js project: npm install
|
||||
# e.g., for a Go project: go mod tidy
|
||||
```
|
||||
|
||||
### Daily Development
|
||||
```bash
|
||||
# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format)
|
||||
# e.g., for a Node.js project: npm run dev, npm test, npm run lint
|
||||
# e.g., for a Go project: go run main.go, go test ./..., go fmt ./...
|
||||
```
|
||||
|
||||
### Before Committing
|
||||
```bash
|
||||
# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests)
|
||||
# e.g., for a Node.js project: npm run check
|
||||
# e.g., for a Go project: make check (if a Makefile exists)
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Testing
|
||||
- Every module must have corresponding tests.
|
||||
- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach).
|
||||
- Mock external dependencies.
|
||||
- Test both success and failure cases.
|
||||
|
||||
### Integration Testing
|
||||
- Test complete user flows
|
||||
- Verify database transactions
|
||||
- Test authentication and authorization
|
||||
- Check form submissions
|
||||
|
||||
### Mobile Testing
|
||||
- Test on actual iPhone when possible
|
||||
- Use Safari developer tools
|
||||
- Test touch interactions
|
||||
- Verify responsive layouts
|
||||
- Check performance on 3G/4G
|
||||
|
||||
## Code Review Process
|
||||
|
||||
### Self-Review Checklist
|
||||
Before requesting review:
|
||||
|
||||
1. **Functionality**
|
||||
- Feature works as specified
|
||||
- Edge cases handled
|
||||
- Error messages are user-friendly
|
||||
|
||||
2. **Code Quality**
|
||||
- Follows style guide
|
||||
- DRY principle applied
|
||||
- Clear variable/function names
|
||||
- Appropriate comments
|
||||
|
||||
3. **Testing**
|
||||
- Unit tests comprehensive
|
||||
- Integration tests pass
|
||||
- Coverage adequate (>80%)
|
||||
|
||||
4. **Security**
|
||||
- No hardcoded secrets
|
||||
- Input validation present
|
||||
- SQL injection prevented
|
||||
- XSS protection in place
|
||||
|
||||
5. **Performance**
|
||||
- Database queries optimized
|
||||
- Images optimized
|
||||
- Caching implemented where needed
|
||||
|
||||
6. **Mobile Experience**
|
||||
- Touch targets adequate (44x44px)
|
||||
- Text readable without zooming
|
||||
- Performance acceptable on mobile
|
||||
- Interactions feel native
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
### Message Format
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
### Types
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation only
|
||||
- `style`: Formatting, missing semicolons, etc.
|
||||
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||
- `test`: Adding missing tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
git commit -m "feat(auth): Add remember me functionality"
|
||||
git commit -m "fix(posts): Correct excerpt generation for short posts"
|
||||
git commit -m "test(comments): Add tests for emoji reaction limits"
|
||||
git commit -m "style(mobile): Improve button touch targets"
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A task is complete when:
|
||||
|
||||
1. All code implemented to specification
|
||||
2. Unit tests written and passing
|
||||
3. Code coverage meets project requirements
|
||||
4. Documentation complete (if applicable)
|
||||
5. Code passes all configured linting and static analysis checks
|
||||
6. Works beautifully on mobile (if applicable)
|
||||
7. Implementation notes added to `plan.md`
|
||||
8. Changes committed with proper message
|
||||
9. Git note with task summary attached to the commit
|
||||
|
||||
## Emergency Procedures
|
||||
|
||||
### Critical Bug in Production
|
||||
1. Create hotfix branch from main
|
||||
2. Write failing test for bug
|
||||
3. Implement minimal fix
|
||||
4. Test thoroughly including mobile
|
||||
5. Deploy immediately
|
||||
6. Document in plan.md
|
||||
|
||||
### Data Loss
|
||||
1. Stop all write operations
|
||||
2. Restore from latest backup
|
||||
3. Verify data integrity
|
||||
4. Document incident
|
||||
5. Update backup procedures
|
||||
|
||||
### Security Breach
|
||||
1. Rotate all secrets immediately
|
||||
2. Review access logs
|
||||
3. Patch vulnerability
|
||||
4. Notify affected users (if any)
|
||||
5. Document and update security procedures
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
- [ ] All tests passing
|
||||
- [ ] Coverage >80%
|
||||
- [ ] No linting errors
|
||||
- [ ] Mobile testing complete
|
||||
- [ ] Environment variables configured
|
||||
- [ ] Database migrations ready
|
||||
- [ ] Backup created
|
||||
|
||||
### Deployment Steps
|
||||
1. Merge feature branch to main
|
||||
2. Tag release with version
|
||||
3. Push to deployment service
|
||||
4. Run database migrations
|
||||
5. Verify deployment
|
||||
6. Test critical paths
|
||||
7. Monitor for errors
|
||||
|
||||
### Post-Deployment
|
||||
1. Monitor analytics
|
||||
2. Check error logs
|
||||
3. Gather user feedback
|
||||
4. Plan next iteration
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
- Review workflow weekly
|
||||
- Update based on pain points
|
||||
- Document lessons learned
|
||||
- Optimize for user happiness
|
||||
- Keep things simple and maintainable
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"imports": {
|
||||
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.4.1",
|
||||
"ts-ics": "npm:ts-ics@2.4.0"
|
||||
"ts-ics": "npm:ts-ics@2.4.0",
|
||||
"rrule": "https://esm.sh/rrule@2.8.1"
|
||||
}
|
||||
}
|
||||
170
icalendar.ts
170
icalendar.ts
@@ -1,25 +1,13 @@
|
||||
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
||||
import { RRule, RRuleSet } from "rrule";
|
||||
import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts";
|
||||
|
||||
const VERSION = "0.3.25";
|
||||
const CACHE_KEY = "icalendar:lastSync";
|
||||
|
||||
console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);
|
||||
|
||||
const TIMEZONE_OFFSETS: Record<string, number> = {
|
||||
"GMT Standard Time": 0,
|
||||
"W. Europe Standard Time": 1,
|
||||
"Central Europe Standard Time": 1,
|
||||
"Romance Standard Time": 1,
|
||||
"Central European Standard Time": 1,
|
||||
"Eastern Standard Time": -5,
|
||||
"Central Standard Time": -6,
|
||||
"Mountain Standard Time": -7,
|
||||
"Pacific Standard Time": -8,
|
||||
"UTC": 0,
|
||||
"None": 0
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
@@ -86,18 +74,15 @@ function convertDatesToStrings<T>(obj: T): any {
|
||||
// Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
async function getSources(): Promise<{ sources: any[], tzShift: number }> {
|
||||
async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
|
||||
try {
|
||||
const rawConfig = await config.get("icalendar", { sources: [] });
|
||||
const rawConfig = await config.get("icalendar", { sources: [] }) as any;
|
||||
console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
|
||||
|
||||
let sources = rawConfig.sources || [];
|
||||
let tzShift = rawConfig.tzShift || 0;
|
||||
const syncWindowDays = rawConfig.syncWindowDays || 365;
|
||||
|
||||
if (sources && typeof sources === "object" && !Array.isArray(sources)) {
|
||||
if (sources.tzShift !== undefined && tzShift === 0) {
|
||||
tzShift = sources.tzShift;
|
||||
}
|
||||
const sourceArray = [];
|
||||
for (const key in sources) {
|
||||
if (sources[key] && typeof sources[key].url === "string") {
|
||||
@@ -107,10 +92,10 @@ async function getSources(): Promise<{ sources: any[], tzShift: number }> {
|
||||
sources = sourceArray;
|
||||
}
|
||||
|
||||
return { sources, tzShift };
|
||||
return { sources, syncWindowDays };
|
||||
} catch (e) {
|
||||
console.error("[iCalendar] Error in getSources:", e);
|
||||
return { sources: [], tzShift: 0 };
|
||||
return { sources: [], syncWindowDays: 365 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +103,101 @@ async function getSources(): Promise<{ sources: any[], tzShift: number }> {
|
||||
// Calendar Fetching & Parsing
|
||||
// ============================================================================
|
||||
|
||||
async function fetchAndParseCalendar(source: any, hourShift = 0): Promise<any[]> {
|
||||
/**
|
||||
* Resolves the event start as a UTC Date object using DST-aware resolution.
|
||||
*/
|
||||
export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
|
||||
const obj = icsEvent.start;
|
||||
if (!obj) return null;
|
||||
|
||||
// 1. Extract the wall-clock local datetime string
|
||||
let wallClock: string | null = null;
|
||||
if (obj.local?.date) {
|
||||
const d = obj.local.date;
|
||||
wallClock = d instanceof Date ? d.toISOString() : String(d);
|
||||
} else if (obj.date) {
|
||||
const d = obj.date;
|
||||
wallClock = d instanceof Date ? d.toISOString() : String(d);
|
||||
}
|
||||
|
||||
if (!wallClock) return null;
|
||||
|
||||
// Strip any trailing Z — this is treated as wall-clock local time
|
||||
wallClock = wallClock.replace(/Z$/, "");
|
||||
|
||||
// 2. Resolve IANA timezone
|
||||
const rawTz = obj.local?.timezone || (obj as any).timezone || "UTC";
|
||||
const ianaName = resolveIanaName(rawTz);
|
||||
|
||||
if (!ianaName) {
|
||||
console.warn(`[iCalendar] Unknown timezone: "${rawTz}" - falling back to UTC for event "${icsEvent.summary}"`);
|
||||
const utcDate = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
|
||||
if (isNaN(utcDate.getTime())) return null;
|
||||
return utcDate;
|
||||
}
|
||||
|
||||
// 3. Parse the wall-clock time as a UTC instant (no offset yet)
|
||||
const wallClockAsUtc = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
|
||||
if (isNaN(wallClockAsUtc.getTime())) return null;
|
||||
|
||||
// 4. Get the DST-aware offset for this IANA zone at this instant
|
||||
const offsetMs = getUtcOffsetMs(ianaName, wallClockAsUtc);
|
||||
|
||||
// 5. Convert: UTC = wall-clock - offset
|
||||
return new Date(wallClockAsUtc.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands recurring events into individual occurrences.
|
||||
*/
|
||||
export function expandRecurrences(icsEvent: any, windowDays = 365): any[] {
|
||||
const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule;
|
||||
if (!rruleStr) return [icsEvent];
|
||||
|
||||
try {
|
||||
const set = new RRuleSet();
|
||||
const cleanRule = rruleStr.replace(/^RRULE:/i, "");
|
||||
|
||||
// We need to provide DTSTART if it's not in the string
|
||||
const dtstart = new Date(icsEvent.start.includes("Z") ? icsEvent.start : icsEvent.start + "Z");
|
||||
if (isNaN(dtstart.getTime())) {
|
||||
console.error(`[iCalendar] Invalid start date for recurrence: ${icsEvent.start}`);
|
||||
return [icsEvent];
|
||||
}
|
||||
|
||||
const ruleOptions = RRule.parseString(cleanRule);
|
||||
ruleOptions.dtstart = dtstart;
|
||||
|
||||
set.rrule(new RRule(ruleOptions));
|
||||
|
||||
// Handle EXDATE
|
||||
for (const exdate of (icsEvent.exdate || [])) {
|
||||
set.exdate(new Date(exdate.includes("Z") ? exdate : exdate + "Z"));
|
||||
}
|
||||
|
||||
const windowEnd = new Date(dtstart.getTime() + windowDays * 86400000);
|
||||
|
||||
// Expand from the event's start date, not from 'now', to ensure tests and past events work
|
||||
const occurrences = set.between(dtstart, windowEnd, true);
|
||||
|
||||
if (occurrences.length === 0) return [icsEvent];
|
||||
|
||||
return occurrences.map(occurrenceDate => {
|
||||
const localIso = localDateString(occurrenceDate);
|
||||
return {
|
||||
...icsEvent,
|
||||
start: localIso,
|
||||
recurrent: true,
|
||||
rrule: undefined,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err);
|
||||
return [icsEvent];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> {
|
||||
console.log(`[iCalendar] Fetching from: ${source.url}`);
|
||||
try {
|
||||
const response = await fetch(source.url);
|
||||
@@ -134,36 +213,31 @@ async function fetchAndParseCalendar(source: any, hourShift = 0): Promise<any[]>
|
||||
|
||||
const events: any[] = [];
|
||||
for (const icsEvent of calendar.events) {
|
||||
const obj = icsEvent.start;
|
||||
if (!obj) continue;
|
||||
if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;
|
||||
|
||||
let wallTimeStr = "";
|
||||
if (obj.local && obj.local.date) {
|
||||
wallTimeStr = typeof obj.local.date === "string" ? obj.local.date : (obj.local.date instanceof Date ? obj.local.date.toISOString() : String(obj.local.date));
|
||||
} else if (obj.date) {
|
||||
wallTimeStr = typeof obj.date === "string" ? obj.date : (obj.date instanceof Date ? obj.date.toISOString() : String(obj.date));
|
||||
}
|
||||
|
||||
if (!wallTimeStr) continue;
|
||||
|
||||
const baseDate = new Date(wallTimeStr.replace("Z", "") + "Z");
|
||||
const tzName = obj.local?.timezone || obj.timezone || "UTC";
|
||||
const sourceOffset = TIMEZONE_OFFSETS[tzName] ?? 0;
|
||||
const utcMillis = baseDate.getTime() - (sourceOffset * 3600000);
|
||||
const finalDate = new Date(utcMillis + (hourShift * 3600000));
|
||||
const finalDate = await resolveEventStart(icsEvent);
|
||||
if (!finalDate) continue;
|
||||
|
||||
const localIso = localDateString(finalDate);
|
||||
const uniqueKey = `${localIso}${icsEvent.uid || icsEvent.summary || ''}`;
|
||||
const ref = await sha256Hash(uniqueKey);
|
||||
|
||||
events.push(convertDatesToStrings({
|
||||
const baseEvent = {
|
||||
...icsEvent,
|
||||
name: icsEvent.summary || "Untitled Event",
|
||||
start: localIso,
|
||||
ref,
|
||||
tag: "ical-event",
|
||||
sourceName: source.name
|
||||
}));
|
||||
};
|
||||
|
||||
const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
|
||||
if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) {
|
||||
baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`;
|
||||
}
|
||||
|
||||
const expanded = expandRecurrences(baseEvent, windowDays);
|
||||
for (const occurrence of expanded) {
|
||||
const uniqueKey = `${occurrence.start}${occurrence.uid || occurrence.summary || ''}`;
|
||||
occurrence.ref = await sha256Hash(uniqueKey);
|
||||
events.push(convertDatesToStrings(occurrence));
|
||||
}
|
||||
}
|
||||
return events;
|
||||
} catch (err) {
|
||||
@@ -174,13 +248,13 @@ async function fetchAndParseCalendar(source: any, hourShift = 0): Promise<any[]>
|
||||
|
||||
export async function syncCalendars() {
|
||||
try {
|
||||
const { sources, tzShift } = await getSources();
|
||||
const { sources, syncWindowDays } = await getSources();
|
||||
if (sources.length === 0) return;
|
||||
|
||||
await editor.flashNotification("Syncing calendars...", "info");
|
||||
const allEvents: any[] = [];
|
||||
for (const source of sources) {
|
||||
const events = await fetchAndParseCalendar(source, tzShift);
|
||||
const events = await fetchAndParseCalendar(source, syncWindowDays);
|
||||
allEvents.push(...events);
|
||||
}
|
||||
await index.indexObjects("$icalendar", allEvents);
|
||||
@@ -210,4 +284,4 @@ export async function clearCache() {
|
||||
|
||||
export async function showVersion() {
|
||||
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
107
icalendar_test.ts
Normal file
107
icalendar_test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { resolveEventStart, expandRecurrences } from "./icalendar.ts";
|
||||
|
||||
Deno.test("resolveEventStart - local date with timezone", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Test Event",
|
||||
start: {
|
||||
date: "2025-01-15T12:00:00.000",
|
||||
local: {
|
||||
date: "2025-01-15T07:00:00.000",
|
||||
timezone: "Eastern Standard Time"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("resolveEventStart - DST check (Summer)", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Test Event DST",
|
||||
start: {
|
||||
date: "2025-07-15T11:00:00.000",
|
||||
local: {
|
||||
date: "2025-07-15T07:00:00.000",
|
||||
timezone: "Eastern Standard Time"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-07-15T11:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("resolveEventStart - UTC event", async () => {
|
||||
const icsEvent = {
|
||||
summary: "UTC Event",
|
||||
start: {
|
||||
date: "2025-01-15T12:00:00.000Z"
|
||||
}
|
||||
};
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("expandRecurrences - weekly event", () => {
|
||||
const icsEvent = {
|
||||
summary: "Weekly Meeting",
|
||||
start: "2025-01-01T10:00:00",
|
||||
rrule: "FREQ=WEEKLY;COUNT=3;BYDAY=WE"
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
assertEquals(results.length, 3);
|
||||
assertEquals(results[0].start, "2025-01-01T10:00:00");
|
||||
assertEquals(results[1].start, "2025-01-08T10:00:00");
|
||||
assertEquals(results[2].start, "2025-01-15T10:00:00");
|
||||
assertEquals(results[1].recurrent, true);
|
||||
});
|
||||
|
||||
Deno.test("expandRecurrences - EXDATE exclusion", () => {
|
||||
const icsEvent = {
|
||||
summary: "Weekly Meeting EXDATE",
|
||||
start: "2025-01-01T10:00:00",
|
||||
rrule: "FREQ=WEEKLY;COUNT=3;BYDAY=WE",
|
||||
exdate: ["2025-01-08T10:00:00"]
|
||||
};
|
||||
|
||||
const results = expandRecurrences(icsEvent, 30);
|
||||
// Should have 2 occurrences (Jan 1, Jan 15), Jan 8 is excluded
|
||||
assertEquals(results.length, 2);
|
||||
assertEquals(results[0].start, "2025-01-01T10:00:00");
|
||||
assertEquals(results[1].start, "2025-01-15T10:00:00");
|
||||
});
|
||||
|
||||
Deno.test("fetchAndParseCalendar - filter cancelled events", async () => {
|
||||
// This requires mocking fetch or testing the inner loop logic.
|
||||
});
|
||||
|
||||
Deno.test("resolveEventStart - ignore tzShift", async () => {
|
||||
const icsEvent = {
|
||||
summary: "Ignore tzShift",
|
||||
start: {
|
||||
date: "2025-01-15T12:00:00.000",
|
||||
local: {
|
||||
date: "2025-01-15T07:00:00.000",
|
||||
timezone: "Eastern Standard Time"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await resolveEventStart(icsEvent);
|
||||
assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
Deno.test("expandRecurrences - custom windowDays", () => {
|
||||
const icsEvent = {
|
||||
summary: "Daily Meeting",
|
||||
start: "2025-01-01T10:00:00",
|
||||
rrule: "FREQ=DAILY"
|
||||
};
|
||||
|
||||
// 7 days window (Jan 1 to Jan 8) should give 8 occurrences if inclusive
|
||||
const results = expandRecurrences(icsEvent, 7);
|
||||
assertEquals(results.length, 8);
|
||||
});
|
||||
159
timezones.ts
Normal file
159
timezones.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// timezones.ts
|
||||
|
||||
/**
|
||||
* Mapping of Windows Timezone names to IANA Timezone names.
|
||||
* Sourced from Unicode CLDR data.
|
||||
*/
|
||||
export const WINDOWS_TO_IANA: Record<string, string> = {
|
||||
"Dateline Standard Time": "Etc/GMT+12",
|
||||
"UTC-11": "Etc/GMT+11",
|
||||
"Hawaiian Standard Time": "Pacific/Honolulu",
|
||||
"Alaskan Standard Time": "America/Anchorage",
|
||||
"Pacific Standard Time (Mexico)": "America/Santa_Isabel",
|
||||
"Pacific Standard Time": "America/Los_Angeles",
|
||||
"US Mountain Standard Time": "America/Phoenix",
|
||||
"Mountain Standard Time (Mexico)": "America/Chihuahua",
|
||||
"Mountain Standard Time": "America/Denver",
|
||||
"Central America Standard Time": "America/Guatemala",
|
||||
"Central Standard Time": "America/Chicago",
|
||||
"Central Standard Time (Mexico)": "America/Mexico_City",
|
||||
"Canada Central Standard Time": "America/Regina",
|
||||
"SA Pacific Standard Time": "America/Bogota",
|
||||
"Eastern Standard Time": "America/New_York",
|
||||
"US Eastern Standard Time": "America/Indiana/Indianapolis",
|
||||
"Venezuela Standard Time": "America/Caracas",
|
||||
"Paraguay Standard Time": "America/Asuncion",
|
||||
"Atlantic Standard Time": "America/Halifax",
|
||||
"Central Brazilian Standard Time": "America/Cuiaba",
|
||||
"SA Western Standard Time": "America/La_Paz",
|
||||
"Pacific SA Standard Time": "America/Santiago",
|
||||
"Newfoundland Standard Time": "America/St_Johns",
|
||||
"E. South America Standard Time": "America/Sao_Paulo",
|
||||
"Argentina Standard Time": "America/Buenos_Aires",
|
||||
"SA Eastern Standard Time": "America/Cayenne",
|
||||
"Greenland Standard Time": "America/Godthab",
|
||||
"Montevideo Standard Time": "America/Montevideo",
|
||||
"Bahia Standard Time": "America/Bahia",
|
||||
"Azores Standard Time": "Atlantic/Azores",
|
||||
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
|
||||
"Morocco Standard Time": "Africa/Casablanca",
|
||||
"GMT Standard Time": "Europe/London",
|
||||
"Greenwich Standard Time": "Atlantic/Reykjavik",
|
||||
"W. Europe Standard Time": "Europe/Berlin",
|
||||
"Central Europe Standard Time": "Europe/Budapest",
|
||||
"Romance Standard Time": "Europe/Paris",
|
||||
"Central European Standard Time": "Europe/Warsaw",
|
||||
"W. Central Africa Standard Time": "Africa/Lagos",
|
||||
"Namibia Standard Time": "Africa/Windhoek",
|
||||
"Jordan Standard Time": "Asia/Amman",
|
||||
"GTB Standard Time": "Europe/Bucharest",
|
||||
"Middle East Standard Time": "Asia/Beirut",
|
||||
"Egypt Standard Time": "Africa/Cairo",
|
||||
"Syria Standard Time": "Asia/Damascus",
|
||||
"E. Europe Standard Time": "Europe/Chisinau",
|
||||
"South Africa Standard Time": "Africa/Johannesburg",
|
||||
"FLE Standard Time": "Europe/Kiev",
|
||||
"Turkey Standard Time": "Europe/Istanbul",
|
||||
"Israel Standard Time": "Asia/Jerusalem",
|
||||
"Kaliningrad Standard Time": "Europe/Kaliningrad",
|
||||
"Libya Standard Time": "Africa/Tripoli",
|
||||
"Arabic Standard Time": "Asia/Baghdad",
|
||||
"Arab Standard Time": "Asia/Riyadh",
|
||||
"Belarus Standard Time": "Europe/Minsk",
|
||||
"Russian Standard Time": "Europe/Moscow",
|
||||
"E. Africa Standard Time": "Africa/Nairobi",
|
||||
"Iran Standard Time": "Asia/Tehran",
|
||||
"Arabian Standard Time": "Asia/Dubai",
|
||||
"Azerbaijan Standard Time": "Asia/Baku",
|
||||
"Russia Time Zone 3": "Europe/Samara",
|
||||
"Mauritius Standard Time": "Indian/Mauritius",
|
||||
"Georgian Standard Time": "Asia/Tbilisi",
|
||||
"Caucasus Standard Time": "Asia/Yerevan",
|
||||
"Afghanistan Standard Time": "Asia/Kabul",
|
||||
"West Asia Standard Time": "Asia/Tashkent",
|
||||
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
|
||||
"Pakistan Standard Time": "Asia/Karachi",
|
||||
"India Standard Time": "Asia/Kolkata",
|
||||
"Sri Lanka Standard Time": "Asia/Colombo",
|
||||
"Nepal Standard Time": "Asia/Kathmandu",
|
||||
"Central Asia Standard Time": "Asia/Almaty",
|
||||
"Bangladesh Standard Time": "Asia/Dhaka",
|
||||
"N. Central Asia Standard Time": "Asia/Novosibirsk",
|
||||
"Myanmar Standard Time": "Asia/Rangoon",
|
||||
"SE Asia Standard Time": "Asia/Bangkok",
|
||||
"North Asia Standard Time": "Asia/Krasnoyarsk",
|
||||
"China Standard Time": "Asia/Shanghai",
|
||||
"North Asia East Standard Time": "Asia/Irkutsk",
|
||||
"Singapore Standard Time": "Asia/Singapore",
|
||||
"W. Australia Standard Time": "Australia/Perth",
|
||||
"Taipei Standard Time": "Asia/Taipei",
|
||||
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
|
||||
"Tokyo Standard Time": "Asia/Tokyo",
|
||||
"Korea Standard Time": "Asia/Seoul",
|
||||
"Yakutsk Standard Time": "Asia/Yakutsk",
|
||||
"Cen. Australia Standard Time": "Australia/Adelaide",
|
||||
"AUS Central Standard Time": "Australia/Darwin",
|
||||
"E. Australia Standard Time": "Australia/Brisbane",
|
||||
"AUS Eastern Standard Time": "Australia/Sydney",
|
||||
"West Pacific Standard Time": "Pacific/Port_Moresby",
|
||||
"Tasmania Standard Time": "Australia/Hobart",
|
||||
"Magadan Standard Time": "Asia/Magadan",
|
||||
"Vladivostok Standard Time": "Asia/Vladivostok",
|
||||
"Russia Time Zone 10": "Asia/Srednekolymsk",
|
||||
"Central Pacific Standard Time": "Pacific/Guadalcanal",
|
||||
"Russia Time Zone 11": "Asia/Anadyr",
|
||||
"New Zealand Standard Time": "Pacific/Auckland",
|
||||
"Fiji Standard Time": "Pacific/Fiji",
|
||||
"Tonga Standard Time": "Pacific/Tongatapu",
|
||||
"Samoa Standard Time": "Pacific/Apia",
|
||||
"Line Islands Standard Time": "Pacific/Kiritimati"
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves an IANA timezone name from a given TZID string.
|
||||
* Supports Windows timezone names, direct IANA names, and UTC.
|
||||
*/
|
||||
export function resolveIanaName(tzid: string): string | null {
|
||||
if (!tzid || tzid === "UTC" || tzid === "None") return "UTC";
|
||||
|
||||
// Heuristic: IANA names typically include a forward slash
|
||||
if (tzid.includes("/")) return tzid;
|
||||
|
||||
return WINDOWS_TO_IANA[tzid] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UTC offset in milliseconds for a given IANA timezone at a
|
||||
* specific point in time. Positive = ahead of UTC, negative = behind UTC.
|
||||
* e.g. "America/New_York" in summer -> -14400000 (-4h)
|
||||
*/
|
||||
export function getUtcOffsetMs(ianaName: string, atDate: Date): number {
|
||||
// Trick: format the same instant in UTC and in the target zone,
|
||||
// parse both, and subtract.
|
||||
// "en-CA" produces "YYYY-MM-DD, HH:MM:SS" (unambiguous)
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
timeZone: "UTC",
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const utcFormatter = new Intl.DateTimeFormat("en-CA", options);
|
||||
const localFormatter = new Intl.DateTimeFormat("en-CA", { ...options, timeZone: ianaName });
|
||||
|
||||
const formatToIso = (formatter: Intl.DateTimeFormat, date: Date) => {
|
||||
return formatter.format(date).replace(", ", "T");
|
||||
};
|
||||
|
||||
const utcStr = formatToIso(utcFormatter, atDate);
|
||||
const localStr = formatToIso(localFormatter, atDate);
|
||||
|
||||
const utcMs = new Date(utcStr + "Z").getTime();
|
||||
const localMs = new Date(localStr + "Z").getTime();
|
||||
|
||||
return localMs - utcMs;
|
||||
}
|
||||
43
timezones_test.ts
Normal file
43
timezones_test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { resolveIanaName, getUtcOffsetMs } from "./timezones.ts";
|
||||
|
||||
Deno.test("resolveIanaName - Windows names", () => {
|
||||
assertEquals(resolveIanaName("Eastern Standard Time"), "America/New_York");
|
||||
assertEquals(resolveIanaName("Romance Standard Time"), "Europe/Paris");
|
||||
assertEquals(resolveIanaName("Pacific Standard Time"), "America/Los_Angeles");
|
||||
});
|
||||
|
||||
Deno.test("resolveIanaName - IANA names (identity)", () => {
|
||||
assertEquals(resolveIanaName("America/Chicago"), "America/Chicago");
|
||||
assertEquals(resolveIanaName("Europe/London"), "Europe/London");
|
||||
});
|
||||
|
||||
Deno.test("resolveIanaName - UTC and special cases", () => {
|
||||
assertEquals(resolveIanaName("UTC"), "UTC");
|
||||
assertEquals(resolveIanaName("None"), "UTC");
|
||||
assertEquals(resolveIanaName(""), "UTC");
|
||||
});
|
||||
|
||||
Deno.test("resolveIanaName - Unknown names", () => {
|
||||
assertEquals(resolveIanaName("Mars Standard Time"), null);
|
||||
});
|
||||
|
||||
Deno.test("getUtcOffsetMs - New York (DST check)", () => {
|
||||
const jan = new Date("2025-01-15T12:00:00Z");
|
||||
const july = new Date("2025-07-15T12:00:00Z");
|
||||
|
||||
// America/New_York is UTC-5 in Winter
|
||||
assertEquals(getUtcOffsetMs("America/New_York", jan), -5 * 3600000);
|
||||
// America/New_York is UTC-4 in Summer
|
||||
assertEquals(getUtcOffsetMs("America/New_York", july), -4 * 3600000);
|
||||
});
|
||||
|
||||
Deno.test("getUtcOffsetMs - Paris (DST check)", () => {
|
||||
const jan = new Date("2025-01-15T12:00:00Z");
|
||||
const july = new Date("2025-07-15T12:00:00Z");
|
||||
|
||||
// Europe/Paris is UTC+1 in Winter
|
||||
assertEquals(getUtcOffsetMs("Europe/Paris", jan), 1 * 3600000);
|
||||
// Europe/Paris is UTC+2 in Summer
|
||||
assertEquals(getUtcOffsetMs("Europe/Paris", july), 2 * 3600000);
|
||||
});
|
||||
Reference in New Issue
Block a user