diff --git a/.gitignore b/.gitignore index 17211fb..f2f1a71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,117 @@ -# Created by https://www.gitignore.io/api/macos,python,visualstudiocode -# Edit at https://www.gitignore.io/?templates=macos,python,visualstudiocode +# Created by https://www.toptal.com/developers/gitignore/api/fsharp,dotnetcore,intellij+all,visualstudiocode,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=fsharp,dotnetcore,intellij+all,visualstudiocode,macos + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ + +# Common node modules locations +/node_modules +/wwwroot/node_modules + +### fsharp ### +lib/debug +lib/release +Debug +*.suo +*.user +obj +bin +/build/ +*.exe +!.paket/paket.bootstrapper.exe +.ionide/ + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint ### macOS ### # General @@ -11,6 +122,7 @@ # Icon must end with two \r Icon + # Thumbnails ._* @@ -30,120 +142,15 @@ Network Trash Folder Temporary Items .apdisk -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - ### VisualStudioCode ### .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json +#!.vscode/tasks.json +#!.vscode/launch.json +*.code-workspace ### VisualStudioCode Patch ### # Ignore all local history of files .history +.ionide -# End of https://www.gitignore.io/api/macos,python,visualstudiocode \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/fsharp,dotnetcore,intellij+all,visualstudiocode,macos \ No newline at end of file diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 90648ac..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -python 3.8.2 diff --git a/Ida.sln b/Ida.sln new file mode 100644 index 0000000..af3bee8 --- /dev/null +++ b/Ida.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Ida", "Ida\Ida.fsproj", "{A0968778-E863-45CB-B79A-A6D148C669A1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A0968778-E863-45CB-B79A-A6D148C669A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Debug|x64.Build.0 = Debug|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Debug|x86.Build.0 = Debug|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Release|Any CPU.Build.0 = Release|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Release|x64.ActiveCfg = Release|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Release|x64.Build.0 = Release|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Release|x86.ActiveCfg = Release|Any CPU + {A0968778-E863-45CB-B79A-A6D148C669A1}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Ida/Console.fs b/Ida/Console.fs new file mode 100644 index 0000000..f8ff1db --- /dev/null +++ b/Ida/Console.fs @@ -0,0 +1,63 @@ +module Ida.Console + +open System +open Ida.Parser +open Spectre.Console + +let printVotes (votes: ParsedVotes) = + let mutable ptsCol = new TableColumn("Points") + ptsCol <- ptsCol.Centered() + + let mutable table = new Table() + table <- table.AddColumn(ptsCol) + table <- table.AddColumn("Country") + table <- table.AddColumn("Artist") + table <- table.AddColumn("Song") + + votes.Votes + |> List.iter (fun vote -> + let countryName = + vote.Entry.Country.PrimaryName + |> Option.defaultValue "" + + table <- table.AddRow(vote.Points.ToString(), countryName, vote.Entry.Artist, vote.Entry.Song)) + + AnsiConsole.Render(table) + AnsiConsole.WriteLine() + + votes.Warnings + |> List.iter (fun warning -> AnsiConsole.MarkupLine($"[red]Warning: {warning}[/]")) + + +// See https://stackoverflow.com/a/27796307 +let readLines () = + let read _ = Console.ReadLine() + + let isValid = + function + | null -> false + | _ -> true + + Seq.initInfinite read + |> Seq.takeWhile isValid + |> Seq.toList + +let rec voterLoop (parser: Parser, firstVoter: bool) = + if not firstVoter then + let rule = new Rule() + AnsiConsole.Render(rule) + + let voter = + AnsiConsole.Ask("Voter Name") + |> parser.Contest.FindVoter + + AnsiConsole.WriteLine() + AnsiConsole.MarkupLine("Votes:") + + let lines = readLines () + let votes = parser.Parse(voter, lines) + + AnsiConsole.WriteLine() + printVotes (votes) + + voterLoop (parser, false) diff --git a/Ida/Contest.fs b/Ida/Contest.fs new file mode 100644 index 0000000..be3b2e0 --- /dev/null +++ b/Ida/Contest.fs @@ -0,0 +1,57 @@ +module Ida.Contest + +open System.IO +open FSharp.Json + +type Country = + { Forum: string + Names: string list } + member this.PrimaryName = List.tryHead this.Names + + member this.Contains(text: string) = + this.Names + |> List.map (fun name -> name.ToLower()) + |> List.exists (text.ToLower().Contains) + +type Entry = + { Country: Country + Artist: string + Song: string } + +type Vote = { Entry: Entry; Points: int } + +type Contest = + { Entries: Entry list + Countries: Country list + Voters: Country list } + + member this.FindVoter(country: string) = + this.Voters + |> List.tryFind (fun voter -> voter.Contains(country)) + + member this.FindEntryByCountryName(text: string) = + this.Entries + |> List.tryFind (fun entry -> entry.Country.Contains(text)) + + member this.FindEntryByCountryForum(text: string) = + this.Entries + |> List.tryFind (fun entry -> + text + .ToLower() + .Contains(entry.Country.Forum.ToLower())) + + member this.FindEntryByArtist(text: string) = + this.Entries + |> List.tryFind (fun entry -> text.ToLower().Contains(entry.Artist.ToLower())) + + member this.FindEntryBySong(text: string) = + this.Entries + |> List.tryFind (fun entry -> text.ToLower().Contains(entry.Song.ToLower())) + +let loadContest (path: string) = + let contents = File.ReadAllText(path) + + let config = + JsonConfig.create (jsonFieldNaming = Json.lowerCamelCase) + + Json.deserializeEx config contents diff --git a/Ida/Ida.fsproj b/Ida/Ida.fsproj new file mode 100644 index 0000000..dcaefbf --- /dev/null +++ b/Ida/Ida.fsproj @@ -0,0 +1,23 @@ + + + + Exe + net5.0 + ida + + + + + + + + + + + + + + + + + diff --git a/Ida/Parser.fs b/Ida/Parser.fs new file mode 100644 index 0000000..d046839 --- /dev/null +++ b/Ida/Parser.fs @@ -0,0 +1,102 @@ +module Ida.Parser + +open System +open System.Text.RegularExpressions +open System.Web +open Ida.Contest +open TextCopy + +type ParsedVotes = + { Votes: Vote list + Warnings: string list } + +type Parser = + { Contest: Contest } + member this.Parse(voter: Country option, lines: string list) = + let votes = + lines + |> List.map (this.unescapeHTML) + |> List.map (this.getVotes) + |> List.choose id + |> List.sortByDescending (fun vote -> vote.Points) + + let warnings = this.getWarnings (voter, votes) + { Votes = votes; Warnings = warnings } + + member this.CopyVotes(voter: Country option, votes: ParsedVotes) = + let getVoteString (entry: Entry) = + let getEntryString = + let vote = + votes.Votes + |> List.tryFind (fun vote -> vote.Entry.Country = entry.Country) + + match vote with + | Some (vote) -> vote.Points.ToString() + | None -> "" + + match voter with + | Some (voter) -> if voter = entry.Country then "X" else getEntryString + | None -> getEntryString + + let votesString = + this.Contest.Entries + |> List.map getVoteString + |> String.concat "\n" + + ClipboardService.SetText(votesString) + + member private this.unescapeHTML(text: string) = HttpUtility.HtmlDecode text + + member private this.getVotes(line: string) = + let entry = + [ this.Contest.FindEntryByCountryForum(line) + this.Contest.FindEntryByCountryName(line) + this.Contest.FindEntryByArtist(line) + this.Contest.FindEntryBySong(line) ] + |> List.choose id + |> List.tryHead + + let points = this.getPoints (line) + + match (entry, points) with + | (Some (entry), Some (points)) -> Some({ Entry = entry; Points = points }) + | _ -> None + + member private this.getPoints(line: string) = + match Int32.TryParse(Regex.Match(line, "\\d+").Value) with + | true, int -> Some(int) + | _ -> None + + member private this.getWarnings(voter: Country option, votes: Vote list) = + let checkForSelfVoting = + let voteCountries = + votes |> List.map (fun vote -> vote.Entry.Country) + + match voter with + | Some (voter) -> + if List.contains voter voteCountries then + let voterName = + voter.PrimaryName |> Option.defaultValue "Voter" + + Some($"{voterName} voted for self") + else + None + | None -> None + + let checkForDuplicateVotes = + if votes.Length <> Seq.length (Seq.distinct (votes)) + then Some("At least one entry received points more than once") + else None + + let checkForPointsTotal = + let pointsTotal = + votes |> List.sumBy (fun vote -> vote.Points) + + if pointsTotal <> 58 + then Some($"Total number of points was not 58: {pointsTotal}") + else None + + [ checkForSelfVoting + checkForDuplicateVotes + checkForPointsTotal ] + |> List.choose id diff --git a/Ida/Program.fs b/Ida/Program.fs new file mode 100644 index 0000000..39be56d --- /dev/null +++ b/Ida/Program.fs @@ -0,0 +1,29 @@ +open Argu +open Ida.Contest +open Ida.Parser +open Ida.Console + +type Arguments = + | [] Contest of path: string + + interface IArgParserTemplate with + member s.Usage = + match s with + | Contest _ -> "JSON file containing contest details" + +let parseArguments (argv: string []) = + let errorHandler = ProcessExiter() + + let argumentParser = + ArgumentParser.Create(programName = "ida", errorHandler = errorHandler) + + let results = argumentParser.Parse(argv) + results.GetResult Contest + +[] +let main argv = + let contestFile = parseArguments argv + let contest = loadContest (contestFile) + let parser = { Contest = contest } + voterLoop (parser, true) + 0 diff --git a/README.md b/README.md index 6bd03f8..966d192 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ # Ida -Ida is a voting assistant to help process voters for online music competitions. It is designed to work with contest files for [Melbourne](https://github.com/Iune/melbourne). +Ida is a voting assistant to help process voters for online music competitions. It is designed to work with contest +files for [Melbourne](https://github.com/Iune/melbourne). ## Installation ``` -python setup.py install +dotnet publish -c Release -r osx-x64 --self-contained false ``` +The resulting build is located at `Ida/bin/Release/net5.0/osx-x64/publish/`. + ## Usage ``` -usage: ida [-h] contest +USAGE: ida [--help] --contest -positional arguments: - contest JSON file containing contest details +OPTIONS: -optional arguments: - -h, --help show this help message and exit + --contest JSON file containing contest details + --help display this list of options. ``` diff --git a/ida/__init__.py b/ida/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ida/ida.py b/ida/ida.py deleted file mode 100644 index dd56115..0000000 --- a/ida/ida.py +++ /dev/null @@ -1,193 +0,0 @@ -from dataclasses import dataclass -from typing import List -from html import unescape -from string import digits -from collections import Counter -from colorama import init -from termcolor import colored - -import re -import json -import argparse -import pyperclip - -init() -digits = frozenset(digits) - -@dataclass(frozen=True) -class Country: - forum: str - iso: str - names: List[str] - - def primary_name(self): - return self.names[0] - - def contains_name(self, line): - return any(name.lower() in line.lower() for name in self.names) - - -@dataclass(frozen=True) -class Entry: - country: Country - artist: str - song: str - - def flag(self): - return "World/{}.png".format(self.country.iso) - - -@dataclass(frozen=True) -class Contest: - countries: List[Country] - entries: List[Entry] - voters: List[Country] - - def find_voter_by_country_name(self, voter_name): - return next((voter for voter in self.voters if voter.contains_name(voter_name)), None) - - def find_entry_by_artist(self, line): - return next((entry for entry in self.entries if entry.artist.lower() in line.lower()), None) - - def find_entry_by_song(self, line): - return next((entry for entry in self.entries if entry.song.lower() in line.lower()), None) - - def find_entry_by_country_name(self, line): - return next((entry for entry in self.entries if entry.country.contains_name(line)), None) - - def find_entry_by_country_forum(self, line): - return next((entry for entry in self.entries if entry.country.forum.lower() in line.lower()), None) - - def copy_votes_to_clipboard(self, voter, votes): - votes_lst = [""] * len(self.voters) - - if voter: - countries = [entry.country for entry in self.entries] - voter_idx = countries.index(voter) - votes_lst[voter_idx] = "X" - - for vote in votes: - entry_idx = self.entries.index(vote.entry) - votes_lst[entry_idx] = str(vote.points) - - votes_str = "\n".join(votes_lst) - pyperclip.copy(votes_str) - - -@dataclass -class Vote: - entry: Entry - points: int - - @staticmethod - def _print_votes(votes): - print("Found the following votes:") - for vote in sorted(votes, key=lambda x: [-x.points, x.entry.country.primary_name()]): - print("{:2} | {}: {} - {}".format(vote.points, - vote.entry.country.primary_name(), vote.entry.artist, vote.entry.song)) - - -def load_contest(file_name): - def load_json(): - with open(file_name, "r") as f: - return json.load(f) - - def parse_country(country): - return Country( - forum=country["forum"], - iso=country["iso"], - names=country["names"] - ) - - def parse_entry(entry): - return Entry( - country=parse_country(entry["country"]), - artist=entry["artist"], - song=entry["song"] - ) - - contest = load_json() - countries = [parse_country(country) for country in contest["countries"]] - voters = [parse_country(country) for country in contest["voters"]] - entries = [parse_entry(entry) for entry in contest["entries"]] - return Contest(countries=countries, voters=voters, entries=entries) - - -class Parser: - def __init__(self, contest): - self.contest = contest - - def parse(self, voter, lines): - votes = [self._get_votes(unescape(line)) for line in lines] - votes = [vote for vote in votes if vote] - - if any(voter == vote.entry.country for vote in votes): - print(colored("{} voted for themselves".format( - voter.primary_name()), "red")) - - vote_recipients = [vote.entry.country.primary_name() for vote in votes] - duplicate_recipients = [country for country, count in Counter( - vote_recipients).items() if count > 1] - for country in duplicate_recipients: - print(colored("{} received points more than once".format(country), "red")) - - points_total = sum([vote.points for vote in votes]) - if points_total != 58: - print(colored("Total number of points was not 58: {}".format(points_total), "red")) - - print() - Vote._print_votes(votes) - self.contest.copy_votes_to_clipboard(voter, votes) - - def _get_votes(self, line): - entry = self.contest.find_entry_by_country_forum(line) - if not entry: - entry = self.contest.find_entry_by_country_name(line) - if not entry: - entry = self.contest.find_entry_by_artist(line) - if not entry: - entry = self.contest.find_entry_by_song(line) - if not entry: - return None - - points = self._get_points(line) - if points: - return Vote(entry=entry, points=points) - else: - return None - - def _get_points(self, line): - try: - return int(''.join(c for c in line if c in digits)) - except ValueError: - return None - -def parse_args(): - parser = argparse.ArgumentParser() - parser.add_argument("contest", help="JSON file containing contest details") - return parser.parse_args() - - -def main(): - args = parse_args() - contest = load_contest(args.contest) - parser = Parser(contest) - - while True: - country = input("Country Name:\n> ") - voter = contest.find_voter_by_country_name(country) - - lines = [] - while True: - try: - lines.append(input()) - except EOFError: - break - - print() - parser.parse(voter, lines) - print() - - -if __name__ == "__main__": - main() diff --git a/setup.py b/setup.py deleted file mode 100644 index 44231a2..0000000 --- a/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="ida", - version="0.3.0", - author="Aditya Duri", - author_email="aditya.duri@gmail.com", - description="Process voters for music competitions.", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/Iune/ida", - packages=setuptools.find_packages(), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - ], - python_requires='>=3.6', - install_requires=["pyperclip", "colorama", "termcolor"], - entry_points={ - "console_scripts": [ - "ida=ida.ida:main" - ] - } -) \ No newline at end of file