├── .config └── dotnet-tools.json ├── .deployment ├── .dockerignore ├── .editorconfig ├── .gitignore ├── Dockerfile ├── Procfile ├── README.md ├── azure-pipelines-1.yml ├── azure-pipelines.yml ├── build.fsx ├── build.sh ├── deploy.fsx ├── fssnip-website.sln ├── fssnip.ps1 ├── global.json ├── localfs.Dockerfile ├── package.json ├── paket.dependencies ├── paket.lock ├── src └── FsSnip.Website │ ├── FsSnip.Website.fsproj │ ├── api.fs │ ├── app.fs │ ├── common │ ├── data.fs │ ├── filters.fs │ ├── parser.fs │ ├── rssfeed.fs │ ├── storage │ │ ├── azure.fs │ │ └── local.fs │ └── utils.fs │ ├── pages │ ├── author.fs │ ├── error.fs │ ├── home.fs │ ├── insert.fs │ ├── like.fs │ ├── recaptcha.fs │ ├── rss.fs │ ├── search.fs │ ├── snippet.fs │ ├── tag.fs │ ├── test.fs │ └── update.fs │ ├── paket.references │ └── samples │ └── index.json ├── templates ├── author.html ├── authors.html ├── error.html ├── home.html ├── insert.html ├── item.html ├── list.html ├── page.html ├── search.html ├── snippet.html ├── tag.html ├── tags.html ├── test.html └── update.html ├── upload-blobs.fsx └── web ├── content ├── app │ ├── codecheck.js │ ├── copycontent.js │ ├── likes.js │ ├── search.js │ ├── snippetvalidation.js │ └── tips.js ├── snippets.css └── style.css ├── favicon.ico ├── fonts ├── glyphicons-halflings-regular.eot ├── glyphicons-halflings-regular.svg ├── glyphicons-halflings-regular.ttf ├── glyphicons-halflings-regular.woff └── glyphicons-halflings-regular.woff2 ├── img └── fswebsnippets.png ├── lib ├── bootstrap.min.css ├── bootstrap.min.js ├── chosen-sprite.png ├── chosen-sprite@2x.png ├── chosen.css ├── chosen.jquery.min.js ├── jquery-2.2.0.min.js └── jquery.validate.min.js └── robots.txt /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "paket": { 6 | "version": "5.257.0", 7 | "commands": [ 8 | "paket" 9 | ] 10 | }, 11 | "fake-cli": { 12 | "version": "5.20.4", 13 | "commands": [ 14 | "fake" 15 | ] 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /.deployment: -------------------------------------------------------------------------------- 1 | [config] 2 | command = build.cmd -t deploy -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.suo 2 | **/*.bak 3 | **/data/ 4 | **/packages/ 5 | **/obj/ 6 | **/bin/ 7 | **/.paket/paket.exe 8 | **/*.svclog 9 | **/temp 10 | **/web/content/dist 11 | **/.fake 12 | **/node_modules/ 13 | **/paket-files/ 14 | **/artifacts/ 15 | 16 | #!! ERROR: is undefined. Use list command to see defined gitignore types !!# 17 | 18 | ### Node ### 19 | # Logs 20 | **/logs 21 | **/*.log 22 | **/npm-debug.log* 23 | 24 | # Runtime data 25 | **/pids 26 | **/*.pid 27 | **/*.seed 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | **/lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | **/coverage 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | **/.grunt 37 | 38 | # node-waf configuration 39 | **/.lock-wscript 40 | 41 | # Compiled binary addons (http://nodejs.org/api/addons.html) 42 | **/build/Release 43 | 44 | # Dependency directory 45 | **/node_modules 46 | 47 | # Optional npm cache directory 48 | **/.npm 49 | 50 | # Optional REPL history 51 | **/.node_repl_history 52 | 53 | # IDE Junk 54 | **/.vs/ 55 | **/.ionide/ 56 | **/.idea/ 57 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.fs] 2 | indent_style = space 3 | indent_size = 2 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.suo 2 | *.bak 3 | data/ 4 | packages/ 5 | obj/ 6 | bin/ 7 | .paket/paket.exe 8 | *.svclog 9 | temp 10 | web/content/dist 11 | .fake/ 12 | .vs/ 13 | node_modules/ 14 | paket-files/ 15 | artifacts/ 16 | 17 | #!! ERROR: is undefined. Use list command to see defined gitignore types !!# 18 | 19 | ### Node ### 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | 30 | # Directory for instrumented libs generated by jscoverage/JSCover 31 | lib-cov 32 | 33 | # Coverage directory used by tools like istanbul 34 | coverage 35 | 36 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (http://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directory 46 | node_modules 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | .ionide/ 55 | /wwwroot 56 | /.farmer 57 | /.paket 58 | /deployed_storage_key.txt 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/core/sdk:3.1.201-buster as base 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y nodejs npm && \ 5 | rm -rf /var/lib/apt/lists/* 6 | 7 | WORKDIR /src 8 | COPY . . 9 | 10 | RUN ./build.sh -t deploy 11 | 12 | FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim 13 | 14 | WORKDIR /wwwroot 15 | COPY --from=base /wwwroot/deploy_0 . 16 | RUN mkdir -p /wwwroot/data 17 | 18 | ENV CUSTOMCONNSTR_FSSNIP_STORAGE= 19 | ENV RECAPTCHA_SECRET= 20 | ENV DISABLE_RECAPTCHA=false 21 | ENV FSSNIP_HOME_DIR=/wwwroot 22 | ENV FSSNIP_DATA_DIR=/wwwroot/data 23 | ENV LOG_LEVEL=Info 24 | ENV IP_ADDRESS=0.0.0.0 25 | ENV PORT=5000 26 | EXPOSE 5000 27 | 28 | ENTRYPOINT ["./bin/fssnip"] 29 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: fsharpi-heroku --define:START_SERVER app.heroku.fsx 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | F# Snippets - Web site 2 | ====================== 3 | 4 | This is a new version of the [www.fssnip.net](http://www.fssnip.net) web site. The project 5 | implements most of the old functionality, but there is still a lot of room for improvements 6 | and it is also a great way to get started with F# and Suave! 7 | 8 | * There is a [list of issues on GitHub](https://github.com/fssnippets/fssnip-website/issues) with 9 | various work items. Many of them not too hard and so this might be a fun way to contribute to 10 | your first open-source F# project! 11 | 12 | * To discuss the Project, register at [Slack](http://foundation.fsharp.org/join) and visit the `#general` channel, for more infos see the [Slack Guide](http://fsharp.org/guides/slack/). 13 | * Feel free to ping me on Twitter at [@tomaspetricek](https://twitter.com/tomaspetricek). 14 | 15 | Running web site locally 16 | ------------------------ 17 | 18 | There is one manual step you need to do before you can run the web site locally, which is to 19 | download sample data. To do this, download [the data dump](https://github.com/fssnippets/fssnip-data/archive/master.zip) 20 | from the `fssnip-data` project and extract the contents into `data` (so that you have `data/index.json`) in your root. 21 | 22 | Once you're done with this, you can run `build.sh` (on Mac/Linux) or `build.cmd` (on Windows) to 23 | run the web site. There is also a Visual Studio solution which can be started with F5, 24 | but the build scripts are nicer because they automatically watch for changes. 25 | 26 | Updated dev scripts 27 | ------------------- 28 | 29 | There is now a PowerShell script `fssnip.ps1` which you can call with a parameter that is one of 4 commands: 30 | - **build** : Builds the project and copies it into ./wwwroot directory 31 | - **run** : Runs the project locally 32 | - **deploy**: Deploys the project into Azure 33 | - **upload**: Downloads the blobs from [the data dump](https://github.com/fssnippets/fssnip-data/archive/master.zip) and uploads them to Azure blob storage 34 | 35 | For some of these commands to work, you will need to edit the script to provide 2 environment variables (Recaptcha secret and the target blob service connection string). 36 | 37 | Project architecture & structure 38 | -------------------------------- 39 | 40 | In the current (development) version, the project uses file system as a data storage. In the 41 | final version, we'll store the snippets in Azure blob storage (see the [issue for adding 42 | this](https://github.com/tpetricek/FsSnip.Website/issues/6)). 43 | 44 | The web page is mostly read-only. There are about 2 new snippets per day, so insertion can be 45 | more expensive and not particularly sophisticated. Also, the metadata about all the snippets 46 | is quite small (about 1MB JSON) and so we can keep all metadata in memory for browsing. When 47 | a snippet is inserted, we update the in-memory metadata and save it to a JSON file (in a blob). 48 | 49 | So, if you download the `data.zip` file (above), you get the following: 50 | 51 | - `data/index.json` - this is the JSON with metadata about all snippets. This is loaded when the 52 | web site starts (and it is updated & saved when a new snippet is inserted) 53 | - `data/formatted//` is a file that contains formatted HTML for a snippet with 54 | a specified ID; we also support multiple versions of snippets. 55 | - `data/source//` is a file with the original source code for a snippet 56 | 57 | Other most important files and folders in the project are: 58 | 59 | - `app.fsx` defines the routing for web requests and puts everything together 60 | - `code/pages/*.fs` are files that handle specific things for individual pages 61 | - `code/common/*.fs` are common utilities, data access code etc. 62 | - `templates/*.html` are DotLiquid templates for various pages 63 | - `web/*` is folder with static files (CSS, JavaScript, images, etc.) 64 | -------------------------------------------------------------------------------- /azure-pipelines-1.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | - group: fssnip-variable-group 3 | 4 | pool: 5 | vmImage: ubuntu-latest 6 | 7 | resources: 8 | pipelines: 9 | - pipeline: fssnip 10 | source: FsSnip Build And Deploy 11 | trigger: true 12 | 13 | steps: 14 | - task: UseDotNet@2 15 | inputs: 16 | packageType: 'sdk' 17 | version: '7.0.x' 18 | 19 | # This restrictive global.json is needed for the build commands above, but it would prevent the .fsx in the next task from running. 20 | - script: rm global.json 21 | 22 | # The FSSNIP_STORAGE_CONNECTION_STRING variable should always contain a valid connection string. 23 | # It is set in the FsSnip Build and Deploy pipeline and sourced from the storage account deployed by that pipeline. 24 | - task: AzureCLI@2 25 | inputs: 26 | azureSubscription: $(AZURE_SUBSCRIPTION) 27 | scriptType: 'pscore' 28 | scriptLocation: 'inlineScript' 29 | inlineScript: | 30 | $Env:fssnip_data_url="$(FSSNIP_DATA_URL)" 31 | $Env:fssnip_storage_key="$(FSSNIP_STORAGE_CONNECTION_STRING)" 32 | dotnet fsi upload-blobs.fsx 33 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: cancelPriorDeployments 3 | displayName: Cancel prior deployments 4 | type: boolean 5 | default: true 6 | 7 | variables: 8 | - group: fssnip-variable-group 9 | - name: cancelPriorDeployments 10 | value: '${{ parameters.cancelPriorDeployments }}' 11 | - name: devOpsApiVersion 12 | value: 6.0 13 | 14 | trigger: 15 | - master 16 | 17 | stages: 18 | - stage: CancelPriorDeploymentsStage 19 | displayName: Cancel prior deployments 20 | condition: eq(variables.cancelPriorDeployments, 'true') 21 | jobs: 22 | - job: CancelPriorDeploymentsJob 23 | displayName: List builds, cancel prior in progress 24 | pool: 25 | vmImage: 'ubuntu-latest' 26 | steps: 27 | - checkout: none 28 | - task: PowerShell@2 29 | displayName: Powershell AzDO Invoke-RestMethod 30 | env: 31 | SYSTEM_ACCESSTOKEN: $(System.AccessToken) 32 | inputs: 33 | targetType: inline 34 | script: | 35 | $header = @{ Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" } 36 | $buildsUrl = "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/build/builds?api-version=$(devOpsApiVersion)" 37 | Write-Host "GET $buildsUrl" 38 | $builds = Invoke-RestMethod -Uri $buildsUrl -Method Get -Header $header 39 | $buildsToStop = $builds.value.Where({ ($_.status -eq 'inProgress') -and ($_.definition.name -eq "$(Build.DefinitionName)") -and ($_.id -lt $(Build.BuildId)) }) 40 | ForEach($build in $buildsToStop) 41 | { 42 | $urlToCancel = "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/build/builds/$($build.id)?api-version=$(devOpsApiVersion)" 43 | $body = @{ status = "cancelling" } | ConvertTo-Json 44 | Write-Host "PATCH $urlToCancel" 45 | Invoke-RestMethod -Uri $urlToCancel -Method Patch -Header $header -ContentType application/json -Body $body 46 | } 47 | 48 | - stage: BuildAndDeploy 49 | displayName: Build and deploy 50 | jobs: 51 | - job: BuildAndDeploy 52 | displayName: Build And Deploy 53 | pool: 54 | vmImage: 'ubuntu-latest' 55 | steps: 56 | - task: UseDotNet@2 57 | inputs: 58 | packageType: 'sdk' 59 | useGlobalJson: true 60 | - script: dotnet tool restore 61 | - script: dotnet paket restore 62 | - script: dotnet fake run build.fsx %* 63 | 64 | - task: UseDotNet@2 65 | inputs: 66 | packageType: 'sdk' 67 | version: '7.0.x' 68 | # This restrictive global.json is needed for the build commands above, but it would prevent the .fsx in the next task from running. 69 | - script: rm global.json 70 | 71 | - task: AzureCLI@2 72 | inputs: 73 | azureSubscription: $(AZURE_SUBSCRIPTION) 74 | scriptType: 'pscore' 75 | scriptLocation: 'inlineScript' 76 | # The deploy.fsx writes it's output to a text file, contents of that is then read into $key 77 | # and the the last line command writes that value into the FSSNIP_STORAGE_CONNECTION_STRING variable 78 | # in the fssnip-variable-group group. This is a hackaround to pass output of this pipeline to the next. 79 | # The '> $null' disables echoing of the last line command output. 80 | inlineScript: | 81 | dotnet fsi deploy.fsx 82 | $key=Get-Content 'deployed_storage_key.txt' -Raw 83 | az pipelines variable-group variable update --group-id $(group_id) --name FSSNIP_STORAGE_CONNECTION_STRING --value $key --org https://dev.azure.com/compositional-it --project 'FSharp Snippets' > $null 84 | env: 85 | RECAPTCHA_SECRET: $(RECAPTCHA_SECRET) 86 | AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) 87 | 88 | -------------------------------------------------------------------------------- /build.fsx: -------------------------------------------------------------------------------- 1 | #r "paket: groupref build //" 2 | #load "./.fake/build.fsx/intellisense.fsx" 3 | #if !NETCOREAPP 4 | #r "System.IO.Compression.FileSystem.dll" 5 | #endif 6 | 7 | open Fake.Core 8 | open Fake.Core.TargetOperators 9 | open Fake.IO 10 | open Fake.IO.FileSystemOperators 11 | open Fake.DotNet 12 | open Fake.JavaScript 13 | open System 14 | open System.IO 15 | 16 | let project = "src/FsSnip.Website" 17 | let publishDirectory = "artifacts" 18 | 19 | // FAKE 5 does not apply cli args to environment before module initialization, delay 20 | // override syntax: dotnet fake run build.fsx -e foo=bar -e foo=bar -t target 21 | let config () = DotNet.BuildConfiguration.fromEnvironVarOrDefault "configuration" DotNet.BuildConfiguration.Release 22 | let runtime () = Environment.environVarOrNone "runtime" 23 | 24 | System.Environment.CurrentDirectory <- __SOURCE_DIRECTORY__ 25 | 26 | //// ------------------------------------------------------------------------------------- 27 | //// Minifying JS for better performance 28 | //// This is using built in NPMHelper and other things are getting done by node js 29 | //// ------------------------------------------------------------------------------------- 30 | 31 | Target.create "minify" (fun _ -> 32 | Trace.trace "Node js web compilation thing" 33 | 34 | // Use nuget tools if windows, require already installed otherwise 35 | //let npmPath = Path.GetFullPath "packages/jstools/Npm.js/tools" 36 | //let nodePath = Path.GetFullPath "packages/jstools/Node.js" 37 | 38 | //if Environment.isWindows then 39 | // [ npmPath ; nodePath ; Environment.environVar "PATH" ] 40 | // |> String.concat ";" 41 | // |> Environment.setEnvironVar "PATH" 42 | 43 | //let getNpmFilePath (p : Npm.NpmParams) = 44 | // if Environment.isWindows then Path.Combine(npmPath, "npm.cmd") else p.NpmFilePath 45 | 46 | Npm.install (fun p -> p) //{ p with NpmFilePath = getNpmFilePath p }) 47 | Npm.exec "run-script build" (fun p -> p) //{ p with NpmFilePath = getNpmFilePath p }) 48 | ) 49 | 50 | Target.create "clean" (fun _ -> 51 | Shell.cleanDirs [publishDirectory] 52 | ) 53 | 54 | let dataDumpLocation = System.Uri "https://github.com/fssnippets/fssnip-data/archive/master.zip" 55 | 56 | Target.create "download-data-dump" (fun _ -> 57 | Directory.delete "data" 58 | let tmpfile = Path.ChangeExtension(Path.GetTempFileName(), ".zip") 59 | use client = new System.Net.WebClient() 60 | client.DownloadFile(dataDumpLocation, tmpfile) 61 | System.IO.Compression.ZipFile.ExtractToDirectory(tmpfile, ".") 62 | Directory.Move("fssnip-data-master", "data") 63 | ) 64 | 65 | Target.create "run" (fun _ -> 66 | let environment = Map.ofList [("LOG_LEVEL", "Info"); ("DISABLE_RECAPTCHA", "true")] 67 | DotNet.exec (fun p -> 68 | { p with WorkingDirectory = project ; Environment = environment }) "run" (sprintf "-c %O" (config())) 69 | |> ignore 70 | ) 71 | 72 | Target.create "publish" (fun _ -> 73 | DotNet.publish (fun p -> 74 | { p with 75 | Configuration = config () 76 | Runtime = runtime () 77 | OutputPath = Some publishDirectory 78 | }) project 79 | ) 80 | 81 | let newName prefix f = 82 | Seq.initInfinite (sprintf "%s_%d" prefix) |> Seq.skipWhile (f >> not) |> Seq.head 83 | 84 | Target.create "deploy" (fun _ -> 85 | let wwwroot = "wwwroot" 86 | Shell.mkdir wwwroot 87 | Shell.cleanDir wwwroot 88 | Shell.mkdir (wwwroot "templates") 89 | Shell.mkdir (wwwroot "web") 90 | printfn "copying binaries into wwwroot" 91 | Shell.copyRecursive publishDirectory wwwroot false |> ignore 92 | printfn "copying templates into wwwroot/templates" 93 | Shell.copyRecursive "templates" (wwwroot "templates") false |> ignore 94 | printfn "copying web into wwwroot/web" 95 | Shell.copyRecursive "web" (wwwroot "web") false |> ignore 96 | ) 97 | 98 | Target.create "root" ignore 99 | 100 | "root" 101 | ==> "minify" 102 | =?> ("download-data-dump", not (File.Exists "data/index.json")) 103 | ==> "run" 104 | 105 | "clean" 106 | ==> "minify" 107 | ==> "publish" 108 | ==> "deploy" 109 | 110 | Target.runOrDefaultWithArguments "deploy" -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export PAKET_SKIP_RESTORE_TARGETS=true 4 | 5 | dotnet tool restore 6 | dotnet paket restore 7 | dotnet fake run build.fsx "$@" -------------------------------------------------------------------------------- /deploy.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: Farmer" 2 | 3 | open System 4 | open Farmer 5 | open Farmer.Arm 6 | open Farmer.Builders 7 | 8 | let appName = "fs-snip" 9 | 10 | let trimmedAppName = appName.Replace("-", "") 11 | let storageAccountName = $"{trimmedAppName}storage" 12 | let artifactDir = "wwwroot" 13 | let blobContainerName = "data" 14 | let recaptchaSecret = Environment.GetEnvironmentVariable "RECAPTCHA_SECRET" 15 | 16 | let logAnalytics = 17 | logAnalytics { 18 | name $"{appName}-workspace" 19 | } 20 | 21 | let insights = 22 | appInsights { 23 | name $"{appName}-ai" 24 | log_analytics_workspace logAnalytics 25 | } 26 | 27 | let storage = storageAccount { 28 | name storageAccountName 29 | sku Storage.Sku.Standard_LRS 30 | add_private_container blobContainerName 31 | } 32 | 33 | let app = webApp { 34 | name appName 35 | runtime_stack Runtime.DotNetCore31 36 | link_to_app_insights insights 37 | sku WebApp.Sku.S1 38 | operating_system OS.Linux 39 | connection_string ("FSSNIP_STORAGE", storage.Key) 40 | settings [ 41 | "FSSNIP_HOME_DIR", "." 42 | "RECAPTCHA_SECRET", recaptchaSecret 43 | ] 44 | zip_deploy artifactDir 45 | https_only 46 | always_on 47 | } 48 | 49 | let deployment = arm { 50 | location Location.WestEurope 51 | add_resources [ 52 | logAnalytics 53 | insights 54 | app 55 | storage 56 | ] 57 | output "storage-key" storage.Key 58 | } 59 | 60 | deployment 61 | |> Deploy.execute appName Deploy.NoParameters 62 | |> Map.find "storage-key" 63 | |> fun key -> IO.File.WriteAllText("deployed_storage_key.txt", key) -------------------------------------------------------------------------------- /fssnip-website.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.7.34031.279 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FsSnip.Website", "src\FsSnip.Website\FsSnip.Website.fsproj", "{F13DAD45-C70D-491F-B203-B8B4F4241BE8}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{F1A76CF6-25D7-4EDA-B86B-F5533DE281C8}" 8 | ProjectSection(SolutionItems) = preProject 9 | .config\dotnet-tools.json = .config\dotnet-tools.json 10 | EndProjectSection 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".paket", ".paket", "{F6586E5F-1DFE-4C42-AF48-B080E0E4D757}" 13 | ProjectSection(SolutionItems) = preProject 14 | .paket\Paket.Restore.targets = .paket\Paket.Restore.targets 15 | EndProjectSection 16 | EndProject 17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41671E92-DB62-4132-A393-795D99F1F1A1}" 18 | ProjectSection(SolutionItems) = preProject 19 | .deployment = .deployment 20 | .dockerignore = .dockerignore 21 | .editorconfig = .editorconfig 22 | .gitignore = .gitignore 23 | azure-pipelines-1.yml = azure-pipelines-1.yml 24 | azure-pipelines.yml = azure-pipelines.yml 25 | build.fsx = build.fsx 26 | build.sh = build.sh 27 | deploy.fsx = deploy.fsx 28 | Dockerfile = Dockerfile 29 | fssnip-website.sln = fssnip-website.sln 30 | fssnip.ps1 = fssnip.ps1 31 | global.json = global.json 32 | localfs.Dockerfile = localfs.Dockerfile 33 | package.json = package.json 34 | paket.dependencies = paket.dependencies 35 | paket.lock = paket.lock 36 | Procfile = Procfile 37 | README.md = README.md 38 | upload-blobs.fsx = upload-blobs.fsx 39 | EndProjectSection 40 | EndProject 41 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9831BC01-ABF4-4FC8-A7BF-58C1F0160357}" 42 | EndProject 43 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "templates", "templates", "{FDA824F0-ADDE-4681-927E-EBDB1D11BAFF}" 44 | ProjectSection(SolutionItems) = preProject 45 | templates\author.html = templates\author.html 46 | templates\authors.html = templates\authors.html 47 | templates\error.html = templates\error.html 48 | templates\home.html = templates\home.html 49 | templates\insert.html = templates\insert.html 50 | templates\item.html = templates\item.html 51 | templates\list.html = templates\list.html 52 | templates\page.html = templates\page.html 53 | templates\search.html = templates\search.html 54 | templates\snippet.html = templates\snippet.html 55 | templates\tag.html = templates\tag.html 56 | templates\tags.html = templates\tags.html 57 | templates\update.html = templates\update.html 58 | EndProjectSection 59 | EndProject 60 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "web", "web", "{04DC9280-9D4D-419A-859D-A86F57515EAB}" 61 | ProjectSection(SolutionItems) = preProject 62 | web\favicon.ico = web\favicon.ico 63 | web\robots.txt = web\robots.txt 64 | EndProjectSection 65 | EndProject 66 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "content", "content", "{2FAEB068-380C-4E3A-AC8A-44A0C081B7E1}" 67 | ProjectSection(SolutionItems) = preProject 68 | web\content\snippets.css = web\content\snippets.css 69 | web\content\style.css = web\content\style.css 70 | EndProjectSection 71 | EndProject 72 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{B73F3306-C850-4981-BD48-845EAFD148FB}" 73 | ProjectSection(SolutionItems) = preProject 74 | web\content\app\codecheck.js = web\content\app\codecheck.js 75 | web\content\app\copycontent.js = web\content\app\copycontent.js 76 | web\content\app\likes.js = web\content\app\likes.js 77 | web\content\app\search.js = web\content\app\search.js 78 | web\content\app\snippetvalidation.js = web\content\app\snippetvalidation.js 79 | web\content\app\tips.js = web\content\app\tips.js 80 | EndProjectSection 81 | EndProject 82 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dist", "dist", "{F5607499-120B-4DED-9CBD-BF5B7ED2C800}" 83 | ProjectSection(SolutionItems) = preProject 84 | web\content\dist\codecheck.min.js = web\content\dist\codecheck.min.js 85 | web\content\dist\copycontent.min.js = web\content\dist\copycontent.min.js 86 | web\content\dist\likes.min.js = web\content\dist\likes.min.js 87 | web\content\dist\search.min.js = web\content\dist\search.min.js 88 | web\content\dist\snippetvalidation.min.js = web\content\dist\snippetvalidation.min.js 89 | web\content\dist\tips.min.js = web\content\dist\tips.min.js 90 | EndProjectSection 91 | EndProject 92 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "fonts", "fonts", "{B2B3C576-DD97-49D7-B49A-ABFA11E77DA5}" 93 | ProjectSection(SolutionItems) = preProject 94 | web\fonts\glyphicons-halflings-regular.eot = web\fonts\glyphicons-halflings-regular.eot 95 | web\fonts\glyphicons-halflings-regular.svg = web\fonts\glyphicons-halflings-regular.svg 96 | web\fonts\glyphicons-halflings-regular.ttf = web\fonts\glyphicons-halflings-regular.ttf 97 | web\fonts\glyphicons-halflings-regular.woff = web\fonts\glyphicons-halflings-regular.woff 98 | web\fonts\glyphicons-halflings-regular.woff2 = web\fonts\glyphicons-halflings-regular.woff2 99 | EndProjectSection 100 | EndProject 101 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{1DD555B5-CFB7-47C9-9997-F3E9A3A45527}" 102 | ProjectSection(SolutionItems) = preProject 103 | web\img\fswebsnippets.png = web\img\fswebsnippets.png 104 | EndProjectSection 105 | EndProject 106 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{721B3F4D-B6E0-4398-917E-769AAADD81D0}" 107 | ProjectSection(SolutionItems) = preProject 108 | web\lib\bootstrap.min.css = web\lib\bootstrap.min.css 109 | web\lib\bootstrap.min.js = web\lib\bootstrap.min.js 110 | web\lib\chosen-sprite.png = web\lib\chosen-sprite.png 111 | web\lib\chosen-sprite@2x.png = web\lib\chosen-sprite@2x.png 112 | web\lib\chosen.css = web\lib\chosen.css 113 | web\lib\chosen.jquery.min.js = web\lib\chosen.jquery.min.js 114 | web\lib\jquery-2.2.0.min.js = web\lib\jquery-2.2.0.min.js 115 | web\lib\jquery.validate.min.js = web\lib\jquery.validate.min.js 116 | EndProjectSection 117 | EndProject 118 | Global 119 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 120 | Debug|Any CPU = Debug|Any CPU 121 | Release|Any CPU = Release|Any CPU 122 | EndGlobalSection 123 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 124 | {F13DAD45-C70D-491F-B203-B8B4F4241BE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 125 | {F13DAD45-C70D-491F-B203-B8B4F4241BE8}.Debug|Any CPU.Build.0 = Debug|Any CPU 126 | {F13DAD45-C70D-491F-B203-B8B4F4241BE8}.Release|Any CPU.ActiveCfg = Release|Any CPU 127 | {F13DAD45-C70D-491F-B203-B8B4F4241BE8}.Release|Any CPU.Build.0 = Release|Any CPU 128 | EndGlobalSection 129 | GlobalSection(SolutionProperties) = preSolution 130 | HideSolutionNode = FALSE 131 | EndGlobalSection 132 | GlobalSection(NestedProjects) = preSolution 133 | {F13DAD45-C70D-491F-B203-B8B4F4241BE8} = {9831BC01-ABF4-4FC8-A7BF-58C1F0160357} 134 | {2FAEB068-380C-4E3A-AC8A-44A0C081B7E1} = {04DC9280-9D4D-419A-859D-A86F57515EAB} 135 | {B73F3306-C850-4981-BD48-845EAFD148FB} = {2FAEB068-380C-4E3A-AC8A-44A0C081B7E1} 136 | {F5607499-120B-4DED-9CBD-BF5B7ED2C800} = {2FAEB068-380C-4E3A-AC8A-44A0C081B7E1} 137 | {B2B3C576-DD97-49D7-B49A-ABFA11E77DA5} = {04DC9280-9D4D-419A-859D-A86F57515EAB} 138 | {1DD555B5-CFB7-47C9-9997-F3E9A3A45527} = {04DC9280-9D4D-419A-859D-A86F57515EAB} 139 | {721B3F4D-B6E0-4398-917E-769AAADD81D0} = {04DC9280-9D4D-419A-859D-A86F57515EAB} 140 | EndGlobalSection 141 | GlobalSection(ExtensibilityGlobals) = postSolution 142 | SolutionGuid = {16FB4BD3-5E24-43C2-BE8F-93BED807CF23} 143 | EndGlobalSection 144 | EndGlobal 145 | -------------------------------------------------------------------------------- /fssnip.ps1: -------------------------------------------------------------------------------- 1 | param($command='run') 2 | 3 | Write-Output "Executing command: $command" 4 | 5 | $env:PAKET_SKIP_RESTORE_TARGETS="true" 6 | $env:IP_ADDRESS="0.0.0.0" 7 | $env:FSSNIP_HOME_DIR="." 8 | $env:RECAPTCHA_SECRET= 9 | $env:fssnip_storage_key= 10 | $env:fssnip_data_url="https://github.com/fssnippets/fssnip-data/archive/master.zip" 11 | 12 | function Restore-GlobalJson { if (Test-Path -Path './notglobal.json') { Rename-Item './notglobal.json' -NewName './global.json' } } 13 | function Hide-GlobalJson { if (Test-Path -Path './global.json') { Rename-Item './global.json' -NewName './notglobal.json' } } 14 | 15 | function Build-App { 16 | # in case another script left the global.json renamed 17 | Restore-GlobalJson 18 | dotnet tool restore 19 | dotnet paket restore 20 | dotnet fake run build.fsx 21 | } 22 | 23 | function Start-App { 24 | # in case another script left the global.json renamed 25 | Restore-GlobalJson 26 | dotnet wwwroot/fssnip.dll 27 | } 28 | 29 | function Deploy-App { 30 | Hide-GlobalJson 31 | dotnet fsi deploy.fsx 32 | Restore-GlobalJson 33 | } 34 | 35 | function Deploy-Data { 36 | Hide-GlobalJson 37 | dotnet fsi upload-blobs.fsx 38 | Restore-GlobalJson 39 | } 40 | 41 | if ($command -eq 'run') { Start-App } 42 | if ($command -eq 'build') { Build-App } 43 | if ($command -eq 'deploy') { Deploy-App } 44 | if ($command -eq 'upload') { Deploy-Data } -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "3.1.100", 4 | "rollForward": "minor" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /localfs.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/core/sdk:3.1.201-buster as base 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y nodejs npm && \ 5 | rm -rf /var/lib/apt/lists/* 6 | 7 | WORKDIR /src 8 | COPY . . 9 | 10 | RUN ./build.sh -t download-data-dump && ./build.sh -t deploy 11 | 12 | FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim 13 | 14 | WORKDIR /wwwroot 15 | COPY --from=base /wwwroot/deploy_0 . 16 | COPY --from=base /src/data data/ 17 | 18 | ENV FSSNIP_HOME_DIR=/wwwroot 19 | ENV FSSNIP_DATA_DIR=/wwwroot/data 20 | ENV LOG_LEVEL=Info 21 | ENV DISABLE_RECAPTCHA=true 22 | ENV IP_ADDRESS=0.0.0.0 23 | ENV PORT=5000 24 | EXPOSE 5000 25 | 26 | ENTRYPOINT ["./bin/fssnip"] 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fssnip", 3 | "version": "1.0.0", 4 | "description": "F# Snippets - Web site\r ======================", 5 | "main": "index.js", 6 | "scripts": { 7 | "build:js": "node node_modules/uglifyjs-folder/cli.js web/content/app/ -eo web/content/dist/", 8 | "build": "npm run build:js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/fssnippets/fssnip-website.git" 13 | }, 14 | "author": "Tomas Petricek", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/fssnippets/fssnip-website/issues" 18 | }, 19 | "homepage": "https://github.com/fssnippets/fssnip-website#readme", 20 | "devDependencies": { 21 | "uglifyjs-folder": "^0.2.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /paket.dependencies: -------------------------------------------------------------------------------- 1 | source https://api.nuget.org/v3/index.json 2 | framework: netcoreapp3.1 3 | storage: none 4 | 5 | nuget FSharp.Core ~> 5.0 6 | nuget FSharp.Formatting ~> 11.1.0 7 | nuget FSharp.Compiler.Service ~> 39.0 8 | 9 | nuget Suave ~> 2.5.6 10 | nuget Suave.DotLiquid ~> 2.5.6 11 | nuget FSharp.Data ~> 3.3.3 12 | nuget DotLiquid ~> 2.0.314 13 | nuget Paket.Core ~> 5.0 14 | nuget WindowsAzure.Storage ~> 9.3.3 15 | 16 | group Build 17 | source https://api.nuget.org/v3/index.json 18 | storage: none 19 | 20 | nuget Fake.Core.UserInput ~> 5.19.0 21 | nuget Fake.Core.Target ~> 5.19.0 22 | nuget Fake.IO.FileSystem ~> 5.19.0 23 | nuget Fake.DotNet.Cli ~> 5.19.0 24 | nuget Fake.JavaScript.Npm ~> 5.19.0 25 | 26 | group JsTools 27 | source https://api.nuget.org/v3/index.json 28 | 29 | nuget Node.js 30 | nuget Npm.js 31 | -------------------------------------------------------------------------------- /src/FsSnip.Website/FsSnip.Website.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | fssnip 5 | Exe 6 | netcoreapp3.1 7 | true 8 | x64 9 | False 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/FsSnip.Website/api.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Api 2 | 3 | open FsSnip 4 | open FsSnip.Data 5 | open FsSnip.Utils 6 | 7 | open System 8 | open System.Text 9 | open FSharp.Data 10 | 11 | open Suave 12 | open Suave.Operators 13 | open Suave.Filters 14 | 15 | // ------------------------------------------------------------------------------------------------- 16 | // REST API - Using JSON type provider to get strongly-typed representations of returned data 17 | // ------------------------------------------------------------------------------------------------- 18 | 19 | let [] GetSnippetExample = 20 | """{ 21 | "id": 22, 22 | "title": "Filtering lists", 23 | "comment": "Two functions showing how to filter functional lists using the specified predicate.", 24 | "author": "Tomas Petricek", 25 | "link": "http://tomasp.net", 26 | "date": "2010-12-03T23:56:49.3730000", 27 | "likes": 56, 28 | "references": ["Nada"], 29 | "source": "http", 30 | "versions": 1, 31 | "formatted": "let x = 4", 32 | "tags": [ "list", "filter", "recursion" ] 33 | }""" 34 | 35 | let [] AllSnippetsExample = 36 | """{ 37 | "id": 22, 38 | "title": "Filtering lists", 39 | "comment": "Two functions showing how to filter functional lists using the specified predicate.", 40 | "author": "Tomas Petricek", 41 | "link": "http://tomasp.net", 42 | "date": "2010-12-03T23:56:49.3730000", 43 | "likes": 56, 44 | "references": ["Nada"], 45 | "source": "http", 46 | "versions": 1, 47 | "tags": [ "list", "filter", "recursion" ] 48 | }""" 49 | 50 | let [] PutSnippetExample = 51 | """{ "title": "Hello", "author": "Tomas Petricek", "description": "Hello world", 52 | "code": "Fun.cube", "tags": [ "test" ], "public": true, "link": "http://tomasp.net", 53 | "nugetpkgs": ["na"], "source": "fun3d" }""" 54 | 55 | let [] FormatSnippetExamples = 56 | """[ {"snippets": [ "let x = 1", "x + 1" ], "packages":["FSharp.Data"] }, 57 | {"snippets": [ "let x = 1", "x + 1" ], "packages":["FSharp.Data"], "prefix":"fs" }, 58 | {"snippets": [ "let x = 1", "x + 1" ], "packages":["FSharp.Data"], "lineNumbers":false } ]""" 59 | 60 | type GetSnippetJson = JsonProvider 61 | type AllSnippetsJson = JsonProvider 62 | type PutSnippetJson = JsonProvider 63 | type FormatSnippetJson = JsonProvider 64 | type FormatSnippetResult = JsonProvider<"""{ "snippets":["html", "more html"], "tips":"divs" }"""> 65 | type PutSnippetResponseJson = JsonProvider<"""{ "status": "created", "id": "sY", "url": "http://fssnip.net/sY" }"""> 66 | 67 | // ------------------------------------------------------------------------------------------------- 68 | // REST API - Suave end-points that return snippets & available snippets 69 | // ------------------------------------------------------------------------------------------------- 70 | 71 | let getSnippet id = 72 | let id' = demangleId id 73 | match Data.loadSnippet id Latest with 74 | | Some formatted -> 75 | let details = Data.snippets |> Seq.find (fun s -> s.ID = id') 76 | let json = 77 | GetSnippetJson.Root( 78 | details.ID, details.Title, details.Comment, details.Author, details.Link, 79 | details.Date, details.Likes, Array.ofSeq details.References, details.Source, details.Versions, 80 | formatted, Array.ofSeq details.Tags) 81 | Writers.setMimeType "application/json" >=> Successful.OK (json.JsonValue.ToString()) 82 | | None -> RequestErrors.NOT_FOUND (sprintf "Snippet %s not found" id) 83 | 84 | let getPublicSnippets = request (fun request -> 85 | let all = request.queryParam "all" = Choice1Of2 "true" 86 | let snippets = getAllPublicSnippets() 87 | let snippets = if all then snippets else snippets |> Seq.take 20 88 | let json = 89 | snippets 90 | |> Seq.map (fun x -> 91 | AllSnippetsJson.Root( 92 | x.ID, x.Title, x.Comment, x.Author, x.Link, x.Date, x.Likes, Array.ofSeq x.References, 93 | x.Source, x.Versions, Array.ofSeq x.Tags)) 94 | |> Seq.map (fun x -> x.JsonValue) 95 | |> Array.ofSeq 96 | |> JsonValue.Array 97 | Writers.setMimeType "application/json" >=> Successful.OK (json.ToString())) 98 | 99 | let putSnippet = 100 | request (fun r -> 101 | try 102 | let json = PutSnippetJson.Parse(Encoding.UTF8.GetString r.rawForm) 103 | let id = Data.getNextId() 104 | let session = (System.Guid.NewGuid().ToString()) 105 | let doc = Parser.parseScript session json.Code json.Nugetpkgs 106 | let html = Literate.writeHtmlToString "fs" true doc 107 | Data.insertSnippet 108 | { ID = id; Title = json.Title; Comment = json.Description; Author = json.Author; 109 | Link = json.Link; Date = System.DateTime.UtcNow; Likes = 0; Private = false; 110 | Passcode = ""; References = json.Nugetpkgs; Source = json.Source; Versions = 1; Tags = json.Tags } 111 | json.Code html 112 | let mangledId = Utils.mangleId id 113 | let response = PutSnippetResponseJson.Root("created", mangledId, "http://fssnip.net/" + mangledId) 114 | Parser.completeSession session 115 | Successful.CREATED <| response.JsonValue.ToString() 116 | with ex -> 117 | RequestErrors.BAD_REQUEST <| (JsonValue.Record [| ("error", JsonValue.String ex.Message) |]).ToString() 118 | ) 119 | 120 | let contentType (str:string) = request (fun r ctx -> 121 | if r.headers |> Seq.exists (fun (h, v) -> h.ToLower() = "content-type" && v = str) then async.Return(Some ctx) 122 | else async.Return(None) ) 123 | 124 | let formatSnippets = request (fun r -> 125 | let request = 126 | try 127 | let r = FormatSnippetJson.Parse(Encoding.UTF8.GetString r.rawForm) 128 | Some(r.Snippets, r.Packages, defaultArg r.LineNumbers true, defaultArg r.Prefix "fs") 129 | with _ -> None 130 | match request with 131 | | None -> RequestErrors.BAD_REQUEST "Incorrectly formatted JSON" 132 | | Some(snippets, packages, lineNumbers, prefix) -> 133 | let session = Guid.NewGuid().ToString() 134 | let snippets = snippets |> String.concat ("\n(** " + session + " *)\n") 135 | let doc = Parser.parseScript session snippets packages 136 | let html = Literate.writeHtmlToString prefix lineNumbers doc 137 | let indexOfTips = html.IndexOf("
" + session + "

" |], StringSplitOptions.None) 142 | let res = FormatSnippetResult.Root(formatted, tips).ToString() 143 | Successful.OK res ) 144 | 145 | // Composed web part to be included in the top-level route 146 | let apis = 147 | choose [ 148 | GET >=> path "/1/snippet" >=> getPublicSnippets 149 | GET >=> pathWithId "/1/snippet/%s" getSnippet 150 | PUT >=> path "/1/snippet" >=> putSnippet ] 151 | 152 | let webPart = 153 | choose 154 | [ clientHost "api.fssnip.net" >=> apis 155 | apis ] 156 | 157 | let acceptWebPart = 158 | contentType "application/vnd.fssnip-v1+json" >=> 159 | POST >=> path "/api/format" >=> formatSnippets -------------------------------------------------------------------------------- /src/FsSnip.Website/app.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.App 2 | 3 | open System 4 | open System.IO 5 | open Suave 6 | open Suave.Operators 7 | open Suave.Filters 8 | open Suave.Logging 9 | open FsSnip 10 | open FsSnip.Utils 11 | open FsSnip.Pages 12 | 13 | let createApp (config : SuaveConfig) (homeDir : string) = 14 | // Configure DotLiquid templates & register filters (in 'filters.fs') 15 | [ for t in System.Reflection.Assembly.GetExecutingAssembly().GetTypes() do 16 | if t.Name = "Filters" && not (t.FullName.StartsWith "<") then yield t ] 17 | |> Seq.last 18 | |> DotLiquid.registerFiltersByType 19 | 20 | DotLiquid.setTemplatesDir (homeDir + "/templates") 21 | 22 | /// Browse static files in the 'web' subfolder 23 | let browseStaticFiles ctx = async { 24 | let root = Path.Combine(ctx.runtime.homeDirectory, "web") 25 | return! Files.browse root ctx } 26 | 27 | // Handles routing for the server 28 | let app = 29 | choose 30 | [ // API parts that check for specific Accept header 31 | Api.acceptWebPart 32 | 33 | // Home page, search and author & tag listings 34 | Home.webPart 35 | Search.webPart 36 | Author.webPart 37 | Tag.webPart 38 | 39 | // Snippet display, like, update & insert 40 | Snippet.webPart 41 | Like.webPart 42 | Update.webPart 43 | Insert.webPart 44 | Test.webPart 45 | // REST API and RSS feeds 46 | Api.webPart 47 | Rss.webPart 48 | 49 | // Static files and fallback case 50 | browseStaticFiles 51 | RequestErrors.NOT_FOUND "Found no handlers." ] 52 | 53 | let fmtLog (ctx : HttpContext) = 54 | sprintf "%O %s response %O %s" ctx.request.method ctx.request.url.PathAndQuery ctx.response.status.code (ctx.response.status.reason) 55 | 56 | app >=> logWithLevel LogLevel.Info config.logger fmtLog 57 | 58 | let defaultHomeDir = Path.Combine(__SOURCE_DIRECTORY__, "../..") 59 | 60 | [] 61 | let main _ = 62 | let siteNameVar = "WEBSITE_SITE_NAME" 63 | let isLocal = Environment.GetEnvironmentVariable siteNameVar |> String.IsNullOrEmpty 64 | 65 | if isLocal 66 | then printfn "%s missing or empty: running locally" siteNameVar 67 | else printfn "%s found: running in Azure" siteNameVar 68 | 69 | let ipAddress = Environment.GetEnvironmentVariable("IP_ADDRESS", defaultValue = "0.0.0.0") 70 | let port = Environment.GetEnvironmentVariable("PORT", defaultValue = "5000") |> int 71 | let logLevel = Environment.GetEnvironmentVariable("LOG_LEVEL", defaultValue = "Info") |> LogLevel.ofString 72 | let homeDir = Environment.GetEnvironmentVariable("FSSNIP_HOME_DIR", defaultValue = defaultHomeDir) 73 | 74 | Recaptcha.ensureConfigured() 75 | 76 | let serverConfig = 77 | { Web.defaultConfig with 78 | homeFolder = Some (Path.GetFullPath homeDir) 79 | logger = LiterateConsoleTarget([|"Suave"|], logLevel) 80 | bindings = [ HttpBinding.createSimple HTTP ipAddress port ] } 81 | 82 | let app = createApp serverConfig homeDir 83 | Web.startWebServer serverConfig app 84 | 85 | 0 86 | -------------------------------------------------------------------------------- /src/FsSnip.Website/common/data.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Data 2 | 3 | open System 4 | open FsSnip.Utils 5 | open FSharp.Data 6 | 7 | // ------------------------------------------------------------------------------------------------- 8 | // Data access. We keep an in-memory index (saved as JSON) with all the meta-data about snippets. 9 | // When something changes (someone likes a snippet or a new snippet is added), we update this and 10 | // save it back. Aside from that, we keep the source, parsed and formatted snippets as blobs. 11 | // ------------------------------------------------------------------------------------------------- 12 | 13 | /// The storage can be either file system or Azure blob storage. This chooses Azure 14 | /// (when it has the connection string) or file system (when running locally) 15 | module Storage = 16 | let readIndex, saveIndex, readFile, writeFile = 17 | if Storage.Azure.isConfigured() then Storage.Azure.functions 18 | else Storage.Local.functions 19 | 20 | let [] Index = __SOURCE_DIRECTORY__ + "/../samples/index.json" 21 | type Index = JsonProvider 22 | 23 | type SnippetVersion = 24 | | Latest 25 | | Revision of int 26 | 27 | type Snippet = 28 | { ID : int; Title : string; Comment : string; Author : string; 29 | Link : string; Date : DateTime; Likes : int; Private : bool; 30 | Passcode : string; References : seq; Source : string; 31 | Versions: int; Tags : seq } 32 | 33 | let private readSnippet (s:Index.Snippet) = 34 | { ID = s.Id; Title = s.Title; Comment = s.Comment; Author = s.Author; 35 | Link = s.Link; Date = s.Date; Likes = s.Likes; Private = s.IsPrivate; 36 | Passcode = s.Passcode; References = s.References; Source = s.Source; 37 | Versions = s.Versions; Tags = (s.DisplayTags |> Array.map(fun t -> t.ToLowerInvariant())) } 38 | 39 | let private saveSnippet (s:Snippet) = 40 | Index.Snippet 41 | ( s.ID, s.Title, s.Comment, s.Author, s.Link, s.Date, s.Likes, s.Private, 42 | s.Passcode, Array.ofSeq s.References, s.Source, s.Versions, Array.ofSeq s.Tags, Array.ofSeq s.Tags ) 43 | 44 | let readSnippets () = 45 | let index = Index.Parse(Storage.readIndex ()) 46 | let snippets = index.Snippets |> Seq.map readSnippet |> List.ofSeq 47 | snippets, snippets |> Seq.filter (fun s -> not s.Private) 48 | 49 | let mutable snippets, publicSnippets = readSnippets () 50 | 51 | let loadSnippetInternal folder id revision = 52 | let id = demangleId id 53 | snippets 54 | |> Seq.tryFind (fun s -> s.ID = id) 55 | |> Option.bind (fun snippetInfo -> 56 | let r = match revision with Latest -> snippetInfo.Versions - 1 | Revision r -> r 57 | Storage.readFile (sprintf "%s/%d/%d" folder id r) ) 58 | 59 | let loadSnippet id revision = 60 | loadSnippetInternal "formatted" id revision 61 | 62 | let loadRawSnippet id revision = 63 | loadSnippetInternal "source" id revision 64 | 65 | let getAllPublicSnippets () = 66 | publicSnippets 67 | 68 | let getNextId () = 69 | let largest = snippets |> Seq.map (fun s -> s.ID) |> Seq.max 70 | largest + 1 71 | 72 | 73 | let insertSnippet newSnippet source formatted = 74 | let index = Index.Parse(Storage.readIndex()) 75 | let _, otherSnippets = index.Snippets |> Array.partition (fun snippet -> snippet.Id = newSnippet.ID) 76 | let json = Index.Root(Array.append otherSnippets [| saveSnippet newSnippet |]).JsonValue.ToString() 77 | 78 | let version = newSnippet.Versions - 1 79 | Storage.writeFile (sprintf "source/%d/%d" newSnippet.ID version) source 80 | Storage.writeFile (sprintf "formatted/%d/%d" newSnippet.ID version) formatted 81 | Storage.saveIndex json 82 | 83 | let newSnippets, newPublicSnippets = readSnippets () 84 | snippets <- newSnippets 85 | publicSnippets <- newPublicSnippets 86 | 87 | 88 | let likeSnippet id revision = 89 | let currentLikes = ref 0 90 | let index = Index.Parse(Storage.readIndex()) 91 | let newSnippets = index.Snippets |> Array.map (fun snippet -> 92 | if snippet.Id = id then 93 | currentLikes := snippet.Likes + 1 94 | Index.Snippet 95 | ( snippet.Id, snippet.Title, snippet.Comment, snippet.Author, snippet.Link, snippet.Date, 96 | !currentLikes, snippet.IsPrivate, snippet.Passcode, Array.ofSeq snippet.References, 97 | snippet.Source, snippet.Versions, Array.ofSeq snippet.DisplayTags, Array.ofSeq snippet.EnteredTags ) 98 | else snippet) 99 | let json = Index.Root(newSnippets).JsonValue.ToString() 100 | Storage.saveIndex json 101 | 102 | let newSnippets, newPublicSnippets = readSnippets () 103 | snippets <- newSnippets 104 | publicSnippets <- newPublicSnippets 105 | !currentLikes 106 | -------------------------------------------------------------------------------- /src/FsSnip.Website/common/filters.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Filters 2 | 3 | open FsSnip.Utils 4 | open System 5 | 6 | // ------------------------------------------------------------------------------------------------- 7 | // Filters that can be used in DotLiquid templates 8 | // ------------------------------------------------------------------------------------------------- 9 | 10 | let formatId (id:int) = 11 | mangleId id 12 | 13 | let cleanTitle (title:string) = 14 | generateCleanTitle title 15 | 16 | let urlEncode (url:string) = 17 | System.Net.WebUtility.UrlEncode(url) 18 | 19 | let urlDecode (input:string) = 20 | System.Net.WebUtility.UrlDecode(input) 21 | 22 | let niceDate (dt:DateTime) = 23 | let print (elapsed:float) timeUnit = 24 | // truncate to int 25 | let count = int elapsed 26 | // pluralize (conveniently, all of our units are pluralized with "s") 27 | let suffix = if count = 1 then "" else "s" 28 | sprintf "%d %s%s ago" count timeUnit suffix 29 | let ts = DateTime.UtcNow - dt 30 | if ts.TotalSeconds < 60.0 then print ts.TotalSeconds "second" 31 | elif ts.TotalMinutes < 60.0 then print ts.TotalMinutes "minute" 32 | elif ts.TotalHours < 24.0 then print ts.TotalHours "hour" 33 | elif ts.TotalHours < 48.0 then "yesterday" 34 | elif ts.TotalDays < 30.0 then print ts.TotalDays "day" 35 | elif ts.TotalDays < 365.0 then print (ts.TotalDays / 30.0) "month" 36 | else print (ts.TotalDays / 365.0) "year" 37 | -------------------------------------------------------------------------------- /src/FsSnip.Website/common/parser.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Parser 2 | 3 | open System 4 | open System.IO 5 | open Paket 6 | open FSharp.Formatting.CodeFormat 7 | open FSharp.Formatting.Literate 8 | open FSharp.Compiler.SourceCodeServices 9 | open FSharp.Compiler.Text 10 | 11 | // ------------------------------------------------------------------------------------------------- 12 | // Parse & format documents - restores packages using Paket and invokes 13 | // the F# Compiler checker with the appropriate references 14 | // ------------------------------------------------------------------------------------------------- 15 | 16 | let private framework = TargetProfile.SinglePlatform (FrameworkIdentifier.DotNetCoreApp DotNetCoreAppVersion.V3_1) 17 | let private formatAgent = lazy CodeFormat.CreateAgent() 18 | let private checker = lazy FSharpChecker.Create() 19 | 20 | let private defaultOptions = lazy( 21 | checker.Value.GetProjectOptionsFromScript("foo.fsx", SourceText.ofString "module Foo", assumeDotNetFramework = false) 22 | |> Async.RunSynchronously 23 | |> fst) 24 | 25 | let private restorePackages packages folder = 26 | if Array.isEmpty packages 27 | then [| |] 28 | else 29 | Dependencies.Init folder 30 | let dependencies = Dependencies.Locate folder 31 | 32 | // Because F# Data is already loaded from another location, we cannot put it in 33 | // another place and load it from there - so we just use the currently loaded one 34 | // hoping that it will be compatible with other dependencies... 35 | // 36 | // Also, silently ignore all packages that cannot be added (e.g. because they don't exist) 37 | let addedPackages = 38 | packages |> Array.choose (fun pkg -> 39 | try 40 | dependencies.Add(pkg) 41 | Some(pkg) 42 | with _ -> None) 43 | 44 | // Silently ignore all packages that could not be installed 45 | addedPackages 46 | |> Seq.collect(fun package -> 47 | try dependencies.GetLibraries(None, package, framework) |> Seq.map (fun l -> l.Path) 48 | with _ -> seq [] ) 49 | |> Array.ofSeq 50 | 51 | let private workingFolderFor session = Path.Combine(Environment.CurrentDirectory, "temp", session) 52 | 53 | /// encloses string content in quotes 54 | let private encloseInQuotes (prefix : string) (line: string) = 55 | if (line.StartsWith prefix && line.Contains " ") 56 | then sprintf "%s\"%s\"" prefix (line.Substring prefix.Length) 57 | else line 58 | 59 | /// Parses F# script file and download NuGet packages if required. 60 | let parseScript session (content : string) packages = 61 | let workingFolder = workingFolderFor session 62 | 63 | if (not <| Directory.Exists workingFolder) 64 | then Directory.CreateDirectory workingFolder |> ignore 65 | 66 | let nugetReferences = 67 | restorePackages packages workingFolder 68 | |> Seq.map (sprintf "-r:%s") 69 | 70 | let scriptFile = Path.Combine(workingFolder, "Script.fsx") 71 | 72 | let compilerOptions = 73 | defaultOptions.Value.OtherOptions 74 | |> Seq.append nugetReferences 75 | |> Seq.map (encloseInQuotes "-r:") 76 | |> Seq.map (encloseInQuotes "--reference:") 77 | |> String.concat " " 78 | 79 | Literate.ParseScriptString(content, scriptFile, formatAgent.Value, compilerOptions) 80 | 81 | /// Marks parsing session as complete - basically deletes working forlder for the given session 82 | let completeSession session = 83 | let folder = workingFolderFor session 84 | if Directory.Exists folder then 85 | try 86 | Directory.Delete(folder, true) 87 | with 88 | | e -> printfn "Failed to delete folder \"%s\": %O" folder e -------------------------------------------------------------------------------- /src/FsSnip.Website/common/rssfeed.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Rssfeed 2 | 3 | open System.Xml 4 | open System.Text 5 | open System.IO 6 | 7 | // ------------------------------------------------------------------------------------------------- 8 | // Rss XML writer - based on Phil's post: http://trelford.com/blog/post/F-XML-Comparison-(XElement-vs-XmlDocument-vs-XmlReaderXmlWriter-vs-Discriminated-Unions).aspx 9 | // ------------------------------------------------------------------------------------------------- 10 | 11 | type XmlAttribute = {Prefix: string; LocalName: string; Value: string} 12 | type RssItem = {Title: string; Link: string; PubDate: string; Author: string; Description: string} 13 | 14 | /// F# Element Tree 15 | type Xml = 16 | | Element of string * XmlAttribute list * string * Xml seq 17 | member this.WriteContentTo(writer:XmlWriter) = 18 | let rec Write element = 19 | match element with 20 | | Element (name, attributes, value, children) -> 21 | writer.WriteStartElement(name) 22 | attributes 23 | |> List.iter (fun attr -> writer.WriteAttributeString(attr.Prefix,attr.LocalName, null,attr.Value)) 24 | writer.WriteString(value) 25 | children |> Seq.iter (fun child -> Write child) 26 | writer.WriteEndElement() 27 | Write this 28 | override this.ToString() = 29 | let output = StringBuilder() 30 | using (new XmlTextWriter(new StringWriter(output), 31 | Formatting=Formatting.Indented)) 32 | this.WriteContentTo 33 | output.ToString() 34 | 35 | // XmlAttribute helper 36 | let attribute prefix localName value = 37 | {Prefix = prefix; LocalName = localName; Value = value} 38 | 39 | // Take an item and return the XML 40 | let getItem (item: RssItem) = 41 | Element("item", [],"", 42 | [ Element("title", [attribute "xmlns" "cf" "http://www.microsoft.com/schemas/rss/core/2005"; attribute "cf" "type" "text"], item.Title,[]) 43 | Element("link", [],item.Link,[]) 44 | Element("pubDate", [],item.PubDate,[]) 45 | Element("author", [],item.Author,[]) 46 | Element("description", [attribute "xmlns" "cf" "http://www.microsoft.com/schemas/rss/core/2005"; attribute "cf" "type" "text"], item.Description,[]) ]) 47 | 48 | // Take a list of items and return the channel 49 | let getChannel title link description language (items: seq) = 50 | let channelAttributes = 51 | [ attribute "xmlns" "cfi" "http://www.microsoft.com/schemas/rss/core/2005/internal" 52 | attribute "xmlns" "lastdownloaderror" "None"] 53 | let channelElements = 54 | [ Element("title", [attribute "xmlns" "cf" "http://www.microsoft.com/schemas/rss/core/2005"; attribute "cf" "type" "text"],title,[]) 55 | Element("link", [],link,[]) 56 | Element("description", [attribute "xmlns" "cf" "http://www.microsoft.com/schemas/rss/core/2005"; attribute "cf" "type" "text"],description,[]) 57 | Element("dc:language", [],language,[]) ] 58 | Element("channel", channelAttributes, "", (Seq.append channelElements items)) 59 | 60 | // Take a channel and return the Rss xml 61 | let getRss channel = 62 | let rssAttributes = 63 | [ attribute null "version" "2.0" 64 | attribute "xmlns" "atom" "http://www.w3.org/2005/Atom" 65 | attribute "xmlns" "cf" "http://www.microsoft.com/schemas/rss/core/2005" 66 | attribute "xmlns" "dc" "http://purl.org/dc/elements/1.1/" 67 | attribute "xmlns" "slash" "http://purl.org/rss/1.0/modules/slash/" 68 | attribute "xmlns" "wfw" "http://wellformedweb.org/CommentAPI/" ] 69 | Element("rss", rssAttributes, "", [channel]) 70 | 71 | // take the channel config and item list and return the string output in valid XML format 72 | let RssOutput title link description language (list: seq) = 73 | let rss = 74 | list 75 | |> Seq.map getItem 76 | |> getChannel title link description language 77 | |> getRss 78 | "\n" + (rss.ToString()) -------------------------------------------------------------------------------- /src/FsSnip.Website/common/storage/azure.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Storage.Azure 2 | 3 | open System 4 | open System.IO 5 | open Microsoft.WindowsAzure.Storage 6 | 7 | // ------------------------------------------------------------------------------------------------- 8 | // Azure storage - the connection string is provided by environment variable, which is configured 9 | // in the Azure portal. The `functions` value should be compatible with one in `local.fs` 10 | // ------------------------------------------------------------------------------------------------- 11 | 12 | let storageEnvVar = "CUSTOMCONNSTR_FSSNIP_STORAGE" 13 | 14 | let isConfigured() = 15 | Environment.GetEnvironmentVariable(storageEnvVar) <> null 16 | 17 | let createCloudBlobClient() = 18 | let account = CloudStorageAccount.Parse(Environment.GetEnvironmentVariable(storageEnvVar)) 19 | account.CreateCloudBlobClient() 20 | 21 | let private readBlobText containerName blobPath = 22 | let container = createCloudBlobClient().GetContainerReference(containerName) 23 | if container.ExistsAsync().Result then 24 | let blob = container.GetBlockBlobReference(blobPath) 25 | if blob.ExistsAsync().Result then 26 | Some(blob.DownloadTextAsync().Result) 27 | else None 28 | else None 29 | 30 | let private readBlobStream containerName blobPath = 31 | let stream = new MemoryStream(); 32 | let container = createCloudBlobClient().GetContainerReference(containerName) 33 | let blob = container.GetBlockBlobReference(blobPath) 34 | if blob.ExistsAsync().Result then 35 | blob.DownloadToStreamAsync(stream).Wait() 36 | else failwith "blob not found" 37 | stream 38 | 39 | let private writeBlobText containerName blobPath text = 40 | let container = createCloudBlobClient().GetContainerReference(containerName) 41 | if container.ExistsAsync().Result then 42 | let blob = container.GetBlockBlobReference(blobPath) 43 | blob.UploadTextAsync(text).Wait() 44 | else failwith (sprintf "container not found %s" containerName) 45 | 46 | let readIndex () = 47 | match readBlobText "data" "index.json" with 48 | | Some x -> x 49 | | None -> failwith "index file not found" 50 | let saveIndex json = 51 | writeBlobText "data" "index.json" json 52 | let readFile file = 53 | readBlobText "data" file 54 | let writeFile file data = 55 | writeBlobText "data" file data 56 | 57 | let functions = 58 | readIndex, saveIndex, 59 | readFile, writeFile -------------------------------------------------------------------------------- /src/FsSnip.Website/common/storage/local.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Storage.Local 2 | 3 | open System 4 | open System.IO 5 | open FsSnip.Utils 6 | 7 | // ------------------------------------------------------------------------------------------------- 8 | // Local file system storage - the `functions` value should be compatible with `azure.fs` 9 | // ------------------------------------------------------------------------------------------------- 10 | 11 | let private defaultDataFolder = Path.Combine(__SOURCE_DIRECTORY__, "../../../../data") 12 | let private dataFolder = Environment.GetEnvironmentVariable("FSSNIP_DATA_DIR", defaultValue = defaultDataFolder) |> Path.GetFullPath 13 | let private indexFile = Path.Combine(dataFolder, "index.json") 14 | 15 | let readIndex () = 16 | File.ReadAllText(indexFile) 17 | let readFile file = 18 | try 19 | Some(File.ReadAllText(Path.Combine(dataFolder, file))) 20 | with 21 | | :? System.IO.FileNotFoundException as ex -> None 22 | let saveIndex json = 23 | File.WriteAllText(indexFile, json) 24 | let writeFile file data = 25 | let path = Path.Combine(dataFolder, file) 26 | let dir = Path.GetDirectoryName(path) 27 | if not(Directory.Exists(dir)) then Directory.CreateDirectory(dir) |> ignore 28 | File.WriteAllText(path, data) 29 | 30 | let functions = 31 | readIndex, saveIndex, 32 | readFile, writeFile -------------------------------------------------------------------------------- /src/FsSnip.Website/common/utils.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Utils 2 | 3 | open System 4 | open FSharp.Reflection 5 | open FSharp.Formatting.Literate 6 | open Suave 7 | open Suave.Filters 8 | open System.Text 9 | open System.Security.Cryptography 10 | 11 | // ------------------------------------------------------------------------------------------------- 12 | // Helpers for working with fssnip IDs, formatting F# code and for various Suave things 13 | // ------------------------------------------------------------------------------------------------- 14 | 15 | // This is the alphabet used in the IDs 16 | let alphabet = [ '0' .. '9' ] @ [ 'a' .. 'z' ] @ [ 'A' .. 'Z' ] |> Array.ofList 17 | let alphabetMap = Seq.zip alphabet [ 0 .. alphabet.Length - 1 ] |> dict 18 | 19 | /// Generate mangled name to be used as part of the URL 20 | let mangleId i = 21 | let rec mangle acc = function 22 | | 0 -> new String(acc |> Array.ofList) 23 | | n -> let d, r = Math.DivRem(n, alphabet.Length) 24 | mangle (alphabet.[r]::acc) d 25 | mangle [] i 26 | 27 | /// Translate mangled URL name to a numeric snippet ID 28 | let demangleId (str:string) = 29 | let rec demangle acc = function 30 | | [] -> acc 31 | | x::xs -> 32 | let v = alphabetMap.[x] 33 | demangle (acc * alphabet.Length + v) xs 34 | demangle 0 (str |> List.ofSeq) 35 | 36 | /// Web part that succeeds when the specified string is a valid FsSnip ID 37 | let pathWithId pf f = 38 | pathScan pf (fun id ctx -> async { 39 | if Seq.forall (alphabetMap.ContainsKey) id then 40 | return! f id ctx 41 | else return None } ) 42 | 43 | let private sha = new SHA1Managed() 44 | 45 | /// Returns SHA1 hash of a given string formatted as Base64 string 46 | let sha1Hash (password:string) = 47 | if String.IsNullOrEmpty password then "" 48 | else Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(password))) 49 | 50 | /// Returns None if the passcode is empty or Some with the hash 51 | let tryGetHashedPasscode (p:string) = 52 | if String.IsNullOrWhiteSpace(p) then None 53 | else Some(sha1Hash p) 54 | 55 | /// Creates a web part from a function (to enable lazy computation) 56 | let delay (f:unit -> WebPart) ctx = 57 | async { return! f () ctx } 58 | 59 | /// Cleanup url for title: "concurrent memoization" -> concurrent-memoization 60 | let generateCleanTitle title = 61 | System.Net.WebUtility.UrlEncode( 62 | System.Text.RegularExpressions.Regex.Replace( 63 | System.Text.RegularExpressions.Regex.Replace(title, "[^a-zA-Z0-9 ]", ""), 64 | " +", "-")) 65 | 66 | module Seq = 67 | /// Take the number of elements specified by `take`, then shuffle the 68 | /// rest of the items and then take just the `top` number of elements 69 | let takeShuffled take top snips = 70 | let rnd = Random() 71 | snips 72 | |> Seq.take take 73 | |> Seq.sortBy (fun _ -> rnd.NextDouble()) 74 | |> Seq.take top 75 | 76 | /// Given a sequence of items, return item together with its relative 77 | /// size (as percentage). The specified function `f` returns the "size". 78 | /// We assume that the smallest size is zero. 79 | let withSizeBy f snips = 80 | let snips = snips |> List.ofSeq 81 | let max = snips |> Seq.map f |> Seq.max 82 | snips |> Seq.map (fun s -> s, (f s) * 100 / max) 83 | 84 | /// Converts an array of string values to the specified type (for use when parsing form data) 85 | /// (supports `string option`, `bool`, `string` and `string[]` at the moment) 86 | let private convert (ty:System.Type) (strs:string[]) = 87 | let single() = 88 | if strs.Length = 1 then strs.[0] 89 | else failwith "Got multiple values!" 90 | if ty = typeof then 91 | box (if System.String.IsNullOrWhiteSpace(single()) then None else Some(single())) 92 | elif ty = typeof then 93 | box (if single() = "on" then true else false) 94 | elif ty = typeof then 95 | box (single()) 96 | elif ty = typeof then 97 | box (strs) 98 | else failwithf "Could not covert '%A' to '%s'" strs ty.Name 99 | 100 | /// Returns a default value when the type has a sensible default or throws 101 | let private getDefaultValue (ty:System.Type) = 102 | if ty.IsGenericType && ty.GetGenericTypeDefinition() = typedefof> then null 103 | elif ty = typeof then box false 104 | elif ty.IsArray then box (Array.CreateInstance(ty.GetElementType(), [| 0 |])) 105 | else failwithf "Could not get value of type '%s'" ty.Name 106 | 107 | /// Read data from a form into an F# record 108 | let readForm<'T> (form:list) = 109 | let lookup = 110 | [ for k, v in form do 111 | match v with Some v -> yield k.ToLower(), v | _ -> () ] 112 | |> Seq.groupBy fst 113 | |> Seq.map (fun (k, vs) -> k, Seq.map snd vs) 114 | |> dict 115 | let values = 116 | [| for pi in FSharpType.GetRecordFields(typeof<'T>) -> 117 | match lookup.TryGetValue(pi.Name.ToLower()) with 118 | | true, vs -> convert pi.PropertyType (Array.ofSeq vs) 119 | | _ -> getDefaultValue pi.PropertyType |] 120 | FSharpValue.MakeRecord(typeof<'T>, values) :?> 'T 121 | 122 | /// Converts comma-separated string with NuGet package names to list of strings 123 | let parseNugetPackages = function 124 | | Some s when not (String.IsNullOrWhiteSpace(s)) -> 125 | s.Split([|","|], StringSplitOptions.RemoveEmptyEntries) 126 | |> Array.map (fun s -> s.Trim()) 127 | | _ -> [| |] 128 | 129 | module Literate = 130 | let writeHtmlToString (prefix : string) (lineNumbers : bool) (doc : LiterateDocument) : string = 131 | use sw = new System.IO.StringWriter() 132 | Literate.WriteHtml(doc, sw, prefix = prefix, lineNumbers = lineNumbers, generateAnchors = true) 133 | sw.ToString() 134 | 135 | type Environment with 136 | static member GetEnvironmentVariable(variable : string, defaultValue : string) = 137 | match Environment.GetEnvironmentVariable variable with 138 | | null -> defaultValue 139 | | value -> value -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/author.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Author 2 | 3 | open Suave 4 | open System 5 | open System.Web 6 | open FsSnip.Utils 7 | open FsSnip.Data 8 | open Suave.Filters 9 | open Suave.Operators 10 | 11 | // ------------------------------------------------------------------------------------------------- 12 | // Author page - domain model 13 | // ------------------------------------------------------------------------------------------------- 14 | 15 | type AuthorLink = 16 | { Text : string 17 | Link : string 18 | Size : int 19 | Count : int } 20 | 21 | type AuthorLinks = seq 22 | 23 | type AuthorModel = 24 | { Author : string 25 | Snippets : seq } 26 | 27 | type AllAuthorsModel = 28 | { Authors: AuthorLinks} 29 | 30 | 31 | let getAllAuthors () = 32 | let links = 33 | publicSnippets 34 | |> Seq.map (fun s -> s.Author) 35 | |> Seq.countBy id 36 | |> Seq.sortBy (fun (_, c) -> -c) 37 | |> Seq.withSizeBy snd 38 | |> Seq.map (fun ((n,c),s) -> 39 | { Text = n; Size = 80 + s; Count = c; 40 | Link = System.Net.WebUtility.UrlEncode(n) }) 41 | { Authors = links } 42 | 43 | // ------------------------------------------------------------------------------------------------- 44 | // Suave web parts 45 | // ------------------------------------------------------------------------------------------------- 46 | 47 | // Loading author page information (snippets by the given author) 48 | let showSnippets (author) = 49 | let a = System.Net.WebUtility.UrlDecode author 50 | let fromAuthor (s:Snippet) = 51 | s.Author.Equals(a, StringComparison.InvariantCultureIgnoreCase) 52 | let ss = Seq.filter fromAuthor publicSnippets 53 | DotLiquid.page "author.html" { Author = a; Snippets = ss } 54 | 55 | // Loading author page information (all authors) 56 | let showAll = delay (fun () -> 57 | DotLiquid.page "authors.html" (getAllAuthors())) 58 | 59 | // Composed web part to be included in the top-level route 60 | let webPart = 61 | choose 62 | [ path "/authors/" >=> showAll 63 | pathScan "/authors/%s" showSnippets ] 64 | -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/error.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Error 2 | 3 | open FsSnip 4 | open FsSnip.Data 5 | open FsSnip.Utils 6 | open Suave 7 | open Suave.Operators 8 | open Suave.Filters 9 | 10 | // ------------------------------------------------------------------------------------------------- 11 | // Basic error page for showing error with title & details 12 | // ------------------------------------------------------------------------------------------------- 13 | 14 | type Error = 15 | { Title : string 16 | Details : string } 17 | 18 | let reportError code title details : WebPart = 19 | ( DotLiquid.page "error.html" 20 | { Title = title; Details = details }) 21 | >=> Writers.setStatus code -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/home.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Home 2 | 3 | open Suave 4 | open System 5 | open System.Web 6 | open FsSnip.Utils 7 | open FsSnip.Data 8 | open Suave.Filters 9 | open Suave.Operators 10 | 11 | // ------------------------------------------------------------------------------------------------- 12 | // Home page - domain model 13 | // ------------------------------------------------------------------------------------------------- 14 | 15 | type HomeLink = 16 | { Text : string 17 | Link : string 18 | Size : int 19 | Count : int } 20 | 21 | type Home = 22 | { Recent : seq 23 | Popular : seq 24 | Tags : seq 25 | Authors : seq 26 | TotalCount : int 27 | PublicCount : int } 28 | 29 | // ------------------------------------------------------------------------------------------------- 30 | // Loading home page information (recent, popular, authors, tags, etc.) 31 | // ------------------------------------------------------------------------------------------------- 32 | 33 | let getRecent () = 34 | publicSnippets 35 | |> Seq.sortBy (fun s -> DateTime.Now - s.Date) 36 | |> Seq.take 6 37 | 38 | let getPopular () = 39 | let rnd = Random() 40 | publicSnippets 41 | |> Seq.sortBy (fun s -> -s.Likes) 42 | |> Seq.takeShuffled 40 6 43 | 44 | let getShuffledLinksByCount take top (names:seq) = 45 | names 46 | |> Seq.countBy id 47 | |> Seq.sortBy (fun (_, c) -> -c) 48 | |> Seq.takeShuffled take top 49 | |> Seq.withSizeBy snd 50 | |> Seq.map (fun ((n,c),s) -> 51 | { Text = n; Size = 80 + s; Count = c; 52 | Link = System.Net.WebUtility.UrlEncode(n) }) 53 | 54 | let getAuthors () = 55 | publicSnippets 56 | |> Seq.map (fun s -> s.Author) 57 | |> getShuffledLinksByCount 30 20 58 | 59 | let getTags () = 60 | publicSnippets 61 | |> Seq.collect (fun s -> s.Tags) 62 | |> getShuffledLinksByCount 40 20 63 | 64 | let getHome () = 65 | { Recent = getRecent(); Popular = getPopular() 66 | TotalCount = Seq.length snippets 67 | PublicCount = Seq.length publicSnippets 68 | Tags = getTags(); Authors = getAuthors() } 69 | 70 | let showHome = delay (fun () -> 71 | DotLiquid.page "home.html" (getHome())) 72 | 73 | let webPart = path "/" >=> showHome -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/insert.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Insert 2 | 3 | open System 4 | open Suave 5 | open Suave.Operators 6 | open FsSnip 7 | open FsSnip.Utils 8 | open FSharp.Formatting.CodeFormat 9 | 10 | // ------------------------------------------------------------------------------------------------- 11 | // Snippet details and raw view pages 12 | // ------------------------------------------------------------------------------------------------- 13 | 14 | type InsertForm = 15 | { Hidden : bool 16 | Title : string 17 | Passcode : string option 18 | Description : string option 19 | Tags : string[] 20 | Author : string option 21 | Link : string 22 | Code : string 23 | NugetPkgs : string option 24 | Session : string } 25 | 26 | type InsertSnippetModel = 27 | { Session: string } 28 | static member Create() = { Session = Guid.NewGuid().ToString() } 29 | 30 | let insertSnippet ctx = async { 31 | if ctx.request.form |> Seq.exists (function "submit", _ -> true | _ -> false) then 32 | // Give up early if the reCAPTCHA was not correct 33 | let! valid = Recaptcha.validateRecaptcha ctx.request.form 34 | if not valid then 35 | return! Recaptcha.recaptchaError ctx 36 | else 37 | 38 | // Parse the inputs and the F# source file 39 | let form = Utils.readForm ctx.request.form 40 | let nugetReferences = Utils.parseNugetPackages form.NugetPkgs 41 | 42 | let id = Data.getNextId() 43 | let doc = Parser.parseScript form.Session form.Code nugetReferences 44 | let html = Literate.writeHtmlToString "fs" true doc 45 | Parser.completeSession form.Session 46 | 47 | // Insert as private or public, depending on the check box 48 | match form with 49 | | { Hidden = true } -> 50 | Data.insertSnippet 51 | { ID = id; Title = form.Title; Comment = ""; Author = ""; 52 | Link = ""; Date = System.DateTime.UtcNow; Likes = 0; Private = true; 53 | Passcode = Utils.sha1Hash (defaultArg form.Passcode ""); 54 | References = nugetReferences; Source = ""; Versions = 1; Tags = [| |] } 55 | form.Code html 56 | return! Redirection.FOUND ("/" + Utils.mangleId id) ctx 57 | 58 | | { Hidden = false; Description = Some descr; Author = Some author; Link = link; 59 | Tags = tags } when tags.Length > 0 -> 60 | Data.insertSnippet 61 | { ID = id; Title = form.Title; Comment = descr; 62 | Author = author; Link = link; Date = System.DateTime.UtcNow; 63 | Likes = 0; Private = form.Hidden; Passcode = Utils.sha1Hash (defaultArg form.Passcode ""); 64 | References = nugetReferences; Source = ""; Versions = 1; 65 | Tags = tags |> Array.map(fun t -> t.ToLowerInvariant()) } 66 | form.Code html 67 | return! Redirection.FOUND ("/" + Utils.mangleId id) ctx 68 | 69 | | _ -> 70 | let details = 71 | "Some of the inputs for the snippet were not valid, but the client-side checking"+ 72 | " did not catch that. Please consider opening a bug issue!" 73 | return! Error.reportError HTTP_400 "Inserting snippet failed!" details ctx 74 | 75 | else 76 | return! DotLiquid.page "insert.html" (InsertSnippetModel.Create()) ctx } 77 | 78 | // ------------------------------------------------------------------------------------------------- 79 | // REST API for checking snippet and listing tags 80 | // ------------------------------------------------------------------------------------------------- 81 | 82 | open FSharp.Data 83 | open Suave.Filters 84 | 85 | let disableCache = 86 | Writers.setHeader "Cache-Control" "no-cache, no-store, must-revalidate" 87 | >=> Writers.setHeader "Pragma" "no-cache" 88 | >=> Writers.setHeader "Expires" "0" 89 | >=> Writers.setMimeType "application/json" 90 | 91 | type CheckResponse = JsonProvider<""" 92 | { "errors": [ {"location":[1,1,10,10], "error":true, "message":"sth"} ], 93 | "tags": [ "test", "demo" ] }"""> 94 | 95 | let checkSnippet = request (fun request -> 96 | let errors, tags = 97 | try 98 | // Check the snippet and report errors 99 | let form = Utils.readForm request.form 100 | let nugetReferences = Utils.parseNugetPackages form.NugetPkgs 101 | let doc = Parser.parseScript form.Session form.Code nugetReferences 102 | let errors = 103 | [| for SourceError((l1,c1),(l2,c2),kind,msg) in doc.Diagnostics -> 104 | CheckResponse.Error([| l1; c1; l2; c2 |], (kind = ErrorKind.Error), msg) |] 105 | 106 | // Recommend tags based on the snippet contents 107 | let tags = [| |] 108 | errors, tags 109 | with e -> 110 | [| CheckResponse.Error([| 0; 0; 0; 0 |], true, "Parsing the snippet failed.") |], [| |] 111 | ( disableCache 112 | >=> Successful.OK(CheckResponse.Root(errors, tags).ToString()) )) 113 | 114 | let listTags = request (fun _ -> 115 | let tags = 116 | Data.getAllPublicSnippets() 117 | |> Seq.collect (fun snip -> snip.Tags) 118 | |> Seq.map (fun tag -> tag.Trim().ToLowerInvariant()) 119 | |> Seq.distinct 120 | |> Seq.sort 121 | let json = JsonValue.Array [| for s in tags -> JsonValue.String s |] 122 | disableCache >=> Successful.OK(json.ToString()) ) 123 | 124 | let webPart = 125 | choose 126 | [ path "/pages/insert" >=> insertSnippet 127 | path "/pages/insert/taglist" >=> listTags 128 | path "/pages/insert/check" >=> checkSnippet ] -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/like.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Like 2 | 3 | open Suave 4 | open FsSnip 5 | open FsSnip.Data 6 | open FsSnip.Utils 7 | open FsSnip.Snippet 8 | 9 | // ------------------------------------------------------------------------------------------------- 10 | // Incrementing the number of likes when called from the client-side 11 | // ------------------------------------------------------------------------------------------------- 12 | 13 | let likeSnippet id r = 14 | let id' = demangleId id 15 | match Seq.tryFind (fun s -> s.ID = id') snippets with 16 | | Some snippetInfo -> 17 | let newLikes = Data.likeSnippet id' r 18 | Successful.OK (newLikes.ToString()) 19 | | None -> showInvalidSnippet "Snippet not found" (sprintf "The snippet '%s' that you were looking for was not found." id) 20 | 21 | let webPart = 22 | pathWithId "/like/%s" (fun id -> likeSnippet id Latest) -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/recaptcha.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Recaptcha 2 | 3 | open Suave 4 | open System 5 | open FSharp.Data 6 | 7 | // ------------------------------------------------------------------------------------------------- 8 | // Helpers for reCAPTCHA validation 9 | // ------------------------------------------------------------------------------------------------- 10 | 11 | type RecaptchaResponse = JsonProvider<"""{"success":true}"""> 12 | 13 | /// reCAPTCHA secret 14 | let recaptchaSecret = Environment.GetEnvironmentVariable "RECAPTCHA_SECRET" 15 | let isRecaptchaDisabled = 16 | match Environment.GetEnvironmentVariable "DISABLE_RECAPTCHA" with 17 | | null -> false 18 | | value -> try Boolean.Parse value with _ -> false 19 | 20 | let ensureConfigured() = 21 | if not isRecaptchaDisabled && isNull recaptchaSecret then 22 | failwith "reCAPTCHA not configured properly." 23 | 24 | /// Validates that reCAPTCHA has been entered properly 25 | let validateRecaptcha form = async { 26 | do ensureConfigured() 27 | 28 | if isRecaptchaDisabled then return true else 29 | 30 | let formValue = form |> Seq.tryPick (fun (k, v) -> 31 | if k = "g-recaptcha-response" then v else None) 32 | let response = defaultArg formValue "" 33 | let! response = 34 | Http.AsyncRequestString 35 | ( "https://www.google.com/recaptcha/api/siteverify", httpMethod="POST", 36 | body=HttpRequestBody.FormValues ["secret", recaptchaSecret; "response", response]) 37 | return RecaptchaResponse.Parse(response).Success } 38 | 39 | /// Reports an reCAPTCHA validation error 40 | let recaptchaError = 41 | let details = 42 | "Sorry, we were not able to verify that you are a human! "+ 43 | "Consider turning on JavaScript or using a browser supported by reCAPTCHA." 44 | Error.reportError HTTP_400 "Human validation failed!" details -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/rss.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Rss 2 | 3 | open Suave 4 | open System 5 | open FsSnip.Utils 6 | open FsSnip.Data 7 | open FsSnip.Rssfeed 8 | open FsSnip.Filters 9 | open Suave.Operators 10 | open Suave.Filters 11 | 12 | // ------------------------------------------------------------------------------------------------- 13 | // Generates RSS feed - this is exposed as /pages/Rss too (for compatibility with old version) 14 | // ------------------------------------------------------------------------------------------------- 15 | 16 | let getRss = delay (fun () -> 17 | let rssOutput = 18 | publicSnippets 19 | |> Seq.sortBy (fun s -> DateTime.Now - s.Date) 20 | |> Seq.take 10 21 | |> Seq.map (fun s -> 22 | { Title = s.Title; 23 | Link = "http://fssnip.net/" + (formatId (s.ID)); 24 | PubDate = s.Date.ToString("R"); 25 | Author = s.Author; 26 | Description = s.Comment }) 27 | |> RssOutput "Recent F# snippets" "http://fssnip.net" "Provides links to all recently added public F# snippets." "en-US" 28 | Writers.setHeader "Content-Type" "application/rss+xml; charset=utf-8" >=> Successful.OK rssOutput) 29 | 30 | let webPart = 31 | ( path "/rss/" <|> path "/rss" <|> path "/pages/Rss" <|> path "/pages/Rss/" ) 32 | >=> getRss -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/search.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Search 2 | 3 | open Suave 4 | open System 5 | open System.Web 6 | open FsSnip.Utils 7 | open FsSnip.Data 8 | open Suave.Filters 9 | 10 | // ------------------------------------------------------------------------------------------------- 11 | // Search results page - domain model 12 | // ------------------------------------------------------------------------------------------------- 13 | 14 | type Results = 15 | { Query : string 16 | Count : int 17 | Results : Snippet list } 18 | 19 | // ------------------------------------------------------------------------------------------------- 20 | // Loading search results 21 | // ------------------------------------------------------------------------------------------------- 22 | 23 | let getResults (query:string) = 24 | publicSnippets 25 | |> Seq.filter (fun s -> 26 | [ s.Title ; s.Comment; s.Title.ToLowerInvariant() ; s.Comment.ToLowerInvariant() ] 27 | |> Seq.exists (fun x -> x.Contains(query) )) 28 | 29 | let showResults (query) = delay (fun () -> 30 | let decodedQuery = FsSnip.Filters.urlDecode query 31 | let results = getResults decodedQuery |> Seq.toList 32 | { Query = decodedQuery 33 | Results = results 34 | Count = (List.length results) } |> DotLiquid.page "search.html") 35 | 36 | let webPart = pathScan "/search/%s" showResults -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/snippet.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Snippet 2 | 3 | open FsSnip 4 | open FsSnip.Data 5 | open FsSnip.Utils 6 | open Suave 7 | open Suave.Operators 8 | open Suave.Filters 9 | 10 | // ------------------------------------------------------------------------------------------------- 11 | // Snippet details and raw view pages 12 | // ------------------------------------------------------------------------------------------------- 13 | 14 | type FormattedSnippet = 15 | { Html : string 16 | Details : Data.Snippet 17 | Revision : int 18 | Similar : seq } 19 | 20 | let showInvalidSnippet = Error.reportError HttpCode.HTTP_404 21 | 22 | let showSnippet id r = 23 | let id' = demangleId id 24 | match Seq.tryFind (fun s -> s.ID = id') snippets with 25 | | Some snippetInfo -> 26 | match Data.loadSnippet id r with 27 | | Some snippet -> 28 | let rev = match r with Latest -> snippetInfo.Versions - 1 | Revision r -> r 29 | let similar = publicSnippets |> Seq.takeShuffled (Seq.length publicSnippets) 4 30 | { Html = snippet 31 | Details = Data.snippets |> Seq.find (fun s -> s.ID = id') 32 | Revision = rev 33 | Similar = similar } 34 | |> DotLiquid.page "snippet.html" 35 | | None -> 36 | showInvalidSnippet "Requested snippet version not found" 37 | (sprintf "Can't find the version you are looking for. See the latest version instead!" id) 38 | | None -> 39 | showInvalidSnippet "Snippet not found" 40 | (sprintf "The snippet '%s' that you were looking for was not found." id) 41 | 42 | let showRawSnippet id r = 43 | match Data.loadRawSnippet id r with 44 | | Some s -> Writers.setMimeType "text/plain" >=> Successful.OK s 45 | | None -> showInvalidSnippet "Snippet not found" (sprintf "The snippet '%s' that you were looking for was not found." id) 46 | 47 | // Web part to be included in the top-level route specification 48 | let webPart = 49 | choose 50 | [ pathScan "/%s/%d" (fun (id, r) -> showSnippet id (Revision r)) 51 | pathWithId "/%s" (fun id -> showSnippet id Latest) 52 | pathScan "/%s/title/%s" (fun (id, title) -> showSnippet id Latest) 53 | pathScan "/raw/%s/%d" (fun (id, r) -> showRawSnippet id (Revision r)) 54 | pathWithId "/raw/%s" (fun id -> showRawSnippet id Latest) ] 55 | -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/tag.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Tag 2 | 3 | open Suave 4 | open System 5 | open System.Web 6 | open FsSnip.Utils 7 | open FsSnip.Data 8 | open Suave.Filters 9 | open Suave.Operators 10 | 11 | // ------------------------------------------------------------------------------------------------- 12 | // Tag page - domain model 13 | // ------------------------------------------------------------------------------------------------- 14 | 15 | type TagLink = 16 | { Text : string 17 | Link : string 18 | Size : int 19 | Count : int } 20 | 21 | type TagLinks = seq 22 | 23 | type TagModel = 24 | { Tag : string 25 | Snippets : seq } 26 | 27 | type AllTagsModel = 28 | { Taglinks: TagLinks} 29 | 30 | let getAllTags () = 31 | let links = 32 | publicSnippets 33 | |> Seq.collect (fun s -> s.Tags) 34 | |> Seq.countBy id 35 | |> Seq.sortBy (fun (_, c) -> -c) 36 | |> Seq.withSizeBy snd 37 | |> Seq.map (fun ((n,c),s) -> 38 | { Text = n; Size = 80 + s; Count = c; 39 | Link = System.Net.WebUtility.UrlEncode(n) }) 40 | { Taglinks = links } 41 | 42 | // ------------------------------------------------------------------------------------------------- 43 | // Suave web parts 44 | // ------------------------------------------------------------------------------------------------- 45 | 46 | // Loading tag page information (snippets by the given tag) 47 | let showSnippets (tag) = 48 | let t = System.Net.WebUtility.UrlDecode tag 49 | let hasTag s = Seq.exists (fun t' -> t.Equals(t', StringComparison.InvariantCultureIgnoreCase)) s.Tags 50 | let ss = Seq.filter hasTag publicSnippets 51 | DotLiquid.page "tag.html" { Tag = t; Snippets = ss } 52 | 53 | // Loading tag page information (all tags) 54 | let showAll = delay (fun () -> 55 | DotLiquid.page "tags.html" (getAllTags())) 56 | 57 | // Composed web part to be included in the top-level route 58 | let webPart = 59 | choose 60 | [ path "/tags/" >=> showAll 61 | pathScan "/tags/%s" showSnippets ] 62 | -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/test.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Test 2 | 3 | open System 4 | open Suave 5 | open FsSnip 6 | open FsSnip.Data 7 | open FsSnip.Utils 8 | open FsSnip.Snippet 9 | 10 | // ------------------------------------------------------------------------------------------------- 11 | // Displays a test page for running the snippet 12 | // ------------------------------------------------------------------------------------------------- 13 | 14 | type TestSnippet = 15 | { Encoded : string 16 | Details : Data.Snippet } 17 | 18 | /// Displays the update form when the user comes to the page for the first time 19 | let showForm snippetInfo source mangledId error = 20 | match source, Data.loadRawSnippet mangledId Latest with 21 | | Some snippet, _ 22 | | None, Some snippet -> 23 | { Encoded = Web.HttpUtility.HtmlAttributeEncode(snippet) 24 | Details = snippetInfo } 25 | |> DotLiquid.page "test.html" 26 | | None, None -> 27 | showInvalidSnippet "Snippet not found" 28 | (sprintf "The snippet '%s' that you were looking for was not found." mangledId) 29 | 30 | /// Generate the form (on the first visit) or insert snippet (on a subsequent visit) 31 | let testSnippet mangledId ctx = async { 32 | let id = demangleId mangledId 33 | match Seq.tryFind (fun s -> s.ID = id) snippets with 34 | | Some snippetInfo -> 35 | return! showForm snippetInfo None mangledId "" ctx 36 | | None -> 37 | let details = (sprintf "The snippet '%s' that you were looking for was not found." mangledId) 38 | return! showInvalidSnippet "Snippet not found" details ctx } 39 | 40 | let webPart = pathWithId "/%s/test" testSnippet 41 | -------------------------------------------------------------------------------- /src/FsSnip.Website/pages/update.fs: -------------------------------------------------------------------------------- 1 | module FsSnip.Pages.Update 2 | 3 | open System 4 | open Suave 5 | open FsSnip 6 | open FsSnip.Data 7 | open FsSnip.Utils 8 | open FsSnip.Snippet 9 | 10 | // ------------------------------------------------------------------------------------------------- 11 | // Updates an existing snippet with a new data (provided the passcode matches) 12 | // ------------------------------------------------------------------------------------------------- 13 | 14 | type RawSnippet = 15 | { Raw : string 16 | Details : Data.Snippet 17 | Revision : int 18 | Session: string 19 | Error : string } 20 | 21 | type UpdateForm = 22 | { Title : string 23 | Passcode : string option 24 | Description : string option 25 | Tags : string[] 26 | Author : string option 27 | Link : string 28 | Code : string 29 | NugetPkgs : string option 30 | Session : string } 31 | 32 | /// Displays the update form when the user comes to the page for the first time 33 | let showForm snippetInfo source mangledId error = 34 | match source, Data.loadRawSnippet mangledId Latest with 35 | | Some snippet, _ 36 | | None, Some snippet -> 37 | let rev = snippetInfo.Versions - 1 38 | { Raw = snippet 39 | Details = snippetInfo 40 | Revision = rev 41 | Session = Guid.NewGuid().ToString() 42 | Error = error } 43 | |> DotLiquid.page "update.html" 44 | | None, None -> 45 | showInvalidSnippet "Snippet not found" 46 | (sprintf "The snippet '%s' that you were looking for was not found." mangledId) 47 | 48 | 49 | /// Handle a post request with updated snippet data - update the DB or show error 50 | let handlePost (snippetInfo:Data.Snippet) ctx mangledId id = async { 51 | 52 | // Parse the snippet & format it 53 | let form = Utils.readForm ctx.request.form 54 | let! valid = Recaptcha.validateRecaptcha ctx.request.form 55 | if not valid then 56 | return! Recaptcha.recaptchaError ctx 57 | else 58 | 59 | let nugetReferences = Utils.parseNugetPackages form.NugetPkgs 60 | let doc = Parser.parseScript form.Session form.Code nugetReferences 61 | let html = Literate.writeHtmlToString "fs" true doc 62 | Parser.completeSession form.Session 63 | let newSnippetInfo = 64 | { ID = id; Title = form.Title; Comment = defaultArg form.Description ""; 65 | Author = defaultArg form.Author ""; Link = form.Link; Date = System.DateTime.UtcNow; 66 | Likes = 0; Private = snippetInfo.Private; Passcode = snippetInfo.Passcode; 67 | References = nugetReferences; Source = ""; Versions = snippetInfo.Versions + 1; 68 | Tags = form.Tags } 69 | 70 | // Check the password if there is one 71 | let existingPass = snippetInfo.Passcode 72 | let enteredPass = form.Passcode |> Option.bind tryGetHashedPasscode 73 | match existingPass, enteredPass with 74 | | p1, Some p2 when p1 <> p2 -> return! showForm newSnippetInfo (Some form.Code) mangledId "The entered password did not match!" ctx 75 | | p1, None when not (String.IsNullOrEmpty p1) -> return! showForm newSnippetInfo (Some form.Code) mangledId "This snippet is password-protected. Please enter a password!" ctx 76 | | p1, Some _ when String.IsNullOrEmpty p1 -> return! showForm newSnippetInfo (Some form.Code) mangledId "This snippet is not password-protected. Password is not needed!" ctx 77 | | _ -> 78 | 79 | // Check that snippet is private or has all required data 80 | match snippetInfo.Private, form, Array.isEmpty form.Tags with 81 | | false, { Description = Some _; Author = Some _ }, false 82 | | true, _, _ -> 83 | Data.insertSnippet newSnippetInfo form.Code html 84 | return! Redirection.FOUND ("/" + mangledId) ctx 85 | | _ -> 86 | return! showForm snippetInfo (Some form.Code) mangledId "Some of the inputs were not valid." ctx } 87 | 88 | 89 | /// Generate the form (on the first visit) or insert snippet (on a subsequent visit) 90 | let updateSnippet mangledId ctx = async { 91 | let id = demangleId mangledId 92 | match Seq.tryFind (fun s -> s.ID = id) snippets with 93 | | Some snippetInfo -> 94 | if ctx.request.form |> Seq.exists (function "submit", _ -> true | _ -> false) then 95 | return! handlePost snippetInfo ctx mangledId id 96 | else 97 | return! showForm snippetInfo None mangledId "" ctx 98 | | None -> 99 | let details = (sprintf "The snippet '%s' that you were looking for was not found." mangledId) 100 | return! showInvalidSnippet "Snippet not found" details ctx } 101 | 102 | let webPart = pathWithId "/%s/update" updateSnippet 103 | -------------------------------------------------------------------------------- /src/FsSnip.Website/paket.references: -------------------------------------------------------------------------------- 1 | Suave 2 | Suave.DotLiquid 3 | FSharp.Data 4 | DotLiquid 5 | FSharp.Formatting 6 | Paket.Core 7 | WindowsAzure.Storage -------------------------------------------------------------------------------- /src/FsSnip.Website/samples/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "snippets": [ 3 | { 4 | "id": 22, 5 | "title": "Filtering lists", 6 | "comment": "Two functions showing how to filter functional lists using the specified predicate. First version uses naive recursion and the second one is tail-recursive using the accumulator parameter.", 7 | "author": "Tomas Petricek", 8 | "link": "http://tomasp.net", 9 | "date": "2010-12-03T23:56:49.3730000", 10 | "likes": 56, 11 | "isPrivate": false, 12 | "passcode": "nada", 13 | "references": ["Nada"], 14 | "source": "http", 15 | "versions": 1, 16 | "displayTags": [ 17 | "list", 18 | "filter", 19 | "recursion", 20 | "pattern matching" 21 | ], 22 | "enteredTags": [ 23 | "list", 24 | "filter", 25 | "recursion", 26 | "pattern matching" 27 | ] 28 | }, 29 | { 30 | "id": 23, 31 | "title": "Projecting lists", 32 | "comment": "Three functions showing how to implement projection for functional lists. First version uses naive recursion and the second one is tail-recursive using the accumulator parameter. The third version extends this with continuation passing.", 33 | "author": "Tomas Petricek", 34 | "link": "http://tomasp.net", 35 | "date": "2010-12-04T00:11:55.0030000", 36 | "likes": 60, 37 | "isPrivate": false, 38 | "passcode": "nada", 39 | "references": [], 40 | "source": "http", 41 | "versions": 3, 42 | "displayTags": [ 43 | "list", 44 | "map", 45 | "recursion", 46 | "pattern matching", 47 | "continuation passing" 48 | ], 49 | "enteredTags": [ 50 | "list", 51 | "map", 52 | "recursion", 53 | "pattern matching", 54 | "continuation passing" 55 | ] 56 | } 57 | ] 58 | } -------------------------------------------------------------------------------- /templates/author.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | {{ model.Author }} | F# Snippets 4 | {% endblock %} 5 | 6 | {% block content %} 7 |
8 |

Snippets created by {{ model.Author }}

9 |
10 | 11 | {% include "list.html" items:model.Snippets %} 12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /templates/authors.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | All Authors | F# Snippets 4 | {% endblock %} 5 | 6 | {% block content %} 7 |
8 |

Snippets by Author

9 |
10 |
11 |
12 |

13 | {% for it in model.authors %} 14 | {{ it.Text }} ({{ it.Count }}) 15 | {% endfor %} 16 |

17 |
18 |
19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | {{ model.Title }} not found | F# Snippets 4 | {% endblock %} 5 | 6 | {% block customPageScripts %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |

{{ model.Title }}

11 |

{{ model.Details }}

12 | {% endblock %} -------------------------------------------------------------------------------- /templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | Home page | F# Snippets 4 | {% endblock %} 5 | 6 | {% block customPageScripts %} 7 | 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |

Recent snippets

13 |
14 | 15 |
16 |
17 |
    18 | {% for item in model.Recent limit:3 %} 19 | {% include "item.html" item:item %} 20 | {% endfor %} 21 |
22 |
23 |
24 |
    25 | {% for item in model.Recent limit:3 offset:3 %} 26 | {% include "item.html" item:item %} 27 | {% endfor %} 28 |
29 |
30 |
31 | 32 |
33 |

Popular snippets

34 |
35 | 36 |
37 |
38 |
    39 | {% for item in model.Popular limit:3 %} 40 | {% include "item.html" item:item %} 41 | {% endfor %} 42 |
43 |
44 |
45 |
    46 | {% for item in model.Popular limit:3 offset:3 %} 47 | {% include "item.html" item:item %} 48 | {% endfor %} 49 |
50 |
51 |
52 | 53 |
54 |
55 |

Snippets by tags

56 |

57 | {% for it in model.Tags %} 58 | {{ it.Text }} ({{ it.Count }}) 59 | {% endfor %} 60 |

61 |

View all...

62 |
63 |
64 | 65 |
66 |
67 |

Snippets by authors

68 |

69 | {% for it in model.authors %} 70 | {{ it.Text }} ({{ it.Count }}) 71 | {% endfor %} 72 |

73 |

View all...

74 |
75 |
76 | 77 |
78 |
79 |

80 | Database contains {{ model.total_count }} snippets out of which {{ model.public_count }} is public. 81 |

82 |
83 |
84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /templates/insert.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | Insert Snippet | F# Snippets 4 | {% endblock %} 5 | 6 | {% block customPageScripts %} 7 | 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 |
15 | 16 |
17 |
18 |
19 |
20 | 23 |
24 |
25 |
26 | Hidden snippets will not be listed on the page. Uncheck this for generally useful snippets. If you specify 'Passcode' only you'll be able to create a new version. 27 |
28 |
29 | 30 |
31 | 32 |
33 | 34 |
35 |
36 |
37 | 38 |
39 | 40 |
41 |
42 | 43 | 44 | 57 | 58 |
59 | 60 |
61 | 62 |
63 |
64 | 65 | 66 | 87 | 88 |
89 |
90 |
91 | 94 |
95 |
96 |
97 | If your snippet has external dependencies, enter the names of NuGet packages to reference, separated by a comma (#r directives are not required). 98 |
99 |
100 | 105 | 106 |
107 |
108 |
109 |
110 | 111 |
112 |
113 |
114 |
115 |
116 | 117 |
118 |
119 | 120 | 121 |
122 | 123 |
124 |
125 | {% endblock %} 126 | -------------------------------------------------------------------------------- /templates/item.html: -------------------------------------------------------------------------------- 1 |
  • 2 |

    {{ item.Title }}

    3 |

    {{ item.Comment }}

    4 | {{ item.Likes }} people like this
    5 | 6 |

    Posted: {{ item.Date | nice_date }} by 7 | {{ item.Author }} 8 |

  • 9 | -------------------------------------------------------------------------------- /templates/list.html: -------------------------------------------------------------------------------- 1 | {% block customPageScripts %} 2 | 3 | {% endblock %} 4 | 5 |
    6 |
    7 |
      8 | {% for item in items %} 9 | {% assign mod = forloop.index0 | modulo:2 %} 10 | {% if mod == 0 %} 11 | {% include "item.html" item:item %} 12 | {% endif %} 13 | {% endfor %} 14 |
    15 |
    16 |
    17 |
      18 | {% for item in items %} 19 | {% assign mod = forloop.index0 | modulo:2 %} 20 | {% if mod == 1 %} 21 | {% include "item.html" item:item %} 22 | {% endif %} 23 | {% endfor %} 24 |
    25 |
    26 |
    27 | -------------------------------------------------------------------------------- /templates/page.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | {% block head %} 8 | {% endblock %} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
    23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 |
    31 |
    32 | 42 |
    43 |
    44 |
    45 |
    46 |
    47 | {% block content %} {% endblock %} 48 |
    49 |
    50 |
    51 |
    52 |
    53 | 77 |
    78 |
    79 |
    80 | 81 | 82 |
    83 | 93 | 94 | 95 | 98 | 99 | 104 | 105 | 106 | 107 | 108 | 109 | {% block customPageScripts %} {% endblock %} 110 |
    111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /templates/search.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | Search: {{ model.Query | escape }} | F# Snippets 4 | {% endblock %} 5 | 6 | {% block customPageScripts %} 7 | 8 | {% endblock %} 9 | 10 | {% block content %} 11 |

    Search: {{ model.Query | escape }}

    12 |

    {{ model.Count }} result(s)

    13 | 14 |
    15 |
    16 |
      17 | {% for item in model.Results %} 18 | {% include "item.html" item:item %} 19 | {% endfor %} 20 |
    21 |
    22 |
    23 | {% endblock %} -------------------------------------------------------------------------------- /templates/snippet.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | {{ model.Details.Title }} | F# Snippets 4 | {% assign next=false %} 5 | 6 | {% endblock %} 7 | 8 | {% block customPageScripts %} 9 | 10 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 | 15 | 31 | 32 |
    33 |
    34 |

    35 | 36 | 37 |

    38 |

    39 | {{ model.Details.Likes }} people like it.
    40 | 41 |

    42 | 43 |

    {{ model.Details.Title }}

    44 |

    {{ model.Details.Comment }}

    45 | 46 | {{ model.Html }} 47 | 48 |
    49 | {% if model.Revision > 0 %} 50 | Previous Version 51 | {% endif %} 52 | {% capture rev %}{{ model.Revision | plus:1 }}{% endcapture %} 53 | {% if rev < model.Details.Versions %} 54 | Next Version 55 | {% endif %} 56 | 57 | 58 | Raw view 59 | Test code 60 | New version 61 |
    62 | 63 |
    64 |

    More information

    65 | 66 | 67 | 68 | 69 | 70 | 76 |
    Link:http://fssnip.net/{{ model.Details.Id | format_id }}
    Posted:{{ model.Details.Date | nice_date }}
    Author:{{ model.Details.Author }}
    Tags: 71 | {% assign next=false %} 72 | {% for tag in model.Details.Tags %}{% if next %}, {% endif %}{{ tag }} 74 | {% assign next=true %}{% endfor tag %} 75 |
    77 | 78 |
    79 |
    80 | {% endblock %} 81 | -------------------------------------------------------------------------------- /templates/tag.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | Tagged {{ model.Tag }} | F# Snippets 4 | {% endblock %} 5 | 6 | {% block content %} 7 |
    8 |

    Snippets tagged {{ model.Tag }}

    9 |
    10 | 11 | {% include "list.html" items:model.Snippets %} 12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /templates/tags.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | All Tags | F# Snippets 4 | {% endblock %} 5 | 6 | {% block content %} 7 |
    8 |

    Snippets by Tag

    9 |
    10 |
    11 |
    12 |

    13 | {% for it in model.TagLinks %} 14 | {{ it.Text }} ({{ it.Count }}) 15 | {% endfor %} 16 |

    17 |
    18 |
    19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /templates/test.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | Test Snippet | F# Snippets 4 | {% endblock %} 5 | 6 | {% block customPageScripts %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
    11 |
    12 | 13 |

    Test code '{{ model.Details.Title }}'

    14 | 15 | 40 |
    41 |
    42 |
    43 | {% endblock %} -------------------------------------------------------------------------------- /templates/update.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block head %} 3 | Update Snippet | F# Snippets 4 | {% endblock %} 5 | 6 | {% block customPageScripts %} 7 | 8 | 9 | 10 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 |
    15 |
    16 | 17 |

    Update snippet '{{ model.Details.Title }}'

    18 | 19 |
    20 |
    21 | 22 |
    23 | 24 |
    25 |
    26 | 27 | {% if model.Details.Passcode != "" -%} 28 |
    29 | 30 |
    31 | 32 |
    33 |
    34 | {% endif -%} 35 | 36 |
    37 |
    38 | 39 |
    40 | 41 |
    42 |
    43 |
    44 | 45 |
    46 | 47 |
    48 | 49 |
    50 |
    51 | 52 |
    53 |
    54 | 55 |
    56 | 61 |
    62 |
    63 |
    64 | 65 |
    66 | 67 |
    68 |
    69 |
    70 | 71 |
    72 | 73 |
    74 |
    75 |
    76 | 77 |
    78 |
    79 |
    80 | 83 |
    84 |
    85 |
    86 | If your snippet has external dependencies, enter the names of NuGet packages to reference, separated by a comma (#r directives are not required). 87 |
    88 |
    89 | 94 | 95 |
    96 |
    97 |
    98 |
    99 | {% if model.Error != '' %} 100 | 101 | {% endif %} 102 |
    103 |
    104 | 105 |
    106 |
    107 |
    108 |
    109 |
    110 | 111 |
    112 |
    113 | 114 | 115 | 116 |
    117 | 118 |
    119 |
    120 | {% endblock %} 121 | -------------------------------------------------------------------------------- /upload-blobs.fsx: -------------------------------------------------------------------------------- 1 | (* This script performs 3 steps: 2 | 1. Download the snippets data from the backup repo in GitHub, 3 | 2. Extract the zip file to a local folder and rename the folder to 'data', 4 | 3. Upload the data folder to blob storage (it compares the list of files present in the blob container 5 | with the list of files in the downloaded data folder and only uploads the difference. 6 | 7 | It should be OS-agnostic as it is meant to be run from a devops pipeline, but can be run locally as well. 8 | For that you will need 2 things that the script looks for in environment variables: 9 | 1) the backup data repo url, 2) the connection string for the target blob storage. 10 | There is a scipt (upload-blobs.cmd) that will set these env variables before running this script, 11 | but you'll have to edit the script to include those values first. *) 12 | 13 | #r "nuget: Azure.Storage.Blobs" 14 | 15 | open Azure.Storage.Blobs 16 | open System 17 | open System.IO 18 | 19 | let dataDumpSource = Environment.GetEnvironmentVariable("fssnip_data_url") 20 | let storageConnString = Environment.GetEnvironmentVariable("fssnip_storage_key") 21 | let dataFolderName = "data" 22 | let tempFile = $"{dataFolderName}.zip" 23 | let unzippedFolder = "fssnip-data-master" 24 | let dirSeparator = Path.DirectorySeparatorChar 25 | let altDirSeparator = Path.AltDirectorySeparatorChar 26 | let serviceClient = BlobServiceClient(storageConnString) 27 | let containerClient = serviceClient.GetBlobContainerClient(dataFolderName) 28 | 29 | let downloadDataAsync tempFile dataDumpSource = 30 | async { 31 | printfn "downloading data..." 32 | let client = new Net.Http.HttpClient() 33 | use! s = client.GetStreamAsync(dataDumpSource |> Uri) |> Async.AwaitTask 34 | use fs = new FileStream(tempFile, FileMode.CreateNew) 35 | do! s.CopyToAsync(fs) |> Async.AwaitTask 36 | fs.Close() 37 | } 38 | let extractDataAsync tempFile = 39 | async { 40 | printfn "extracting data..." 41 | Compression.ZipFile.ExtractToDirectory(tempFile, ".", true) 42 | Directory.Move(unzippedFolder, dataFolderName) 43 | } 44 | 45 | let uploadDataAsync (folderSeparator:char) (dataFolderName:string) (containerClient:BlobContainerClient) = 46 | async { 47 | let getMissingFileAsyncs () = 48 | let blobNameSet = 49 | containerClient.GetBlobs().AsPages() 50 | |> Seq.collect (fun page -> page.Values) 51 | |> Seq.filter (fun blob -> blob.Deleted |> not) 52 | // Throw index.json out of this set to make sure it is uploaded even if it exists already. 53 | |> Seq.filter (fun blob -> blob.Name <> "index.json") 54 | |> Seq.map (fun blob -> blob.Name) 55 | |> Set.ofSeq 56 | 57 | let fileNameSet = 58 | let fileNames = 59 | DirectoryInfo(dataFolderName).EnumerateFiles("*", SearchOption.AllDirectories) 60 | |> Seq.map (fun file -> file.FullName.Split($"{folderSeparator}{dataFolderName}{folderSeparator}").[1]) 61 | if OperatingSystem.IsWindows() 62 | then fileNames |> Seq.map (fun file -> file.Replace(dirSeparator, altDirSeparator)) 63 | else fileNames 64 | |> Set.ofSeq 65 | 66 | let difference = Set.difference fileNameSet blobNameSet 67 | 68 | difference 69 | |> fun fns -> printfn $"uploading {fns.Count} file(s)..."; fns 70 | |> Seq.map (fun fn -> 71 | let blobClient = BlobClient(storageConnString, dataFolderName, fn) 72 | let stream = File.OpenRead(Path.Combine [| dataFolderName; fn |]) :> Stream 73 | blobClient.UploadAsync(stream, true) |> Async.AwaitTask |> Async.Ignore 74 | ) 75 | 76 | let! _ = containerClient.CreateIfNotExistsAsync() |> Async.AwaitTask 77 | //let! containerExists = containerClient.ExistsAsync() |> Async.AwaitTask 78 | //let containerExists = containerExists.Value 79 | getMissingFileAsyncs() 80 | |> Async.Parallel 81 | |> Async.RunSynchronously 82 | |> ignore 83 | } 84 | 85 | // Preemptive dir deletes for local use, devops pipeline agent won't need them. 86 | if File.Exists($"{dataFolderName}.zip") then File.Delete($"{dataFolderName}.zip") 87 | if Directory.Exists(dataFolderName) then Directory.Delete(dataFolderName, true) 88 | if Directory.Exists(unzippedFolder) then Directory.Delete(unzippedFolder, true) 89 | 90 | [ 91 | downloadDataAsync tempFile dataDumpSource 92 | extractDataAsync tempFile 93 | uploadDataAsync dirSeparator dataFolderName containerClient 94 | ] 95 | |> Async.Sequential 96 | |> Async.RunSynchronously 97 | |> ignore 98 | -------------------------------------------------------------------------------- /web/content/app/codecheck.js: -------------------------------------------------------------------------------- 1 | /************************ Background checking & tag recommendation ********************************/ 2 | 3 | $(document).ready(function () { 4 | var timer; 5 | var previous = {code: "", packages: ""}; 6 | var errors = false; 7 | var tagRecommendationOn = true; 8 | 9 | $("select").on("change", function() { 10 | tagRecommendationOn = false; 11 | }); 12 | 13 | $('#submit').on('click', function () { 14 | if (errors && !confirm('Your snippet contains compilation errors. Are you sure you want to submit it?')) return false; 15 | }); 16 | 17 | $('#code, #nuget').on('change keyup paste', function (e) { 18 | clearTimeout(timer); 19 | timer = setTimeout(function (event) { 20 | var current = { 21 | code: $('#code').val(), 22 | packages: $('#nuget').val() 23 | } 24 | 25 | if ((current.code == previous.code && current.packages == previous.packages) || 26 | current.code == "") { 27 | return; 28 | } 29 | 30 | previous = current; 31 | var content = $("#insert-form").serialize(); 32 | $.ajax({ 33 | url: "/pages/insert/check", data:content, type: "POST" 34 | }).done(function (res) { 35 | // Select the recommended tags for the snippet 36 | if (tagRecommendationOn && res.tags.length > 0) { 37 | $("select").val(res.tags); 38 | $("select").trigger("chosen:updated"); 39 | } 40 | 41 | // Display the reported errors and warnings 42 | var container = $("#errors"); 43 | container.empty(); 44 | errors = res.errors.filter(function(err) { return err.error; }).length > 0; 45 | if (res.errors.length == 0) { 46 | var glyph = ""; 47 | container.append($("")); 49 | } 50 | else { 51 | res.errors.forEach(function (err) { 52 | var glyph = ""; 53 | container.append($("")); 56 | }); 57 | } 58 | }); 59 | }, 1000); 60 | }); 61 | }); 62 | 63 | /************************ Listing all tags and chosen initialization ******************************/ 64 | 65 | var chosenInitialized = false; 66 | 67 | function initChosen() { 68 | chosenInitialized = true; 69 | $("select").chosen({ 70 | create_option: true, 71 | persistent_create_option: true, 72 | skip_no_results: true 73 | }); 74 | $('select').trigger("chosen:updated"); 75 | } 76 | function switchHidden() { 77 | if ($('#hidden').is(':checked')) { 78 | $('.public-details').css('display', 'none'); 79 | } else { 80 | $('.public-details').css('display', ''); 81 | if (!chosenInitialized) initChosen(); 82 | } 83 | } 84 | 85 | $(document).ready(function() { 86 | $.ajax({ url: "/pages/insert/taglist" }).done(function (res) { 87 | res.forEach(function(tag) { 88 | $('select').append(''); 89 | }); 90 | $('select').trigger("chosen:updated"); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /web/content/app/copycontent.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | 3 | jQuery.fn.selectText = function(){ 4 | var doc = document; 5 | var element = this[0]; 6 | var range, selection; 7 | 8 | if (doc.body.createTextRange) { 9 | try 10 | { 11 | range = document.body.createTextRange(); 12 | range.moveToElementText(element); 13 | range.select(); 14 | } catch(e) { } 15 | } else if (window.getSelection) { 16 | selection = window.getSelection(); 17 | range = document.createRange(); 18 | range.selectNodeContents(element); 19 | selection.removeAllRanges(); 20 | selection.addRange(range); 21 | } 22 | }; 23 | 24 | $('#linkModal').on('show.bs.modal', function (e) { 25 | var dialog = $(this); 26 | var button = $(e.relatedTarget); // Button that triggered the modal 27 | var action = button.data('action'); 28 | var snippetId = button.data('snippetid'); 29 | 30 | if (action == 'show-link') { 31 | dialog.find('.modal-dialog').removeClass('modal-lg') 32 | dialog.find('.modal-title').text('Link to Snippet'); 33 | dialog.find('.modal-body-inner').html('
    ' + $(location).attr('href') + '
    '); 34 | $("#selectMe").selectText(); 35 | } else if (action == 'show-source') { 36 | dialog.find('.modal-dialog').addClass('modal-lg'); 37 | dialog.find('.modal-title').text('Snippet Source'); 38 | var snippetSourceUrl = document.location.href.replace(document.location.pathname, '/raw/') + snippetId; 39 | 40 | $.get(snippetSourceUrl, function (data) { 41 | dialog.find('.modal-body-inner') 42 | .html('
    ' + $('
    ').text(data).html() + '
    ') 43 | .css('max-height', $(window).height() * 0.7) 44 | .css('overflow-y', 'auto'); 45 | $("#selectMe").selectText(); 46 | }); 47 | } 48 | 49 | var dismissDialogHandler = function () { 50 | dialog.modal('hide'); 51 | }; 52 | 53 | $(document).keyup(dismissDialogHandler); 54 | dialog.on('hidden.bs.modal', function() { 55 | $(document).off("keyup", dismissDialogHandler); 56 | }); 57 | }); 58 | 59 | }); 60 | -------------------------------------------------------------------------------- /web/content/app/likes.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | $('.likeLink').click(function (e) { 3 | addToLikeCount($(this).data('snippetid'), $(this)); 4 | }); 5 | }); 6 | 7 | function addToLikeCount(id, elem) { 8 | elem.text('Liking...'); 9 | $.ajax({ 10 | url: "/like/"+id, type: "POST" 11 | }).done(function (res) { 12 | //remove likes link, update counter 13 | $('.likeCount', elem.parent()).text(res); 14 | elem.remove(); 15 | }) 16 | .fail(function (res) { 17 | elem.text('Could not like this, please try again'); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /web/content/app/search.js: -------------------------------------------------------------------------------- 1 | function search() { 2 | var searchBox = $('#searchbox')[0]; 3 | window.location.href = '/search/' + searchBox.value; 4 | return false; 5 | } 6 | 7 | $(document).ready(function () { 8 | $('#searchForm').submit(search); 9 | $('#searchbutton').click(search); 10 | }); 11 | -------------------------------------------------------------------------------- /web/content/app/snippetvalidation.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | 3 | function isRequired(element){ 4 | var hid = $('#hidden'); 5 | // On insert page, #hidden is checkbox 6 | if (hid.attr("type") == "checkbox") return !(hid.is(":checked")); 7 | // On update page, #hidden is 'hidden' with bool value 8 | else return !($("#hidden").val() == "true"); 9 | } 10 | 11 | $('#insert-form').validate({ 12 | rules: { 13 | title: "required", 14 | description:{ 15 | required:isRequired 16 | }, 17 | author:{ 18 | required:isRequired 19 | }, 20 | tags:{ 21 | required:isRequired 22 | }, 23 | code:"required" 24 | }, 25 | messages: { 26 | title: "Please enter the title", 27 | description: "Please enter the description", 28 | author: "Please enter the author", 29 | tags: "Please enter at least one tag", 30 | code: "Please enter the code of the snippet" 31 | } 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /web/content/app/tips.js: -------------------------------------------------------------------------------- 1 | var currentTip = null; 2 | var currentTipElement = null; 3 | 4 | function hideTip(evt, name, unique) { 5 | var el = document.getElementById(name); 6 | el.style.display = "none"; 7 | currentTip = null; 8 | } 9 | 10 | function findPos(obj) { 11 | var curleft = 0; 12 | var curtop = obj.offsetHeight; 13 | while (obj) { 14 | var pos = $(obj).css("position"); 15 | if (pos == "relative" || pos == "absolute") break; 16 | curleft += obj.offsetLeft; 17 | curtop += obj.offsetTop; 18 | obj = obj.offsetParent; 19 | }; 20 | return [curleft, curtop]; 21 | } 22 | 23 | function hideUsingEsc(e) { 24 | if (!e) { e = event; } 25 | hideTip(e, currentTipElement, currentTip); 26 | } 27 | 28 | function showTip(evt, name, unique, owner) { 29 | document.onkeydown = hideUsingEsc; 30 | if (currentTip == unique) return; 31 | currentTip = unique; 32 | currentTipElement = name; 33 | 34 | var pos = findPos(owner ? owner : (evt.srcElement ? evt.srcElement : evt.target)); 35 | var posx = pos[0]; 36 | var posy = pos[1]; 37 | 38 | var el = document.getElementById(name); 39 | var parent = (document.documentElement == null) ? document.body : document.documentElement; 40 | el.style.position = "absolute"; 41 | el.style.left = posx + "px"; 42 | el.style.top = posy + "px"; 43 | el.style.display = "block"; 44 | } -------------------------------------------------------------------------------- /web/content/snippets.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Droid+Sans|Droid+Sans+Mono); 2 | 3 | /*-------------------------------------------------------------------------- 4 | Formatting for F# code snippets 5 | /*--------------------------------------------------------------------------*/ 6 | 7 | /* strings --- and stlyes for other string related formats */ 8 | span.s { color:#E0E268; } 9 | /* printf formatters */ 10 | span.pf { color:#E0C57F; } 11 | /* escaped chars */ 12 | span.e { color:#EA8675; } 13 | 14 | /* identifiers --- and styles for more specific identifier types */ 15 | span.i { color:#d1d1d1; } 16 | /* type or module */ 17 | span.t { color:#43AEC6; } 18 | /* function */ 19 | span.f { color:#e1e1e1; } 20 | /* DU case or active pattern */ 21 | span.p { color:#4ec9b0; } 22 | 23 | /* keywords */ 24 | span.k { color:#FAB11D; } 25 | /* comment */ 26 | span.c { color:#808080; } 27 | /* operators */ 28 | span.o { color:#af75c1; } 29 | /* numbers */ 30 | span.n { color:#96C71D; } 31 | /* line number */ 32 | span.l { color:#80b0b0; } 33 | /* mutable var or ref cell */ 34 | span.v { color:#d1d1d1; font-weight: bold; } 35 | /* inactive code */ 36 | span.inactive { color:#808080; } 37 | /* preprocessor */ 38 | span.prep { color:#af75c1; } 39 | /* fsi output */ 40 | span.fsi { color:#808080; } 41 | 42 | /* omitted */ 43 | span.omitted { 44 | background:#3c4e52; 45 | border-radius:5px; 46 | color:#808080; 47 | padding:0px 0px 1px 0px; 48 | } 49 | /* tool tip */ 50 | div.tip { 51 | background:#475b5f; 52 | border-radius:4px; 53 | font:11pt 'Droid Sans', arial, sans-serif; 54 | padding:6px 8px 6px 8px; 55 | display:none; 56 | color:#d1d1d1; 57 | z-index:1; 58 | pointer-events:none; 59 | } 60 | /* this is a quick fix to html files now being generated with the tooltip class under this name. */ 61 | div.fsdocs-tip { 62 | background: #475b5f; 63 | border-radius: 4px; 64 | font: 11pt 'Droid Sans', arial, sans-serif; 65 | padding: 6px 8px 6px 8px; 66 | display: none; 67 | color: #d1d1d1; 68 | z-index: 1; 69 | pointer-events: none; 70 | } 71 | table.pre pre { 72 | padding:0px; 73 | margin:0px; 74 | border:none; 75 | } 76 | table.pre, pre.fssnip, pre { 77 | line-height:13pt; 78 | border:1px solid #d8d8d8; 79 | border-collapse:separate; 80 | white-space:pre; 81 | font: 9pt 'Droid Sans Mono',consolas,monospace; 82 | width:90%; 83 | margin:10px 20px 20px 20px; 84 | background-color:#212d30; 85 | padding:10px; 86 | border-radius:5px; 87 | color:#d1d1d1; 88 | } 89 | pre.fssnip code { 90 | font: 9pt 'Droid Sans Mono',consolas,monospace; 91 | } 92 | table.pre pre { 93 | padding:0px; 94 | margin:0px; 95 | border-radius:0px; 96 | width: 100%; 97 | } 98 | table.pre td { 99 | padding:0px; 100 | white-space:normal; 101 | margin:0px; 102 | } 103 | table.pre td.lines { 104 | width:30px; 105 | } 106 | 107 | pre { 108 | word-wrap: inherit; 109 | } 110 | -------------------------------------------------------------------------------- /web/content/style.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Open+Sans:400,600,700); 2 | 3 | /******************************* General page formatting ******************************************/ 4 | 5 | body { 6 | font-family:'Open Sans'; 7 | background-color:#E0E0E0; 8 | } 9 | 10 | .main { 11 | padding-bottom:40px; 12 | } 13 | .content { 14 | background:white; 15 | } 16 | 17 | body { 18 | font-family: 'Open Sans', serif; 19 | padding-top: 0px; 20 | padding-bottom: 40px; 21 | } 22 | 23 | a, a:visited, a:link, a:hover { 24 | color:#5A1345; 25 | text-decoration:underline; 26 | } 27 | 28 | h2 .anchor { 29 | color:black; 30 | font-size:22px; 31 | text-decoration:none; 32 | } 33 | 34 | /*********************************** Styles for the home page *************************************/ 35 | 36 | .brief-listing { 37 | text-align:center; 38 | } 39 | .brief-all { 40 | text-align:right; 41 | } 42 | 43 | /*********************************** Styles for snippet details ***********************************/ 44 | 45 | .modal-backdrop.in { 46 | z-index: auto; 47 | } 48 | 49 | .snip-tweet { 50 | float:right; 51 | padding-top:5px; 52 | margin:20px 30px 0px 0px; 53 | } 54 | 55 | .snip-like { 56 | float:right; 57 | font-size:80%; 58 | margin:20px 30px 0px 0px; 59 | } 60 | 61 | #snip-tools { 62 | float:right; 63 | margin-right:8%; 64 | } 65 | 66 | #snip-tools a { 67 | text-decoration:none; 68 | } 69 | 70 | .detail-table { 71 | margin-left:30px; 72 | } 73 | 74 | .detail-lbl { 75 | font-weight:bold; 76 | width:80px; 77 | } 78 | 79 | /*********************************** Insert form **************************************************/ 80 | 81 | #insert-form { 82 | margin:30px 40px 0px 0px; 83 | } 84 | 85 | #errors .alert { 86 | margin:0px 0px 4px 0px; 87 | padding:4px 10px 4px 10px; 88 | } 89 | #errors .glyphicon { 90 | float:left; 91 | } 92 | #errors .loc { 93 | margin-left:15px; 94 | display:block; 95 | float:left; 96 | } 97 | #errors .msg { 98 | display:block; 99 | margin-left:70px; 100 | } 101 | .g-recaptcha { 102 | float:right; 103 | margin:0px 0px 20px 0px; 104 | } 105 | .submit-group { 106 | text-align: right; 107 | } 108 | 109 | /*********************************** Headers and footers ******************************************/ 110 | 111 | #heading { 112 | background:#693F6A; 113 | padding:10px 30px 10px 10px; 114 | text-align:right; 115 | } 116 | #links { 117 | background:#390F3A; 118 | padding:2px 0px 2px 10px; 119 | } 120 | #links a:hover, #links a:visited, #links a:link { 121 | font-weight:bold; 122 | text-decoration:none; 123 | color:white; 124 | padding-right:10px; 125 | } 126 | 127 | #search { 128 | width: 170px; 129 | float: right; 130 | } 131 | 132 | #searchbox { 133 | height: 24px; 134 | width: 140px; 135 | border-width:0px; 136 | color:white; 137 | font-weight:bold; 138 | background:#390F3A; 139 | } 140 | 141 | .footer { 142 | background:#693F6A; 143 | } 144 | 145 | .footer .col-sm-4 p, .footer .col-sm-4 ul { 146 | color:white; 147 | font-size:80%; 148 | padding:20px; 149 | } 150 | 151 | .footer a, .footer a:visited, .footer a:link, .footer a:hover { 152 | color:white; 153 | text-decoration:underline; 154 | } 155 | 156 | /******************************* Form validation errors *******************************************/ 157 | 158 | label.error{ 159 | color:red; 160 | font-weight:normal; 161 | } 162 | 163 | input.error{ 164 | border:1px dotted red; 165 | } -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/favicon.ico -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /web/img/fswebsnippets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/img/fswebsnippets.png -------------------------------------------------------------------------------- /web/lib/chosen-sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/lib/chosen-sprite.png -------------------------------------------------------------------------------- /web/lib/chosen-sprite@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fssnippets/fssnip-website/5d953f2a0b7ca108a199b61897c1ce7d5981cf62/web/lib/chosen-sprite@2x.png -------------------------------------------------------------------------------- /web/lib/chosen.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Chosen, a Select Box Enhancer for jQuery and Prototype 3 | by Patrick Filler for Harvest, http://getharvest.com 4 | 5 | Version 1.4.2 6 | Full source at https://github.com/harvesthq/chosen 7 | Copyright (c) 2011-2015 Harvest http://getharvest.com 8 | 9 | MIT License, https://github.com/harvesthq/chosen/blob/master/LICENSE.md 10 | This file is generated by `grunt build`, do not edit it by hand. 11 | */ 12 | 13 | /* @group Base */ 14 | .chosen-container { 15 | position: relative; 16 | display: inline-block; 17 | vertical-align: middle; 18 | font-size: 14px; 19 | zoom: 1; 20 | *display: inline; 21 | -webkit-user-select: none; 22 | -moz-user-select: none; 23 | user-select: none; 24 | width:100%; 25 | } 26 | .chosen-container * { 27 | -webkit-box-sizing: border-box; 28 | -moz-box-sizing: border-box; 29 | box-sizing: border-box; 30 | } 31 | .chosen-container .chosen-drop { 32 | position: absolute; 33 | top: 100%; 34 | left: -9999px; 35 | z-index: 1010; 36 | width: 100%; 37 | border: 1px solid #aaa; 38 | border-top: 0; 39 | background: #fff; 40 | box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); 41 | } 42 | .chosen-container.chosen-with-drop .chosen-drop { 43 | left: 0; 44 | } 45 | .chosen-container a { 46 | cursor: pointer; 47 | } 48 | .chosen-container .search-choice .group-name, .chosen-container .chosen-single .group-name { 49 | margin-right: 4px; 50 | overflow: hidden; 51 | white-space: nowrap; 52 | text-overflow: ellipsis; 53 | font-weight: normal; 54 | color: #999999; 55 | } 56 | .chosen-container .search-choice .group-name:after, .chosen-container .chosen-single .group-name:after { 57 | content: ":"; 58 | padding-left: 2px; 59 | vertical-align: top; 60 | } 61 | 62 | /* @end */ 63 | /* @group Single Chosen */ 64 | .chosen-container-single .chosen-single { 65 | position: relative; 66 | display: block; 67 | overflow: hidden; 68 | padding: 0 0 0 8px; 69 | height: 25px; 70 | border: 1px solid #aaa; 71 | border-radius: 5px; 72 | background-color: #fff; 73 | background: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(20%, #ffffff), color-stop(50%, #f6f6f6), color-stop(52%, #eeeeee), color-stop(100%, #f4f4f4)); 74 | background: -webkit-linear-gradient(#ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); 75 | background: -moz-linear-gradient(#ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); 76 | background: -o-linear-gradient(#ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); 77 | background: linear-gradient(#ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); 78 | background-clip: padding-box; 79 | box-shadow: 0 0 3px white inset, 0 1px 1px rgba(0, 0, 0, 0.1); 80 | color: #444; 81 | text-decoration: none; 82 | white-space: nowrap; 83 | line-height: 24px; 84 | } 85 | .chosen-container-single .chosen-default { 86 | color: #999; 87 | } 88 | .chosen-container-single .chosen-single span { 89 | display: block; 90 | overflow: hidden; 91 | margin-right: 26px; 92 | text-overflow: ellipsis; 93 | white-space: nowrap; 94 | } 95 | .chosen-container-single .chosen-single-with-deselect span { 96 | margin-right: 38px; 97 | } 98 | .chosen-container-single .chosen-single abbr { 99 | position: absolute; 100 | top: 6px; 101 | right: 26px; 102 | display: block; 103 | width: 12px; 104 | height: 12px; 105 | background: url('chosen-sprite.png') -42px 1px no-repeat; 106 | font-size: 1px; 107 | } 108 | .chosen-container-single .chosen-single abbr:hover { 109 | background-position: -42px -10px; 110 | } 111 | .chosen-container-single.chosen-disabled .chosen-single abbr:hover { 112 | background-position: -42px -10px; 113 | } 114 | .chosen-container-single .chosen-single div { 115 | position: absolute; 116 | top: 0; 117 | right: 0; 118 | display: block; 119 | width: 18px; 120 | height: 100%; 121 | } 122 | .chosen-container-single .chosen-single div b { 123 | display: block; 124 | width: 100%; 125 | height: 100%; 126 | background: url('chosen-sprite.png') no-repeat 0px 2px; 127 | } 128 | .chosen-container-single .chosen-search { 129 | position: relative; 130 | z-index: 1010; 131 | margin: 0; 132 | padding: 3px 4px; 133 | white-space: nowrap; 134 | } 135 | .chosen-container-single .chosen-search input[type="text"] { 136 | margin: 1px 0; 137 | padding: 4px 20px 4px 5px; 138 | width: 100%; 139 | height: auto; 140 | outline: 0; 141 | border: 1px solid #aaa; 142 | background: white url('chosen-sprite.png') no-repeat 100% -20px; 143 | background: url('chosen-sprite.png') no-repeat 100% -20px; 144 | font-size: 1em; 145 | font-family: sans-serif; 146 | line-height: normal; 147 | border-radius: 0; 148 | } 149 | .chosen-container-single .chosen-drop { 150 | margin-top: -1px; 151 | border-radius: 0 0 4px 4px; 152 | background-clip: padding-box; 153 | } 154 | .chosen-container-single.chosen-container-single-nosearch .chosen-search { 155 | position: absolute; 156 | left: -9999px; 157 | } 158 | 159 | /* @end */ 160 | /* @group Results */ 161 | .chosen-container .chosen-results { 162 | color: #444; 163 | position: relative; 164 | overflow-x: hidden; 165 | overflow-y: auto; 166 | margin: 0 4px 4px 0; 167 | padding: 0 0 0 4px; 168 | max-height: 240px; 169 | -webkit-overflow-scrolling: touch; 170 | } 171 | .chosen-container .chosen-results li { 172 | display: none; 173 | margin: 0; 174 | padding: 5px 6px; 175 | list-style: none; 176 | line-height: 15px; 177 | word-wrap: break-word; 178 | -webkit-touch-callout: none; 179 | } 180 | .chosen-container .chosen-results li.active-result { 181 | display: list-item; 182 | cursor: pointer; 183 | } 184 | .chosen-container .chosen-results li.disabled-result { 185 | display: list-item; 186 | color: #ccc; 187 | cursor: default; 188 | } 189 | .chosen-container .chosen-results li.highlighted { 190 | background-color: #3875d7; 191 | background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(20%, #3875d7), color-stop(90%, #2a62bc)); 192 | background-image: -webkit-linear-gradient(#3875d7 20%, #2a62bc 90%); 193 | background-image: -moz-linear-gradient(#3875d7 20%, #2a62bc 90%); 194 | background-image: -o-linear-gradient(#3875d7 20%, #2a62bc 90%); 195 | background-image: linear-gradient(#3875d7 20%, #2a62bc 90%); 196 | color: #fff; 197 | } 198 | .chosen-container .chosen-results li.no-results { 199 | color: #777; 200 | display: list-item; 201 | background: #f4f4f4; 202 | } 203 | .chosen-container .chosen-results li.group-result { 204 | display: list-item; 205 | font-weight: bold; 206 | cursor: default; 207 | } 208 | .chosen-container .chosen-results li.group-option { 209 | padding-left: 15px; 210 | } 211 | .chosen-container .chosen-results li em { 212 | font-style: normal; 213 | text-decoration: underline; 214 | } 215 | 216 | /* @end */ 217 | /* @group Multi Chosen */ 218 | .chosen-container-multi .chosen-choices { 219 | position: relative; 220 | overflow: hidden; 221 | margin: 0; 222 | padding: 2px 5px 2px 10px; 223 | width: 100%; 224 | height: auto !important; 225 | height: 1%; 226 | border: 1px solid #aaa; 227 | background-color: #fff; 228 | background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff)); 229 | background-image: -webkit-linear-gradient(#eeeeee 1%, #ffffff 15%); 230 | background-image: -moz-linear-gradient(#eeeeee 1%, #ffffff 15%); 231 | background-image: -o-linear-gradient(#eeeeee 1%, #ffffff 15%); 232 | background-image: linear-gradient(#eeeeee 1%, #ffffff 15%); 233 | cursor: text; 234 | } 235 | .chosen-container-multi .chosen-choices li { 236 | float: left; 237 | list-style: none; 238 | } 239 | .chosen-container-multi .chosen-choices li.search-field { 240 | margin: 0; 241 | padding: 0; 242 | white-space: nowrap; 243 | } 244 | .chosen-container-multi .chosen-choices li.search-field input[type="text"] { 245 | margin: 1px 0; 246 | padding: 0; 247 | height: 25px; 248 | outline: 0; 249 | border: 0 !important; 250 | background: transparent !important; 251 | box-shadow: none; 252 | color: #999; 253 | font-size: 100%; 254 | font-family: 'open sans', sans-serif; 255 | line-height: normal; 256 | border-radius: 0; 257 | } 258 | .chosen-container-multi .chosen-choices li.search-choice { 259 | position: relative; 260 | margin: 3px 5px 3px 0; 261 | padding: 3px 20px 3px 5px; 262 | border: 1px solid #aaa; 263 | max-width: 100%; 264 | border-radius: 3px; 265 | background-color: #eeeeee; 266 | background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee)); 267 | background-image: -webkit-linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 268 | background-image: -moz-linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 269 | background-image: -o-linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 270 | background-image: linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 271 | background-size: 100% 19px; 272 | background-repeat: repeat-x; 273 | background-clip: padding-box; 274 | box-shadow: 0 0 2px white inset, 0 1px 0 rgba(0, 0, 0, 0.05); 275 | color: #333; 276 | line-height: 13px; 277 | cursor: default; 278 | } 279 | .chosen-container-multi .chosen-choices li.search-choice span { 280 | word-wrap: break-word; 281 | } 282 | .chosen-container-multi .chosen-choices li.search-choice .search-choice-close { 283 | position: absolute; 284 | top: 4px; 285 | right: 3px; 286 | display: block; 287 | width: 12px; 288 | height: 12px; 289 | background: url('chosen-sprite.png') -42px 1px no-repeat; 290 | font-size: 1px; 291 | } 292 | .chosen-container-multi .chosen-choices li.search-choice .search-choice-close:hover { 293 | background-position: -42px -10px; 294 | } 295 | .chosen-container-multi .chosen-choices li.search-choice-disabled { 296 | padding-right: 5px; 297 | border: 1px solid #ccc; 298 | background-color: #e4e4e4; 299 | background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee)); 300 | background-image: -webkit-linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 301 | background-image: -moz-linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 302 | background-image: -o-linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 303 | background-image: linear-gradient(#f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); 304 | color: #666; 305 | } 306 | .chosen-container-multi .chosen-choices li.search-choice-focus { 307 | background: #d4d4d4; 308 | } 309 | .chosen-container-multi .chosen-choices li.search-choice-focus .search-choice-close { 310 | background-position: -42px -10px; 311 | } 312 | .chosen-container-multi .chosen-results { 313 | margin: 0; 314 | padding: 0; 315 | } 316 | .chosen-container-multi .chosen-drop .result-selected { 317 | display: list-item; 318 | color: #ccc; 319 | cursor: default; 320 | } 321 | 322 | /* @end */ 323 | /* @group Active */ 324 | .chosen-container-active .chosen-single { 325 | border: 1px solid #5897fb; 326 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); 327 | } 328 | .chosen-container-active.chosen-with-drop .chosen-single { 329 | border: 1px solid #aaa; 330 | -moz-border-radius-bottomright: 0; 331 | border-bottom-right-radius: 0; 332 | -moz-border-radius-bottomleft: 0; 333 | border-bottom-left-radius: 0; 334 | background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(20%, #eeeeee), color-stop(80%, #ffffff)); 335 | background-image: -webkit-linear-gradient(#eeeeee 20%, #ffffff 80%); 336 | background-image: -moz-linear-gradient(#eeeeee 20%, #ffffff 80%); 337 | background-image: -o-linear-gradient(#eeeeee 20%, #ffffff 80%); 338 | background-image: linear-gradient(#eeeeee 20%, #ffffff 80%); 339 | box-shadow: 0 1px 0 #fff inset; 340 | } 341 | .chosen-container-active.chosen-with-drop .chosen-single div { 342 | border-left: none; 343 | background: transparent; 344 | } 345 | .chosen-container-active.chosen-with-drop .chosen-single div b { 346 | background-position: -18px 2px; 347 | } 348 | .chosen-container-active .chosen-choices { 349 | border: 1px solid #5897fb; 350 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); 351 | } 352 | .chosen-container-active .chosen-choices li.search-field input[type="text"] { 353 | color: #222 !important; 354 | } 355 | 356 | /* @end */ 357 | /* @group Disabled Support */ 358 | .chosen-disabled { 359 | opacity: 0.5 !important; 360 | cursor: default; 361 | } 362 | .chosen-disabled .chosen-single { 363 | cursor: default; 364 | } 365 | .chosen-disabled .chosen-choices .search-choice .search-choice-close { 366 | cursor: default; 367 | } 368 | 369 | /* @end */ 370 | /* @group Right to Left */ 371 | .chosen-rtl { 372 | text-align: right; 373 | } 374 | .chosen-rtl .chosen-single { 375 | overflow: visible; 376 | padding: 0 8px 0 0; 377 | } 378 | .chosen-rtl .chosen-single span { 379 | margin-right: 0; 380 | margin-left: 26px; 381 | direction: rtl; 382 | } 383 | .chosen-rtl .chosen-single-with-deselect span { 384 | margin-left: 38px; 385 | } 386 | .chosen-rtl .chosen-single div { 387 | right: auto; 388 | left: 3px; 389 | } 390 | .chosen-rtl .chosen-single abbr { 391 | right: auto; 392 | left: 26px; 393 | } 394 | .chosen-rtl .chosen-choices li { 395 | float: right; 396 | } 397 | .chosen-rtl .chosen-choices li.search-field input[type="text"] { 398 | direction: rtl; 399 | } 400 | .chosen-rtl .chosen-choices li.search-choice { 401 | margin: 3px 5px 3px 0; 402 | padding: 3px 5px 3px 19px; 403 | } 404 | .chosen-rtl .chosen-choices li.search-choice .search-choice-close { 405 | right: auto; 406 | left: 4px; 407 | } 408 | .chosen-rtl.chosen-container-single-nosearch .chosen-search, 409 | .chosen-rtl .chosen-drop { 410 | left: 9999px; 411 | } 412 | .chosen-rtl.chosen-container-single .chosen-results { 413 | margin: 0 0 4px 4px; 414 | padding: 0 4px 0 0; 415 | } 416 | .chosen-rtl .chosen-results li.group-option { 417 | padding-right: 15px; 418 | padding-left: 0; 419 | } 420 | .chosen-rtl.chosen-container-active.chosen-with-drop .chosen-single div { 421 | border-right: none; 422 | } 423 | .chosen-rtl .chosen-search input[type="text"] { 424 | padding: 4px 5px 4px 20px; 425 | background: white url('chosen-sprite.png') no-repeat -30px -20px; 426 | background: url('chosen-sprite.png') no-repeat -30px -20px; 427 | direction: rtl; 428 | } 429 | .chosen-rtl.chosen-container-single .chosen-single div b { 430 | background-position: 6px 2px; 431 | } 432 | .chosen-rtl.chosen-container-single.chosen-with-drop .chosen-single div b { 433 | background-position: -12px 2px; 434 | } 435 | 436 | /* @end */ 437 | /* @group Retina compatibility */ 438 | @media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (min-resolution: 144dpi), only screen and (min-resolution: 1.5dppx) { 439 | .chosen-rtl .chosen-search input[type="text"], 440 | .chosen-container-single .chosen-single abbr, 441 | .chosen-container-single .chosen-single div b, 442 | .chosen-container-single .chosen-search input[type="text"], 443 | .chosen-container-multi .chosen-choices .search-choice .search-choice-close, 444 | .chosen-container .chosen-results-scroll-down span, 445 | .chosen-container .chosen-results-scroll-up span { 446 | background-image: url('chosen-sprite@2x.png') !important; 447 | background-size: 52px 37px !important; 448 | background-repeat: no-repeat !important; 449 | } 450 | } 451 | /* @end */ 452 | -------------------------------------------------------------------------------- /web/lib/chosen.jquery.min.js: -------------------------------------------------------------------------------- 1 | /* Chosen v1.4.2 | (c) 2011-2015 by Harvest | MIT License, https://github.com/harvesthq/chosen/blob/master/LICENSE.md */ 2 | (function(){var a,AbstractChosen,Chosen,SelectParser,b,c={}.hasOwnProperty,d=function(a,b){function d(){this.constructor=a}for(var e in b)c.call(b,e)&&(a[e]=b[e]);return d.prototype=b.prototype,a.prototype=new d,a.__super__=b.prototype,a};SelectParser=function(){function SelectParser(){this.options_index=0,this.parsed=[]}return SelectParser.prototype.add_node=function(a){return"OPTGROUP"===a.nodeName.toUpperCase()?this.add_group(a):this.add_option(a)},SelectParser.prototype.add_group=function(a){var b,c,d,e,f,g;for(b=this.parsed.length,this.parsed.push({array_index:b,group:!0,label:this.escapeExpression(a.label),title:a.title?a.title:void 0,children:0,disabled:a.disabled,classes:a.className}),f=a.childNodes,g=[],d=0,e=f.length;e>d;d++)c=f[d],g.push(this.add_option(c,b,a.disabled));return g},SelectParser.prototype.add_option=function(a,b,c){return"OPTION"===a.nodeName.toUpperCase()?(""!==a.text?(null!=b&&(this.parsed[b].children+=1),this.parsed.push({array_index:this.parsed.length,options_index:this.options_index,value:a.value,text:a.text,html:a.innerHTML,title:a.title?a.title:void 0,selected:a.selected,disabled:c===!0?c:a.disabled,group_array_index:b,group_label:null!=b?this.parsed[b].label:null,classes:a.className,style:a.style.cssText})):this.parsed.push({array_index:this.parsed.length,options_index:this.options_index,empty:!0}),this.options_index+=1):void 0},SelectParser.prototype.escapeExpression=function(a){var b,c;return null==a||a===!1?"":/[\&\<\>\"\'\`]/.test(a)?(b={"<":"<",">":">",'"':""","'":"'","`":"`"},c=/&(?!\w+;)|[\<\>\"\'\`]/g,a.replace(c,function(a){return b[a]||"&"})):a},SelectParser}(),SelectParser.select_to_array=function(a){var b,c,d,e,f;for(c=new SelectParser,f=a.childNodes,d=0,e=f.length;e>d;d++)b=f[d],c.add_node(b);return c.parsed},AbstractChosen=function(){function AbstractChosen(a,b){this.form_field=a,this.options=null!=b?b:{},AbstractChosen.browser_is_supported()&&(this.is_multiple=this.form_field.multiple,this.set_default_text(),this.set_default_values(),this.setup(),this.set_up_html(),this.register_observers(),this.on_ready())}return AbstractChosen.prototype.set_default_values=function(){var a=this;return this.click_test_action=function(b){return a.test_active_click(b)},this.activate_action=function(b){return a.activate_field(b)},this.active_field=!1,this.mouse_on_container=!1,this.results_showing=!1,this.result_highlighted=null,this.allow_single_deselect=null!=this.options.allow_single_deselect&&null!=this.form_field.options[0]&&""===this.form_field.options[0].text?this.options.allow_single_deselect:!1,this.disable_search_threshold=this.options.disable_search_threshold||0,this.disable_search=this.options.disable_search||!1,this.enable_split_word_search=null!=this.options.enable_split_word_search?this.options.enable_split_word_search:!0,this.group_search=null!=this.options.group_search?this.options.group_search:!0,this.search_contains=this.options.search_contains||!1,this.single_backstroke_delete=null!=this.options.single_backstroke_delete?this.options.single_backstroke_delete:!0,this.max_selected_options=this.options.max_selected_options||1/0,this.inherit_select_classes=this.options.inherit_select_classes||!1,this.display_selected_options=null!=this.options.display_selected_options?this.options.display_selected_options:!0,this.display_disabled_options=null!=this.options.display_disabled_options?this.options.display_disabled_options:!0,this.include_group_label_in_selected=this.options.include_group_label_in_selected||!1,this.max_shown_results=this.options.max_shown_results||Number.POSITIVE_INFINITY,this.create_option=this.options.create_option||!1,this.persistent_create_option=this.options.persistent_create_option||!1,this.skip_no_results=this.options.skip_no_results||!1},AbstractChosen.prototype.set_default_text=function(){return this.form_field.getAttribute("data-placeholder")?this.default_text=this.form_field.getAttribute("data-placeholder"):this.is_multiple?this.default_text=this.options.placeholder_text_multiple||this.options.placeholder_text||AbstractChosen.default_multiple_text:this.default_text=this.options.placeholder_text_single||this.options.placeholder_text||AbstractChosen.default_single_text,this.results_none_found=this.form_field.getAttribute("data-no_results_text")||this.options.no_results_text||AbstractChosen.default_no_result_text,this.create_option_text=this.form_field.getAttribute("data-create_option_text")||this.options.create_option_text||AbstractChosen.default_create_option_text},AbstractChosen.prototype.choice_label=function(a){return this.include_group_label_in_selected&&null!=a.group_label?""+a.group_label+""+a.html:a.html},AbstractChosen.prototype.mouse_enter=function(){return this.mouse_on_container=!0},AbstractChosen.prototype.mouse_leave=function(){return this.mouse_on_container=!1},AbstractChosen.prototype.input_focus=function(a){var b=this;if(this.is_multiple){if(!this.active_field)return setTimeout(function(){return b.container_mousedown()},50)}else if(!this.active_field)return this.activate_field()},AbstractChosen.prototype.input_blur=function(a){var b=this;return this.mouse_on_container?void 0:(this.active_field=!1,setTimeout(function(){return b.blur_test()},100))},AbstractChosen.prototype.results_option_build=function(a){var b,c,d,e,f,g,h;for(b="",e=0,h=this.results_data,f=0,g=h.length;g>f&&(c=h[f],d="",d=c.group?this.result_add_group(c):this.result_add_option(c),""!==d&&(e++,b+=d),(null!=a?a.first:void 0)&&(c.selected&&this.is_multiple?this.choice_build(c):c.selected&&!this.is_multiple&&this.single_set_selected_text(this.choice_label(c))),!(e>=this.max_shown_results));f++);return b},AbstractChosen.prototype.result_add_option=function(a){var b,c;return a.search_match&&this.include_option_in_results(a)?(b=[],a.disabled||a.selected&&this.is_multiple||b.push("active-result"),!a.disabled||a.selected&&this.is_multiple||b.push("disabled-result"),a.selected&&b.push("result-selected"),null!=a.group_array_index&&b.push("group-option"),""!==a.classes&&b.push(a.classes),c=document.createElement("li"),c.className=b.join(" "),c.style.cssText=a.style,c.setAttribute("data-option-array-index",a.array_index),c.innerHTML=a.search_text,a.title&&(c.title=a.title),this.outerHTML(c)):""},AbstractChosen.prototype.result_add_group=function(a){var b,c;return(a.search_match||a.group_match)&&a.active_options>0?(b=[],b.push("group-result"),a.classes&&b.push(a.classes),c=document.createElement("li"),c.className=b.join(" "),c.innerHTML=a.search_text,a.title&&(c.title=a.title),this.outerHTML(c)):""},AbstractChosen.prototype.append_option=function(a){return this.select_append_option(a)},AbstractChosen.prototype.results_update_field=function(){return this.set_default_text(),this.is_multiple||this.results_reset_cleanup(),this.result_clear_highlight(),this.results_build(),this.results_showing?this.winnow_results():void 0},AbstractChosen.prototype.reset_single_select_options=function(){var a,b,c,d,e;for(d=this.results_data,e=[],b=0,c=d.length;c>b;b++)a=d[b],a.selected?e.push(a.selected=!1):e.push(void 0);return e},AbstractChosen.prototype.results_toggle=function(){return this.results_showing?this.results_hide():this.results_show()},AbstractChosen.prototype.results_search=function(a){return this.results_showing?this.winnow_results():this.results_show()},AbstractChosen.prototype.winnow_results=function(){var a,b,c,d,e,f,g,h,i,j,k,l,m,n;for(this.no_results_clear(),f=0,c=!1,h=this.get_search_text(),b=h.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&"),k=new RegExp(b,"i"),e=this.get_search_regex(b),a=new RegExp("^"+b+"$","i"),n=this.results_data,l=0,m=n.length;m>l;l++)d=n[l],d.search_match=!1,g=null,this.include_option_in_results(d)&&(d.group&&(d.group_match=!1,d.active_options=0),null!=d.group_array_index&&this.results_data[d.group_array_index]&&(g=this.results_data[d.group_array_index],0===g.active_options&&g.search_match&&(f+=1),g.active_options+=1),d.search_text=d.group?d.label:d.html,(!d.group||this.group_search)&&(d.search_match=this.search_string_match(d.search_text,e),d.search_match&&!d.group&&(f+=1),c=c||a.test(d.html),d.search_match?(h.length&&(i=d.search_text.search(k),j=d.search_text.substr(0,i+h.length)+""+d.search_text.substr(i+h.length),d.search_text=j.substr(0,i)+""+j.substr(i)),null!=g&&(g.group_match=!0)):null!=d.group_array_index&&this.results_data[d.group_array_index].search_match&&(d.search_match=!0)));return this.result_clear_highlight(),1>f&&h.length?(this.update_results_content(""),this.create_option&&this.skip_no_results||this.no_results(h)):(this.update_results_content(this.results_option_build()),this.winnow_results_set_highlight()),this.create_option&&(1>f||!c&&this.persistent_create_option)&&h.length?this.show_create_option(h):void 0},AbstractChosen.prototype.get_search_regex=function(a){var b;return b=this.search_contains?"":"^",new RegExp(b+a,"i")},AbstractChosen.prototype.search_string_match=function(a,b){var c,d,e,f;if(b.test(a))return!0;if(this.enable_split_word_search&&(a.indexOf(" ")>=0||0===a.indexOf("["))&&(d=a.replace(/\[|\]/g,"").split(" "),d.length))for(e=0,f=d.length;f>e;e++)if(c=d[e],b.test(c))return!0},AbstractChosen.prototype.choices_count=function(){var a,b,c,d;if(null!=this.selected_option_count)return this.selected_option_count;for(this.selected_option_count=0,d=this.form_field.options,b=0,c=d.length;c>b;b++)a=d[b],a.selected&&(this.selected_option_count+=1);return this.selected_option_count},AbstractChosen.prototype.choices_click=function(a){return a.preventDefault(),this.results_showing||this.is_disabled?void 0:this.results_show()},AbstractChosen.prototype.keyup_checker=function(a){var b,c;switch(b=null!=(c=a.which)?c:a.keyCode,this.search_field_scale(),b){case 8:if(this.is_multiple&&this.backstroke_length<1&&this.choices_count()>0)return this.keydown_backstroke();if(!this.pending_backstroke)return this.result_clear_highlight(),this.results_search();break;case 13:if(a.preventDefault(),this.results_showing)return this.result_select(a);break;case 27:return this.results_showing&&this.results_hide(),!0;case 9:case 38:case 40:case 16:case 91:case 17:break;default:return this.results_search()}},AbstractChosen.prototype.clipboard_event_checker=function(a){var b=this;return setTimeout(function(){return b.results_search()},50)},AbstractChosen.prototype.container_width=function(){return null!=this.options.width?this.options.width:""+this.form_field.offsetWidth+"px"},AbstractChosen.prototype.include_option_in_results=function(a){return this.is_multiple&&!this.display_selected_options&&a.selected?!1:!this.display_disabled_options&&a.disabled?!1:a.empty?!1:!0},AbstractChosen.prototype.search_results_touchstart=function(a){return this.touch_started=!0,this.search_results_mouseover(a)},AbstractChosen.prototype.search_results_touchmove=function(a){return this.touch_started=!1,this.search_results_mouseout(a)},AbstractChosen.prototype.search_results_touchend=function(a){return this.touch_started?this.search_results_mouseup(a):void 0},AbstractChosen.prototype.outerHTML=function(a){var b;return a.outerHTML?a.outerHTML:(b=document.createElement("div"),b.appendChild(a),b.innerHTML)},AbstractChosen.browser_is_supported=function(){return/iP(od|hone)/i.test(window.navigator.userAgent)?!1:/Android/i.test(window.navigator.userAgent)&&/Mobile/i.test(window.navigator.userAgent)?!1:/IEMobile/i.test(window.navigator.userAgent)?!1:/Windows Phone/i.test(window.navigator.userAgent)?!1:/BlackBerry/i.test(window.navigator.userAgent)?!1:/BB10/i.test(window.navigator.userAgent)?!1:"Microsoft Internet Explorer"===window.navigator.appName?document.documentMode>=8:!0},AbstractChosen.default_multiple_text="Select Some Options",AbstractChosen.default_single_text="Select an Option",AbstractChosen.default_no_result_text="No results match",AbstractChosen.default_create_option_text="Add Option",AbstractChosen}(),a=jQuery,a.fn.extend({chosen:function(b){return AbstractChosen.browser_is_supported()?this.each(function(c){var d,e;d=a(this),e=d.data("chosen"),"destroy"===b&&e instanceof Chosen?e.destroy():e instanceof Chosen||d.data("chosen",new Chosen(this,b))}):this}}),Chosen=function(c){function Chosen(){return b=Chosen.__super__.constructor.apply(this,arguments)}return d(Chosen,c),Chosen.prototype.setup=function(){return this.form_field_jq=a(this.form_field),this.current_selectedIndex=this.form_field.selectedIndex,this.is_rtl=this.form_field_jq.hasClass("chosen-rtl")},Chosen.prototype.set_up_html=function(){var b,c;return b=["chosen-container"],b.push("chosen-container-"+(this.is_multiple?"multi":"single")),this.inherit_select_classes&&this.form_field.className&&b.push(this.form_field.className),this.is_rtl&&b.push("chosen-rtl"),c={"class":b.join(" "),style:"width: "+this.container_width()+";",title:this.form_field.title},this.form_field.id.length&&(c.id=this.form_field.id.replace(/[^\w]/g,"_")+"_chosen"),this.container=a("
    ",c),this.is_multiple?this.container.html('
      '):this.container.html(''+this.default_text+'
        '),this.form_field_jq.hide().after(this.container),this.dropdown=this.container.find("div.chosen-drop").first(),this.search_field=this.container.find("input").first(),this.search_results=this.container.find("ul.chosen-results").first(),this.search_field_scale(),this.search_no_results=this.container.find("li.no-results").first(),this.is_multiple?(this.search_choices=this.container.find("ul.chosen-choices").first(),this.search_container=this.container.find("li.search-field").first()):(this.search_container=this.container.find("div.chosen-search").first(),this.selected_item=this.container.find(".chosen-single").first()),this.results_build(),this.set_tab_index(),this.set_label_behavior()},Chosen.prototype.on_ready=function(){return this.form_field_jq.trigger("chosen:ready",{chosen:this})},Chosen.prototype.register_observers=function(){var a=this;return this.container.bind("touchstart.chosen",function(b){return a.container_mousedown(b),b.preventDefault()}),this.container.bind("touchend.chosen",function(b){return a.container_mouseup(b),b.preventDefault()}),this.container.bind("mousedown.chosen",function(b){a.container_mousedown(b)}),this.container.bind("mouseup.chosen",function(b){a.container_mouseup(b)}),this.container.bind("mouseenter.chosen",function(b){a.mouse_enter(b)}),this.container.bind("mouseleave.chosen",function(b){a.mouse_leave(b)}),this.search_results.bind("mouseup.chosen",function(b){a.search_results_mouseup(b)}),this.search_results.bind("mouseover.chosen",function(b){a.search_results_mouseover(b)}),this.search_results.bind("mouseout.chosen",function(b){a.search_results_mouseout(b)}),this.search_results.bind("mousewheel.chosen DOMMouseScroll.chosen",function(b){a.search_results_mousewheel(b)}),this.search_results.bind("touchstart.chosen",function(b){a.search_results_touchstart(b)}),this.search_results.bind("touchmove.chosen",function(b){a.search_results_touchmove(b)}),this.search_results.bind("touchend.chosen",function(b){a.search_results_touchend(b)}),this.form_field_jq.bind("chosen:updated.chosen",function(b){a.results_update_field(b)}),this.form_field_jq.bind("chosen:activate.chosen",function(b){a.activate_field(b)}),this.form_field_jq.bind("chosen:open.chosen",function(b){a.container_mousedown(b)}),this.form_field_jq.bind("chosen:close.chosen",function(b){a.input_blur(b)}),this.search_field.bind("blur.chosen",function(b){a.input_blur(b)}),this.search_field.bind("keyup.chosen",function(b){a.keyup_checker(b)}),this.search_field.bind("keydown.chosen",function(b){a.keydown_checker(b)}),this.search_field.bind("focus.chosen",function(b){a.input_focus(b)}),this.search_field.bind("cut.chosen",function(b){a.clipboard_event_checker(b)}),this.search_field.bind("paste.chosen",function(b){a.clipboard_event_checker(b)}),this.is_multiple?this.search_choices.bind("click.chosen",function(b){a.choices_click(b)}):this.container.bind("click.chosen",function(a){a.preventDefault()})},Chosen.prototype.destroy=function(){return a(this.container[0].ownerDocument).unbind("click.chosen",this.click_test_action),this.search_field[0].tabIndex&&(this.form_field_jq[0].tabIndex=this.search_field[0].tabIndex),this.container.remove(),this.form_field_jq.removeData("chosen"),this.form_field_jq.show()},Chosen.prototype.search_field_disabled=function(){return this.is_disabled=this.form_field_jq[0].disabled,this.is_disabled?(this.container.addClass("chosen-disabled"),this.search_field[0].disabled=!0,this.is_multiple||this.selected_item.unbind("focus.chosen",this.activate_action),this.close_field()):(this.container.removeClass("chosen-disabled"),this.search_field[0].disabled=!1,this.is_multiple?void 0:this.selected_item.bind("focus.chosen",this.activate_action))},Chosen.prototype.container_mousedown=function(b){return this.is_disabled||(b&&"mousedown"===b.type&&!this.results_showing&&b.preventDefault(),null!=b&&a(b.target).hasClass("search-choice-close"))?void 0:(this.active_field?this.is_multiple||!b||a(b.target)[0]!==this.selected_item[0]&&!a(b.target).parents("a.chosen-single").length||(b.preventDefault(),this.results_toggle()):(this.is_multiple&&this.search_field.val(""),a(this.container[0].ownerDocument).bind("click.chosen",this.click_test_action),this.results_show()),this.activate_field())},Chosen.prototype.container_mouseup=function(a){return"ABBR"!==a.target.nodeName||this.is_disabled?void 0:this.results_reset(a)},Chosen.prototype.search_results_mousewheel=function(a){var b;return a.originalEvent&&(b=a.originalEvent.deltaY||-a.originalEvent.wheelDelta||a.originalEvent.detail),null!=b?(a.preventDefault(),"DOMMouseScroll"===a.type&&(b=40*b),this.search_results.scrollTop(b+this.search_results.scrollTop())):void 0},Chosen.prototype.blur_test=function(a){return!this.active_field&&this.container.hasClass("chosen-container-active")?this.close_field():void 0},Chosen.prototype.close_field=function(){return a(this.container[0].ownerDocument).unbind("click.chosen",this.click_test_action),this.active_field=!1,this.results_hide(),this.container.removeClass("chosen-container-active"),this.clear_backstroke(),this.show_search_field_default(),this.search_field_scale()},Chosen.prototype.activate_field=function(){return this.container.addClass("chosen-container-active"),this.active_field=!0,this.search_field.val(this.search_field.val()),this.search_field.focus()},Chosen.prototype.test_active_click=function(b){var c;return c=a(b.target).closest(".chosen-container"),c.length&&this.container[0]===c[0]?this.active_field=!0:this.close_field()},Chosen.prototype.results_build=function(){return this.parsing=!0,this.selected_option_count=null,this.results_data=SelectParser.select_to_array(this.form_field),this.is_multiple?this.search_choices.find("li.search-choice").remove():this.is_multiple||(this.single_set_selected_text(),this.disable_search||this.form_field.options.length<=this.disable_search_threshold&&!this.create_option?(this.search_field[0].readOnly=!0,this.container.addClass("chosen-container-single-nosearch")):(this.search_field[0].readOnly=!1,this.container.removeClass("chosen-container-single-nosearch"))),this.update_results_content(this.results_option_build({first:!0})),this.search_field_disabled(),this.show_search_field_default(),this.search_field_scale(),this.parsing=!1},Chosen.prototype.result_do_highlight=function(a){var b,c,d,e,f;if(a.length){if(this.result_clear_highlight(),this.result_highlight=a,this.result_highlight.addClass("highlighted"),d=parseInt(this.search_results.css("maxHeight"),10),f=this.search_results.scrollTop(),e=d+f,c=this.result_highlight.position().top+this.search_results.scrollTop(),b=c+this.result_highlight.outerHeight(),b>=e)return this.search_results.scrollTop(b-d>0?b-d:0);if(f>c)return this.search_results.scrollTop(c)}},Chosen.prototype.result_clear_highlight=function(){return this.result_highlight&&this.result_highlight.removeClass("highlighted"),this.result_highlight=null},Chosen.prototype.results_show=function(){return this.is_multiple&&this.max_selected_options<=this.choices_count()?(this.form_field_jq.trigger("chosen:maxselected",{chosen:this}),!1):(this.container.addClass("chosen-with-drop"),this.results_showing=!0,this.search_field.focus(),this.search_field.val(this.search_field.val()),this.winnow_results(),this.form_field_jq.trigger("chosen:showing_dropdown",{chosen:this}))},Chosen.prototype.update_results_content=function(a){return this.search_results.html(a)},Chosen.prototype.results_hide=function(){return this.results_showing&&(this.result_clear_highlight(),this.container.removeClass("chosen-with-drop"),this.form_field_jq.trigger("chosen:hiding_dropdown",{chosen:this})),this.results_showing=!1},Chosen.prototype.set_tab_index=function(a){var b;return this.form_field.tabIndex?(b=this.form_field.tabIndex,this.form_field.tabIndex=-1,this.search_field[0].tabIndex=b):void 0},Chosen.prototype.set_label_behavior=function(){var b=this;return this.form_field_label=this.form_field_jq.parents("label"),!this.form_field_label.length&&this.form_field.id.length&&(this.form_field_label=a("label[for='"+this.form_field.id+"']")),this.form_field_label.length>0?this.form_field_label.bind("click.chosen",function(a){return b.is_multiple?b.container_mousedown(a):b.activate_field()}):void 0},Chosen.prototype.show_search_field_default=function(){return this.is_multiple&&this.choices_count()<1&&!this.active_field?(this.search_field.val(this.default_text),this.search_field.addClass("default")):(this.search_field.val(""),this.search_field.removeClass("default"))},Chosen.prototype.search_results_mouseup=function(b){var c;return c=a(b.target).hasClass("active-result")?a(b.target):a(b.target).parents(".active-result").first(),c.length?(this.result_highlight=c,this.result_select(b),this.search_field.focus()):void 0},Chosen.prototype.search_results_mouseover=function(b){var c;return c=a(b.target).hasClass("active-result")?a(b.target):a(b.target).parents(".active-result").first(),c?this.result_do_highlight(c):void 0},Chosen.prototype.search_results_mouseout=function(b){return a(b.target).hasClass("active-result")?this.result_clear_highlight():void 0},Chosen.prototype.choice_build=function(b){var c,d,e=this;return c=a("
      • ",{"class":"search-choice"}).html(""+this.choice_label(b)+""),b.disabled?c.addClass("search-choice-disabled"):(d=a("",{"class":"search-choice-close","data-option-array-index":b.array_index}),d.bind("click.chosen",function(a){return e.choice_destroy_link_click(a)}),c.append(d)),this.search_container.before(c)},Chosen.prototype.choice_destroy_link_click=function(b){return b.preventDefault(),b.stopPropagation(),this.is_disabled?void 0:this.choice_destroy(a(b.target))},Chosen.prototype.choice_destroy=function(a){return this.result_deselect(a[0].getAttribute("data-option-array-index"))?(this.show_search_field_default(),this.is_multiple&&this.choices_count()>0&&this.search_field.val().length<1&&this.results_hide(),a.parents("li").first().remove(),this.search_field_scale()):void 0},Chosen.prototype.results_reset=function(){return this.reset_single_select_options(),this.form_field.options[0].selected=!0,this.single_set_selected_text(),this.show_search_field_default(),this.results_reset_cleanup(),this.form_field_jq.trigger("change"),this.active_field?this.results_hide():void 0},Chosen.prototype.results_reset_cleanup=function(){return this.current_selectedIndex=this.form_field.selectedIndex,this.selected_item.find("abbr").remove()},Chosen.prototype.result_select=function(a){var b,c;return this.result_highlight?(b=this.result_highlight,b.hasClass("create-option")?(this.select_create_option(this.search_field.val()),this.results_hide()):(this.result_clear_highlight(),this.is_multiple&&this.max_selected_options<=this.choices_count()?(this.form_field_jq.trigger("chosen:maxselected",{chosen:this}),!1):(this.is_multiple?b.removeClass("active-result"):this.reset_single_select_options(),b.addClass("result-selected"),c=this.results_data[b[0].getAttribute("data-option-array-index")],c.selected=!0,this.form_field.options[c.options_index].selected=!0,this.selected_option_count=null,this.is_multiple?this.choice_build(c):this.single_set_selected_text(this.choice_label(c)),(a.metaKey||a.ctrlKey)&&this.is_multiple||this.results_hide(),this.show_search_field_default(),(this.is_multiple||this.form_field.selectedIndex!==this.current_selectedIndex)&&this.form_field_jq.trigger("change",{selected:this.form_field.options[c.options_index].value}),this.current_selectedIndex=this.form_field.selectedIndex,a.preventDefault(),this.search_field_scale()))):void 0},Chosen.prototype.single_set_selected_text=function(a){return null==a&&(a=this.default_text),a===this.default_text?this.selected_item.addClass("chosen-default"):(this.single_deselect_control_build(),this.selected_item.removeClass("chosen-default")),this.selected_item.find("span").html(a)},Chosen.prototype.result_deselect=function(a){var b;return b=this.results_data[a],this.form_field.options[b.options_index].disabled?!1:(b.selected=!1,this.form_field.options[b.options_index].selected=!1,this.selected_option_count=null,this.result_clear_highlight(),this.results_showing&&this.winnow_results(),this.form_field_jq.trigger("change",{deselected:this.form_field.options[b.options_index].value}),this.search_field_scale(),!0)},Chosen.prototype.single_deselect_control_build=function(){return this.allow_single_deselect?(this.selected_item.find("abbr").length||this.selected_item.find("span").first().after(''),this.selected_item.addClass("chosen-single-with-deselect")):void 0},Chosen.prototype.get_search_text=function(){return a("
        ").text(a.trim(this.search_field.val())).html()},Chosen.prototype.winnow_results_set_highlight=function(){var a,b;return b=this.is_multiple?[]:this.search_results.find(".result-selected.active-result"),a=b.length?b.first():this.search_results.find(".active-result").first(),null!=a?this.result_do_highlight(a):void 0},Chosen.prototype.no_results=function(b){var c;return c=a('
      • '+this.results_none_found+' ""
      • '),c.find("span").first().html(b),this.search_results.append(c),this.form_field_jq.trigger("chosen:no_results",{chosen:this})},Chosen.prototype.show_create_option=function(b){var c;return c=a('
      • '+this.create_option_text+': "'+b+'"
      • '),this.search_results.append(c)},Chosen.prototype.create_option_clear=function(){return this.search_results.find(".create-option").remove()},Chosen.prototype.select_create_option=function(b){return a.isFunction(this.create_option)?this.create_option.call(this,b):this.select_append_option({value:b,text:b})},Chosen.prototype.select_append_option=function(b){var c;return c=a("