gosavetool 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +61 -0
- data/exe/gosavetool +117 -0
- data/lib/gosavetool/subtitles.rb +100 -0
- data/lib/gosavetool/text.rb +106 -0
- data/lib/gosavetool/version.rb +5 -0
- data/lib/gosavetool.rb +12 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 69f3cfb13ab8816d737f566f527bda5e73a895d29512c41c5e99d98e344b211f
|
|
4
|
+
data.tar.gz: fc2ca7bce7f80fc3e2ad6851daf828c640df5f6129f4f56ca50f2cb652166f4a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f7c43d20f31741d1300b477c5821c169ab4afe3984b07cdfbbad958c03c0d6d275e8f3b00541e8ddffd093fad11f63fca47d827a347793cc9b0619fe83acd3b8
|
|
7
|
+
data.tar.gz: 9400fa7db0d4086150d39c0b9af9335ca55d8a2762e0a0a09cfddd78a2e44cdcb01793d93b1a5662ae24d7f4ebe9af8ae498b676ff4580262a49c884fb8e792b
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GoSaveTool
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# gosavetool
|
|
2
|
+
|
|
3
|
+
Parse, convert, shift and repair **SRT and WebVTT** subtitles, and count **words, characters and reading time** with **social media character limits**, **Title Case**, **slugs** and other case changes. A Ruby library and a command-line tool, with no dependencies.
|
|
4
|
+
|
|
5
|
+
Made by [GoSaveTool](https://www.gosavetool.com/), free online tools for creators.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install gosavetool
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in a Gemfile: `gem "gosavetool"`. Requires Ruby 3.0 or newer.
|
|
14
|
+
|
|
15
|
+
## Command line
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
gosavetool convert movie.srt --to vtt # writes movie.vtt
|
|
19
|
+
gosavetool shift movie.srt -1.5 # 1.5 s earlier, in place
|
|
20
|
+
gosavetool repair movie.srt # sort, fix overlaps and bad times
|
|
21
|
+
gosavetool info movie.vtt # WebVTT, 412 cues, ends at 01:42:10.500
|
|
22
|
+
|
|
23
|
+
echo "Your caption" | gosavetool count
|
|
24
|
+
gosavetool limits post.txt # characters left for X, Instagram, TikTok, YouTube, SEO...
|
|
25
|
+
echo "the lord of the rings" | gosavetool case title
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Library
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "gosavetool"
|
|
32
|
+
|
|
33
|
+
S = GoSaveTool::Subtitles
|
|
34
|
+
vtt = S.convert(File.read("movie.srt"), "vtt")
|
|
35
|
+
cues = S.parse(text) # [#<struct Cue start=1000, end=3500, settings="", text="Hello">, ...]
|
|
36
|
+
later = S.to_srt(S.shift(cues, 1500))
|
|
37
|
+
fixed, n = S.repair(cues) # sorted, no overlaps, no empty cues
|
|
38
|
+
|
|
39
|
+
T = GoSaveTool::Text
|
|
40
|
+
T.stats(post) # { words:, chars:, chars_no_spaces:, lines:, sentences:, read_seconds: }
|
|
41
|
+
T.limits(post)["x-post"][:fits] # also instagram-caption, tiktok-caption, youtube-title, seo-title, ...
|
|
42
|
+
T.title("the lord of the rings") # "The Lord of the Rings"
|
|
43
|
+
T.slug("Ölçü Birimi & Çeviri!") # "olcu-birimi-ceviri"
|
|
44
|
+
T.camel("best posting time") # "bestPostingTime"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Characters are Unicode code points, so an emoji counts as one. The rules are the same as the [`@gosavetool/subtitles`](https://www.npmjs.com/package/@gosavetool/subtitles) and [`@gosavetool/text`](https://www.npmjs.com/package/@gosavetool/text) JavaScript packages.
|
|
48
|
+
|
|
49
|
+
## Tests
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
rake test
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Online tools
|
|
56
|
+
|
|
57
|
+
[VTT Subtitle Generator](https://www.gosavetool.com/vtt-subtitle-generator), [Auto Captions Generator](https://www.gosavetool.com/auto-captions-generator), [Character Counter](https://www.gosavetool.com/character-counter)
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
data/exe/gosavetool
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# gosavetool: subtitle and text tools on the command line. https://www.gosavetool.com/
|
|
5
|
+
require "gosavetool"
|
|
6
|
+
|
|
7
|
+
HELP = <<~TEXT
|
|
8
|
+
GoSaveTool #{GoSaveTool::VERSION}: subtitle and text tools
|
|
9
|
+
|
|
10
|
+
Subtitles:
|
|
11
|
+
gosavetool convert <file> --to srt|vtt Write name.srt or name.vtt next to the file
|
|
12
|
+
gosavetool shift <file> <seconds> Move every cue (1.5 = later, -2 = earlier), in place
|
|
13
|
+
gosavetool repair <file> Sort, fix overlaps and bad times, renumber, in place
|
|
14
|
+
gosavetool info <file> Format, number of cues and the end time
|
|
15
|
+
|
|
16
|
+
Text (reads the file, or standard input without one):
|
|
17
|
+
gosavetool count [file] Words, characters, sentences, lines, reading time
|
|
18
|
+
gosavetool limits [file] Social media and SEO character limits
|
|
19
|
+
gosavetool case <title|sentence|upper|lower|slug|camel|snake|kebab|clean> [file]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
-o, --out <file> Write to this file instead
|
|
23
|
+
--stdout Print the result instead of writing a file
|
|
24
|
+
|
|
25
|
+
Online tools: https://www.gosavetool.com/
|
|
26
|
+
TEXT
|
|
27
|
+
|
|
28
|
+
CASES = %w[title sentence upper lower slug camel snake kebab clean].freeze
|
|
29
|
+
|
|
30
|
+
def fail_with(msg)
|
|
31
|
+
warn "gosavetool: #{msg}\n\nRun gosavetool --help for usage."
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
args = ARGV.dup
|
|
36
|
+
take = lambda do |*names|
|
|
37
|
+
i = args.index { |a| names.include?(a) }
|
|
38
|
+
next nil unless i
|
|
39
|
+
|
|
40
|
+
args.delete_at(i)
|
|
41
|
+
args.delete_at(i)
|
|
42
|
+
end
|
|
43
|
+
to = take.call("--to")
|
|
44
|
+
out = take.call("-o", "--out")
|
|
45
|
+
stdout = !args.delete("--stdout").nil?
|
|
46
|
+
|
|
47
|
+
if args.empty? || args.include?("-h") || args.include?("--help")
|
|
48
|
+
puts HELP
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
if args.include?("-v") || args.include?("--version")
|
|
52
|
+
puts GoSaveTool::VERSION
|
|
53
|
+
exit 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
save = lambda do |content, target|
|
|
57
|
+
if stdout
|
|
58
|
+
print content
|
|
59
|
+
else
|
|
60
|
+
File.write(out || target, content)
|
|
61
|
+
puts "Wrote #{out || target}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
cmd = args.shift
|
|
66
|
+
T = GoSaveTool::Text
|
|
67
|
+
S = GoSaveTool::Subtitles
|
|
68
|
+
|
|
69
|
+
case cmd
|
|
70
|
+
when "count", "limits", "case"
|
|
71
|
+
op = cmd == "case" ? args.shift : nil
|
|
72
|
+
fail_with("case needs one of: #{CASES.join(', ')}") if cmd == "case" && !CASES.include?(op)
|
|
73
|
+
file = args.first
|
|
74
|
+
fail_with("file not found: #{file}") if file && file != "-" && !File.exist?(file)
|
|
75
|
+
s = file && file != "-" ? File.read(file, encoding: "UTF-8") : $stdin.read.force_encoding("UTF-8")
|
|
76
|
+
# A file or a pipe ends with a newline that is not part of the text being counted
|
|
77
|
+
counted = s.sub(/\r?\n\z/, "")
|
|
78
|
+
if cmd == "count"
|
|
79
|
+
st = T.stats(counted)
|
|
80
|
+
puts "Words: #{st[:words]}", "Characters: #{st[:chars]}", "Without spaces: #{st[:chars_no_spaces]}",
|
|
81
|
+
"Sentences: #{st[:sentences]}", "Lines: #{st[:lines]}", "Reading time: #{st[:read_seconds]} s"
|
|
82
|
+
elsif cmd == "limits"
|
|
83
|
+
T.limits(counted).each do |field, l|
|
|
84
|
+
puts format("%-22s%5d %s", field, l[:limit], l[:fits] ? "#{l[:left]} left" : "#{-l[:left]} over")
|
|
85
|
+
end
|
|
86
|
+
elsif out
|
|
87
|
+
save.call(T.public_send(op, s), out)
|
|
88
|
+
else
|
|
89
|
+
print T.public_send(op, s)
|
|
90
|
+
end
|
|
91
|
+
when "convert", "shift", "repair", "info"
|
|
92
|
+
file = args.first
|
|
93
|
+
fail_with("missing file") unless file
|
|
94
|
+
fail_with("file not found: #{file}") unless File.exist?(file)
|
|
95
|
+
src = File.read(file, encoding: "UTF-8")
|
|
96
|
+
cues = S.parse(src)
|
|
97
|
+
fail_with("no subtitle cues found in #{file}") if cues.empty?
|
|
98
|
+
vtt = S.vtt?(src)
|
|
99
|
+
write = ->(c) { vtt ? S.to_vtt(c) : S.to_srt(c) }
|
|
100
|
+
case cmd
|
|
101
|
+
when "convert"
|
|
102
|
+
fail_with("convert needs --to srt or --to vtt") unless %w[srt vtt].include?(to)
|
|
103
|
+
save.call(S.convert(src, to), file.sub(/\.[^.\/\\]*\z/, "") + ".#{to}")
|
|
104
|
+
when "shift"
|
|
105
|
+
seconds = Float(args[1].to_s.tr(",", "."), exception: false)
|
|
106
|
+
fail_with("shift needs seconds, like 1.5 or -2") unless seconds
|
|
107
|
+
save.call(write.call(S.shift(cues, (seconds * 1000).round)), file)
|
|
108
|
+
when "repair"
|
|
109
|
+
fixed, n = S.repair(cues)
|
|
110
|
+
save.call(write.call(fixed), file)
|
|
111
|
+
puts(n.positive? ? "#{n} problems fixed" : "No problems found") unless stdout
|
|
112
|
+
else
|
|
113
|
+
puts "#{vtt ? 'WebVTT' : 'SRT'}, #{cues.length} cues, ends at #{S.from_ms(cues.map(&:end).max, vtt ? '.' : ',')}"
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
fail_with("unknown command #{cmd}")
|
|
117
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoSaveTool
|
|
4
|
+
# SRT and WebVTT: read cues, write them in either format, shift and repair timings.
|
|
5
|
+
# The Ruby version of @gosavetool/subtitles: https://www.gosavetool.com/vtt-subtitle-generator
|
|
6
|
+
module Subtitles
|
|
7
|
+
# One subtitle cue; times in milliseconds.
|
|
8
|
+
Cue = Struct.new(:start, :end, :settings, :text, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
STAMP = /(?:\d{1,2}:)?\d{1,2}:\d{1,2}[,.]\d{1,3}/
|
|
11
|
+
TIME = /(\d{1,2}:)?(\d{1,2}):(\d{1,2})[,.](\d{1,3})/
|
|
12
|
+
ARROW = /\A\s*(#{STAMP})\s*-->\s*(#{STAMP})(.*)\z/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# "00:01:02,500" or "01:02.5" => 62500; nil when it is not a timestamp
|
|
17
|
+
def to_ms(stamp)
|
|
18
|
+
m = TIME.match(stamp)
|
|
19
|
+
return nil unless m
|
|
20
|
+
|
|
21
|
+
h = m[1] ? m[1].chomp(":").to_i : 0
|
|
22
|
+
((h * 60 + m[2].to_i) * 60 + m[3].to_i) * 1000 + m[4].ljust(3, "0").to_i
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 62500 => "00:01:02,500" (sep ",", SRT) or "00:01:02.500" (sep ".", WebVTT)
|
|
26
|
+
def from_ms(ms, sep = ",")
|
|
27
|
+
# Half up, as JavaScript's Math.round
|
|
28
|
+
t = [(ms + 0.5).floor, 0].max
|
|
29
|
+
format("%02d:%02d:%02d%s%03d", t / 3_600_000, t / 60_000 % 60, t / 1000 % 60, sep, t % 1000)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Cues of an SRT or WebVTT text. Headers, NOTE and STYLE blocks are skipped.
|
|
33
|
+
def parse(src)
|
|
34
|
+
# "\r\r\n" too: a file saved twice with Windows line endings
|
|
35
|
+
text = src.delete_prefix("").gsub(/\r*\n|\r/, "\n")
|
|
36
|
+
text.split(/\n{2,}/).filter_map do |block|
|
|
37
|
+
lines = block.split("\n", -1)
|
|
38
|
+
at = lines.index { |l| ARROW.match?(l) }
|
|
39
|
+
next unless at
|
|
40
|
+
|
|
41
|
+
m = ARROW.match(lines[at])
|
|
42
|
+
Cue.new(start: to_ms(m[1]), end: to_ms(m[2]), settings: m[3].strip, text: lines[(at + 1)..].join("\n").strip)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the text starts with the WEBVTT signature
|
|
47
|
+
def vtt?(src)
|
|
48
|
+
src.delete_prefix("").start_with?("WEBVTT")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_srt(cues)
|
|
52
|
+
cues.each_with_index.map { |c, i| "#{i + 1}\n#{from_ms(c.start, ',')} --> #{from_ms(c.end, ',')}\n#{c.text}" }.join("\n\n") + "\n"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_vtt(cues)
|
|
56
|
+
body = cues.map do |c|
|
|
57
|
+
settings = c.settings.to_s.empty? ? "" : " #{c.settings}"
|
|
58
|
+
"#{from_ms(c.start, '.')} --> #{from_ms(c.end, '.')}#{settings}\n#{c.text}"
|
|
59
|
+
end
|
|
60
|
+
"WEBVTT\n\n#{body.join("\n\n")}\n"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Converts a subtitle text to "srt" or "vtt"
|
|
64
|
+
def convert(src, to)
|
|
65
|
+
case to.to_s
|
|
66
|
+
when "vtt" then to_vtt(parse(src))
|
|
67
|
+
when "srt" then to_srt(parse(src))
|
|
68
|
+
else raise ArgumentError, 'the target format must be "srt" or "vtt"'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Moves every cue by ms (negative = earlier); cues pushed before zero are clipped or dropped
|
|
73
|
+
def shift(cues, ms)
|
|
74
|
+
cues.map { |c| Cue.new(start: [c.start + ms, 0].max, end: [c.end + ms, 0].max, settings: c.settings, text: c.text) }
|
|
75
|
+
.select { |c| c.end.positive? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sorts cues, fixes end times before the start and overlaps, drops empty cues.
|
|
79
|
+
# Returns [cues, number_of_changes]. The input is not changed.
|
|
80
|
+
def repair(cues, min_duration: 2000)
|
|
81
|
+
fixed = cues.count { |c| c.text.empty? }
|
|
82
|
+
kept = cues.reject { |c| c.text.empty? }.map(&:dup)
|
|
83
|
+
# sort_by is not stable in Ruby, so the original position breaks ties
|
|
84
|
+
out = kept.each_with_index.sort_by { |c, i| [c.start, i] }.map(&:first)
|
|
85
|
+
fixed += 1 if out.map(&:start) != kept.map(&:start)
|
|
86
|
+
out.each_with_index do |c, i|
|
|
87
|
+
nxt = out[i + 1]
|
|
88
|
+
unless c.end > c.start
|
|
89
|
+
c.end = c.start + min_duration
|
|
90
|
+
fixed += 1
|
|
91
|
+
end
|
|
92
|
+
if nxt && c.end > nxt.start
|
|
93
|
+
c.end = [c.start + 1, nxt.start - 1].max
|
|
94
|
+
fixed += 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
[out, fixed]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoSaveTool
|
|
4
|
+
# Word, character and reading-time counts, social media character limits, case changes, URL slugs and
|
|
5
|
+
# whitespace cleanup. The Ruby version of @gosavetool/text: https://www.gosavetool.com/character-counter
|
|
6
|
+
module Text
|
|
7
|
+
# Character limits of common fields; SEO values are the length search results show before cutting
|
|
8
|
+
LIMITS = {
|
|
9
|
+
"x-post" => 280,
|
|
10
|
+
"instagram-caption" => 2200,
|
|
11
|
+
"tiktok-caption" => 4000,
|
|
12
|
+
"youtube-title" => 100,
|
|
13
|
+
"youtube-description" => 5000,
|
|
14
|
+
"linkedin-post" => 3000,
|
|
15
|
+
"pinterest-description" => 500,
|
|
16
|
+
"seo-title" => 60,
|
|
17
|
+
"meta-description" => 160
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
SMALL = %w[a an and as at but by for in nor of on or so the to up yet via vs].freeze
|
|
21
|
+
SLUG_MAP = {
|
|
22
|
+
"ı" => "i", "İ" => "i", "ş" => "s", "Ş" => "s", "ğ" => "g", "Ğ" => "g", "ç" => "c", "Ç" => "c", "ö" => "o",
|
|
23
|
+
"Ö" => "o", "ü" => "u", "Ü" => "u", "ß" => "ss", "æ" => "ae", "ø" => "o", "đ" => "d", "ł" => "l"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
WORD = /[\p{L}\p{N}][\p{L}\p{N}'’_-]*/
|
|
27
|
+
TITLE_WORD = /[\p{L}\p{N}][\p{L}\p{N}'’]*/
|
|
28
|
+
SENTENCE = /[^.!?…]+[.!?…]+/
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Words, characters, lines, sentences and reading time (238 words a minute) of a text.
|
|
33
|
+
# Characters are Unicode code points, so an emoji counts as one.
|
|
34
|
+
def stats(s)
|
|
35
|
+
words = s.scan(WORD).length
|
|
36
|
+
{
|
|
37
|
+
words: words,
|
|
38
|
+
chars: s.length,
|
|
39
|
+
chars_no_spaces: s.gsub(/[[:space:]]/, "").length,
|
|
40
|
+
lines: s.empty? ? 0 : s.split(/\r\n|\r|\n/, -1).length,
|
|
41
|
+
sentences: s.scan(SENTENCE).length,
|
|
42
|
+
read_seconds: (words / 238.0 * 60 + 0.5).floor
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# How the text meets each limit: { "x-post" => { limit: 280, used: 120, left: 160, fits: true }, ... }
|
|
47
|
+
def limits(s, only = nil)
|
|
48
|
+
used = s.length
|
|
49
|
+
LIMITS.each_with_object({}) do |(field, limit), out|
|
|
50
|
+
next if only && !only.include?(field)
|
|
51
|
+
|
|
52
|
+
out[field] = { limit: limit, used: used, left: limit - used, fits: used <= limit }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# "bestPostingTime 2" => ["best", "Posting", "Time", "2"]
|
|
57
|
+
def words(s)
|
|
58
|
+
s.gsub(/([\p{Ll}\p{N}])(\p{Lu})/, '\1 \2').split(/[^\p{L}\p{N}]+/).reject(&:empty?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def upper(s) = s.upcase
|
|
62
|
+
def lower(s) = s.downcase
|
|
63
|
+
|
|
64
|
+
def capitalize_first(w)
|
|
65
|
+
w.empty? ? w : w[0].upcase + w[1..]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# "the lord of the rings: return of the king" => "The Lord of the Rings: Return of the King"
|
|
69
|
+
def title(s)
|
|
70
|
+
low = s.downcase
|
|
71
|
+
first = true
|
|
72
|
+
low.gsub(TITLE_WORD) do |w|
|
|
73
|
+
at = Regexp.last_match.begin(0)
|
|
74
|
+
after_colon = low[0, at].match?(/[:.!?][[:space:]]*\z/)
|
|
75
|
+
keep = !first && !after_colon && SMALL.include?(w)
|
|
76
|
+
first = false
|
|
77
|
+
keep ? w : capitalize_first(w)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# "HELLO WORLD. how ARE you?" => "Hello world. How are you?"
|
|
82
|
+
def sentence(s)
|
|
83
|
+
s.downcase.gsub(/(\A[[:space:]]*|[.!?…][[:space:]]+)(\p{L})/) { Regexp.last_match(1) + Regexp.last_match(2).upcase }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# "Ölçü Birimi & Çeviri!" => "olcu-birimi-ceviri"
|
|
87
|
+
def slug(s)
|
|
88
|
+
mapped = s.gsub(/[ıİşŞğĞçÇöÖüÜßæøđł]/, SLUG_MAP)
|
|
89
|
+
mapped.unicode_normalize(:nfkd).gsub(/[̀-ͯ]/, "").downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def camel(s)
|
|
93
|
+
words(s).each_with_index.map { |w, i| i.zero? ? w.downcase : capitalize_first(w.downcase) }.join
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def snake(s) = words(s).map(&:downcase).join("_")
|
|
97
|
+
def kebab(s) = words(s).map(&:downcase).join("-")
|
|
98
|
+
|
|
99
|
+
# Removes trailing spaces, runs of spaces and more than one blank line in a row.
|
|
100
|
+
def clean(s)
|
|
101
|
+
nl = s.include?("\r\n") ? "\r\n" : "\n"
|
|
102
|
+
lines = s.split(/\r\n|\n/, -1).map { |l| l.sub(/[ \t]+\z/, "").gsub(/([^[:space:]])[ \t]{2,}/, '\1 ') }
|
|
103
|
+
lines.join(nl).gsub(/(?:#{Regexp.escape(nl)}){3,}/, nl * 2)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/gosavetool.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "gosavetool/version"
|
|
4
|
+
require_relative "gosavetool/subtitles"
|
|
5
|
+
require_relative "gosavetool/text"
|
|
6
|
+
|
|
7
|
+
# Subtitle and text tools from GoSaveTool, free online tools for creators: https://www.gosavetool.com/
|
|
8
|
+
#
|
|
9
|
+
# GoSaveTool::Subtitles.convert(srt, "vtt")
|
|
10
|
+
# GoSaveTool::Text.title("the lord of the rings") # => "The Lord of the Rings"
|
|
11
|
+
module GoSaveTool
|
|
12
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gosavetool
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- GoSaveTool
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Parse, convert, shift and repair SRT and WebVTT subtitles; word, character
|
|
13
|
+
and reading-time counts, social media character limits, Title Case, slugs and other
|
|
14
|
+
case changes. No dependencies, with a command-line tool.
|
|
15
|
+
executables:
|
|
16
|
+
- gosavetool
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE.txt
|
|
22
|
+
- README.md
|
|
23
|
+
- exe/gosavetool
|
|
24
|
+
- lib/gosavetool.rb
|
|
25
|
+
- lib/gosavetool/subtitles.rb
|
|
26
|
+
- lib/gosavetool/text.rb
|
|
27
|
+
- lib/gosavetool/version.rb
|
|
28
|
+
homepage: https://www.gosavetool.com/
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata:
|
|
32
|
+
homepage_uri: https://www.gosavetool.com/
|
|
33
|
+
source_code_uri: https://github.com/mitolocya/gosavetool-ruby
|
|
34
|
+
bug_tracker_uri: https://www.gosavetool.com/contact
|
|
35
|
+
changelog_uri: https://github.com/mitolocya/gosavetool-ruby/blob/main/CHANGELOG.md
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '3.0'
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 4.0.20
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: SRT and WebVTT subtitle tools, word count, social media limits and case changes
|
|
54
|
+
test_files: []
|