Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic Calendar Options for go-calendar Project #5

Open
selfish opened this issue Apr 3, 2023 · 9 comments
Open

Dynamic Calendar Options for go-calendar Project #5

selfish opened this issue Apr 3, 2023 · 9 comments
Labels
enhancement New feature or request

Comments

@selfish
Copy link

selfish commented Apr 3, 2023

Option 2 is an awesome suggestion and was my original path. [...] So although its absolutely the right path to take, I'm not sure its perhaps the best one in this scenario.

Originally posted by @othyn in #4 (comment)

--

Hey @othyn,

I've been thinking about your comment on Option 2 in the go-calendar project, and I decided to give the dynamic option a try. I hope it's okay that I decided to split the discussion to a new issue.

If your domain is on Cloudflare, it would be super easy to set up with a Cloudflare Worker (which is free). I made a proof of concept and put it under my personal domain. It's not fully tested, so there might be some errors, but here are the links for reference:

Description URL
Main calendar (defaults to UTC) https://nit.ai/gocal.ics
Timezone-specific with timezone param https://nit.ai/gocal.ics?timezone=America/Port-au-Prince
List of valid timezones shows if you type in a wrong one https://nit.ai/gocal.ics?timezone=foo
Exclude events (blacklist style) https://nit.ai/gocal.ics?exclude=community_day
Exclude multiple events https://nit.ai/gocal.ics?exclude=community_day&exclude=elite_raids
List of valid categories shows if you type in a wrong one https://nit.ai/gocal.ics?exclude=foo
Include events (whitelist style) https://nit.ai/gocal.ics?include=pokemon_spotlight_hour
Timezone and filter combined https://nit.ai/gocal.ics?timezone=America/Port-au-Prince&exclude=season&exclude=research
Special category all_day (see notes) https://nit.ai/gocal.ics?exclude=all_day

Just a few notes:

  1. As I mentioned, this isn't fully tested, so there might be some issues.
  2. 'Exclude' and 'include' parameters can't be used together (not sure about the logic if both are used).
  3. I added a special category for 'exclude/include' called all_day, which filters all-day events across categories.

I've actually been using it with Google Calendar for about a week now with no issues.

I'd be happy to contribute the full code for this! Let me know the best way to do that, and if you need any help setting it up under your own domain.

@othyn
Copy link
Owner

othyn commented Apr 4, 2023

Awesome! Thank you for getting a proof of concept online, that's sweet.

Indeed I do use CF as the registrar/domain host, did not know their workers are free - thats cool.

Feel free to submit this as a PR and I can review it when I get time and look at deploying something like this.

I think the binary nature of include exclude is fine, a UI for the URL builder could reflect that. I can't envisiage a scenario where you are already only including what you want to then need to also exclude something, or vice versa.

@disengaged
Copy link

This is awesome @selfish! I just started using the official calendar but since I want to use it with Google Calendar it didn't work so now I've switched to using your solution. Hope you keep hosting your version of it until it can get integrated in the official one.

@selfish
Copy link
Author

selfish commented Jun 5, 2023

F me, I got a bit overwhelmed by life after we spoke, and I completely forgot to respond.

@othyn, I'm sorry for the delay.

Here's the full worker code, free to use.
It does resolve the google cal issues.

I'll leave mine running for the foreseeable future, and if you need any help setting it up, I'm happy to chat.

const categories = {
    community_day: '[Community Day]',
    elite_raids: '[Elite Raids]',
    event: '[Event]',
    go_battle_league: '[GO Battle League]',
    limited_research: '[Limited Research]',
    pokemon_go_tour: '[Pokémon GO Tour]',
    pokemon_spotlight_hour: '[Pokémon Spotlight Hour]',
    raid_battles: '[Raid Battles]',
    raid_hour: '[Raid Hour]',
    research_breakthrough: '[Research Breakthrough]',
    research: '[Research]',
    season: '[Season]',
    team_go_rocket: '[Team GO Rocket]',
    timed_research: '[Timed Research]',
    update: '[Update]',
    all_day: 'all_day'
  };

/**
 * Fetch the ICS calendar file, add timezone info, and respond with the modified file.
 * @param {Request} request
 */
async function handleRequest(request) {
  const url = new URL(request.url);
  const timezone = url.searchParams.get('timezone') || 'UTC';
  const exclude = url.searchParams.getAll('exclude');
  const include = url.searchParams.getAll('include');

  if (!isValidTimezone(timezone)) {
    const validTimezones = getAllValidTimezones();
    return new Response(`Invalid timezone: ${timezone}\n\nValid timezones:\n${validTimezones.join('\n')}`, {
      status: 400,
      headers: { 'content-type': 'text/plain' },
    });
  }

  if (exclude.length > 0 && include.length > 0) {
    return new Response('Please use either "exclude" or "include" parameters, not both.', {
      status: 400,
      headers: { 'content-type': 'text/plain' },
    });
  }

  const invalidExcludes = exclude.filter(value => !(value in categories));
  const invalidIncludes = include.filter(value => !(value in categories));

  if (invalidExcludes.length > 0 || invalidIncludes.length > 0) {
    const invalidValues = [...invalidExcludes, ...invalidIncludes].join(', ');
    const validCategories = Object.keys(categories).join('\n');

    return new Response(`Invalid categories provided: ${invalidValues}\n\nValid categories:\n${validCategories}`, {
      status: 400,
      headers: { 'content-type': 'text/plain' },
    });
  }

  const icsUrl = 'https://github.com/othyn/go-calendar/releases/latest/download/gocal.ics';
  const response = await fetch(icsUrl);
  const icsData = await response.text();

  const modifiedIcsData = addTimezoneInfo(icsData, timezone);
  
  if(exclude.length === 0 && include.length === 0){
    return new Response(modifiedIcsData, {
      headers: { 'content-type': 'text/calendar; charset=utf-8' },
    });
  }
  
  const filteredIcsData = filterEvents(modifiedIcsData, exclude, include);
  return new Response(filteredIcsData, {
    headers: { 'content-type': 'text/calendar; charset=utf-8' },
  });
}

/**
 * Check if the provided timezone is valid.
 * @param {string} timezone - The timezone to be validated.
 * @returns {boolean} True if the timezone is valid, otherwise false.
 */
function isValidTimezone(timezone) {
  try {
    new Intl.DateTimeFormat('en-US', { timeZone: timezone });
    return true;
  } catch (error) {
    return false;
  }
}

/**
 * Add timezone info to an ICS calendar file.
 * @param {string} icsData - The ICS calendar file data.
 * @param {string} timezone - The timezone to be added.
 * @returns {string} The modified ICS calendar file data with timezone info added.
 */
function addTimezoneInfo(icsData, timezone) {
  const lines = icsData.split('\r\n');
  const newLines = [];
  // DTSTART;TZID=Asia/Jerusalem:20230329T130000
  for (const line of lines) {
    if (line.startsWith('DTSTART')) {
      newLines.push(line.replace('DTSTART', `DTSTART;TZID=${timezone}`));
    } else if (line.startsWith('DTEND')) {
      newLines.push(line.replace('DTEND', `DTEND;TZID=${timezone}`));
    } else if (line.startsWith('BEGIN:VCALENDAR')) {
      newLines.push(line);
      newLines.push(`X-WR-TIMEZONE:${timezone}`);
    } else {
      newLines.push(line);
    }
  }

  return newLines.join('\r\n');
}

/**
 * Get all valid timezones.
 * @returns {Array<string>} An array of valid timezone strings.
 */
function getAllValidTimezones() {
  return Intl.supportedValuesOf('timeZone');
}

/**
 * Filter events based on include or exclude parameters.
 * @param {string} icsData - The ICS calendar file data.
 * @param {Array<string>} exclude - The categories to exclude.
 * @param {Array<string>} include - The categories to include.
 * @returns {string} The filtered ICS calendar file data.
 */
function filterEvents(icsData, exclude, include) {
  const lines = icsData.split('\r\n');
  const newLines = [];
  let insideEvent = false;
  let eventLines = [];
  let isAllDayEvent = undefined;

  for (const line of lines) {
    if (line.startsWith('BEGIN:VEVENT')) {
      insideEvent = true;
      eventLines = [];      
    }

    if (insideEvent) {
      eventLines.push(line);
    } else {
      newLines.push(line);
    }

    if (line.startsWith('END:VEVENT')) {
      insideEvent = false;
      const summaryLine = eventLines.find(eventLine => eventLine.startsWith('SUMMARY'));
      const startTimeLine = eventLines.find(eventLine => eventLine.startsWith('DTSTART'));
      const isAllDayEvent = startTimeLine.includes('VALUE=DATE:');
      const category = summaryLine.slice(summaryLine.indexOf('['), summaryLine.indexOf(']') + 1);
      const categoryKey = Object.keys(categories).find(key => categories[key] === category);

      if (exclude.length > 0 && !exclude.includes(categoryKey)) {
        if (exclude.includes('all_day') && isAllDayEvent) continue;
        newLines.push(...eventLines);
      } else if (include.length > 0 && include.includes(categoryKey)) {
        if (include.includes('all_day') && !isAllDayEvent) continue;
        newLines.push(...eventLines);
      }
    }
  }

  return newLines.join('\r\n');
}

export default {
  async fetch(request, env) {
    return handleRequest(request);
  },
};

@othyn
Copy link
Owner

othyn commented Aug 24, 2023

Amazing, thank you so much @selfish. Sorry for my delayed reply too, this is a fabulous contribution, thank you for your efforts. I too have been overcome by life as of late, not sure when I'll get a chance to properly implement all this. Especially as I haven't played the game in months - lost interest after all the remote raid pass changes.

@selfish
Copy link
Author

selfish commented Aug 29, 2023

Yep, my enthusiasm has settled as well after the recent changes, and admittedly, also the Pokemon Go Fest was a massive disappointment this year.
I feel you.

@BrentTheBert
Copy link

BrentTheBert commented Oct 17, 2023

Timezone-specific with timezone param https://nit.ai/gocal.ics?timezone=America/Port-au-Prince
List of valid timezones shows if you type in a wrong one https://nit.ai/gocal.ics?timezone=foo

These worked perfectly for me after I swapped the timezone parameter out.
I really appreciate all of the effort you both have put in so far for a game you no longer play!
It's been a little while since these posts, but I'm interested in helping out. What rough steps would be needed to make this easy for non-Github folks to use?

  • Adding @selfish 's code to the repo in a PR?
  • Marking it for cloudflare to run on it's workers?

Does Selfish have a forked repo I could look at to see how it works on their personal domain? Or is it more on the config side of Cloudflare where the changes are needed?

@selfish
Copy link
Author

selfish commented Oct 17, 2023

@BrentTheBert this has to be manually copied into the CF config so @othyn has to do it. It may be possible to setup using some ci/cd, but setting up something like that will probably require even more effort.

I have no fork, because the code I shared is meaningless without CF, but feel free to use it through my domain for now (as I have been doing for months) and if you want me to make changes, we can communicate over here or if you want to PR over this I can make a repo/gist.

Cheers

@othyn
Copy link
Owner

othyn commented Oct 19, 2023

It won't be something I broach soon, so by all means create a fork or use the current hosted version 👍 thank you @selfish!

@Zachatoo
Copy link

Zachatoo commented Feb 8, 2024

The cloudflare worker code will need to be adjusted slightly to account for the acronym changes made in 2.4.0 a few days ago.

The only part that needs to be changed is the categories variable.

Before:

const categories = {
    community_day: '[Community Day]',
    elite_raids: '[Elite Raids]',
    event: '[Event]',
    go_battle_league: '[GO Battle League]',
    limited_research: '[Limited Research]',
    pokemon_go_tour: '[Pokémon GO Tour]',
    pokemon_spotlight_hour: '[Pokémon Spotlight Hour]',
    raid_battles: '[Raid Battles]',
    raid_hour: '[Raid Hour]',
    research_breakthrough: '[Research Breakthrough]',
    research: '[Research]',
    season: '[Season]',
    team_go_rocket: '[Team GO Rocket]',
    timed_research: '[Timed Research]',
    update: '[Update]',
    all_day: 'all_day'
  };

After:

const categories = {
    community_day: '[CD]',
    elite_raids: '[ER]',
    event: '[E]',
    go_battle_league: '[GBL]',
    limited_research: '[LR]',
    pokemon_go_tour: '[PGT]',
    pokemon_spotlight_hour: '[PSH]',
    raid_battles: '[RB]',
    raid_hour: '[RH]',
    research_breakthrough: '[RBT]',
    research: '[R]',
    season: '[S]',
    team_go_rocket: '[TGR]',
    timed_research: '[TR]',
    update: '[U]',
    all_day: 'all_day'
  };

I've tested this on my own cloudflare worker and it appears to be working as expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants