팀 휴가 캘린더 채우기

코딩 수준: 초급
시간: 15분
프로젝트 유형: 시간 기반 트리거를 사용한 자동화

목표

  • 솔루션의 기능을 이해합니다.
  • 솔루션 내에서 Apps Script 서비스가 하는 작업을 이해합니다.
  • 스크립트를 설정합니다.
  • 스크립트를 실행합니다.

이 솔루션 정보

공유 휴가 캘린더는 팀원이 공동작업하는 데 유용한 도구로, 누구나 누가 부재중인지 한눈에 확인할 수 있습니다. 이 솔루션을 사용하면 직접 입력할 필요 없이 동료가 부재중인지 확인할 수 있습니다.

휴가 캘린더 예시

사용 방법

이 솔루션은 Google 그룹에 있는 각 사용자의 개별 캘린더를 기반으로 공유 휴가 캘린더를 채웁니다. 휴가를 예약하면 '휴가' 또는 '부재중'과 같은 키워드를 사용하여 개인 Google Calendar에 일정을 추가합니다.

스크립트는 매시간 그룹 내 구성원의 캘린더를 검색하고 적절한 이벤트를 공유 캘린더에 동기화합니다. 스크립트가 새 이벤트를 스캔하는 빈도를 변경할 수 있습니다.

이 솔루션은 동료가 개인 정보 보호 설정을 통해 나에게 공개한 캘린더 일정에만 액세스합니다.

Apps Script 서비스

이 솔루션은 다음 서비스를 사용합니다.

기본 요건

이 샘플을 사용하려면 다음과 같은 기본 요건이 필요합니다.

  • Google 계정 (Google Workspace 계정은 관리자 승인이 필요할 수 있음)
  • 인터넷에 액세스할 수 있는 웹브라우저

스크립트 설정

팀의 휴가 캘린더 만들기

  1. Google 캘린더를 엽니다.
  2. '팀 휴가'라는 이름의 새 캘린더를 만듭니다.
  3. 캘린더 설정의 캘린더 통합에서 캘린더 ID를 복사합니다.

Apps Script 프로젝트 만들기

  1. 다음 버튼을 클릭하여 휴가 캘린더 Apps Script 프로젝트를 엽니다.
    프로젝트 열기
  2. 개요 를 클릭합니다.
  3. 개요 페이지에서 사본 만들기 사본 만들기 아이콘를 클릭합니다.
  4. 복사한 Apps Script 프로젝트에서 TEAM_CALENDAR_ID 변수를 앞에서 만든 캘린더의 ID로 설정합니다.
  5. GROUP_EMAIL 변수를 팀 구성원이 포함된 Google 그룹의 이메일 주소로 설정합니다.
  6. 서비스 옆에 있는 서비스 추가 를 클릭합니다.
  7. Google Calendar API를 선택하고 추가를 클릭합니다.

스크립트 실행

  1. 복사된 Apps Script 프로젝트의 함수 드롭다운에서 setup을 선택합니다.
  2. 실행을 클릭합니다.
  3. 메시지가 표시되면 스크립트를 승인합니다. OAuth 동의 화면에 이 앱이 확인되지 않았습니다라는 경고가 표시되면 고급 > {프로젝트 이름}으로 이동(안전하지 않음)을 선택하여 계속 진행합니다.

  4. 완료되면 캘린더로 돌아와 팀 휴가 캘린더에 일정이 채워져 있는지 확인합니다.

코드 검토

이 솔루션의 Apps Script 코드를 검토하려면 아래의 소스 코드 보기를 클릭하세요.

소스 코드 보기

Code.gs

solutions/automations/vacation-calendar/Code.js
// To learn how to use this script, refer to the documentation:
// https://developers.google.com/apps-script/samples/automations/vacation-calendar

/*
Copyright 2022 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Set the ID of the team calendar to add events to. You can find the calendar's
// ID on the settings page.
let TEAM_CALENDAR_ID = 'ENTER_TEAM_CALENDAR_ID_HERE';
// Set the email address of the Google Group that contains everyone in the team.
// Ensure the group has less than 500 members to avoid timeouts.
// Change to an array in order to add indirect members frrm multiple groups, for example:
// let GROUP_EMAIL = ['ENTER_GOOGLE_GROUP_EMAIL_HERE', 'ENTER_ANOTHER_GOOGLE_GROUP_EMAIL_HERE'];
let GROUP_EMAIL = 'ENTER_GOOGLE_GROUP_EMAIL_HERE';

let ONLY_DIRECT_MEMBERS = false;

let KEYWORDS = ['vacation', 'ooo', 'out of office', 'offline'];
let MONTHS_IN_ADVANCE = 3;

/**
 * Sets up the script to run automatically every hour.
 */
function setup() {
  let triggers = ScriptApp.getProjectTriggers();
  if (triggers.length > 0) {
    throw new Error('Triggers are already setup.');
  }
  ScriptApp.newTrigger('sync').timeBased().everyHours(1).create();
  // Runs the first sync immediately.
  sync();
}

/**
 * Looks through the group members' public calendars and adds any
 * 'vacation' or 'out of office' events to the team calendar.
 */
function sync() {
  // Defines the calendar event date range to search.
  let today = new Date();
  let maxDate = new Date();
  maxDate.setMonth(maxDate.getMonth() + MONTHS_IN_ADVANCE);

  // Determines the time the the script was last run.
  let lastRun = PropertiesService.getScriptProperties().getProperty('lastRun');
  lastRun = lastRun ? new Date(lastRun) : null;

  // Gets the list of users in the Google Group.
  let users = getAllMembers(GROUP_EMAIL);
  if (ONLY_DIRECT_MEMBERS){
    users = GroupsApp.getGroupByEmail(GROUP_EMAIL).getUsers();
  } else if (Array.isArray(GROUP_EMAIL)) {
    users = getUsersFromGroups(GROUP_EMAIL);
  }

  // For each user, finds events having one or more of the keywords in the event
  // summary in the specified date range. Imports each of those to the team
  // calendar.
  let count = 0;
  users.forEach(function(user) {
    let username = user.getEmail().split('@')[0];
    KEYWORDS.forEach(function(keyword) {
      let events = findEvents(user, keyword, today, maxDate, lastRun);
      events.forEach(function(event) {
        importEvent(username, event);
        count++;
      }); // End foreach event.
    }); // End foreach keyword.
  }); // End foreach user.

  PropertiesService.getScriptProperties().setProperty('lastRun', today);
  console.log('Imported ' + count + ' events');
}

/**
 * Imports the given event from the user's calendar into the shared team
 * calendar.
 * @param {string} username The team member that is attending the event.
 * @param {Calendar.Event} event The event to import.
 */
function importEvent(username, event) {
  event.summary = '[' + username + '] ' + event.summary;
  event.organizer = {
    id: TEAM_CALENDAR_ID,
  };
  event.attendees = [];

  // If the event is not of type 'default', it can't be imported, so it needs
  // to be changed.
  if (event.eventType != 'default') {
    event.eventType = 'default';
    delete event.outOfOfficeProperties;
    delete event.focusTimeProperties;
  }

  console.log('Importing: %s', event.summary);
  try {
    Calendar.Events.import(event, TEAM_CALENDAR_ID);
  } catch (e) {
    console.error('Error attempting to import event: %s. Skipping.',
        e.toString());
  }
}

/**
 * In a given user's calendar, looks for occurrences of the given keyword
 * in events within the specified date range and returns any such events
 * found.
 * @param {Session.User} user The user to retrieve events for.
 * @param {string} keyword The keyword to look for.
 * @param {Date} start The starting date of the range to examine.
 * @param {Date} end The ending date of the range to examine.
 * @param {Date} optSince A date indicating the last time this script was run.
 * @return {Calendar.Event[]} An array of calendar events.
 */
function findEvents(user, keyword, start, end, optSince) {
  let params = {
    q: keyword,
    timeMin: formatDateAsRFC3339(start),
    timeMax: formatDateAsRFC3339(end),
    showDeleted: true,
  };
  if (optSince) {
    // This prevents the script from examining events that have not been
    // modified since the specified date (that is, the last time the
    // script was run).
    params.updatedMin = formatDateAsRFC3339(optSince);
  }
  let pageToken = null;
  let events = [];
  do {
    params.pageToken = pageToken;
    let response;
    try {
      response = Calendar.Events.list(user.getEmail(), params);
    } catch (e) {
      console.error('Error retriving events for %s, %s: %s; skipping',
          user, keyword, e.toString());
      continue;
    }
    events = events.concat(response.items.filter(function(item) {
      return shouldImportEvent(user, keyword, item);
    }));
    pageToken = response.nextPageToken;
  } while (pageToken);
  return events;
}

/**
 * Determines if the given event should be imported into the shared team
 * calendar.
 * @param {Session.User} user The user that is attending the event.
 * @param {string} keyword The keyword being searched for.
 * @param {Calendar.Event} event The event being considered.
 * @return {boolean} True if the event should be imported.
 */
function shouldImportEvent(user, keyword, event) {
  // Filters out events where the keyword did not appear in the summary
  // (that is, the keyword appeared in a different field, and are thus
  // is not likely to be relevant).
  if (event.summary.toLowerCase().indexOf(keyword) < 0) {
    return false;
  }
  if (!event.organizer || event.organizer.email == user.getEmail()) {
    // If the user is the creator of the event, always imports it.
    return true;
  }
  // Only imports events the user has accepted.
  if (!event.attendees) return false;
  let matching = event.attendees.filter(function(attendee) {
    return attendee.self;
  });
  return matching.length > 0 && matching[0].responseStatus == 'accepted';
}

/**
 * Returns an RFC3339 formated date String corresponding to the given
 * Date object.
 * @param {Date} date a Date.
 * @return {string} a formatted date string.
 */
function formatDateAsRFC3339(date) {
  return Utilities.formatDate(date, 'UTC', 'yyyy-MM-dd\'T\'HH:mm:ssZ');
}

/**
* Get both direct and indirect members (and delete duplicates).
* @param {string} the e-mail address of the group.
* @return {object} direct and indirect members.
*/
function getAllMembers(groupEmail) {
  var group = GroupsApp.getGroupByEmail(groupEmail);
  var users = group.getUsers();
  var childGroups = group.getGroups();
  for (var i = 0; i < childGroups.length; i++) {
    var childGroup = childGroups[i];
    users = users.concat(getAllMembers(childGroup.getEmail()));
  }
  // Remove duplicate members
  var uniqueUsers = [];
  var userEmails = {};
  for (var i = 0; i < users.length; i++) {
    var user = users[i];
    if (!userEmails[user.getEmail()]) {
      uniqueUsers.push(user);
      userEmails[user.getEmail()] = true;
    }
  }
  return uniqueUsers;
}

/**
* Get indirect members from multiple groups (and delete duplicates).
* @param {array} the e-mail addresses of multiple groups.
* @return {object} indirect members of multiple groups.
*/
function getUsersFromGroups(groupEmails) {
  let users = [];
  for (let groupEmail of groupEmails) {
    let groupUsers = GroupsApp.getGroupByEmail(groupEmail).getUsers();
    for (let user of groupUsers) {
      if (!users.some(u => u.getEmail() === user.getEmail())) {
        users.push(user);
      }
    }
  }
  return users;
}

수정

팀의 휴가 캘린더 자동화는 필요에 맞게 원하는 만큼 수정할 수 있습니다. 다음은 트리거를 수정하기 위한 선택적 변경사항입니다.

스크립트에서 새 이벤트를 스캔하는 빈도 변경

스크립트 실행 빈도를 변경하려면 다음 단계를 따르세요.

  1. Apps Script 프로젝트에서 트리거 를 클릭합니다.
  2. 트리거 옆에 있는 트리거 수정 을 클릭합니다.
  3. 변경사항을 선택하고 저장을 클릭합니다.

기여자

이 샘플은 Google Developer Expert의 도움을 받아 Google에서 관리합니다.

다음 단계