QFtpCompat 1.0.0
Qt6-compatible async FTP client library
Loading...
Searching...
No Matches
qftpcompatdirentry.cpp
1// SPDX-License-Identifier: MIT
2
4
5#include <QByteArray>
6#include <QDebug>
7#include <QRegularExpression>
8#include <QStringList>
9#include <QTextStream>
10#include <QTimeZone>
11
12// =============================================================================
13// Public factory
14// =============================================================================
15
16QVector<QFtpCompatDirEntry> QFtpCompatDirEntry::parseList(const QByteArray &rawListing)
17{
18 QVector<QFtpCompatDirEntry> result;
19
20 const QString listing = QString::fromUtf8(rawListing);
21 const QStringList lines = listing.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
22
23 for (const QString &rawLine : lines) {
24 const QString line = rawLine.trimmed();
25 if (line.isEmpty()) {
26 continue;
27 }
28
29 // Try each format in order of specificity.
30 QFtpCompatDirEntry entry;
31
32 if (line.contains(QLatin1Char('='))) {
33 // MLSD lines always contain '=' (e.g. "Type=file;Size=…")
34 entry = parseMlsd(line);
35 } else if (!line.isEmpty() && line.at(0).isLetter()
36 && line.size() > 1 && line.at(1) == QLatin1Char('-')) {
37 // Windows: "01-01-21 12:00PM …" – digit, dash, digit pattern
38 entry = parseWindows(line);
39 } else {
40 // Fall back to Unix ls-style
41 entry = parseUnix(line);
42 }
43
44 if (!entry.name().isEmpty()) {
45 result.append(entry);
46 } else {
47 qDebug() << "QFtpCompatDirEntry: could not parse listing line:" << line;
48 }
49 }
50
51 return result;
52}
53
54// =============================================================================
55// MLSD parser (RFC 3659)
56// Format: "Type=file;Size=12345;Modify=20210101120000; filename"
57// =============================================================================
58
59QFtpCompatDirEntry QFtpCompatDirEntry::parseMlsd(const QString &line)
60{
61 // Mandatory space separates facts from the file name
62 const int spaceIdx = line.indexOf(QLatin1Char(' '));
63 if (spaceIdx < 0) {
64 return {};
65 }
66
67 QFtpCompatDirEntry e;
68 e.m_name = line.mid(spaceIdx + 1);
69
70 const QStringList facts = line.left(spaceIdx).split(QLatin1Char(';'), Qt::SkipEmptyParts);
71 for (const QString &fact : facts) {
72 const int eq = fact.indexOf(QLatin1Char('='));
73 if (eq < 0) {
74 continue;
75 }
76 const QString key = fact.left(eq).toLower();
77 const QString value = fact.mid(eq + 1);
78
79 if (key == QLatin1String("type")) {
80 const QString vl = value.toLower();
81 e.m_isDirectory = (vl == QLatin1String("dir") || vl == QLatin1String("cdir")
82 || vl == QLatin1String("pdir"));
83 } else if (key == QLatin1String("size")) {
84 e.m_size = value.toLongLong();
85 } else if (key == QLatin1String("modify")) {
86 // YYYYMMDDHHmmss[.fraction]
87 e.m_lastModified = QDateTime::fromString(value.left(14),
88 QStringLiteral("yyyyMMddHHmmss"));
89 e.m_lastModified.setTimeZone(QTimeZone::UTC);
90 } else if (key == QLatin1String("unix.mode")) {
91 e.m_permissions = value;
92 } else if (key == QLatin1String("unix.owner")) {
93 e.m_owner = value;
94 } else if (key == QLatin1String("unix.group")) {
95 e.m_group = value;
96 }
97 }
98
99 return e;
100}
101
102// =============================================================================
103// Unix/POSIX parser
104// Format: "-rw-r--r-- 1 owner group 12345 Jan 1 12:00 name"
105// "lrwxrwxrwx 1 owner group 11 Jan 1 12:00 link -> target"
106// =============================================================================
107
108QFtpCompatDirEntry QFtpCompatDirEntry::parseUnix(const QString &line)
109{
110 static const QRegularExpression re(
111 QStringLiteral(
112 // 1: permissions 2: links 3: owner 4: group 5: size
113 R"(^([a-zA-Z\-]{10})\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+)"
114 // 6: month 7: day 8: year-or-time 9: name
115 R"((\w{3})\s+(\d{1,2})\s+(\d{4}|\d{2}:\d{2})\s+(.+)$)"
116 )
117 );
118
119 const QRegularExpressionMatch m = re.match(line);
120 if (!m.hasMatch()) {
121 return {};
122 }
123
124 QFtpCompatDirEntry e;
125 e.m_permissions = m.captured(1);
126 e.m_owner = m.captured(3);
127 e.m_group = m.captured(4);
128 e.m_size = m.captured(5).toLongLong();
129
130 const QChar typeChar = e.m_permissions.isEmpty() ? QLatin1Char('-')
131 : e.m_permissions.at(0);
132 e.m_isDirectory = (typeChar == QLatin1Char('d'));
133 e.m_isSymLink = (typeChar == QLatin1Char('l'));
134
135 // Date parsing
136 const QString month = m.captured(6);
137 const int day = m.captured(7).toInt();
138 const QString yearOrTime = m.captured(8);
139
140 static const QStringList monthNames = {
141 QStringLiteral("Jan"), QStringLiteral("Feb"), QStringLiteral("Mar"),
142 QStringLiteral("Apr"), QStringLiteral("May"), QStringLiteral("Jun"),
143 QStringLiteral("Jul"), QStringLiteral("Aug"), QStringLiteral("Sep"),
144 QStringLiteral("Oct"), QStringLiteral("Nov"), QStringLiteral("Dec")
145 };
146 const int monthNum = monthNames.indexOf(month) + 1; // 0-based → 1-based
147
148 if (yearOrTime.contains(QLatin1Char(':'))) {
149 // Time without year: infer year (use previous year if date is in the future)
150 const QStringList parts = yearOrTime.split(QLatin1Char(':'));
151 const int hour = parts.value(0).toInt();
152 const int minute = parts.value(1).toInt();
153 int year = QDate::currentDate().year();
154 if (QDate(year, monthNum, day) > QDate::currentDate()) {
155 year--;
156 }
157 e.m_lastModified = QDateTime(QDate(year, monthNum, day),
158 QTime(hour, minute),
159 QTimeZone::LocalTime);
160 } else {
161 const int year = yearOrTime.toInt();
162 e.m_lastModified = QDateTime(QDate(year, monthNum, day),
163 QTime(0, 0),
164 QTimeZone::LocalTime);
165 }
166
167 // Handle symlink "name -> target"
168 QString name = m.captured(9);
169 if (e.m_isSymLink) {
170 const int arrowIdx = name.indexOf(QStringLiteral(" -> "));
171 if (arrowIdx >= 0) {
172 e.m_linkTarget = name.mid(arrowIdx + 4);
173 name = name.left(arrowIdx);
174 }
175 }
176 e.m_name = name;
177
178 return e;
179}
180
181// =============================================================================
182// Windows/MS-DOS parser
183// Format: "01-01-21 12:00PM <DIR> dirname"
184// "01-01-21 12:00PM 12345 filename"
185// =============================================================================
186
187QFtpCompatDirEntry QFtpCompatDirEntry::parseWindows(const QString &line)
188{
189 static const QRegularExpression re(
190 QStringLiteral(
191 // 1: date (MM-DD-YY) 2: time 3: <DIR> or size 4: name
192 R"(^(\d{2}-\d{2}-\d{2})\s+(\d{2}:\d{2}[AP]M)\s+(<DIR>|\d+)\s+(.+)$)"
193 )
194 );
195
196 const QRegularExpressionMatch m = re.match(line);
197 if (!m.hasMatch()) {
198 return {};
199 }
200
201 QFtpCompatDirEntry e;
202
203 const QString dateStr = m.captured(1); // MM-DD-YY
204 const QString timeStr = m.captured(2); // HH:MMxM
205 const QString sizeOrDir = m.captured(3);
206 e.m_name = m.captured(4);
207
208 e.m_isDirectory = (sizeOrDir == QLatin1String("<DIR>"));
209 if (!e.m_isDirectory) {
210 e.m_size = sizeOrDir.toLongLong();
211 }
212
213 // Parse date MM-DD-YY
214 const QStringList dateParts = dateStr.split(QLatin1Char('-'));
215 if (dateParts.size() == 3) {
216 int year = dateParts.at(2).toInt();
217 year += (year < 70) ? 2000 : 1900; // pivot at 1970
218 const int month = dateParts.at(0).toInt();
219 const int day = dateParts.at(1).toInt();
220
221 // Parse time HH:MMxM
222 const bool isPm = timeStr.endsWith(QLatin1String("PM"), Qt::CaseInsensitive);
223 const QStringList tp = timeStr.left(5).split(QLatin1Char(':'));
224 int hour = tp.value(0).toInt();
225 if (isPm && hour != 12) { hour += 12; }
226 if (!isPm && hour == 12) { hour = 0; }
227 const int minute = tp.value(1).toInt();
228
229 e.m_lastModified = QDateTime(QDate(year, month, day),
230 QTime(hour, minute),
231 QTimeZone::LocalTime);
232 }
233
234 return e;
235}
236
237// =============================================================================
238// toString
239// =============================================================================
240
242{
243 return QStringLiteral("[%1] %2 size=%3 modified=%4 owner=%5:%6")
244 .arg(m_isDirectory ? QLatin1String("DIR ") : QLatin1String("FILE"),
245 m_name,
246 QString::number(m_size),
247 m_lastModified.toString(Qt::ISODate),
248 m_owner,
249 m_group);
250}
Represents a single entry from an FTP LIST response.
QString name() const noexcept
File or directory name (never includes the path).
QString toString() const
Returns a human-readable summary for debug output.
static QVector< QFtpCompatDirEntry > parseList(const QByteArray &rawListing)
Parses a full LIST / MLSD response body into directory entries.
FTP directory entry returned by QFtpCompat::list().