expo-tiddlywiki-filesystem-android-external-storage 2.2.10 → 2.2.13

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.
@@ -0,0 +1,158 @@
1
+ import Foundation
2
+
3
+ /// Minimal tar archive extractor — iOS equivalent of the Kotlin implementation.
4
+ /// Supports POSIX ustar, GNU long-name (type 'L'), and POSIX pax (type 'x'/'g') extensions.
5
+ enum TarExtractor {
6
+
7
+ private static let blockSize = 512
8
+
9
+ /// Extract an uncompressed tar archive to a destination directory.
10
+ /// Returns the number of files extracted.
11
+ static func extract(tarPath: String, destDir: String) throws -> Int {
12
+ let fm = FileManager.default
13
+ try fm.createDirectory(atPath: destDir, withIntermediateDirectories: true, attributes: nil)
14
+
15
+ guard let handle = FileHandle(forReadingAtPath: tarPath) else {
16
+ throw NSError(domain: "ENOENT", code: 2, userInfo: [NSLocalizedDescriptionKey: "Tar file not found: \(tarPath)"])
17
+ }
18
+ defer { handle.closeFile() }
19
+
20
+ let destURL = URL(fileURLWithPath: destDir).standardized
21
+ var filesExtracted = 0
22
+ var longName: String?
23
+
24
+ while true {
25
+ let headerData = handle.readData(ofLength: blockSize)
26
+ if headerData.count < blockSize { break }
27
+
28
+ // Check for end-of-archive (two consecutive zero blocks)
29
+ if headerData.allSatisfy({ $0 == 0 }) { break }
30
+
31
+ // Extract filename from header
32
+ let rawName = extractString(from: headerData, offset: 0, maxLen: 100)
33
+ let typeFlag = headerData.count > 156 ? headerData[156] : 0
34
+
35
+ // Parse file size from octal
36
+ let sizeString = extractString(from: headerData, offset: 124, maxLen: 12)
37
+ let fileSize = UInt64(sizeString.trimmingCharacters(in: .whitespaces), radix: 8) ?? 0
38
+
39
+ // POSIX ustar prefix
40
+ let prefix = extractString(from: headerData, offset: 345, maxLen: 155)
41
+
42
+ // Handle special entry types
43
+ if typeFlag == UInt8(ascii: "L") {
44
+ // GNU long name
45
+ let nameData = readExactly(handle: handle, count: Int(fileSize))
46
+ longName = String(data: nameData, encoding: .utf8)?.trimmingCharacters(in: CharacterSet(charactersIn: "\0"))
47
+ skipToBlockBoundary(handle: handle, fileSize: fileSize)
48
+ continue
49
+ }
50
+
51
+ if typeFlag == UInt8(ascii: "x") || typeFlag == UInt8(ascii: "g") {
52
+ // POSIX pax extended header — parse for "path" keyword
53
+ let paxData = readExactly(handle: handle, count: Int(fileSize))
54
+ skipToBlockBoundary(handle: handle, fileSize: fileSize)
55
+ if let paxStr = String(data: paxData, encoding: .utf8) {
56
+ for line in paxStr.components(separatedBy: "\n") {
57
+ // Format: "len key=value\n"
58
+ if let eqIndex = line.firstIndex(of: "=") {
59
+ let beforeEq = line[line.startIndex..<eqIndex]
60
+ if let spaceIndex = beforeEq.lastIndex(of: " ") {
61
+ let key = String(beforeEq[beforeEq.index(after: spaceIndex)...])
62
+ if key == "path" {
63
+ longName = String(line[line.index(after: eqIndex)...])
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ continue
70
+ }
71
+
72
+ // Skip directories and non-regular files
73
+ if typeFlag == UInt8(ascii: "5") || typeFlag == UInt8(ascii: "2") {
74
+ skipDataBlocks(handle: handle, fileSize: fileSize)
75
+ longName = nil
76
+ continue
77
+ }
78
+
79
+ // Determine final filename
80
+ let entryName: String
81
+ if let ln = longName {
82
+ entryName = ln
83
+ longName = nil
84
+ } else if !prefix.isEmpty {
85
+ entryName = "\(prefix)/\(rawName)"
86
+ } else {
87
+ entryName = rawName
88
+ }
89
+
90
+ guard !entryName.isEmpty else {
91
+ skipDataBlocks(handle: handle, fileSize: fileSize)
92
+ continue
93
+ }
94
+
95
+ // Path traversal protection
96
+ let destFile = destURL.appendingPathComponent(entryName).standardized
97
+ guard destFile.path.hasPrefix(destURL.path) else {
98
+ skipDataBlocks(handle: handle, fileSize: fileSize)
99
+ continue
100
+ }
101
+
102
+ // Create parent directories
103
+ try fm.createDirectory(
104
+ at: destFile.deletingLastPathComponent(),
105
+ withIntermediateDirectories: true,
106
+ attributes: nil
107
+ )
108
+
109
+ // Read file content
110
+ let contentData = readExactly(handle: handle, count: Int(fileSize))
111
+ skipToBlockBoundary(handle: handle, fileSize: fileSize)
112
+ try contentData.write(to: destFile)
113
+ filesExtracted += 1
114
+ }
115
+
116
+ return filesExtracted
117
+ }
118
+
119
+ // ─── Helpers ─────────────────────────────────────────────────────
120
+
121
+ private static func extractString(from data: Data, offset: Int, maxLen: Int) -> String {
122
+ let end = min(offset + maxLen, data.count)
123
+ guard offset < end else { return "" }
124
+ let slice = data[offset..<end]
125
+ // Find null terminator
126
+ let nullIndex = slice.firstIndex(of: 0) ?? end
127
+ let stringSlice = data[offset..<nullIndex]
128
+ return String(data: stringSlice, encoding: .utf8) ?? ""
129
+ }
130
+
131
+ private static func readExactly(handle: FileHandle, count: Int) -> Data {
132
+ guard count > 0 else { return Data() }
133
+ var result = Data()
134
+ var remaining = count
135
+ while remaining > 0 {
136
+ let chunk = handle.readData(ofLength: remaining)
137
+ if chunk.isEmpty { break }
138
+ result.append(chunk)
139
+ remaining -= chunk.count
140
+ }
141
+ return result
142
+ }
143
+
144
+ private static func skipDataBlocks(handle: FileHandle, fileSize: UInt64) {
145
+ guard fileSize > 0 else { return }
146
+ let totalBytes = ((fileSize + UInt64(blockSize - 1)) / UInt64(blockSize)) * UInt64(blockSize)
147
+ handle.seek(toFileOffset: handle.offsetInFile + totalBytes)
148
+ }
149
+
150
+ private static func skipToBlockBoundary(handle: FileHandle, fileSize: UInt64) {
151
+ guard fileSize > 0 else { return }
152
+ let remainder = fileSize % UInt64(blockSize)
153
+ if remainder > 0 {
154
+ let padding = UInt64(blockSize) - remainder
155
+ handle.seek(toFileOffset: handle.offsetInFile + padding)
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,254 @@
1
+ import Foundation
2
+
3
+ /// TiddlyWiki batch file parser — iOS equivalent of the Kotlin implementation.
4
+ /// Parses .tid, .json, and .meta files and returns a JSON array string.
5
+ enum TiddlerParser {
6
+
7
+ /// Parse a batch of tiddler files and return a JSON array string.
8
+ static func batchParse(filePaths: [String], quickLoadMode: Bool) -> String {
9
+ let fm = FileManager.default
10
+ var results = [[String: Any]]()
11
+
12
+ for path in filePaths {
13
+ guard fm.fileExists(atPath: path) else { continue }
14
+ let filename = (path as NSString).lastPathComponent
15
+ let ext = (filename as NSString).pathExtension.lowercased()
16
+
17
+ switch ext {
18
+ case "tid":
19
+ if let tiddler = parseDotTid(path: path, quickLoadMode: quickLoadMode) {
20
+ results.append(tiddler)
21
+ }
22
+ case "json":
23
+ if let parsed = parseDotJson(path: path) {
24
+ if let array = parsed as? [[String: Any]] {
25
+ results.append(contentsOf: array)
26
+ } else if let single = parsed as? [String: Any] {
27
+ results.append(single)
28
+ }
29
+ }
30
+ case "meta":
31
+ if let tiddler = parseDotMeta(path: path, quickLoadMode: quickLoadMode) {
32
+ results.append(tiddler)
33
+ }
34
+ default:
35
+ break
36
+ }
37
+ }
38
+
39
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: results, options: []) else {
40
+ return "[]"
41
+ }
42
+ return String(data: jsonData, encoding: .utf8) ?? "[]"
43
+ }
44
+
45
+ // ─── .tid parser ─────────────────────────────────────────────────
46
+
47
+ private static func parseDotTid(path: String, quickLoadMode: Bool) -> [String: Any]? {
48
+ guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }
49
+
50
+ var fields = [String: Any]()
51
+
52
+ // Find the first blank line separating headers from body
53
+ let blankLinePattern = try? NSRegularExpression(pattern: "\\r?\\n\\r?\\n")
54
+ let range = NSRange(content.startIndex..<content.endIndex, in: content)
55
+ let match = blankLinePattern?.firstMatch(in: content, range: range)
56
+
57
+ let headerText: String
58
+ let bodyOffset: Int
59
+ let estimatedBodyLength: Int
60
+
61
+ if let match = match {
62
+ let headerRange = content.startIndex..<content.index(content.startIndex, offsetBy: match.range.location)
63
+ headerText = String(content[headerRange])
64
+ bodyOffset = match.range.location + match.range.length
65
+ estimatedBodyLength = content.utf8.count - bodyOffset
66
+ } else {
67
+ headerText = content
68
+ bodyOffset = -1
69
+ estimatedBodyLength = 0
70
+ }
71
+
72
+ // Parse header lines
73
+ for line in headerText.components(separatedBy: .newlines) {
74
+ guard let colonIndex = line.firstIndex(of: ":") else { continue }
75
+ let fieldName = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
76
+ let fieldValue = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
77
+ if !fieldName.isEmpty {
78
+ fields[fieldName] = fieldValue
79
+ }
80
+ }
81
+
82
+ // Use filename as title fallback
83
+ if fields["title"] == nil {
84
+ fields["title"] = getTitleFromFilename((path as NSString).lastPathComponent)
85
+ }
86
+
87
+ let title = fields["title"] as? String ?? ""
88
+ let type = fields["type"] as? String ?? ""
89
+ let hasModuleType = fields["module-type"] != nil
90
+ let hasPluginType = fields["plugin-type"] != nil
91
+
92
+ // Determine if we should include full text
93
+ let shouldIncludeText: Bool
94
+ if quickLoadMode {
95
+ shouldIncludeText = shouldPreserveFullTextInQuickLoad(
96
+ title: title, type: type, hasModuleType: hasModuleType, hasPluginType: hasPluginType
97
+ )
98
+ } else {
99
+ shouldIncludeText = shouldSaveFullTiddler(
100
+ title: title, type: type, hasModuleType: hasModuleType, hasPluginType: hasPluginType,
101
+ estimatedTextLength: estimatedBodyLength
102
+ )
103
+ }
104
+
105
+ if shouldIncludeText, bodyOffset >= 0, estimatedBodyLength > 0 {
106
+ let bodyStartIndex = content.index(content.startIndex, offsetBy: bodyOffset)
107
+ fields["text"] = String(content[bodyStartIndex...])
108
+ } else if !shouldIncludeText {
109
+ fields.removeValue(forKey: "text")
110
+ fields["_is_skinny"] = "yes"
111
+ }
112
+
113
+ return fields
114
+ }
115
+
116
+ // ─── .json parser ────────────────────────────────────────────────
117
+
118
+ private static func parseDotJson(path: String) -> Any? {
119
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
120
+
121
+ guard let json = try? JSONSerialization.jsonObject(with: data) else { return nil }
122
+
123
+ if let array = json as? [[String: Any]] {
124
+ let filtered = array.filter { $0["title"] != nil }
125
+ return filtered.isEmpty ? nil : filtered
126
+ } else if let obj = json as? [String: Any] {
127
+ if obj["title"] != nil {
128
+ return obj
129
+ }
130
+ // Plugin bundle format — skip
131
+ return nil
132
+ }
133
+ return nil
134
+ }
135
+
136
+ // ─── .meta parser ───────────────────────────────────────────────
137
+
138
+ private static let textCompanionExtensions: Set<String> = [
139
+ "json", "js", "css", "svg", "txt", "html", "htm"
140
+ ]
141
+
142
+ private static func parseDotMeta(path: String, quickLoadMode: Bool) -> [String: Any]? {
143
+ guard let metaContent = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }
144
+
145
+ var fields = [String: Any]()
146
+
147
+ for line in metaContent.components(separatedBy: .newlines) {
148
+ guard let colonIndex = line.firstIndex(of: ":") else { continue }
149
+ let fieldName = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
150
+ let fieldValue = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
151
+ if !fieldName.isEmpty {
152
+ fields[fieldName] = fieldValue
153
+ }
154
+ }
155
+
156
+ if fields["title"] == nil {
157
+ let metaFilename = (path as NSString).lastPathComponent
158
+ let baseName = (metaFilename as NSString).deletingPathExtension // removes .meta
159
+ fields["title"] = getTitleFromFilename(baseName)
160
+ }
161
+
162
+ // Find companion file
163
+ let companionPath = (path as NSString).deletingPathExtension // removes .meta
164
+ let fm = FileManager.default
165
+
166
+ if fm.fileExists(atPath: companionPath) {
167
+ let companionExt = (companionPath as NSString).pathExtension.lowercased()
168
+
169
+ if companionExt == "json" {
170
+ // .meta + .json pair
171
+ let title = fields["title"] as? String ?? ""
172
+ let type = fields["type"] as? String ?? ""
173
+ let hasModuleType = fields["module-type"] != nil
174
+ let hasPluginType = fields["plugin-type"] != nil
175
+
176
+ let shouldIncludeText: Bool
177
+ if quickLoadMode {
178
+ shouldIncludeText = shouldPreserveFullTextInQuickLoad(
179
+ title: title, type: type, hasModuleType: hasModuleType, hasPluginType: hasPluginType
180
+ )
181
+ } else {
182
+ shouldIncludeText = true
183
+ }
184
+
185
+ if shouldIncludeText {
186
+ if let jsonContent = try? String(contentsOfFile: companionPath, encoding: .utf8) {
187
+ fields["text"] = jsonContent
188
+ }
189
+ } else {
190
+ fields["_is_skinny"] = "yes"
191
+ }
192
+ } else if textCompanionExtensions.contains(companionExt) {
193
+ // Text companion — include if not quick-load-skinny
194
+ let title = fields["title"] as? String ?? ""
195
+ let type = fields["type"] as? String ?? ""
196
+ let hasModuleType = fields["module-type"] != nil
197
+ let hasPluginType = fields["plugin-type"] != nil
198
+
199
+ let shouldIncludeText: Bool
200
+ if quickLoadMode {
201
+ shouldIncludeText = shouldPreserveFullTextInQuickLoad(
202
+ title: title, type: type, hasModuleType: hasModuleType, hasPluginType: hasPluginType
203
+ )
204
+ } else {
205
+ shouldIncludeText = true
206
+ }
207
+
208
+ if shouldIncludeText {
209
+ if let textContent = try? String(contentsOfFile: companionPath, encoding: .utf8) {
210
+ fields["text"] = textContent
211
+ }
212
+ } else {
213
+ fields["_is_skinny"] = "yes"
214
+ }
215
+ }
216
+ // Binary companions (images etc.) — no text set
217
+ }
218
+
219
+ return fields["title"] != nil ? fields : nil
220
+ }
221
+
222
+ // ─── Helpers ─────────────────────────────────────────────────────
223
+
224
+ static func getTitleFromFilename(_ filename: String) -> String {
225
+ var name = filename
226
+ for ext in [".tid", ".json", ".meta"] {
227
+ if name.lowercased().hasSuffix(ext) {
228
+ name = String(name.dropLast(ext.count))
229
+ break
230
+ }
231
+ }
232
+ return name
233
+ }
234
+
235
+ static func shouldSaveFullTiddler(
236
+ title: String, type: String, hasModuleType: Bool, hasPluginType: Bool,
237
+ estimatedTextLength: Int
238
+ ) -> Bool {
239
+ if shouldPreserveFullTextInQuickLoad(title: title, type: type, hasModuleType: hasModuleType, hasPluginType: hasPluginType) {
240
+ return true
241
+ }
242
+ if estimatedTextLength < 10000 { return true }
243
+ return false
244
+ }
245
+
246
+ static func shouldPreserveFullTextInQuickLoad(
247
+ title: String, type: String, hasModuleType: Bool, hasPluginType: Bool
248
+ ) -> Bool {
249
+ if title.hasPrefix("$:/") { return true }
250
+ if type == "application/json" && hasPluginType { return true }
251
+ if hasModuleType { return true }
252
+ return false
253
+ }
254
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "expo-tiddlywiki-filesystem-android-external-storage",
3
- "version": "2.2.10",
4
- "description": "Expo native module for TidGi-Mobile: external storage I/O + TiddlyWiki .tid/.meta/.json batch parsing in Kotlin",
3
+ "version": "2.2.13",
4
+ "description": "Expo native module for TidGi-Mobile: filesystem I/O + TiddlyWiki .tid/.meta/.json batch parsing + git status in Kotlin (Android) and Swift (iOS)",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
7
7
  "plugin": "app.plugin.js",
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "build",
41
41
  "android",
42
+ "ios",
42
43
  "expo-module.config.json",
43
44
  "src",
44
45
  "plugin.js",
package/src/index.ts CHANGED
@@ -19,8 +19,8 @@ let _module: IExternalStorageModule | undefined;
19
19
  */
20
20
  function getNativeModule(): IExternalStorageModule {
21
21
  if (_module) return _module;
22
- if (Platform.OS !== 'android') {
23
- throw new Error('ExternalStorage native module is only available on Android');
22
+ if (Platform.OS !== 'android' && Platform.OS !== 'ios') {
23
+ throw new Error('ExternalStorage native module is only available on Android and iOS');
24
24
  }
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
26
  const { requireNativeModule } = require('expo-modules-core') as { requireNativeModule: (name: string) => IExternalStorageModule };