package org.ryu22e.nico2cal.service; import java.io.File; import java.io.IOException; import java.io.StringWriter; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.TimeZone; import java.util.logging.Logger; import net.fortuna.ical4j.model.Calendar; import net.fortuna.ical4j.model.DateTime; import net.fortuna.ical4j.model.PropertyList; import net.fortuna.ical4j.model.component.VEvent; import net.fortuna.ical4j.model.property.Description; import net.fortuna.ical4j.model.property.DtEnd; import net.fortuna.ical4j.model.property.DtStart; import net.fortuna.ical4j.model.property.ProdId; import net.fortuna.ical4j.model.property.Summary; import net.fortuna.ical4j.model.property.Url; import net.fortuna.ical4j.model.property.Version; import net.fortuna.ical4j.model.property.XProperty; import org.ryu22e.nico2cal.meta.MyCalendarLogMeta; import org.ryu22e.nico2cal.meta.MyCalendarMeta; import org.ryu22e.nico2cal.meta.NicoliveIndexMeta; import org.ryu22e.nico2cal.meta.NicoliveMeta; import org.ryu22e.nico2cal.model.MyCalendar; import org.ryu22e.nico2cal.model.MyCalendarLog; import org.ryu22e.nico2cal.model.Nicolive; import org.ryu22e.nico2cal.model.NicoliveIndex; import org.ryu22e.nico2cal.util.GoogleApiKeyUtil; import org.ryu22e.nico2cal.util.HtmlRemoveUtil; import org.slim3.datastore.Datastore; import org.slim3.datastore.ModelQuery; import org.slim3.util.AppEngineUtil; import org.slim3.util.BeanUtil; import org.slim3.util.CopyOptions; import org.slim3.util.DateUtil; import org.slim3.util.TimeZoneLocator; import org.xml.sax.SAXException; import com.google.api.client.auth.oauth2.AuthorizationCodeFlow; import com.google.api.client.auth.oauth2.Credential; import com.google.api.client.extensions.appengine.auth.oauth2.AppEngineCredentialStore; import com.google.api.client.extensions.appengine.http.urlfetch.UrlFetchTransport; import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.jackson.JacksonFactory; import com.google.api.services.calendar.CalendarScopes; import com.google.api.services.calendar.model.CalendarList; import com.google.api.services.calendar.model.Event; import com.google.api.services.calendar.model.EventDateTime; import com.google.appengine.api.datastore.Key; import com.google.appengine.api.datastore.Query.SortDirection; import com.google.appengine.api.mail.MailService.Message; import com.google.appengine.api.mail.MailServiceFactory; import com.google.appengine.api.users.User; import com.google.appengine.api.users.UserServiceFactory; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapper; import freemarker.template.Template; import freemarker.template.TemplateException; /** * iCalendarファイルを操作するサービスクラス。 * @author ryu22e * */ public class CalendarService { /** * */ private static final Logger LOGGER = Logger.getLogger(CalendarService.class .getName()); /** * */ private static final ProdId PROD_ID = new ProdId("nico2ical"); /** * */ private static final HttpTransport HTTP_TRANSPORT = new UrlFetchTransport(); /** * */ private static final JsonFactory JSON_FACTORY = new JacksonFactory(); /** * */ private static final String CALNAME = "ニコニコ生放送"; /** * リスト1とリスト2の同じ要素をマージする。 * @param <T> 型名 * @param list1 リスト1 * @param list2 リスト2 * @return マージされたリスト */ private <T> List<T> merge(List<T> list1, List<T> list2) { List<T> merged = new LinkedList<T>(); if (list1.size() <= 0) { merged.addAll(list2); } else if (list2.size() <= 0) { merged.addAll(list1); } else { for (T t : list1) { if (list2.contains(t)) { merged.add(t); } } } return merged; } /** * キーワードに該当する{@link Nicolive}のキーを取得する。 * @param keywords キーワード * @return キーワードに該当する{@link Nicolive}のキー */ private List<Key> getKeywordKeys(List<String> keywords) { NicoliveIndexMeta ni = NicoliveIndexMeta.get(); List<Key> keywordKeys = new LinkedList<Key>(); for (String keyword : keywords) { List<NicoliveIndex> indexes = Datastore .query(ni) .filter(ni.keyword.equal(keyword)) .asList(); List<Key> keys = new LinkedList<Key>(); for (NicoliveIndex nicoliveIndex : indexes) { keys.add(nicoliveIndex.getNicoliveKey()); } keywordKeys = merge(keys, keywordKeys); } return keywordKeys; } /** * @param userId * @return */ protected com.google.api.services.calendar.Calendar createGoogleCalendarClientFromUserId( String userId) { Credential credential = createNewGoogleCalendarApiFlow().loadCredential(userId); if (credential == null) { return null; } else { return com.google.api.services.calendar.Calendar .builder(HTTP_TRANSPORT, JSON_FACTORY) .setHttpRequestInitializer(credential) .build(); } } /** * Google Calendarインポートに失敗した旨をユーザーにメールで通知する。 * @param myCalendar {@link MyCalendar} */ protected void sendImportErrorMail(MyCalendar myCalendar) { Message message = new Message(); message.setTo(myCalendar.getUser().getEmail()); message.setSubject("Google Calendarへのインポートに失敗しました"); message.setSender("ryu22e@gmail.com"); Configuration config = new Configuration(); config.setObjectWrapper(new DefaultObjectWrapper()); try { config.setDirectoryForTemplateLoading(new File( "src/main/resources/templates/mail/")); Template template = config.getTemplate("importError.flt"); StringWriter writer = new StringWriter(); Map<String, String> root = new HashMap<String, String>(); root.put("userId", myCalendar.getUser().getUserId()); template.process(root, writer); message.setTextBody(new String(writer.getBuffer())); com.google.appengine.api.mail.MailService mailService = MailServiceFactory.getMailService(); mailService.send(message); } catch (IOException e) { LOGGER.warning(e.getMessage()); return; } catch (TemplateException e) { LOGGER.warning(e.getMessage()); return; } } /** * Datastoreに登録されたRSSフィードをiCalendar形式のデータに変換する。 * @param condition 検索条件 * @return iCalendar形式のデータ * @throws NullPointerException パラメータがnullの場合。 * @throws IllegalArgumentException 検索条件にStartDateが指定されていない場合。 */ public Calendar getCalendar(CalendarCondition condition) { if (condition == null) { throw new NullPointerException("condition is null."); } if (condition.getStartDate() == null) { throw new IllegalArgumentException("StartDate is null."); } Calendar calendar = new Calendar(); calendar.getProperties().add(PROD_ID); calendar.getProperties().add(Version.VERSION_2_0); calendar.getProperties().add(new XProperty("X-WR-CALNAME", CALNAME)); NicoliveMeta n = NicoliveMeta.get(); ModelQuery<Nicolive> query = Datastore .query(n) .filter( n.openTime.greaterThanOrEqual(condition.getStartDate())) .sort(n.openTime.getName(), SortDirection.ASCENDING); if (condition.getKeywords() != null && 0 < condition.getKeywords().size()) { List<Key> keywordKeys = getKeywordKeys(condition.getKeywords()); if (0 < keywordKeys.size()) { query = query.filterInMemory(n.key.in(keywordKeys)); } else { // キーワード検索で該当するエンティティがなければ、この後のクエリを発行する必要がないので、ここで検索を終了とする。 return calendar; } } TimeZone timezone = TimeZoneLocator.get(); List<Nicolive> nicolives = query.asList(); for (Nicolive nicolive : nicolives) { PropertyList properties = new PropertyList(); properties.add(new Summary(nicolive.getTitle())); String description; try { description = HtmlRemoveUtil.removeHtml(nicolive .getDescription() .getValue()); } catch (SAXException e1) { description = ""; } catch (IOException e1) { description = ""; } java.util.Calendar c = DateUtil.toCalendar(nicolive.getOpenTime()); c.setTimeZone(timezone); properties.add(new Description(description)); properties.add(new DtStart(new DateTime(c.getTime()), true)); properties.add(new DtEnd(new DateTime(c.getTime()), true)); try { URI uri = new URI(nicolive.getLink().getValue()); properties.add(new Url(uri)); } catch (URISyntaxException e) { LOGGER.warning(e.getMessage()); } VEvent event = new VEvent(properties); calendar.getComponents().add(event); } return calendar; } /** * GoogleCalendarAPI用Flowを取得する。 * @return GoogleCalendarAPI用Flow */ public AuthorizationCodeFlow createNewGoogleCalendarApiFlow() { return new GoogleAuthorizationCodeFlow.Builder( HTTP_TRANSPORT, JSON_FACTORY, GoogleApiKeyUtil.getClientId(), GoogleApiKeyUtil.getClientSecret(), Collections.singleton(CalendarScopes.CALENDAR)).setCredentialStore( new AppEngineCredentialStore()).build(); } /** * Google Calendar API用Clientを取得する。 * @return Google Calendar API用Client * @throws NullPointerException パラメータがnullの場合。 */ public com.google.api.services.calendar.Calendar createGoogleCalendarClient( User user) { if (user == null) { throw new NullPointerException("user is null."); } return createGoogleCalendarClientFromUserId(user.getUserId()); } /** * Google Calendarのカレンダー一覧を取得する。 * @return Google Calendarのカレンダー一覧 * @throws IOException * @throws NullPointerException パラメータがnullの場合。 */ public CalendarList getGoogleCalendarList(User user) throws IOException { com.google.api.services.calendar.Calendar client = createGoogleCalendarClient(user); if (client == null) { return null; } com.google.api.services.calendar.Calendar.CalendarList.List listRequest = client.calendarList().list(); listRequest.setFields("items(id,summary)"); listRequest.setMinAccessRole("writer"); return listRequest.execute(); } /** * 連携対象のGoogle Calendarを登録する。 * @param myCalendar {@link MyCalendar} * @return 登録後のKey * @throws NullPointerException パラメータmyCalendarまたは{@link MyCalendar#getCalendarId()}がnullの場合。 * @throws AssertionError ログインしていない場合。 */ public Key putMyCalendar(MyCalendar myCalendar) { if (myCalendar == null) { throw new NullPointerException("myCalendar is null."); } if (myCalendar.getCalendarId() == null) { throw new NullPointerException("calendarId is null."); } User user = UserServiceFactory.getUserService().getCurrentUser(); if (user == null) { throw new AssertionError("Require authorized."); } MyCalendarMeta mc = MyCalendarMeta.get(); MyCalendar storedmyCalendar = Datastore .query(MyCalendar.class) .filter(mc.user.equal(user)) .asSingle(); MyCalendar entity; if (storedmyCalendar == null) { // データストアにデータが存在しなければ、新規にデータを作る。 entity = myCalendar; entity.setUser(user); } else { // データストアにデータが存在するなら、データを上書きする。 entity = storedmyCalendar; CopyOptions options = new CopyOptions(); options.exclude("key", "user"); BeanUtil.copy(myCalendar, entity, options); } return Datastore.put(entity); } /** * 連携対象のGoogleCalendarを取得する。 */ public MyCalendar getCurrentMyCalendar() { User user = UserServiceFactory.getUserService().getCurrentUser(); if (user == null) { return null; } MyCalendarMeta mc = MyCalendarMeta.get(); return Datastore.query(mc).filter(mc.user.equal(user)).asSingle(); } /** * Google Calendar連携をやめる。 */ public void disConnectMyCalendar() { User user = UserServiceFactory.getUserService().getCurrentUser(); if (user != null) { // Access tokenが格納されているAppEngineCredentialStoreを削除する。 new AppEngineCredentialStore().delete(user.getUserId(), null); MyCalendarMeta mc = MyCalendarMeta.get(); MyCalendar myCalendar = Datastore.query(mc).filter(mc.user.equal(user)).asSingle(); if (myCalendar != null) { myCalendar.setDisabled(true); Datastore.put(myCalendar); } } } /** * {@link MyCalendar}のリストを取得する。 * @return {@link MyCalendar}のリスト */ public List<MyCalendar> getMyCalendars() { MyCalendarMeta mc = MyCalendarMeta.get(); return Datastore.query(mc).filter(mc.disabled.equal(false)).asList(); } /** * {@link MyCalendar}のキーリストを取得する。 * @return {@link MyCalendar}のキーリスト */ public List<Key> getMyCalendarKeys() { MyCalendarMeta mc = MyCalendarMeta.get(); return Datastore.query(mc).filter(mc.disabled.equal(false)).asKeyList(); } /** * マイカレンダー(Google Calendar)にニコニコ生放送の放送予定日をインポートする。 * @param myCalendarKey {@link MyCalendar}のキー * @throws IOException マイカレンダー(Google Calendar)へのインポートに失敗した場合 */ public void importToMyCalendar(Key myCalendarKey) throws IOException { MyCalendar myCalendar = Datastore.getOrNull(MyCalendar.class, myCalendarKey); if (myCalendar == null) { return; } com.google.api.services.calendar.Calendar client = createGoogleCalendarClient(myCalendar.getUser()); if (client != null) { NicoliveMeta n = NicoliveMeta.get(); org.joda.time.DateTime d = new org.joda.time.DateTime(); d = d.minusWeeks(1); ModelQuery<Nicolive> query = Datastore.query(n).filter( n.openTime.greaterThanOrEqual(d.toDate())); if (myCalendar.getKeyword() != null && 0 < myCalendar.getKeyword().length()) { List<Key> keywordKeys = getKeywordKeys(Arrays.asList(myCalendar .getKeyword() .split(" "))); if (0 < keywordKeys.size()) { query = query.filterInMemory(n.key.in(keywordKeys)); } else { // キーワード検索で該当するエンティティがなければ、この後のクエリを発行する必要がないので、ここで検索を終了とする。 return; } } MyCalendarLogMeta ml = MyCalendarLogMeta.get(); MyCalendarLog myCalendarLog = Datastore .query(ml) .filter( ml.user.equal(myCalendar.getUser()), ml.calendarId.equal(myCalendar.getCalendarId())) .asSingle(); if (myCalendarLog == null) { myCalendarLog = new MyCalendarLog(); myCalendarLog.setUser(myCalendar.getUser()); myCalendarLog.setCalendarId(myCalendar.getCalendarId()); myCalendarLog.setNicoliveKeys(new ArrayList<Key>()); } List<Key> importedNicoliveKeys = new LinkedList<Key>(); List<Key> allNicoliveKeys = new LinkedList<Key>(); List<Nicolive> nicolives = query.asList(); for (Nicolive nicolive : nicolives) { allNicoliveKeys.add(nicolive.getKey()); if (!myCalendarLog .getNicoliveKeys() .contains(nicolive.getKey())) { Event event = new Event(); // カレンダーのSummaryに生放送のタイトルを設定する。 event.setSummary(nicolive.getTitle()); // カレンダーのDescriptionに生放送のURLと説明文を設定する。 try { event.setDescription(nicolive.getLink().getValue() + " " + HtmlRemoveUtil.removeHtml(nicolive .getDescription() .getValue())); } catch (SAXException e) { LOGGER.warning(e.getMessage()); } catch (IOException e) { LOGGER.warning(e.getMessage()); } java.util.Calendar c = DateUtil.toCalendar(nicolive.getOpenTime()); // 生放送会場日時をcom.google.api.client.util.DateTimeに変換する。 TimeZone timezone = TimeZoneLocator.get(); c.setTimeZone(timezone); com.google.api.client.util.DateTime datetime = new com.google.api.client.util.DateTime(c.getTime()); // カレンダーのStartに生放送会場日時を設定する。 EventDateTime start = new EventDateTime(); start.setDateTime(datetime); start.setTimeZone(timezone.getID()); event.setStart(start); // カレンダーのEndに生放送会場日時を設定する。 EventDateTime end = new EventDateTime(); end.setTimeZone(timezone.getID()); end.setDateTime(datetime); event.setEnd(end); if (AppEngineUtil.isProduction()) { try { client .events() .insert(myCalendar.getCalendarId(), event) .execute(); } catch (IOException e) { // 途中でインポートに失敗した場合は、成功した分までMyCalendarLogに記録して、次回のインポートの対象から外すようにする。 if (0 < importedNicoliveKeys.size()) { myCalendarLog.getNicoliveKeys().addAll( importedNicoliveKeys); Datastore.putAsync(myCalendarLog); } if (myCalendar.isNotifyErrorMail()) { sendImportErrorMail(myCalendar); } myCalendar.setDisabled(true); Datastore.putAsync(myCalendar); throw e; } } importedNicoliveKeys.add(nicolive.getKey()); } } if (0 < allNicoliveKeys.size() && !myCalendarLog.getNicoliveKeys().equals(allNicoliveKeys)) { // 今回インポート対象にした範囲のNicoliveをMyCalendarLogに記録して、次回のインポート対象から外すようにする。 myCalendarLog.setNicoliveKeys(allNicoliveKeys); Datastore.putAsync(myCalendarLog); LOGGER.info("MyCalendarLog is saved"); } } } }