mailmate 0.1.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/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/config.yml.example +21 -0
- data/exe/mm-modify +8 -0
- data/exe/mm-send +8 -0
- data/exe/mmdiscover +8 -0
- data/exe/mmmessage +8 -0
- data/exe/mmsearch +8 -0
- data/lib/mailmate/applescript_driver.rb +103 -0
- data/lib/mailmate/ast.rb +33 -0
- data/lib/mailmate/attributes.rb +289 -0
- data/lib/mailmate/cli/discover.rb +170 -0
- data/lib/mailmate/cli/message.rb +109 -0
- data/lib/mailmate/cli/modify.rb +190 -0
- data/lib/mailmate/cli/search.rb +609 -0
- data/lib/mailmate/cli/send.rb +29 -0
- data/lib/mailmate/config.rb +134 -0
- data/lib/mailmate/duplicate_scanner.rb +65 -0
- data/lib/mailmate/eml_lookup.rb +86 -0
- data/lib/mailmate/evaluator.rb +93 -0
- data/lib/mailmate/filter_classifier.rb +123 -0
- data/lib/mailmate/header_reader.rb +74 -0
- data/lib/mailmate/identity.rb +35 -0
- data/lib/mailmate/index_reader.rb +126 -0
- data/lib/mailmate/lexer.rb +136 -0
- data/lib/mailmate/mailbox_graph.rb +77 -0
- data/lib/mailmate/message.rb +31 -0
- data/lib/mailmate/mid_url.rb +23 -0
- data/lib/mailmate/operators.rb +110 -0
- data/lib/mailmate/parser.rb +218 -0
- data/lib/mailmate/platform_error.rb +21 -0
- data/lib/mailmate/source_resolver.rb +104 -0
- data/lib/mailmate/var_resolver.rb +108 -0
- data/lib/mailmate/version.rb +5 -0
- data/lib/mailmate.rb +73 -0
- metadata +146 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# IndexReader — decodes MailMate's binary header indexes at
|
|
4
|
+
# `~/Library/Application Support/MailMate/Database.noindex/Headers/`.
|
|
5
|
+
#
|
|
6
|
+
# Index format (verified against `#flags.offsets` and `#forwarding.offsets`):
|
|
7
|
+
#
|
|
8
|
+
# .cache Newline-separated string table.
|
|
9
|
+
# .offsets Concatenated 12-byte records, three little-endian uint32 each:
|
|
10
|
+
# [0..4) message body-part ID
|
|
11
|
+
# [4..8) start byte offset into .cache
|
|
12
|
+
# [8..12) end byte offset into .cache (exclusive)
|
|
13
|
+
# value = cache[start...end] (start == end → empty / "no value")
|
|
14
|
+
# .plist Old-style plist with offsetsFileSize / stringsFileSize sentinels.
|
|
15
|
+
#
|
|
16
|
+
# Specific accessors:
|
|
17
|
+
# `flags.flag(eml_id)` → Array<String> of IMAP keywords (`\Seen`,
|
|
18
|
+
# `\Flagged`, `$Forwarded`, `$Muted`, custom tags…)
|
|
19
|
+
# or [] if the message has no flags / isn't indexed.
|
|
20
|
+
#
|
|
21
|
+
# IndexReader instances cache both files in memory and build a hash from
|
|
22
|
+
# msg_id → (start,end) for O(1) lookup. Construction cost ≈ 5–20 ms for
|
|
23
|
+
# 50–200k records; memory ≈ a few MB. For a CLI invocation that's fine; the
|
|
24
|
+
# evaluator instantiates one lazily when first needed.
|
|
25
|
+
|
|
26
|
+
module Mailmate
|
|
27
|
+
# @api public
|
|
28
|
+
class IndexReader
|
|
29
|
+
RECORD_SIZE = 12
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Per-process cache of readers keyed by [name, db_headers]. Including
|
|
33
|
+
# db_headers means a Mailmate.config swap (e.g. a test pointing at a
|
|
34
|
+
# different tmpdir) doesn't return stale readers built from the old
|
|
35
|
+
# path.
|
|
36
|
+
def for(name)
|
|
37
|
+
@cache ||= {}
|
|
38
|
+
@cache[cache_key(name)] ||= new(name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Invalidate cached readers. With no argument, drops the entire cache
|
|
42
|
+
# (useful for tests or when MailMate's database swaps out). With a name,
|
|
43
|
+
# invalidates only entries for that name across all db_headers — the
|
|
44
|
+
# common case (cache-bust after a write) doesn't need to thread config
|
|
45
|
+
# through.
|
|
46
|
+
def reset!(name = nil)
|
|
47
|
+
if name.nil?
|
|
48
|
+
@cache = nil
|
|
49
|
+
elsif @cache
|
|
50
|
+
@cache.delete_if { |(n, _dir), _reader| n == name }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def cache_key(name)
|
|
57
|
+
[name, Mailmate.config.db_headers]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :name
|
|
62
|
+
|
|
63
|
+
def initialize(name)
|
|
64
|
+
@name = name
|
|
65
|
+
base = "#{Mailmate.config.db_headers}/#{name}"
|
|
66
|
+
raise ArgumentError, "Index not found: #{name} (looked at #{base}.{cache,offsets})" \
|
|
67
|
+
unless File.exist?("#{base}.cache") && File.exist?("#{base}.offsets")
|
|
68
|
+
|
|
69
|
+
@cache_bytes = File.binread("#{base}.cache")
|
|
70
|
+
@offsets_bytes = File.binread("#{base}.offsets")
|
|
71
|
+
build_index!
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the raw cached value for a given .eml body-part ID, or nil if
|
|
75
|
+
# the message isn't in this index.
|
|
76
|
+
def value_for(eml_id)
|
|
77
|
+
pair = @index[eml_id.to_i]
|
|
78
|
+
return nil unless pair
|
|
79
|
+
@cache_bytes[pair[0]...pair[1]]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `#flags.flag` semantics: the cache stores a space-separated list of IMAP
|
|
83
|
+
# keywords. Split into individual flag tokens.
|
|
84
|
+
def flags_for(eml_id)
|
|
85
|
+
v = value_for(eml_id)
|
|
86
|
+
return [] if v.nil? || v.empty?
|
|
87
|
+
v.split(/\s+/).reject(&:empty?)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Number of records (mostly for diagnostics).
|
|
91
|
+
def size
|
|
92
|
+
@index.size
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Iterate every recorded eml-id. Yields just the id; callers that also
|
|
96
|
+
# want the value should pair this with `value_for`. Exists so other gem
|
|
97
|
+
# modules don't have to reach into `@index` directly.
|
|
98
|
+
def each_eml_id(&block)
|
|
99
|
+
return enum_for(:each_eml_id) unless block
|
|
100
|
+
@index.each_key(&block)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Iterate every (eml_id, raw_value) pair. The value comes back as the
|
|
104
|
+
# bare cache substring; callers that need parsed form (e.g. flag tokens)
|
|
105
|
+
# should massage it themselves.
|
|
106
|
+
def each_record
|
|
107
|
+
return enum_for(:each_record) unless block_given?
|
|
108
|
+
@index.each_key do |eml_id|
|
|
109
|
+
yield eml_id, value_for(eml_id)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def build_index!
|
|
116
|
+
@index = {}
|
|
117
|
+
n = @offsets_bytes.bytesize / RECORD_SIZE
|
|
118
|
+
i = 0
|
|
119
|
+
while i < n
|
|
120
|
+
rec = @offsets_bytes[i * RECORD_SIZE, RECORD_SIZE].unpack("V3")
|
|
121
|
+
@index[rec[0]] = [rec[1], rec[2]]
|
|
122
|
+
i += 1
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lexer for MailMate filter expressions. Emits a flat array of [kind, value]
|
|
4
|
+
# tokens. Whitespace is skipped. Keywords (`and`, `or`, `not`, `exists`) are
|
|
5
|
+
# recognized at lex time; `ago`, `days`, `months`, etc. stay as :ident and are
|
|
6
|
+
# disambiguated by the parser.
|
|
7
|
+
|
|
8
|
+
module Mailmate
|
|
9
|
+
# @api private
|
|
10
|
+
module Lexer
|
|
11
|
+
KEYWORDS = %w[and or not exists].freeze
|
|
12
|
+
OP_FLAG_CHARS = "cafx" # operator-modifier flags, e.g. =[c], >[f], !=[x]
|
|
13
|
+
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
def self.lex(input)
|
|
17
|
+
tokens = []
|
|
18
|
+
i = 0
|
|
19
|
+
while i < input.length
|
|
20
|
+
c = input[i]
|
|
21
|
+
|
|
22
|
+
# Whitespace
|
|
23
|
+
if c =~ /\s/
|
|
24
|
+
i += 1
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Punctuation
|
|
29
|
+
if c == "("
|
|
30
|
+
tokens << [:lparen]; i += 1; next
|
|
31
|
+
elsif c == ")"
|
|
32
|
+
tokens << [:rparen]; i += 1; next
|
|
33
|
+
elsif c == "."
|
|
34
|
+
tokens << [:dot]; i += 1; next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Variable reference: $SENT, $PERSONAL_INBOX
|
|
38
|
+
if c == "$"
|
|
39
|
+
j = i + 1
|
|
40
|
+
j += 1 while j < input.length && input[j] =~ /[A-Z_]/
|
|
41
|
+
raise Error, "empty variable name at #{i}" if j == i + 1
|
|
42
|
+
tokens << [:var, input[(i + 1)...j]]
|
|
43
|
+
i = j
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# String: '...' Only `\\` and `\'` are recognized escapes; other
|
|
48
|
+
# backslashes are preserved literally so IMAP keywords like
|
|
49
|
+
# `\Seen` / `\Flagged` survive intact.
|
|
50
|
+
if c == "'"
|
|
51
|
+
j = i + 1
|
|
52
|
+
buf = +""
|
|
53
|
+
while j < input.length && input[j] != "'"
|
|
54
|
+
if input[j] == "\\" && j + 1 < input.length && (input[j + 1] == "\\" || input[j + 1] == "'")
|
|
55
|
+
buf << input[j + 1]
|
|
56
|
+
j += 2
|
|
57
|
+
else
|
|
58
|
+
buf << input[j]
|
|
59
|
+
j += 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
raise Error, "unterminated string starting at #{i}" if j >= input.length
|
|
63
|
+
tokens << [:string, buf]
|
|
64
|
+
i = j + 1
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Operator: !=, =, ~, !~, <, <=, >, >=, with optional [flags]
|
|
69
|
+
if "!=~<>".include?(c)
|
|
70
|
+
op = c
|
|
71
|
+
j = i + 1
|
|
72
|
+
if c == "!" && j < input.length && (input[j] == "=" || input[j] == "~")
|
|
73
|
+
op = "!" + input[j]
|
|
74
|
+
j += 1
|
|
75
|
+
elsif (c == "<" || c == ">") && j < input.length && input[j] == "="
|
|
76
|
+
op = c + "="
|
|
77
|
+
j += 1
|
|
78
|
+
end
|
|
79
|
+
# Optional [flags]
|
|
80
|
+
flags = []
|
|
81
|
+
if j < input.length && input[j] == "["
|
|
82
|
+
k = j + 1
|
|
83
|
+
while k < input.length && OP_FLAG_CHARS.include?(input[k])
|
|
84
|
+
flags << input[k]
|
|
85
|
+
k += 1
|
|
86
|
+
end
|
|
87
|
+
raise Error, "expected ] after operator flags at #{j}" if k >= input.length || input[k] != "]"
|
|
88
|
+
j = k + 1
|
|
89
|
+
end
|
|
90
|
+
tokens << [:op, op, flags]
|
|
91
|
+
i = j
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Number: 0-9+
|
|
96
|
+
if c =~ /\d/
|
|
97
|
+
j = i
|
|
98
|
+
j += 1 while j < input.length && input[j] =~ /\d/
|
|
99
|
+
tokens << [:number, input[i...j].to_i]
|
|
100
|
+
i = j
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Shorthand: # or ## followed by ident
|
|
105
|
+
if c == "#"
|
|
106
|
+
j = i
|
|
107
|
+
j += 1 while j < input.length && input[j] == "#"
|
|
108
|
+
start = j
|
|
109
|
+
j += 1 while j < input.length && input[j] =~ /[a-zA-Z0-9_-]/
|
|
110
|
+
raise Error, "empty shorthand at #{i}" if start == j
|
|
111
|
+
tokens << [:shorthand, input[i...j]]
|
|
112
|
+
i = j
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Identifier: [a-zA-Z_][a-zA-Z0-9_-]*
|
|
117
|
+
if c =~ /[a-zA-Z_]/
|
|
118
|
+
j = i
|
|
119
|
+
j += 1 while j < input.length && input[j] =~ /[a-zA-Z0-9_-]/
|
|
120
|
+
word = input[i...j]
|
|
121
|
+
if KEYWORDS.include?(word)
|
|
122
|
+
tokens << [:keyword, word]
|
|
123
|
+
else
|
|
124
|
+
tokens << [:ident, word]
|
|
125
|
+
end
|
|
126
|
+
i = j
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
raise Error, "unexpected char #{c.inspect} at position #{i}"
|
|
131
|
+
end
|
|
132
|
+
tokens << [:eof]
|
|
133
|
+
tokens
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# Reads MailMate's mailbox configuration: the user's `Mailboxes.plist` plus
|
|
6
|
+
# the bundled `standardMailboxes.plist`/`defaultMailboxes.plist`. Builds an
|
|
7
|
+
# in-memory graph keyed by UUID with name → uuid lookup.
|
|
8
|
+
|
|
9
|
+
module Mailmate
|
|
10
|
+
# @api public
|
|
11
|
+
class MailboxGraph
|
|
12
|
+
APP_RESOURCES = "/Applications/MailMate.app/Contents/Resources"
|
|
13
|
+
|
|
14
|
+
SPECIAL_UUIDS = %w[ALL_MESSAGES INBOX SENT DRAFTS ARCHIVE JUNK TRASH FLAGGED MAILBOXES PERSONAL_INBOX].freeze
|
|
15
|
+
|
|
16
|
+
# Mailbox = a Hash with keys: :uuid, :name, :parent, :set, :filter
|
|
17
|
+
attr_reader :by_uuid, :by_name
|
|
18
|
+
|
|
19
|
+
def self.load
|
|
20
|
+
new.tap(&:load!)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@by_uuid = {}
|
|
25
|
+
@by_name = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def load!
|
|
29
|
+
load_plist!("#{APP_RESOURCES}/standardMailboxes.plist")
|
|
30
|
+
load_plist!("#{APP_RESOURCES}/defaultMailboxes.plist")
|
|
31
|
+
load_plist!(Mailmate.config.mailboxes_plist)
|
|
32
|
+
build_name_index!
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def lookup(name_or_uuid)
|
|
37
|
+
@by_uuid[name_or_uuid] || @by_name[name_or_uuid]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def load_plist!(path)
|
|
43
|
+
return unless File.exist?(path)
|
|
44
|
+
data = JSON.parse(`plutil -convert json -o - #{shellesc(path)}`)
|
|
45
|
+
boxes = (data["mailboxes"] || []) + (data["deltaMailboxes"] || [])
|
|
46
|
+
boxes.each do |m|
|
|
47
|
+
next unless m["uuid"]
|
|
48
|
+
existing = @by_uuid[m["uuid"]] || {}
|
|
49
|
+
@by_uuid[m["uuid"]] = existing.merge(
|
|
50
|
+
uuid: m["uuid"],
|
|
51
|
+
name: m["name"] || existing[:name],
|
|
52
|
+
parent: m["parentUUID"] || existing[:parent],
|
|
53
|
+
set: m["set"] || existing[:set],
|
|
54
|
+
filter: m["filter"] || existing[:filter],
|
|
55
|
+
symbol: m["symbol"] || existing[:symbol],
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_name_index!
|
|
61
|
+
@by_uuid.each do |uuid, m|
|
|
62
|
+
next unless m[:name]
|
|
63
|
+
# Prefer user-defined entries (later loads) over defaults; later loads
|
|
64
|
+
# already overwrite earlier ones in @by_uuid via merge, but the name
|
|
65
|
+
# index can still get clobbered when two mailboxes share a name.
|
|
66
|
+
# We keep the most-recently-loaded — and for tied cases, prefer the
|
|
67
|
+
# one that has a filter (smart mailboxes are what users address by name).
|
|
68
|
+
existing = @by_name[m[:name]]
|
|
69
|
+
@by_name[m[:name]] = uuid if existing.nil? || @by_uuid[existing][:filter].nil? || m[:filter]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shellesc(s)
|
|
74
|
+
"'#{s.gsub("'", "'\\\\''")}'"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Message — thin wrapper that pairs a parsed Mail::Message with its `.eml`
|
|
4
|
+
# body-part ID and on-disk path. Stage B+ attribute paths (`#flags.flag`,
|
|
5
|
+
# `#date-last-viewed`, …) need the body-part ID to look up MailMate's binary
|
|
6
|
+
# Database.noindex/Headers indexes; carrying it alongside the Mail object lets
|
|
7
|
+
# the resolver fetch indexed values without re-deriving the ID.
|
|
8
|
+
#
|
|
9
|
+
# Delegates the headers-and-body interface back to Mail so existing code that
|
|
10
|
+
# expects a `Mail::Message` keeps working unchanged.
|
|
11
|
+
|
|
12
|
+
module Mailmate
|
|
13
|
+
# @api public
|
|
14
|
+
class Message
|
|
15
|
+
attr_reader :mail, :eml_id, :path
|
|
16
|
+
|
|
17
|
+
def initialize(mail, eml_id, path)
|
|
18
|
+
@mail = mail
|
|
19
|
+
@eml_id = eml_id.to_i
|
|
20
|
+
@path = path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Delegate the headers/body methods Attributes uses.
|
|
24
|
+
%i[from to cc bcc reply_to sender subject date message_id received body
|
|
25
|
+
text_part html_part attachments].each do |m|
|
|
26
|
+
define_method(m) { mail.public_send(m) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def [](key); mail[key]; end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailmate
|
|
4
|
+
# @api public
|
|
5
|
+
#
|
|
6
|
+
# Build a MailMate `mid:` URL from a Message-ID. MailMate registers the
|
|
7
|
+
# `mid:` scheme (RFC 2392) and resolves it to a specific message. The angle
|
|
8
|
+
# brackets that bracket the Message-ID in headers must be percent-encoded.
|
|
9
|
+
module MidUrl
|
|
10
|
+
# Returns `mid:%3C<message-id>%3E`. Accepts the Message-ID with or without
|
|
11
|
+
# surrounding angle brackets — strips them either way before encoding.
|
|
12
|
+
# URL-encodes characters that would break the URL parser, notably `[` and
|
|
13
|
+
# `]` (which appear in Message-IDs containing IPv4 literals, e.g.
|
|
14
|
+
# `<id@[169.254.16.253]>`). Other URL-reserved characters (`@`, `.`, `-`,
|
|
15
|
+
# `_`, etc.) are preserved — MailMate's parser accepts them as-is.
|
|
16
|
+
def self.for(message_id)
|
|
17
|
+
raise ArgumentError, "Message-ID required" if message_id.nil? || message_id.to_s.empty?
|
|
18
|
+
id = message_id.to_s.sub(/\A</, "").sub(/>\z/, "")
|
|
19
|
+
encoded = id.gsub(/[\[\]<>\s]/) { |c| "%%%02X" % c.ord }
|
|
20
|
+
"mid:%3C#{encoded}%3E"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
# Operator evaluator + date arithmetic for MailMate filter expressions.
|
|
7
|
+
|
|
8
|
+
module Mailmate
|
|
9
|
+
# @api private
|
|
10
|
+
module Operators
|
|
11
|
+
# Compare a single LHS value against a single RHS, applying modifier flags.
|
|
12
|
+
# Returns true/false. lhs may be String, Time/DateTime, Numeric, or
|
|
13
|
+
# Mailmate::Attributes::AddressValue (which to_s renders as "Name <addr>").
|
|
14
|
+
def self.compare(lhs, op, flags, rhs)
|
|
15
|
+
lhs_norm = normalize(lhs, flags)
|
|
16
|
+
rhs_norm = normalize(rhs, flags)
|
|
17
|
+
|
|
18
|
+
case op
|
|
19
|
+
when "=" then lhs_norm == rhs_norm
|
|
20
|
+
when "!=" then lhs_norm != rhs_norm
|
|
21
|
+
when "~" then lhs_norm.to_s.include?(rhs_norm.to_s)
|
|
22
|
+
when "!~" then !lhs_norm.to_s.include?(rhs_norm.to_s)
|
|
23
|
+
when "<", "<=", ">", ">="
|
|
24
|
+
compare_ordered(lhs_norm, op, rhs_norm)
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "unknown operator: #{op.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.normalize(v, flags)
|
|
31
|
+
return v if v.is_a?(Time) || v.is_a?(DateTime) || v.is_a?(Numeric)
|
|
32
|
+
s = v.to_s
|
|
33
|
+
s = s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "") if flags.include?("a")
|
|
34
|
+
s = s.downcase if flags.include?("c")
|
|
35
|
+
s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.compare_ordered(lhs, op, rhs)
|
|
39
|
+
lhs_v = coerce_for_ordering(lhs)
|
|
40
|
+
rhs_v = coerce_for_ordering(rhs)
|
|
41
|
+
return false if lhs_v.nil? || rhs_v.nil?
|
|
42
|
+
case op
|
|
43
|
+
when "<" then lhs_v < rhs_v
|
|
44
|
+
when "<=" then lhs_v <= rhs_v
|
|
45
|
+
when ">" then lhs_v > rhs_v
|
|
46
|
+
when ">=" then lhs_v >= rhs_v
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.coerce_for_ordering(v)
|
|
53
|
+
return v.to_time if v.is_a?(DateTime)
|
|
54
|
+
return v if v.is_a?(Time) || v.is_a?(Numeric)
|
|
55
|
+
s = v.to_s
|
|
56
|
+
# Try Time, fall back to Integer
|
|
57
|
+
begin
|
|
58
|
+
return Time.parse(s)
|
|
59
|
+
rescue ArgumentError
|
|
60
|
+
end
|
|
61
|
+
Integer(s) rescue Float(s) rescue nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# ---- Date arithmetic ----
|
|
65
|
+
|
|
66
|
+
# Resolve a relative date (n units ago) to a Time.
|
|
67
|
+
# `flags` may include "f", which floors to the start of the unit
|
|
68
|
+
# (matches MailMate's >[f] / <[f] semantics).
|
|
69
|
+
def self.relative_date(n, unit, flags = [])
|
|
70
|
+
now = Time.now
|
|
71
|
+
base =
|
|
72
|
+
case unit
|
|
73
|
+
when :day then now - n * 86_400
|
|
74
|
+
when :week then now - n * 7 * 86_400
|
|
75
|
+
when :month
|
|
76
|
+
y = now.year
|
|
77
|
+
m = now.month - n
|
|
78
|
+
while m <= 0
|
|
79
|
+
m += 12
|
|
80
|
+
y -= 1
|
|
81
|
+
end
|
|
82
|
+
Time.new(y, m, [now.day, last_day_of_month(y, m)].min, now.hour, now.min, now.sec)
|
|
83
|
+
when :year
|
|
84
|
+
Time.new(now.year - n, now.month, now.day, now.hour, now.min, now.sec)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return base unless flags.include?("f")
|
|
88
|
+
|
|
89
|
+
case unit
|
|
90
|
+
when :day
|
|
91
|
+
Time.new(base.year, base.month, base.day)
|
|
92
|
+
when :week
|
|
93
|
+
# Snap to start of ISO week (Monday)
|
|
94
|
+
d = base.to_date
|
|
95
|
+
d -= (d.cwday - 1) # Mon = 1
|
|
96
|
+
Time.new(d.year, d.month, d.day)
|
|
97
|
+
when :month
|
|
98
|
+
Time.new(base.year, base.month, 1)
|
|
99
|
+
when :year
|
|
100
|
+
Time.new(base.year, 1, 1)
|
|
101
|
+
else
|
|
102
|
+
base
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.last_day_of_month(y, m)
|
|
107
|
+
Date.new(y, m, -1).day
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|