espial-0.0.34: src/Model/FileNetscape.hs
{-# LANGUAGE DeriveGeneric #-}
module Model.FileNetscape where
import ClassyPrelude.Yesod hiding (Value, exists, groupBy, on, (<=.), (==.), (>=.), (||.))
import Data.Char (isSpace)
import Data.List (elemIndex, findIndex)
import Data.Text (breakOn, splitOn, strip)
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TLB
import qualified Data.Time.Clock.POSIX as TI (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import qualified HTMLEntities.Decoder as HED
import qualified HTMLEntities.Text as HE
import Model
import Model.Custom
import Types
-- * Netscape Bookmarks
data NetscapeBookmark = NetscapeBookmark
{ nsbHref :: !Text,
nsbTitle :: !Text,
nsbDescription :: !Text,
nsbTags :: !Text,
nsbTime :: !UTCTime
}
deriving (Eq, Typeable, Ord, Show)
parseNetscapeBookmarks :: Text -> Either String [NetscapeBookmark]
parseNetscapeBookmarks content = do
let links = extractLinks
if null links
then Left "No bookmarks found"
else Right links
where
-- Extract bookmark links from NETSCAPE-Bookmark-file HTML.
-- Split on "<DT>" so each segment corresponds to one entry. A <DD>
-- following the </A> within that same segment is the description for that
-- entry (matching the Netscape model: DD belongs to the last seen DT).
extractLinks :: [NetscapeBookmark]
extractLinks =
let dtSegments = drop 1 $ splitOnDT content
anchorSegments = filter (\s -> "<A " `isInfixOf` s && "</A>" `isInfixOf` s) dtSegments
in catMaybes (_parseNetscapeLink <$> anchorSegments)
-- Split content into segments at each "<DT>", discarding any text before
-- the first marker.
splitOnDT :: Text -> [Text]
splitOnDT txt =
case breakOn "<DT>" txt of
(_, "") -> [txt]
(before, rest) -> before : splitOnDT (drop 4 rest) -- drop "<DT>" (4 chars)
-- Parse a single <A HREF="..." ...>Title</A> line
_parseNetscapeLink :: Text -> Maybe NetscapeBookmark
_parseNetscapeLink line = do
guard (("<A " `isInfixOf` line) && ("</A>" `isInfixOf` line))
href <- extractAttr "HREF"
let lineStr = unpack line
aIdx = findIndex (isPrefixOf "<A ") (tails lineStr)
case aIdx of
Nothing -> Nothing
Just idx ->
let aTag = drop idx lineStr
gtIdx = elemIndex '>' aTag
in case gtIdx of
Nothing -> Nothing
Just gtOffset ->
let afterGt = drop (gtOffset + 1) aTag
ltIdx = elemIndex '<' afterGt
titleStr = case ltIdx of
Nothing -> afterGt
Just ltidx -> take ltidx afterGt
title = strip (decodeHtml (pack titleStr))
in do
guard (not (null href))
let tags = fromMaybe "" (decodeHtml <$> extractAttr "TAGS")
timeStr = fromMaybe "" (extractAttr "ADD_DATE")
timeInt = readMay timeStr :: Maybe Int
utcTime = maybe (UTCTime (toEnum 0) 0) (TI.posixSecondsToUTCTime . fromIntegral) timeInt
pure
$ NetscapeBookmark
{ nsbHref = href,
nsbTitle = title,
nsbDescription = extractDD,
nsbTags = tags,
nsbTime = utcTime
}
where
-- Extract description text from a <DD> tag that follows </A> in the chunk.
extractDD :: Text
extractDD =
let afterClose = snd (breakOn "</A>" line)
afterDD = snd (breakOn "<DD>" afterClose)
in if null afterDD
then ""
else
let inner = drop 4 afterDD -- drop "<DD>"
trimmed = fst (breakOn "<" inner)
trimmedStr = dropWhile isSpace . reverse . dropWhile isSpace . reverse . unpack $ trimmed
in decodeHtml (pack trimmedStr)
-- Extract an HTML attribute value (e.g., HREF="value" -> value)
extractAttr :: Text -> Maybe Text
extractAttr attr =
let attrPattern = unpack attr <> "=\""
lineStr = unpack line
in case dropWhile (\s -> not (attrPattern `isPrefixOf` s)) (tails lineStr) of
[] -> Nothing
(match : _) ->
let afterPattern = drop (length attrPattern) match
in case elemIndex '"' afterPattern of
Nothing -> Nothing
Just endIdx ->
let extracted = take endIdx afterPattern
in if null extracted then Nothing else Just (decodeHtml (pack extracted))
decodeHtml :: Text -> Text
decodeHtml = TL.toStrict . TLB.toLazyText . HED.htmlEncodedText
netscapeBookmarkToBookmark :: UserId -> NetscapeBookmark -> IO Bookmark
netscapeBookmarkToBookmark user NetscapeBookmark {..} = do
slug <- mkBmSlug
pure
$ Bookmark
{ bookmarkUserId = user,
bookmarkSlug = slug,
bookmarkHref = nsbHref,
bookmarkDescription = nsbTitle,
bookmarkExtended = nsbDescription,
bookmarkTime = nsbTime,
bookmarkShared = True,
bookmarkToRead = False,
bookmarkSelected = False,
bookmarkArchiveHref = Nothing
}
bookmarkToNetscapeBookmark :: Bookmark -> Text -> NetscapeBookmark
bookmarkToNetscapeBookmark Bookmark {..} tags =
NetscapeBookmark
{ nsbHref = bookmarkHref,
nsbTitle = bookmarkDescription,
nsbDescription = bookmarkExtended,
nsbTags = tags,
nsbTime = bookmarkTime
}
renderNetscapeBookmarks :: [NetscapeBookmark] -> Text
renderNetscapeBookmarks bmarks =
unlines
$ [ "<!DOCTYPE NETSCAPE-Bookmark-file-1>",
"<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">",
"<TITLE>Bookmarks</TITLE>",
"<H1>Bookmarks</H1>",
"<DL><p>"
]
<> concatMap renderBookmark bmarks
<> ["</DL><p>"]
where
renderBookmark :: NetscapeBookmark -> [Text]
renderBookmark NetscapeBookmark {..} =
[ " <DT><A HREF=\""
<> escapeHtml nsbHref
<> "\" ADD_DATE=\""
<> tshow (floor (TI.utcTimeToPOSIXSeconds nsbTime) :: Int)
<> "\" TAGS=\""
<> escapeHtml nsbTags
<> "\">"
<> escapeHtml nsbTitle
<> "</A>"
]
<> [" <DD>" <> escapeHtml nsbDescription | not (null (strip nsbDescription))]
escapeHtml :: Text -> Text
escapeHtml = HE.text
insertNetscapeBookmarks :: Key User -> FilePath -> DB (Either String Int)
insertNetscapeBookmarks userId bookmarkFile = do
meither <- liftIO $ tryAny (readFile bookmarkFile)
case meither of
Left _ -> pure $ Left "Could not read file"
Right (content :: ByteString) ->
let contentText = decodeUtf8 content
in case parseNetscapeBookmarks contentText of
Left e -> pure $ Left e
Right nbmarks -> do
bmarks <- liftIO $ mapM (netscapeBookmarkToBookmark userId) nbmarks
mbids <- mapM insertUnique bmarks
mapM_ (void . insertUnique)
$ concatMap (uncurry (mkBookmarkTags userId))
$ catMaybes
$ zipWith
(\mbid nbm -> (,parseNetscapeTags $ nsbTags nbm) <$> mbid)
mbids
nbmarks
pure $ Right (length bmarks)
where
parseNetscapeTags :: Text -> [Text]
parseNetscapeTags tagsText =
filter (not . null)
. map strip
$ if ',' `elem` unpack tagsText
then splitOn "," tagsText
else words tagsText
exportNetscapeBookmarks :: Key User -> FilePath -> DB ()
exportNetscapeBookmarks user fpath = do
marks <- allUserBookmarks user
let bmarks = fmap (\(bm, t) -> bookmarkToNetscapeBookmark (entityVal bm) t) marks
liftIO $ writeFileUtf8 fpath (renderNetscapeBookmarks bmarks)