fradrive/translate.hs
2021-08-12 17:55:19 +02:00

356 lines
12 KiB
Haskell
Executable File

#!/usr/bin/env stack
-- stack runghc --package unordered-containers --package text --package editor-open --package cmdargs --package pointedlist --package filepath --package temporary --package directory --package process --package exceptions
{-# OPTIONS_GHC -fno-warn-missing-fields #-}
{-# LANGUAGE OverloadedStrings
, ViewPatterns
, NamedFieldPuns
, RecordWildCards
, TemplateHaskell
, DeriveGeneric, DeriveDataTypeable
, MagicHash
, MultiWayIf
#-}
import Data.Text (Text)
import qualified Data.Text.IO as Text
import qualified Data.Text as Text
import Data.HashSet (HashSet)
import qualified Data.HashSet as HashSet
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
import Text.Editor (Template, mkTemplate, userEditorDefault, _default_editor)
import Numeric.Natural
import System.Environment (getArgs)
import Control.Monad
import Control.Arrow
import Data.Maybe
import Data.List
import Data.List.PointedList (PointedList)
import qualified Data.List.PointedList as P
import System.Console.CmdArgs.Implicit
import System.Console.CmdArgs.Quote
import Data.Foldable
import Data.Data (Data)
import GHC.Generics (Generic)
import Data.Typeable (Typeable)
import System.FilePath hiding (combine)
import System.Directory
import System.Process
import System.IO
import System.Exit
import System.IO.Temp (withSystemTempFile, writeSystemTempFile)
import System.IO.Error
import Data.Ord (Down(..))
import Control.Monad.Catch
import Data.Containers.ListUtils
data Translate
= TranslateMsgs
{ msgFiles :: [FilePath]
, msgOnlyLang :: Bool
, msgRequiredLangs :: [String]
}
| TranslateI18nDirectory
{ i18nDir :: FilePath
, i18nOnlyLang :: Bool
, i18nRequiredLangs :: [String]
, i18nSourceLangs :: [String]
}
deriving (Eq, Ord, Read, Show, Generic, Typeable, Data)
$(cmdArgsQuote [d|
messages = TranslateMsgs
{ msgFiles = [] &=# args
&=# typFile
, msgOnlyLang = True
&=# name "only-lang"
&=# help "Should only the language compononent (en, de, ...) be considered?"
, msgRequiredLangs = []
&=# name "required"
&=# help "Which languages are required to exist?"
&=# typ "LANG"
}
i18n = TranslateI18nDirectory
{ i18nDir = def &=# argPos 0
&=# typDir
, i18nOnlyLang = True
&=# name "only-lang"
&=# help "Should only the language compononent (en, de, ...) be considered?"
, i18nRequiredLangs = []
&=# name "required"
&=# help "Which languages are required to exist?"
&=# typ "LANG"
, i18nSourceLangs = []
&=# name "source"
&=# help "Preference order for source of translation"
&=# typ "LANG"
}
run = cmdArgs# (modes# [ messages &=# auto &=# name "msgs"
, i18n &=# name "dir"
]) :: IO Translate
|])
type LineNumber = Natural
type MsgName = Text
type ArgName = Text
type ArgType = Text
data MsgDefinition = MsgDefinition
{ msgName :: MsgName
, msgArgs :: [(ArgName, Maybe ArgType)]
, msgTrans :: Text
}
data MsgFile = MsgFile
{ msgFile :: FilePath
, msgDefinitions :: [MsgDefinition]
, msgNames :: HashSet MsgName
}
main :: IO ()
main = main' =<< run
where
main' m@TranslateMsgs{..}
| null msgRequiredLangs = main' m{ msgRequiredLangs = ["de-de-formal", "en-eu"] }
| otherwise = do
msgFiles' <- nubOrd . (msgFiles ++) <$> getMissingFiles m
let
filePairs :: [(FilePath, FilePath)]
filePairs = concat $ zipWith (fmap . (,)) msgFiles (tail $ tails msgFiles')
mapM_ (\(f1, f2) -> join $ combine <$> readMsgFile f1 <*> readMsgFile f2) filePairs
main' m@TranslateI18nDirectory{..}
| null i18nRequiredLangs = main' m{ i18nRequiredLangs = ["de-de-formal", "en-eu"] }
| null i18nSourceLangs = main' m{ i18nSourceLangs = ["de-de-formal"] }
| otherwise = do
missing <- getMissingFiles m
forM_ missing $ \missing' -> do
translateSource <- getTranslateSource m missing'
case translateSource of
Just src -> translateFrom src missing'
Nothing -> do
hPutStrLn stderr "Could not determine translation source."
exitWith $ ExitFailure 1
readMsgFile :: FilePath -> IO MsgFile
readMsgFile f = catchIf isDoesNotExistError readMsgFile' . const . return $ MsgFile f mempty mempty
where readMsgFile' = readMsgText f <$> Text.readFile f
readMsgText :: FilePath -> Text -> MsgFile
readMsgText f (Text.lines -> ls) =
let defs = mapMaybe readMsgLine ls
names = HashSet.fromList $ map msgName defs
in MsgFile
{ msgFile = f
, msgDefinitions = defs
, msgNames = names
}
readMsgLine :: Text -> Maybe MsgDefinition
readMsgLine l = do
guard . not $ "#" `Text.isPrefixOf` l
let
(msgPrefix, trans) = Text.breakOn ":" l
parts = filter (not . Text.null) $ Text.splitOn " " msgPrefix
parseArg arg = (argName, Text.stripPrefix "@" argType)
where
(argName, argType) = Text.breakOn "@" arg
if | msgName : args <- parts
-> do
t <- Text.stripPrefix ":" trans
return MsgDefinition
{ msgName
, msgArgs = map parseArg args
, msgTrans = Text.strip t
}
| otherwise
-> Nothing
computeMissing :: MsgFile -> MsgFile -> [MsgDefinition]
computeMissing f1 f2 = filter f $ msgDefinitions f1
where f MsgDefinition{..} = not $ msgName `HashSet.member` msgNames f2
renderMsg :: MsgDefinition -> Text
renderMsg MsgDefinition{..} = (<> (": " <> msgTrans)) . Text.unwords $
[ msgName ] ++
[ argName <> maybe Text.empty ("@" <>) mArgType
| (argName, mArgType) <- msgArgs
]
insertIntoFile :: MsgFile -> MsgFile -> MsgFile -> IO ()
insertIntoFile target reference source = do
let getLines = fmap (P.fromListEnd . Text.lines) . Text.readFile
forM_ (msgDefinitions source) $ \msgDef -> do
targetLines <- catchIf isDoesNotExistError (getLines $ msgFile target) . const $ return Nothing
referenceLines <- getLines $ msgFile reference
case toList <$> targetLines of
Just lines -> do
let template = takeFileName (msgFile target) <.> Text.unpack (msgName msgDef) <.> "..bak"
void . writeSystemTempFile template . Text.unpack $ Text.unlines lines
Nothing -> return ()
let -- fName = "/tmp" </> takeFileName (msgFile target) <.> Text.unpack (msgName msgDef)
fName = msgFile target
Text.writeFile fName . Text.unlines $ if
| Just targetLines' <- targetLines
, Just referenceLines' <- referenceLines
-> toList $ insertInto targetLines' referenceLines' msgDef
| otherwise
-> renderMsg msgDef : maybe [] toList targetLines
where
insertInto ts rs msgDef
= let rs' = focusDef rs
where focusDef rs
| Just f <- readMsgLine $ P._focus rs
, msgName f == msgName msgDef
= rs
| otherwise
= maybe rs focusDef $ P.previous rs
ts' = alignFocus 0 ts rs'
where alignFocus off cts crs
| Just rp <- P.previous crs
, Just rdef <- readMsgLine $ P._focus rp
, Just tdef <- readMsgLine $ P._focus cts
= if | msgName tdef /= msgName rdef
, Just nts <- P.previous cts
-> alignFocus off nts crs
| msgName tdef /= msgName rdef
, Just nrs <- P.previous crs
-> alignFocus 0 ts nrs
| otherwise
-> insertOffset off cts crs
| Just nrs <- P.previous crs
, Just _ <- readMsgLine $ P._focus cts
= alignFocus (succ off) cts nrs
| Just nts <- P.previous cts
= alignFocus off nts crs
| otherwise
= P.insertRight (renderMsg msgDef) ts
insertOffset 0 cts _ = P.insertRight (renderMsg msgDef) cts
insertOffset n cts crs
| Just nrs <- P.next crs
= insertOffset (pred n) (P.insertRight (P._focus crs) cts) nrs
| otherwise
= P.insertRight (renderMsg msgDef) cts
in ts'
combine :: MsgFile -> MsgFile -> IO ()
combine f1 f2 = insertMissing f1 f2 >> insertMissing f2 f1
where
insertMissing :: MsgFile -> MsgFile -> IO ()
insertMissing f1' f2' = do
let missing = computeMissing f1' f2'
header = Text.unlines
[ "# Translate from: " <> Text.pack (msgFile f1')
, "# Target: " <> Text.pack (msgFile f2')
]
query = (Text.unlines . (header :) . pure) . Text.unlines $ map renderMsg missing
unless (null missing) $ do
response <- runUserEditor (mkTemplate "msg") query
let responseMsgs = readMsgText "" response
responseMsgs' = responseMsgs { msgDefinitions = map stripTypes $ msgDefinitions responseMsgs }
stripTypes defn = defn { msgArgs = map (\(n, _) -> (n, Nothing)) $ msgArgs defn }
insertIntoFile f2' f1' responseMsgs'
normalizeLang :: Bool -> String -> String
normalizeLang onlyLang
| onlyLang = fst . break (== '-')
| otherwise = id
getLang :: Bool -> FilePath -> String
getLang onlyLang = normalizeLang onlyLang . Text.unpack . last . Text.splitOn "." . Text.pack . dropExtension . takeFileName
replaceLang :: FilePath -> String -> FilePath
replaceLang reference l = takeDirectory reference </> withPrefix l <.> takeExtension reference
where prefix = getPrefix reference
withPrefix | not $ null prefix = (prefix <.>)
| otherwise = id
getPrefix :: FilePath -> String
getPrefix = Text.unpack . Text.intercalate "." . init . Text.splitOn "." . Text.pack . dropExtension . takeFileName
getFiles :: FilePath -> IO [FilePath]
getFiles dir = filterM doesFileExist . map (dir </>) =<< listDirectory dir
getTranslateSource :: Translate -> FilePath -> IO (Maybe FilePath)
getTranslateSource TranslateI18nDirectory{..} missing = do
fs <- getFiles i18nDir
let fs' = sortOn (\(getLang i18nOnlyLang -> l) -> Down . elemIndex l . reverse $ map (normalizeLang i18nOnlyLang) i18nSourceLangs)
[ f
| f <- fs
, getPrefix f == getPrefix missing
, takeExtension f == takeExtension missing
]
return $ listToMaybe fs'
getMissingFiles :: Translate -> IO [FilePath]
getMissingFiles TranslateI18nDirectory{..} = do
fs <- getFiles i18nDir
let
exts :: HashMap (String, String) [FilePath]
exts = HashMap.fromListWith (<>) $ map ((getPrefix &&& takeExtension) &&& pure) fs
return . flip concatMap exts $ \refs@(r : _) ->
let ls :: [String]
ls = map (getLang i18nOnlyLang) refs
missing :: [String]
missing = filter (\l -> not $ any (== normalizeLang i18nOnlyLang l) ls) i18nRequiredLangs
in map (replaceLang r) missing
getMissingFiles TranslateMsgs{..} = do
fs <- concat <$> mapM getFiles (map takeDirectory msgFiles)
let
exts :: HashMap String [FilePath]
exts = HashMap.fromListWith (<>) $ map (takeExtension &&& pure) fs
return . flip concatMap exts $ \refs@(r : _) ->
let ls :: [String]
ls = map (getLang msgOnlyLang) refs
missing :: [String]
missing = filter (\l -> not $ any (== normalizeLang msgOnlyLang l) ls) msgRequiredLangs
in map (replaceLang r) missing
translateFrom :: FilePath -> FilePath -> IO ()
translateFrom from to = do
editor <- userEditorDefault _default_editor
flip onError (removeFile to) $ do
copyFile from to
callProcess editor [to]
runUserEditor :: Template -> Text -> IO Text
runUserEditor templ initialContents = withSystemTempFile templ $ \filePath hdl -> do
editor <- userEditorDefault _default_editor
hSetBuffering hdl NoBuffering
Text.hPutStr hdl initialContents
hClose hdl
callProcess editor [filePath]
Text.readFile filePath