Reader

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
	}
Photo

Michael Engard was born shortly before the Berlin Wall came down. By day, he does web design and development for a marketing agency in northern Virginia. On weekends, he helps his local Starbucks fulfill its legal obligation to have at least one pretentious introvert writing a novel in the corner. He uses words like “tautological” in conversation. He will pet your dog.

Kaelri is an Internet pseudonym. It means nothing, and you’re welcome to pronounce it however you want to.