Writer and web developer.

Reader

Featured image

An RSS/Atom feed reader utility for Rainmeter desktop widgets.

function Initialize()
	-- CREATE MAIN TABLES
	Feeds    = {}
	-- "Feeds" is the master database of all feeds' items
	-- and properties.
	Display  = {}
	-- "Display" is a dynamic table containing items from
	-- one or more feeds, sorted by user preference.
	Feedback = {}
	-- "Feedback" is a chronology of success, error and
	-- debug messages from the current session.

	-- SET STARTING FEED
	F = F or tonumber(SKIN:GetVariable('CurrentFeed', 1))
	-- Capital "F" always refers to the global "real" feed
	-- number. Lower-case "f" refers to the local "working"
	-- feed number within a specific function.

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

	-- SET UPDATE DIVIDER
	Script = SELF:GetName()
	local UpdateDivider = SELF:GetNumberOption('UpdateDivider')
	if UpdateDivider == 0 then
		SKIN:Bang('!SetOption', Script, 'UpdateDivider', 60)
	end
	-- Since frequent updating is not necessary with this
	-- script, it defaults to an update divider of 60
	-- (once per minute) unless explicitly set.

	-- SET UP MODULES
	EventFile_Initialize()
	HistoryFile_Initialize()
end

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

function Input(f, EventType)
	local InputTime = os.time()
	local Feed      = Feeds[f]

	-- GET OPTIONS
	local KeepOldItems = SELF:GetNumberOption('KeepOldItems', 0)
	local MaxKeepItems = SELF:GetNumberOption('MaxKeepItems', 0)
	local LimitNew     = SELF:GetNumberOption('LimitNew',     5) * 60
	local LimitUnread  = SELF:GetNumberOption('LimitUnread',  0) * 60

	-- CHECK FOR CONTENT
	if (not Feed.Raw) or (Feed.Raw == '') then
		Feed.Error = {
			Description = 'Waiting for data from WebParser.',
			Title       = 'Loading...',
			Link        = 'http://enigma.kaelri.com/support'
			}
		return
	end

	-- RE-PARSE CONTENT
	if EventType == 'Refresh' then
		-- RESOLVE CDATA
		if Feed.Raw:match('<!%[CDATA%[') then
			Feed.Raw = Feed.Raw:gsub('<!%[CDATA%[(.-)%]%]>', EncodeCharacterReference)
		end

		-- DETERMINE FEED FORMAT AND CONTENTS
		local t = IdentifyType(Feed.Raw)

		if t then
			Feed.Type = t
		else
			Feed.Error = {
				Description = 'Could not identify a valid feed format.',
				Title       = 'Invalid Feed Format',
				Link        = 'http://enigma.kaelri.com/support'
				}
			SendFeedback(('Error parsing #%d (from %s). %s'):format(f, Feed.MeasureName, Feed.Error.Description))
			return
		end

		local Type = Types[t]

		-- GET NEW DATA
		Feed.Title = IdentifyTag(Feed.Raw, {'<title.->(.-)</title>'})
		Feed.Link  = IdentifyTag(Feed.Raw, Type.Link)

		local NewItems = {}
		for RawItem in Feed.Raw:gmatch(Type.Item) do
			local Item  = {}

			-- MATCH RAW DATA
			Item.Origin = f
			Item.New    = 1
			Item.Unread = 1
			Item.Pull   = InputTime
			Item.Title  = IdentifyTag(RawItem, {'<title.->(.-)</title>'})
			Item.Link   = IdentifyTag(RawItem, Type.ItemLink)
			Item.Desc   = IdentifyTag(RawItem, Type.ItemDesc)
			Item.Date   = IdentifyTag(RawItem, Type.ItemDate)
			Item.Event  = IdentifyTag(RawItem, Type.ItemEvent)
			Item.ID     = IdentifyTag(RawItem, Type.ItemID) or Item.Link or Item.Title or Item.Desc or Item.Date or Item.Event

			-- DEFAULTS & ADDITIONAL PARSING
			if not Item.Link then Item.Link = Feed.Link end

			Item.Date = IdentifyDate(Item.Date,  Type.ParseDate)
			if not Item.Date then
				Item.Date   = Item.Pull
				Item.NoDate = 1
			end

			if Type.ParseEvent then
				Item.Event, Item.AllDay = IdentifyDate(Item.Event, Type.ParseEvent)
				if not Item.Event then
					Item.Event   = Item.Date
					Item.AllDay  = 0
					Item.NoEvent = 1
				end
			end

			table.insert(NewItems, Item)
		end
	
		-- IDENTIFY DUPLICATES
		for i, OldItem in ipairs(Feed) do
			for j, NewItem in ipairs(NewItems) do
				if NewItem.ID == OldItem.ID then
					OldItem.Match  = NewItem
					NewItem.New    = OldItem.New
					NewItem.Unread = OldItem.Unread
					NewItem.Pull   = OldItem.Pull
					if NewItem.NoDate then
						NewItem.Date   = OldItem.Date
					end
					if NewItem.NoEvent then
						NewItem.Event   = OldItem.Event
						NewItem.AllDay = OldItem.AllDay
					end
					break
				end
			end
		end
		-- This process scans each item that already exists in the feed table,
		-- and checks it against each item that has been discovered in the live
		-- feed content. If it recognizes one of the new items as matching a
		-- preexisting item, it keeps the old unread state and retrieval date,
		-- as well as the publication and event dates (if expected). Finally, it
		-- marks the old item as a duplicate for deletion.

		-- CLEAR DUPLICATES OR ALL HISTORY
		if (KeepOldItems == 1) then
			for i = #Feed, 1, -1 do
				if Feed[i].Match then
					table.remove(Feed, i)
				end
			end
		else
			for i = 1, #Feed do
				table.remove(Feed)
			end
		end

		-- ADD NEW ITEMS
		for i = #NewItems, 1, -1 do
			if NewItems[i] then
				table.insert(Feed, 1, NewItems[i])
			end
		end

		-- CHECK NUMBER OF ITEMS
		if #Feed == 0 then
			Feed.Error = {
				Description = 'No items found.',
				Title       = Feed.Title,
				Link        = Feed.Link
				}
			SendFeedback(('Error parsing #%d (from %s). %s'):format(f, Feed.MeasureName, Feed.Error.Description))
			return
		elseif (MaxKeepItems == 1) and (#Feed > MaxKeepItems) then
			for i = #Feed, (MaxKeepItems + 1), -1 do
				table.remove(Feed)
			end
		end

		-- CLEAR ERROR FROM PREVIOUS REFRESH
		Feed.Error   = nil
	elseif Feed.Error then
		return
		-- If the previous refresh encountered an error, and the
		-- content has not changed, then the error is still present,
		-- and the function should not continue.
	end

	-- LIMIT NEW/UNREAD STATES AND RECALCULATE TOTAL
	-- Done for Refresh, Update and Mark.
	Feed.New    = 0
	Feed.Unread = 0
	for _, Item in ipairs(Feed) do
		Item.New = (InputTime - Item.Pull < LimitNew) and Item.New or 0
		Feed.New = Feed.New + Item.New

		if LimitUnread > 0 then
			Item.Unread = (InputTime - Item.Pull < LimitUnread) and Item.Unread or 0
		end
		Feed.Unread = Feed.Unread + Item.Unread
	end

	-- MODULES
	EventFile_Update(f)
	HistoryFile_Update(f)

	SendFeedback(('Finished #%d (from %s). Name: "%s". Type: %s. Items: %d (%d new, %d unread).'):format(f, Feed.MeasureName, Feed.Title, Feed.Type, #Feed, Feed.New, Feed.Unread))
end

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

function Output()
	Display = {}

	-- GET OPTIONS
	local CombineFeeds   = SELF:GetNumberOption('CombineFeeds',   0)
	local MinShowItems   = SELF:GetNumberOption('MinShowItems',   nil)
	local MaxShowItems   = SELF:GetNumberOption('MaxShowItems',   nil)
	local Timestamp      = SELF:GetOption      ('Timestamp',      '%I.%M %p on %d %B %Y')
	local VariablePrefix = SELF:GetOption      ('VariablePrefix', '')
	local FinishAction   = SELF:GetOption      ('FinishAction',   nil)

	-- CLEAR AND REBUILD DISPLAY CONTENT
	if (CombineFeeds == 1) then
		Display.All    = true
		Display.Title  = 'All Items'
		Display.Link   = ''
		Display.New    = 0
		Display.Unread = 0
		for f = 1, #Feeds do
			if Feeds[f].Error then
				Display.Error = Feeds[f].Error
				break
			else
				Display.New    = Display.New    + Feeds[f].New
				Display.Unread = Display.Unread + Feeds[f].Unread
				for _, Item in ipairs(Feeds[f]) do
					table.insert(Display, Item)
				end
			end
		end
	else
		Display.All = false
		for k, v in pairs(Feeds[F]) do
			Display[k] = v
		end
	end

	-- SORTING
	if (CombineFeeds == 1) then
		table.sort(Display, function(a, b) return a.Date > b.Date end)
	end

	-- BUILD QUEUE
	local Queue     = {}
	local ShowRange = math.min(MaxShowItems, math.max(MinShowItems, #Display))

	Queue['CurrentFeed']   = (CombineFeeds == 1) and 0 or F
	Queue['NumberOfItems'] = #Display

	-- CHECK FOR INPUT ERRORS
	if Display.Error then
		-- ERROR; QUEUE MESSAGES
		Queue['FeedTitle']   = Display.Error.Title       or 'Untitled'
		Queue['FeedLink']    = Display.Error.Link        or ''
		Queue['FeedNew']     = 0
		Queue['FeedUnread']  = 0
		Queue['Item1Title']  = Display.Error.Description or ''
		Queue['Item1Link']   = Display.Error.Link        or ''
		Queue['Item1Desc']   = ''
		Queue['Item1Date']   = ''
		Queue['Item1Event']  = ''
		Queue['Item1Origin'] = ''
		Queue['Item1Unread'] = 0
		Queue['Item1New']    = 0

		for i = 2, ShowRange do
			Queue['Item'..i..'Title']   = ''
			Queue['Item'..i..'Link']    = ''
			Queue['Item'..i..'Desc']    = ''
			Queue['Item'..i..'Unread']  = 0
			Queue['Item'..i..'New']     = 0
			Queue['Item'..i..'Date']    = ''
			Queue['Item'..i..'Event']   = ''
			Queue['Item'..i..'Origin']  = ''
		end
	else
		-- NO ERROR; QUEUE FEED
		Queue['FeedTitle']  = Display.Title  or 'Untitled'
		Queue['FeedLink']   = Display.Link   or ''
		Queue['FeedNew']    = Display.New
		Queue['FeedUnread'] = Display.Unread

		for i = 1, ShowRange do
			local Item = Display[i] or {}
			Queue['Item'..i..'Title']  = Item.Title  or ''
			Queue['Item'..i..'Link']   = Item.Link   or Queue['FeedLink'] or ''
			Queue['Item'..i..'Desc']   = Item.Desc   or ''
			Queue['Item'..i..'Unread'] = Item.Unread or 0
			Queue['Item'..i..'New']    = Item.New    or 0
			Queue['Item'..i..'Event']  = Item.Event  and os.date(Timestamp, Item.Event) or ''
			Queue['Item'..i..'Date']   = Item.Date   and os.date(Timestamp, Item.Date)  or ''
			Queue['Item'..i..'Origin'] = Item.Origin and Feeds[Item.Origin].Title       or ''
		end
	end

	-- SET VARIABLES
	for k, v in pairs(Queue) do
		SKIN:Bang('!SetVariable', VariablePrefix..k, v)
	end

	-- FINISH ACTION
	if FinishAction then
		SKIN:Bang(FinishAction)
	end
end

-----------------------------------------------------------------------
-- EVENTS

function Update()
	for f, Feed in ipairs(Feeds) do
		Input(f, 'Update')
	end
	Output()
end

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

function Refresh(f)
	f = tonumber(f) or F
	local Feed = Feeds[f]

	local Raw = Feed.Measure:GetStringValue()

	-- IF FEED HAS CHANGED, UPDATE INPUT
	if (Raw ~= Feed.Raw) then
		Feed.Raw = Raw
		Input(f, 'Refresh')
	end

	-- IF FEED IS DISPLAYING, UPDATE OUTPUT
	if (f == F) or (Display.All) then
		Output()
	end
end

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

function Show(f)
	F = tonumber(f)
	Output()
end

function ShowNext()
	F = (F % #Feeds) + 1
	Output()
end

function ShowPrevious()
	F = (F == 1) and #Feeds or (F - 1)
	Output()
end

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

function Mark(Target, Unread)
	Target = tonumber(Target)
	Unread = tonumber(Unread)

	-- DEFINE RANGE
	local Start = (Target == 0) and 1        or Target
	local End   = (Target == 0) and #Display or Target

	-- MARK ITEM AND UPDATE INPUT FOR AFFECTED FEEDS
	local InputQueue = {}

	for i = Start, End do
		local Item = Display[i]
		Item.Unread = (Unread == -1) and (1 - Item.Unread) or Unread
		InputQueue[Item.Origin] = true
	end

	for f, _ in pairs(InputQueue) do
		Input(f, 'Mark')
	end

	Output()
end

function MarkRead        (a) Mark(a,  0) end
function MarkUnread      (a) Mark(a,  1) end
function ToggleUnread    (a) Mark(a, -1) end
function MarkAllRead     ( ) Mark(0,  0) end
function MarkAllUnread   ( ) Mark(0,  1) end
function ToggleAllUnread ( ) Mark(0, -1) end

-----------------------------------------------------------------------
-- SORTING



-----------------------------------------------------------------------
-- PARSING

function IdentifyType(s)
	-- COLLAPSE CONTAINER TAGS
	-- Unnecessary when DecodeCharacterReference is not used in WebParser.
	-- 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)
			-- 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')

			if HaveItems and not HaveEntries then
				return 'RSS'
			elseif HaveEntries and not HaveItems then
				return 'Atom'
			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 'GoogleCalendar'
		elseif s:match('<subtitle>rememberthemilk.com</subtitle>') then
			return 'RememberTheMilk'
		else
			return 'Atom'
		end
	else
		return false
	end
end

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

function IdentifyTag(s, p)
	local Tag

	-- IF BOTH STRING AND PATTERNS ARE GIVEN, FIND MATCH
	if s and p then
		for _, Pattern in ipairs(p) do
			Tag = s:match(Pattern)
			if Tag then break end
		end
	end

	-- IF MATCH IS FOUND, PARSE
	if Tag and Tag:match('[%w%p]') then
		-- CLEAN UP FORMATTING
		Tag = DecodeCharacterReference(Tag) -- Replace HTML character references with real values.
		Tag = Tag:match('^%s*(.-)%s*$')     -- Strip whitespace from beginning and end of value.
		Tag = Tag:gsub('%s%s+', ' ')        -- Condense whitespace within value.
	else
		-- VALUE CONTAINS NO REAL CONTENT; TREAT AS NONEXISTENT.
		Tag = nil
	end

	return Tag
end

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

function IdentifyDate(s, p)
	local Date, AllDay

	-- IF BOTH STRING AND PATTERNS ARE GIVEN, FIND MATCH
	if s and p then
		for _, Pattern in ipairs(p) do
			Date = Pattern(s)
			if Date then break end
		end
	end

	-- IF MATCH IS FOUND, PARSE
	if Date then
		Date.year  = tonumber(Date.year)
		Date.month = tonumber(Date.month) or MonthAcronyms[Date.month]
		Date.day   = tonumber(Date.day)
		Date.hour  = tonumber(Date.hour)
		Date.min   = tonumber(Date.min)
		Date.sec   = tonumber(Date.sec)

		-- DETECT ALL-DAY EVENT
		local AllDay
		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
			Date.year = (math.abs(CurrentYear - IfThisCentury) < math.abs(CurrentYear - IfNextCentury)) and IfThisCentury or IfNextCentury
		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
	end

	return Date, AllDay
end

function ParseDateRSS(s)
	local d = {}
	d.day, d.month, d.year, d.hour, d.min, d.sec, d.Offset = s:match('(%d+) (%a+) (%d+) (%d+)%:(%d+)%:(%d+) (.-)$')
	return next(d) and d or nil
end

function ParseDateAtom(s)
	local d = {}
	d.year, d.month, d.day, d.hour, d.min, d.sec, d.Offset = s:match('(%d+)%-(%d+)%-(%d+)T(%d+)%:(%d+)%:(%d+%.?%d*)(.-)$')
	return next(d) and d or nil
end

function ParseEventGoogleCalendar(s)
	local d    = {}
	d.year, d.month, d.day, d.hour, d.min, d.sec, d.Offset = s:match('(%d+)%-(%d+)%-(%d+)T(%d+)%:(%d+)%:(%d+)%.%d+(.-)$')
	return next(d) and d or nil
end

function ParseEventGoogleCalendarAllDay(s)
	local d  = {}
	d.year, d.month, d.day = s:match('(%d+)%-(%d+)%-(%d+)$')
	return next(d) and d or nil
end

function ParseEventRememberTheMilk(s)
	local d = {}
	d.day, d.month, d.year, d.hour, d.min, d.Meridiem = s:match('%a+ (%d+) (%a+) (%d+) at (%d+)%:(%d+)(%a+)') -- e.g. 'Wed 7 Nov 12 at 3:17PM'
	return next(d) and d or nil
end

function ParseEventRememberTheMilkAllDay(s)
	local d = {}
	d.day, d.month, d.year = s:match('%a+ (%d+) (%a+) (%d+)') -- e.g. 'Tue 25 Dec 12'
	return next(d) and d or nil
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 Script..'_Feed'..i..'Events.xml'
		Feeds[i].EventFile = SKIN:MakePathAbsolute(EventFile)
	end
end

function EventFile_Update(f)
	f = f or F
	local Feed = Feeds[f]

	local WriteEvents = SELF:GetNumberOption('WriteEvents', 0)
	if (WriteEvents == 1) and (Feed.Type == 'GoogleCalendar') then
		-- CREATE XML TABLE
		local WriteLines = {}
		table.insert(WriteLines, '<EventFile Title="'..Feed.Title..'">')
		for i, v in ipairs(Feed) 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(Feed.EventFile, 'w')
		if WriteFile then
			local WriteContent = table.concat(WriteLines, '\r\n')
			WriteFile:write(WriteContent)
			WriteFile:close()
		else
			SKIN:Bang('!Log', Script..': cannot open file: '..Feed.EventFile)
		end
	end
end

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

function HistoryFile_Initialize()
	-- DETERMINE FILEPATH
	HistoryFile = SELF:GetOption('HistoryFile', Script..'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
					Item[Key] = DecodeCharacterReference(Value)
					Item[Key] = tonumber(Item[Key]) or Item[Key]
				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
				Item.Origin = f
				table.insert(Feeds[f], Item)
			end
		end
	end
end

function HistoryFile_Update(f)
	f = f or F
	local Feed = Feeds[f]

	-- CLEAR AND REBUILD HISTORY
	local h = Feed.URL
	History[h] = {}
	for i, Item in ipairs(Feed) 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,                        ('<feed URL=%q>'):format(WriteURL))
			for _, WriteItem in ipairs(WriteFeed) do
				table.insert(WriteLines,                     '\t<item>')
				for Key, Value in pairs(WriteItem) do
					Value = EncodeCharacterReference(Value)
					table.insert(WriteLines,                ('\t\t<%s>%s</%s>'):format(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', Script..': 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', Script..': deleted history cache at '..HistoryFile)
	end
	SKIN:Bang('!Refresh')
end

-----------------------------------------------------------------------
-- DECODE CHARACTER REFERENCE

function EncodeCharacterReference(s)
	if type(s) == 'string' then
		for Ref, Char in pairs(CharacterReferences) do
			if Char < 256 then -- Temporary safeguard for Unicode characters.
				s = s:gsub(string.char(Char), '&'..Ref..';')
			end
		end
	end
	return s
end

function DecodeCharacterReference(s, Max)
	if type(s) == 'string' then
		local Max   = Max or 0
		local Loops = 0
		local Matches

		-- DEFINE MATCHING FUNCTION FOR SINGLE VALID REFERENCE.
		local function ReplaceReference(s)
			-- STRIP CONTAINER
			local Ref = s:match('^&(.+);$')
			local Char

			-- MATCH NUMBER OR HTML CODE
			if Ref:match('^#') then
				-- NUMBER
				local Base = Ref:match('^#x') and 16 or 10
				Char = tonumber(Ref:match('^#x?(%w+)'), Base)
			else
				-- HTML
				Char = CharacterReferences[Ref]
			end

			if Char then
				-- IF REFERENCE WAS FOUND, INDICATE MATCH AND RETURN TRUE CHARACTER.
				Matches = Matches + 1
				Char = (Char < 256) and string.char(Char) or '' -- Temporary safeguard for Unicode characters.
				return Char
			else
				-- IF NO REFERENCE WAS FOUND, RETURN THE ORIGINAL INPUT UNCHANGED.
				return s
			end
		end

		-- FIND ALL VALID CHARACTER REFERENCES AND ATTEMPT TO MATCH.
		repeat
			Matches = 0
			Loops   = Loops + 1
			s       = s:gsub('&#?x?%w+;', ReplaceReference)
		until (Loops == Max) or (Matches == 0)
	end
	return s
end

-----------------------------------------------------------------------
-- FEEDBACK

function SendFeedback(s)
	table.insert(Feedback, s)
	local Debug = SELF:GetNumberOption('Debug', 0)
	if (Debug == 1) then
		SKIN:Bang('!Log', ('%s: %s'):format(Script, s))
	end
end

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

Types = {
	RSS = {
		Link       = { '<link.->(.-)</link>' },
		Item       = '<item.-</item>',
		ItemID     = { '<guid.->(.-)</guid>' },
		ItemLink   = { '<link.->(.-)</link>' },
		ItemDesc   = { '<description.->(.-)</description>' },
		ItemDate   = { '<pubDate.->(.-)</pubDate>', '<dc:date>(.-)</dc:date>' },
		ParseDate  = { ParseDateRSS, ParseDateAtom }
		},
	Atom = {
		Link       = { '<link.-href=["\'](.-)["\']' },
		Item       = '<entry.-</entry>',
		ItemID     = { '<id.->(.-)</id>' },
		ItemLink   = { '<link.-href=["\'](.-)["\']' },
		ItemDesc   = { '<summary.->(.-)</summary>' },
		ItemDate   = { '<updated.->(.-)</updated>' },
		ParseDate  = { ParseDateAtom, ParseDateRSS }
		},
	GoogleCalendar = {
		Link       = { '<link.-rel=.-alternate.-href=["\'](.-)["\']' },
		Item       = '<entry.-</entry>',
		ItemID     = { '<id.->(.-)</id>' },
		ItemLink   = { '<link.-href=["\'](.-)["\']' },
		ItemDesc   = { '<summary.->(.-)</summary>' },
		ItemDate   = { 'startTime=["\'](.-)["\']' },
		ParseDate  = { ParseDateAtom },
		ParseEvent = { ParseEventGoogleCalendar, ParseEventGoogleCalendarAllDay }
		},
	RememberTheMilk = {
		Link       = { '<link.-rel=.-alternate.-href=["\'](.-)["\']' },
		Item       = '<entry.-</entry>',
		ItemID     = { '<id.->(.-)</id>' },
		ItemLink   = { '<link.-href=["\'](.-)["\']' },
		ItemDesc   = { '<summary.->(.-)</summary>' },
		ItemDate   = { '<span class=["\']rtm_due_value["\']>(.-)</span>' },
		ParseDate  = { ParseDateAtom },
		ParseEvent = { ParseEventRememberTheMilk, ParseEventRememberTheMilkAllDay }
		}
	}

CharacterReferences = {
	-- This table matches HTML character codes with numeric
	-- character codes. Converted from Rainmeter's WebParser.dll.

	-- STANDARD ASCII CHARACTERS (0-255):
	quot      = 34,
	amp       = 38,
	apos      = 39,
	lt        = 60,
	gt        = 62,
	nbsp      = 160,
	iexcl     = 161,
	cent      = 162,
	pound     = 163,
	curren    = 164,
	yen       = 165,
	brvbar    = 166,
	sect      = 167,
	uml       = 168,
	copy      = 169,
	ordf      = 170,
	laquo     = 171,
	['not']   = 172,
	shy       = 173,
	reg       = 174,
	macr      = 175,
	deg       = 176,
	plusmn    = 177,
	sup2      = 178,
	sup3      = 179,
	acute     = 180,
	micro     = 181,
	para      = 182,
	middot    = 183,
	cedil     = 184,
	sup1      = 185,
	ordm      = 186,
	raquo     = 187,
	frac14    = 188,
	frac12    = 189,
	frac34    = 190,
	iquest    = 191,
	Agrave    = 192,
	Aacute    = 193,
	Acirc     = 194,
	Atilde    = 195,
	Auml      = 196,
	Aring     = 197,
	AElig     = 198,
	Ccedil    = 199,
	Egrave    = 200,
	Eacute    = 201,
	Ecirc     = 202,
	Euml      = 203,
	Igrave    = 204,
	Iacute    = 205,
	Icirc     = 206,
	Iuml      = 207,
	ETH       = 208,
	Ntilde    = 209,
	Ograve    = 210,
	Oacute    = 211,
	Ocirc     = 212,
	Otilde    = 213,
	Ouml      = 214,
	times     = 215,
	Oslash    = 216,
	Ugrave    = 217,
	Uacute    = 218,
	Ucirc     = 219,
	Uuml      = 220,
	Yacute    = 221,
	THORN     = 222,
	szlig     = 223,
	agrave    = 224,
	aacute    = 225,
	acirc     = 226,
	atilde    = 227,
	auml      = 228,
	aring     = 229,
	aelig     = 230,
	ccedil    = 231,
	egrave    = 232,
	eacute    = 233,
	ecirc     = 234,
	euml      = 235,
	igrave    = 236,
	iacute    = 237,
	icirc     = 238,
	iuml      = 239,
	eth       = 240,
	ntilde    = 241,
	ograve    = 242,
	oacute    = 243,
	ocirc     = 244,
	otilde    = 245,
	ouml      = 246,
	divide    = 247,
	oslash    = 248,
	ugrave    = 249,
	uacute    = 250,
	ucirc     = 251,
	uuml      = 252,
	yacute    = 253,
	thorn     = 254,
	yuml      = 255,

	-- EXTENDED UNICODE CHARACTERS
	-- These will become usable if and when Rainmeter's Lua
	-- libraries support extended Unicode character encodings.

	OElig     = 338,
	oelig     = 339,
	Scaron    = 352,
	scaron    = 353,
	Yuml      = 376,
	fnof      = 402,
	circ      = 710,
	tilde     = 732,
	Alpha     = 913,
	Beta      = 914,
	Gamma     = 915,
	Delta     = 916,
	Epsilon   = 917,
	Zeta      = 918,
	Eta       = 919,
	Theta     = 920,
	Iota      = 921,
	Kappa     = 922,
	Lambda    = 923,
	Mu        = 924,
	Nu        = 925,
	Xi        = 926,
	Omicron   = 927,
	Pi        = 928,
	Rho       = 929,
	Sigma     = 931,
	Tau       = 932,
	Upsilon   = 933,
	Phi       = 934,
	Chi       = 935,
	Psi       = 936,
	Omega     = 937,
	alpha     = 945,
	beta      = 946,
	gamma     = 947,
	delta     = 948,
	epsilon   = 949,
	zeta      = 950,
	eta       = 951,
	theta     = 952,
	iota      = 953,
	kappa     = 954,
	lambda    = 955,
	mu        = 956,
	nu        = 957,
	xi        = 958,
	omicron   = 959,
	pi        = 960,
	rho       = 961,
	sigmaf    = 962,
	sigma     = 963,
	tau       = 964,
	upsilon   = 965,
	phi       = 966,
	chi       = 967,
	psi       = 968,
	omega     = 969,
	thetasym  = 977,
	upsih     = 978,
	piv       = 982,
	ensp      = 8194,
	emsp      = 8195,
	thinsp    = 8201,
	zwnj      = 8204,
	zwj       = 8205,
	lrm       = 8206,
	rlm       = 8207,
	ndash     = 8211,
	mdash     = 8212,
	lsquo     = 8216,
	rsquo     = 8217,
	sbquo     = 8218,
	ldquo     = 8220,
	rdquo     = 8221,
	bdquo     = 8222,
	dagger    = 8224,
	Dagger    = 8225,
	bull      = 8226,
	hellip    = 8230,
	permil    = 8240,
	prime     = 8242,
	Prime     = 8243,
	lsaquo    = 8249,
	rsaquo    = 8250,
	oline     = 8254,
	frasl     = 8260,
	euro      = 8364,
	image     = 8465,
	weierp    = 8472,
	real      = 8476,
	trade     = 8482,
	alefsym   = 8501,
	larr      = 8592,
	uarr      = 8593,
	rarr      = 8594,
	darr      = 8595,
	harr      = 8596,
	crarr     = 8629,
	lArr      = 8656,
	uArr      = 8657,
	rArr      = 8658,
	dArr      = 8659,
	hArr      = 8660,
	forall    = 8704,
	part      = 8706,
	exist     = 8707,
	empty     = 8709,
	nabla     = 8711,
	isin      = 8712,
	notin     = 8713,
	ni        = 8715,
	prod      = 8719,
	sum       = 8721,
	minus     = 8722,
	lowast    = 8727,
	radic     = 8730,
	prop      = 8733,
	infin     = 8734,
	ang       = 8736,
	['and']   = 8743,
	['or']    = 8744,
	cap       = 8745,
	cup       = 8746,
	int       = 8747,
	there4    = 8756,
	sim       = 8764,
	cong      = 8773,
	asymp     = 8776,
	ne        = 8800,
	equiv     = 8801,
	le        = 8804,
	ge        = 8805,
	sub       = 8834,
	sup       = 8835,
	nsub      = 8836,
	sube      = 8838,
	supe      = 8839,
	oplus     = 8853,
	otimes    = 8855,
	perp      = 8869,
	sdot      = 8901,
	lceil     = 8968,
	rceil     = 8969,
	lfloor    = 8970,
	rfloor    = 8971,
	lang      = 9001,
	rang      = 9002,
	loz       = 9674,
	spades    = 9824,
	clubs     = 9827,
	hearts    = 9829,
	diams     = 9830
	}

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
	}
© 2021