Permalink
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
725 lines (631 sloc) 41.4 KB
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 Input(a)
local f = a or f
local Raw = Feeds[f].Measure:GetStringValue()
if Raw == '' then
Feeds[f].Error = {
Description = 'Waiting for data from WebParser.',
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
Feeds[f].Title = Raw:match('<title.->(.-)</title>') or 'Untitled'
Feeds[f].Link = Raw:match(Type.MatchLink) or nil
local Items = {}
for RawItem in Raw:gmatch(Type.MatchItem) do
local Item = {}
-- MATCH RAW DATA
Item.Unread = 1
Item.Title = RawItem:match('<title.->(.-)</title>') or 'Untitled'
Item.Link = RawItem:match(Type.MatchItemLink) or nil
Item.Desc = RawItem:match(Type.MatchItemDesc) or nil
Item.Date = RawItem:match(Type.MatchItemDate) or nil
Item.ID = RawItem:match(Type.MatchItemID) or Item.Link or Item.Title or Item.Desc or Item.Date
-- ADDITIONAL PARSING
if Item.Desc then
Item.Desc = Item.Desc:gsub('<.->', ''):gsub('%s%s+', ' ')
end
Item.Date, Item.AllDay, Item.RealDate = IdentifyDate(Item.Date, t)
table.insert(Items, Item)
end
-- 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
table.insert(Feeds[f], 1, Items[i])
end
end
-- CHECK NUMBER OF ITEMS
local MaxItems = SELF:GetNumberOption('MaxItems')
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()
-- MAKE SYNTAX PRETTIER
local Feed = Feeds[f]
local Type = Types[Feed.Type]
local Error = Feed.Error
-- BUILD QUEUE
local Queue = {CurrentFeed = f, NumberOfItems = #Feed,}
-- CHECK FOR INPUT ERRORS
local MinItems = SELF:GetNumberOption('MinItems', 0)
local Timestamp = SELF:GetOption('Timestamp', '%I.%M %p on %d %B %Y')
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 ''
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 #%d (%s). Name: %s. Type: %s. Items: %d.'):format(f, Feed.MeasureName, Feed.Title, Feed.Type, #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 = '<updated.->(.-)</updated>',
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
},
GoogleCalendar = {
MatchLink = '<link.-rel=.-alternate.-href=["\'](.-)["\']',
MatchItem = '<entry.-</entry>',
MatchItemID = '<id.->(.-)</id>',
MatchItemLink = '<link.-href=["\'](.-)["\']',
MatchItemDesc = '<summary.->(.-)</summary>',
MatchItemDate = 'startTime=["\'](.-)["\']',
MergeItems = false,
ParseDate = function(s)
local Date = {}
local MatchTime = '(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%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
},
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 e.g. '<entry.->.+</entry>' --> '<entry></entry>'
s = s:gsub('<item.->.+</item>', '<item></item>'):gsub('<entry.->.+</entry>', '<entry></entry>')
--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 IdentifyDate(s, t)
-- PARSE STRING BY TYPE
local 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 LocalTime = os.date('*t')
local DaylightSavings = LocalTime.isdst and 3600 or 0
local LocalOffset = os.time(LocalTime) - os.time(os.date('!*t')) + 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 = 1, #Feeds do
Feeds[i].EventFile = SKIN:MakePathAbsolute(EventFiles[i] or ('%s_Feed%dEvents.xml'):format(SELF:GetName(), i))
end
end
function EventFile_Update(a)
local f = a or f
if (SELF:GetNumberOption('WriteEvents', 0) > 0) and (Feeds[f].Type == 'GoogleCalendar') then
-- CREATE XML TABLE
local WriteLines = {}
table.insert(WriteLines, ('<EventFile Title=%q>'):format(Feeds[f].Title))
for i, v in ipairs(Feeds[f]) do
local ItemDate = os.date('*t', v.Date)
table.insert(WriteLines, ('\t<Event Month=%q Day=%q Year=%q Description=%q/>'):format(ItemDate.month, ItemDate.day, ItemDate.year, v.Title))
end
table.insert(WriteLines, '</EventFile>')
-- WRITE FILE
local WriteFile = io.open(Feeds[f].EventFile, 'w')
if WriteFile then
WriteFile:write(table.concat(WriteLines, '\r\n'))
WriteFile:close()
else
SKIN:Bang('!Log', ('%s: cannot open file: %s'):format(SELF:GetName(), Feeds[f].EventFile))
end
end
end
-----------------------------------------------------------------------
-- HISTORY FILE MODULE
function HistoryFile_Initialize()
-- DETERMINE FILEPATH
HistoryFile = SKIN:MakePathAbsolute(SELF:GetOption('HistoryFile', SELF:GetName() .. 'History.xml'))
-- 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="([^"]+)">(.-)</feed>') do
History[ReadFeedURL] = {}
for ReadItem in ReadFeed:gmatch('<item>(.-)</item>') do
local Item = {}
for Key, Value in ReadItem:gmatch('<(.-)>(.-)</.->') do
Item[Key] = Value:gsub('&([^;]+);', {lt = '<', gt = '>',})
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, ('<feed URL=%q>'):format(WriteURL))
for _, WriteItem in ipairs(WriteFeed) do
table.insert(WriteLines, '\t<item>')
for Key, Value in pairs(WriteItem) do
table.insert(WriteLines, ('\t\t<%s>%s</%s>'):format(Key, Value:gsub('<', '&lt;'):gsub('>', '&gt;'), 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
WriteFile:write(table.concat(WriteLines, '\n'))
WriteFile:close()
else
SKIN:Bang('!Log', ('%s: cannot open file: %s'):format(SELF:GetName(), HistoryFile))
end
end
end
function ClearHistory()
local DeleteFile = io.open(HistoryFile)
if DeleteFile then
DeleteFile:close()
os.remove(HistoryFile)
SKIN:Bang('!Log', ('%s: deleted history cache at %s'):format(SELF:GetName(), 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
}