It is currently August 17th, 2019, 9:50 pm

Enigma Google Calendar Patch

Post your work-in-progress and completed skins to share and discuss.
User avatar
eclectic-tech
Rainmeter Sage
Posts: 3478
Joined: April 12th, 2012, 9:40 pm
Location: Cedar Point, Ohio, USA

Enigma Google Calendar Patch

eclectic-tech » January 9th, 2018, 4:21 pm

[This is a WIP]

Since Google removed the XML access to the calendar, Enigma's GCal skin stopped working. Many users have wanted a way to access the new format using calendar skins based on Enigma code.

A few weeks ago, rick-t-fiedrain posted a solution he is using. He did a lot of customizing to achieve the end result he wanted, but it is definitely not the Enigma style. He also had issues having the code recognize repeating events; he ended up using a service to set repeating dates in his skin.

I decided to look at the latest Lua code and see if I could get the Enigma GCal skin working based on what rick-t-fiedrain was able to do.

It has been a long time since I bothered to code in any language (I'm talking COBOL/FORTRAN back in the 1970's!), but I was able to make changes to Reader.lua file that allowed obtaining information from an ICS type of calendar file. It will find repeating events and should work with any skin based on Enigma's reader.

Here is the updated 'Enigma\@Resources\Measures\Reader.lua' code.
It is all that is needed to get your GCal skin working if you do not want the expanded substitution. (See below)

Code: Select all

function Initialize()
	-- SET UPDATE DIVIDER
	SKIN:Bang('!SetOption', SELF:GetName(), 'UpdateDivider', -1)
	-- This script never needs to update on a schedule. It should only
	-- update when it gets a "Refresh" command from WebParser.

	-- CREATE MAIN DATABASE
	Feeds = {}

	-- CREATE TYPE MATCHING PATTERNS AND FORMATTING FUNCTIONS
	DefineTypes()

	-- GET MEASURE NAMES
	local AllMeasureNames = SELF:GetOption('MeasureName', '')
	for MeasureName in AllMeasureNames:gmatch('[^%|]+') do
		table.insert(Feeds, {
			Measure     = SKIN:GetMeasure(MeasureName),
			MeasureName = MeasureName,
			Raw         = nil,
			Type        = nil,
			Title       = nil,
			Link        = nil,
			Error       = nil
			})
	end

	-- MODULES
	EventFile_Initialize()
	HistoryFile_Initialize()

	-- SET STARTING FEED
	f = f or 1

	-- SET USER INPUT
	UserInput = false
	-- Used to detect when an item has been marked as read.
end

function Update()
	Input()
	return Output()
end

-----------------------------------------------------------------------
-- INPUT

function compareTime(a,b)
  return a.Date < b.Date
end

function Input(a)
        local f = a or f
 
        local Raw = Feeds[f].Measure:GetStringValue()
       
        if Raw == '' then
                Feeds[f].Error = {
                        Description = 'Processing...',
                        Title       = 'Loading...',
                        Link        = 'http://enigma.kaelri.com/support'
                        }
                return false
        elseif (Raw ~= Feeds[f].Raw) or UserInput then
                Feeds[f].Raw = Raw
 
                -- DETERMINE FEED FORMAT AND CONTENTS
                local t = IdentifyType(Raw)
 
                if not t then
                        Feeds[f].Error = {
                                Description = 'Could not identify a valid feed format.',
                                Title       = 'Invalid Feed Format',
                                Link        = 'http://enigma.kaelri.com/support'
                                }
                        return false
                else
                        Feeds[f].Type = t
                end
 
                -- MAKE SYNTAX PRETTIER
                local Type = Types[t]
 
				-- GET NEW DATA

				if (t == 'iCalendar') then

					Feeds[f].Title = Raw:match('X%-WR%-CALNAME:(.-)\n') or 'Untitled'

				else

					Feeds[f].Title = Raw:match('<title.->(.-)</title>') or 'Untitled'

				end

				if (t == 'iCalendar') then
						Feeds[f].Link   = 'https://calendar.google.com/calendar/r/month/' or nil
								
				else
				Feeds[f].Link  = Raw:match(Type.MatchLink) or nil
				end

				local Items = {}
                for RawItem in Raw:gmatch(Type.MatchItem) do
                        local Item  = {}
 
                        -- MATCH RAW DATA
                        Item.Unread = 1
						
						if (t == 'iCalendar') then
                                Item.Title  = RawItem:match('SUMMARY:(.-)\n') or nil
                                if (string.len(Item.Title) == 1) then
                                        Item.Title = RawItem:match('DESCRIPTION:(.-)\n') or nil
                                end
                                Item.Title = Item.Title:gsub('\\', '')
                        else
                                Item.Title  = RawItem:match('<title.->(.-)</title>') or nil
						end

						Item.Date   = RawItem:match(Type.MatchItemDate)      or nil

						if (t == 'iCalendar') then
								Item.Link   = 'https://calendar.google.com/calendar/r/month/' or nil
								
						else
								Item.Link   = RawItem:match(Type.MatchItemLink) or nil
						end
						
						Item.Desc   = RawItem:match(Type.MatchItemDesc)      or nil
                       
                        Item.ID     = RawItem:match(Type.MatchItemID)        or Item.Link or Item.Title or Item.Desc or Item.Date
 
                        -- ADDITIONAL PARSING
                        if (not Item.Title) or (Item.Title == '') then
                                Item.Title = 'Untitled'
                        end
                        if Item.Desc then
                                Item.Desc = Item.Desc:gsub('<.->', '')
                                Item.Desc = Item.Desc:gsub('%s%s+', ' ')
                        end
                        Item.Date, Item.AllDay, Item.RealDate = IdentifyDate(Item.Date, t)
 
                        table.insert(Items, Item)
                end
				
				table.sort(Items, compareTime)
               
                -- IDENTIFY DUPLICATES
                for i, OldItem in ipairs(Feeds[f]) do
                        for j, NewItem in ipairs(Items) do
                                if NewItem.ID == OldItem.ID then
                                        Feeds[f][i].Match = j
                                        Items[j].Unread   = OldItem.Unread
                                        if NewItem.RealDate == 0 then
                                                Items[j].Date   = OldItem.Date
                                                Items[j].AllDay = OldItem.AllDay
                                        end
                                end
                        end
                end
 
                -- CLEAR DUPLICATES OR ALL HISTORY
                local KeepOldItems = SELF:GetNumberOption('KeepOldItems', 0)
 
                if (KeepOldItems == 1) and Type.MergeItems then
                        for i = #Feeds[f], 1, -1 do
                                if Feeds[f][i].Match then
                                        table.remove(Feeds[f], i)
                                end
                        end
                else
                        for i = 1, #Feeds[f] do
                                table.remove(Feeds[f])
                        end
                end
 
                -- ADD NEW ITEMS
				for i = #Items, 1, -1 do
					if Items[i] then
						if t == 'iCalendar' then
							if (Items[i].Date > os.time()) then
								table.insert(Feeds[f], 1, Items[i])
							end
						else
							table.insert(Feeds[f], 1, Items[i])
						end
					end
				end

                -- CHECK NUMBER OF ITEMS
                local MaxItems = SELF:GetNumberOption('MaxItems', nil)
                local MaxItems = (MaxItems > 0) and MaxItems or nil
 
                if #Feeds[f] == 0 then
                        Feeds[f].Error = {
                                Description = 'No items found.',
                                Title       = Feeds[f]['Title'],
                                Link        = Feeds[f]['Link']
                        }
                        return false
                elseif MaxItems and (#Feeds[f] > MaxItems) then
                        for i = #Feeds[f], (MaxItems + 1), -1 do
                                table.remove(Feeds[f])
                        end
                end
               
                -- MODULES
                EventFile_Update(f)
                HistoryFile_Update(f)
 
                -- CLEAR ERRORS FROM PREVIOUS UPDATE
                Feeds[f].Error = nil
 
                -- RESET USER INPUT
                UserInput = false
        end
 
        return true
end

-----------------------------------------------------------------------
-- OUTPUT

function Output()
	local Queue = {}

	-- MAKE SYNTAX PRETTIER
	local Feed  = Feeds[f]
	local Type  = Types[Feed.Type]
	local Error = Feed.Error

	-- BUILD QUEUE
	Queue['CurrentFeed']   = f
	Queue['NumberOfItems'] = #Feed

	-- CHECK FOR INPUT ERRORS
	local MinItems  = SELF:GetNumberOption('MinItems', 0)
	local Timestamp = SELF:GetOption('Timestamp', '%I:%M %p %A %b %d')

	if Error then
		-- ERROR; QUEUE MESSAGES
		Queue['FeedTitle']   = Error.Title
		Queue['FeedLink']    = Error.Link
		Queue['Item1Title']  = Error.Description
		Queue['Item1Link']   = Error.Link
		Queue['Item1Desc']   = ''
		Queue['Item1Date']   = ''
		Queue['Item1Unread'] = 0

		for i = 2, MinItems do
			Queue['Item'..i..'Title']   = ''
			Queue['Item'..i..'Link']    = ''
			Queue['Item'..i..'Desc']    = ''
			Queue['Item'..i..'Date']    = ''
			Queue['Item'..i..'Unread']  = 0
		end
	else
		-- NO ERROR; QUEUE FEED
		Queue['FeedTitle'] = Feed.Title
		Queue['FeedLink']  = Feed.Link or ''

		for i = 1, math.max(#Feed, MinItems) do
			local Item = Feed[i] or {}			
			Queue['Item'..i..'Title']   = Item.Title  or ''
			Queue['Item'..i..'Link']    = Item.Link   or Feed.Link or ''
			Queue['Item'..i..'Desc']    = Item.Desc   or ''
			Queue['Item'..i..'Unread']  = Item.Unread or ''
			Queue['Item'..i..'Date']    = Item.Date and os.date(Timestamp, Item.Date) or ''
			-- print(Item.Title..Item.Date)
		end
	end

	-- SET VARIABLES
	local VariablePrefix = SELF:GetOption('VariablePrefix', '')
	for k, v in pairs(Queue) do
		SKIN:Bang('!SetVariable', VariablePrefix..k, v)
	end
	
	-- FINISH ACTION   
	local FinishAction = SELF:GetOption('FinishAction', '')
	if FinishAction ~= '' then
		SKIN:Bang(FinishAction)
	end

	return Error and Error.Description or 'Finished #'..f..' ('..Feed.MeasureName..'). Name: '..Feed.Title..'. Type: '..Feed.Type..'. Items: '..#Feed..'.'
	
end

-----------------------------------------------------------------------
-- EXTERNAL COMMANDS

function Refresh(a)
	a = a and tonumber(a) or f
	if a == f then
		SKIN:Bang('!UpdateMeasure', SELF:GetName())
	else
		Input(a)
	end
end

function Show(a)
	f = tonumber(a)
	SKIN:Bang('!UpdateMeasure', SELF:GetName())
end

function ShowNext()
	f = (f % #Feeds) + 1
	SKIN:Bang('!UpdateMeasure', SELF:GetName())
end

function ShowPrevious()
	f = (f == 1) and #Feeds or (f - 1)
	SKIN:Bang('!UpdateMeasure', SELF:GetName())
end

function MarkRead(a, b)
	b = b and tonumber(b) or f
	Feeds[b][a].Unread = 0
	UserInput = true
	SKIN:Bang('!UpdateMeasure', SELF:GetName())
end

function MarkUnread(a, b)
	b = b and tonumber(b) or f
	Feeds[b][a].Unread = 1
	UserInput = true
	SKIN:Bang('!UpdateMeasure', SELF:GetName())
end

function ToggleUnread(a, b)
	b = b and tonumber(b) or f
	Feeds[b][a].Unread = 1 - Feeds[b][a].Unread
	UserInput = true
	SKIN:Bang('!UpdateMeasure', SELF:GetName())
end

-----------------------------------------------------------------------
-- TYPES

function DefineTypes()
	Types = {
		RSS = {
			MatchLink     = '<link.->(.-)</link>',
			MatchItem     = '<item.-</item>',
			MatchItemID   = '<guid.->(.-)</guid>',
			MatchItemLink = '<link.->(.-)</link>',
			MatchItemDesc = '<description.->(.-)</description>',
			MatchItemDate = '<pubDate.->(.-)</pubDate>',
			MergeItems    = true,
			ParseDate     = function(s)
				local Date = {}
				local MatchTime = '%a%a%a, (%d%d) (%a%a%a) (%d%d%d%d) (%d%d)%:(%d%d)%:(%d%d) (.-)$'
				local MatchDate = '%a%a%a, (%d%d) (%a%a%a) (%d%d%d%d)$'
				if s:match(MatchTime) then
					Date.day, Date.month, Date.year, Date.hour, Date.min, Date.sec, Date.Offset = s:match(MatchTime)
				elseif s:match(MatchDate) then
					Date.day, Date.month, Date.year = s:match(MatchDate)
				end
				return (Date.year and Date.month and Date.day) and Date or nil
			end
			},
		Atom = {
			MatchLink     = '<link.-href=["\'](.-)["\']',
			MatchItem     = '<entry.-</entry>',
			MatchItemID   = '<id.->(.-)</id>',
			MatchItemLink = '<link.-href=["\'](.-)["\']',
			MatchItemDesc = '<summary.->(.-)</summary>',
			MatchItemDate = '<modified.->(.-)</modified>',
			MergeItems    = true,
			ParseDate     = function(s)
				local Date = {}
				local MatchTime = '(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d)%:(%d%d)%:(%d%d)(.-)$'
				local MatchDate = '(%d%d%d%d)%-(%d%d)%-(%d%d)$'
				if s:match(MatchTime) then
					Date.year, Date.month, Date.day, Date.hour, Date.min, Date.sec, Date.Offset = s:match(MatchTime)
				elseif s:match(MatchDate) then
					Date.year, Date.month, Date.day = s:match(MatchDate)
				end
				return Date
			end
			},
		iCalendar = {
			MatchLink     = 'UID:(%S+)',
			MatchItem     = 'BEGIN:VEVENT.-END:VEVENT',
			MatchItemID   = 'UID:(%S+)',
			MatchItemLink = 'UID:(%S+)',
			MatchItemDesc = 'DESCRIPTION:(.-)\n',
			MatchItemDate = 'DTSTART.-UID',
			MergeItems    = false,
			ParseDate     = function(s)
				local Date = {}
				
				-- For finding the Offset
				local DS = {}
				local StampMatch = 'DTSTAMP:(%d%d%d%d)(%d%d)(%d%d)T(%d%d)(%d%d)(%d%d)(%S+)'
				if s:match(StampMatch) then
					DS.year, DS.month, DS.day, DS.hour, DS.min, DS.sec, DS.Offset = s:match(StampMatch)
					-- Remove date from main string so it doesn't interfere with date matching below
					s = s:gsub('DTSTAMP:' .. DS.year .. DS.month .. DS.day .. 'T' .. DS.hour .. DS.min .. DS.sec .. DS.Offset, '')	
				end
				
				local MatchRecurrence = 'RECURRENCE-ID;VALUE=DATE:(%d%d%d%d)(%d%d)(%d%d)'
				if s:match(MatchRecurrence) then
				
					Date.year, Date.month, Date.day = s:match(MatchRecurrence)
					return Date
					
				-- Monthly recurring events
				elseif s:match('FREQ=MONTHLY') then
					Date.year = os.date('%Y')
					Date.month = os.date('%m')
					Date.day = s:match('DTSTART;VALUE=DATE:%d%d%d%d%d%d(%d%d)')
					
					-- If date is in the past then add a month
					if os.time(Date) < os.time() then
						if tonumber(Date.month) < 12 then
							Date.month = Date.month + 1
						else
							Date.month = 1
							Date.year = Date.year + 1
						end
					end
					return Date
				
				-- Yearly recurring events
				elseif s:match('FREQ=YEARLY') then
					-- Match and set to this year
					Date.year, Date.month, Date.day = s:match('DTSTART;VALUE=DATE:(%d%d%d%d)(%d%d)(%d%d)')
					Date.year = os.date('%Y')
					return Date
					
				-- Day is not numeric, e.g. '1FR' for first Friday of month or '2TH' for 2nd Thursday
				elseif s:match('BYDAY=(%d+)(%u+)') then
				
					local days1 = {['Mon']=0, ['Tue']=1, ['Wed']=2, ['Thu']=3, ['Fri']=4, ['Sat']=5, ['Sun']=6}
					local days2 = {['MO']=0, ['TU']=1, ['WE']=2, ['TH']=3, ['FR']=4, ['SA']=5, ['SU']=6}
					-- Get first day of current month
					local d = os.date('%a', os.time{ year=os.date('%Y'), month=os.date('%m'), day=1 })
					
					local wday = {}
					wday.n, wday.d = s:match('BYDAY=(%d+)(%u+)')
					
					local daydiff = (days2[wday.d] - days1[d]) + 1
					if daydiff < 0 then
						daydiff = daydiff + 7
					end
					
					-- nth of day
					daydiff = daydiff + ((wday.n - 1) * 7)
					
					Date.year = os.date('%Y')
					Date.month = os.date('%m')
					Date.day = daydiff
					
					-- If date is in the past then add a month
					if os.time(Date) < os.time() then
						if tonumber(Date.month) < 12 then
							Date.month = Date.month + 1
						else
							Date.month = 1
							Date.year = Date.year + 1
						end
					end
					
					-- Match time from original record
					local Date2 = {}
					if s:match('(%d%d%d%d)(%d%d)(%d%d)T(%d%d)(%d%d)(%d%d)') then
						Date2.year, Date2.month, Date2.day, Date2.hour, Date2.min, Date2.sec = s:match('(%d%d%d%d)(%d%d)(%d%d)T(%d%d)(%d%d)(%d%d)')
						Date.hour = Date2.hour
						Date.min = Date2.min
						Date.sec = Date2.sec
						Date.Offset = DS.Offset
					end
					
					return Date
				end
				
				-- Standard date fallback
				local MatchTime = '(%d%d%d%d)(%d%d)(%d%d)T(%d%d)(%d%d)(%d%d)'
				local MatchDate = '(%d%d%d%d)(%d%d)(%d%d)'
				
				if s:match(MatchTime) then
					Date.year, Date.month, Date.day, Date.hour, Date.min, Date.sec = s:match(MatchTime)
					Date.Offset = DS.Offset
				else
					Date.year, Date.month, Date.day = s:match(MatchDate)
				end

				return Date
			end
		},
		RememberTheMilk = {
			MatchLink     = '<link.-rel=.-alternate.-href=["\'](.-)["\']',
			MatchItem     = '<entry.-</entry>',
			MatchItemID   = '<id.->(.-)</id>',
			MatchItemLink = '<link.-href=["\'](.-)["\']',
			MatchItemDesc = '<summary.->(.-)</summary>',
			MatchItemDate = '<span class=["\']rtm_due_value["\']>(.-)</span>',
			MergeItems    = false,
			ParseDate     = function(s)
				local Date = {}
				local MatchTime = '%a%a%a (%d+) (%a%a%a) (%d+) at (%d+)%:(%d+)(%a%a)' -- e.g. 'Wed 7 Nov 12 at 3:17PM'
				local MatchDate = '%a%a%a (%d+) (%a%a%a) (%d+)' -- e.g. 'Tue 25 Dec 12'
				if s:match(MatchTime) then
					Date.day, Date.month, Date.year, Date.hour, Date.min, Date.Meridiem = s:match(MatchTime)
				elseif s:match(MatchDate) then
					Date.day, Date.month, Date.year = s:match(MatchDate)
				end
				return Date
			end
			}
		}
end

-------------------------

function IdentifyType(s)

	-- COLLAPSE CONTAINER TAGS
	for _, v in ipairs{ 'item', 'entry' } do
		s = s:gsub('<'..v..'.->.+</'..v..'>', '<'..v..'></'..v..'>') -- e.g. '<entry.->.+</entry>' --> '<entry></entry>'
	end

	--DEFINE RSS MARKER TESTS
	--Each of these test functions will be run in turn, until one of them gets a solid match on the format type.
	local TestRSS = {
		function(a)
			-- If the feed contains these tags outside of <item> or <entry>, RSS is confirmed.
			for _, v in ipairs{ '<rss', '<channel', '<lastBuildDate', '<pubDate', '<ttl', '<description' } do
				if a:match(v) then
					return 'RSS'
				end
			end
			return false
		end,

		function(a)
			-- Alternatively, if the feed contains these tags outside of <item> or <entry>, Atom is confirmed.
			for _, v in ipairs{ '<feed', '<subtitle' } do
				if a:match(v) then
					return 'Atom'
				end
			end
			return false
		end,
		
		function(a)
			-- Alternatively, if the feed contains these tags ICAL is confirmed.
			for _, v in ipairs{ 'DTSTART', 'DTEND' } do
				if a:match(v) then
					return 'Ical'
				end
			end
			return false
		end,

		function(a)
			-- If no markers are present, we search for <item> or <entry> tags to confirm the type.
			local HaveItems   = a:match('<item')
			local HaveEntries = a:match('<entry')
			local HaveIcal = a:match('DTSTART')

			if HaveItems and not HaveEntries then
				return 'RSS'
			elseif HaveEntries and not HaveItems then
				return 'Atom'
			elseif HaveIcal then
				return 'Ical'
			else
				-- If both kinds of tags are present, and no markers are given, then I give up
				-- because your feed is ridiculous. And if neither tag is present, then no type
				-- can be confirmed (and there would be no usable data anyway).
				return false
			end
		end
		}

	-- RUN RSS MARKER TESTS
	local Class = false
	for _, v in ipairs(TestRSS) do
		Class = v(s)
		if Class then break end
	end
	
	-- DETECT SUBTYPE AND RETURN
	if Class == 'RSS' then
		return 'RSS'
	elseif Class == 'Atom' then
		if s:match('xmlns:gCal') then
			return 'iCalendar'
		elseif s:match('<subtitle>rememberthemilk.com</subtitle>') then
			return 'RememberTheMilk'
		else
			return 'Atom'
		end
	elseif Class == 'Ical' then
		return 'iCalendar'
	else
		return false
	end
end

-------------------------

function IdentifyDate(s, t)

	local Date = nil
	
	Date = s and Types[t].ParseDate(s) or {}

	Date.year   = tonumber(Date.year)  or nil
	Date.month  = tonumber(Date.month) or MonthAcronyms[Date.month] or nil
	Date.day    = tonumber(Date.day)   or nil
	Date.hour   = tonumber(Date.hour)  or nil
	Date.min    = tonumber(Date.min)   or nil
	Date.sec    = tonumber(Date.sec)   or 0

	-- FIND ENOUGH ELEMENTS, OR DEFAULT TO RETRIEVAL DATE
	local RealDate, AllDay

	if (Date.year and Date.month and Date.day) then
		RealDate = 1

		-- DETECT ALL-DAY EVENT
		if (Date.hour and Date.min) then
			AllDay    = 0
		else
			AllDay    = 1
			Date.hour = 0
			Date.min  = 0
		end

		-- GET CURRENT LOCAL TIME, UTC OFFSET
		-- These values are referenced in several procedures below.
		local UTC             = os.date('!*t')
		local LocalTime       = os.date('*t')
		local DaylightSavings = LocalTime.isdst and 3600 or 0
		local LocalOffset     = os.time(LocalTime) - os.time(UTC) + DaylightSavings

		-- CHANGE 12-HOUR to 24-HOUR
		if Date.Meridiem then
			if (Date.Meridiem == 'AM') and (Date.hour == 12) then
				Date.hour = 0
			elseif (Date.Meridiem == 'PM') and (Date.hour < 12) then
				Date.hour = Date.hour + 12
			end
		end

		-- FIND CLOSEST MATCH FOR TWO-DIGIT YEAR
		if Date.year < 100 then
			local CurrentYear    = LocalTime.year
			local CurrentCentury = math.floor(CurrentYear / 100) * 100
			local IfThisCentury  = CurrentCentury + Date.year
			local IfNextCentury  = CurrentCentury + Date.year + 100
			if math.abs(CurrentYear - IfThisCentury) < math.abs(CurrentYear - IfNextCentury) then
				Date.year = IfThisCentury
			else
				Date.year = IfNextCentury
			end
		end



		-- GET INPUT OFFSET FROM UTC (OR DEFAULT TO LOCAL)
		if (Date.Offset) and (Date.Offset ~= '') then
			if Date.Offset:match('%a') then
				Date.Offset = TimeZones[Date.Offset] and (TimeZones[Date.Offset] * 3600) or 0
			elseif Date.Offset:match('%d') then
				local Direction, Hours, Minutes = Date.Offset:match('^([^%d]-)(%d+)[^%d]-(%d%d)')

				Direction = Direction:match('%-') and -1 or 1
				Hours     = tonumber(Hours) * 3600
				Minutes   = tonumber(Minutes) and (tonumber(Minutes) * 60) or 0

				Date.Offset = (Hours + Minutes) * Direction
			end
		else
			Date.Offset = LocalOffset
		end

		-- RETURN CONVERTED DATE
		Date     = os.time(Date) + LocalOffset - Date.Offset
	else
		-- NO USABLE DATE FOUND; USE RETRIEVAL DATE INSTEAD
		RealDate = 0
		AllDay   = 0
		Date     = os.time()
	end

	return Date, AllDay, RealDate
end

-----------------------------------------------------------------------
-- EVENT FILE MODULE

function EventFile_Initialize()
	local EventFiles = {}
	local AllEventFiles = SELF:GetOption('EventFile', '')
	for EventFile in AllEventFiles:gmatch('[^%|]+') do
		table.insert(EventFiles, EventFile)
	end
	for i, v in ipairs(Feeds) do
		local EventFile = EventFiles[i] or SELF:GetName()..'_Feed'..i..'Events.xml'
		Feeds[i].EventFile = SKIN:MakePathAbsolute(EventFile)
	end
end

function EventFile_Update(a)
	local f = a or f

	local WriteEvents = SELF:GetNumberOption('WriteEvents', 0)
	if (WriteEvents == 1) and (Feeds[f].Type == 'iCalendar') then
		-- CREATE XML TABLE
		local WriteLines = {}
		table.insert(WriteLines, '<EventFile Title="'..Feeds[f].Title..'">')
		for i, v in ipairs(Feeds[f]) do
			local ItemDate = os.date('*t', v.Date)
			table.insert(WriteLines, '<Event Month="'..ItemDate['month']..'" Day="'..ItemDate['day']..'" Desc="'..v.Title..'"/>')
		end
		table.insert(WriteLines, '</EventFile>')
		
		-- WRITE FILE
		local WriteFile = io.output(Feeds[f].EventFile, 'w')
		if WriteFile then
			local WriteContent = table.concat(WriteLines, '\r\n')
			WriteFile:write(WriteContent)
			WriteFile:close()
		else
			SKIN:Bang('!Log', SELF:GetName()..': cannot open file: '..Feeds[f].EventFile)
		end
	end
end

-----------------------------------------------------------------------
-- HISTORY FILE MODULE

function HistoryFile_Initialize()
	-- DETERMINE FILEPATH
	HistoryFile = SELF:GetOption('HistoryFile', SELF:GetName()..'History.xml')
	HistoryFile = SKIN:MakePathAbsolute(HistoryFile)

	-- CREATE HISTORY DATABASE
	History = {}

	-- CHECK IF FILE EXISTS
	local ReadFile = io.open(HistoryFile)
	if ReadFile then
		local ReadContent = ReadFile:read('*all')
		ReadFile:close()

		-- PARSE HISTORY FROM LAST SESSION
		for ReadFeedURL, ReadFeed in ReadContent:gmatch('<feed URL=(%b"")>(.-)</feed>') do
			local ReadFeedURL = ReadFeedURL:match('^"(.-)"$')
			History[ReadFeedURL] = {}
			for ReadItem in ReadFeed:gmatch('<item>(.-)</item>') do
				local Item = {}
				for Key, Value in ReadItem:gmatch('<(.-)>(.-)</.->') do
					Value = Value:gsub('<', '<')
					Value = Value:gsub('>', '>')
					Item[Key] = Value
				end
				Item.Date = tonumber(Item.Date) or Item.Date
				Item.Unread = tonumber(Item.Unread)
				table.insert(History[ReadFeedURL], Item)
			end
		end
	end

	-- ADD HISTORY TO MAIN DATABASE
	-- For each feed, if URLs match, add all contents from History[h] to Feeds[f].
	for f, Feed in ipairs(Feeds) do
		local h = Feed.Measure:GetOption('URL')
		Feeds[f].URL = h
		if History[h] then
			for _, Item in ipairs(History[h]) do
				table.insert(Feeds[f], Item)
			end
		end
	end
end

function HistoryFile_Update(a)
	local f = a or f

	-- CLEAR AND REBUILD HISTORY
	local h = Feeds[f].URL
	History[h] = {}
	for i, Item in ipairs(Feeds[f]) do
		table.insert(History[h], Item)
	end

	-- WRITE HISTORY IF REQUESTED
	WriteHistory()
end

function WriteHistory()
	local WriteHistory = SELF:GetNumberOption('WriteHistory', 0)
	if WriteHistory == 1 then
		-- GENERATE XML TABLE
		local WriteLines = {}
		for WriteURL, WriteFeed in pairs(History) do
			table.insert(WriteLines, string.format(         '<feed URL=%q>', WriteURL))
			for _, WriteItem in ipairs(WriteFeed) do
				table.insert(WriteLines,                    '\t<item>')
				for Key, Value in pairs(WriteItem) do
					Value = string.gsub(Value, '<', '<')
					Value = string.gsub(Value, '>', '>')
					table.insert(WriteLines, string.format( '\t\t<%s>%s</%s>', Key, Value, Key))
				end
				table.insert(WriteLines,                    '\t</item>')
			end
			table.insert(WriteLines,                        '</feed>')
		end

		-- WRITE XML TO FILE
		local WriteFile = io.open(HistoryFile, 'w')
		if WriteFile then
			local WriteContent = table.concat(WriteLines, '\n')
			WriteFile:write(WriteContent)
			WriteFile:close()
		else
			SKIN:Bang('!Log', SELF:GetName()..': cannot open file: '..HistoryFile)
		end
	end
end

function ClearHistory()
	local DeleteFile = io.open(HistoryFile)
	if DeleteFile then
		DeleteFile:close()
		os.remove(HistoryFile)
		SKIN:Bang('!Log', SELF:GetName()..': deleted history cache at '..HistoryFile)
	end
	SKIN:Bang('!Refresh')
end

-----------------------------------------------------------------------
-- CONSTANTS

TimeZones = {
	IDLW = -12, --  International Date Line West 
	NT   = -11, --  Nome 
	CAT  = -10, --  Central Alaska 
	HST  = -10, --  Hawaii Standard 
	HDT  = -9,  --  Hawaii Daylight 
	YST  = -9,  --  Yukon Standard 
	YDT  = -8,  --  Yukon Daylight 
	PST  = -8,  --  Pacific Standard 
	PDT  = -7,  --  Pacific Daylight 
	MST  = -7,  --  Mountain Standard 
	MDT  = -6,  --  Mountain Daylight 
	CST  = -6,  --  Central Standard 
	CDT  = -5,  --  Central Daylight 
	EST  = -5,  --  Eastern Standard 
	EDT  = -4,  --  Eastern Daylight 
	AST  = -3,  --  Atlantic Standard 
	ADT  = -2,  --  Atlantic Daylight 
	WAT  = -1,  --  West Africa 
	GMT  =  0,  --  Greenwich Mean 
	UTC  =  0,  --  Universal (Coordinated) 
	Z    =  0,  --  Zulu, alias for UTC 
	WET  =  0,  --  Western European 
	BST  =  1,  --  British Summer 
	CET  =  1,  --  Central European 
	MET  =  1,  --  Middle European 
	MEWT =  1,  --  Middle European Winter 
	MEST =  2,  --  Middle European Summer 
	CEST =  2,  --  Central European Summer 
	MESZ =  2,  --  Middle European Summer 
	FWT  =  1,  --  French Winter 
	FST  =  2,  --  French Summer 
	EET  =  2,  --  Eastern Europe, USSR Zone 1 
	EEST =  3,  --  Eastern European Daylight 
	WAST =  7,  --  West Australian Standard 
	WADT =  8,  --  West Australian Daylight 
	CCT  =  8,  --  China Coast, USSR Zone 7 
	JST  =  9,  --  Japan Standard, USSR Zone 8 
	EAST = 10,  --  Eastern Australian Standard 
	EADT = 11,  --  Eastern Australian Daylight 
	GST  = 10,  --  Guam Standard, USSR Zone 9 
	NZT  = 12,  --  New Zealand 
	NZST = 12,  --  New Zealand Standard 
	NZDT = 13,  --  New Zealand Daylight 
	IDLE = 12   --  International Date Line East 
	}

MonthAcronyms = {
	Jan = 1,
	Feb = 2,
	Mar = 3,
	Apr = 4,
	May = 5,
	Jun = 6,
	Jul = 7,
	Aug = 8,
	Sep = 9,
	Oct = 10,
	Nov = 11,
	Dec = 12
	}
Or you can install the patch package below; it will update 3 Enigma skins: Reader.lua, Reader.inc, and ReaderDates.inc. The only difference in those 2 INC files is an expanded 'WebParserSubstitute' variable.

The rmskin patch contains 4 files:
:17dot6 Enigma\@Resources\Measures\Reader.lua (Updated reader for ICS [iCal] files | note RSS & Atom code was not changed)
:17dot2 Enigma\@Resources\Measures\Reader.lua.bak (Original Lua file backup)
:17dot1 Enigma/Sidebar/Reader/Reader.inc (Only difference is an expanded 'WebParserSubstitute' variable)
:17dot1 Enigma/Sidebar/Reader/ReaderDates.inc (Only difference is an expanded 'WebParserSubstitute' variable)

There can probably be some improvements to the code; one area is in IdentifyDates() function, so any comments and suggestions are welcome :welcome:
Enigma_GCal_Patch_1.2018.01.09.rmskin
EDIT: The URL is located in the Settings for you calendar in the 'Integrate' section (sample):
ICS_GCal_URL.png
Also attached is a Google Calendar skin that can be modified to any style. It is based on kaelri's Enigma GCal, but does not require you to have Enigma installed. Two default calendars are included US holidays and moon phases (a total of 3 calendars are possible with the current code).
GoogleCalendar_1.2018.01.09.rmskin
GC1.gif
You do not have the required permissions to view the files attached to this post.
mastermindjb
Posts: 2
Joined: January 11th, 2018, 4:42 am

Re: Enigma Google Calendar Patch

mastermindjb » January 11th, 2018, 4:46 am

Oh your calendar that doesn't require Enigma is exactly what I was looking for.

I am unfortunately experiencing an issue with showing my personal Google Calendar. I was able to successfully have another private calendar (an ical that was posted from an NHL team to be precise) without issue, yet for some reason, it doesn't want to pull my personal one. It grabs my email address as the name but then doesn't load any items.

I'm sure its something small I'm doing wrong here. Any chance you can point me in the right direction?
User avatar
eclectic-tech
Rainmeter Sage
Posts: 3478
Joined: April 12th, 2012, 9:40 pm
Location: Cedar Point, Ohio, USA

Re: Enigma Google Calendar Patch

eclectic-tech » January 11th, 2018, 4:04 pm

mastermindjb wrote:Oh your calendar that doesn't require Enigma is exactly what I was looking for.

I am unfortunately experiencing an issue with showing my personal Google Calendar. I was able to successfully have another private calendar (an ical that was posted from an NHL team to be precise) without issue, yet for some reason, it doesn't want to pull my personal one. It grabs my email address as the name but then doesn't load any items.

I'm sure its something small I'm doing wrong here. Any chance you can point me in the right direction?
Hmm, not sure what you did differently.

If you are able to pull other calendars, I would look for what is different with your URL versus a working one.

Double check the secret address for your calendar in the [Variables] section, it should be in this form:
GoogleCalendar3=https://calendar.google.com/calendar/ical/[i]{youremailaddress}[/i]%40gmail.com/private-[i]{numricCode}[/i]/basic.ics.

Also change tabs from 2 to 3:
NumberOfTabs=3

EDIT: I am still working on daily events display. This may be what you are missing. I will post an update when that is working (WIP for sure!)... :)
mastermindjb
Posts: 2
Joined: January 11th, 2018, 4:42 am

Re: Enigma Google Calendar Patch

mastermindjb » January 12th, 2018, 10:16 pm

Yeah that's exactly how mine looks. I'm sure I (or you) will figure it out. :) Thank you for creating this!
Neurohax
Posts: 6
Joined: January 18th, 2018, 8:59 pm

Re: Enigma Google Calendar Patch

Neurohax » January 18th, 2018, 9:31 pm

I'm shocked the lack of gcal went unaddressed for this long, so kudos for tackling this beast.

There is one bug that's driving me a bit batty. As it currently is implemented, the date parsing ignores end dates for repeating events. So, I'm seeing reminders for old mortgage/bill payments which can't be good for my health. OMG bill is due... oh wait... 5 minutes later... OMG bill is due... oh yeah...

I believe the DTEND value either needs to be check for the year being the current one or repeating multiday events are messing things up, because the event before it has RRULE:FREQ=MONTHLY;UNTIL=XXXXXXXXX;BYMONTHDAY=XX (X being some number)

I'll see what I can do with it, but I figured you might be able to isolate the issues faster, being familiar with the code.

Awesome work and thanks!

EDIT:
A few other pieces of the puzzle, looking at the lua code (minimal experience with lua, but get the gist) and my ics file. Here are some things that stick out. It looks like RRULE is ignored and TZID in the DTSTART messes up parsing (two separate issues with different outcomes.)

I have expired repeating events that (I think) don't match the conditions in the lua code, but DO NOT appear, like this:

BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20141108T150000 <------ TZID not checked in lua
DTEND;TZID=America/Chicago:20141108T160000
DTSTAMP:20180118T204933Z
UID:(XXXXXXXXXXXX)@google.com
RECURRENCE-ID;TZID=America/Chicago:20141107T210000
CREATED:20141021T220041Z
DESCRIPTION:
LAST-MODIFIED:20150112T011548Z
LOCATION:
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:(XXXXXXXXXXXX)
TRANSP:OPAQUE
END:VEVENT

and

BEGIN:VEVENT
DTSTART;TZID=America/Chicago:20141022T210000
DTEND;TZID=America/Chicago:20141022T220000
RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20150113T025959Z;BYDAY=MO,WE,FR <--------RRULE not checked
DTSTAMP:20180118T204933Z
UID:(XXXXXXXXXXXX)@google.com
CREATED:20141021T220041Z
DESCRIPTION:
LAST-MODIFIED:20150112T011548Z
LOCATION:
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:(XXXXXXXXXXXX)
TRANSP:OPAQUE
END:VEVENT

But, then I have expired repeating events that DO appear, like these:

BEGIN:VEVENT
DTSTART;VALUE=DATE:20081021
DTEND;VALUE=DATE:20081023
RRULE:FREQ=MONTHLY;UNTIL=20110320;BYMONTHDAY=21 <---------- again, RRULE not checked
DTSTAMP:20180118T204933Z
UID:(XXXXXXXXXXXX)
CLASS:PRIVATE
CREATED:20090411T045028Z
DESCRIPTION:
LAST-MODIFIED:20110430T000900Z
LOCATION:
SEQUENCE:2
STATUS:CONFIRMED
SUMMARY:(XXXXXXXXXXXX)
TRANSP:OPAQUE
CATEGORIES:http://schemas.google.com/g/2005#event
END:VEVENT

and

BEGIN:VEVENT
DTSTART;TZID=America/New_York:20090923T100000
DTEND;TZID=America/New_York:20090923T120000
RRULE:FREQ=WEEKLY;UNTIL=20091216T150000Z;INTERVAL=1;BYDAY=WE <--------RRULE again
DTSTAMP:20180118T204933Z
UID:(XXXXXXXXXXXX)
CREATED:20090922T195848Z
DESCRIPTION:
LAST-MODIFIED:20090929T190801Z
LOCATION:
SEQUENCE:2
STATUS:CONFIRMED
SUMMARY:(XXXXXXXXXXXX)
TRANSP:OPAQUE
CATEGORIES:http://schemas.google.com/g/2005#event
END:VEVENT

Hopefully that helps.
User avatar
eclectic-tech
Rainmeter Sage
Posts: 3478
Joined: April 12th, 2012, 9:40 pm
Location: Cedar Point, Ohio, USA

Re: Enigma Google Calendar Patch

eclectic-tech » January 18th, 2018, 10:47 pm

My experience with lua is also limited...
Thanks for the ics codes, the more examples there are, it should help when I get time to look further into this. :17nodding
Neurohax
Posts: 6
Joined: January 18th, 2018, 8:59 pm

Re: Enigma Google Calendar Patch

Neurohax » January 19th, 2018, 1:25 am

Cool beans, no hurry.

I did find something that might make this all easier: https://github.com/MParolari/lua-ical/blob/master/ical.lua
I'll mess around with it when i get a chance.
Fureniku
Posts: 3
Joined: July 27th, 2018, 2:35 am

Re: Enigma Google Calendar Patch

Fureniku » July 28th, 2018, 11:27 pm

I'm having the same issue as mastermindjb, and there doesn't seem to be any other google calendar skins out there.

Did you ever figure out what the problem was?

EDIT:
Ok so my google-based work account works, just not my private one. So that suggests it's an issue with the account, as opposed to an issue with the skin...
User avatar
eclectic-tech
Rainmeter Sage
Posts: 3478
Joined: April 12th, 2012, 9:40 pm
Location: Cedar Point, Ohio, USA

Re: Enigma Google Calendar Patch

eclectic-tech » July 29th, 2018, 1:58 am

Fureniku wrote:I'm having the same issue as mastermindjb, and there doesn't seem to be any other google calendar skins out there.

Did you ever figure out what the problem was?

EDIT:
Ok so my google-based work account works, just not my private one. So that suggests it's an issue with the account, as opposed to an issue with the skin...
Wow! I haven't look at this for over 6 months... more rust on my coding skill than I thought, so I took a break. :oops:

I may find some time to look deeper, but for the near future, it is what it is... a semi-working script. :yawn:
Ruff_hi
Posts: 4
Joined: August 21st, 2018, 1:30 pm

Re: Enigma Google Calendar Patch

Ruff_hi » August 21st, 2018, 4:26 pm

This is excellent. I've been looking for a rainmeter presentation of my upcoming events. This seems like it is close to what I am after.

Edit: Well ... it is close ... and I am pushing my understanding of rainmeter. Every skin I look at seems to approach the reading and presenting of data slightly differently.

Anyway ... this is what I think I know about this skin ...
1) data is obtained from / by 'Reader.lua' file
2) this file controls the content (ie time / date)
3) 'Gcalendar.ini' file controls the fonts, colors, etc

What I am really after is a list of the next 10 days with the items on that day ... like this ...

Calendar Heading "my calendar" (I like how it currently shows)

Tue Aug 21, 2018 (slightly bigger and bolder)
All Day Today is Tuesday
8:00 - 9:00 Breakfast of Champions
<space>
Wed Aug 22, 2018
Nothing on this day
<space>
Thu Aug 23, 2018
11:00 - 15:00 Dr Appointment (rash)


I've been trying to think how not to repeat the date when two events are on the same day ... but I think I should be just expanding / building the 'item' to contain all of the appointments on that day. The skin about is built around 8 items ... if I expand that to 10 ... and filter / drop any events that are not in my desired time period ... then ...

title 1 is today's date
item 1 is all of the events for today's date ... can you crlf with rainmeter?