mirror of
https://github.com/garraflavatra/go-fmp.git
synced 2025-06-28 04:25:11 +00:00
Compare commits
22 Commits
88ea33c76e
...
main
Author | SHA1 | Date | |
---|---|---|---|
c08c429b99 | |||
4ab7f0a588 | |||
7cf3fd93ac | |||
d22b209ca5 | |||
7359962d98 | |||
c261a15041 | |||
cc10a1e7b4 | |||
f7fef208f8 | |||
10285084eb | |||
53134a0d09 | |||
cc8a602b51 | |||
bd871b6457 | |||
b94fa28ba9 | |||
d9ffc3e573 | |||
a7bde87c6f | |||
b44c90da30 | |||
193f23a20c | |||
ae32067c68 | |||
f364ed7ede | |||
17a3664e42 | |||
ba62b66f63 | |||
3c5a80b71c |
27
.github/workflows/ci.yml
vendored
Normal file
27
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23.0'
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
working-directory: fmp
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
working-directory: fmp
|
Binary file not shown.
195
fmp/fmp_chunk.go
195
fmp/fmp_chunk.go
@ -1,195 +0,0 @@
|
||||
package fmp
|
||||
|
||||
func (ctx *FmpFile) readChunk(payload []byte) (*FmpChunk, error) {
|
||||
|
||||
// https://github.com/evanmiller/fmptools/blob/02eb770e59e0866dab213d80e5f7d88e17648031/HACKING
|
||||
// https://github.com/Rasmus20B/fmplib/blob/66245e5269275724bacfe1437fb1f73bc587a2f3/src/fmp_format/chunk.rs#L57-L60
|
||||
|
||||
chunk := &FmpChunk{}
|
||||
|
||||
if (payload[0] & 0xC0) == 0xC0 {
|
||||
payload[0] &= 0x3F
|
||||
chunk.Delayed = true
|
||||
}
|
||||
|
||||
switch payload[0] {
|
||||
case 0x00:
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[1 : 1+1]
|
||||
chunk.Length = 2
|
||||
|
||||
case 0x01:
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_KEY_VALUE
|
||||
chunk.Key = uint64(payload[1])
|
||||
chunk.Value = payload[2 : 2+1]
|
||||
chunk.Length = 3
|
||||
|
||||
case 0x02, 0x03, 0x04, 0x05:
|
||||
valueLength := 2 * (payload[0] - 1)
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_KEY_VALUE
|
||||
chunk.Key = uint64(payload[1])
|
||||
chunk.Value = payload[2 : 2+valueLength]
|
||||
chunk.Length = 2 + uint64(valueLength)
|
||||
|
||||
case 0x06:
|
||||
valueLength := payload[2]
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_KEY_VALUE
|
||||
chunk.Key = uint64(payload[1])
|
||||
chunk.Value = payload[3 : 3+valueLength]
|
||||
chunk.Length = 3 + uint64(valueLength)
|
||||
|
||||
case 0x07:
|
||||
valueLength := parseVarUint64(payload[2 : 2+2])
|
||||
payloadLimit := min(4+valueLength, uint64(len(payload)))
|
||||
chunk.Type = FMP_CHUNK_SEGMENTED_DATA
|
||||
chunk.Index = uint64(payload[1])
|
||||
chunk.Value = payload[4:payloadLimit]
|
||||
chunk.Length = 4 + uint64(valueLength)
|
||||
|
||||
case 0x08:
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[1 : 1+2]
|
||||
chunk.Length = 3
|
||||
|
||||
case 0x09:
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_KEY_VALUE
|
||||
chunk.Key = parseVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[3 : 3+1]
|
||||
chunk.Length = 4
|
||||
|
||||
case 0x0A, 0x0B, 0x0C, 0x0D:
|
||||
valueLength := 2 * (payload[0] - 0x09)
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_KEY_VALUE
|
||||
chunk.Key = parseVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[3 : 3+valueLength]
|
||||
chunk.Length = 2 + uint64(valueLength)
|
||||
|
||||
case 0x0E:
|
||||
if payload[1] == 0xFE {
|
||||
chunk.Type = FMP_CHUNK_PATH_PUSH
|
||||
chunk.Value = payload[1 : 1+8]
|
||||
chunk.Length = 10
|
||||
break
|
||||
}
|
||||
|
||||
if payload[1] == 0xFF {
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[2 : 2+5]
|
||||
chunk.Length = 7
|
||||
break
|
||||
}
|
||||
|
||||
valueLength := payload[2]
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_KEY_VALUE
|
||||
chunk.Key = parseVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[4 : 4+valueLength]
|
||||
chunk.Length = 4 + uint64(valueLength)
|
||||
|
||||
case 0x0F:
|
||||
valueLength := parseVarUint64(payload[3 : 3+2])
|
||||
payloadLimit := min(5+valueLength, uint64(len(payload)))
|
||||
chunk.Type = FMP_CHUNK_SEGMENTED_DATA
|
||||
chunk.Index = parseVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[5:payloadLimit]
|
||||
chunk.Length = 5 + valueLength
|
||||
|
||||
case 0x10, 0x11:
|
||||
valueLength := 3 + (payload[0] - 0x10)
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[1 : 1+valueLength]
|
||||
chunk.Length = 1 + uint64(valueLength)
|
||||
|
||||
case 0x12, 0x13, 0x14, 0x15:
|
||||
valueLength := 1 + 2*(payload[0]-0x10)
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[1 : 1+valueLength]
|
||||
chunk.Length = 1 + uint64(valueLength)
|
||||
|
||||
case 0x16:
|
||||
valueLength := payload[4]
|
||||
chunk.Type = FMP_CHUNK_LONG_KEY_VALUE
|
||||
chunk.Key = parseVarUint64(payload[1 : 1+3])
|
||||
chunk.Value = payload[5 : 5+valueLength]
|
||||
chunk.Length = 5 + uint64(valueLength)
|
||||
|
||||
case 0x17:
|
||||
valueLength := parseVarUint64(payload[4 : 4+2])
|
||||
chunk.Type = FMP_CHUNK_LONG_KEY_VALUE
|
||||
chunk.Key = parseVarUint64(payload[1 : 1+3])
|
||||
chunk.Value = payload[6 : 6+valueLength]
|
||||
chunk.Length = 6 + uint64(valueLength)
|
||||
|
||||
case 0x19:
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[1 : 1+1]
|
||||
chunk.Length = 2
|
||||
|
||||
case 0x1A, 0x1B, 0x1C, 0x1D:
|
||||
valueLength := 2 * (payload[0] - 0x19)
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[1 : 1+valueLength]
|
||||
chunk.Length = 1 + uint64(valueLength)
|
||||
|
||||
case 0x1E:
|
||||
keyLength := payload[1]
|
||||
valueLength := payload[2+keyLength]
|
||||
chunk.Type = FMP_CHUNK_LONG_KEY_VALUE
|
||||
chunk.Key = parseVarUint64(payload[2 : 2+keyLength])
|
||||
chunk.Value = payload[2+keyLength+1 : 2+keyLength+1+valueLength]
|
||||
chunk.Length = 2 + uint64(keyLength) + 1 + uint64(valueLength)
|
||||
|
||||
case 0x1F:
|
||||
keyLength := uint64(payload[1])
|
||||
valueLength := parseVarUint64(payload[2+keyLength : 2+keyLength+2+1])
|
||||
chunk.Type = FMP_CHUNK_LONG_KEY_VALUE
|
||||
chunk.Key = parseVarUint64(payload[2 : 2+keyLength])
|
||||
chunk.Value = payload[2+keyLength+2 : 2+keyLength+2+valueLength]
|
||||
chunk.Length = 4 + uint64(keyLength) + uint64(valueLength)
|
||||
|
||||
case 0x20:
|
||||
if payload[1] == 0xFE {
|
||||
chunk.Type = FMP_CHUNK_PATH_PUSH
|
||||
chunk.Value = payload[1 : 1+8]
|
||||
chunk.Length = 10
|
||||
break
|
||||
}
|
||||
|
||||
chunk.Type = FMP_CHUNK_PATH_PUSH
|
||||
chunk.Value = payload[1 : 1+1]
|
||||
chunk.Length = 2
|
||||
|
||||
case 0x23:
|
||||
chunk.Type = FMP_CHUNK_SIMPLE_DATA
|
||||
chunk.Value = payload[1 : 1+1]
|
||||
chunk.Length = 2
|
||||
|
||||
case 0x28:
|
||||
chunk.Type = FMP_CHUNK_PATH_PUSH
|
||||
chunk.Value = payload[1 : 1+2]
|
||||
chunk.Length = 3
|
||||
|
||||
case 0x30:
|
||||
chunk.Type = FMP_CHUNK_PATH_PUSH
|
||||
chunk.Value = payload[1 : 1+3]
|
||||
chunk.Length = 4
|
||||
|
||||
case 0x38:
|
||||
valueLength := payload[1]
|
||||
chunk.Type = FMP_CHUNK_PATH_PUSH
|
||||
chunk.Value = payload[2 : 2+valueLength]
|
||||
chunk.Length = 2 + uint64(valueLength)
|
||||
|
||||
case 0x3D, 0x40:
|
||||
chunk.Type = FMP_CHUNK_PATH_POP
|
||||
chunk.Length = 1
|
||||
|
||||
case 0x80:
|
||||
chunk.Type = FMP_CHUNK_NOOP
|
||||
chunk.Length = 1
|
||||
|
||||
default:
|
||||
return nil, ErrBadChunk
|
||||
}
|
||||
|
||||
return chunk, nil
|
||||
}
|
333
fmp/fmp_const.go
333
fmp/fmp_const.go
@ -1,7 +1,6 @@
|
||||
package fmp
|
||||
|
||||
type FmpError string
|
||||
type FmpChunkType uint8
|
||||
|
||||
func (e FmpError) Error() string { return string(e) }
|
||||
|
||||
@ -16,41 +15,305 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
FMP_CHUNK_SIMPLE_DATA FmpChunkType = iota
|
||||
FMP_CHUNK_SEGMENTED_DATA
|
||||
FMP_CHUNK_SIMPLE_KEY_VALUE
|
||||
FMP_CHUNK_LONG_KEY_VALUE
|
||||
FMP_CHUNK_PATH_PUSH
|
||||
FMP_CHUNK_PATH_POP
|
||||
FMP_CHUNK_NOOP
|
||||
FmpDateLayout = "02/01/2006"
|
||||
FmpTimeLayout = "15:04:05"
|
||||
FmpDateTimeLayout = "02/01/2006 15:04:05"
|
||||
)
|
||||
|
||||
type FmpChunkType uint8
|
||||
|
||||
const (
|
||||
FMP_COLLATION_ENGLISH = 0x00
|
||||
FMP_COLLATION_FRENCH = 0x01
|
||||
FMP_COLLATION_GERMAN = 0x03
|
||||
FMP_COLLATION_ITALIAN = 0x04
|
||||
FMP_COLLATION_DUTCH = 0x05
|
||||
FMP_COLLATION_SWEDISH = 0x07
|
||||
FMP_COLLATION_SPANISH = 0x08
|
||||
FMP_COLLATION_DANISH = 0x09
|
||||
FMP_COLLATION_PORTUGUESE = 0x0A
|
||||
FMP_COLLATION_NORWEGIAN = 0x0C
|
||||
FMP_COLLATION_FINNISH = 0x11
|
||||
FMP_COLLATION_GREEK = 0x14
|
||||
FMP_COLLATION_ICELANDIC = 0x15
|
||||
FMP_COLLATION_TURKISH = 0x18
|
||||
FMP_COLLATION_ROMANIAN = 0x27
|
||||
FMP_COLLATION_POLISH = 0x2a
|
||||
FMP_COLLATION_HUNGARIAN = 0x2b
|
||||
FMP_COLLATION_RUSSIAN = 0x31
|
||||
FMP_COLLATION_CZECH = 0x38
|
||||
FMP_COLLATION_UKRAINIAN = 0x3e
|
||||
FMP_COLLATION_CROATIAN = 0x42
|
||||
FMP_COLLATION_CATALAN = 0x49
|
||||
FMP_COLLATION_FINNISH_ALT = 0x62
|
||||
FMP_COLLATION_SWEDISH_ALT = 0x63
|
||||
FMP_COLLATION_GERMAN_ALT = 0x64
|
||||
FMP_COLLATION_SPANISH_ALT = 0x65
|
||||
FMP_COLLATION_ASCII = 0x66
|
||||
FmpChunkSimpleData FmpChunkType = iota
|
||||
FmpChunkSegmentedData
|
||||
FmpChunkSimpleKeyValue
|
||||
FmpChunkLongKeyValue
|
||||
FmpChunkPathPush
|
||||
FmpChunkPathPushLong
|
||||
FmpChunkPathPop
|
||||
FmpChunkNoop
|
||||
)
|
||||
|
||||
type FmpFieldType uint8
|
||||
|
||||
const (
|
||||
FmpFieldSimple FmpFieldType = 1
|
||||
FmpFieldCalculation FmpFieldType = 2
|
||||
FmpFieldScript FmpFieldType = 3
|
||||
)
|
||||
|
||||
type FmpFieldStorageType uint8
|
||||
|
||||
const (
|
||||
FmpFieldStorageRegular FmpFieldStorageType = 0
|
||||
FmpFieldStorageGlobal FmpFieldStorageType = 1
|
||||
FmpFieldStorageCalculation FmpFieldStorageType = 8
|
||||
FmpFieldStorageUnstoredCalculation FmpFieldStorageType = 10
|
||||
)
|
||||
|
||||
type FmpDataType uint8
|
||||
|
||||
const (
|
||||
FmpDataText FmpDataType = 1
|
||||
FmpDataNumber FmpDataType = 2
|
||||
FmpDataDate FmpDataType = 3
|
||||
FmpDataTime FmpDataType = 4
|
||||
FmpDataTS FmpDataType = 5
|
||||
FmpDataContainer FmpDataType = 6
|
||||
)
|
||||
|
||||
type FmpAutoEnterOption uint8
|
||||
|
||||
const (
|
||||
FmpAutoEnterData FmpAutoEnterOption = iota
|
||||
FmpAutoEnterSerialNumber
|
||||
FmpAutoEnterCalculation
|
||||
FmpAutoEnterCalculationReplacingExistingValue
|
||||
FmpAutoEnterFromLastVisitedRecord
|
||||
FmpAutoEnterCreateDate
|
||||
FmpAutoEnterCreateTime
|
||||
FmpAutoEnterCreateTS
|
||||
FmpAutoEnterCreateName
|
||||
FmpAutoEnterCreateAccountName
|
||||
FmpAutoEnterModDate
|
||||
FmpAutoEnterModTime
|
||||
FmpAutoEnterModTS
|
||||
FmpAutoEnterModName
|
||||
FmpAutoEnterModAccountName
|
||||
)
|
||||
|
||||
var autoEnterPresetMap = map[uint8]FmpAutoEnterOption{
|
||||
0: FmpAutoEnterCreateDate,
|
||||
1: FmpAutoEnterCreateTime,
|
||||
2: FmpAutoEnterCreateTS,
|
||||
3: FmpAutoEnterCreateName,
|
||||
4: FmpAutoEnterCreateAccountName,
|
||||
5: FmpAutoEnterModDate,
|
||||
6: FmpAutoEnterModTime,
|
||||
7: FmpAutoEnterModTS,
|
||||
8: FmpAutoEnterModName,
|
||||
9: FmpAutoEnterModAccountName,
|
||||
}
|
||||
|
||||
var autoEnterOptionMap = map[uint8]FmpAutoEnterOption{
|
||||
2: FmpAutoEnterSerialNumber,
|
||||
4: FmpAutoEnterData,
|
||||
8: FmpAutoEnterCalculation,
|
||||
16: FmpAutoEnterFromLastVisitedRecord,
|
||||
32: FmpAutoEnterCalculation,
|
||||
136: FmpAutoEnterCalculationReplacingExistingValue,
|
||||
}
|
||||
|
||||
type FmpCalculationOperator byte
|
||||
|
||||
const (
|
||||
FmpCalcOperatorAdd FmpCalculationOperator = '+'
|
||||
FmpCalcOperatorSubtract FmpCalculationOperator = '-'
|
||||
FmpCalcOperatorMultiply FmpCalculationOperator = '*'
|
||||
FmpCalcOperatorDivide FmpCalculationOperator = '/'
|
||||
FmpCalcOperatorConcatenate FmpCalculationOperator = '&'
|
||||
)
|
||||
|
||||
// var calcOperatorMap = map[uint8]FmpCalculationOperator{
|
||||
// 0x25: FmpCalcOperatorAdd,
|
||||
// 0x26: FmpCalcOperatorSubtract,
|
||||
// 0x27: FmpCalcOperatorMultiply,
|
||||
// 0x28: FmpCalcOperatorDivide,
|
||||
// 0x50: FmpCalcOperatorConcatenate,
|
||||
// }
|
||||
|
||||
type FmpScriptStepType uint64
|
||||
|
||||
const (
|
||||
FmpScriptPerformScript FmpScriptStepType = 1
|
||||
FmpScriptSaveCopyAsXML FmpScriptStepType = 3
|
||||
FmpScriptGoToNextField FmpScriptStepType = 4
|
||||
FmpScriptGoToPreviousField FmpScriptStepType = 5
|
||||
FmpScriptGoToLayout FmpScriptStepType = 6
|
||||
FmpScriptNewRecordRequest FmpScriptStepType = 7
|
||||
FmpScriptDuplicateRecordRequest FmpScriptStepType = 8
|
||||
FmpScriptDeleteRecordRequest FmpScriptStepType = 9
|
||||
FmpScriptDeleteAllRecords FmpScriptStepType = 10
|
||||
FmpScriptInsertFromIndex FmpScriptStepType = 11
|
||||
FmpScriptInsertFromLastVisited FmpScriptStepType = 12
|
||||
FmpScriptInsertCurrentDate FmpScriptStepType = 13
|
||||
FmpScriptInsertCurrentTime FmpScriptStepType = 14
|
||||
FmpScriptGoToRecordRequestPage FmpScriptStepType = 16
|
||||
FmpScriptGoToField FmpScriptStepType = 17
|
||||
FmpScriptCheckSelection FmpScriptStepType = 18
|
||||
FmpScriptCheckRecord FmpScriptStepType = 19
|
||||
FmpScriptCheckFoundSet FmpScriptStepType = 20
|
||||
FmpScriptUnsortRecords FmpScriptStepType = 21
|
||||
FmpScriptEnterFindMode FmpScriptStepType = 22
|
||||
FmpScriptShowAllRecords FmpScriptStepType = 23
|
||||
FmpScriptModifyLastFind FmpScriptStepType = 24
|
||||
FmpScriptOmitRecord FmpScriptStepType = 25
|
||||
FmpScriptOmitMultipleRecords FmpScriptStepType = 26
|
||||
FmpScriptShowOmmitedOnly FmpScriptStepType = 27
|
||||
FmpScriptPerformFind FmpScriptStepType = 28
|
||||
FmpScriptShowHideToolbars FmpScriptStepType = 29
|
||||
FmpScriptViewAs FmpScriptStepType = 30
|
||||
FmpScriptAdjustWindow FmpScriptStepType = 31
|
||||
FmpScriptOpenHelp FmpScriptStepType = 32
|
||||
FmpScriptOpenFile FmpScriptStepType = 33
|
||||
FmpScriptCloseFile FmpScriptStepType = 34
|
||||
FmpScriptImportRecords FmpScriptStepType = 35
|
||||
FmpScriptExportRecords FmpScriptStepType = 36
|
||||
FmpScriptSaveACopyAs FmpScriptStepType = 37
|
||||
FmpScriptOpenManageDatabase FmpScriptStepType = 38
|
||||
FmpScriptSortRecords FmpScriptStepType = 39
|
||||
FmpScriptRelookupFieldContents FmpScriptStepType = 40
|
||||
FmpScriptEnterPreviewMode FmpScriptStepType = 41
|
||||
FmpScriptPrintSetup FmpScriptStepType = 42
|
||||
FmpScriptPrint FmpScriptStepType = 43
|
||||
FmpScriptExitApplication FmpScriptStepType = 44
|
||||
FmpScriptUndoRedo FmpScriptStepType = 45
|
||||
FmpScriptCut FmpScriptStepType = 46
|
||||
FmpScriptCopy FmpScriptStepType = 47
|
||||
FmpScriptPaste FmpScriptStepType = 48
|
||||
FmpScriptClear FmpScriptStepType = 49
|
||||
FmpScriptSelectAll FmpScriptStepType = 50
|
||||
FmpScriptRevertRecordRequest FmpScriptStepType = 51
|
||||
FmpScriptEnterBrowserMode FmpScriptStepType = 55
|
||||
FmpScriptInsertPicture FmpScriptStepType = 56
|
||||
FmpScriptSendEvent FmpScriptStepType = 57
|
||||
FmpScriptInsertCurrentUserName FmpScriptStepType = 60
|
||||
FmpScriptInsertText FmpScriptStepType = 61
|
||||
FmpScriptPauseResumeScript FmpScriptStepType = 62
|
||||
FmpScriptSendMail FmpScriptStepType = 63
|
||||
FmpScriptSendDDEExecute FmpScriptStepType = 64
|
||||
FmpScriptDialPhone FmpScriptStepType = 65
|
||||
FmpScriptSpeak FmpScriptStepType = 66
|
||||
FmpScriptPerformApplescript FmpScriptStepType = 67
|
||||
FmpScriptIf FmpScriptStepType = 68
|
||||
FmpScriptElse FmpScriptStepType = 69
|
||||
FmpScriptEndIf FmpScriptStepType = 70
|
||||
FmpScriptLoop FmpScriptStepType = 71
|
||||
FmpScriptExitLoopIf FmpScriptStepType = 72
|
||||
FmpScriptEndLoop FmpScriptStepType = 73
|
||||
FmpScriptGoToRelatedRecord FmpScriptStepType = 74
|
||||
FmpScriptCommitRecordsRequests FmpScriptStepType = 75
|
||||
FmpScriptSetField FmpScriptStepType = 76
|
||||
FmpScriptInsertCalculatedResult FmpScriptStepType = 77
|
||||
FmpScriptFreezeWindow FmpScriptStepType = 79
|
||||
FmpScriptRefreshWindow FmpScriptStepType = 80
|
||||
FmpScriptScrollWindow FmpScriptStepType = 81
|
||||
FmpScriptNewFile FmpScriptStepType = 82
|
||||
FmpScriptChangePassword FmpScriptStepType = 83
|
||||
FmpScriptSetMultiUser FmpScriptStepType = 84
|
||||
FmpScriptAllowUserAbort FmpScriptStepType = 85
|
||||
FmpScriptSetErrorCapture FmpScriptStepType = 86
|
||||
FmpScriptShowCustomDialog FmpScriptStepType = 87
|
||||
FmpScriptOpenScriptWorkspace FmpScriptStepType = 88
|
||||
FmpScriptBlankLineComment FmpScriptStepType = 89
|
||||
FmpScriptHaltScript FmpScriptStepType = 90
|
||||
FmpScriptReplaceFieldContents FmpScriptStepType = 91
|
||||
FmpScriptShowHideTextRuler FmpScriptStepType = 92
|
||||
FmpScriptBeep FmpScriptStepType = 93
|
||||
FmpScriptSetUseSystemFormats FmpScriptStepType = 94
|
||||
FmpScriptRecoverFile FmpScriptStepType = 95
|
||||
FmpScriptSaveACopyAsAddOnPackage FmpScriptStepType = 96
|
||||
FmpScriptSetZoomLevel FmpScriptStepType = 97
|
||||
FmpScriptCopyAllRecordsRequests FmpScriptStepType = 98
|
||||
FmpScriptGoToPortalRow FmpScriptStepType = 99
|
||||
FmpScriptCopyRecordRequest FmpScriptStepType = 101
|
||||
FmpScriptFluchCacheToDisk FmpScriptStepType = 102
|
||||
FmpScriptExitScript FmpScriptStepType = 103
|
||||
FmpScriptDeletePortalRow FmpScriptStepType = 104
|
||||
FmpScriptOpenPreferences FmpScriptStepType = 105
|
||||
FmpScriptCorrectWord FmpScriptStepType = 106
|
||||
FmpScriptSpellingOptions FmpScriptStepType = 107
|
||||
FmpScriptSelectDictionaries FmpScriptStepType = 108
|
||||
FmpScriptEditUserDictionary FmpScriptStepType = 109
|
||||
FmpScriptOpenUrl FmpScriptStepType = 111
|
||||
FmpScriptOpenManageValueLists FmpScriptStepType = 112
|
||||
FmpScriptOpenSharing FmpScriptStepType = 113
|
||||
FmpScriptOpenFileOptions FmpScriptStepType = 114
|
||||
FmpScriptAllowFormattingBar FmpScriptStepType = 115
|
||||
FmpScriptSetNextSerialValue FmpScriptStepType = 116
|
||||
FmpScriptExecuteSQL FmpScriptStepType = 117
|
||||
FmpScriptOpenHosts FmpScriptStepType = 118
|
||||
FmpScriptMoveResizeWindow FmpScriptStepType = 119
|
||||
FmpScriptArrangeAllWindows FmpScriptStepType = 120
|
||||
FmpScriptCloseWindow FmpScriptStepType = 121
|
||||
FmpScriptNewWindow FmpScriptStepType = 122
|
||||
FmpScriptSelectWindow FmpScriptStepType = 123
|
||||
FmpScriptSetWindowTitle FmpScriptStepType = 124
|
||||
FmpScriptElseIf FmpScriptStepType = 125
|
||||
FmpScriptConstrainFoundSet FmpScriptStepType = 126
|
||||
FmpScriptExtendFoundSet FmpScriptStepType = 127
|
||||
FmpScriptPerformFindReplace FmpScriptStepType = 128
|
||||
FmpScriptOpenFindReplace FmpScriptStepType = 129
|
||||
FmpScriptSetSelection FmpScriptStepType = 130
|
||||
FmpScriptInsertFile FmpScriptStepType = 131
|
||||
FmpScriptExportFieldContents FmpScriptStepType = 132
|
||||
FmpScriptOpenRecordRequest FmpScriptStepType = 133
|
||||
FmpScriptAddAccount FmpScriptStepType = 134
|
||||
FmpScriptDeleteAccount FmpScriptStepType = 135
|
||||
FmpScriptResetAccountPassword FmpScriptStepType = 136
|
||||
FmpScriptEnableAccount FmpScriptStepType = 137
|
||||
FmpScriptRelogin FmpScriptStepType = 138
|
||||
FmpScriptConvertFile FmpScriptStepType = 139
|
||||
FmpScriptOpenManageDataSources FmpScriptStepType = 140
|
||||
FmpScriptSetVariable FmpScriptStepType = 141
|
||||
FmpScriptInstallMenuSet FmpScriptStepType = 142
|
||||
FmpScriptSaveRecordsAsExcel FmpScriptStepType = 143
|
||||
FmpScriptSaveRecordsAsPdf FmpScriptStepType = 144
|
||||
FmpScriptGoToObject FmpScriptStepType = 145
|
||||
FmpScriptSetWebViewer FmpScriptStepType = 146
|
||||
FmpScriptSetFieldByName FmpScriptStepType = 147
|
||||
FmpScriptInstallOntimerScript FmpScriptStepType = 148
|
||||
FmpScriptOpenEditSavedFinds FmpScriptStepType = 149
|
||||
FmpScriptPerformQuickFind FmpScriptStepType = 150
|
||||
FmpScriptOpenManageLayouts FmpScriptStepType = 151
|
||||
FmpScriptSaveRecordsAsSnapshotLink FmpScriptStepType = 152
|
||||
FmpScriptSortRecordsByField FmpScriptStepType = 154
|
||||
FmpScriptFindMatchingRecords FmpScriptStepType = 155
|
||||
FmpScriptManageContainers FmpScriptStepType = 156
|
||||
FmpScriptInstallPluginFile FmpScriptStepType = 157
|
||||
FmpScriptInsertPdf FmpScriptStepType = 158
|
||||
FmpScriptInsertAudioVideo FmpScriptStepType = 159
|
||||
FmpScriptInsertFromUrl FmpScriptStepType = 160
|
||||
FmpScriptInsertFromDevice FmpScriptStepType = 161
|
||||
FmpScriptPerformScriptOnServer FmpScriptStepType = 164
|
||||
FmpScriptOpenManageThemes FmpScriptStepType = 165
|
||||
FmpScriptShowHideMenubar FmpScriptStepType = 166
|
||||
FmpScriptRefreshObject FmpScriptStepType = 167
|
||||
FmpScriptSetLayoutObjectAnimation FmpScriptStepType = 168
|
||||
FmpScriptClosePopover FmpScriptStepType = 169
|
||||
FmpScriptOpenUploadToHost FmpScriptStepType = 172
|
||||
FmpScriptEnableTouchKeyboard FmpScriptStepType = 174
|
||||
FmpScriptPerformJavascriptInWebViewer FmpScriptStepType = 175
|
||||
FmpScriptCommentedOut FmpScriptStepType = 176
|
||||
FmpScriptAvplayerPlay FmpScriptStepType = 177
|
||||
FmpScriptAvplayerSetPlaybackState FmpScriptStepType = 178
|
||||
FmpScriptAvplayerSetOptions FmpScriptStepType = 179
|
||||
FmpScriptRefreshPortal FmpScriptStepType = 180
|
||||
FmpScriptGetFolderPath FmpScriptStepType = 181
|
||||
FmpScriptTruncateTable FmpScriptStepType = 182
|
||||
FmpScriptOpenFavorites FmpScriptStepType = 183
|
||||
FmpScriptConfigureRegionMonitorScript FmpScriptStepType = 185
|
||||
FmpScriptConfigureLocalNotification FmpScriptStepType = 187
|
||||
FmpScriptGetFileExists FmpScriptStepType = 188
|
||||
FmpScriptGetFileSize FmpScriptStepType = 189
|
||||
FmpScriptCreateDataFile FmpScriptStepType = 190
|
||||
FmpScriptOpenDataFile FmpScriptStepType = 191
|
||||
FmpScriptWriteToDataFile FmpScriptStepType = 192
|
||||
FmpScriptReadFromDataFile FmpScriptStepType = 193
|
||||
FmpScriptGetDataFilePosition FmpScriptStepType = 194
|
||||
FmpScriptSetDataFilePosition FmpScriptStepType = 195
|
||||
FmpScriptCloseDataFile FmpScriptStepType = 196
|
||||
FmpScriptDeleteFile FmpScriptStepType = 197
|
||||
FmpScriptRenameFile FmpScriptStepType = 199
|
||||
FmpScriptSetErrorLogging FmpScriptStepType = 200
|
||||
FmpScriptConfigureNfcReading FmpScriptStepType = 201
|
||||
FmpScriptConfigureMachineLearningModel FmpScriptStepType = 202
|
||||
FmpScriptExecuteFileMakerDataAPI FmpScriptStepType = 203
|
||||
FmpScriptOpenTransaction FmpScriptStepType = 205
|
||||
FmpScriptCommitTransaction FmpScriptStepType = 206
|
||||
FmpScriptRevertTransaction FmpScriptStepType = 207
|
||||
FmpScriptSetSessionIdentifier FmpScriptStepType = 208
|
||||
FmpScriptSetDictionary FmpScriptStepType = 209
|
||||
FmpScriptPerformScriptOnServerWithCallback FmpScriptStepType = 210
|
||||
FmpScriptTriggerClarisConnectFlow FmpScriptStepType = 211
|
||||
FmpScriptAssert FmpScriptStepType = 255
|
||||
)
|
||||
|
@ -3,8 +3,35 @@ package fmp
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
)
|
||||
|
||||
const debugging = false
|
||||
|
||||
func debug(str string, args ...interface{}) {
|
||||
if debugging {
|
||||
fmt.Printf(str+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
func dump(data []byte) {
|
||||
if debugging {
|
||||
for _, b := range data {
|
||||
fmt.Printf("%02x ", b)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func dumpPath(path []uint64) {
|
||||
if debugging {
|
||||
for _, p := range path {
|
||||
fmt.Printf("%v. ", p)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FmpFile) ToDebugFile(fname string) {
|
||||
f_sectors, err := os.Create(fname + ".sectors")
|
||||
if err != nil {
|
||||
@ -54,7 +81,15 @@ func (c *FmpChunk) String() string {
|
||||
|
||||
func (dict *FmpDict) string(parentPath string) string {
|
||||
s := ""
|
||||
for k, v := range *dict {
|
||||
keys := make([]uint64, 0, len(*dict))
|
||||
|
||||
for k := range *dict {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
v := (*dict)[k]
|
||||
s += fmt.Sprintf("%v%v: %v\n", parentPath, k, string(v.Value))
|
||||
|
||||
if v.Children != nil {
|
||||
|
58
fmp/fmp_dict.go
Normal file
58
fmp/fmp_dict.go
Normal file
@ -0,0 +1,58 @@
|
||||
package fmp
|
||||
|
||||
type FmpDict map[uint64]*FmpDictEntry
|
||||
|
||||
type FmpDictEntry struct {
|
||||
Value []byte
|
||||
Children *FmpDict
|
||||
}
|
||||
|
||||
func (dict *FmpDict) GetEntry(path ...uint64) *FmpDictEntry {
|
||||
for i, key := range path {
|
||||
_, ok := (*dict)[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if i == len(path)-1 {
|
||||
return (*dict)[key]
|
||||
} else {
|
||||
dict = (*dict)[key].Children
|
||||
if dict == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dict *FmpDict) GetValue(path ...uint64) []byte {
|
||||
ent := dict.GetEntry(path...)
|
||||
if ent != nil {
|
||||
return ent.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dict *FmpDict) GetChildren(path ...uint64) *FmpDict {
|
||||
ent := dict.GetEntry(path...)
|
||||
if ent != nil {
|
||||
return ent.Children
|
||||
}
|
||||
return &FmpDict{}
|
||||
}
|
||||
|
||||
func (dict *FmpDict) set(path []uint64, value []byte) {
|
||||
for i, key := range path {
|
||||
_, ok := (*dict)[key]
|
||||
if !ok {
|
||||
(*dict)[key] = &FmpDictEntry{Children: &FmpDict{}}
|
||||
}
|
||||
|
||||
if i == len(path)-1 {
|
||||
(*dict)[key].Value = value
|
||||
} else {
|
||||
dict = (*dict)[key].Children
|
||||
}
|
||||
}
|
||||
}
|
196
fmp/fmp_file.go
196
fmp/fmp_file.go
@ -2,7 +2,6 @@ package fmp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
@ -21,17 +20,31 @@ const (
|
||||
hbamSize = len(hbamSequence)
|
||||
)
|
||||
|
||||
type FmpFile struct {
|
||||
VersionDate time.Time
|
||||
CreatorName string
|
||||
FileSize uint
|
||||
Sectors []*FmpSector
|
||||
Chunks []*FmpChunk
|
||||
Dictionary *FmpDict
|
||||
|
||||
tables []*FmpTable
|
||||
numSectors uint64 // Excludes the header sector
|
||||
currentSectorID uint64
|
||||
|
||||
stream *os.File
|
||||
}
|
||||
|
||||
func OpenFile(path string) (*FmpFile, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stream, err := os.Open(path)
|
||||
stream, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
ctx := &FmpFile{stream: stream, Dictionary: &FmpDict{}}
|
||||
if err := ctx.readHeader(); err != nil {
|
||||
@ -41,7 +54,7 @@ func OpenFile(path string) (*FmpFile, error) {
|
||||
ctx.FileSize = uint(info.Size())
|
||||
ctx.numSectors = uint64((ctx.FileSize / sectorSize) - 1)
|
||||
ctx.Sectors = make([]*FmpSector, 0)
|
||||
currentPath := make([]uint64, 0)
|
||||
ctx.stream.Seek(2*sectorSize, io.SeekStart)
|
||||
|
||||
for {
|
||||
sector, err := ctx.readSector()
|
||||
@ -51,52 +64,15 @@ func OpenFile(path string) (*FmpFile, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx.Sectors = append(ctx.Sectors, sector)
|
||||
|
||||
if sector.ID != 0 {
|
||||
err = sector.processChunks(ctx.Dictionary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx.Chunks = append(ctx.Chunks, sector.Chunks...)
|
||||
|
||||
for _, chunk := range sector.Chunks {
|
||||
switch chunk.Type {
|
||||
case FMP_CHUNK_PATH_PUSH:
|
||||
currentPath = append(currentPath, uint64(chunk.Value[0]))
|
||||
|
||||
case FMP_CHUNK_PATH_POP:
|
||||
if len(currentPath) > 0 {
|
||||
currentPath = currentPath[:len(currentPath)-1]
|
||||
}
|
||||
|
||||
case FMP_CHUNK_SIMPLE_DATA:
|
||||
ctx.Dictionary.set(currentPath, chunk.Value)
|
||||
|
||||
case FMP_CHUNK_SEGMENTED_DATA:
|
||||
// Todo: take index into account
|
||||
ctx.Dictionary.set(
|
||||
currentPath,
|
||||
append(ctx.Dictionary.getValue(currentPath), chunk.Value...),
|
||||
)
|
||||
|
||||
case FMP_CHUNK_SIMPLE_KEY_VALUE:
|
||||
ctx.Dictionary.set(
|
||||
append(currentPath, uint64(chunk.Key)),
|
||||
chunk.Value,
|
||||
)
|
||||
|
||||
case FMP_CHUNK_LONG_KEY_VALUE:
|
||||
ctx.Dictionary.set(
|
||||
append(currentPath, uint64(chunk.Key)), // todo: ??
|
||||
chunk.Value,
|
||||
)
|
||||
|
||||
case FMP_CHUNK_NOOP:
|
||||
// noop
|
||||
}
|
||||
|
||||
if chunk.Delayed {
|
||||
if len(currentPath) == 0 {
|
||||
println("warning: delayed pop without path")
|
||||
} else {
|
||||
currentPath = currentPath[:len(currentPath)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.currentSectorID = sector.NextID
|
||||
@ -109,9 +85,14 @@ func OpenFile(path string) (*FmpFile, error) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.readTables()
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (ctx *FmpFile) Close() {
|
||||
ctx.stream.Close()
|
||||
}
|
||||
|
||||
func (ctx *FmpFile) readHeader() error {
|
||||
buf := make([]byte, headerSize)
|
||||
_, err := ctx.stream.Read(buf)
|
||||
@ -137,7 +118,7 @@ func (ctx *FmpFile) readHeader() error {
|
||||
}
|
||||
|
||||
func (ctx *FmpFile) readSector() (*FmpSector, error) {
|
||||
println("------- Reading sector", ctx.currentSectorID)
|
||||
debug("---------- Reading sector %d", ctx.currentSectorID)
|
||||
buf := make([]byte, sectorHeaderSize)
|
||||
n, err := ctx.stream.Read(buf)
|
||||
|
||||
@ -152,16 +133,17 @@ func (ctx *FmpFile) readSector() (*FmpSector, error) {
|
||||
ID: ctx.currentSectorID,
|
||||
Deleted: buf[0] > 0,
|
||||
Level: uint8(buf[1]),
|
||||
PrevID: parseVarUint64(buf[4 : 4+4]),
|
||||
NextID: parseVarUint64(buf[8 : 8+4]),
|
||||
PrevID: decodeVarUint64(buf[4 : 4+4]),
|
||||
NextID: decodeVarUint64(buf[8 : 8+4]),
|
||||
Chunks: make([]*FmpChunk, 0),
|
||||
}
|
||||
|
||||
if ctx.currentSectorID == 0 && sector.PrevID > 0 {
|
||||
return nil, ErrBadSectorHeader
|
||||
}
|
||||
|
||||
payload := make([]byte, sectorPayloadSize)
|
||||
n, err = ctx.stream.Read(payload)
|
||||
sector.Payload = make([]byte, sectorPayloadSize)
|
||||
n, err = ctx.stream.Read(sector.Payload)
|
||||
|
||||
if n != sectorPayloadSize {
|
||||
return nil, ErrRead
|
||||
@ -169,33 +151,103 @@ func (ctx *FmpFile) readSector() (*FmpSector, error) {
|
||||
if err != nil {
|
||||
return nil, ErrRead
|
||||
}
|
||||
return sector, nil
|
||||
}
|
||||
|
||||
sector.Chunks = make([]*FmpChunk, 0)
|
||||
func (ctx *FmpFile) readTables() {
|
||||
tables := make([]*FmpTable, 0)
|
||||
ent := ctx.Dictionary.GetEntry(3, 16, 5)
|
||||
|
||||
for {
|
||||
chunk, err := ctx.readChunk(payload)
|
||||
fmt.Printf("0x%02X", payload[0])
|
||||
if chunk != nil {
|
||||
fmt.Printf(" (type %v)\n", int(chunk.Type))
|
||||
for path, tableEnt := range *ent.Children {
|
||||
if path < 128 {
|
||||
continue
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
|
||||
table := &FmpTable{
|
||||
ID: path,
|
||||
Name: decodeString(tableEnt.Children.GetValue(16)),
|
||||
Columns: map[uint64]*FmpColumn{},
|
||||
Records: map[uint64]*FmpRecord{},
|
||||
}
|
||||
|
||||
tables = append(tables, table)
|
||||
|
||||
for colPath, colEnt := range *ctx.Dictionary.GetChildren(table.ID, 3, 5) {
|
||||
name := decodeString(colEnt.Children.GetValue(16))
|
||||
flags := colEnt.Children.GetValue(2)
|
||||
|
||||
column := &FmpColumn{
|
||||
Index: colPath,
|
||||
Name: name,
|
||||
Type: FmpFieldType(flags[0]),
|
||||
DataType: FmpDataType(flags[1]),
|
||||
StorageType: FmpFieldStorageType(flags[9]),
|
||||
Repetitions: flags[25],
|
||||
Indexed: flags[8] == 128,
|
||||
}
|
||||
|
||||
if flags[11] == 1 {
|
||||
column.AutoEnter = autoEnterPresetMap[flags[4]]
|
||||
} else {
|
||||
column.AutoEnter = autoEnterOptionMap[flags[11]]
|
||||
}
|
||||
|
||||
table.Columns[column.Index] = column
|
||||
}
|
||||
|
||||
for recPath, recEnt := range *ctx.Dictionary.GetChildren(table.ID, 5) {
|
||||
record := &FmpRecord{Index: recPath, Values: make(map[uint64]string)}
|
||||
table.Records[record.Index] = record
|
||||
|
||||
if recPath > table.lastRecordID {
|
||||
table.lastRecordID = recPath
|
||||
}
|
||||
|
||||
for colIndex, value := range *recEnt.Children {
|
||||
record.Values[colIndex] = decodeString(value.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.tables = tables
|
||||
}
|
||||
|
||||
func (ctx *FmpFile) NewSector() (*FmpSector, error) {
|
||||
id := uint64(len(ctx.Sectors)) + 1
|
||||
prevID := id - 2
|
||||
|
||||
ctx.Sectors[prevID].NextID = uint64(id)
|
||||
_, err := ctx.stream.WriteAt(encodeUint(4, int(id)), int64((id-1)*sectorSize)+4)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if chunk == nil {
|
||||
break
|
||||
}
|
||||
if chunk.Length == 0 {
|
||||
panic("chunk length not set")
|
||||
}
|
||||
sector.Chunks = append(sector.Chunks, chunk)
|
||||
payload = payload[min(chunk.Length, uint64(len(payload))):]
|
||||
if len(payload) == 0 || (len(payload) == 1 && payload[0] == 0x00) {
|
||||
break
|
||||
}
|
||||
|
||||
sector := &FmpSector{
|
||||
ID: uint64(id),
|
||||
Deleted: false,
|
||||
Level: 0,
|
||||
PrevID: prevID,
|
||||
NextID: 0,
|
||||
Chunks: make([]*FmpChunk, 0),
|
||||
}
|
||||
|
||||
ctx.Sectors = append(ctx.Sectors, sector)
|
||||
|
||||
sectorBuf := make([]byte, sectorSize)
|
||||
sectorBuf[0] = 0 // deleted
|
||||
sectorBuf[1] = 0 // level
|
||||
writeToSlice(sectorBuf, 4, encodeUint(4, int(prevID))...)
|
||||
writeToSlice(sectorBuf, 8, encodeUint(4, int(id))...)
|
||||
|
||||
_, err = ctx.stream.WriteAt(sectorBuf, int64((id+1)*sectorSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sector, nil
|
||||
}
|
||||
|
||||
func (ctx *FmpFile) setValue(path []uint64, value []byte) {
|
||||
// ctx.Dictionary.set(path, value)
|
||||
|
||||
}
|
||||
|
268
fmp/fmp_sector.go
Normal file
268
fmp/fmp_sector.go
Normal file
@ -0,0 +1,268 @@
|
||||
package fmp
|
||||
|
||||
type FmpSector struct {
|
||||
ID uint64
|
||||
Level uint8
|
||||
Deleted bool
|
||||
PrevID uint64
|
||||
NextID uint64
|
||||
Prev *FmpSector
|
||||
Next *FmpSector
|
||||
Payload []byte
|
||||
Chunks []*FmpChunk
|
||||
}
|
||||
|
||||
type FmpChunk struct {
|
||||
Type FmpChunkType
|
||||
Length uint64
|
||||
Key uint64 // If Type == FMP_CHUNK_SHORT_KEY_VALUE or FMP_CHUNK_LONG_KEY_VALUE
|
||||
Index uint64 // Segment index, if Type == FMP_CHUNK_SEGMENTED_DATA
|
||||
Value []byte
|
||||
}
|
||||
|
||||
func (sect *FmpSector) readChunks() error {
|
||||
if len(sect.Chunks) > 0 {
|
||||
panic("chunks already read")
|
||||
}
|
||||
for {
|
||||
pos := (sect.ID+1)*sectorSize - uint64(len(sect.Payload))
|
||||
|
||||
if sect.Payload[0] == 0x00 && sect.Payload[1] == 0x00 {
|
||||
break
|
||||
}
|
||||
|
||||
chunk, err := sect.readChunk(sect.Payload)
|
||||
if chunk == nil {
|
||||
debug("0x%02x (pos %v, unknown)\n", sect.Payload[0], pos)
|
||||
} else {
|
||||
debug("0x%02x (pos %v, type %v)\n", sect.Payload[0], pos, int(chunk.Type))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
debug("chunk error at sector %d", sect.ID)
|
||||
dump(sect.Payload)
|
||||
return err
|
||||
}
|
||||
if chunk == nil {
|
||||
break
|
||||
}
|
||||
if chunk.Length == 0 {
|
||||
panic("chunk length not set")
|
||||
}
|
||||
|
||||
sect.Chunks = append(sect.Chunks, chunk)
|
||||
sect.Payload = sect.Payload[min(chunk.Length, uint64(len(sect.Payload))):]
|
||||
|
||||
if len(sect.Payload) == 0 || (len(sect.Payload) == 1 && sect.Payload[0] == 0x00) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sect *FmpSector) processChunks(dict *FmpDict) error {
|
||||
err := sect.readChunks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentPath := make([]uint64, 0)
|
||||
for _, chunk := range sect.Chunks {
|
||||
switch chunk.Type {
|
||||
case FmpChunkPathPush, FmpChunkPathPushLong:
|
||||
currentPath = append(currentPath, decodeVarUint64(chunk.Value))
|
||||
dumpPath(currentPath)
|
||||
|
||||
case FmpChunkPathPop:
|
||||
if len(currentPath) > 0 {
|
||||
currentPath = (currentPath)[:len(currentPath)-1]
|
||||
}
|
||||
|
||||
case FmpChunkSimpleData:
|
||||
dict.set(currentPath, chunk.Value)
|
||||
|
||||
case FmpChunkSegmentedData:
|
||||
// Todo: take index into account
|
||||
dict.set(
|
||||
currentPath,
|
||||
append(dict.GetValue(currentPath...), chunk.Value...),
|
||||
)
|
||||
|
||||
case FmpChunkSimpleKeyValue:
|
||||
dict.set(
|
||||
append(currentPath, uint64(chunk.Key)),
|
||||
chunk.Value,
|
||||
)
|
||||
|
||||
case FmpChunkLongKeyValue:
|
||||
dict.set(
|
||||
append(currentPath, uint64(chunk.Key)), // todo: ??
|
||||
chunk.Value,
|
||||
)
|
||||
|
||||
case FmpChunkNoop:
|
||||
// noop
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sect *FmpSector) readChunk(payload []byte) (*FmpChunk, error) {
|
||||
|
||||
// https://github.com/evanmiller/fmptools/blob/02eb770e59e0866dab213d80e5f7d88e17648031/HACKING
|
||||
// https://github.com/Rasmus20B/fmplib/blob/66245e5269275724bacfe1437fb1f73bc587a2f3/src/fmp_format/chunk.rs#L57-L60
|
||||
|
||||
chunk := &FmpChunk{}
|
||||
chunkCode := payload[0]
|
||||
|
||||
switch chunkCode {
|
||||
case 0x00:
|
||||
chunk.Length = 2
|
||||
chunk.Type = FmpChunkSimpleData
|
||||
chunk.Value = payload[1:chunk.Length]
|
||||
|
||||
case 0x01, 0x02, 0x03, 0x04, 0x05:
|
||||
chunk.Length = 2 + 2*uint64(chunkCode-0x01) + addIf(chunkCode == 0x01, 1)
|
||||
chunk.Type = FmpChunkSimpleKeyValue
|
||||
chunk.Key = uint64(payload[1])
|
||||
chunk.Value = payload[2:chunk.Length]
|
||||
|
||||
case 0x06:
|
||||
chunk.Length = 3 + uint64(payload[2])
|
||||
chunk.Type = FmpChunkSimpleKeyValue
|
||||
chunk.Key = uint64(payload[1])
|
||||
chunk.Value = payload[3:chunk.Length]
|
||||
|
||||
case 0x07:
|
||||
valueLength := decodeVarUint64(payload[2 : 2+2])
|
||||
chunk.Length = min(4+valueLength, uint64(len(payload)))
|
||||
chunk.Type = FmpChunkSegmentedData
|
||||
chunk.Index = uint64(payload[1])
|
||||
chunk.Value = payload[4:chunk.Length]
|
||||
|
||||
case 0x08:
|
||||
chunk.Length = 3
|
||||
chunk.Type = FmpChunkSimpleData
|
||||
chunk.Value = payload[1:chunk.Length]
|
||||
|
||||
case 0x09:
|
||||
chunk.Length = 4
|
||||
chunk.Type = FmpChunkSimpleKeyValue
|
||||
chunk.Key = decodeVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[3:chunk.Length]
|
||||
|
||||
case 0x0A, 0x0B, 0x0C, 0x0D:
|
||||
chunk.Length = 3 + 2*uint64(chunkCode-0x09)
|
||||
chunk.Type = FmpChunkSimpleKeyValue
|
||||
chunk.Key = decodeVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[3:chunk.Length]
|
||||
|
||||
case 0x0E:
|
||||
if payload[1] == 0xFF {
|
||||
chunk.Length = 7
|
||||
chunk.Type = FmpChunkSimpleData
|
||||
chunk.Value = payload[2:chunk.Length]
|
||||
break
|
||||
}
|
||||
|
||||
chunk.Length = 4 + uint64(payload[3])
|
||||
chunk.Type = FmpChunkSimpleKeyValue
|
||||
chunk.Key = decodeVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[4:chunk.Length]
|
||||
|
||||
case 0x0F:
|
||||
valueLength := decodeVarUint64(payload[3 : 3+2])
|
||||
chunk.Length = uint64(len(payload))
|
||||
if chunk.Length > 5+valueLength {
|
||||
return nil, ErrBadChunk
|
||||
}
|
||||
chunk.Type = FmpChunkSegmentedData
|
||||
chunk.Index = decodeVarUint64(payload[1 : 1+2])
|
||||
chunk.Value = payload[5:chunk.Length]
|
||||
|
||||
case 0x10, 0x11:
|
||||
chunk.Length = 4 + addIf(chunkCode == 0x11, 1)
|
||||
chunk.Type = FmpChunkSimpleData
|
||||
chunk.Value = payload[1:chunk.Length]
|
||||
|
||||
case 0x12, 0x13, 0x14, 0x15:
|
||||
chunk.Length = 4 + 2*(uint64(chunkCode)-0x11)
|
||||
chunk.Type = FmpChunkSimpleData
|
||||
chunk.Value = payload[1:chunk.Length]
|
||||
|
||||
case 0x16:
|
||||
chunk.Length = 5 + uint64(payload[4])
|
||||
chunk.Type = FmpChunkLongKeyValue
|
||||
chunk.Key = decodeVarUint64(payload[1 : 1+3])
|
||||
chunk.Value = payload[5:chunk.Length]
|
||||
|
||||
case 0x17:
|
||||
chunk.Length = 6 + decodeVarUint64(payload[4:4+2])
|
||||
chunk.Type = FmpChunkLongKeyValue
|
||||
chunk.Key = decodeVarUint64(payload[1 : 1+3])
|
||||
chunk.Value = payload[6:chunk.Length]
|
||||
|
||||
case 0x19, 0x1A, 0x1B, 0x1C, 0x1D:
|
||||
valueLength := uint64(payload[1])
|
||||
chunk.Length = 2 + valueLength + 2*uint64(chunkCode-0x19) + addIf(chunkCode == 0x19, 1)
|
||||
chunk.Type = FmpChunkSimpleData
|
||||
chunk.Value = payload[2 : 2+valueLength]
|
||||
|
||||
case 0x1E:
|
||||
keyLength := uint64(payload[1])
|
||||
valueLength := uint64(payload[2+keyLength])
|
||||
chunk.Length = 2 + keyLength + 1 + valueLength
|
||||
chunk.Type = FmpChunkLongKeyValue
|
||||
chunk.Key = decodeVarUint64(payload[2 : 2+keyLength])
|
||||
chunk.Value = payload[2+keyLength+1 : chunk.Length]
|
||||
|
||||
case 0x1F:
|
||||
keyLength := uint64(payload[1])
|
||||
valueLength := decodeVarUint64(payload[2+keyLength : 2+keyLength+2+1])
|
||||
chunk.Length = 2 + keyLength + 2 + valueLength
|
||||
chunk.Type = FmpChunkLongKeyValue
|
||||
chunk.Key = decodeVarUint64(payload[2 : 2+keyLength])
|
||||
chunk.Value = payload[2+keyLength+2 : chunk.Length]
|
||||
|
||||
case 0x20, 0xE0:
|
||||
if payload[1] == 0xFE {
|
||||
chunk.Length = 10
|
||||
chunk.Type = FmpChunkPathPush
|
||||
chunk.Value = payload[2:chunk.Length]
|
||||
break
|
||||
}
|
||||
|
||||
chunk.Length = 2
|
||||
chunk.Type = FmpChunkPathPush
|
||||
chunk.Value = payload[1:chunk.Length]
|
||||
|
||||
case 0x23:
|
||||
chunk.Length = 2 + uint64(payload[1])
|
||||
chunk.Type = FmpChunkSimpleData
|
||||
chunk.Value = payload[1:chunk.Length]
|
||||
|
||||
case 0x28, 0x30:
|
||||
chunk.Length = 3 + addIf(chunkCode == 0x30, 1)
|
||||
chunk.Type = FmpChunkPathPush
|
||||
chunk.Value = payload[1:chunk.Length]
|
||||
|
||||
case 0x38:
|
||||
valueLength := uint64(payload[1])
|
||||
chunk.Length = 2 + valueLength
|
||||
chunk.Type = FmpChunkPathPushLong
|
||||
chunk.Value = payload[2:chunk.Length]
|
||||
|
||||
case 0x3D, 0x40:
|
||||
chunk.Type = FmpChunkPathPop
|
||||
chunk.Length = 1
|
||||
|
||||
case 0x80:
|
||||
chunk.Type = FmpChunkNoop
|
||||
chunk.Length = 1
|
||||
|
||||
default:
|
||||
return nil, ErrBadChunk
|
||||
}
|
||||
|
||||
return chunk, nil
|
||||
}
|
102
fmp/fmp_table.go
102
fmp/fmp_table.go
@ -1,47 +1,63 @@
|
||||
package fmp
|
||||
|
||||
func (ctx *FmpFile) Tables() []*FmpTable {
|
||||
tables := make([]*FmpTable, 0)
|
||||
type FmpTable struct {
|
||||
ID uint64
|
||||
Name string
|
||||
Columns map[uint64]*FmpColumn
|
||||
Records map[uint64]*FmpRecord
|
||||
|
||||
for key, ent := range *ctx.Dictionary {
|
||||
if key != 3 {
|
||||
continue
|
||||
}
|
||||
println("Found a 3")
|
||||
|
||||
for key, ent = range *ent.Children {
|
||||
if key != 16 {
|
||||
continue
|
||||
}
|
||||
println("Found a 3.16")
|
||||
|
||||
for key, ent = range *ent.Children {
|
||||
if key != 5 {
|
||||
continue
|
||||
}
|
||||
println("Found a 3.16.5")
|
||||
|
||||
for tablePath := range *ent.Children {
|
||||
if key >= 128 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Found a table!
|
||||
println("Found a table at 3.16.5.", tablePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// metaDict := ctx.Dictionary.get([]uint64{4, 1, 7})
|
||||
// if metaDict == nil {
|
||||
// return tables
|
||||
// }
|
||||
// for _, meta := range *metaDict.Children {
|
||||
// name := decodeByteSeq(meta.Children.get([]uint64{16}).Value)
|
||||
// table := &FmpTable{Name: name}
|
||||
// tables = append(tables, table)
|
||||
// }
|
||||
|
||||
return tables
|
||||
lastRecordID uint64
|
||||
}
|
||||
|
||||
type FmpColumn struct {
|
||||
Index uint64
|
||||
Name string
|
||||
Type FmpFieldType
|
||||
DataType FmpDataType
|
||||
StorageType FmpFieldStorageType
|
||||
AutoEnter FmpAutoEnterOption
|
||||
Repetitions uint8
|
||||
Indexed bool
|
||||
}
|
||||
|
||||
type FmpRecord struct {
|
||||
Table *FmpTable
|
||||
Index uint64
|
||||
Values map[uint64]string
|
||||
}
|
||||
|
||||
func (ctx *FmpFile) Table(name string) *FmpTable {
|
||||
for _, table := range ctx.tables {
|
||||
if table.Name == name {
|
||||
return table
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FmpTable) Column(name string) *FmpColumn {
|
||||
for _, column := range t.Columns {
|
||||
if column.Name == name {
|
||||
return column
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FmpTable) NewRecord(values map[string]string) (*FmpRecord, error) {
|
||||
vals := make(map[uint64]string)
|
||||
for k, v := range values {
|
||||
col := t.Column(k)
|
||||
vals[col.Index] = v
|
||||
}
|
||||
|
||||
id := t.lastRecordID + 1
|
||||
t.lastRecordID = id
|
||||
t.Records[id] = &FmpRecord{Table: t, Index: id, Values: vals}
|
||||
|
||||
return t.Records[id], nil
|
||||
}
|
||||
|
||||
func (r *FmpRecord) Value(name string) string {
|
||||
return r.Values[r.Table.Column(name).Index]
|
||||
}
|
||||
|
@ -1,17 +1,22 @@
|
||||
package fmp
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenFile(t *testing.T) {
|
||||
f, err := OpenFile("../files/Untitled.fmp12")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.FileSize != 393216 {
|
||||
t.Errorf("expected file size to be 393216, got %d", f.FileSize)
|
||||
defer f.Close()
|
||||
|
||||
if f.FileSize != 229376 {
|
||||
t.Errorf("expected file size to be 229376, got %d", f.FileSize)
|
||||
}
|
||||
if f.numSectors != 95 {
|
||||
t.Errorf("expected number of sectors to be 95, got %d", f.numSectors)
|
||||
if f.numSectors != 55 {
|
||||
t.Errorf("expected number of sectors to be 55, got %d", f.numSectors)
|
||||
}
|
||||
if f.CreatorName != "Pro 12.0" {
|
||||
t.Errorf("expected application name to be 'Pro 12.0', got '%s'", f.CreatorName)
|
||||
@ -19,6 +24,7 @@ func TestOpenFile(t *testing.T) {
|
||||
if f.VersionDate.Format("2006-01-02") != "2025-01-11" {
|
||||
t.Errorf("expected version date to be '2025-01-11', got '%s'", f.VersionDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
f.ToDebugFile("../private/output")
|
||||
}
|
||||
|
||||
@ -27,16 +33,81 @@ func TestTables(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables := f.Tables()
|
||||
defer f.Close()
|
||||
|
||||
if len(tables) != 1 || tables[0].Name != "Untitled" {
|
||||
tablesString := ""
|
||||
for i, table := range tables {
|
||||
tablesString += table.Name
|
||||
if i < len(tables)-1 {
|
||||
tablesString += ", "
|
||||
expectedNames := []string{"Untitled"}
|
||||
tableNames := []string{}
|
||||
for _, table := range f.tables {
|
||||
tableNames = append(tableNames, table.Name)
|
||||
}
|
||||
if !slicesHaveSameElements(tableNames, expectedNames) {
|
||||
t.Errorf("tables do not match")
|
||||
}
|
||||
t.Errorf("expected tables to be 'Untitled', got '%s'", tablesString)
|
||||
|
||||
table := f.Table("Untitled")
|
||||
if table == nil {
|
||||
t.Errorf("expected table to exist, but it does not")
|
||||
return
|
||||
}
|
||||
if table.Name != "Untitled" {
|
||||
t.Errorf("expected table name to be 'Untitled', but it is '%s'", table.Name)
|
||||
}
|
||||
if len(table.Records) != 3 {
|
||||
t.Errorf("expected table to have 3 records, but it has %d", len(table.Records))
|
||||
}
|
||||
if table.Records[1].Values[1] != "629FAA83-50D8-401F-A560-C8D45217D17B" {
|
||||
t.Errorf("first record has an incorrect ID '%s'", table.Records[0].Values[0])
|
||||
}
|
||||
|
||||
col := table.Column("PrimaryKey")
|
||||
if col == nil {
|
||||
t.Errorf("expected column to exist, but it does not")
|
||||
return
|
||||
}
|
||||
if col.Name != "PrimaryKey" {
|
||||
t.Errorf("expected column name to be 'PrimaryKey', but it is '%s'", col.Name)
|
||||
}
|
||||
if col.Type != FmpFieldSimple {
|
||||
t.Errorf("expected field type to be simple, but it is not")
|
||||
}
|
||||
if col.DataType != FmpDataText {
|
||||
t.Errorf("expected field data type to be text, but it is not")
|
||||
}
|
||||
if col.StorageType != FmpFieldStorageRegular {
|
||||
t.Errorf("expected field storage type to be regular, but it is not")
|
||||
}
|
||||
if col.Repetitions != 1 {
|
||||
t.Errorf("expected field repetition count to be 1, but it is %d", col.Repetitions)
|
||||
}
|
||||
if !col.Indexed {
|
||||
t.Errorf("expected field to be indexed, but it is not")
|
||||
}
|
||||
if col.AutoEnter != FmpAutoEnterCalculationReplacingExistingValue {
|
||||
t.Errorf("expected field to have auto enter calculation replacing existing value, but it does not")
|
||||
}
|
||||
|
||||
newRecord, err := table.NewRecord(map[string]string{"PrimaryKey": "629FAA83-50D8-401F-A560-C8D45217D17B"})
|
||||
if newRecord == nil || err != nil {
|
||||
t.Errorf("expected new record to be created, but it is nil")
|
||||
return
|
||||
}
|
||||
if newRecord.Index != 4 {
|
||||
t.Errorf("expected new record index to be 4, but it is %d", newRecord.Index)
|
||||
}
|
||||
if newRecord.Value("PrimaryKey") != "629FAA83-50D8-401F-A560-C8D45217D17B" {
|
||||
t.Errorf("expected new record primary key to be '629FAA83-50D8-401F-A560-C8D45217D17B', but it is '%s'", newRecord.Value("PrimaryKey"))
|
||||
}
|
||||
}
|
||||
|
||||
func slicesHaveSameElements[Type comparable](a, b []Type) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for _, av := range a {
|
||||
found := slices.Contains(b, av)
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
@ -1,90 +0,0 @@
|
||||
package fmp
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FmpFile struct {
|
||||
VersionDate time.Time
|
||||
CreatorName string
|
||||
FileSize uint
|
||||
Sectors []*FmpSector
|
||||
Chunks []*FmpChunk
|
||||
Dictionary *FmpDict
|
||||
|
||||
numSectors uint64 // Excludes the header sector
|
||||
currentSectorID uint64
|
||||
|
||||
stream io.ReadSeeker
|
||||
}
|
||||
|
||||
type FmpSector struct {
|
||||
ID uint64
|
||||
Level uint8
|
||||
Deleted bool
|
||||
PrevID uint64
|
||||
NextID uint64
|
||||
Prev *FmpSector
|
||||
Next *FmpSector
|
||||
Chunks []*FmpChunk
|
||||
}
|
||||
|
||||
type FmpChunk struct {
|
||||
Type FmpChunkType
|
||||
Length uint64
|
||||
Key uint64 // If Type == FMP_CHUNK_SHORT_KEY_VALUE or FMP_CHUNK_LONG_KEY_VALUE
|
||||
Index uint64 // Segment index, if Type == FMP_CHUNK_SEGMENTED_DATA
|
||||
Value []byte
|
||||
Delayed bool
|
||||
}
|
||||
|
||||
type FmpDict map[uint64]*FmpDictEntry
|
||||
|
||||
type FmpDictEntry struct {
|
||||
Value []byte
|
||||
Children *FmpDict
|
||||
}
|
||||
|
||||
type FmpTable struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (dict *FmpDict) get(path []uint64) *FmpDictEntry {
|
||||
for i, key := range path {
|
||||
_, ok := (*dict)[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if i == len(path)-1 {
|
||||
return (*dict)[key]
|
||||
} else {
|
||||
dict = (*dict)[key].Children
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dict *FmpDict) getValue(path []uint64) []byte {
|
||||
ent := dict.get(path)
|
||||
if ent != nil {
|
||||
return ent.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dict *FmpDict) set(path []uint64, value []byte) {
|
||||
for i, key := range path {
|
||||
_, ok := (*dict)[key]
|
||||
if !ok {
|
||||
(*dict)[key] = &FmpDictEntry{Children: &FmpDict{}}
|
||||
}
|
||||
|
||||
if i == len(path)-1 {
|
||||
(*dict)[key].Value = value
|
||||
} else {
|
||||
dict = (*dict)[key].Children
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,13 @@
|
||||
package fmp
|
||||
|
||||
func parseVarUint64(payload []byte) uint64 {
|
||||
func addIf(cond bool, val uint64) uint64 {
|
||||
if cond {
|
||||
return val
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func decodeVarUint64(payload []byte) uint64 {
|
||||
var length uint64
|
||||
n := min(len(payload), 8) // clamp to uint64
|
||||
for i := range n {
|
||||
@ -10,10 +17,25 @@ func parseVarUint64(payload []byte) uint64 {
|
||||
return length
|
||||
}
|
||||
|
||||
func decodeByteSeq(payload []byte) string {
|
||||
func decodeString(payload []byte) string {
|
||||
result := ""
|
||||
for i := range payload {
|
||||
result += string(payload[i] ^ 0x5A)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func encodeUint(size uint, value int) []byte {
|
||||
result := make([]byte, size)
|
||||
for i := range size {
|
||||
result[i] = byte(value & 0xFF)
|
||||
value >>= 8
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func writeToSlice(slice []byte, start int, payload ...byte) {
|
||||
for i := range payload {
|
||||
slice[start+i] = payload[i]
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user